diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 23a0dccd4b0..c844e4b486e 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,34 @@ mod tests { } } + #[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 13631e4537f..37287b79098 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,123 @@ pub fn reorder_track_lane( } } +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; + } + let edited_holds = timeline.hold_windows(); + 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() + } + } + } + // The edit placed the unlocked tracks against the fullscreen holds of the + // text it rippled; a locked text track keeps its old holds instead, so + // those positions are re-derived through the recording time they show. + let final_holds = timeline.hold_windows(); + if edited_holds != final_holds { + let flow = |time: f64| time - held_time_before(&edited_holds, time); + let remap_start = |time: f64| effective_to_output(&final_holds, flow(time)); + let remap_end = |time: f64| effective_to_output_end(&final_holds, flow(time)); + remap_unlocked_tracks(timeline, locked, remap_start, remap_end); + } + true +} + +fn remap_unlocked_tracks( + timeline: &mut TimelineConfiguration, + locked: &[LockableTimelineTrack], + start_map: impl Fn(f64) -> f64, + end_map: impl Fn(f64) -> f64, +) { + fn remap_track( + segments: &mut [T], + start_map: &impl Fn(f64) -> f64, + end_map: &impl Fn(f64) -> f64, + ) { + for segment in segments { + let start = start_map(segment.start()); + let end = end_map(segment.end()).max(start); + segment.set_start(start); + segment.set_end(end); + } + } + let unlocked = |track: LockableTimelineTrack| !locked.contains(&track); + if unlocked(LockableTimelineTrack::Style) { + remap_track(&mut timeline.style_segments, &start_map, &end_map); + } + if unlocked(LockableTimelineTrack::Image) { + remap_track(&mut timeline.image_segments, &start_map, &end_map); + } + if unlocked(LockableTimelineTrack::Caption) { + remap_track(&mut timeline.caption_segments, &start_map, &end_map); + } + if unlocked(LockableTimelineTrack::Keyboard) { + for segment in &mut timeline.keyboard_segments { + let end = segment.end; + segment.remap_times(&start_map); + segment.end = end_map(end).max(segment.start); + } + } + if unlocked(LockableTimelineTrack::Text) { + remap_track(&mut timeline.text_segments, &start_map, &end_map); + } + if unlocked(LockableTimelineTrack::Zoom) { + remap_track(&mut timeline.zoom_segments, &start_map, &end_map); + } + if unlocked(LockableTimelineTrack::Scene) { + remap_track(&mut timeline.scene_segments, &start_map, &end_map); + } + if unlocked(LockableTimelineTrack::Mask) { + remap_track(&mut timeline.mask_segments, &start_map, &end_map); + } + if unlocked(LockableTimelineTrack::Audio) { + remap_track(&mut timeline.audio_segments, &start_map, &end_map); + } + if unlocked(LockableTimelineTrack::Camera3d) { + remap_track(&mut timeline.camera3d_segments, &start_map, &end_map); + } +} + +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 +4694,171 @@ 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 a_locked_fullscreen_title_keeps_unlocked_effects_on_their_content() { + let mut timeline = 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": 30.0 } + ], + "zoomSegments": [{ "start": 17.0, "end": 18.0, "amount": 2.0, "mode": "auto" }], + "textSegments": [ + { "start": 2.0, "end": 4.0, "layout": "fullscreen", "content": "Title" } + ] + } + })) + .timeline + .unwrap(); + let zoom_flow = |timeline: &TimelineConfiguration| { + let start = timeline.zoom_segments[0].start; + start - held_time_before(&timeline.hold_windows(), start) + }; + let before = zoom_flow(&timeline); + + assert!(with_locked_tracks( + &mut timeline, + &[LockableTimelineTrack::Text], + |timeline| delete_clip_segments(timeline, &[0]), + )); + + assert_eq!(timeline.hold_windows(), vec![(2.0, 4.0)]); + assert_eq!( + ( + timeline.zoom_segments[0].start, + timeline.zoom_segments[0].end + ), + (7.0, 8.0) + ); + assert_eq!(zoom_flow(&timeline), before - 10.0); + } + + #[test] + fn an_unlocked_effect_ending_at_a_locked_title_stops_before_it() { + let mut timeline = 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": 30.0 } + ], + "zoomSegments": [{ "start": 13.0, "end": 14.0, "amount": 2.0, "mode": "auto" }], + "textSegments": [ + { "start": 2.0, "end": 4.0, "layout": "fullscreen", "content": "Title" } + ] + } + })) + .timeline + .unwrap(); + + assert!(with_locked_tracks( + &mut timeline, + &[LockableTimelineTrack::Text], + |timeline| delete_clip_segments(timeline, &[0]), + )); + + assert_eq!( + ( + timeline.zoom_segments[0].start, + timeline.zoom_segments[0].end + ), + (1.0, 2.0) + ); + } + + #[test] + fn locked_captions_stay_put_through_a_clip_delete_and_a_reopen() { + let project = |locked: bool| { + let mut project = 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": 30.0 } + ], + "zoomSegments": [] + }, + "captions": { + "segments": [{ + "id": "c0", + "start": 12.0, + "end": 14.0, + "text": "hello there", + "words": [ + { "text": "hello", "start": 12.0, "end": 13.0 }, + { "text": "there", "start": 13.0, "end": 14.0 } + ] + }], + "settings": {}, + "sourceTimed": true + } + })); + if locked { + project.locked_tracks = vec![LockableTimelineTrack::Caption]; + } + let durations = [30.0]; + cap_project::synchronize_captions(&mut project, &durations); + let locked_tracks = project.locked_tracks.clone(); + assert!(with_locked_tracks( + project.timeline.as_mut().unwrap(), + &locked_tracks, + |timeline| delete_clip_segments(timeline, &[0]), + )); + cap_project::synchronize_captions(&mut project, &durations); + let edited = project.timeline.as_ref().unwrap().caption_segments.clone(); + + let mut reopened: ProjectConfiguration = + serde_json::from_value(serde_json::to_value(&project).unwrap()).unwrap(); + cap_project::synchronize_captions(&mut reopened, &durations); + let reopened = reopened.timeline.unwrap().caption_segments; + assert_eq!(reopened.len(), edited.len()); + ( + edited[0].start, + edited[0].end, + reopened[0].start, + reopened[0].end, + ) + }; + + assert_eq!(project(true), (12.0, 14.0, 12.0, 14.0)); + assert_eq!(project(false), (2.0, 4.0, 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 64edb658fe8..9ccd66bf0b8 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -2525,6 +2525,10 @@ impl EditorWindow { let timeline = self.project.timeline.as_ref()?; let mut hasher = std::hash::DefaultHasher::new(); captions.source_timed.hash(&mut hasher); + self.project + .locked_tracks + .contains(&cap_project::LockableTimelineTrack::Caption) + .hash(&mut hasher); for segment in &captions.segments { segment.id.hash(&mut hasher); segment.start.to_bits().hash(&mut hasher); @@ -6203,8 +6207,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 +8352,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); @@ -8427,15 +8443,16 @@ 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; }; - let changed = match transition { + let changed = edits::with_locked_tracks(timeline, &locked, |timeline| match transition { Some((kind, duration)) => { crate::editor_clips::set_clip_transition(timeline, index, kind, duration) } None => crate::editor_clips::drop_clip_transition(timeline, index), - }; + }); if !changed { return; } @@ -8443,6 +8460,28 @@ impl EditorWindow { self.note_edit("clip-transition", Some(TrackKind::Clip)); } + // Written straight to the project: `edit_project` goes through an open + // style group's apply path, which keeps only style settings. + fn toggle_track_lock( + &mut self, + track: cap_project::LockableTimelineTrack, + window: &mut Window, + cx: &mut Context, + ) { + if !self.project_ready() { + return; + } + let locks = &mut self.project.locked_tracks; + match locks.iter().position(|locked| *locked == track) { + Some(at) => { + locks.remove(at); + } + None => locks.push(track), + } + self.project_changed(window, cx); + self.note_project_edit("track-lock"); + } + fn merge_clip( &mut self, index: usize, @@ -8452,10 +8491,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 +11614,57 @@ impl EditorWindow { )), ) })) + .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.toggle_track_lock(track, window, cx); + }), + ) + .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 a2f746e47ad..8b049486a21 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 f793e41d67c..c36490b2813 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/caption_timing.rs b/crates/project/src/caption_timing.rs index a9ef301911c..94414799336 100644 --- a/crates/project/src/caption_timing.rs +++ b/crates/project/src/caption_timing.rs @@ -1,8 +1,9 @@ use crate::caption_words::{MAX_CAPTION_WORD_DURATION, caption_text_from_words}; use crate::{ - CaptionSegment, CaptionTrackSegment, CaptionWord, ProjectConfiguration, TimelineConfiguration, + CaptionSegment, CaptionTrackSegment, CaptionWord, LockableTimelineTrack, ProjectConfiguration, + TimelineConfiguration, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; pub(crate) fn clip_timeline_offsets(timeline: &TimelineConfiguration) -> Vec { let mut offset = 0.0; @@ -378,6 +379,9 @@ pub fn derive_caption_track_segments( } pub fn synchronize_captions(project: &mut ProjectConfiguration, recording_durations: &[f64]) { + let locked = project + .locked_tracks + .contains(&LockableTimelineTrack::Caption); let (Some(captions), Some(timeline)) = (&mut project.captions, &mut project.timeline) else { return; }; @@ -438,8 +442,154 @@ pub fn synchronize_captions(project: &mut ProjectConfiguration, recording_durati .sort_by(|a, b| a.start.total_cmp(&b.start)); captions.source_timed = true; } - timeline.caption_segments = - derive_caption_track_segments(&captions.segments, timeline, recording_durations); + let derived = derive_caption_track_segments(&captions.segments, timeline, recording_durations); + timeline.caption_segments = if locked { + keep_locked_caption_positions(&timeline.caption_segments, derived, &captions.segments) + } else { + derived + }; +} + +fn keep_locked_caption_positions( + current: &[CaptionTrackSegment], + derived: Vec, + sources: &[CaptionSegment], +) -> Vec { + let source_ids = sources + .iter() + .map(|source| source.id.as_str()) + .collect::>(); + let locked = current + .iter() + .filter(|segment| source_ids.contains(source_caption_id(&segment.id))) + .collect::>(); + let fresh_for = locked_part_sources(&locked, &derived); + let mut kept = locked + .iter() + .zip(&fresh_for) + .map(|(segment, fresh)| locked_caption_content(segment, fresh.map(|index| &derived[index]))) + .collect::>(); + let placed = kept + .iter() + .map(|segment| source_caption_id(&segment.id).to_string()) + .collect::>(); + kept.extend( + derived + .into_iter() + .filter(|segment| !placed.contains(source_caption_id(&segment.id))), + ); + kept.sort_by(|a, b| a.start.total_cmp(&b.start)); + kept +} + +fn word_texts(segment: &CaptionTrackSegment) -> Vec<&str> { + segment + .words + .iter() + .map(|word| word.text.as_str()) + .collect() +} + +// A caption cut by the edit list is split into parts numbered in timeline +// order, and those numbers shift when clips are reordered or deleted, so the +// id cannot say which stretch of speech a locked part holds. The track keeps +// no source times either, so a locked part is paired with the fresh part +// carrying exactly its words. Only when none of a caption's parts match (its +// text was rewritten) and the part counts agree are they paired in order; a +// locked part left unpaired keeps its own words rather than borrow a +// neighbour's. +fn locked_part_sources( + locked: &[&CaptionTrackSegment], + derived: &[CaptionTrackSegment], +) -> Vec> { + let mut pairs = vec![None; locked.len()]; + let mut used = vec![false; derived.len()]; + for (slot, part) in locked.iter().enumerate() { + let source = source_caption_id(&part.id); + let words = word_texts(part); + pairs[slot] = derived + .iter() + .enumerate() + .filter(|(index, fresh)| { + !used[*index] + && source_caption_id(&fresh.id) == source + && word_texts(fresh) == words + }) + .max_by_key(|(_, fresh)| fresh.id == part.id) + .map(|(index, _)| index); + if let Some(index) = pairs[slot] { + used[index] = true; + } + } + let mut sources = locked + .iter() + .map(|part| source_caption_id(&part.id)) + .collect::>(); + sources.sort_unstable(); + sources.dedup(); + for source in sources { + let parts = (0..locked.len()) + .filter(|slot| source_caption_id(&locked[*slot].id) == source) + .collect::>(); + if parts.iter().any(|slot| pairs[*slot].is_some()) { + continue; + } + let fresh = (0..derived.len()) + .filter(|index| source_caption_id(&derived[*index].id) == source) + .collect::>(); + if fresh.len() != parts.len() { + continue; + } + let mut parts = parts; + parts.sort_by(|a, b| locked[*a].start.total_cmp(&locked[*b].start)); + let mut fresh = fresh; + fresh.sort_by(|a, b| derived[*a].start.total_cmp(&derived[*b].start)); + for (slot, index) in parts.into_iter().zip(fresh) { + pairs[slot] = Some(index); + } + } + pairs +} + +fn locked_caption_content( + locked: &CaptionTrackSegment, + fresh: Option<&CaptionTrackSegment>, +) -> CaptionTrackSegment { + let mut segment = locked.clone(); + let (start, end) = (locked.start as f32, locked.end.max(locked.start) as f32); + if let Some(fresh) = fresh { + let offset = (locked.start - fresh.start) as f32; + segment.text.clone_from(&fresh.text); + segment.words = fresh + .words + .iter() + .map(|word| CaptionWord { + text: word.text.clone(), + start: (word.start + offset).clamp(start, end), + end: (word.end + offset).clamp(start, end), + }) + .collect(); + return segment; + } + let tokens = segment.text.split_whitespace().collect::>(); + let matches = tokens.len() == segment.words.len() + && tokens + .iter() + .zip(&segment.words) + .all(|(token, word)| *token == word.text); + if !matches { + let step = (end - start) / tokens.len().max(1) as f32; + segment.words = tokens + .iter() + .enumerate() + .map(|(index, token)| CaptionWord { + text: (*token).to_string(), + start: start + step * index as f32, + end: start + step * (index + 1) as f32, + }) + .collect(); + } + segment } #[cfg(test)] @@ -492,6 +642,227 @@ mod tests { ); } + #[test] + fn locked_caption_track_keeps_positions_but_follows_source_deletions_and_additions() { + let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({ + "timeline": { + "segments": [{ "recordingSegment": 0, "timescale": 1.0, "start": 5.0, "end": 20.0 }], + "zoomSegments": [], + "captionSegments": [ + { "id": "kept", "start": 12.0, "end": 14.0, "text": "kept" }, + { "id": "removed", "start": 1.0, "end": 2.0, "text": "removed" } + ] + }, + "captions": { + "segments": [ + { "id": "kept", "start": 12.0, "end": 14.0, "text": "kept" }, + { "id": "added", "start": 8.0, "end": 9.0, "text": "added" } + ], + "settings": {}, + "sourceTimed": true + }, + "lockedTracks": ["caption"] + })) + .unwrap(); + + synchronize_captions(&mut project, &[20.0]); + + let track = &project.timeline.as_ref().unwrap().caption_segments; + assert_eq!( + track + .iter() + .map(|segment| (segment.id.as_str(), segment.start, segment.end)) + .collect::>(), + vec![("added", 3.0, 4.0), ("kept", 12.0, 14.0)] + ); + + project.locked_tracks.clear(); + synchronize_captions(&mut project, &[20.0]); + let track = &project.timeline.as_ref().unwrap().caption_segments; + assert_eq!((track[1].start, track[1].end), (7.0, 9.0)); + } + + #[test] + fn a_locked_caption_takes_edited_words_at_its_locked_position() { + let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({ + "timeline": { + "segments": [{ "recordingSegment": 0, "timescale": 1.0, "start": 5.0, "end": 20.0 }], + "zoomSegments": [], + "captionSegments": [ + { + "id": "kept", "start": 12.0, "end": 14.0, "text": "old words", + "words": [ + { "text": "old", "start": 12.0, "end": 13.0 }, + { "text": "words", "start": 13.0, "end": 14.0 } + ] + }, + { "id": "gone", "start": 30.0, "end": 32.0, "text": "two words" } + ] + }, + "captions": { + "segments": [ + { + "id": "kept", "start": 12.0, "end": 14.0, "text": "new text here", + "words": [ + { "text": "new", "start": 12.0, "end": 12.5 }, + { "text": "text", "start": 12.5, "end": 13.0 }, + { "text": "here", "start": 13.0, "end": 14.0 } + ] + }, + { "id": "gone", "start": 40.0, "end": 41.0, "text": "two words" } + ], + "settings": {}, + "sourceTimed": true + }, + "lockedTracks": ["caption"] + })) + .unwrap(); + + synchronize_captions(&mut project, &[20.0]); + + let track = &project.timeline.as_ref().unwrap().caption_segments; + let kept = track.iter().find(|segment| segment.id == "kept").unwrap(); + assert_eq!((kept.start, kept.end), (12.0, 14.0)); + assert_eq!(kept.text, "new text here"); + assert_eq!( + kept.words + .iter() + .map(|word| (word.text.as_str(), word.start, word.end)) + .collect::>(), + vec![ + ("new", 12.0, 12.5), + ("text", 12.5, 13.0), + ("here", 13.0, 14.0) + ] + ); + let gone = track.iter().find(|segment| segment.id == "gone").unwrap(); + assert_eq!((gone.start, gone.end), (30.0, 32.0)); + assert_eq!( + gone.words + .iter() + .map(|word| (word.text.as_str(), word.start, word.end)) + .collect::>(), + vec![("two", 30.0, 31.0), ("words", 31.0, 32.0)] + ); + } + + #[test] + fn locked_parts_of_a_caption_keep_their_own_words_when_clips_swap() { + let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({ + "timeline": { + "segments": [ + { "recordingSegment": 0, "timescale": 1.0, "start": 0.0, "end": 5.0 }, + { "recordingSegment": 0, "timescale": 1.0, "start": 5.0, "end": 10.0 } + ], + "zoomSegments": [] + }, + "captions": { + "segments": [{ + "id": "c", "start": 3.0, "end": 7.0, "text": "early late", + "words": [ + { "text": "early", "start": 3.0, "end": 4.0 }, + { "text": "late", "start": 6.0, "end": 7.0 } + ] + }], + "settings": {}, + "sourceTimed": true + }, + "lockedTracks": ["caption"] + })) + .unwrap(); + synchronize_captions(&mut project, &[10.0]); + let parts = |project: &ProjectConfiguration| { + let mut parts = project + .timeline + .as_ref() + .unwrap() + .caption_segments + .iter() + .map(|segment| (segment.start, segment.text.clone(), segment.words[0].start)) + .collect::>(); + parts.sort_by(|a, b| a.0.total_cmp(&b.0)); + parts + }; + let before = parts(&project); + assert_eq!( + before, + vec![ + (3.0, "early".to_string(), 3.0), + (6.0, "late".to_string(), 6.0) + ] + ); + + project.timeline.as_mut().unwrap().segments.swap(0, 1); + synchronize_captions(&mut project, &[10.0]); + + assert_eq!(parts(&project), before); + } + + fn split_caption_locked(timeline_segments: serde_json::Value) -> ProjectConfiguration { + let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({ + "timeline": { + "segments": [ + { "recordingSegment": 0, "timescale": 1.0, "start": 0.0, "end": 5.0 }, + { "recordingSegment": 0, "timescale": 1.0, "start": 5.0, "end": 10.0 } + ], + "zoomSegments": [] + }, + "captions": { + "segments": [{ + "id": "c", "start": 3.0, "end": 7.0, "text": "early late", + "words": [ + { "text": "early", "start": 3.0, "end": 4.0 }, + { "text": "late", "start": 6.0, "end": 7.0 } + ] + }], + "settings": {}, + "sourceTimed": true + }, + "lockedTracks": ["caption"] + })) + .unwrap(); + synchronize_captions(&mut project, &[10.0]); + project.timeline.as_mut().unwrap().segments = + serde_json::from_value(timeline_segments).unwrap(); + synchronize_captions(&mut project, &[10.0]); + project + } + + fn locked_parts(project: &ProjectConfiguration) -> Vec<(f64, String, Vec)> { + let mut parts = project + .timeline + .as_ref() + .unwrap() + .caption_segments + .iter() + .map(|segment| { + ( + segment.start, + segment.text.clone(), + segment.words.iter().map(|word| word.start).collect(), + ) + }) + .collect::>(); + parts.sort_by(|a, b| a.0.total_cmp(&b.0)); + parts + } + + #[test] + fn deleting_either_clip_keeps_each_locked_part_on_its_own_words() { + let expected = vec![ + (3.0, "early".to_string(), vec![3.0]), + (6.0, "late".to_string(), vec![6.0]), + ]; + let first_deleted = split_caption_locked(serde_json::json!([ + { "recordingSegment": 0, "timescale": 1.0, "start": 5.0, "end": 10.0 } + ])); + assert_eq!(locked_parts(&first_deleted), expected); + let second_deleted = split_caption_locked(serde_json::json!([ + { "recordingSegment": 0, "timescale": 1.0, "start": 0.0, "end": 5.0 } + ])); + assert_eq!(locked_parts(&second_deleted), expected); + } + #[test] fn transcription_coverage_accepts_identical_ranges() { let ranges = [1.0..4.0, 6.0..10.0]; diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index bb0a0491097..5088813d096 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2707,6 +2707,24 @@ pub struct ProjectConfiguration { /// durations from `fade_duration`. #[serde(default)] pub text_anim_version: u32, + #[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 +2776,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 +2861,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 +3283,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 =