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`'s timeline range to `[new_in_ts, new_out_ts)` (undoable
|
||||||
/// "Trim Clip"; the facade's `oakengine_clip_trim` semantics: one end at
|
/// "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
|
/// a time, trim-in anchors the OUT, trim-out anchors the IN — the
|
||||||
|
|||||||
@@ -3834,10 +3834,10 @@ impl AppEngine for RealEngine {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut result = Ok(());
|
// ONE preserving-links split for the whole set: linked pairs (the
|
||||||
for block in targets {
|
// A/V drop) get their rear halves linked too — the per-block plain
|
||||||
result = graphops::split_clip(&project, block, frame.0);
|
// 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);
|
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
|
/// 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
|
||||||
|
|||||||
Reference in New Issue
Block a user