diff --git a/crates/editor/examples/zoom-seek-preview-benchmark.rs b/crates/editor/examples/zoom-seek-preview-benchmark.rs new file mode 100644 index 00000000000..957c2717140 --- /dev/null +++ b/crates/editor/examples/zoom-seek-preview-benchmark.rs @@ -0,0 +1,135 @@ +use cap_editor::{EditorFrameOutput, EditorInstance}; +use cap_project::{ + ClipConfiguration, CursorEvents, CursorMoveEvent, GlideDirection, ProjectConfiguration, + RecordingMeta, TimelineSegment, XY, ZoomMode, ZoomSegment, +}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + path::PathBuf, + sync::mpsc, + time::{Duration, Instant}, +}; + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() { + let root = PathBuf::from(std::env::args_os().nth(1).expect("fixture directory")); + let duration: f64 = std::env::var("CAP_ZOOM_DURATION").map_or(3600.0, |v| v.parse().unwrap()); + let clips: usize = std::env::var("CAP_ZOOM_CLIPS").map_or(1000, |v| v.parse().unwrap()); + let mut project = ProjectConfiguration { + timeline: Some( + serde_json::from_value(serde_json::json!({"segments":[],"zoomSegments":[]})).unwrap(), + ), + ..Default::default() + }; + project.camera.hide = true; + project.cursor.hide = true; + project.cursor.raw = true; + project.clips = (0..4) + .map(|index| ClipConfiguration { + index, + ..Default::default() + }) + .collect(); + let timeline = project.timeline.as_mut().unwrap(); + let length = duration / clips as f64; + timeline.segments = (0..clips) + .map(|index| TimelineSegment { + recording_clip: (index % 4) as u32, + start: (index / 4) as f64 * length, + end: ((index / 4) + 1) as f64 * length, + timescale: 1.0, + ..Default::default() + }) + .collect(); + timeline.zoom_segments = (0..30) + .map(|index| ZoomSegment { + start: index as f64 * duration / 30.0, + end: (index as f64 + 0.85) * duration / 30.0, + amount: 2.0, + mode: ZoomMode::Auto, + glide_direction: GlideDirection::default(), + glide_speed: 0.5, + instant_animation: false, + edge_snap_ratio: 0.25, + }) + .collect(); + project.write(&root).unwrap(); + let cursor = CursorEvents { + moves: (0..=(duration / 4.0 * 60.0) as usize) + .map(|index| CursorMoveEvent { + time_ms: index as f64 * 1000.0 / 60.0, + x: 0.2 + (index % 700) as f64 / 1200.0, + y: 0.2 + (index % 900) as f64 / 1500.0, + cursor_id: "default".into(), + active_modifiers: Vec::new(), + }) + .collect(), + ..Default::default() + }; + std::fs::write( + root.join("cursor.json"), + serde_json::to_vec(&cursor).unwrap(), + ) + .unwrap(); + let mut meta: RecordingMeta = serde_json::from_value(serde_json::json!({ + "platform":"Linux", "pretty_name":"Dense zoom seek benchmark", "sharing":null, + "segments":(0..4).map(|_|serde_json::json!({"display":{"path":"display.mp4","fps":1,"start_time":0.0},"cursor":"cursor.json"})).collect::>(), + "cursors":{},"status":{"status":"Complete"} + })).unwrap(); + meta.project_path = root.clone(); + meta.save_for_project().unwrap(); + let (tx, rx) = mpsc::channel(); + let startup = Instant::now(); + let editor = EditorInstance::new( + root, + |_| {}, + Box::new(move |output, _| { + if let EditorFrameOutput::Rgba(frame) = output { + let _ = tx.send((Instant::now(), frame)); + } + }), + None, + ) + .await + .expect("editor startup"); + let setup_seconds = startup.elapsed().as_secs_f64(); + editor + .preview_tx + .send_modify(|value| *value = Some((0, 30, XY::new(640, 360)))); + loop { + let (_, frame) = rx + .recv_timeout(Duration::from_secs(60)) + .expect("initial frame"); + if frame.frame_number == 0 { + break; + } + } + let target = (duration * 0.95 * 30.0) as u32; + let start = Instant::now(); + editor + .preview_tx + .send_modify(|value| *value = Some((target, 30, XY::new(640, 360)))); + let (ready, frame) = loop { + let value = rx + .recv_timeout(Duration::from_secs(60)) + .expect("late seek frame"); + if value.1.frame_number == target { + break value; + } + }; + let elapsed = ready.saturating_duration_since(start).as_secs_f64(); + let mut hash = DefaultHasher::new(); + for row in frame + .data + .chunks_exact(frame.padded_bytes_per_row as usize) + .take(frame.height as usize) + { + row[..frame.width as usize * 4].hash(&mut hash); + } + println!( + "PREVIEW_BENCH {}", + serde_json::json!({"duration":duration,"clips":clips,"startup_seconds":setup_seconds,"seek_seconds":elapsed,"fingerprint":hash.finish(),"width":frame.width,"height":frame.height}) + ); + editor.dispose().await; +} diff --git a/crates/rendering/src/zoom_spring.rs b/crates/rendering/src/zoom_spring.rs index 0dee58234a9..5f66c11806a 100644 --- a/crates/rendering/src/zoom_spring.rs +++ b/crates/rendering/src/zoom_spring.rs @@ -553,6 +553,28 @@ fn build_time_map(timeline: Option<&TimelineConfiguration>) -> Vec bool { + map.iter() + .all(|segment| segment.timeline_start.is_finite() && segment.timeline_end.is_finite()) + && map.windows(2).all(|pair| { + pair[0].timeline_start <= pair[1].timeline_start + && pair[0].timeline_end <= pair[1].timeline_end + }) +} + +fn time_map_at(map: &[TimeMapSegment], time: f64, ordered: bool) -> &[TimeMapSegment] { + if !ordered || !time.is_finite() { + return map; + } + let first = map.partition_point(|segment| segment.timeline_end <= time); + let end = map.partition_point(|segment| segment.timeline_start <= time); + if first < end && time > map[first].timeline_start { + &map[first..end] + } else { + map + } +} + /// Maps a timeline timestamp to recording seconds. Identity when no timeline /// is configured; clamps to the nearest edit boundary outside the timeline. fn map_timeline_to_recording_secs( @@ -688,6 +710,7 @@ pub struct ZoomTransformTimeline { /// Auto segments, `None` for Manual ones. clusters: Vec>, time_map: Vec, + time_map_ordered: bool, recording_clip: Option, prefer_outgoing: bool, /// Total number of samples covering [0, duration] plus one lerp partner. @@ -753,6 +776,7 @@ impl ZoomTransformTimeline { } else { build_time_map(timeline) }; + let time_map_ordered = ordered_time_map(&time_map); let clusters = zoom_segments .iter() .map(|segment| match segment.mode { @@ -799,6 +823,7 @@ impl ZoomTransformTimeline { segment_index, clusters, time_map, + time_map_ordered, recording_clip, prefer_outgoing, total_samples: 1, @@ -818,6 +843,7 @@ impl ZoomTransformTimeline { segment_index, clusters, time_map, + time_map_ordered, recording_clip, prefer_outgoing, total_samples, @@ -1132,7 +1158,7 @@ impl ZoomTransformTimeline { } ZoomMode::Auto => { let recording_ms = map_timeline_to_recording_secs( - &self.time_map, + time_map_at(&self.time_map, timeline_secs, self.time_map_ordered), timeline_secs, self.recording_clip, self.prefer_outgoing, @@ -1326,6 +1352,124 @@ mod tests { project } + #[test] + fn narrowed_time_maps_match_full_scans_at_boundaries_and_overlaps() { + let span = |start, end, clip| TimeMapSegment { + timeline_start: start, + timeline_end: end, + recording_start: -0.0, + timescale: 1.5, + recording_clip: clip, + }; + for map in [ + vec![], + vec![span(0.0, 2.0, 0)], + vec![span(0.0, 4.0, 0), span(3.0, 7.0, 1), span(6.0, 10.0, 0)], + vec![span(0.0, 10.0, 0), span(1.0, 3.0, 1)], + vec![span(3.0, 5.0, 1), span(0.0, 4.0, 0)], + vec![span(0.0, 3.0, 0), span(0.0, 4.0, 1)], + vec![span(0.0, 2.0, 0), span(3.0, 4.0, 1)], + vec![ + span(0.0, 2.0, 0), + TimeMapSegment { + timescale: 0.0, + ..span(2.0, 4.0, 0) + }, + span(4.0, 6.0, 1), + ], + vec![span(0.0, f64::INFINITY, 0)], + vec![span(f64::NAN, 4.0, 0)], + ] { + let ordered = ordered_time_map(&map); + let mut queries = vec![ + f64::NEG_INFINITY, + -1.0, + -0.0, + 0.0, + 1.5, + 3.5, + 8.0, + 20.0, + f64::INFINITY, + f64::NAN, + ]; + for segment in &map { + for boundary in [segment.timeline_start, segment.timeline_end] { + queries.extend([boundary.next_down(), boundary, boundary.next_up()]); + } + } + for query in queries { + let selected = time_map_at(&map, query, ordered); + if !ordered || !query.is_finite() { + assert!(std::ptr::eq(selected, map.as_slice())); + } + for clip in [None, Some(0), Some(1), Some(99)] { + for outgoing in [false, true] { + let expected = map_timeline_to_recording_secs(&map, query, clip, outgoing); + let actual = + map_timeline_to_recording_secs(selected, query, clip, outgoing); + if expected.is_nan() { + assert!(actual.is_nan()); + } else { + assert_eq!( + actual.to_bits(), + expected.to_bits(), + "query {query}, clip {clip:?}, outgoing {outgoing}" + ); + } + } + } + } + } + let map = [span(0.0, 2.0, 0), span(2.0, 4.0, 1), span(4.0, 6.0, 0)]; + assert_eq!(time_map_at(&map, 3.0, ordered_time_map(&map)).len(), 1); + } + + #[test] + fn indexed_mapping_preserves_complete_zoom_simulation_samples() { + let project = project_for_zoom_reuse(); + let duration = project.timeline.as_ref().unwrap().duration(); + let cursor = CursorEvents { + moves: vec![ + move_event(0.0, 0.1, 0.2), + move_event(5000.0, 0.4, 0.8), + move_event(10000.0, 0.8, 0.3), + move_event(20000.0, 0.2, 0.6), + ], + clicks: vec![click_event(4500.0), click_event(11000.0)], + }; + for clip in [0, 1] { + for outgoing in [false, true] { + let build = || { + if outgoing { + ZoomTransformTimeline::from_project_for_outgoing_clip( + &project, + &cursor, + duration, + XY::new(1920, 1080), + clip, + ) + } else { + ZoomTransformTimeline::from_project_for_clip( + &project, + &cursor, + duration, + XY::new(1920, 1080), + clip, + ) + } + }; + let mut expected = build(); + expected.time_map_ordered = false; + let mut actual = build(); + assert!(actual.time_map_ordered); + expected.precompute(); + actual.precompute(); + assert_samples_identical(&expected, &actual); + } + } + } + #[test] fn unrelated_project_edits_preserve_incoming_and_outgoing_zoom_samples() { let project = project_for_zoom_reuse();