diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 23a0dccd4b..966ce601f7 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -720,10 +720,12 @@ impl EditorWindow { return; } self.edit_project("clip-delete", window, cx, move |project| { - project - .timeline - .as_mut() - .is_some_and(|timeline| edits::delete_clip_segments(timeline, &[index])) + let locked = project.locked_tracks.clone(); + project.timeline.as_mut().is_some_and(|timeline| { + edits::with_locked_tracks(timeline, &locked, |timeline| { + edits::delete_clip_segments(timeline, &[index]) + }) + }) }); self.set_selection(None, cx); } @@ -793,10 +795,12 @@ impl EditorWindow { } self.edit_project("clip-reorder", window, cx, move |project| { - project - .timeline - .as_mut() - .is_some_and(|timeline| move_clip(timeline, from, insertion)) + let locked = project.locked_tracks.clone(); + project.timeline.as_mut().is_some_and(|timeline| { + crate::editor_edits::with_locked_tracks(timeline, &locked, |timeline| { + move_clip(timeline, from, insertion) + }) + }) }); self.set_selection(None, cx); } @@ -4105,6 +4109,36 @@ mod tests { } } + /// A locked track keeps its segments where they were even when a reorder + /// drops a transition and ripples everything else. + #[test] + fn reordering_clips_leaves_locked_tracks_in_place() { + let mut config = timeline(vec![ + segment(0, 0.0, 10.0), + segment(1, 0.0, 10.0), + segment(2, 0.0, 10.0), + ]); + config.transitions = vec![transition(1, 1.0)]; + config.zoom_segments = vec![zoom_at(15.0, 18.0)]; + config.style_segments.push(cap_project::StyleSegment { + start: 15., + end: 18., + ..Default::default() + }); + + let locked = [cap_project::LockableTimelineTrack::Zoom]; + assert!(crate::editor_edits::with_locked_tracks( + &mut config, + &locked, + |timeline| move_clip(timeline, 0, 3) + )); + assert_eq!(config.zoom_segments[0].start, 15.0, "locked zoom stays"); + assert!( + config.style_segments[0].start > 15.0, + "unlocked style ripples" + ); + } + #[test] fn adding_a_transition_overlaps_the_clips_and_ripples_the_other_tracks() { let mut config = timeline(vec![segment(0, 0.0, 10.0), segment(1, 0.0, 10.0)]); diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index 13631e4537..b10da2b154 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -27,9 +27,10 @@ use cap_project::{ AudioTrackSegment, Camera3DProperties, Camera3DSegment, CaptionTrackSegment, - ClipSpeedAudioMode, CursorClickEvent, GlideDirection, KeyboardTrackSegment, MaskKind, - MaskSegment, OverlayTrack, ProjectConfiguration, SceneMode, SceneSegment, TextSegment, - TimelineConfiguration, TimelineSegment, XY, ZoomMode, ZoomSegment, mask_effect_contract, + ClipSpeedAudioMode, CursorClickEvent, GlideDirection, KeyboardTrackSegment, + LockableTimelineTrack, MaskKind, MaskSegment, OverlayTrack, ProjectConfiguration, SceneMode, + SceneSegment, TextSegment, TimelineConfiguration, TimelineSegment, XY, ZoomMode, ZoomSegment, + mask_effect_contract, }; use crate::editor_timeline::{self, Segment, TrackKind}; @@ -1200,6 +1201,60 @@ pub fn reorder_track_lane( } } +/// Runs a clip edit that ripples the other tracks, then puts the segments of +/// every locked track back where they were, so locked tracks keep their +/// timing when clips are deleted, merged, retimed or reordered. +pub fn with_locked_tracks( + timeline: &mut TimelineConfiguration, + locked: &[LockableTimelineTrack], + edit: impl FnOnce(&mut TimelineConfiguration) -> bool, +) -> bool { + if locked.is_empty() { + return edit(timeline); + } + let saved = timeline.clone(); + if !edit(timeline) { + return false; + } + for track in locked { + match track { + LockableTimelineTrack::Style => timeline.style_segments = saved.style_segments.clone(), + LockableTimelineTrack::Image => timeline.image_segments = saved.image_segments.clone(), + LockableTimelineTrack::Caption => { + timeline.caption_segments = saved.caption_segments.clone() + } + LockableTimelineTrack::Keyboard => { + timeline.keyboard_segments = saved.keyboard_segments.clone() + } + LockableTimelineTrack::Text => timeline.text_segments = saved.text_segments.clone(), + LockableTimelineTrack::Zoom => timeline.zoom_segments = saved.zoom_segments.clone(), + LockableTimelineTrack::Scene => timeline.scene_segments = saved.scene_segments.clone(), + LockableTimelineTrack::Mask => timeline.mask_segments = saved.mask_segments.clone(), + LockableTimelineTrack::Audio => timeline.audio_segments = saved.audio_segments.clone(), + LockableTimelineTrack::Camera3d => { + timeline.camera3d_segments = saved.camera3d_segments.clone() + } + } + } + true +} + +pub fn lockable_track(kind: TrackKind) -> Option { + Some(match kind { + TrackKind::Clip => return None, + TrackKind::Style => LockableTimelineTrack::Style, + TrackKind::Image => LockableTimelineTrack::Image, + TrackKind::Caption => LockableTimelineTrack::Caption, + TrackKind::Keyboard => LockableTimelineTrack::Keyboard, + TrackKind::Text => LockableTimelineTrack::Text, + TrackKind::Zoom => LockableTimelineTrack::Zoom, + TrackKind::Scene => LockableTimelineTrack::Scene, + TrackKind::Mask => LockableTimelineTrack::Mask, + TrackKind::Audio => LockableTimelineTrack::Audio, + TrackKind::ThreeD => LockableTimelineTrack::Camera3d, + }) +} + /// `deleteClipSegment` (`ED/context.ts:581-600`), including the guard that /// makes it the one track a selection cannot empty: **the last clip cannot be /// deleted**. The Delete binding sorts descending and deletes one at a time @@ -4576,6 +4631,43 @@ mod tests { assert_eq!(audio[0].trim_start, 3.0); } + #[test] + fn deleting_a_clip_leaves_locked_tracks_in_place() { + let config = config(serde_json::json!({ + "timeline": { + "segments": [ + { "recordingSegment": 0, "timescale": 1.0, "start": 0.0, "end": 10.0 }, + { "recordingSegment": 0, "timescale": 1.0, "start": 10.0, "end": 20.0 } + ], + "zoomSegments": [{ "start": 12.0, "end": 14.0, "amount": 2.0, "mode": "auto" }], + "sceneSegments": [{ "start": 12.0, "end": 14.0, "mode": "cameraOnly" }] + } + })); + let mut timeline = config.timeline.unwrap(); + + assert!(with_locked_tracks( + &mut timeline, + &[LockableTimelineTrack::Zoom], + |timeline| delete_clip_segments(timeline, &[0]), + )); + + assert_eq!(timeline.segments.len(), 1); + assert_eq!( + ( + timeline.zoom_segments[0].start, + timeline.zoom_segments[0].end + ), + (12.0, 14.0) + ); + assert_eq!( + ( + timeline.scene_segments[0].start, + timeline.scene_segments[0].end + ), + (2.0, 4.0) + ); + } + #[test] fn deleting_a_masks_only_lane_renumbers_the_lanes_above_it() { let mut config = config(serde_json::json!({ diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 64edb658fe..cd0f7a6772 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -6203,8 +6203,17 @@ impl EditorWindow { cx, ) } else { + let locked = if selection.track == TrackKind::Clip { + self.project.locked_tracks.clone() + } else { + Vec::new() + }; self.edit( - |timeline| edits::delete_segments(timeline, selection.track, &selection.indices), + |timeline| { + edits::with_locked_tracks(timeline, &locked, |timeline| { + edits::delete_segments(timeline, selection.track, &selection.indices) + }) + }, window, cx, ) @@ -8339,10 +8348,13 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { + let locked = self.project.locked_tracks.clone(); let Some(timeline) = self.project.timeline.as_mut() else { return; }; - if !edits::set_clip_segment_timescale(timeline, index, timescale) { + if !edits::with_locked_tracks(timeline, &locked, |timeline| { + edits::set_clip_segment_timescale(timeline, index, timescale) + }) { return; } self.project_changed(window, cx); @@ -8452,10 +8464,13 @@ impl EditorWindow { ) { self.clip_speed = None; let mut merged = None; + let locked = self.project.locked_tracks.clone(); let changed = self.edit( |timeline| { - merged = edits::merge_clip_segments(timeline, index, direction); - merged.is_some() + edits::with_locked_tracks(timeline, &locked, |timeline| { + merged = edits::merge_clip_segments(timeline, index, direction); + merged.is_some() + }) }, window, cx, @@ -11572,6 +11587,75 @@ impl EditorWindow { )), ) })) + // A lock covers the whole track, so it sits on the + // track's first lane only. + .children( + edits::lockable_track(kind) + .filter(|_| lane == 0) + .map(|track| { + let locked = self.project.locked_tracks.contains(&track); + let left = if delete_label.is_some() { + timeline::TRACK_GUTTER - 48. + } else { + timeline::TRACK_GUTTER - 24. + }; + div() + .id(gpui::ElementId::NamedInteger( + "track-lock".into(), + (kind as usize as u64) << 16 | lane as u64, + )) + .absolute() + .left(px(left)) + .top(px((model.track_height() - 20.) / 2.)) + .w(px(20.)) + .h(px(20.)) + .when(row_hovered || locked, |this| { + this.cursor_pointer() + .flex() + .items_center() + .justify_center() + .rounded(px(6.)) + .bg(Hsla::from(theme.editor.ctl_active)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.edit_project( + "track-lock", + window, + cx, + move |project| { + if let Some(at) = project + .locked_tracks + .iter() + .position(|t| *t == track) + { + project.locked_tracks.remove(at); + } else { + project.locked_tracks.push(track); + } + true + }, + ); + }), + ) + .child( + svg() + .path(if locked { + "icons/lock.svg" + } else { + "icons/lock-open.svg" + }) + .size(px(13.)) + .text_color(if locked { + Hsla::from(theme.editor.accent) + } else { + Hsla::from(theme.editor.text_3) + }), + ) + }) + }), + ) // The lane's delete: a ghost that only appears with the // row under the pointer, and only turns red under its own. .children(delete_label.map(|_| { diff --git a/apps/desktop-gpui/src/presets.rs b/apps/desktop-gpui/src/presets.rs index a2f746e47a..8b049486a2 100644 --- a/apps/desktop-gpui/src/presets.rs +++ b/apps/desktop-gpui/src/presets.rs @@ -143,6 +143,7 @@ pub fn preset_config(project: &ProjectConfiguration) -> Value { map.insert("timeline".into(), Value::Null); map.insert("clips".into(), Value::Array(Vec::new())); map.remove("overlayOrder"); + map.remove("lockedTracks"); if let Some(captions) = map.get_mut("captions").and_then(Value::as_object_mut) { captions.insert("segments".into(), Value::Array(Vec::new())); captions.remove("sourceTimed"); @@ -263,6 +264,23 @@ mod tests { assert!(applied.timeline.is_some(), "current timeline is kept"); } + #[test] + fn track_locks_stay_with_the_project_not_the_preset() { + let current = ProjectConfiguration { + locked_tracks: vec![cap_project::LockableTimelineTrack::Zoom], + ..Default::default() + }; + let mut preset_source = ProjectConfiguration::default(); + preset_source.locked_tracks = vec![cap_project::LockableTimelineTrack::Text]; + let preset = preset_config(&preset_source); + assert!(preset.get("lockedTracks").is_none(), "presets never store locks"); + + let applied = apply_preset(&preset, ¤t).expect("preset deserializes"); + assert_eq!(applied.locked_tracks, current.locked_tracks); + let unlocked = apply_preset(&preset, &ProjectConfiguration::default()).unwrap(); + assert!(unlocked.locked_tracks.is_empty(), "a preset doesn't lock tracks"); + } + #[test] fn applying_a_legacy_tauri_preset_keeps_text_unchanged_after_reopening() { let store: Value = serde_json::from_str( diff --git a/crates/editor/src/audio.rs b/crates/editor/src/audio.rs index f793e41d67..c36490b281 100644 --- a/crates/editor/src/audio.rs +++ b/crates/editor/src/audio.rs @@ -1954,6 +1954,7 @@ fn audio_project_key(project: &ProjectConfiguration) -> Option> { color_correction: _, text_size_version: _, text_anim_version: _, + locked_tracks: _, } = project; let timeline = timeline.as_ref().map(|timeline| { let TimelineConfiguration { diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index bb0a049109..7ae4660598 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2707,6 +2707,26 @@ pub struct ProjectConfiguration { /// durations from `fade_duration`. #[serde(default)] pub text_anim_version: u32, + /// Timeline tracks that keep their segments in place when clips are + /// deleted, merged, retimed or reordered, instead of rippling. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub locked_tracks: Vec, +} + +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LockableTimelineTrack { + Style, + Image, + Caption, + Keyboard, + Text, + Zoom, + Scene, + Mask, + Audio, + #[serde(rename = "3d")] + Camera3d, } pub const TEXT_SIZE_VERSION: u32 = 1; @@ -2758,6 +2778,7 @@ impl Default for ProjectConfiguration { color_correction: Default::default(), text_size_version: TEXT_SIZE_VERSION, text_anim_version: TEXT_ANIM_VERSION, + locked_tracks: Vec::new(), } } } @@ -2842,6 +2863,7 @@ impl ProjectConfiguration { preset.text_size_version = self.text_size_version; preset.text_anim_version = self.text_anim_version; preset.overlay_order = self.overlay_order.clone(); + preset.locked_tracks = self.locked_tracks.clone(); preset.clips = self.clips.clone(); preset } @@ -3263,6 +3285,16 @@ mod tests { use super::*; use serde_json::Value; + #[test] + fn applying_a_preset_keeps_the_projects_locked_tracks() { + let project = ProjectConfiguration { + locked_tracks: vec![LockableTimelineTrack::Zoom, LockableTimelineTrack::Text], + ..Default::default() + }; + let applied = project.with_preset(ProjectConfiguration::default()); + assert_eq!(applied.locked_tracks, project.locked_tracks); + } + #[test] fn studio_sound_defaults_old_projects_to_balanced_and_round_trips_tiers() { let legacy: AudioConfiguration =