From 1ad2d71c81e77c14cd26fa871df632edbcf5665f Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 25 Aug 2026 03:40:57 +0800 Subject: [PATCH] app: implement Link/Unlink for timeline clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑 > 链接/重新链接 (Cmd+L) was an unhandled stub (PanelCommandHandler::toggle_links returned false), so linking clips never created graph links and linked drags never happened. - graphops::set_clips_linked: one undoable entry toggling every pair of the selection; the undo restores the exact prior internal topology. - RealEngine::toggle_clip_links: fully-linked selections unlink, otherwise the selection links together (the C++ crude "any member has ANY link" rule would have made split halves — which inherit the original clip's A/V links — impossible to link to each other). - TimelinePanel overrides toggle_links, routing the timeline selection. - MockEngine stores demo link pairs. - Test: link_unlink_toggles_the_selection_links — dropped A/V pair toggles off/on with undo, and split halves link manually. --- crates/oak-app/src/oakui/engine.rs | 9 ++ crates/oak-app/src/oakui/graphops.rs | 59 ++++++++++ crates/oak-app/src/oakui/mock.rs | 36 ++++++ crates/oak-app/src/oakui/real.rs | 154 ++++++++++++++++++++++++++ crates/oak-app/src/panels/timeline.rs | 8 ++ 5 files changed, 266 insertions(+) diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index e87bfd330..37354811f 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -948,6 +948,15 @@ pub trait AppEngine: let _ = (clips, adjust_speed, cx); } + /// 链接/重新链接 the selected clips (the C++ `toggle_links_on_selected`): + /// when ANY of them carries a link the set is unlinked, otherwise the + /// set is linked together — one undoable entry either way. Linked clips + /// move/trim in lockstep (the timeline's drag expansion reads the graph + /// links). + fn toggle_clip_links(&mut self, clips: Vec, cx: &mut Context) { + let _ = (clips, cx); + } + // ------------------------------------------------------------------- // Multi-camera (the C++ MulticamWidget / timeline Multi-Cam menu): // detection state for the panel, angle-frame rendering, the timeline diff --git a/crates/oak-app/src/oakui/graphops.rs b/crates/oak-app/src/oakui/graphops.rs index 86166daf9..fcd69b7ca 100644 --- a/crates/oak-app/src/oakui/graphops.rs +++ b/crates/oak-app/src/oakui/graphops.rs @@ -1713,6 +1713,65 @@ pub fn move_clip_with_links( push_multi(commands, "Move Clip") } +/// Link or unlink a set of clips as ONE undoable entry (the C++ +/// `TimelineWidget::toggle_links_on_selected` → `oakengine_clip_set_linked` +/// composition): linking connects every pair of the set, unlinking clears +/// every link among them. The undo restores the exact prior topology among +/// the set (links to nodes OUTSIDE the set are untouched, like the C++ +/// command's). +pub fn set_clips_linked(p: &ProjectRef, blocks: &[NodeId], linked: bool) -> Result<(), String> { + if blocks.len() < 2 { + return Ok(()); + } + let blocks: Vec = blocks.to_vec(); + // Snapshot the prior links among the set (the undo's target state). + let prior: Vec<(NodeId, NodeId)> = { + let g = lock(p); + let mut v = Vec::new(); + for (i, &a) in blocks.iter().enumerate() { + for &b in &blocks[i + 1..] { + if g.graph.links_of(a).contains(&b) { + v.push((a, b)); + } + } + } + v + }; + let all_pairs: Vec<(NodeId, NodeId)> = { + let mut v = Vec::new(); + for (i, &a) in blocks.iter().enumerate() { + for &b in &blocks[i + 1..] { + v.push((a, b)); + } + } + v + }; + // The shared mutation: clear the set's internal links, then restore the + // target topology (redo: all pairs when linking, none when unlinking; + // undo: the snapshot). + fn apply(p: &ProjectRef, blocks: &[NodeId], target: &[(NodeId, NodeId)]) { + let mut g = lock(p); + for (i, &a) in blocks.iter().enumerate() { + for &b in &blocks[i + 1..] { + g.graph.unlink(a, b); + } + } + for &(a, b) in target { + g.graph.link(a, b); + } + } + let redo_target = if linked { all_pairs } else { Vec::new() }; + let (p1, p2) = (p.clone(), p.clone()); + let (b1, b2) = (blocks.clone(), blocks); + push_command( + oak_undo::undocommand::UndoCommand::from_closures( + move || apply(&p1, &b1, &redo_target), + move || apply(&p2, &b2, &prior), + ), + if linked { "Link Clips" } else { "Unlink Clips" }, + ) +} + /// Delete `clip` leaving a gap (undoable "Delete Clips"; the facade's /// single-clip `oakengine_sequence_delete_clips` composition). pub fn delete_clip(p: &ProjectRef, clip: NodeId) -> Result<(), String> { diff --git a/crates/oak-app/src/oakui/mock.rs b/crates/oak-app/src/oakui/mock.rs index 5b8ba06c3..2bcb0c7e0 100644 --- a/crates/oak-app/src/oakui/mock.rs +++ b/crates/oak-app/src/oakui/mock.rs @@ -543,6 +543,9 @@ pub struct MockEngine { ocio_config: String, /// The demo project's disk-cache location (setting, custom path). cache_location: (i32, String), + /// The demo clip link pairs (normalized `(min, max)` id pairs; the + /// 链接/重新链接 toggle edits them). + clip_links: std::collections::HashSet<(u64, u64)>, /// The demo multicam graph: a real oaknode project whose source /// sequence's video tracks are the angles. Created lazily so the demo /// panel shows a genuine graph behind its synthetic frames — and the @@ -812,6 +815,7 @@ impl MockEngine { use_proxy: true, ocio_config: String::new(), cache_location: (0, String::new()), + clip_links: std::collections::HashSet::new(), multicam_graph: Mutex::new(None), multicam_frames: Mutex::new(HashMap::new()), }; @@ -1160,6 +1164,13 @@ impl MockEngine { .unwrap_or(1) } + /// Whether two demo clips are linked (the 链接/重新链接 toggle's state; + /// the app tests assert through this). + pub fn clips_linked(&self, a: u64, b: u64) -> bool { + let pair = if a < b { (a, b) } else { (b, a) }; + self.clip_links.contains(&pair) + } + /// Splits the clip at (track, clip) position into two at `time` /// (mock-apply, not undoable). fn split_mock_clip(&mut self, track: usize, index: usize, time: Frame) { @@ -1942,6 +1953,31 @@ impl AppEngine for MockEngine { cx.notify(); } + /// 链接/重新链接 the demo clips: a fully linked selection unlinks, + /// otherwise the set links together (matching the real engine's rule). + /// The demo stores the pairs (normalized min-max ids). + fn toggle_clip_links(&mut self, clips: Vec, cx: &mut Context) { + if clips.len() < 2 { + return; + } + let pair = |a: u64, b: u64| if a < b { (a, b) } else { (b, a) }; + let all_linked = clips.iter().enumerate().all(|(i, a)| { + clips[i + 1..] + .iter() + .all(|b| self.clip_links.contains(&pair(a.0, b.0))) + }); + for (i, a) in clips.iter().enumerate() { + for b in &clips[i + 1..] { + if all_linked { + self.clip_links.remove(&pair(a.0, b.0)); + } else { + self.clip_links.insert(pair(a.0, b.0)); + } + } + } + cx.notify(); + } + fn proxy_rows(&self) -> Vec { // Every explorer footage entry gets a row; audio-only entries keep // `can_generate` off (the proxy pipeline is video-only). diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 467530683..073d2c666 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -4848,6 +4848,39 @@ impl AppEngine for RealEngine { cx.notify(); } + fn toggle_clip_links(&mut self, clips: Vec, cx: &mut Context) { + let Some(project) = self.project.clone() else { + return; + }; + // Only clip blocks can be linked (the C++ `block_as_clip` filter). + let blocks: Vec = clips + .iter() + .filter_map(|c| graphops::id_of(c.0)) + .filter(|&n| { + let g = graphops::lock(&project); + graphops::clip_behavior(&g.graph, n).is_some() + }) + .collect(); + if blocks.len() < 2 { + return; + } + // Toggle rule: fully linked internally → unlink; otherwise link + // (deviation from the C++ crude "any member has ANY link" check, + // which made split halves — carrying inherited A/V links — + // impossible to link to each other). The undo restores the prior + // internal topology; links to nodes outside the set stay untouched. + let all_linked = { + let g = graphops::lock(&project); + blocks.iter().enumerate().all(|(i, &a)| { + blocks[i + 1..] + .iter() + .all(|&b| g.graph.links_of(a).contains(&b)) + }) + }; + let result = graphops::set_clips_linked(&project, &blocks, !all_linked); + self.apply_edit(result, if all_linked { "unlink clips" } else { "link clips" }, cx); + } + fn start_export(&mut self, format: i32, path: PathBuf) -> Result { let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { return Err("no sequence open".into()); @@ -7010,6 +7043,127 @@ mod tests { ); } + /// 链接/重新链接 toggles the graph links among the selected clips (one + /// undoable entry, the C++ toggle_links_on_selected): the A/V pair + /// dropped from one file starts linked, the toggle unlinks it, a second + /// toggle re-links it; and the two halves of a SPLIT clip — unlinked by + /// default — link manually (the reported regression: nothing happened + /// either way). + #[gpui::test] + async fn link_unlink_toggles_the_selection_links(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 media = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/demo.mp4"); + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.import_footage(media.clone(), cx).expect("import") + }) + }); + let name = media.file_name().unwrap().to_string_lossy().into_owned(); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(0), cx) + }) + }); + + // The dropped pair: the video clip and its linked audio clip. + let (video_id, audio_id) = cx.read(|app| { + let engine = engine.read(app); + let video = engine + .tracks + .iter() + .find(|t| t.kind == TrackKind::Video && !t.clips.is_empty()) + .expect("video track with the clip") + .clips[0] + .id; + let audio = engine + .tracks + .iter() + .find(|t| t.kind == TrackKind::Audio && !t.clips.is_empty()) + .expect("audio track with the clip") + .clips[0] + .id; + (video, audio) + }); + let linked = |cx: &mut gpui::TestAppContext, a: ClipId, b: ClipId| { + cx.read(|app| { + let engine = engine.read(app); + let project = engine.project_ref().expect("project").clone(); + let (Some(na), Some(nb)) = (graphops::id_of(a.0), graphops::id_of(b.0)) else { + return false; + }; + let guard = graphops::lock(&project); + guard.graph.links_of(na).contains(&nb) + }) + }; + assert!(linked(cx, video_id, audio_id), "the dropped A/V pair starts linked"); + + // The toggle unlinks the pair; ONE undo restores the link; the next + // toggle unlinks it again (the undo left the pair linked, so the + // toggle goes the unlink way). + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.toggle_clip_links(vec![video_id, audio_id], cx) + }) + }); + assert!(!linked(cx, video_id, audio_id), "the toggle unlinks the pair"); + cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); + assert!(linked(cx, video_id, audio_id), "one undo restores the link"); + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.toggle_clip_links(vec![video_id, audio_id], cx) + }) + }); + assert!( + !linked(cx, video_id, audio_id), + "the pair is linked after the undo, so the toggle unlinks it again" + ); + cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); + + // Split the video clip at frame 20: the two halves are NOT linked by + // default; selecting both and toggling links them. + cx.update(|app| { + engine.update(app, |engine, cx| engine.request_frame(Monitor::Program, Frame(20), cx)) + }); + cx.update(|app| engine.update(app, |engine, cx| engine.split_at_playhead(cx))); + let halves: Vec = cx.read(|app| { + let engine = engine.read(app); + engine + .tracks + .iter() + .find(|t| t.kind == TrackKind::Video && t.clips.len() == 2) + .expect("the split produced two clips on the video track") + .clips + .iter() + .map(|c| c.id) + .collect() + }); + assert!( + !linked(cx, halves[0], halves[1]), + "the split halves start unlinked" + ); + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.toggle_clip_links(halves.clone(), cx) + }) + }); + assert!( + linked(cx, halves[0], halves[1]), + "the halves link through the toggle" + ); + } + /// Playback pre-render window (M15 S2): during playback the playhead /// frame must come from the worker-rendered shm slot cache, NOT the /// synchronous render path (the main thread blocking in diff --git a/crates/oak-app/src/panels/timeline.rs b/crates/oak-app/src/panels/timeline.rs index 06a7acf4d..8ba61d042 100644 --- a/crates/oak-app/src/panels/timeline.rs +++ b/crates/oak-app/src/panels/timeline.rs @@ -683,6 +683,14 @@ impl PanelCommandHandler for TimelinePanel { .update(cx, |engine, cx| engine.sync_clips_by_waveform(ids, true, cx)); true } + /// 编辑 → 链接/重新链接: toggles the graph links among the selected + /// clips through the engine (one undoable entry). + fn toggle_links(&mut self, cx: &mut Context) -> bool { + let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); + self.engine + .update(cx, |engine, cx| engine.toggle_clip_links(ids, cx)); + true + } // --- view --- fn zoom_in(&mut self, cx: &mut Context) -> bool {