diff --git a/docs/screenshot-manager-en.png b/docs/screenshot-manager-en.png index b8c0e5f8b..000a9b313 100644 Binary files a/docs/screenshot-manager-en.png and b/docs/screenshot-manager-en.png differ diff --git a/docs/screenshot-manager.png b/docs/screenshot-manager.png index f0ebe05d3..ece4a9f33 100644 Binary files a/docs/screenshot-manager.png and b/docs/screenshot-manager.png differ diff --git a/docs/screenshot-preferences-en.png b/docs/screenshot-preferences-en.png index 8243c41ce..5cde3e6bb 100644 Binary files a/docs/screenshot-preferences-en.png and b/docs/screenshot-preferences-en.png differ diff --git a/docs/screenshot-preferences.png b/docs/screenshot-preferences.png index ee0e1bc6b..8ffa51390 100644 Binary files a/docs/screenshot-preferences.png and b/docs/screenshot-preferences.png differ diff --git a/docs/screenshot-window-en.png b/docs/screenshot-window-en.png index 47ece0b6b..3ee317681 100644 Binary files a/docs/screenshot-window-en.png and b/docs/screenshot-window-en.png differ diff --git a/docs/screenshot-window.png b/docs/screenshot-window.png index 376cc9287..0935d3d97 100644 Binary files a/docs/screenshot-window.png and b/docs/screenshot-window.png differ diff --git a/src/app.rs b/src/app.rs index c865d6692..de0929364 100644 --- a/src/app.rs +++ b/src/app.rs @@ -57,6 +57,7 @@ use gpui_widgets::viewer::PlaybackClock; use crate::dialogs::{ExportDialogContent, PreferencesContent}; use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine}; +use crate::panels::effect_library::EffectLibraryPanel; use crate::panels::history::HistoryPanel; use crate::panels::ids::*; use crate::panels::inspector::InspectorPanel; @@ -121,6 +122,7 @@ pub(crate) mod menu_ids { pub const FOCUS_INSPECTOR: usize = 605; pub const FOCUS_HISTORY: usize = 606; pub const FOCUS_TIMELINE: usize = 607; + pub const FOCUS_EFFECT_LIBRARY: usize = 608; pub const ABOUT: usize = 801; } @@ -219,6 +221,7 @@ impl PanelRegistry for AppPanelRegistry { INSPECTOR => "inspector", HISTORY => "history", TIMELINE => "timeline", + EFFECT_LIBRARY => "effect-library", _ => return None, } .to_string(), @@ -270,6 +273,10 @@ impl PanelRegistry for AppPanelRegistry { cx.new(|cx| HistoryPanel::new(window, cx)), cx, )), + "effect-library" => Some(PanelHandle::new( + cx.new(|cx| EffectLibraryPanel::new(self.engine.clone(), window, cx)), + cx, + )), "timeline" => Some(PanelHandle::new( cx.new(|cx| { let timeline = @@ -360,6 +367,7 @@ impl OakApp { }); let project = cx.new(|cx| ProjectExplorerPanel::new(engine.clone(), window, cx)); + let effect_library = cx.new(|cx| EffectLibraryPanel::new(engine.clone(), window, cx)); let source_viewer = cx.new(|cx| SourceViewerPanel::new(engine.clone(), source_clock.clone(), window, cx)); let program_viewer = cx.new(|cx| { @@ -382,6 +390,16 @@ impl OakApp { // as tabs, timeline full width at the bottom. dock.update(cx, |dock, cx| { dock.add_panel(PanelHandle::new(project, cx), None, cx); + // The effect library tabs behind the project bin, per the + // design's left-top tab group (项目 | 效果库). + dock.add_panel( + PanelHandle::new(effect_library, cx), + Some(DropTarget { + panel: Some(PROJECT), + zone: DropZone::Center, + }), + cx, + ); dock.add_panel( PanelHandle::new(source_viewer, cx), Some(DropTarget { @@ -453,6 +471,11 @@ impl OakApp { if let Some(path) = layout.find_panel(PROGRAM_VIEWER) { layout.set_tabs_active(&path, PROGRAM_VIEWER); } + // The project bin is the active tab of its group (the effect library + // sits behind it). + if let Some(path) = layout.find_panel(PROJECT) { + layout.set_tabs_active(&path, PROJECT); + } dock.update(cx, |dock, cx| dock.set_layout(layout, cx)); // --- status bar ---------------------------------------------------- @@ -661,6 +684,7 @@ impl OakApp { FOCUS_INSPECTOR => self.focus_panel(INSPECTOR, cx), FOCUS_HISTORY => self.focus_panel(HISTORY, cx), FOCUS_TIMELINE => self.focus_panel(TIMELINE, cx), + FOCUS_EFFECT_LIBRARY => self.focus_panel(EFFECT_LIBRARY, cx), other => println!("[menu] placeholder action for item {other}"), } } @@ -1657,6 +1681,7 @@ fn make_menus(dark: bool) -> Vec { MenuItem::new(FOCUS_INSPECTOR, tr("menu.window.inspector")), MenuItem::new(FOCUS_HISTORY, tr("menu.window.history")), MenuItem::new(FOCUS_TIMELINE, tr("menu.window.timeline")), + MenuItem::new(FOCUS_EFFECT_LIBRARY, tr("menu.window.effect_library")), ]), ), MenuBarEntry::new( diff --git a/src/i18n.rs b/src/i18n.rs index accb24a2d..2088ca128 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -234,6 +234,7 @@ const EN: &[(&str, &str)] = &[ ("menu.window.inspector", "Inspector"), ("menu.window.history", "History"), ("menu.window.timeline", "Timeline"), + ("menu.window.effect_library", "Effect Library"), // --- Tools --- ("menu.tools.select", "Select"), ("menu.tools.razor", "Razor"), @@ -248,6 +249,9 @@ const EN: &[(&str, &str)] = &[ ("panel.inspector", "Inspector"), ("panel.history", "History"), ("panel.timeline", "Timeline"), + ("panel.effect_library", "Effect Library"), + // --- effect library --- + ("effect_library.hint", "Double-click to add to the selected clip"), // --- status bar --- ("status.ready", "Ready"), ("status.cache", "Cache: Enabled"), @@ -438,6 +442,7 @@ const ZH: &[(&str, &str)] = &[ ("menu.window.inspector", "检查器"), ("menu.window.history", "历史记录"), ("menu.window.timeline", "时间线"), + ("menu.window.effect_library", "效果库"), // --- Tools --- ("menu.tools.select", "选择"), ("menu.tools.razor", "剃刀"), @@ -452,6 +457,9 @@ const ZH: &[(&str, &str)] = &[ ("panel.inspector", "检查器"), ("panel.history", "历史记录"), ("panel.timeline", "时间线"), + ("panel.effect_library", "效果库"), + // --- effect library --- + ("effect_library.hint", "双击添加到选中片段"), // --- status bar --- ("status.ready", "就绪"), ("status.cache", "缓存:已启用"), diff --git a/src/oakui/graphops.rs b/src/oakui/graphops.rs index ac0a04f8d..0a079aeed 100644 --- a/src/oakui/graphops.rs +++ b/src/oakui/graphops.rs @@ -1012,6 +1012,98 @@ pub fn track_height(p: &ProjectRef, track: NodeId) -> Option { track_behavior(&g.graph, track).map(|t| t.height) } +/// A track's muted flag (`None` when the id is stale). Muted means +/// "silenced" on audio tracks and "hidden" on video/subtitle tracks +/// (Olive parity: one flag drives both). +pub fn track_muted(p: &ProjectRef, track: NodeId) -> Option { + let g = lock(p); + track_behavior(&g.graph, track).map(|t| t.muted) +} + +/// A track's locked flag (`None` when the id is stale). +pub fn track_locked(p: &ProjectRef, track: NodeId) -> Option { + let g = lock(p); + track_behavior(&g.graph, track).map(|t| t.locked) +} + +/// An undoable track-flag set (the closures capture the previous value, so +/// the undo restores it exactly). Shared by the mute/hide and lock toggles. +fn set_track_flag( + p: &ProjectRef, + track: NodeId, + field: TrackFlag, + value: bool, + name: &str, +) -> Result<(), String> { + let old = { + let g = lock(p); + let t = track_behavior(&g.graph, track) + .ok_or_else(|| "set track flag: the node is not a track".to_string())?; + match field { + TrackFlag::Muted => t.muted, + TrackFlag::Locked => t.locked, + } + }; + if old == value { + return Ok(()); + } + let (p1, p2) = (p.clone(), p.clone()); + push( + oakundo::undocommand::UndoCommand::from_closures( + move || { + let mut g = lock(&p1); + if let Some(t) = g + .graph + .get_mut(track) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + match field { + TrackFlag::Muted => t.muted = value, + TrackFlag::Locked => t.locked = value, + } + } + }, + move || { + let mut g = lock(&p2); + if let Some(t) = g + .graph + .get_mut(track) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + match field { + TrackFlag::Muted => t.muted = old, + TrackFlag::Locked => t.locked = old, + } + } + }, + ), + name, + ) +} + +/// The track flags the undoable setters cover. +#[derive(Clone, Copy)] +enum TrackFlag { + /// Muted (audio) / hidden (video, subtitle). + Muted, + /// Locked against clip edits. + Locked, +} + +/// Set a track's muted flag (undoable "Set Track Muted"). On video and +/// subtitle tracks this is the visibility (show/hide) toggle. +pub fn set_track_muted(p: &ProjectRef, track: NodeId, muted: bool) -> Result<(), String> { + set_track_flag(p, track, TrackFlag::Muted, muted, "Set Track Muted") +} + +/// Set a track's locked flag (undoable "Set Track Locked"). Locked tracks +/// reject clip edits (the app layer refuses trim/move/split/delete). +pub fn set_track_locked(p: &ProjectRef, track: NodeId, locked: bool) -> Result<(), String> { + set_track_flag(p, track, TrackFlag::Locked, locked, "Set Track Locked") +} + /// Set a track's height in internal units (NOT undoable, mirroring the /// facade's `oakengine_track_set_height`). pub fn set_track_height(p: &ProjectRef, track: NodeId, height: f64) { @@ -1454,3 +1546,75 @@ pub fn test_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); LOCK.lock().unwrap_or_else(|e| e.into_inner()) } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// A project with one sequence holding one track of `kind`; returns the + /// project, the sequence and the track's node id. + fn project_with_track(kind: TrackType) -> (ProjectRef, NodeId, NodeId) { + let project = create_project(); + let seq = create_sequence(&project, "Seq"); + let index = add_track(&project, seq, kind).expect("add a track"); + let track = { + let g = lock(&project); + track_ids(&g.graph, seq, kind)[index] + }; + (project, seq, track) + } + + /// The track flag setters flip the flag as ONE undoable entry each; + /// undo restores the previous value and redo re-applies. + #[test] + fn track_flag_setters_toggle_and_undo() { + let _g = test_lock(); + oakundo::global::clear().unwrap(); + let (project, _seq, track) = project_with_track(TrackType::Video); + + assert_eq!(track_muted(&project, track), Some(false)); + assert_eq!(track_locked(&project, track), Some(false)); + + set_track_muted(&project, track, true).expect("mute the track"); + assert_eq!(track_muted(&project, track), Some(true)); + set_track_locked(&project, track, true).expect("lock the track"); + assert_eq!(track_locked(&project, track), Some(true)); + + oakundo::global::undo().unwrap(); + assert_eq!(track_locked(&project, track), Some(false)); + oakundo::global::undo().unwrap(); + assert_eq!(track_muted(&project, track), Some(false)); + oakundo::global::redo().unwrap(); + assert_eq!(track_muted(&project, track), Some(true)); + oakundo::global::clear().unwrap(); + } + + /// Setting a flag to its current value pushes no undo row. + #[test] + fn track_flag_setter_noop_when_unchanged() { + let _g = test_lock(); + oakundo::global::clear().unwrap(); + let (project, _seq, track) = project_with_track(TrackType::Audio); + let before = oakundo::global::count().unwrap(); + + set_track_muted(&project, track, false).expect("mute already false"); + set_track_locked(&project, track, false).expect("lock already false"); + assert_eq!(oakundo::global::count().unwrap(), before); + oakundo::global::clear().unwrap(); + } + + /// A stale (non-track) id is rejected, not silently ignored. + #[test] + fn track_flag_setters_reject_non_tracks() { + let _g = test_lock(); + oakundo::global::clear().unwrap(); + let (project, seq, _track) = project_with_track(TrackType::Video); + assert!(set_track_muted(&project, seq, true).is_err()); + assert!(set_track_locked(&project, seq, true).is_err()); + oakundo::global::clear().unwrap(); + } +} diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 1b9ba62fc..d4dca39ff 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -1205,6 +1205,41 @@ impl AppEngine for MockEngine { self.apply_effect_event(event, cx); } + fn addable_effects(&self) -> Vec<(String, String)> { + // The demo list is the real factory's video-effect table, so the + // effect library shows the same entries the real engine would. + crate::oakui::effectchain::addable_effects() + } + + fn add_effect( + &mut self, + index: usize, + type_id: &str, + cx: &mut Context, + ) -> Result<(), String> { + let Some((_, name)) = crate::oakui::effectchain::addable_effects() + .into_iter() + .find(|(id, _)| id == type_id) + else { + return Err(format!("unknown effect \"{type_id}\"")); + }; + let id = EffectId(self.next_effect_id); + self.next_effect_id += 1; + let card = MockEffect { + id, + kind: EffectCardKind::Effect, + title: name.into(), + subtitle: None, + enabled: true, + expanded: false, + badge: None, + }; + let index = index.min(self.effects.len()); + self.effects.insert(index, card); + cx.notify(); + Ok(()) + } + fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context) { self.apply_node_graph_event(event, cx); } @@ -1223,6 +1258,9 @@ impl AppEngine for MockEngine { new_frame, } => { if let Some((track, index)) = self.mock_clip_position(*clip) { + if self.tracks[track].locked { + return; + } let clip = &mut self.tracks[track].clips[index]; match edge { gpui::timeline::TrimEdge::Start => { @@ -1245,6 +1283,11 @@ impl AppEngine for MockEngine { let Some((track, index)) = self.mock_clip_position(*clip) else { return; }; + if self.tracks[track].locked + || self.tracks.get(*new_track).map(|t| t.locked).unwrap_or(true) + { + return; + } let mut clip = self.tracks[track].clips.remove(index); let length = clip.range.end.0 - clip.range.start.0; clip.range = FrameRange::new(*new_start, Frame(new_start.0 + length)); @@ -1263,6 +1306,30 @@ impl AppEngine for MockEngine { | TimelineEvent::TrackSelected { .. } | TimelineEvent::TransitionChanged { .. } | TimelineEvent::ZoomChanged(_) => {} + TimelineEvent::TrackToggleRequested { track, toggle } => { + // The demo model applies the toggles directly (no undo in + // mock mode); the muted flag doubles as video visibility, + // mirroring the real engine. + let Some(track) = self.tracks.get_mut(*track) else { + return; + }; + match toggle { + gpui::timeline::TrackHeaderEvent::ToggleLock => { + track.locked = !track.locked; + } + gpui::timeline::TrackHeaderEvent::ToggleMute => { + track.muted = !track.muted; + } + gpui::timeline::TrackHeaderEvent::ToggleSolo => { + track.solo = !track.solo; + } + gpui::timeline::TrackHeaderEvent::ToggleVisibility => { + track.visible = !track.visible; + track.muted = !track.visible; + } + } + cx.notify(); + } TimelineEvent::WorkAreaPreview { start, end } => { self.set_workarea_preview(*start, *end, cx); } @@ -1281,6 +1348,9 @@ impl AppEngine for MockEngine { let Some((track, index)) = self.mock_clip_position(clip) else { return; }; + if self.tracks[track].locked { + return; + } self.split_mock_clip(track, index, time); cx.notify(); } @@ -1291,6 +1361,7 @@ impl AppEngine for MockEngine { .tracks .iter() .enumerate() + .filter(|(_, track)| !track.locked) .flat_map(|(track_index, track)| { track .clips @@ -1311,6 +1382,9 @@ impl AppEngine for MockEngine { let Some((track, index)) = self.mock_clip_position(clip) else { return; }; + if self.tracks[track].locked { + return; + } let removed = self.tracks[track].clips.remove(index); if ripple { // Shift the following clips on the same track left by the removed @@ -1994,10 +2068,131 @@ mod tests { } #[gpui::test] - async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) { + async fn track_toggle_requests_flip_the_track_flags(cx: &mut TestAppContext) { + use gpui::timeline::TrackHeaderEvent; cx.update(|app| { let engine = demo_engine(app); + // V1 (display index 1) starts unlocked + visible. + let v1 = engine.read(app).track(1).expect("V1"); + assert!(!v1.is_locked()); + assert!(v1.is_visible()); + + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::TrackToggleRequested { + track: 1, + toggle: TrackHeaderEvent::ToggleLock, + }, + cx, + ); + }); + assert!(engine.read(app).track(1).expect("V1").is_locked()); + + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::TrackToggleRequested { + track: 1, + toggle: TrackHeaderEvent::ToggleVisibility, + }, + cx, + ); + }); + let v1 = engine.read(app).track(1).expect("V1"); + assert!(!v1.is_visible(), "the visibility toggle hides the track"); + + // A1 (display index 2) mutes. + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::TrackToggleRequested { + track: 2, + toggle: TrackHeaderEvent::ToggleMute, + }, + cx, + ); + }); + assert!(engine.read(app).track(2).expect("A1").is_muted()); + }); + } + + #[gpui::test] + async fn locked_tracks_reject_clip_edits(cx: &mut TestAppContext) { + use gpui::timeline::TrackHeaderEvent; + cx.update(|app| { + let engine = demo_engine(app); + + // Lock V1 (the B-roll track, display index 1). + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::TrackToggleRequested { + track: 1, + toggle: TrackHeaderEvent::ToggleLock, + }, + cx, + ); + }); + + // Trim, move and delete are all refused. + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::ClipTrimRequested { + clip: ClipId(12), + edge: TrimEdge::Start, + new_frame: Frame(280), + }, + cx, + ); + engine.apply_timeline_event( + &TimelineEvent::ClipMoveRequested { + clip: ClipId(12), + new_track: 0, + new_start: Frame(300), + }, + cx, + ); + engine.delete_clip(ClipId(12), false, cx); + }); + let v1 = engine.read(app).track(1).expect("V1"); + let b_roll = v1 + .clips() + .iter() + .find(|c| c.id() == ClipId(12)) + .expect("B-roll survives the rejected delete"); + assert_eq!( + b_roll.range(), + FrameRange::new(Frame(240), Frame(600)), + "trim and move were rejected" + ); + }); + } + + #[gpui::test] + async fn add_effect_appends_a_named_card(cx: &mut TestAppContext) { + cx.update(|app| { + let engine = demo_engine(app); + let effects = engine.read(app).addable_effects(); + assert!(!effects.is_empty(), "the demo list is the factory table"); + let (type_id, name) = effects[0].clone(); + let before = engine.read(app).effects().len(); + engine.update(app, |engine, cx| { + engine + .add_effect(usize::MAX, &type_id, cx) + .expect("add the effect"); + }); + let stack = engine.read(app).effects(); + assert_eq!(stack.len(), before + 1); + assert_eq!( + stack.last().map(|e| e.title().to_string()).as_deref(), + Some(name.as_str()), + "the card was appended at the chain end" + ); + }); + } + + #[gpui::test] + async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) { cx.update(|app| { + let engine = demo_engine(app); + // Park the program playhead inside 开场 (0–240) and split there. engine.update(app, |engine, cx| { engine.request_frame(Monitor::Program, Frame(120), cx); diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 1f72398b7..5d8128060 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -71,7 +71,7 @@ use gpui::effect_stack::{ use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent}; use gpui::timeline::{ ClipData, ClipId, Frame, FrameRange, FrameRate, Marker, TimelineDataSource, TimelineEvent, - TrackData, TrackKind, TrimEdge, + TrackData, TrackHeaderEvent, TrackKind, TrimEdge, }; use gpui::{prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString}; use gpui_widgets::audio_meter::AudioMeterDataSource; @@ -1134,14 +1134,19 @@ impl RealEngine { }) }) .collect(); + // The muted flag doubles as the video/subtitle visibility toggle + // (Olive parity: the eye button flips `muted`). + let (muted, locked) = graphops::track_behavior(graph, track) + .map(|t| (t.muted, t.locked)) + .unwrap_or((false, false)); RealTrack { kind: track_kind_of(kind), name: name.into(), height, - locked: false, - muted: false, + locked, + muted, solo: false, - visible: true, + visible: !muted, clips, track, track_index, @@ -1197,6 +1202,19 @@ impl RealEngine { .then_some(block) } + /// Whether the track hosting clip `block` is locked (locked tracks + /// reject every clip edit: trim, move, split, delete). + fn clip_track_locked(&self, block: NodeId) -> bool { + let Some(project) = self.project_ref() else { + return false; + }; + let guard = graphops::lock(project); + graphops::clip_track(&guard.graph, block) + .and_then(|track| graphops::track_behavior(&guard.graph, track)) + .map(|t| t.locked) + .unwrap_or(false) + } + /// The selected clip's block node, or `None` when no single clip is /// selected. fn selected_clip_node(&self) -> Option { @@ -1750,6 +1768,9 @@ impl AppEngine for RealEngine { let Some(block) = self.clip_block(*clip) else { return; }; + if self.clip_track_locked(block) { + return; + } let Some(project) = self.project.clone() else { return; }; @@ -1786,6 +1807,9 @@ impl AppEngine for RealEngine { let Some(block) = self.clip_block(*clip) else { return; }; + if self.clip_track_locked(block) { + return; + } let Some(project) = self.project.clone() else { return; }; @@ -1818,6 +1842,30 @@ impl AppEngine for RealEngine { | TimelineEvent::TrackSelected { .. } | TimelineEvent::TransitionChanged { .. } | TimelineEvent::ZoomChanged(_) => {} + TimelineEvent::TrackToggleRequested { track, toggle } => { + // The header toggles map onto the undoable track flag + // setters. The muted flag doubles as the video/subtitle + // visibility toggle (Olive parity); the model has no solo + // flag yet, so solo requests are inert. + let (Some(t), Some(project)) = + (self.tracks.get(*track), self.project.clone()) + else { + return; + }; + let result = match toggle { + TrackHeaderEvent::ToggleLock => { + graphops::set_track_locked(&project, t.track, !t.locked) + } + TrackHeaderEvent::ToggleMute => { + graphops::set_track_muted(&project, t.track, !t.muted) + } + TrackHeaderEvent::ToggleVisibility => { + graphops::set_track_muted(&project, t.track, t.visible) + } + TrackHeaderEvent::ToggleSolo => Ok(()), + }; + self.apply_edit(result, "toggle track flag", cx); + } TimelineEvent::WorkAreaPreview { start, end } => { self.set_workarea_preview(*start, *end, cx); } @@ -1836,6 +1884,9 @@ impl AppEngine for RealEngine { let (Some(block), Some(project)) = (self.clip_block(clip), self.project.clone()) else { return; }; + if self.clip_track_locked(block) { + return; + } let result = graphops::split_clip(&project, block, time.0); self.apply_edit(result, "split clip", cx); } @@ -1848,6 +1899,7 @@ impl AppEngine for RealEngine { let targets: Vec = self .tracks .iter() + .filter(|track| !track.locked) .flat_map(|track| { track.clips.iter().filter_map(|clip| { if clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0 { @@ -1970,6 +2022,9 @@ impl AppEngine for RealEngine { let (Some(block), Some(project)) = (self.clip_block(clip), self.project.clone()) else { return; }; + if self.clip_track_locked(block) { + return; + } let result = if ripple { graphops::ripple_delete_clip(&project, block) } else { @@ -3028,6 +3083,52 @@ mod tests { let _ = std::fs::remove_file(&media); } + /// Track header toggles through the app seam: a `TrackToggleRequested` + /// event lands as ONE undoable engine command, the timeline snapshot + /// reflects the new flag, and undo restores it. Visibility maps onto the + /// track's muted flag (Olive parity). + #[gpui::test] + async fn real_engine_track_toggles_are_undoable(cx: &mut gpui::TestAppContext) { + use gpui::timeline::TrackHeaderEvent; + 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))); + cx.update(|app| engine.update(app, |engine, cx| engine.add_track(TrackKind::Video, cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.add_track(TrackKind::Audio, cx))); + + // Display order: video tracks first (index 0), then audio (index 1). + assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_visible())); + assert!(!cx.read(|app| engine.read(app).track(1).expect("A1").is_muted())); + + let toggle = |cx: &mut gpui::TestAppContext, track: usize, t: TrackHeaderEvent| { + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::TrackToggleRequested { track, toggle: t }, + cx, + ); + }) + }); + }; + toggle(cx, 0, TrackHeaderEvent::ToggleVisibility); + assert!( + !cx.read(|app| engine.read(app).track(0).expect("V1").is_visible()), + "the visibility toggle hides the video track" + ); + toggle(cx, 0, TrackHeaderEvent::ToggleLock); + assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_locked())); + toggle(cx, 1, TrackHeaderEvent::ToggleMute); + assert!(cx.read(|app| engine.read(app).track(1).expect("A1").is_muted())); + + // Three toggle commands, three undos. + cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); + assert!(!cx.read(|app| engine.read(app).track(1).expect("A1").is_muted())); + cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); + assert!(!cx.read(|app| engine.read(app).track(0).expect("V1").is_locked())); + cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); + assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_visible())); + } + /// M12 P2 acceptance: a real project with a sequence + footage clip /// builds a NON-EMPTY node graph with the wires the node editor shows: /// the footage feeds the clip's `tex_in` (a real edge), and every clip diff --git a/src/oakui/renderops.rs b/src/oakui/renderops.rs index c7a8668b9..0832bd3a5 100644 --- a/src/oakui/renderops.rs +++ b/src/oakui/renderops.rs @@ -74,7 +74,8 @@ fn clip_media(g: &oaknode::graph::Graph, block_id: NodeId) -> Option { /// The video montage at sequence time `time`: every clip covering `time` /// on video tracks, ordered bottom-to-top (track index 0 is topmost, so -/// it is composited last). +/// it is composited last). Hidden tracks (the muted flag doubles as the +/// video visibility toggle, Olive parity) contribute nothing. pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec { let g = lock(p); let mut clips = Vec::new(); @@ -92,6 +93,9 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec Vec Vec { let g = lock(p); let mut clips = Vec::new(); @@ -137,6 +142,9 @@ pub fn audio_montage(p: &ProjectRef, seq: NodeId, range: TimeRange) -> Vec