feat(app): A/V drop creates linked clips, add-track affordances, default 2V+2A layout

- Dropping a video-with-audio footage now places a video clip AND a
  linked audio clip at the same range in ONE undoable 'Add Clip' entry
  (the links live on NodeCore.links, the canonical links_of storage;
  auto-creates the missing track kind).
- The timeline toolbar gains 'Add Video/Audio Track' buttons and the
  track-header context menu offers the same two entries above
  Delete/Delete All Empty.
- create_sequence now starts every new sequence with the default
  2 video + 2 audio track layout (driven directly through the add-track
  commands, not through the undo stack). Tests updated for the new
  default track counts.
This commit is contained in:
2026-08-19 15:21:54 +08:00
parent 35b9ad9541
commit dffa94127a
5 changed files with 373 additions and 52 deletions
+8
View File
@@ -385,6 +385,8 @@ const EN: &[(&str, &str)] = &[
("timeline.zoom_out", "Zoom Out"),
("timeline.track_height", "Track Height"),
("timeline.snap", "Snap"),
("timeline.add_video_track", "Add Video Track"),
("timeline.add_audio_track", "Add Audio Track"),
// --- project bin ---
("bin.footage", "Footage"),
("bin.music", "Music"),
@@ -564,6 +566,8 @@ const EN: &[(&str, &str)] = &[
("timeline.context.thumbnails_at_in_points", "Only At In Points"),
("timeline.context.thumbnails_on", "Enabled"),
("timeline.context.show_waveforms", "Show Waveforms"),
("timeline.context.add_video_track", "Add Video Track"),
("timeline.context.add_audio_track", "Add Audio Track"),
("timeline.context.delete_track", "Delete"),
("timeline.context.delete_all_empty", "Delete All Empty"),
("timeline.context.timecode_drop_frame", "Drop Frame"),
@@ -851,6 +855,8 @@ const ZH: &[(&str, &str)] = &[
("timeline.zoom_out", "缩小"),
("timeline.track_height", "轨道高"),
("timeline.snap", "吸附"),
("timeline.add_video_track", "新建视频轨"),
("timeline.add_audio_track", "新建音频轨"),
// --- project bin ---
("bin.footage", "素材"),
("bin.music", "音乐"),
@@ -1030,6 +1036,8 @@ const ZH: &[(&str, &str)] = &[
("timeline.context.thumbnails_at_in_points", "仅在入点"),
("timeline.context.thumbnails_on", "开启"),
("timeline.context.show_waveforms", "显示波形"),
("timeline.context.add_video_track", "新建视频轨"),
("timeline.context.add_audio_track", "新建音频轨"),
("timeline.context.delete_track", "删除"),
("timeline.context.delete_all_empty", "删除所有空轨道"),
("timeline.context.timecode_drop_frame", "丢帧"),
+118 -1
View File
@@ -206,7 +206,20 @@ pub fn create_sequence(project: &ProjectRef, name: &str) -> NodeId {
let mut guard = lock(project);
let (mut core, behavior) = SequenceBehavior::create();
core.label = name.to_string();
guard.graph.add_node(core, behavior)
let seq = guard.graph.add_node(core, behavior);
drop(guard);
// A new sequence starts with the default 2 video + 2 audio track
// layout (user-mandated NLE default: V1, V2 on top, A1, A2 below).
// Driven directly through the add-track commands' redo — NOT pushed
// to the undo stack, a sequence's default layout is part of its
// creation, not an undoable edit.
for kind in [TrackType::Video, TrackType::Video, TrackType::Audio, TrackType::Audio] {
let Some(list) = find_or_create_track_list(project, seq, kind) else {
continue;
};
oaktimeline::undogeneral::TimelineAddTrackCommand::new(node_ref(project, list)).redo();
}
seq
}
/// The label of a node (`NodeCore::label`).
@@ -1272,6 +1285,110 @@ pub fn place_footage_clip(
Ok(clip)
}
/// Place linked clips of one footage on several tracks in ONE undoable
/// "Add Clip" entry (the NLE A/V-drop: a video-with-audio file lands as
/// a video clip plus a linked audio clip). `placements` lists the
/// `(track kind, track index)` targets in order; every clip shares the
/// same timeline range and media-in, and all clips are linked both ways
/// (C++ `block_links_` semantics: grouped edits like split/ripple apply
/// to the whole group).
pub fn place_footage_clips_linked(
p: &ProjectRef,
seq: NodeId,
footage: NodeId,
placements: &[(TrackType, usize)],
in_ts: i64,
out_ts: i64,
media_in_ts: i64,
) -> Result<Vec<NodeId>, String> {
if placements.len() < 2 {
return Err("linked placement needs at least two tracks".to_string());
}
let tb = {
let g = lock(p);
if footage_behavior(&g.graph, footage).is_none() {
return Err("the footage node is not in the project".to_string());
}
sequence_time_base(&g.graph, seq)
.ok_or_else(|| "sequence has no valid frame rate".to_string())?
};
let in_r = ts_to_rational(in_ts, tb);
let out_r = ts_to_rational(out_ts, tb);
let media_r = ts_to_rational(media_in_ts, tb);
let length = out_r - in_r;
// Create all clips first (graph writes are not undoable; the placement
// commands below are).
let mut clips = Vec::with_capacity(placements.len());
for _ in placements {
let mut g = lock(p);
let (core, behavior) = oaknode::block::clip_create();
let id = g.graph.add_node(core, behavior);
if let Some(c) = g
.graph
.get_mut(id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
c.core.media_in = media_r;
c.core.set_length_and_media_in(length);
}
clips.push(id);
}
let mut commands = Vec::new();
for (&(kind, track_index), &clip) in placements.iter().zip(&clips) {
let list = {
let g = lock(p);
track_list_of(&g.graph, seq, kind)
.ok_or_else(|| "sequence has no track list for this type".to_string())?
};
commands.push(
oaktimeline::undopointer::TrackPlaceBlockCommand::new(
node_ref(p, list),
track_index as i32,
node_ref(p, clip),
in_r,
)
.to_command(),
);
commands.push(connect_command(p, footage, clip, oaknode::block::clip_input::TEXTURE_INPUT)?);
}
// Link the group both ways (closure commands capture the current
// links for undo).
for (i, &a) in clips.iter().enumerate() {
for (j, &b) in clips.iter().enumerate() {
if i == j {
continue;
}
let old: Vec<NodeId> = {
let g = lock(p);
g.graph.links_of(a)
};
let (p1, p2) = (p.clone(), p.clone());
commands.push(oakundo::undocommand::UndoCommand::from_closures(
move || {
let mut g = lock(&p1);
if let Some(entry) = g.graph.get_mut(a) {
let links = &mut entry.core.links;
if !links.contains(&b) {
links.push(b);
}
}
},
move || {
let mut g = lock(&p2);
if let Some(entry) = g.graph.get_mut(a) {
entry.core.links = old.clone();
}
},
));
}
}
push_multi(commands, "Add Clip")?;
Ok(clips)
}
/// Split `clip` at `time_ts` (a frame timestamp strictly inside the
/// clip's range), undoable "Split Clip" (the module's
/// `BlockSplitCommand`).
+185 -47
View File
@@ -3866,63 +3866,85 @@ impl AppEngine for RealEngine {
// Media type from the probed stream list (the probe is real since
// the import fills it); fall back to the extension only when no
// streams were recorded (legacy projects loaded without a probe).
let footage_kind = if total_streams > 0 {
if video_streams > 0 {
TrackKind::Video
} else {
TrackKind::Audio
}
let (has_video, has_audio) = if total_streams > 0 {
(video_streams > 0, total_streams > video_streams)
} else if crate::oakui::filename_is_audio(&filename) {
TrackKind::Audio
(false, true)
} else {
TrackKind::Video
(true, false)
};
// Track policy (see the `AppEngine::drop_footage` docs): use the
// pointed display track when its kind matches, otherwise auto-select
// the topmost track of the footage's kind; reject when there is none.
let target = if let Some(track) = self.tracks.get(track_index) {
if track.kind == footage_kind {
track_index
} else {
match self.tracks.iter().position(|t| t.kind == footage_kind) {
Some(index) => index,
None => {
// No track of the media's kind: create one (the NLE
// convention — Premiere auto-creates on drop).
self.add_track(footage_kind, cx);
match self.tracks.iter().position(|t| t.kind == footage_kind) {
Some(index) => index,
None => {
println!(
"[real engine] drop footage: could not add a {:?} track for \"{}\"",
footage_kind, filename
);
return;
}
}
}
// A video-with-audio file needs BOTH a video and an audio track —
// missing kinds are created (the NLE convention — Premiere
// auto-creates on drop).
let mut ensure_track = |this: &mut Self, kind: TrackKind, cx: &mut Context<Self>| {
if let Some(track) = this.tracks.get(track_index) {
if track.kind == kind {
return Some(track_index);
}
}
} else if self.tracks.is_empty() {
// An empty timeline (a fresh sequence has no tracks yet): create
// the media's track and drop onto it.
self.add_track(footage_kind, cx);
match self.tracks.iter().position(|t| t.kind == footage_kind) {
Some(index) => index,
if let Some(index) = this.tracks.iter().position(|t| t.kind == kind) {
return Some(index);
}
this.add_track(kind, cx);
match this.tracks.iter().position(|t| t.kind == kind) {
Some(index) => Some(index),
None => {
println!(
"[real engine] drop footage: could not add a {:?} track",
footage_kind
"[real engine] drop footage: could not add a {:?} track for \"{}\"",
kind, filename
);
return;
None
}
}
} else {
println!("[real engine] drop footage: display track {track_index} does not exist");
return;
};
let track = &self.tracks[target];
let (kind, track_index_facade) = (track_type_of(track.kind), track.track_index);
let (kind, track_index_facade) = if has_video && has_audio {
let Some(video_target) = ensure_track(self, TrackKind::Video, cx) else {
return;
};
let Some(audio_target) = ensure_track(self, TrackKind::Audio, cx) else {
return;
};
let (video_index, audio_index) = (
self.tracks[video_target].track_index,
self.tracks[audio_target].track_index,
);
// Clip length: the footage's probed duration when available;
// otherwise a 10-second default.
let fps = self.frame_rate();
let fps_f = fps.num as f64 / fps.den.max(1) as f64;
let length = match seconds {
Some(s) => (s * fps_f).round().max(1.0) as i64,
None => (10.0 * fps_f).round().max(1.0) as i64,
};
let in_ts = time.0.max(0);
// One undoable "Add Clip" entry: the video clip plus its linked
// audio clip at the same range (the NLE A/V drop).
let result = graphops::place_footage_clips_linked(
&project,
seq,
footage,
&[
(TrackType::Video, video_index as usize),
(TrackType::Audio, audio_index as usize),
],
in_ts,
in_ts + length,
0,
)
.map(|_| ());
self.apply_edit(result, "drop footage", cx);
return;
} else {
let footage_kind = if has_video { TrackKind::Video } else { TrackKind::Audio };
let Some(target) = ensure_track(self, footage_kind, cx) else {
return;
};
let track = &self.tracks[target];
(track_type_of(track.kind), track.track_index)
};
// Clip length: the footage's probed duration when available;
// otherwise a 10-second default.
let fps = self.frame_rate();
@@ -5192,7 +5214,9 @@ mod tests {
let seq = graphops::create_sequence(&project, "Round Trip");
let v = graphops::add_track(&project, seq, TrackType::Video).expect("add a video track");
let a = graphops::add_track(&project, seq, TrackType::Audio).expect("add an audio track");
assert_eq!((v, a), (0, 0), "in-memory tracks");
// add_track returns the NEW track's index: with the default 2V+2A
// layout those are the third video and third audio track.
assert_eq!((v, a), (2, 2), "in-memory tracks");
oakundo::global::clear().unwrap();
// Save as uncompressed `.ovexml` (the module serializer reads plain
@@ -5218,7 +5242,11 @@ mod tests {
graphops::track_ids(&guard.graph, sequences[0], TrackType::Audio).len(),
)
};
assert_eq!((video, audio), (1, 1), "the tracks survive the round-trip");
assert_eq!(
(video, audio),
(3, 3),
"the default 2V+2A layout plus the two added tracks survive the round-trip"
);
let _ = std::fs::remove_file(&save_path);
}
@@ -5666,11 +5694,12 @@ mod tests {
assert!(cx.read(|app| engine.read(app).multicam_enabled_on_selection(&[clip_id])));
// The detection (selection → clip → find_multicam) resolves the
// source count from the source sequence's video tracks.
// source count from the source sequence's video tracks (the two
// default video tracks plus the two added for this test).
let state = cx
.read(|app| engine.read(app).multicam_state())
.expect("a selected multicam clip is detected");
assert_eq!(state.source_count, 2, "two video tracks = two angles");
assert_eq!(state.source_count, 4, "default 2 video tracks + 2 added = four angles");
assert_eq!(state.current_source, 0);
// Switch through the UI path (no split: the playhead sits at the
@@ -6069,6 +6098,115 @@ mod tests {
}
}
/// A fresh sequence starts with the default 2 video + 2 audio track
/// layout (user-mandated: V1, V2 on top, A1, A2 below) — and the
/// default layout is NOT an undoable edit (the undo stack stays
// empty for the sequence's birth).
#[gpui::test]
async fn new_sequence_has_default_two_video_two_audio_tracks(cx: &mut gpui::TestAppContext) {
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
let kinds: Vec<TrackKind> =
cx.read(|app| engine.read(app).tracks.iter().map(|t| t.kind).collect());
assert_eq!(
kinds,
vec![
TrackKind::Video,
TrackKind::Video,
TrackKind::Audio,
TrackKind::Audio
],
"a new sequence starts with 2 video + 2 audio tracks"
);
}
/// Dropping a video-with-audio footage places BOTH a video clip and a
/// linked audio clip at the same range in ONE undoable entry (the NLE
/// A/V drop): one undo removes both, and the clips are linked.
#[gpui::test]
async fn drop_av_footage_places_linked_video_and_audio_clips(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)));
// demo.mp4: 1080p H.264 video + AAC audio (+ timecode stream).
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)
})
});
let clip_count = |engine: &RealEngine, kind: TrackKind| -> usize {
engine
.tracks
.iter()
.filter(|t| t.kind == kind)
.map(|t| t.clips.len())
.sum()
};
let (video_clips, audio_clips) = cx.read(|app| {
let engine = engine.read(app);
(clip_count(engine, TrackKind::Video), clip_count(engine, TrackKind::Audio))
});
assert_eq!((video_clips, audio_clips), (1, 1), "one video clip + one audio clip");
// The two clips are linked (grouped edits apply to both).
let (video_block, audio_block) = cx.read(|app| {
let engine = engine.read(app);
let v = engine
.tracks
.iter()
.find(|t| t.kind == TrackKind::Video && !t.clips.is_empty())
.map(|t| t.clips[0].block);
let a = engine
.tracks
.iter()
.find(|t| t.kind == TrackKind::Audio && !t.clips.is_empty())
.map(|t| t.clips[0].block);
(v.expect("video clip"), a.expect("audio clip"))
});
let project = cx.read(|app| engine.read(app).project.clone().expect("project"));
let guard = graphops::lock(&project);
assert!(
guard.graph.links_of(video_block).contains(&audio_block),
"the video clip links to its audio clip"
);
assert!(
guard.graph.links_of(audio_block).contains(&video_block),
"the audio clip links back to its video clip"
);
drop(guard);
// ONE undo removes both clips (a single "Add Clip" entry).
cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx)));
let (video_clips, audio_clips) = cx.read(|app| {
let engine = engine.read(app);
(clip_count(engine, TrackKind::Video), clip_count(engine, TrackKind::Audio))
});
assert_eq!((video_clips, audio_clips), (0, 0), "one undo removes both clips");
cx.update(|app| engine.update(app, |engine, cx| engine.redo(cx)));
let (video_clips, audio_clips) = cx.read(|app| {
let engine = engine.read(app);
(clip_count(engine, TrackKind::Video), clip_count(engine, TrackKind::Audio))
});
assert_eq!((video_clips, audio_clips), (1, 1), "one redo restores both clips");
}
/// 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
+2 -1
View File
@@ -942,7 +942,8 @@ mod tests {
let g = lock(&project);
graphops::track_ids(&g.graph, seq, TrackType::Video)
};
assert_eq!(tracks.len(), 2, "two video tracks");
// The two default video tracks plus the two added here.
assert_eq!(tracks.len(), 4, "default 2 video tracks + 2 added");
// The whole stack sees both clips; the single-track montage sees only
// its own track's clip.
+60 -3
View File
@@ -259,6 +259,12 @@ impl<E: AppEngine> TimelinePanel<E> {
return;
}
match item {
LOCAL_ADD_VIDEO_TRACK => {
self.engine.update(cx, |engine, cx| engine.add_track(TrackKind::Video, cx));
}
LOCAL_ADD_AUDIO_TRACK => {
self.engine.update(cx, |engine, cx| engine.add_track(TrackKind::Audio, cx));
}
LOCAL_DELETE_TRACK => {
if let Some(track) = self.context_track {
self.engine.update(cx, |engine, cx| engine.remove_track(track, cx));
@@ -709,6 +715,45 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
toolbar = toolbar.child(tool_button(index, icon_name, tool_key, cx));
}
// Add-track buttons (the convenient way to create tracks; the track
// header context menu offers the same two entries).
let add_track_btn = |id: &'static str,
key: &'static str,
kind: TrackKind,
cx: &mut Context<Self>| {
let label = i18n::tr(key);
let hover_bg = colors.selected;
div()
.id(id)
.h(px(24.0))
.px_2()
.flex()
.items_center()
.rounded_sm()
.cursor_pointer()
.text_color(colors.text)
.text_xs()
.hover(move |style| style.bg(hover_bg))
.tooltip(move |window, cx| tooltip_view(label.into(), window, cx))
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.engine.update(cx, |engine, cx| engine.add_track(kind, cx));
}))
.child(label)
};
toolbar = toolbar
.child(add_track_btn(
"toolbar-add-video-track",
"timeline.add_video_track",
TrackKind::Video,
cx,
))
.child(add_track_btn(
"toolbar-add-audio-track",
"timeline.add_audio_track",
TrackKind::Audio,
cx,
));
// A plain icon button (no selection state), e.g. zoom in/out.
let icon_btn = |id: &'static str,
icon_name: &'static str,
@@ -909,6 +954,8 @@ const LOCAL_TIMECODE_NON_DROP_FRAME: usize = 2124;
const LOCAL_TIMECODE_SECONDS: usize = 2125;
const LOCAL_TIMECODE_FRAMES: usize = 2126;
const LOCAL_TIMECODE_MILLISECONDS: usize = 2127;
const LOCAL_ADD_VIDEO_TRACK: usize = 2130;
const LOCAL_ADD_AUDIO_TRACK: usize = 2131;
/// A registry-backed item shown under a "Properties" label (the C++ clip
/// and sequence "Properties" entries open the Speed/Duration and Sequence
@@ -1078,7 +1125,9 @@ pub(crate) fn empty_area_menu() -> Menu {
/// every empty track.
pub(crate) fn track_head_menu() -> Menu {
Menu::new(vec![
MenuItem::new(LOCAL_DELETE_TRACK, i18n::tr("timeline.context.delete_track")),
MenuItem::new(LOCAL_ADD_VIDEO_TRACK, i18n::tr("timeline.context.add_video_track")),
MenuItem::new(LOCAL_ADD_AUDIO_TRACK, i18n::tr("timeline.context.add_audio_track")),
MenuItem::new(LOCAL_DELETE_TRACK, i18n::tr("timeline.context.delete_track")).separated(),
MenuItem::new(LOCAL_DELETE_ALL_EMPTY, i18n::tr("timeline.context.delete_all_empty")),
])
}
@@ -1487,13 +1536,21 @@ mod tests {
/// The track-header menu is exactly the two delete entries.
#[test]
fn track_head_menu_is_the_two_delete_entries() {
fn track_head_menu_offers_add_then_delete_entries() {
let ids: Vec<usize> = track_head_menu()
.items
.iter()
.map(|item| item.id)
.collect();
assert_eq!(ids, vec![LOCAL_DELETE_TRACK, LOCAL_DELETE_ALL_EMPTY]);
assert_eq!(
ids,
vec![
LOCAL_ADD_VIDEO_TRACK,
LOCAL_ADD_AUDIO_TRACK,
LOCAL_DELETE_TRACK,
LOCAL_DELETE_ALL_EMPTY
]
);
}
/// The marker menu pairs the color labels with the plain edit section