From dffa94127adc037d35f6a6ea479ebc5237d8eb59 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 19 Aug 2026 15:21:54 +0800 Subject: [PATCH] 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. --- src/i18n.rs | 8 ++ src/oakui/graphops.rs | 119 ++++++++++++++++++++- src/oakui/real.rs | 232 ++++++++++++++++++++++++++++++++--------- src/oakui/renderops.rs | 3 +- src/panels/timeline.rs | 63 ++++++++++- 5 files changed, 373 insertions(+), 52 deletions(-) diff --git a/src/i18n.rs b/src/i18n.rs index 42bbd3f0d..4dd503269 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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", "丢帧"), diff --git a/src/oakui/graphops.rs b/src/oakui/graphops.rs index 58f44e4bc..4eeda674b 100644 --- a/src/oakui/graphops.rs +++ b/src/oakui/graphops.rs @@ -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, 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::()) + { + 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 = { + 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`). diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 1147cef50..15cba5c5e 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -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| { + 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 = + 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 diff --git a/src/oakui/renderops.rs b/src/oakui/renderops.rs index 230500372..87a041ef2 100644 --- a/src/oakui/renderops.rs +++ b/src/oakui/renderops.rs @@ -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. diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index 457e3ff40..0942a52d9 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -259,6 +259,12 @@ impl TimelinePanel { 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 Render for TimelinePanel { 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| { + 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 = 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