From 22da06bfc3ad653fe4f113361ac90cd4e7e00e9e Mon Sep 17 00:00:00 2001 From: Yoav Levavi Date: Tue, 20 Oct 2026 22:34:33 +0300 Subject: [PATCH 1/3] feat(gpui): lock timeline tracks so clip edits don't move them Locked tracks (project config) keep their segments in place when clips are deleted, merged or retimed. A lock button sits in each track's gutter in the GPUI timeline. Co-Authored-By: Claude Opus 5.5 --- apps/desktop-gpui/assets/icons/lock-open.svg | 1 + apps/desktop-gpui/src/assets.rs | 1 + apps/desktop-gpui/src/editor_clips.rs | 10 +- apps/desktop-gpui/src/editor_edits.rs | 98 +++++++++++++++++++- apps/desktop-gpui/src/editor_window.rs | 86 ++++++++++++++++- crates/project/src/configuration.rs | 21 +++++ 6 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 apps/desktop-gpui/assets/icons/lock-open.svg diff --git a/apps/desktop-gpui/assets/icons/lock-open.svg b/apps/desktop-gpui/assets/icons/lock-open.svg new file mode 100644 index 00000000000..321976c8eba --- /dev/null +++ b/apps/desktop-gpui/assets/icons/lock-open.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 7eac2663f27..054aed4dc41 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -195,6 +195,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": // (gpui's `svg()` keeps only the alpha). "circle-check.svg", "lock.svg", + "lock-open.svg", // The screenshot editor's annotation toolbar, layers panel and header // (`AnnotationTools.tsx`, `LayersPanel.tsx`, `Header.tsx`). Lucide 24x24 // originals, matching the `~icons/lucide/*` imports over there. diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 51c4bfa521d..e3f44e0344e 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -617,10 +617,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); } diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index 3fa8467cba9..3886c82afbb 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}; @@ -1105,6 +1106,60 @@ pub fn reorder_track_lane( /// deleted**. The Delete binding sorts descending and deletes one at a time /// (`TL/index.tsx:993-1000`), so the guard is re-checked per clip and a /// "select all + delete" leaves exactly one behind. +/// 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 or retimed. +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, + }) +} + pub fn delete_clip_segments(timeline: &mut TimelineConfiguration, indices: &[usize]) -> bool { let mut deleted = false; for index in descending_indices(indices, timeline.segments.len()) { @@ -4108,6 +4163,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 e295f24fab6..ec7a2d6f367 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -6090,8 +6090,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, ) @@ -7954,10 +7963,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); @@ -8041,10 +8053,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, @@ -10956,6 +10971,69 @@ impl EditorWindow { )), ) })) + .children(edits::lockable_track(kind).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/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 8b7f2dd2e6e..8507c606611 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2679,6 +2679,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, trimmed, retimed or given transitions, 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; @@ -2730,6 +2750,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(), } } } From 5da66c342df4df488fb294fab2b8b0afe943af60 Mon Sep 17 00:00:00 2001 From: Yoav Levavi Date: Wed, 21 Oct 2026 19:29:57 +0300 Subject: [PATCH 2/3] Track lock: cover clip reordering, one lock per track, fix docs - Reordering clips in the Clips sidebar runs through with_locked_tracks, so a locked track no longer moves when a dropped transition ripples the rest. - The lock is drawn on a track's first lane only, since it locks the track. - with_locked_tracks and lockable_track sit above delete_clip_segments' doc again, and the locked_tracks doc lists the edits actually covered. Co-Authored-By: Claude Opus 5.5 --- apps/desktop-gpui/src/editor_clips.rs | 50 +++++++++- apps/desktop-gpui/src/editor_edits.rs | 12 +-- apps/desktop-gpui/src/editor_window.rs | 130 +++++++++++++------------ crates/project/src/configuration.rs | 2 +- 4 files changed, 121 insertions(+), 73 deletions(-) diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index e3f44e0344e..71078738735 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -692,10 +692,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); } @@ -3975,6 +3977,46 @@ mod tests { assert_eq!(dropped[0].segment_index, 3); } + /// 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)]; + let zoom = cap_project::ZoomSegment { + start: 15.0, + end: 18.0, + amount: 1.5, + mode: cap_project::ZoomMode::Auto, + glide_direction: Default::default(), + glide_speed: 0.5, + instant_animation: false, + edge_snap_ratio: 0.25, + }; + config.zoom_segments = vec![zoom.clone()]; + 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" + ); + } + /// The dropped transition's overlap is given back: every later segment on /// the other tracks shifts right by its effective duration /// (`ClipsSidebar.tsx:656-683`). diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index 3886c82afbb..d24dadc9a57 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -1101,14 +1101,9 @@ pub fn reorder_track_lane( } } -/// `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 -/// (`TL/index.tsx:993-1000`), so the guard is re-checked per clip and a -/// "select all + delete" leaves exactly one behind. /// 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 or retimed. +/// timing when clips are deleted, merged, retimed or reordered. pub fn with_locked_tracks( timeline: &mut TimelineConfiguration, locked: &[LockableTimelineTrack], @@ -1160,6 +1155,11 @@ pub fn lockable_track(kind: TrackKind) -> Option { }) } +/// `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 +/// (`TL/index.tsx:993-1000`), so the guard is re-checked per clip and a +/// "select all + delete" leaves exactly one behind. pub fn delete_clip_segments(timeline: &mut TimelineConfiguration, indices: &[usize]) -> bool { let mut deleted = false; for index in descending_indices(indices, timeline.segments.len()) { diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index ec7a2d6f367..fdccc9bebc7 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -10971,69 +10971,75 @@ impl EditorWindow { )), ) })) - .children(edits::lockable_track(kind).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); + // 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 { - 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) - }), - ) - }) - })) + "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/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 8507c606611..d891a2e1a89 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2680,7 +2680,7 @@ pub struct ProjectConfiguration { #[serde(default)] pub text_anim_version: u32, /// Timeline tracks that keep their segments in place when clips are - /// deleted, trimmed, retimed or given transitions, instead of rippling. + /// deleted, merged, retimed or reordered, instead of rippling. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub locked_tracks: Vec, } From c9165e7983c97f6118f3b3a275942ad7813b6987 Mon Sep 17 00:00:00 2001 From: Yoav Levavi Date: Sun, 25 Oct 2026 16:45:03 +0200 Subject: [PATCH 3/3] Track lock: keep locks out of presets; merge main - preset_config drops lockedTracks, and both apply paths keep the current project's locks, so applying a preset never locks or unlocks tracks. - Merges origin/main; audio_project_key lists locked_tracks as not affecting playback audio. Co-Authored-By: Claude Opus 5.5 --- apps/desktop-gpui/src/editor_window.rs | 1 + apps/desktop-gpui/src/presets.rs | 19 +++++++++++++++++++ crates/editor/src/audio.rs | 1 + 3 files changed, 21 insertions(+) diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index c5912bddf8d..e3612f10cec 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -7233,6 +7233,7 @@ impl EditorWindow { let mut next = crate::presets::default_project_config(); next.timeline = self.project.timeline.clone(); next.overlay_order = self.project.overlay_order.clone(); + next.locked_tracks = self.project.locked_tracks.clone(); next.clips = self.project.clips.clone(); self.apply_project_preset(next, window, cx); } diff --git a/apps/desktop-gpui/src/presets.rs b/apps/desktop-gpui/src/presets.rs index 27c8f3b67b7..8ee5d20d905 100644 --- a/apps/desktop-gpui/src/presets.rs +++ b/apps/desktop-gpui/src/presets.rs @@ -145,6 +145,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"); } value } @@ -161,6 +162,7 @@ pub fn apply_preset( next.text_size_version = current.text_size_version; next.text_anim_version = current.text_anim_version; next.overlay_order = current.overlay_order.clone(); + next.locked_tracks = current.locked_tracks.clone(); next.clips = current.clips.clone(); Some(next) } @@ -266,6 +268,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 7f846172b49..241cb6670c3 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 {