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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user