app: link the rear halves when splitting an A/V pair at the playhead

The Cmd+K split ran the plain per-block split for every selected clip,
so an originally linked audio/video pair came out with its front halves
linked but the two rear halves unlinked — dragging one rear clip left
its mate behind and Link/Relink could not repair it.

Split the whole target set as ONE BlockSplitPreservingLinksCommand (the
same command the multicam path uses): any pair of originally linked
blocks split at the same time gets its new halves linked too. Adds a
gpui test asserting front and rear halves of a dropped A/V clip stay
linked after split_at_playhead.
This commit is contained in:
2026-08-25 19:10:08 +08:00
parent 2828984187
commit a56f6ddf87
2 changed files with 119 additions and 4 deletions
+35
View File
@@ -1421,6 +1421,41 @@ pub fn split_clip(p: &ProjectRef, clip: NodeId, time_ts: i64) -> Result<(), Stri
)
}
/// Split every clip of `blocks` at `time_ts` as ONE undoable
/// `BlockSplitPreservingLinksCommand` (the Cmd+K path): originally linked
/// pairs that are both split get their rear halves linked too — the
/// per-block plain split left the rear half of an A/V pair unlinked.
/// Blocks whose range does not strictly contain the time are skipped by
/// the command itself.
pub fn split_clips_preserving_links(
p: &ProjectRef,
blocks: &[NodeId],
time_ts: i64,
) -> Result<(), String> {
let Some((&first, rest)) = blocks.split_first() else {
return Ok(());
};
let tb = {
let g = lock(p);
clip_track(&g.graph, first)
.and_then(|t| track_behavior(&g.graph, t))
.and_then(|t| t.track_list)
.and_then(|l| track_list_behavior(&g.graph, l))
.and_then(|l| l.sequence)
.and_then(|s| sequence_time_base(&g.graph, s))
.ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?
};
let point = ts_to_rational(time_ts, tb);
let mut refs = Vec::with_capacity(blocks.len());
refs.push(node_ref(p, first));
refs.extend(rest.iter().map(|&b| node_ref(p, b)));
let times = vec![point; refs.len()];
push(
oak_timeline::undosplit::BlockSplitPreservingLinksCommand::new(refs, times).to_command(),
"Split Clips",
)
}
/// Trim `clip`'s timeline range to `[new_in_ts, new_out_ts)` (undoable
/// "Trim Clip"; the facade's `oakengine_clip_trim` semantics: one end at
/// a time, trim-in anchors the OUT, trim-out anchors the IN — the
+84 -4
View File
@@ -3834,10 +3834,10 @@ impl AppEngine for RealEngine {
})
})
.collect();
let mut result = Ok(());
for block in targets {
result = graphops::split_clip(&project, block, frame.0);
}
// ONE preserving-links split for the whole set: linked pairs (the
// A/V drop) get their rear halves linked too — the per-block plain
// split left the rear halves unlinked.
let result = graphops::split_clips_preserving_links(&project, &targets, frame.0);
self.apply_edit(result, "split at playhead", cx);
}
@@ -7164,6 +7164,86 @@ mod tests {
);
}
/// Cmd+K split of an A/V pair: the front halves keep their link AND
/// the two rear halves are linked to each other (the old per-block
/// plain split left the rear halves unlinked).
#[gpui::test]
async fn split_at_playhead_links_the_rear_halves(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)
})
});
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)));
// Front/rear clips of the video track and the audio track (matched
// by their start frame: front starts at 0, rear at 20).
let clips_of = |kind: TrackKind, cx: &mut gpui::TestAppContext| {
cx.read(|app| {
let engine = engine.read(app);
let mut clips: Vec<ClipId> = engine
.tracks
.iter()
.filter(|t| t.kind == kind)
.flat_map(|t| t.clips.iter().map(|c| c.id))
.collect();
clips.sort_by_key(|c| c.0);
clips
})
};
let by_start = |kind: TrackKind, start: i64, cx: &mut gpui::TestAppContext| {
cx.read(|app| {
let engine = engine.read(app);
engine
.tracks
.iter()
.filter(|t| t.kind == kind)
.flat_map(|t| t.clips.iter())
.find(|c| c.range.start.0 == start)
.map(|c| c.id)
.unwrap_or_else(|| panic!("a {kind:?} clip starts at {start}"))
})
};
let linked = |a: ClipId, b: ClipId, cx: &mut gpui::TestAppContext| {
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)
})
};
let _ = clips_of;
let (vf, vr) = (by_start(TrackKind::Video, 0, cx), by_start(TrackKind::Video, 20, cx));
let (af, ar) = (by_start(TrackKind::Audio, 0, cx), by_start(TrackKind::Audio, 20, cx));
assert!(linked(vf, af, cx), "the front halves stay linked");
assert!(linked(vr, ar, cx), "the rear halves are linked too");
}
/// 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