From 2817ac286c24dd1a523103410d54c12cad99c48c Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 11 Sep 2026 08:49:08 +0800 Subject: [PATCH] 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. --- crates/oak-app/src/oakui/engine.rs | 32 ++ crates/oak-app/src/oakui/graphops.rs | 319 +++++++++++++++++-- crates/oak-app/src/oakui/mock.rs | 66 ++++ crates/oak-app/src/oakui/real.rs | 246 +++++++++++++- crates/oak-app/src/panels/timeline.rs | 142 ++++++++- crates/oak-render/src/eval.rs | 49 ++- crates/oak-render/tests/transition_render.rs | 178 +++++++++++ 7 files changed, 983 insertions(+), 49 deletions(-) diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index e7ad5a600..71fd20e0e 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -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, + ) -> 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, + ) -> 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, diff --git a/crates/oak-app/src/oakui/graphops.rs b/crates/oak-app/src/oakui/graphops.rs index b9d1f59c4..fd377d845 100644 --- a/crates/oak-app/src/oakui/graphops.rs +++ b/crates/oak-app/src/oakui/graphops.rs @@ -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), 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::()) + 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 { + 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 { + { + 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 { 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::()) + .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::()) + .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. diff --git a/crates/oak-app/src/oakui/mock.rs b/crates/oak-app/src/oakui/mock.rs index 7ac8e7286..69d5c4937 100644 --- a/crates/oak-app/src/oakui/mock.rs +++ b/crates/oak-app/src/oakui/mock.rs @@ -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, + ) -> 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, + ) -> Result<(), String> { + // The mock models no transition blocks; the route is accepted. + Ok(()) + } + fn drop_footage( &mut self, id: u64, diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 7fa66a4e9..afcda1c4b 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -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, + ) -> 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, + ) -> 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 diff --git a/crates/oak-app/src/panels/timeline.rs b/crates/oak-app/src/panels/timeline.rs index 4626010e3..4da91566c 100644 --- a/crates/oak-app/src/panels/timeline.rs +++ b/crates/oak-app/src/panels/timeline.rs @@ -97,6 +97,9 @@ pub struct TimelinePanel { /// the cursor plus the start frame. `None` outside the clip area or while /// no footage drag is active. footage_drop: Option, + /// The drop point of an in-flight effect-library drag (generator + /// effects becoming standalone clips). Same shape as `footage_drop`. + effect_drop: Option, /// The right-click context menu (opened from /// [`TimelineEvent::ContextMenuRequested`]). context_menu: ContextMenuHandle, @@ -201,6 +204,7 @@ impl TimelinePanel { height, snap, footage_drop: None, + effect_drop: None, context_menu, context_track: None, context_empty: None, @@ -367,21 +371,21 @@ impl TimelinePanel { } } - /// 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, cx: &mut Context) { - 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, + 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 TimelinePanel { .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, cx: &mut Context) { + 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 TimelinePanel { 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, + cx: &mut Context, + ) { + 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, + ) { + 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 Render for TimelinePanel { } this.finish_footage_drop(drag, cx); })) + // Effect-library drop target (generator effects → + // standalone generator clips). + .on_drag_move(cx.listener( + |this, + event: &DragMoveEvent, + _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)); diff --git a/crates/oak-render/src/eval.rs b/crates/oak-render/src/eval.rs index ccf2a575b..ee464eb70 100644 --- a/crates/oak-render/src/eval.rs +++ b/crates/oak-render/src/eval.rs @@ -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, + /// The incoming (next) block — `None` for a tail transition (no + /// next clip; fades out to black). + in_block: Option, 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, + in_block: Option, 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( diff --git a/crates/oak-render/tests/transition_render.rs b/crates/oak-render/tests/transition_render.rs index 53514646f..d295d6315 100644 --- a/crates/oak-render/tests/transition_render.rs +++ b/crates/oak-render/tests/transition_render.rs @@ -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 = (8..56).collect(); + let ys: Vec = (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::() + .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::() + .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::() + .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::() + .expect("video track list") + .tracks + .push(v1); + p.graph + .get_mut(seq) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .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); +}