diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 0c4abd9db7..ab94eb4661 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -123,16 +123,15 @@ impl PreviewCursorCache { fn refresh_project_snapshot( project_rx: &mut watch::Receiver, snapshot: &mut Arc, -) -> bool { +) -> Option> { let refreshed = { let project = project_rx.borrow_and_update(); if !project.has_changed() { - return false; + return None; } Arc::new(project.clone()) }; - *snapshot = refreshed; - true + Some(std::mem::replace(snapshot, refreshed)) } fn get_video_duration_fallback(path: &Path) -> Option { @@ -1040,7 +1039,13 @@ impl EditorInstance { break; } - if refresh_project_snapshot(&mut project_rx, &mut project) { + if let Some(previous) = refresh_project_snapshot(&mut project_rx, &mut project) + && !ZoomTransformTimeline::project_inputs_match( + &previous, + &project, + self.render_constants.options.screen_size, + ) + { zoom_timelines.clear(); outgoing_zoom_timelines.clear(); } @@ -1119,7 +1124,7 @@ impl EditorInstance { // timeline playback and export use (the old focus // interpolator was never precomputed here and fell // back to a divergent direct interpolation). The - // timeline is kept until the project changes: it + // timeline is kept until its inputs change: it // precomputes from t=0, so rebuilding it per frame // made each scrub frame cost O(playhead). let zoom_timeline = zoom_timelines @@ -2298,7 +2303,7 @@ mod tests { let mut snapshot = Arc::new(project_rx.borrow_and_update().clone()); let initial = Arc::clone(&snapshot); for _ in 0..3 { - assert!(!refresh_project_snapshot(&mut project_rx, &mut snapshot)); + assert!(refresh_project_snapshot(&mut project_rx, &mut snapshot).is_none()); assert!(Arc::ptr_eq(&snapshot, &initial)); } assert_eq!(snapshot.background.padding, padding); @@ -2315,13 +2320,14 @@ mod tests { project_tx.send_modify(|project| project.background.padding = padding); } - assert!(refresh_project_snapshot(&mut project_rx, &mut snapshot)); + let previous = refresh_project_snapshot(&mut project_rx, &mut snapshot).unwrap(); + assert!(Arc::ptr_eq(&previous, &initial)); assert!(!Arc::ptr_eq(&snapshot, &initial)); assert_eq!(snapshot.background.padding, 16.0); assert_eq!(initial.background.padding, 0.0); let refreshed = Arc::clone(&snapshot); - assert!(!refresh_project_snapshot(&mut project_rx, &mut snapshot)); + assert!(refresh_project_snapshot(&mut project_rx, &mut snapshot).is_none()); assert!(Arc::ptr_eq(&snapshot, &refreshed)); } diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs index 4a93c6e844..92cbe23264 100644 --- a/crates/editor/src/playback.rs +++ b/crates/editor/src/playback.rs @@ -1460,6 +1460,11 @@ impl Playback { || clip_audio_changed(&cached_project, &next_project); let cursor_changed = cursor_timeline_settings(&cached_project.cursor) != cursor_timeline_settings(&next_project.cursor); + let zoom_changed = !ZoomTransformTimeline::project_inputs_match( + &cached_project, + &next_project, + self.render_constants.options.screen_size, + ); cached_project = next_project; if adopted.is_none() && audio_changed @@ -1480,8 +1485,10 @@ impl Playback { if cursor_changed { cursor_timelines = build_cursor_timelines(&cached_project); } - zoom_timelines = build_zoom_timelines(&cached_project); - outgoing_zoom_timelines = build_outgoing_zoom_timelines(&cached_project); + if zoom_changed { + zoom_timelines = build_zoom_timelines(&cached_project); + outgoing_zoom_timelines = build_outgoing_zoom_timelines(&cached_project); + } } let frame_offset = frame_number.saturating_sub(clock_anchor_frame) as f64; diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 269a2cba1c..8b7f2dd2e6 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -788,7 +788,7 @@ impl Default for ClickSpringConfig { } } -#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug)] +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ScreenMovementSpring { pub stiffness: f32, @@ -1026,7 +1026,7 @@ pub enum GlideDirection { Down, } -#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ZoomSegment { pub start: f64, @@ -1053,7 +1053,7 @@ impl ZoomSegment { } } -#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub enum ZoomMode { Auto, diff --git a/crates/rendering/src/zoom_spring.rs b/crates/rendering/src/zoom_spring.rs index f35c9567c1..666b1c1c51 100644 --- a/crates/rendering/src/zoom_spring.rs +++ b/crates/rendering/src/zoom_spring.rs @@ -111,12 +111,14 @@ impl CursorCropMap { } } +#[derive(PartialEq)] struct TimedCursorCropMap { start: f64, end: f64, crop: Option, } +#[derive(PartialEq)] struct CursorCropMaps { base: Option, styles: Vec, @@ -438,7 +440,7 @@ pub(crate) fn cluster_center_at_time( /// One entry of the timeline-time -> recording-time mapping derived from /// [`TimelineConfiguration::get_segment_time`]'s accumulation logic. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq)] struct TimeMapSegment { timeline_start: f64, timeline_end: f64, @@ -833,6 +835,39 @@ impl ZoomTransformTimeline { timeline } + /// Whether a project edit can keep zoom samples for unchanged recording + /// data, screen dimensions, clip selection, and explicit duration. + pub fn project_inputs_match( + before: &ProjectConfiguration, + after: &ProjectConfiguration, + screen_size: XY, + ) -> bool { + let before_timeline = before.timeline.as_ref(); + let after_timeline = after.timeline.as_ref(); + let before_zooms = before_timeline + .map(|timeline| timeline.zoom_segments.as_slice()) + .unwrap_or_default(); + let after_zooms = after_timeline + .map(|timeline| timeline.zoom_segments.as_slice()) + .unwrap_or_default(); + let had_outgoing = before_timeline.is_some_and(|timeline| !timeline.transitions.is_empty()); + let needs_outgoing = + after_timeline.is_some_and(|timeline| !timeline.transitions.is_empty()); + if before_zooms != after_zooms || had_outgoing != needs_outgoing { + return false; + } + if before_zooms.is_empty() { + return true; + } + + before.screen_movement_spring == after.screen_movement_spring + && before_timeline.map(TimelineConfiguration::duration) + == after_timeline.map(TimelineConfiguration::duration) + && build_time_map(before_timeline) == build_time_map(after_timeline) + && CursorCropMaps::from_project(before, screen_size) + == CursorCropMaps::from_project(after, screen_size) + } + /// Convenience constructor pulling zoom segments, edit mapping, spring /// config and crop mapping out of a [`ProjectConfiguration`]. /// `screen_size` is the raw recorded display size in px @@ -1239,6 +1274,174 @@ mod tests { XY::new(x as f32, y as f32) } + fn project_for_zoom_reuse() -> ProjectConfiguration { + let mut project = project_with_crop_styles( + vec![auto_segment(0.5, 18.0, 2.0)], + vec![crop_style(3.0, 8.0, 0, None)], + ); + let timeline = project.timeline.as_mut().unwrap(); + timeline.segments = vec![ + TimelineSegment { + end: 10.0, + timescale: 1.0, + ..Default::default() + }, + TimelineSegment { + recording_clip: 1, + start: 5.0, + end: 15.0, + timescale: 1.0, + ..Default::default() + }, + ]; + timeline.transitions.push(ClipTransition { + segment_index: 1, + kind: ClipTransitionType::CrossFade, + duration: 0.5, + }); + timeline.text_segments.push( + serde_json::from_value(serde_json::json!({ + "start": 4.0, "end": 5.0, "layout": "fullscreen" + })) + .unwrap(), + ); + project + } + + #[test] + fn unrelated_project_edits_preserve_incoming_and_outgoing_zoom_samples() { + let project = project_for_zoom_reuse(); + let mut edited = project.clone(); + edited.background.padding = 24.0; + edited.camera.rounding = 0.75; + edited.cursor.size = 150; + let timeline = edited.timeline.as_mut().unwrap(); + timeline.segments[0].name = Some("Intro".to_string()); + timeline.segments[0].volume = Some(0.5); + timeline.text_segments[0].content = "Edited title".to_string(); + timeline.style_segments[0] + .overrides + .background + .as_mut() + .unwrap() + .rounding = 12.0; + let screen = XY::new(1000, 1000); + assert!(ZoomTransformTimeline::project_inputs_match( + &project, &edited, screen + )); + + let cursor = CursorEvents { + moves: vec![ + move_event(0.0, 0.2, 0.8), + move_event(4000.0, 0.4, 0.3), + move_event(10000.0, 0.9, 0.7), + move_event(18000.0, 0.3, 0.2), + ], + clicks: vec![click_event(1000.0), click_event(7000.0)], + }; + let duration = project.timeline.as_ref().unwrap().duration(); + for recording_clip in [0, 1] { + for outgoing in [false, true] { + let build = |project| { + ZoomTransformTimeline::from_project_for_recording_clip( + project, + &cursor, + duration, + screen, + Some(recording_clip), + outgoing, + ) + }; + let mut retained = build(&project); + retained.ensure_precomputed_until(5.0); + let mut rebuilt = build(&edited); + for time in [9.0, 4.0, 0.0, 17.0, duration as f32] { + retained.ensure_precomputed_until(time); + rebuilt.ensure_precomputed_until(time); + assert_samples_identical(&retained, &rebuilt); + assert_eq!(retained.sample(time).bounds, rebuilt.sample(time).bounds); + } + } + } + } + + #[test] + fn zoom_inputs_invalidate_when_framing_or_timing_changes() { + let project = project_for_zoom_reuse(); + let changes: [fn(&mut ProjectConfiguration); 9] = [ + |project| project.timeline.as_mut().unwrap().zoom_segments[0].amount = 3.0, + |project| project.screen_movement_spring.stiffness += 1.0, + |project| { + project.background.crop = Some(Crop { + position: XY::new(100, 0), + size: XY::new(500, 1000), + }); + }, + |project| { + project.timeline.as_mut().unwrap().style_segments[0] + .overrides + .background + .as_mut() + .unwrap() + .crop = Some(Crop { + position: XY::new(0, 100), + size: XY::new(1000, 500), + }); + }, + |project| { + let segment = &mut project.timeline.as_mut().unwrap().segments[0]; + segment.start += 1.0; + segment.end += 1.0; + }, + |project| project.timeline.as_mut().unwrap().segments[0].timescale = 2.0, + |project| project.timeline.as_mut().unwrap().segments[0].recording_clip = 1, + |project| project.timeline.as_mut().unwrap().transitions[0].duration = 0.75, + |project| { + project.timeline.as_mut().unwrap().text_segments.push( + serde_json::from_value(serde_json::json!({ + "start": 50.0, "end": 52.0, "layout": "fullscreen" + })) + .unwrap(), + ); + }, + ]; + for (index, change) in changes.into_iter().enumerate() { + let mut edited = project.clone(); + change(&mut edited); + assert!( + !ZoomTransformTimeline::project_inputs_match( + &project, + &edited, + XY::new(1000, 1000) + ), + "change {index} reused stale zoom samples" + ); + } + } + + #[test] + fn empty_zoom_tracks_still_refresh_outgoing_timeline_availability() { + let mut before = project_for_zoom_reuse(); + before.timeline.as_mut().unwrap().zoom_segments.clear(); + let mut after = before.clone(); + after.timeline.as_mut().unwrap().transitions.clear(); + let screen = XY::new(1000, 1000); + + assert!(!ZoomTransformTimeline::project_inputs_match( + &before, &after, screen + )); + assert!(!ZoomTransformTimeline::project_inputs_match( + &after, &before, screen + )); + let no_transitions = after.clone(); + after.background.padding = 24.0; + assert!(ZoomTransformTimeline::project_inputs_match( + &no_transitions, + &after, + screen + )); + } + #[test] fn style_crop_retargets_auto_focus_at_start_and_restores_at_end() { let project = project_with_crop_styles(