app: implement Link/Unlink for timeline clips
编辑 > 链接/重新链接 (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:
@@ -948,6 +948,15 @@ pub trait AppEngine:
|
|||||||
let _ = (clips, adjust_speed, cx);
|
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):
|
// Multi-camera (the C++ MulticamWidget / timeline Multi-Cam menu):
|
||||||
// detection state for the panel, angle-frame rendering, the timeline
|
// detection state for the panel, angle-frame rendering, the timeline
|
||||||
|
|||||||
@@ -1713,6 +1713,65 @@ pub fn move_clip_with_links(
|
|||||||
push_multi(commands, "Move Clip")
|
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
|
/// Delete `clip` leaving a gap (undoable "Delete Clips"; the facade's
|
||||||
/// single-clip `oakengine_sequence_delete_clips` composition).
|
/// single-clip `oakengine_sequence_delete_clips` composition).
|
||||||
pub fn delete_clip(p: &ProjectRef, clip: NodeId) -> Result<(), String> {
|
pub fn delete_clip(p: &ProjectRef, clip: NodeId) -> Result<(), String> {
|
||||||
|
|||||||
@@ -543,6 +543,9 @@ pub struct MockEngine {
|
|||||||
ocio_config: String,
|
ocio_config: String,
|
||||||
/// The demo project's disk-cache location (setting, custom path).
|
/// The demo project's disk-cache location (setting, custom path).
|
||||||
cache_location: (i32, String),
|
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
|
/// The demo multicam graph: a real oaknode project whose source
|
||||||
/// sequence's video tracks are the angles. Created lazily so the demo
|
/// sequence's video tracks are the angles. Created lazily so the demo
|
||||||
/// panel shows a genuine graph behind its synthetic frames — and the
|
/// panel shows a genuine graph behind its synthetic frames — and the
|
||||||
@@ -812,6 +815,7 @@ impl MockEngine {
|
|||||||
use_proxy: true,
|
use_proxy: true,
|
||||||
ocio_config: String::new(),
|
ocio_config: String::new(),
|
||||||
cache_location: (0, String::new()),
|
cache_location: (0, String::new()),
|
||||||
|
clip_links: std::collections::HashSet::new(),
|
||||||
multicam_graph: Mutex::new(None),
|
multicam_graph: Mutex::new(None),
|
||||||
multicam_frames: Mutex::new(HashMap::new()),
|
multicam_frames: Mutex::new(HashMap::new()),
|
||||||
};
|
};
|
||||||
@@ -1160,6 +1164,13 @@ impl MockEngine {
|
|||||||
.unwrap_or(1)
|
.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`
|
/// Splits the clip at (track, clip) position into two at `time`
|
||||||
/// (mock-apply, not undoable).
|
/// (mock-apply, not undoable).
|
||||||
fn split_mock_clip(&mut self, track: usize, index: usize, time: Frame) {
|
fn split_mock_clip(&mut self, track: usize, index: usize, time: Frame) {
|
||||||
@@ -1942,6 +1953,31 @@ impl AppEngine for MockEngine {
|
|||||||
cx.notify();
|
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> {
|
fn proxy_rows(&self) -> Vec<crate::oakui::engine::ProxyFootageRow> {
|
||||||
// Every explorer footage entry gets a row; audio-only entries keep
|
// Every explorer footage entry gets a row; audio-only entries keep
|
||||||
// `can_generate` off (the proxy pipeline is video-only).
|
// `can_generate` off (the proxy pipeline is video-only).
|
||||||
|
|||||||
@@ -4848,6 +4848,39 @@ impl AppEngine for RealEngine {
|
|||||||
cx.notify();
|
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> {
|
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String> {
|
||||||
let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else {
|
let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else {
|
||||||
return Err("no sequence open".into());
|
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
|
/// Playback pre-render window (M15 S2): during playback the playhead
|
||||||
/// frame must come from the worker-rendered shm slot cache, NOT the
|
/// frame must come from the worker-rendered shm slot cache, NOT the
|
||||||
/// synchronous render path (the main thread blocking in
|
/// synchronous render path (the main thread blocking in
|
||||||
|
|||||||
@@ -683,6 +683,14 @@ impl<E: AppEngine> PanelCommandHandler for TimelinePanel<E> {
|
|||||||
.update(cx, |engine, cx| engine.sync_clips_by_waveform(ids, true, cx));
|
.update(cx, |engine, cx| engine.sync_clips_by_waveform(ids, true, cx));
|
||||||
true
|
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 ---
|
// --- view ---
|
||||||
fn zoom_in(&mut self, cx: &mut Context<Self>) -> bool {
|
fn zoom_in(&mut self, cx: &mut Context<Self>) -> bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user