app: implement Link/Unlink for timeline clips
CI / Build & test (Linux) (push) Canceled after 8m56s
CI / Build & test (Windows) (push) Canceled after 7m16s

编辑 > 链接/重新链接 (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.
This commit is contained in:
2026-08-25 03:40:57 +08:00
parent a1bfe5ee5e
commit 1ad2d71c81
5 changed files with 266 additions and 0 deletions
+9
View File
@@ -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<ClipId>, cx: &mut Context<Self>) {
let _ = (clips, cx);
}
// -------------------------------------------------------------------
// Multi-camera (the C++ MulticamWidget / timeline Multi-Cam menu):
// detection state for the panel, angle-frame rendering, the timeline
+59
View File
@@ -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<NodeId> = 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> {
+36
View File
@@ -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<ClipId>, cx: &mut Context<Self>) {
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<crate::oakui::engine::ProxyFootageRow> {
// Every explorer footage entry gets a row; audio-only entries keep
// `can_generate` off (the proxy pipeline is video-only).
+154
View File
@@ -4848,6 +4848,39 @@ impl AppEngine for RealEngine {
cx.notify();
}
fn toggle_clip_links(&mut self, clips: Vec<ClipId>, cx: &mut Context<Self>) {
let Some(project) = self.project.clone() else {
return;
};
// Only clip blocks can be linked (the C++ `block_as_clip` filter).
let blocks: Vec<NodeId> = 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<ExportSession, String> {
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<ClipId> = 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
+8
View File
@@ -683,6 +683,14 @@ impl<E: AppEngine> PanelCommandHandler for TimelinePanel<E> {
.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<Self>) -> bool {
let ids: Vec<ClipId> = 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<Self>) -> bool {