timeline: drag generators onto the timeline, drop transitions at clip edges
Generator effects (bars, checkerboard) can be dragged from the library onto the timeline, where they land as a standalone five-second clip built from the node factory; the inspector shows the generator's parameters as the clip's own chain. Transitions are no longer junction-only. The render planner accepts a transition with at least one wired neighbor and blends the missing side against transparent black, so head transitions fade in from black and tail transitions fade out to black. add_transition_at_edge creates those single-sided blocks (wired to just the IN or OUT block), the default-transition command covers both ends of a lone clip, and an effect drag dropped near a clip edge routes to the nearest seam or edge within a one-second window.
This commit is contained in:
@@ -1094,6 +1094,38 @@ pub trait AppEngine:
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Drops a GENERATOR effect (checkerboard / color bars / shape / …)
|
||||
/// from the effect library onto the timeline at `(track_index, time)`:
|
||||
/// creates the generator node and places a 5-second generator clip fed
|
||||
/// by it — a standalone clip, not an effect on another clip (the
|
||||
/// inspector just shows the generator's params). Default: unsupported.
|
||||
fn drop_generator_clip(
|
||||
&mut self,
|
||||
type_id: &str,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let _ = (type_id, track_index, time, cx);
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Drops a TRANSITION effect onto the timeline near `(track_index,
|
||||
/// time)`: snaps to the nearest clip edge on that track and creates a
|
||||
/// transition there — a junction transition when the edge is a
|
||||
/// contiguous cut, otherwise a single-sided head/tail transition
|
||||
/// (PR-style edge drops). Default: unsupported.
|
||||
fn drop_transition_at(
|
||||
&mut self,
|
||||
type_id: &str,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let _ = (type_id, track_index, time, cx);
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Applies new name/format/interlaced parameters to the sequence `id`.
|
||||
fn update_sequence_parameters(
|
||||
&mut self,
|
||||
|
||||
@@ -1851,19 +1851,119 @@ pub fn add_transition_at_seam(
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
/// The undo commands of [`add_transition_at_edge`], unpushed (the batch
|
||||
/// paths build their own combined rows).
|
||||
fn edge_transition_commands(
|
||||
p: &ProjectRef,
|
||||
clip: NodeId,
|
||||
start_edge: bool,
|
||||
length: Rational,
|
||||
) -> Result<(NodeId, Vec<oak_undo::undocommand::UndoCommand>), String> {
|
||||
if length <= Rational::new(0, 1) {
|
||||
return Err("transition: the length must be positive".to_string());
|
||||
}
|
||||
let (track, anchor, edge_time) = {
|
||||
let g = lock(p);
|
||||
if clip_behavior(&g.graph, clip).is_none() {
|
||||
return Err("transition: the block is not a clip".to_string());
|
||||
}
|
||||
let Some(core) = block_core_of(&g.graph, clip) else {
|
||||
return Err("transition: the clip has no block core".to_string());
|
||||
};
|
||||
let Some(track) = core.track else {
|
||||
return Err("transition: the clip is not on a track".to_string());
|
||||
};
|
||||
let Some(blocks) = track_behavior(&g.graph, track).map(|t| t.blocks.clone()) else {
|
||||
return Err("transition: the track is not in the project".to_string());
|
||||
};
|
||||
let Some(index) = blocks.iter().position(|&b| b == clip) else {
|
||||
return Err("transition: the clip is not on the track".to_string());
|
||||
};
|
||||
if transition_of_clip(&g.graph, clip, start_edge).is_some() {
|
||||
return Err("transition: the edge already carries a transition".to_string());
|
||||
}
|
||||
// The head transition inserts before the clip (after whatever block
|
||||
// precedes it); the tail inserts after the clip.
|
||||
let anchor = if start_edge {
|
||||
index.checked_sub(1).and_then(|i| blocks.get(i)).copied()
|
||||
} else {
|
||||
Some(clip)
|
||||
};
|
||||
let edge_time = if start_edge { core.in_() } else { core.out() };
|
||||
(track, anchor, edge_time)
|
||||
};
|
||||
let block = {
|
||||
let mut g = lock(p);
|
||||
let (core, behavior) = oak_node::block::transition_create();
|
||||
let id = g.graph.add_node(core, behavior);
|
||||
let Some(t) = g
|
||||
.graph
|
||||
.get_mut(id)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<oak_node::block::TransitionBlockBehavior>())
|
||||
else {
|
||||
return Err("transition: could not create the block".to_string());
|
||||
};
|
||||
if start_edge {
|
||||
// Head: no outgoing side — the whole span eats into the clip.
|
||||
t.core.range = TimeRange::new(edge_time, edge_time + length);
|
||||
t.in_offset = Rational::new(0, 1);
|
||||
t.out_offset = length;
|
||||
} else {
|
||||
t.core.range = TimeRange::new(edge_time - length, edge_time);
|
||||
t.in_offset = length;
|
||||
t.out_offset = Rational::new(0, 1);
|
||||
}
|
||||
id
|
||||
};
|
||||
let input = if start_edge {
|
||||
oak_node::block::transition_input::IN_BLOCK
|
||||
} else {
|
||||
oak_node::block::transition_input::OUT_BLOCK
|
||||
};
|
||||
let insert = oak_timeline::undotrack::TrackInsertBlockAfterCommand::new(
|
||||
node_ref(p, track),
|
||||
node_ref(p, block),
|
||||
anchor.map(|b| node_ref(p, b)),
|
||||
)
|
||||
.to_command();
|
||||
let edge = connect_command(p, clip, block, input)?;
|
||||
Ok((block, vec![insert, edge]))
|
||||
}
|
||||
|
||||
/// Create a SINGLE-SIDED transition on one edge of `clip` (PR-style: a
|
||||
/// head fade-in when nothing precedes the clip, a tail fade-out when
|
||||
/// nothing follows it) and push it as one "Add Transition" undo row.
|
||||
/// Only the clip itself is wired (`in_block_in` for the head,
|
||||
/// `out_block_in` for the tail); the renderer blends the open side
|
||||
/// against black. An edge that already carries a transition is an error.
|
||||
pub fn add_transition_at_edge(
|
||||
p: &ProjectRef,
|
||||
clip: NodeId,
|
||||
start_edge: bool,
|
||||
length: Rational,
|
||||
) -> Result<NodeId, String> {
|
||||
let (block, commands) = edge_transition_commands(p, clip, start_edge, length)?;
|
||||
push_multi(commands, "Add Transition")?;
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
/// Add a default transition at every contiguous seam around the selected
|
||||
/// clips (the Ctrl+Shift+D action / the timeline clip-menu item).
|
||||
/// clips, plus BOTH ends of any clip with no junction at all (the
|
||||
/// Ctrl+Shift+D action / the timeline clip-menu item — PR's
|
||||
/// apply-to-edit-points semantics, extended to lone clips).
|
||||
///
|
||||
/// `half` is half of the default transition length: the transition spans
|
||||
/// `half` on either side of its seam. Every selected clip contributes the
|
||||
/// seam before it and the seam after it; a neighbor only counts when it is
|
||||
/// a clip block (not a gap, a transition or an adjustment layer) that
|
||||
/// touches the clip exactly, and a seam shared by two selected clips is
|
||||
/// built once. Seams that cannot take a transition (an existing
|
||||
/// transition, a non-contiguous neighbor) are skipped. The whole batch is
|
||||
/// one "Add Transition" undo row; a batch that builds nothing is an error,
|
||||
/// so the action never leaves an empty row. Returns the number of
|
||||
/// transitions created.
|
||||
/// `half` is half of the default transition length: a junction transition
|
||||
/// spans `half` on either side of its seam, a head/tail (single-sided)
|
||||
/// transition spans `2*half` into its own clip. Every selected clip
|
||||
/// contributes the seam before it and the seam after it (a neighbor
|
||||
/// counts only when it is a clip block that touches the clip exactly,
|
||||
/// shared seams built once), plus a head transition when nothing
|
||||
/// precedes it and a tail transition when nothing follows it. Seams and
|
||||
/// edges that cannot take a transition (an existing one) are skipped.
|
||||
/// The whole batch is one "Add Transition" undo row; a batch that builds
|
||||
/// nothing is an error, so the action never leaves an empty row. Returns
|
||||
/// the number of transitions created.
|
||||
///
|
||||
/// `seq` scopes the action: a selected block whose track list belongs to
|
||||
/// another sequence is ignored.
|
||||
@@ -1882,12 +1982,12 @@ pub fn add_default_transition(
|
||||
};
|
||||
|
||||
let mut seams: Vec<(NodeId, NodeId)> = Vec::new();
|
||||
// Standalone ends, applied ONLY when the clip has no junction at all
|
||||
// (PR's both-ends-on-a-lone-clip default): `(clip, start_edge)`.
|
||||
let mut edges: Vec<(NodeId, bool)> = Vec::new();
|
||||
for &clip in clip_blocks {
|
||||
let candidates: Vec<(NodeId, NodeId)> = {
|
||||
let (candidates, touches_prev, touches_next) = {
|
||||
let g = lock(p);
|
||||
if clip_behavior(&g.graph, clip).is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(track) = clip_track(&g.graph, clip) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1903,34 +2003,60 @@ pub fn add_default_transition(
|
||||
let Some(index) = blocks.iter().position(|&b| b == clip) else {
|
||||
continue;
|
||||
};
|
||||
if clip_behavior(&g.graph, clip).is_none() {
|
||||
continue;
|
||||
}
|
||||
let range = block_core_of(&g.graph, clip).map(|c| (c.in_(), c.out()));
|
||||
let mut sides = Vec::new();
|
||||
if let Some(&prev) = index.checked_sub(1).and_then(|i| blocks.get(i)) {
|
||||
let prev = index.checked_sub(1).and_then(|i| blocks.get(i)).copied();
|
||||
if let Some(prev) = prev {
|
||||
if clip_behavior(&g.graph, prev).is_some()
|
||||
&& block_core_of(&g.graph, prev).map(|c| c.out()) == range.map(|r| r.0)
|
||||
{
|
||||
sides.push((prev, clip));
|
||||
}
|
||||
}
|
||||
if let Some(&next) = blocks.get(index + 1) {
|
||||
let next = blocks.get(index + 1).copied();
|
||||
if let Some(next) = next {
|
||||
if clip_behavior(&g.graph, next).is_some()
|
||||
&& block_core_of(&g.graph, next).map(|c| c.in_()) == range.map(|r| r.1)
|
||||
{
|
||||
sides.push((clip, next));
|
||||
}
|
||||
}
|
||||
sides
|
||||
let (touches_prev, touches_next) = (
|
||||
sides.iter().any(|(_, next)| *next == clip),
|
||||
sides.iter().any(|(prev, _)| *prev == clip),
|
||||
);
|
||||
(sides, touches_prev, touches_next)
|
||||
};
|
||||
let seam_count = candidates.len();
|
||||
for seam in candidates {
|
||||
if !seams.contains(&seam) {
|
||||
seams.push(seam);
|
||||
}
|
||||
}
|
||||
}
|
||||
if seams.is_empty() {
|
||||
return Err("transition: no selected clip borders another clip".to_string());
|
||||
// A clip with no junction at all and no transition wired to it
|
||||
// takes the free ends (a leading gap is "nothing" — a head
|
||||
// fade-in still applies); a clip with any junction or an existing
|
||||
// transition keeps just what it has (the action is idempotent: a
|
||||
// second run builds nothing).
|
||||
if seam_count == 0
|
||||
&& transition_of_clip(&lock(p).graph, clip, true).is_none()
|
||||
&& transition_of_clip(&lock(p).graph, clip, false).is_none()
|
||||
{
|
||||
if !touches_prev {
|
||||
edges.push((clip, true));
|
||||
}
|
||||
if !touches_next {
|
||||
edges.push((clip, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One undo row covers the junctions AND the standalone ends; a batch
|
||||
// that builds nothing is an error, so the action never leaves an empty
|
||||
// row.
|
||||
let mut commands = Vec::new();
|
||||
let mut created = 0usize;
|
||||
for (prev, next) in seams {
|
||||
@@ -1939,8 +2065,15 @@ pub fn add_default_transition(
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
let edge_length = half + half;
|
||||
for (clip, start_edge) in edges {
|
||||
if let Ok((_, children)) = edge_transition_commands(p, clip, start_edge, edge_length) {
|
||||
commands.extend(children);
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
if created == 0 {
|
||||
return Err("transition: no contiguous seam accepts a transition".to_string());
|
||||
return Err("transition: no seam or clip edge accepts a transition".to_string());
|
||||
}
|
||||
push_multi(commands, "Add Transition")?;
|
||||
Ok(created)
|
||||
@@ -2154,20 +2287,43 @@ pub fn place_text_clip(
|
||||
track_index: usize,
|
||||
in_ts: i64,
|
||||
out_ts: i64,
|
||||
) -> Result<NodeId, String> {
|
||||
{
|
||||
let g = lock(p);
|
||||
if node_type_id(&g.graph, text) != TEXT_FOOTAGE_TYPE_ID {
|
||||
return Err("the text entry is not in the project".to_string());
|
||||
}
|
||||
}
|
||||
place_generator_clip(p, host_seq, text, track_index, in_ts, out_ts)
|
||||
}
|
||||
|
||||
/// Place a clip fed by a GENERATOR node (checkerboard / color bars /
|
||||
/// text / shape / solid…) at `(track_index, [in_ts, out_ts))` — the
|
||||
/// effect-library drag-to-timeline counterpart of [`place_text_clip`]:
|
||||
/// one undo row covers the placement and the generator→`tex_in` edge.
|
||||
/// The generator must already be in the project; the clip is labelled
|
||||
/// after it. Returns the clip's node id.
|
||||
pub fn place_generator_clip(
|
||||
p: &ProjectRef,
|
||||
host_seq: NodeId,
|
||||
generator: NodeId,
|
||||
track_index: usize,
|
||||
in_ts: i64,
|
||||
out_ts: i64,
|
||||
) -> Result<NodeId, String> {
|
||||
if in_ts < 0 || out_ts <= in_ts {
|
||||
return Err("invalid clip range (need 0 <= in < out)".to_string());
|
||||
}
|
||||
let (tb, list, label) = {
|
||||
let g = lock(p);
|
||||
if node_type_id(&g.graph, text) != TEXT_FOOTAGE_TYPE_ID {
|
||||
return Err("the text entry is not in the project".to_string());
|
||||
if g.graph.get(generator).is_none() {
|
||||
return Err("the generator node is not in the project".to_string());
|
||||
}
|
||||
let tb = sequence_time_base(&g.graph, host_seq)
|
||||
.ok_or_else(|| "host sequence has no valid frame rate".to_string())?;
|
||||
let list = track_list_of(&g.graph, host_seq, TrackType::Video)
|
||||
.ok_or_else(|| "host sequence has no video track list".to_string())?;
|
||||
let label = node_label(&g.graph, text);
|
||||
let label = node_label(&g.graph, generator);
|
||||
(tb, list, label)
|
||||
};
|
||||
let track_count = {
|
||||
@@ -2214,7 +2370,7 @@ pub fn place_text_clip(
|
||||
in_r,
|
||||
)
|
||||
.to_command();
|
||||
let edge = connect_command(p, text, clip, oak_node::block::clip_input::TEXTURE_INPUT)?;
|
||||
let edge = connect_command(p, generator, clip, oak_node::block::clip_input::TEXTURE_INPUT)?;
|
||||
push_multi(vec![place, edge], "Add Clip")?;
|
||||
Ok(clip)
|
||||
}
|
||||
@@ -4420,6 +4576,117 @@ mod undo_cycle_ops_tests {
|
||||
(project, seq, track, a, b, tb, half)
|
||||
}
|
||||
|
||||
/// A LONE clip (no junction on either side) takes the default
|
||||
/// transition on BOTH ends (PR's apply-to-a-lone-clip semantics): a
|
||||
/// head fade-in wired `in_block_in` only and a tail fade-out wired
|
||||
/// `out_block_in` only, each spanning `2*half` into the clip, as ONE
|
||||
/// undo row — and the action is idempotent (a second run has nothing
|
||||
/// left to build and leaves no empty row).
|
||||
#[test]
|
||||
fn default_transition_covers_both_ends_of_a_lone_clip() {
|
||||
let _g = test_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
let media = std::env::temp_dir()
|
||||
.join(format!("oak_lone_transition_{}.mp4", std::process::id()));
|
||||
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate");
|
||||
let project = create_project();
|
||||
let seq = create_sequence(&project, "Lone Transition");
|
||||
let footage = import_footage(&project, &media).expect("import");
|
||||
let clip = place_footage_clip(&project, seq, footage, TrackType::Video, 0, 10, 30, 0)
|
||||
.expect("place the clip");
|
||||
let (tb, track) = {
|
||||
let g = lock(&project);
|
||||
(
|
||||
sequence_time_base(&g.graph, seq).expect("time base"),
|
||||
track_ids(&g.graph, seq, TrackType::Video)[0],
|
||||
)
|
||||
};
|
||||
let half = ts_to_rational(3, tb);
|
||||
|
||||
let before = oak_undo::global::count().unwrap();
|
||||
let created = add_default_transition(&project, seq, &[clip], half)
|
||||
.expect("add default transition");
|
||||
assert_eq!(created, 2, "a lone clip takes both ends");
|
||||
assert_eq!(oak_undo::global::count().unwrap(), before + 1, "one undo row");
|
||||
|
||||
let edge = half + half;
|
||||
{
|
||||
let g = lock(&project);
|
||||
let blocks = track_behavior(&g.graph, track)
|
||||
.map(|t| t.blocks.clone())
|
||||
.unwrap_or_default();
|
||||
let index = blocks.iter().position(|&b| b == clip).expect("clip on track");
|
||||
// (A leading gap when the clip starts past zero,) the head
|
||||
// transition, the clip, the tail transition.
|
||||
let (head, tail) = (blocks[index - 1], blocks[index + 1]);
|
||||
let head_b = g
|
||||
.graph
|
||||
.get(head)
|
||||
.and_then(|e| e.behavior.as_any())
|
||||
.and_then(|a| a.downcast_ref::<oak_node::block::TransitionBlockBehavior>())
|
||||
.expect("head transition");
|
||||
assert_eq!(
|
||||
(head_b.in_offset, head_b.out_offset),
|
||||
(Rational::new(0, 1), edge),
|
||||
"the head transition eats only into the clip"
|
||||
);
|
||||
assert_eq!(
|
||||
(head_b.core.in_(), head_b.core.out()),
|
||||
(ts_to_rational(10, tb), ts_to_rational(10, tb) + edge),
|
||||
"the head transition spans the clip's head"
|
||||
);
|
||||
assert_eq!(
|
||||
g.graph.connected_output(head, oak_node::block::transition_input::IN_BLOCK, -1),
|
||||
Some(clip),
|
||||
"the head transition wires only the clip in"
|
||||
);
|
||||
assert_eq!(
|
||||
g.graph.connected_output(head, oak_node::block::transition_input::OUT_BLOCK, -1),
|
||||
None,
|
||||
"the head transition has no outgoing side"
|
||||
);
|
||||
let tail_b = g
|
||||
.graph
|
||||
.get(tail)
|
||||
.and_then(|e| e.behavior.as_any())
|
||||
.and_then(|a| a.downcast_ref::<oak_node::block::TransitionBlockBehavior>())
|
||||
.expect("tail transition");
|
||||
assert_eq!(
|
||||
(tail_b.in_offset, tail_b.out_offset),
|
||||
(edge, Rational::new(0, 1)),
|
||||
"the tail transition eats only into the clip"
|
||||
);
|
||||
assert_eq!(
|
||||
(tail_b.core.in_(), tail_b.core.out()),
|
||||
(ts_to_rational(30, tb) - edge, ts_to_rational(30, tb)),
|
||||
"the tail transition spans the clip's tail"
|
||||
);
|
||||
assert_eq!(
|
||||
g.graph.connected_output(tail, oak_node::block::transition_input::OUT_BLOCK, -1),
|
||||
Some(clip),
|
||||
"the tail transition wires only the clip out"
|
||||
);
|
||||
assert_eq!(
|
||||
g.graph.connected_output(tail, oak_node::block::transition_input::IN_BLOCK, -1),
|
||||
None,
|
||||
"the tail transition has no incoming side"
|
||||
);
|
||||
// The wedges: a head transition draws on the clip's start edge, a
|
||||
// tail transition on its end edge.
|
||||
assert_eq!(transition_of_clip(&g.graph, clip, true), Some(head));
|
||||
assert_eq!(transition_of_clip(&g.graph, clip, false), Some(tail));
|
||||
}
|
||||
|
||||
// Idempotent: every edge already carries a transition, so a second
|
||||
// run builds nothing and leaves no empty row.
|
||||
let rows = oak_undo::global::count().unwrap();
|
||||
assert!(add_default_transition(&project, seq, &[clip], half).is_err());
|
||||
assert_eq!(oak_undo::global::count().unwrap(), rows);
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
/// 编辑 → 设为默认转场: one transition per contiguous seam around the
|
||||
/// selection — the single seam here, built once even though both clips
|
||||
/// are selected — wired onto both clips as ONE undo row.
|
||||
|
||||
@@ -2015,6 +2015,72 @@ impl AppEngine for MockEngine {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn drop_generator_clip(
|
||||
&mut self,
|
||||
type_id: &str,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
// A 5-second canned clip labelled after the generator, on the
|
||||
// pointed (or first) video track — the mock's footage-drop shape.
|
||||
let name = crate::oakui::effectchain::addable_effects()
|
||||
.into_iter()
|
||||
.find(|entry| entry.type_id == type_id)
|
||||
.map(|entry| entry.name)
|
||||
.unwrap_or_else(|| type_id.to_string());
|
||||
let target = if self
|
||||
.tracks
|
||||
.get(track_index)
|
||||
.is_some_and(|t| t.kind == TrackKind::Video)
|
||||
{
|
||||
track_index
|
||||
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == TrackKind::Video)
|
||||
{
|
||||
index
|
||||
} else {
|
||||
return Err("no video track".to_string());
|
||||
};
|
||||
let fps = self.frame_rate();
|
||||
let length = Frame(
|
||||
(5.0 * fps.num as f64 / fps.den.max(1) as f64)
|
||||
.round()
|
||||
.max(1.0) as i64,
|
||||
);
|
||||
let clip = MockClip {
|
||||
id: ClipId(self.next_mock_clip_id()),
|
||||
range: FrameRange::new(Frame(time.0.max(0)), Frame(time.0.max(0) + length.0)),
|
||||
media_in: Frame::ZERO,
|
||||
label: name.into(),
|
||||
color: Hsla {
|
||||
h: 0.402,
|
||||
s: 0.385,
|
||||
l: 0.459,
|
||||
a: 1.0,
|
||||
},
|
||||
};
|
||||
let track = &mut self.tracks[target];
|
||||
let position = track
|
||||
.clips
|
||||
.iter()
|
||||
.position(|c| c.range.start.0 > time.0)
|
||||
.unwrap_or(track.clips.len());
|
||||
track.clips.insert(position, clip);
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_transition_at(
|
||||
&mut self,
|
||||
_type_id: &str,
|
||||
_track_index: usize,
|
||||
_time: Frame,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
// The mock models no transition blocks; the route is accepted.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_footage(
|
||||
&mut self,
|
||||
id: u64,
|
||||
|
||||
@@ -5864,6 +5864,159 @@ impl AppEngine for RealEngine {
|
||||
Ok(id.identity())
|
||||
}
|
||||
|
||||
fn drop_generator_clip(
|
||||
&mut self,
|
||||
type_id: &str,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Err("no project open".to_string());
|
||||
};
|
||||
let Some(host_seq) = self.sequence else {
|
||||
return Err("no sequence open".to_string());
|
||||
};
|
||||
// The effect-library generator becomes a node in the project, then
|
||||
// a generator clip feeds from it (the text-clip drop's shape).
|
||||
let (core, behavior) = oak_node::factory::Factory::global()
|
||||
.create_any(type_id)
|
||||
.ok_or_else(|| format!("unknown effect \"{type_id}\""))?;
|
||||
if !behavior.categories().contains(&oak_node::node::Category::Generator) {
|
||||
return Err(format!("effect \"{type_id}\" is not a generator"));
|
||||
}
|
||||
let generator = {
|
||||
let mut guard = graphops::lock(&project);
|
||||
guard.graph.add_node(core, behavior)
|
||||
};
|
||||
let video_target = if self
|
||||
.tracks
|
||||
.get(track_index)
|
||||
.is_some_and(|t| t.kind == TrackKind::Video)
|
||||
{
|
||||
track_index
|
||||
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == TrackKind::Video)
|
||||
{
|
||||
index
|
||||
} else {
|
||||
return Err("no video track".to_string());
|
||||
};
|
||||
let video_index = self.tracks[video_target].track_index;
|
||||
// Five seconds of the host sequence's frames (the drop's default
|
||||
// extent — a generator clip has no media length to take one from).
|
||||
let (in_ts, length_ts) = {
|
||||
let guard = graphops::lock(&project);
|
||||
let tb = graphops::sequence_time_base(&guard.graph, host_seq)
|
||||
.ok_or_else(|| "host sequence has no frame rate".to_string())?;
|
||||
let fps_f = tb.1.max(1) as f64 / tb.0.max(1) as f64;
|
||||
((time.0.max(0)), ((5.0 * fps_f).round() as i64).max(1))
|
||||
};
|
||||
let placed = graphops::place_generator_clip(
|
||||
&project,
|
||||
host_seq,
|
||||
generator,
|
||||
video_index,
|
||||
in_ts,
|
||||
in_ts + length_ts,
|
||||
);
|
||||
let result = placed.map(|_| ());
|
||||
self.apply_edit(result.clone(), "drop generator clip", cx);
|
||||
result
|
||||
}
|
||||
|
||||
fn drop_transition_at(
|
||||
&mut self,
|
||||
_type_id: &str,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Err("no project open".to_string());
|
||||
};
|
||||
let Some(host_seq) = self.sequence else {
|
||||
return Err("no sequence open".to_string());
|
||||
};
|
||||
let video_target = if self
|
||||
.tracks
|
||||
.get(track_index)
|
||||
.is_some_and(|t| t.kind == TrackKind::Video)
|
||||
{
|
||||
track_index
|
||||
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == TrackKind::Video)
|
||||
{
|
||||
index
|
||||
} else {
|
||||
return Err("no video track".to_string());
|
||||
};
|
||||
let video_index = self.tracks[video_target].track_index;
|
||||
let placed = {
|
||||
let guard = graphops::lock(&project);
|
||||
let Some(track) = graphops::track_ids(&guard.graph, host_seq, TrackType::Video)
|
||||
.get(video_index)
|
||||
.copied()
|
||||
else {
|
||||
return Err("the target track is not in the sequence".to_string());
|
||||
};
|
||||
let Some(tb) = graphops::sequence_time_base(&guard.graph, track) else {
|
||||
return Err("the track has no frame rate".to_string());
|
||||
};
|
||||
// Snap to the nearest clip edge on the target track (within one
|
||||
// second): a junction when the edge is a contiguous cut,
|
||||
// otherwise a single-sided head/tail transition.
|
||||
let time_r = graphops::ts_to_rational(time.0.max(0), tb);
|
||||
let mut best: Option<(oak_core::Rational, NodeId, bool)> = None;
|
||||
for clip in graphops::clip_ids(&guard.graph, track) {
|
||||
let Some((in_r, out_r, _)) = graphops::clip_range(&guard.graph, clip) else {
|
||||
continue;
|
||||
};
|
||||
for (edge, start_edge) in [(in_r, true), (out_r, false)] {
|
||||
let dist = if edge > time_r { edge - time_r } else { time_r - edge };
|
||||
if best.as_ref().is_none_or(|(d, ..)| dist < *d) {
|
||||
best = Some((dist, clip, start_edge));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((dist, clip, start_edge)) = best else {
|
||||
return Err("the target track has no clips".to_string());
|
||||
};
|
||||
if dist > oak_core::Rational::new(1, 1) {
|
||||
return Err("no clip edge within one second of the drop point".to_string());
|
||||
}
|
||||
let Some((in_r, out_r, _)) = graphops::clip_range(&guard.graph, clip) else {
|
||||
return Err("the edge clip has no range".to_string());
|
||||
};
|
||||
let blocks = graphops::track_behavior(&guard.graph, track)
|
||||
.map(|t| t.blocks.clone())
|
||||
.unwrap_or_default();
|
||||
let index = blocks.iter().position(|&b| b == clip);
|
||||
let prev = index.and_then(|i| i.checked_sub(1)).and_then(|i| blocks.get(i)).copied();
|
||||
let next = index.and_then(|i| blocks.get(i + 1)).copied();
|
||||
let prev_touch = prev.is_some_and(|b| {
|
||||
graphops::clip_behavior(&guard.graph, b).is_some()
|
||||
&& graphops::block_core_of(&guard.graph, b).map(|c| c.out()) == Some(in_r)
|
||||
});
|
||||
let next_touch = next.is_some_and(|b| {
|
||||
graphops::clip_behavior(&guard.graph, b).is_some()
|
||||
&& graphops::block_core_of(&guard.graph, b).map(|c| c.in_()) == Some(out_r)
|
||||
});
|
||||
// The default transition's half-length, one frame at least.
|
||||
let fps = tb.1 as f64 / tb.0.max(1) as f64;
|
||||
let half_frames = (0.5 * fps).round().max(1.0) as i64;
|
||||
let half = graphops::ts_to_rational(half_frames, tb);
|
||||
if start_edge && prev_touch {
|
||||
graphops::add_transition_at_seam(&project, prev.unwrap(), clip, half)
|
||||
} else if !start_edge && next_touch {
|
||||
graphops::add_transition_at_seam(&project, clip, next.unwrap(), half)
|
||||
} else {
|
||||
graphops::add_transition_at_edge(&project, clip, start_edge, half + half)
|
||||
}
|
||||
};
|
||||
let result = placed.map(|_| ());
|
||||
self.apply_edit(result.clone(), "drop transition", cx);
|
||||
result
|
||||
}
|
||||
|
||||
fn update_sequence_parameters(
|
||||
&mut self,
|
||||
id: u64,
|
||||
@@ -10622,7 +10775,98 @@ mod tests {
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// 调整图层 through the engine facade: `add_adjustment_layer` places a
|
||||
/// An effect-library generator (Color Bars) drops onto the timeline as
|
||||
/// a STANDALONE generator clip: one undo row, the clip labelled after
|
||||
/// the generator, fed through `tex_in`, spanning five seconds from the
|
||||
/// drop frame — and undo removes it.
|
||||
#[gpui::test]
|
||||
async fn engine_drops_a_generator_effect_as_a_clip(cx: &mut gpui::TestAppContext) {
|
||||
let _media = media_lock();
|
||||
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
|
||||
let seq = cx.update(|app| {
|
||||
engine.update(app, |engine, cx| {
|
||||
engine
|
||||
.create_sequence_with_params(
|
||||
"Generator Drop".to_string(),
|
||||
VideoFormat {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rate: FrameRate::new(30000, 1001),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.expect("create sequence")
|
||||
})
|
||||
});
|
||||
let project = cx.read(|app| engine.read(app).project.clone().expect("project"));
|
||||
|
||||
cx.update(|app| {
|
||||
engine.update(app, |engine, cx| {
|
||||
engine
|
||||
.drop_generator_clip(
|
||||
"org.olivevideoeditor.Olive.colorbars",
|
||||
0,
|
||||
Frame(25),
|
||||
cx,
|
||||
)
|
||||
.expect("drop generator clip")
|
||||
})
|
||||
});
|
||||
|
||||
let seq_node = graphops::id_of(seq).expect("sequence node");
|
||||
let target_row = cx.read(|app| engine.read(app).tracks[0].track_index);
|
||||
let (tb, tracks) = {
|
||||
let guard = graphops::lock(&project);
|
||||
(
|
||||
graphops::sequence_time_base(&guard.graph, seq_node).expect("time base"),
|
||||
graphops::track_ids(&guard.graph, seq_node, TrackType::Video),
|
||||
)
|
||||
};
|
||||
let track = tracks[target_row];
|
||||
let (clip, generator) = {
|
||||
let guard = graphops::lock(&project);
|
||||
let clips = graphops::clip_ids(&guard.graph, track);
|
||||
assert_eq!(clips.len(), 1, "the drop lands exactly one clip: {clips:?}");
|
||||
let clip = clips[0];
|
||||
let generator = guard
|
||||
.graph
|
||||
.connected_output(clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("the clip reads the generator through tex_in");
|
||||
assert_eq!(
|
||||
graphops::node_type_id(&guard.graph, generator),
|
||||
"org.olivevideoeditor.Olive.colorbars"
|
||||
);
|
||||
let (in_r, out_r, _) = graphops::clip_range(&guard.graph, clip).expect("clip range");
|
||||
let fps = tb.1 as f64 / tb.0 as f64;
|
||||
let length = (5.0 * fps).round().max(1.0) as i64;
|
||||
assert_eq!(in_r, graphops::ts_to_rational(25, tb));
|
||||
assert_eq!(
|
||||
out_r,
|
||||
graphops::ts_to_rational(25 + length, tb),
|
||||
"the clip spans five seconds from the drop point"
|
||||
);
|
||||
assert_eq!(
|
||||
graphops::node_label(&guard.graph, clip),
|
||||
graphops::node_label(&guard.graph, generator),
|
||||
"the clip is labelled after its generator"
|
||||
);
|
||||
(clip, generator)
|
||||
};
|
||||
let _ = (clip, generator);
|
||||
|
||||
oak_undo::global::undo().unwrap();
|
||||
{
|
||||
let guard = graphops::lock(&project);
|
||||
assert!(
|
||||
graphops::clip_ids(&guard.graph, track).is_empty(),
|
||||
"the undo removes the dropped clip"
|
||||
);
|
||||
}
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// five-second empty block at the clicked frame on a video track (an
|
||||
/// `AdjustmentBlockBehavior` with no footage and no texture wiring),
|
||||
/// refuses audio tracks, resolves through the timeline selection, and is
|
||||
|
||||
@@ -97,6 +97,9 @@ pub struct TimelinePanel<E: AppEngine> {
|
||||
/// the cursor plus the start frame. `None` outside the clip area or while
|
||||
/// no footage drag is active.
|
||||
footage_drop: Option<FootageDropTarget>,
|
||||
/// The drop point of an in-flight effect-library drag (generator
|
||||
/// effects becoming standalone clips). Same shape as `footage_drop`.
|
||||
effect_drop: Option<FootageDropTarget>,
|
||||
/// The right-click context menu (opened from
|
||||
/// [`TimelineEvent::ContextMenuRequested`]).
|
||||
context_menu: ContextMenuHandle,
|
||||
@@ -201,6 +204,7 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
height,
|
||||
snap,
|
||||
footage_drop: None,
|
||||
effect_drop: None,
|
||||
context_menu,
|
||||
context_track: None,
|
||||
context_empty: None,
|
||||
@@ -367,21 +371,21 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the footage-drop target under the cursor: converts the
|
||||
/// pointer (relative to the timeline body) into a display track + start
|
||||
/// frame using the timeline view's zoom/scroll state and the engine's
|
||||
/// track heights — the same affine mapping the timeline itself uses (see
|
||||
/// [`TimelineState::frame_at_point`] and the view's track-row walk).
|
||||
/// Hovering outside the clip area (above the ruler) clears the target.
|
||||
fn update_footage_drop(&mut self, event: &DragMoveEvent<FootageDrag>, cx: &mut Context<Self>) {
|
||||
let now = event.event.position - event.bounds.origin;
|
||||
/// Resolves a drag pointer (relative to the timeline body) into a
|
||||
/// display track + start frame using the timeline view's zoom/scroll
|
||||
/// state and the engine's track heights — the same affine mapping the
|
||||
/// timeline itself uses (see [`TimelineState::frame_at_point`] and the
|
||||
/// view's track-row walk). `None` above the ruler (outside the clip
|
||||
/// area).
|
||||
fn resolve_drop_point(
|
||||
&self,
|
||||
now: Point<Pixels>,
|
||||
cx: &App,
|
||||
) -> Option<(TrackKind, usize, Frame)> {
|
||||
// The clip area starts below the ruler and right of the track
|
||||
// headers column.
|
||||
if f32::from(now.y) < RULER_HEIGHT {
|
||||
if self.footage_drop.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let clip_x = f32::from(now.x - px(HEADER_WIDTH)).max(0.0);
|
||||
let clip_y = now.y - px(RULER_HEIGHT);
|
||||
@@ -413,6 +417,20 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
.unwrap_or((TrackKind::Video, 0))
|
||||
})
|
||||
};
|
||||
Some((track_kind, track_index, time))
|
||||
}
|
||||
|
||||
/// Resolves the footage-drop target under the cursor (see
|
||||
/// [`Self::resolve_drop_point`]). Hovering outside the clip area
|
||||
/// clears the target.
|
||||
fn update_footage_drop(&mut self, event: &DragMoveEvent<FootageDrag>, cx: &mut Context<Self>) {
|
||||
let now = event.event.position - event.bounds.origin;
|
||||
let Some((track_kind, track_index, time)) = self.resolve_drop_point(now, cx) else {
|
||||
if self.footage_drop.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.footage_drop = Some(FootageDropTarget {
|
||||
track_kind,
|
||||
track_index,
|
||||
@@ -426,6 +444,86 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Resolves the effect-library drop target under the cursor (same
|
||||
/// mapping as the footage drop; a generator clip's 5-second default
|
||||
/// length in sequence frames).
|
||||
fn update_effect_drop(
|
||||
&mut self,
|
||||
event: &DragMoveEvent<gpui::effect_stack::LibraryEffectDrag>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let now = event.event.position - event.bounds.origin;
|
||||
let Some((track_kind, track_index, time)) = self.resolve_drop_point(now, cx) else {
|
||||
if self.effect_drop.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
};
|
||||
let length = {
|
||||
let engine = self.engine.read(cx);
|
||||
let fps = engine.frame_rate();
|
||||
((5.0 * fps.num as f64 / fps.den.max(1) as f64).round() as i64).max(1)
|
||||
};
|
||||
self.effect_drop = Some(FootageDropTarget {
|
||||
track_kind,
|
||||
track_index,
|
||||
time,
|
||||
length,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Applies a finished effect-library drop: generator-category effects
|
||||
/// become a standalone generator clip at the hovered track + frame
|
||||
/// (undoable, one row); transition effects snap to the nearest clip
|
||||
/// edge as a junction or single-sided transition. Everything else is
|
||||
/// ignored.
|
||||
fn finish_effect_drop(
|
||||
&mut self,
|
||||
drag: &gpui::effect_stack::LibraryEffectDrag,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(target) = self.effect_drop.take() else {
|
||||
return;
|
||||
};
|
||||
let type_id = drag.type_id.as_str();
|
||||
let engine = self.engine.clone();
|
||||
if matches!(
|
||||
type_id,
|
||||
"org.olivevideoeditor.Olive.transition" | "org.olivevideoeditor.Olive.transitionfx"
|
||||
) {
|
||||
engine.update(cx, |engine, cx| {
|
||||
if let Err(err) =
|
||||
engine.drop_transition_at(type_id, target.track_index, target.time, cx)
|
||||
{
|
||||
println!("[timeline] transition drop: {err}");
|
||||
}
|
||||
});
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let is_generator = oak_node::factory::Factory::global()
|
||||
.create_any(type_id)
|
||||
.map(|(_, behavior)| {
|
||||
behavior
|
||||
.categories()
|
||||
.contains(&oak_node::node::Category::Generator)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_generator {
|
||||
println!("[timeline] effect drop: \"{type_id}\" is not a generator");
|
||||
return;
|
||||
}
|
||||
engine.update(cx, |engine, cx| {
|
||||
if let Err(err) =
|
||||
engine.drop_generator_clip(type_id, target.track_index, target.time, cx)
|
||||
{
|
||||
println!("[timeline] generator clip drop failed: {err}");
|
||||
}
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Applies a finished footage drop: routes the payload's footage id with
|
||||
/// the last hovered track + frame to the engine, which resolves the
|
||||
/// footage, validates the track and places the clip (undoable).
|
||||
@@ -1020,13 +1118,31 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
}
|
||||
this.finish_footage_drop(drag, cx);
|
||||
}))
|
||||
// Effect-library drop target (generator effects →
|
||||
// standalone generator clips).
|
||||
.on_drag_move(cx.listener(
|
||||
|this,
|
||||
event: &DragMoveEvent<gpui::effect_stack::LibraryEffectDrag>,
|
||||
_window,
|
||||
cx| {
|
||||
this.update_effect_drop(event, cx);
|
||||
},
|
||||
))
|
||||
.on_drop(cx.listener(
|
||||
|this,
|
||||
drag: &gpui::effect_stack::LibraryEffectDrag,
|
||||
_window,
|
||||
cx| {
|
||||
this.finish_effect_drop(drag, cx);
|
||||
},
|
||||
))
|
||||
.child({
|
||||
let mut inner =
|
||||
div().relative().size_full().child(self.timeline.clone());
|
||||
// The drop ghost: a translucent block at the
|
||||
// resolved track + frame, spanning the footage's
|
||||
// length, so the user sees where the clip lands.
|
||||
if let Some(target) = &self.footage_drop {
|
||||
if let Some(target) = self.footage_drop.as_ref().or(self.effect_drop.as_ref()) {
|
||||
let state = self.timeline.read(cx).state.clone();
|
||||
let x = px(HEADER_WIDTH) + state.point_at_frame(target.time);
|
||||
let width = px(target.length as f32 * state.zoom).max(px(4.0));
|
||||
|
||||
@@ -1437,8 +1437,12 @@ enum TrackRenderStep {
|
||||
/// selects.
|
||||
Transition {
|
||||
block: oak_node::id::NodeId,
|
||||
out_block: oak_node::id::NodeId,
|
||||
in_block: oak_node::id::NodeId,
|
||||
/// The outgoing (previous) block — `None` for a head transition
|
||||
/// (no previous clip; fades in from black).
|
||||
out_block: Option<oak_node::id::NodeId>,
|
||||
/// The incoming (next) block — `None` for a tail transition (no
|
||||
/// next clip; fades out to black).
|
||||
in_block: Option<oak_node::id::NodeId>,
|
||||
progress: f64,
|
||||
shader: &'static str,
|
||||
},
|
||||
@@ -1510,8 +1514,8 @@ fn blend_transition(
|
||||
graph: &oak_node::graph::Graph,
|
||||
traverser: &mut oak_node::traverser::Traverser,
|
||||
hooks: &mut RenderEvalHooks,
|
||||
out_block: oak_node::id::NodeId,
|
||||
in_block: oak_node::id::NodeId,
|
||||
out_block: Option<oak_node::id::NodeId>,
|
||||
in_block: Option<oak_node::id::NodeId>,
|
||||
time: Rational,
|
||||
progress: f64,
|
||||
shader: &str,
|
||||
@@ -1519,8 +1523,30 @@ fn blend_transition(
|
||||
use oak_node::nodes::transitions;
|
||||
use oak_node::value::NodeValueRow;
|
||||
|
||||
let from = evaluate_block_frame(graph, traverser, hooks, out_block, time)?;
|
||||
let to = evaluate_block_frame(graph, traverser, hooks, in_block, time)?;
|
||||
let from = match out_block {
|
||||
Some(block) => evaluate_block_frame(graph, traverser, hooks, block, time)?,
|
||||
None => None,
|
||||
};
|
||||
let to = match in_block {
|
||||
Some(block) => evaluate_block_frame(graph, traverser, hooks, block, time)?,
|
||||
None => None,
|
||||
};
|
||||
if from.is_none() && to.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// A single-sided transition blends against transparent black (a head
|
||||
// fade-in from nothing, a tail fade-out to nothing) — the same
|
||||
// shaders, with one side generated empty.
|
||||
let size = from
|
||||
.as_ref()
|
||||
.or(to.as_ref())
|
||||
.map(|f| (f.width, f.height))
|
||||
.or(hooks.frame_size)
|
||||
.unwrap_or((1, 1));
|
||||
let black = |size: (i32, i32)| generate_frame(time, size, PixelFormat::F32).ok();
|
||||
let from = from.or_else(|| black(size));
|
||||
let to = to.or_else(|| black(size));
|
||||
|
||||
// Both sides present: blend them. The side frames are boxed as CPU
|
||||
// textures (the shader-job path uploads those into scratch textures
|
||||
@@ -1803,7 +1829,11 @@ pub fn render_graph_frame(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (Some(out_block), Some(in_block)) = (
|
||||
// A transition needs at least one neighbor: a
|
||||
// junction block wires both, a head/tail
|
||||
// (single-sided) transition wires only its own
|
||||
// clip and fades from/to black.
|
||||
let (out_block, in_block) = (
|
||||
graph.connected_output(
|
||||
*block_id,
|
||||
oak_node::block::transition_input::OUT_BLOCK,
|
||||
@@ -1814,9 +1844,10 @@ pub fn render_graph_frame(
|
||||
oak_node::block::transition_input::IN_BLOCK,
|
||||
-1,
|
||||
),
|
||||
) else {
|
||||
);
|
||||
if out_block.is_none() && in_block.is_none() {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
let style = block
|
||||
.core
|
||||
.value_at_time(
|
||||
|
||||
@@ -395,3 +395,181 @@ fn wipe_style_splits_the_frame_at_the_boundary() {
|
||||
let _ = std::fs::remove_file(&red);
|
||||
let _ = std::fs::remove_file(&blue);
|
||||
}
|
||||
|
||||
/// Single-sided transitions (PR-style edge transitions): a head
|
||||
/// transition wired only `in_block_in` fades the clip in from black, a
|
||||
/// tail transition wired only `out_block_in` fades it out to black — the
|
||||
/// same shader with the open side generated transparent.
|
||||
#[test]
|
||||
fn single_sided_transitions_fade_from_and_to_black() {
|
||||
if oak_core::backend::GpuContext::shared().is_none() {
|
||||
eprintln!("skipping single_sided_transitions_fade_from_and_to_black: no GPU adapter");
|
||||
return;
|
||||
}
|
||||
let red = clip_path("edge_red");
|
||||
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
|
||||
.expect("red clip generation");
|
||||
let red_path = red.to_string_lossy().to_string();
|
||||
let xs: Vec<usize> = (8..56).collect();
|
||||
let ys: Vec<usize> = (8..56).collect();
|
||||
|
||||
let build = |start_edge: bool| {
|
||||
pin_legacy_working_space();
|
||||
let project = Project::new();
|
||||
let seq;
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let (score, sbehavior) = SequenceBehavior::create();
|
||||
seq = p.graph.add_node(score, sbehavior);
|
||||
let (tcore, tbehavior) = TrackListBehavior::create();
|
||||
let tl = p.graph.add_node(tcore, tbehavior);
|
||||
let (tcore, tbehavior) = TrackBehavior::create();
|
||||
let v1 = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let mut footage = FootageBehavior::new(&red_path);
|
||||
footage.probe().expect("probe the generated clip");
|
||||
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
|
||||
let (ccore, cbehavior) = oak_node::block::clip_create();
|
||||
let clip = p.graph.add_node(ccore, cbehavior);
|
||||
p.graph
|
||||
.connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("connect footage to clip");
|
||||
p.graph
|
||||
.get_mut(clip)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<ClipBlockBehavior>()
|
||||
.expect("clip block")
|
||||
.core
|
||||
.range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 1));
|
||||
|
||||
let (tcore, tbehavior) = oak_node::block::transition_create();
|
||||
let block = p.graph.add_node(tcore, tbehavior);
|
||||
{
|
||||
let t = p
|
||||
.graph
|
||||
.get_mut(block)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TransitionBlockBehavior>()
|
||||
.expect("transition block");
|
||||
if start_edge {
|
||||
t.core.range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 2));
|
||||
t.in_offset = Rational::new(0, 1);
|
||||
t.out_offset = Rational::new(1, 2);
|
||||
} else {
|
||||
t.core.range = TimeRange::new(Rational::new(1, 2), Rational::new(1, 1));
|
||||
t.in_offset = Rational::new(1, 2);
|
||||
t.out_offset = Rational::new(0, 1);
|
||||
}
|
||||
}
|
||||
p.graph
|
||||
.connect(
|
||||
clip,
|
||||
block,
|
||||
if start_edge {
|
||||
oak_node::block::transition_input::IN_BLOCK
|
||||
} else {
|
||||
oak_node::block::transition_input::OUT_BLOCK
|
||||
},
|
||||
-1,
|
||||
)
|
||||
.expect("wire the clip to the transition");
|
||||
{
|
||||
let track = p
|
||||
.graph
|
||||
.get_mut(v1)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.expect("video track");
|
||||
if start_edge {
|
||||
track.append_block(block);
|
||||
track.append_block(clip);
|
||||
} else {
|
||||
track.append_block(clip);
|
||||
track.append_block(block);
|
||||
}
|
||||
}
|
||||
p.graph
|
||||
.get_mut(tl)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackListBehavior>()
|
||||
.expect("video track list")
|
||||
.tracks
|
||||
.push(v1);
|
||||
p.graph
|
||||
.get_mut(seq)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<SequenceBehavior>()
|
||||
.expect("sequence")
|
||||
.track_lists
|
||||
.push(tl);
|
||||
}
|
||||
(project, seq)
|
||||
};
|
||||
|
||||
// Head (fade-in): progress 0 = black, 0.5 = the half blend, 1 = the
|
||||
// clip (and the plain clip beyond the span).
|
||||
let (project, seq) = build(true);
|
||||
let full = render_frame(&project, seq, Rational::new(3, 4));
|
||||
assert!(
|
||||
channel_mean(&full, 0, &xs, &ys) > 0.5,
|
||||
"past the span the plain clip shows"
|
||||
);
|
||||
let start = render_frame(&project, seq, Rational::new(0, 1));
|
||||
assert!(
|
||||
channel_mean(&start, 0, &xs, &ys) < 0.02,
|
||||
"the fade-in starts at black, got {}",
|
||||
channel_mean(&start, 0, &xs, &ys)
|
||||
);
|
||||
let mid = render_frame(&project, seq, Rational::new(1, 4));
|
||||
// The pipeline's composite convention (the same one the opacity
|
||||
// stack carries): the half blend carries half alpha, and the
|
||||
// alpha-over composite applies that alpha once more, so the
|
||||
// midpoint reads a quarter of the clip's channels.
|
||||
let expected = 0.25 * channel_mean(&full, 0, &xs, &ys);
|
||||
let got = channel_mean(&mid, 0, &xs, &ys);
|
||||
assert!(
|
||||
(got - expected).abs() < 0.02,
|
||||
"fade-in midpoint must be the half blend composited: expected {expected}, got {got}"
|
||||
);
|
||||
|
||||
// Tail (fade-out): progress 0 = the clip, 0.5 = the half blend, 1 =
|
||||
// black.
|
||||
let (project, seq) = build(false);
|
||||
let full = render_frame(&project, seq, Rational::new(1, 4));
|
||||
assert!(
|
||||
channel_mean(&full, 0, &xs, &ys) > 0.5,
|
||||
"before the span the plain clip shows"
|
||||
);
|
||||
let mid = render_frame(&project, seq, Rational::new(3, 4));
|
||||
// Same composite convention as the fade-in: half blend, alpha
|
||||
// applied again, a quarter of the clip at the midpoint.
|
||||
let expected = 0.25 * channel_mean(&full, 0, &xs, &ys);
|
||||
let got = channel_mean(&mid, 0, &xs, &ys);
|
||||
assert!(
|
||||
(got - expected).abs() < 0.02,
|
||||
"fade-out midpoint must be the half blend composited: expected {expected}, got {got}"
|
||||
);
|
||||
let end = render_frame(&project, seq, Rational::new(1, 1));
|
||||
assert!(
|
||||
channel_mean(&end, 0, &xs, &ys) < 0.02,
|
||||
"the fade-out ends at black, got {}",
|
||||
channel_mean(&end, 0, &xs, &ys)
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&red);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user