Skip to content
Merged
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
24 changes: 15 additions & 9 deletions crates/editor/src/editor_instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,15 @@ impl PreviewCursorCache {
fn refresh_project_snapshot(
project_rx: &mut watch::Receiver<ProjectConfiguration>,
snapshot: &mut Arc<ProjectConfiguration>,
) -> bool {
) -> Option<Arc<ProjectConfiguration>> {
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<f64> {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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));
}

Expand Down
11 changes: 9 additions & 2 deletions crates/editor/src/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions crates/project/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
205 changes: 204 additions & 1 deletion crates/rendering/src/zoom_spring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,14 @@ impl CursorCropMap {
}
}

#[derive(PartialEq)]
struct TimedCursorCropMap {
start: f64,
end: f64,
crop: Option<CursorCropMap>,
}

#[derive(PartialEq)]
struct CursorCropMaps {
base: Option<CursorCropMap>,
styles: Vec<TimedCursorCropMap>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<u32>,
) -> 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
Expand Down Expand Up @@ -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(
Expand Down
Loading