Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions apps/desktop-gpui/src/editor_clips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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)]);
Expand Down
98 changes: 95 additions & 3 deletions apps/desktop-gpui/src/editor_edits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()
Comment on lines +1223 to +1224

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Locked captions still move

For generated captions, with_locked_tracks restores caption_segments, but the next project_changed call runs synchronize_caption_track(false). Deleting a clip or changing its speed changes the caption signature, so synchronize_captions replaces the restored positions with times calculated from the edited clips. Deleting a preceding 10-second clip therefore still moves a locked caption from 12 seconds to 2 seconds.

Make caption synchronization respect the lock, including when the project is reopened, and test this through the full edit path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/src/editor_edits.rs
Line: 1223-1224

Comment:
**Locked captions still move**

For generated captions, `with_locked_tracks` restores `caption_segments`, but the next `project_changed` call runs `synchronize_caption_track(false)`. Deleting a clip or changing its speed changes the caption signature, so `synchronize_captions` replaces the restored positions with times calculated from the edited clips. Deleting a preceding 10-second clip therefore still moves a locked caption from 12 seconds to 2 seconds.

Make caption synchronization respect the lock, including when the project is reopened, and test this through the full edit path.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
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<LockableTimelineTrack> {
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
Expand Down Expand Up @@ -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!({
Expand Down
92 changes: 88 additions & 4 deletions apps/desktop-gpui/src/editor_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -8339,10 +8348,13 @@ impl EditorWindow {
window: &mut Window,
cx: &mut Context<Self>,
) {
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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(|_| {
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop-gpui/src/presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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, &current).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(
Expand Down
1 change: 1 addition & 0 deletions crates/editor/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1954,6 +1954,7 @@ fn audio_project_key(project: &ProjectConfiguration) -> Option<Vec<u8>> {
color_correction: _,
text_size_version: _,
text_anim_version: _,
locked_tracks: _,
} = project;
let timeline = timeline.as_ref().map(|timeline| {
let TimelineConfiguration {
Expand Down
Loading
Loading