diff --git a/apps/desktop-gpui/examples/live-edit-benchmark.rs b/apps/desktop-gpui/examples/live-edit-benchmark.rs new file mode 100644 index 00000000000..134dc309982 --- /dev/null +++ b/apps/desktop-gpui/examples/live-edit-benchmark.rs @@ -0,0 +1,188 @@ +//! The editor can write `project-config.json` back into the project, so point +//! it at a copy rather than a recording in the library. +//! +//! Run: cargo run --release --example live-edit-benchmark -- [TICKS] + +use std::{path::PathBuf, sync::Arc, time::Duration, time::Instant}; + +use cap_editor::{AudioOutput, EditorFrameFormat, EditorFrameOutput, EditorInstance}; +use cap_project::XY; + +const PREVIEW_FPS: u32 = 60; +const PREVIEW_RESOLUTION: XY = XY { x: 1248, y: 702 }; +const REGION_FRACTIONS: [f64; 3] = [0.02, 0.5, 0.98]; + +#[cfg(unix)] +fn cpu_secs() -> f64 { + let mut usage = std::mem::MaybeUninit::::zeroed(); + unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + let usage = unsafe { usage.assume_init() }; + let secs = |time: libc::timeval| time.tv_sec as f64 + time.tv_usec as f64 / 1_000_000.0; + secs(usage.ru_utime) + secs(usage.ru_stime) +} + +#[cfg(windows)] +fn cpu_secs() -> f64 { + use windows_sys::Win32::{ + Foundation::FILETIME, + System::Threading::{GetCurrentProcess, GetProcessTimes}, + }; + + let empty = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let (mut creation, mut exit, mut kernel, mut user) = (empty, empty, empty, empty); + unsafe { + GetProcessTimes( + GetCurrentProcess(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + }; + let secs = |time: FILETIME| { + ((u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)) as f64 + / 10_000_000.0 + }; + secs(kernel) + secs(user) +} + +fn percentile(sorted: &[f64], fraction: f64) -> f64 { + sorted[((sorted.len() - 1) as f64 * fraction).round() as usize] +} + +fn frame_number(output: &EditorFrameOutput) -> u32 { + match output { + EditorFrameOutput::Rgba(frame) => frame.frame_number, + EditorFrameOutput::Nv12(frame) => frame.frame_number, + #[cfg(target_os = "macos")] + EditorFrameOutput::Surface(frame) => frame.frame_number, + } +} + +async fn render( + instance: &EditorInstance, + frames: &flume::Receiver, + frame: u32, +) -> anyhow::Result { + while frames.try_recv().is_ok() {} + let requested = Instant::now(); + instance + .preview_tx + .send_modify(|value| *value = Some((frame, PREVIEW_FPS, PREVIEW_RESOLUTION))); + loop { + let rendered = tokio::time::timeout(Duration::from_secs(30), frames.recv_async()) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for frame {frame}"))??; + if rendered == frame { + return Ok(requested.elapsed().as_secs_f64() * 1000.0); + } + } +} + +async fn run(project_path: PathBuf, ticks: u32) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + let format = EditorFrameFormat::BgraSurface; + #[cfg(not(target_os = "macos"))] + let format = EditorFrameFormat::Rgba; + + let (frame_tx, frame_rx) = flume::unbounded::(); + let audio_output = Arc::new(AudioOutput::new_headless(Box::new(|_samples, _at| {}))); + let instance = EditorInstance::new_with_audio_output_and_frame_format( + project_path.clone(), + |_state| {}, + Box::new(move |output, _layout| { + let _ = frame_tx.send(frame_number(&output)); + }), + None, + format, + audio_output, + ) + .await + .map_err(anyhow::Error::msg)?; + + let mut project = instance.project_config.1.borrow().clone(); + let duration = project + .timeline + .as_ref() + .map_or(0.0, |timeline| timeline.duration()); + let zoom_segments = project + .timeline + .as_ref() + .map_or(0, |timeline| timeline.zoom_segments.len()); + let last_frame = ((duration * PREVIEW_FPS as f64) as u32).saturating_sub(1); + let base_padding = project.background.padding; + render(&instance, &frame_rx, 0).await?; + + let mut regions = Vec::new(); + let mut edits = Vec::new(); + let mut edit_cpu_secs = 0.0; + for fraction in REGION_FRACTIONS { + let frame = (last_frame as f64 * fraction) as u32; + let seek_ms = render(&instance, &frame_rx, frame).await?; + let cpu_started = cpu_secs(); + let mut latencies = Vec::with_capacity(ticks as usize); + for tick in 1..=ticks { + project.background.padding = base_padding + f64::from(tick % 20) * 0.5; + while frame_rx.try_recv().is_ok() {} + let edited = Instant::now(); + instance.project_config.0.send(project.clone()).ok(); + render(&instance, &frame_rx, frame).await?; + latencies.push(edited.elapsed().as_secs_f64() * 1000.0); + } + edit_cpu_secs += cpu_secs() - cpu_started; + edits.extend_from_slice(&latencies); + latencies.sort_by(f64::total_cmp); + regions.push(serde_json::json!({ + "atSecs": frame as f64 / PREVIEW_FPS as f64, + "seekMs": seek_ms, + "editMedianMs": percentile(&latencies, 0.5), + "editP95Ms": percentile(&latencies, 0.95), + })); + } + edits.sort_by(f64::total_cmp); + println!( + "{}", + serde_json::json!({ + "project": project_path, + "durationSecs": duration, + "zoomSegments": zoom_segments, + "ticks": ticks, + "regions": regions, + "editMedianMs": percentile(&edits, 0.5), + "editP95Ms": percentile(&edits, 0.95), + "editCpuSecs": edit_cpu_secs, + }) + ); + instance.dispose().await; + Ok(()) +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()), + ) + .with_writer(std::io::stderr) + .init(); + + let mut arguments = std::env::args().skip(1); + let project_path = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("Pass a studio .cap project path"))?; + let ticks = arguments + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or(60u32) + .max(1); + + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(16 * 1024 * 1024) + .enable_all() + .build()? + .block_on(run(project_path, ticks)) +} diff --git a/crates/rendering/src/spring_mass_damper.rs b/crates/rendering/src/spring_mass_damper.rs index 088db96c9bd..966cec52e3e 100644 --- a/crates/rendering/src/spring_mass_damper.rs +++ b/crates/rendering/src/spring_mass_damper.rs @@ -14,67 +14,152 @@ pub struct SpringMassDamperSimulation { pub position: XY, pub velocity: XY, pub target_position: XY, + cached_step: Option, } const REST_VELOCITY_THRESHOLD: f32 = 0.0001; const REST_DISPLACEMENT_THRESHOLD: f32 = 0.00001; -fn solve_spring_1d(displacement: f32, velocity: f32, t: f32, omega0: f32, zeta: f32) -> (f32, f32) { - const CRITICAL_EPSILON: f32 = 0.01; - - if zeta < 1.0 - CRITICAL_EPSILON { - let omega_d = omega0 * (1.0 - zeta * zeta).sqrt(); - let decay = (-zeta * omega0 * t).exp(); - let cos_term = (omega_d * t).cos(); - let sin_term = (omega_d * t).sin(); - - let a = displacement; - let b = (velocity + displacement * zeta * omega0) / omega_d.max(1e-4); - - let new_disp = decay * (a * cos_term + b * sin_term); - let new_vel = decay - * ((b * omega_d - a * zeta * omega0) * cos_term - - (a * omega_d + b * zeta * omega0) * sin_term); - - (new_disp, new_vel) - } else if zeta > 1.0 + CRITICAL_EPSILON { - let sqrt_term = (zeta * zeta - 1.0).sqrt(); - let s1 = -omega0 * (zeta - sqrt_term); - let s2 = -omega0 * (zeta + sqrt_term); - let denom = s1 - s2; - - if denom.abs() < 1e-10 { - let s_avg = 0.5 * (s1 + s2); - let decay = (s_avg * t).exp(); - let new_disp = decay * (displacement + (velocity - displacement * s_avg) * t); - let new_vel = decay - * ((velocity - displacement * s_avg) - + s_avg * (displacement + (velocity - displacement * s_avg) * t)); - (new_disp, new_vel) - } else { - let c1 = (velocity - displacement * s2) / denom; - let c2 = displacement - c1; +/// `solve` keeps the original per-step operand order, so cached steps stay +/// bit-identical to solving afresh. +#[derive(Clone, Copy)] +enum SpringStep { + Underdamped { + omega0: f32, + zeta: f32, + omega_d: f32, + b_divisor: f32, + decay: f32, + cos_term: f32, + sin_term: f32, + }, + Overdamped { + s1: f32, + s2: f32, + denom: f32, + e1: f32, + e2: f32, + }, + OverdampedDegenerate { + t: f32, + s_avg: f32, + decay: f32, + }, + CriticallyDamped { + t: f32, + omega0: f32, + decay: f32, + }, +} - let e1 = (s1 * t).exp(); - let e2 = (s2 * t).exp(); +impl SpringStep { + fn new(t: f32, omega0: f32, zeta: f32) -> Self { + const CRITICAL_EPSILON: f32 = 0.01; - let new_disp = c1 * e1 + c2 * e2; - let new_vel = c1 * s1 * e1 + c2 * s2 * e2; + if zeta < 1.0 - CRITICAL_EPSILON { + let omega_d = omega0 * (1.0 - zeta * zeta).sqrt(); + Self::Underdamped { + omega0, + zeta, + omega_d, + b_divisor: omega_d.max(1e-4), + decay: (-zeta * omega0 * t).exp(), + cos_term: (omega_d * t).cos(), + sin_term: (omega_d * t).sin(), + } + } else if zeta > 1.0 + CRITICAL_EPSILON { + let sqrt_term = (zeta * zeta - 1.0).sqrt(); + let s1 = -omega0 * (zeta - sqrt_term); + let s2 = -omega0 * (zeta + sqrt_term); + let denom = s1 - s2; - (new_disp, new_vel) + if denom.abs() < 1e-10 { + let s_avg = 0.5 * (s1 + s2); + Self::OverdampedDegenerate { + t, + s_avg, + decay: (s_avg * t).exp(), + } + } else { + Self::Overdamped { + s1, + s2, + denom, + e1: (s1 * t).exp(), + e2: (s2 * t).exp(), + } + } + } else { + Self::CriticallyDamped { + t, + omega0, + decay: (-omega0 * t).exp(), + } } - } else { - let decay = (-omega0 * t).exp(); - let a = displacement; - let b = velocity + displacement * omega0; + } + + fn solve(self, displacement: f32, velocity: f32) -> (f32, f32) { + match self { + Self::Underdamped { + omega0, + zeta, + omega_d, + b_divisor, + decay, + cos_term, + sin_term, + } => { + let a = displacement; + let b = (velocity + displacement * zeta * omega0) / b_divisor; - let new_disp = decay * (a + b * t); - let new_vel = decay * (b - omega0 * (a + b * t)); + let new_disp = decay * (a * cos_term + b * sin_term); + let new_vel = decay + * ((b * omega_d - a * zeta * omega0) * cos_term + - (a * omega_d + b * zeta * omega0) * sin_term); - (new_disp, new_vel) + (new_disp, new_vel) + } + Self::Overdamped { + s1, + s2, + denom, + e1, + e2, + } => { + let c1 = (velocity - displacement * s2) / denom; + let c2 = displacement - c1; + + let new_disp = c1 * e1 + c2 * e2; + let new_vel = c1 * s1 * e1 + c2 * s2 * e2; + + (new_disp, new_vel) + } + Self::OverdampedDegenerate { t, s_avg, decay } => { + let new_disp = decay * (displacement + (velocity - displacement * s_avg) * t); + let new_vel = decay + * ((velocity - displacement * s_avg) + + s_avg * (displacement + (velocity - displacement * s_avg) * t)); + (new_disp, new_vel) + } + Self::CriticallyDamped { t, omega0, decay } => { + let a = displacement; + let b = velocity + displacement * omega0; + + let new_disp = decay * (a + b * t); + let new_vel = decay * (b - omega0 * (a + b * t)); + + (new_disp, new_vel) + } + } } } +#[derive(Clone, Copy)] +struct CachedSpringStep { + key: [u32; 4], + step: SpringStep, +} + impl SpringMassDamperSimulation { pub fn new(config: SpringMassDamperSimulationConfig) -> Self { Self { @@ -84,6 +169,7 @@ impl SpringMassDamperSimulation { position: XY::new(0.0, 0.0), velocity: XY::new(0.0, 0.0), target_position: XY::new(0.0, 0.0), + cached_step: None, } } @@ -103,9 +189,12 @@ impl SpringMassDamperSimulation { self.target_position = target_position; } - pub fn run(&mut self, dt_ms: f32) -> XY { - if dt_ms <= 0.0 { - return self.position; + fn step(&mut self, dt_ms: f32) -> SpringStep { + let key = [dt_ms, self.tension, self.mass, self.friction].map(f32::to_bits); + if let Some(cached) = self.cached_step + && cached.key == key + { + return cached.step; } let t = dt_ms / 1000.0; @@ -116,11 +205,23 @@ impl SpringMassDamperSimulation { let omega0 = (stiffness / mass).sqrt(); let zeta = damping / (2.0 * (stiffness * mass).sqrt()); + let step = SpringStep::new(t, omega0, zeta); + self.cached_step = Some(CachedSpringStep { key, step }); + step + } + + pub fn run(&mut self, dt_ms: f32) -> XY { + if dt_ms <= 0.0 { + return self.position; + } + + let step = self.step(dt_ms); + let disp_x = self.position.x - self.target_position.x; let disp_y = self.position.y - self.target_position.y; - let (new_disp_x, new_vel_x) = solve_spring_1d(disp_x, self.velocity.x, t, omega0, zeta); - let (new_disp_y, new_vel_y) = solve_spring_1d(disp_y, self.velocity.y, t, omega0, zeta); + let (new_disp_x, new_vel_x) = step.solve(disp_x, self.velocity.x); + let (new_disp_y, new_vel_y) = step.solve(disp_y, self.velocity.y); self.position = XY::new( self.target_position.x + new_disp_x, @@ -139,3 +240,161 @@ impl SpringMassDamperSimulation { self.position } } + +#[cfg(test)] +mod tests { + use super::*; + + fn original_solve_spring_1d( + displacement: f32, + velocity: f32, + t: f32, + omega0: f32, + zeta: f32, + ) -> (f32, f32) { + const CRITICAL_EPSILON: f32 = 0.01; + + if zeta < 1.0 - CRITICAL_EPSILON { + let omega_d = omega0 * (1.0 - zeta * zeta).sqrt(); + let decay = (-zeta * omega0 * t).exp(); + let cos_term = (omega_d * t).cos(); + let sin_term = (omega_d * t).sin(); + + let a = displacement; + let b = (velocity + displacement * zeta * omega0) / omega_d.max(1e-4); + + let new_disp = decay * (a * cos_term + b * sin_term); + let new_vel = decay + * ((b * omega_d - a * zeta * omega0) * cos_term + - (a * omega_d + b * zeta * omega0) * sin_term); + + (new_disp, new_vel) + } else if zeta > 1.0 + CRITICAL_EPSILON { + let sqrt_term = (zeta * zeta - 1.0).sqrt(); + let s1 = -omega0 * (zeta - sqrt_term); + let s2 = -omega0 * (zeta + sqrt_term); + let denom = s1 - s2; + + if denom.abs() < 1e-10 { + let s_avg = 0.5 * (s1 + s2); + let decay = (s_avg * t).exp(); + let new_disp = decay * (displacement + (velocity - displacement * s_avg) * t); + let new_vel = decay + * ((velocity - displacement * s_avg) + + s_avg * (displacement + (velocity - displacement * s_avg) * t)); + (new_disp, new_vel) + } else { + let c1 = (velocity - displacement * s2) / denom; + let c2 = displacement - c1; + + let e1 = (s1 * t).exp(); + let e2 = (s2 * t).exp(); + + let new_disp = c1 * e1 + c2 * e2; + let new_vel = c1 * s1 * e1 + c2 * s2 * e2; + + (new_disp, new_vel) + } + } else { + let decay = (-omega0 * t).exp(); + let a = displacement; + let b = velocity + displacement * omega0; + + let new_disp = decay * (a + b * t); + let new_vel = decay * (b - omega0 * (a + b * t)); + + (new_disp, new_vel) + } + } + + fn original_run( + config: SpringMassDamperSimulationConfig, + position: &mut XY, + velocity: &mut XY, + target: XY, + dt_ms: f32, + ) { + let t = dt_ms / 1000.0; + let mass = config.mass.max(0.001); + let stiffness = config.tension; + let damping = config.friction; + + let omega0 = (stiffness / mass).sqrt(); + let zeta = damping / (2.0 * (stiffness * mass).sqrt()); + + let disp_x = position.x - target.x; + let disp_y = position.y - target.y; + + let (new_disp_x, new_vel_x) = original_solve_spring_1d(disp_x, velocity.x, t, omega0, zeta); + let (new_disp_y, new_vel_y) = original_solve_spring_1d(disp_y, velocity.y, t, omega0, zeta); + + *position = XY::new(target.x + new_disp_x, target.y + new_disp_y); + *velocity = XY::new(new_vel_x, new_vel_y); + + let disp_mag = (new_disp_x * new_disp_x + new_disp_y * new_disp_y).sqrt(); + let vel_mag = (new_vel_x * new_vel_x + new_vel_y * new_vel_y).sqrt(); + + if disp_mag < REST_DISPLACEMENT_THRESHOLD && vel_mag < REST_VELOCITY_THRESHOLD { + *position = target; + *velocity = XY::new(0.0, 0.0); + } + } + + #[test] + fn cached_spring_steps_match_original_solver() { + let configs = [ + SpringMassDamperSimulationConfig { + tension: 200.0, + mass: 2.25, + friction: 40.0, + }, + SpringMassDamperSimulationConfig { + tension: 100.0, + mass: 1.0, + friction: 20.0, + }, + SpringMassDamperSimulationConfig { + tension: 50.0, + mass: 1.0, + friction: 200.0, + }, + SpringMassDamperSimulationConfig { + tension: 1e-24, + mass: 1.0, + friction: 1e-11, + }, + SpringMassDamperSimulationConfig { + tension: 400.0, + mass: 0.0, + friction: 1.0, + }, + ]; + let mut cached = SpringMassDamperSimulation::new(configs[0]); + cached.set_position(XY::new(0.1, 0.9)); + let mut position = cached.position; + let mut velocity = cached.velocity; + for step in 0..2_000usize { + let config = configs[(step / 97) % configs.len()]; + let target = XY::new(((step / 150) % 3) as f32 * 0.4, 0.5); + let dt_ms = if step % 11 == 0 { 16.0 } else { 8.0 }; + + original_run(config, &mut position, &mut velocity, target, dt_ms); + + cached.set_config(config); + cached.set_target_position(target); + cached.run(dt_ms); + + assert_eq!( + [ + cached.position.x, + cached.position.y, + cached.velocity.x, + cached.velocity.y + ] + .map(f32::to_bits), + [position.x, position.y, velocity.x, velocity.y].map(f32::to_bits), + "step {step}" + ); + } + } +} diff --git a/crates/rendering/src/zoom_spring.rs b/crates/rendering/src/zoom_spring.rs index 462c931d3d7..eb85f8b8ed6 100644 --- a/crates/rendering/src/zoom_spring.rs +++ b/crates/rendering/src/zoom_spring.rs @@ -205,6 +205,7 @@ enum ZoomClusters { impl ZoomClusters { fn new( cursor_events: &CursorEvents, + moves_time_ordered: bool, recording_start: f64, recording_end: f64, segment: &ZoomSegment, @@ -213,6 +214,7 @@ impl ZoomClusters { let build = |crop| { build_clusters( cursor_events, + moves_time_ordered, recording_start, recording_end, segment.amount, @@ -330,6 +332,7 @@ impl ClickCluster { /// need no separate pass. pub(crate) fn build_clusters( cursor_events: &CursorEvents, + moves_time_ordered: bool, segment_start_secs: f64, segment_end_secs: f64, zoom_amount: f64, @@ -351,23 +354,38 @@ pub(crate) fn build_clusters( m.x.is_finite() && m.y.is_finite() && m.time_ms.is_finite() }; - let events_in_range: Vec<&cap_project::CursorMoveEvent> = cursor_events - .moves + // The narrowed slices only drop moves the filters below would reject, so + // the filters stay and NaN bounds still match a full scan. + let moves = cursor_events.moves.as_slice(); + let (range_moves, moves_until_start, moves_from_start) = if moves_time_ordered { + let from_start = moves.partition_point(|m| m.time_ms < start_ms); + let through_end = moves + .partition_point(|m| m.time_ms <= end_ms) + .max(from_start); + let through_start = moves.partition_point(|m| m.time_ms <= start_ms); + ( + &moves[from_start..through_end], + &moves[..through_start], + &moves[from_start..], + ) + } else { + (moves, moves, moves) + }; + + let events_in_range: Vec<&cap_project::CursorMoveEvent> = range_moves .iter() .filter(finite) .filter(|m| m.time_ms >= start_ms && m.time_ms <= end_ms) .collect(); if events_in_range.is_empty() { - let fallback = cursor_events - .moves + let fallback = moves_until_start .iter() .filter(finite) .rev() .find(|m| m.time_ms <= start_ms) .or_else(|| { - cursor_events - .moves + moves_from_start .iter() .filter(finite) .find(|m| m.time_ms >= start_ms) @@ -580,6 +598,56 @@ struct StepTargets { snap: bool, } +/// Segments with a NaN bound can never be active, so they are left out of the +/// binary-searched index. +#[derive(Default)] +struct ZoomSegmentIndex { + ordered: Vec, + starts: Vec, + /// Running maximum of `end`, so the first entry reaching `t` is also the + /// first segment whose own end reaches `t`. + max_ends: Vec, + instant_windows: Vec<(f64, f64)>, +} + +impl ZoomSegmentIndex { + fn new(zoom_segments: &[ZoomSegment]) -> Self { + let mut index = Self::default(); + let mut max_end = f64::NEG_INFINITY; + for (i, segment) in zoom_segments.iter().enumerate() { + if segment.instant_animation { + index.instant_windows.push(( + segment.start - INSTANT_SNAP_WINDOW_SECS, + segment.end + INSTANT_SNAP_WINDOW_SECS, + )); + } + if segment.start.is_nan() || segment.end.is_nan() { + continue; + } + max_end = max_end.max(segment.end); + index.ordered.push(i); + index.starts.push(segment.start); + index.max_ends.push(max_end); + } + index + } + + /// Same active predicate `SegmentsCursor` used: (start, end]. + fn active_at(&self, timeline_secs: f64) -> Option { + let started = self.starts.partition_point(|&start| timeline_secs > start); + let first_reaching = self + .max_ends + .partition_point(|&max_end| max_end < timeline_secs); + (first_reaching < started).then(|| self.ordered[first_reaching]) + } + + fn snaps_at(&self, timeline_secs: f64) -> bool { + self.instant_windows + .iter() + .any(|&(start, end)| timeline_secs >= start && timeline_secs <= end) + } +} + /// Deterministic, lazily precomputed zoom transform timeline. /// /// Construction is cheap (clusters only); integration happens on demand via @@ -590,6 +658,7 @@ pub struct ZoomTransformTimeline { samples: Vec, state: Option, zoom_segments: Vec, + segment_index: ZoomSegmentIndex, /// Parallel to `zoom_segments`: prebuilt clusters (RECORDING-time ms) for /// Auto segments, `None` for Manual ones. clusters: Vec>, @@ -646,6 +715,13 @@ impl ZoomTransformTimeline { } = selection; let mut zoom_segments = zoom_segments.to_vec(); zoom_segments.sort_by(|a, b| a.start.total_cmp(&b.start).then(a.end.total_cmp(&b.end))); + let segment_index = ZoomSegmentIndex::new(&zoom_segments); + let moves_time_ordered = zoom_segments + .iter() + .any(|segment| matches!(segment.mode, ZoomMode::Auto)) + && cursor_events + .moves + .is_sorted_by(|a, b| a.time_ms <= b.time_ms); let time_map = if zoom_segments.is_empty() { Vec::new() @@ -671,6 +747,7 @@ impl ZoomTransformTimeline { .max(recording_start); Some(ZoomClusters::new( cursor_events, + moves_time_ordered, recording_start, recording_end, segment, @@ -694,6 +771,7 @@ impl ZoomTransformTimeline { samples: Vec::new(), state: None, zoom_segments, + segment_index, clusters, time_map, recording_clip, @@ -712,6 +790,7 @@ impl ZoomTransformTimeline { samples: Vec::new(), state: None, zoom_segments, + segment_index, clusters, time_map, recording_clip, @@ -976,17 +1055,8 @@ impl ZoomTransformTimeline { } fn targets_at(&self, timeline_secs: f64, held_center: XY) -> StepTargets { - // Same active predicate `SegmentsCursor` used: (start, end]. - let active = self - .zoom_segments - .iter() - .position(|s| timeline_secs > s.start && timeline_secs <= s.end); - - let snap = self.zoom_segments.iter().any(|s| { - s.instant_animation - && timeline_secs >= s.start - INSTANT_SNAP_WINDOW_SECS - && timeline_secs <= s.end + INSTANT_SNAP_WINDOW_SECS - }); + let active = self.segment_index.active_at(timeline_secs); + let snap = self.segment_index.snaps_at(timeline_secs); match active { Some(index) => { @@ -2094,6 +2164,125 @@ mod tests { ); } + fn benchmark_project(duration_secs: f64, zoom_count: usize) -> ProjectConfiguration { + let spacing = duration_secs / zoom_count as f64; + let zoom_segments = (0..zoom_count) + .map(|i| { + let start = i as f64 * spacing + spacing * 0.25; + auto_segment(start, start + 4.0, 2.0) + }) + .collect(); + let mut project = project_with_crop_styles(zoom_segments, Vec::new()); + if let Some(timeline) = project.timeline.as_mut() { + timeline.segments.push(TimelineSegment { + recording_clip: 0, + timescale: 1.0, + start: 0.0, + end: duration_secs, + name: None, + speed_audio_mode: None, + volume: None, + hide_cursor: None, + }); + } + project + } + + fn benchmark_cursor(duration_secs: f64) -> CursorEvents { + let moves = (0..(duration_secs * 60.0) as usize) + .map(|i| { + move_event( + i as f64 * 1000.0 / 60.0, + ((i as f64 * 0.0011).sin() + 1.0) / 2.0, + ((i as f64 * 0.0007).cos() + 1.0) / 2.0, + ) + }) + .collect(); + CursorEvents { + moves, + clicks: Vec::new(), + } + } + + fn samples_hash(timeline: &ZoomTransformTimeline) -> u64 { + timeline + .samples + .iter() + .fold(0xcbf2_9ce4_8422_2325, |hash, sample| { + [ + sample.amount.to_bits(), + sample.center.x.to_bits(), + sample.center.y.to_bits(), + sample.activity.to_bits(), + u32::from(sample.snapped), + ] + .into_iter() + .fold(hash, |hash, word| { + (hash ^ u64::from(word)).wrapping_mul(0x0100_0000_01b3) + }) + }) + } + + #[test] + #[ignore = "benchmark; run with --release --ignored --nocapture"] + fn benchmark_zoom_timeline_rebuild() { + use std::time::{Duration, Instant}; + + const RUNS: usize = 15; + let screen_size = XY::new(1920, 1080); + let percentile = |times: &mut Vec, p: f64| { + times.sort_unstable(); + let index = ((times.len() - 1) as f64 * p).round() as usize; + times[index].as_secs_f64() * 1000.0 + }; + + println!( + "| recording | auto zooms | playhead | build p50 | precompute p50 | rebuild p50 | rebuild p95 | samples hash |" + ); + println!("| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |"); + for (minutes, zoom_count) in [(10.0, 20), (30.0, 60), (60.0, 120)] { + let duration_secs = minutes * 60.0; + let project = benchmark_project(duration_secs, zoom_count); + let cursor = benchmark_cursor(duration_secs); + for playhead_fraction in [0.5, 1.0] { + let playhead = (duration_secs * playhead_fraction) as f32; + let mut build_times = Vec::with_capacity(RUNS); + let mut precompute_times = Vec::with_capacity(RUNS); + let mut rebuild_times = Vec::with_capacity(RUNS); + let mut checksum = 0.0f64; + let mut hash = 0; + for _ in 0..RUNS { + let build_start = Instant::now(); + let mut timeline = ZoomTransformTimeline::from_project_for_clip( + &project, + &cursor, + duration_secs, + screen_size, + 0, + ); + let build_elapsed = build_start.elapsed(); + let precompute_start = Instant::now(); + timeline.ensure_precomputed_until(playhead); + let precompute_elapsed = precompute_start.elapsed(); + checksum += timeline.sample(playhead).display_amount(); + hash = samples_hash(&timeline); + build_times.push(build_elapsed); + precompute_times.push(precompute_elapsed); + rebuild_times.push(build_elapsed + precompute_elapsed); + } + std::hint::black_box(checksum); + println!( + "| {minutes} min | {zoom_count} | {:.0}% | {:.3} ms | {:.3} ms | {:.3} ms | {:.3} ms | {hash:016x} |", + playhead_fraction * 100.0, + percentile(&mut build_times, 0.5), + percentile(&mut precompute_times, 0.5), + percentile(&mut rebuild_times, 0.5), + percentile(&mut rebuild_times, 0.95), + ); + } + } + } + #[test] fn timeline_mapping_filters_clusters_in_recording_time() { // Timeline: single segment showing recording range [10s, 20s] at 1x, @@ -2317,4 +2506,111 @@ mod tests { settled.bounds ); } + + #[test] + fn segment_index_matches_linear_segment_scan() { + let mut instant = manual_segment(4.0, 4.5, 2.0, 0.5, 0.5); + instant.instant_animation = true; + let mut instant_nan = manual_segment(f64::NAN, 9.0, 2.0, 0.5, 0.5); + instant_nan.instant_animation = true; + let cases = [ + vec![], + vec![manual_segment(1.0, 2.0, 2.0, 0.5, 0.5)], + vec![ + manual_segment(1.0, 6.0, 2.0, 0.5, 0.5), + manual_segment(2.0, 3.0, 3.0, 0.5, 0.5), + manual_segment(2.0, 3.0, 4.0, 0.5, 0.5), + manual_segment(3.0, 3.0, 2.0, 0.5, 0.5), + manual_segment(5.0, 4.0, 2.0, 0.5, 0.5), + instant, + ], + vec![ + manual_segment(f64::NAN, 2.0, 2.0, 0.5, 0.5), + manual_segment(-f64::NAN, 2.0, 2.0, 0.5, 0.5), + manual_segment(1.0, f64::NAN, 2.0, 0.5, 0.5), + manual_segment(-0.0, 0.0, 2.0, 0.5, 0.5), + manual_segment(f64::NEG_INFINITY, 0.5, 2.0, 0.5, 0.5), + manual_segment(7.0, f64::INFINITY, 2.0, 0.5, 0.5), + instant_nan, + ], + ]; + for segments in cases { + let timeline = timeline_for(&segments, &CursorEvents::default(), 10.0); + let mut times: Vec = segments + .iter() + .flat_map(|s| [s.start, s.end, s.start - 0.1, s.end + 0.1]) + .flat_map(|t| [t, t.next_down(), t.next_up()]) + .collect(); + times.extend((0..=1300).map(|step| step as f64 * STEP_MS / 1000.0)); + for t in times { + let linear_active = timeline + .zoom_segments + .iter() + .position(|s| t > s.start && t <= s.end); + let linear_snap = timeline.zoom_segments.iter().any(|s| { + s.instant_animation + && t >= s.start - INSTANT_SNAP_WINDOW_SECS + && t <= s.end + INSTANT_SNAP_WINDOW_SECS + }); + assert_eq!(timeline.segment_index.active_at(t), linear_active, "t={t}"); + assert_eq!(timeline.segment_index.snaps_at(t), linear_snap, "t={t}"); + } + } + } + + #[test] + fn ordered_cluster_search_matches_full_scan() { + let moves = vec![ + move_event(f64::NEG_INFINITY, 0.1, 0.1), + move_event(0.0, 0.2, 0.2), + move_event(500.0, f64::NAN, 0.5), + move_event(500.0, 0.9, 0.9), + move_event(500.0, 0.1, 0.9), + move_event(1_000.0, 0.5, f64::INFINITY), + move_event(2_000.0, 0.95, 0.05), + move_event(2_000.0, 0.05, 0.95), + move_event(3_500.0, 0.6, 0.4), + move_event(9_000.0, 0.3, 0.7), + move_event(f64::INFINITY, 0.8, 0.2), + ]; + let cursor = CursorEvents { + moves, + clicks: vec![], + }; + assert!(cursor.moves.is_sorted_by(|a, b| a.time_ms <= b.time_ms)); + let crop = CursorCropMap::from_crop( + &Crop { + position: XY::new(100, 50), + size: XY::new(1600, 900), + }, + XY::new(1920, 1080), + ); + let bounds = [ + (0.0, 0.0), + (0.0, 0.5), + (0.5, 0.5), + (0.5, 2.0), + (0.6, 0.9), + (2.5, 3.0), + (4.0, 8.0), + (10.0, 20.0), + (-5.0, -1.0), + (f64::NAN, 2.0), + (0.5, f64::NAN), + (f64::NEG_INFINITY, f64::INFINITY), + ]; + for (start, end) in bounds { + for amount in [1.0, 2.0, 4.0] { + for crop in [None, crop] { + let ordered = build_clusters(&cursor, true, start, end, amount, crop); + let scanned = build_clusters(&cursor, false, start, end, amount, crop); + assert_eq!( + format!("{ordered:?}"), + format!("{scanned:?}"), + "range {start}..{end} amount {amount}" + ); + } + } + } + } }