From 9df8106e7eb99a993b538a8955d7108defcf5320 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Tue, 13 Oct 2026 13:43:48 -0400 Subject: [PATCH 1/2] perf(editor): avoid playback hitches during unrelated live edits --- .../examples/editor-playback-benchmark.rs | 75 ++++++++++++++++--- crates/editor/src/editor_instance.rs | 26 ++++--- crates/editor/src/playback.rs | 8 +- 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/crates/editor/examples/editor-playback-benchmark.rs b/crates/editor/examples/editor-playback-benchmark.rs index 14305d6c61a..d526b22fe64 100644 --- a/crates/editor/examples/editor-playback-benchmark.rs +++ b/crates/editor/examples/editor-playback-benchmark.rs @@ -89,6 +89,8 @@ struct BenchmarkSummary { submitted_frames: u64, rendered_frames: u64, callback_frames: u64, + last_callback: Option, + callback_gaps: DurationStats, dropped_by_renderer: u64, send_failures: u64, skipped_frames: u64, @@ -273,7 +275,12 @@ impl BenchmarkSummary { } } - fn record_callback_frame(&mut self, bytes: usize) { + fn record_callback_frame(&mut self, bytes: usize, received_at: Instant) { + if let Some(previous) = self.last_callback { + self.callback_gaps + .push(received_at.saturating_duration_since(previous)); + } + self.last_callback = Some(received_at); self.callback_frames += 1; self.bytes_from_callback = self.bytes_from_callback.saturating_add(bytes as u64); } @@ -435,6 +442,12 @@ fn parse_resolution(args: &[String]) -> XY { } } +#[derive(Clone, Copy, Debug)] +enum LiveEdit { + Background, + CursorSpring, +} + #[tokio::main] async fn main() { tracing_subscriber::fmt() @@ -447,6 +460,19 @@ async fn main() { ffmpeg::init().expect("Failed to initialize FFmpeg"); let args: Vec = std::env::args().collect(); + let live_edit = arg_value(&args, "--live-edit").map(|kind| match kind { + "background" => LiveEdit::Background, + "cursor-spring" => LiveEdit::CursorSpring, + _ => panic!("--live-edit must be background or cursor-spring"), + }); + let edit_interval_ms = arg_value(&args, "--edit-interval-ms") + .map(|value| { + value + .parse::() + .expect("--edit-interval-ms must be an integer") + }) + .unwrap_or(50); + assert!(edit_interval_ms > 0, "--edit-interval-ms must be positive"); let recording_path = arg_value(&args, "--recording-path") .map(PathBuf::from) @@ -521,7 +547,7 @@ async fn main() { let layers_rx = finish_renderer_layers_creation(layers_rx).await; let (telemetry, mut telemetry_rx) = PlaybackTelemetry::channel(); - let (frame_tx, mut frame_rx) = mpsc::unbounded_channel::(); + let (frame_tx, mut frame_rx) = mpsc::unbounded_channel::<(usize, Instant)>(); let frame_cb = Box::new(move |output: EditorFrameOutput, _: FrameLayout| { let bytes = match output { EditorFrameOutput::Nv12(frame) => { @@ -536,7 +562,7 @@ async fn main() { #[cfg(target_os = "macos")] EditorFrameOutput::Surface(_) => return, }; - let _ = frame_tx.send(bytes); + let _ = frame_tx.send((bytes, Instant::now())); }); let renderer = match Renderer::spawn_with_telemetry( @@ -574,10 +600,16 @@ async fn main() { .collect() }; - let (_project_tx, project_rx) = watch::channel(project); + let base_padding = project.background.padding; + let base_tension = project.cursor.tension; + let (project_tx, project_rx) = watch::channel(project); // One persistent output stream shared across presses, like the real app. - let audio_output = Arc::new(cap_editor::AudioOutput::new()); + let audio_output = Arc::new(if args.iter().any(|arg| arg == "--headless") { + cap_editor::AudioOutput::new_headless(Box::new(|_, _| {})) + } else { + cap_editor::AudioOutput::new() + }); audio_output.prewarm(); let mut summary = BenchmarkSummary::default(); @@ -611,6 +643,15 @@ async fn main() { tokio::pin!(deadline); let mut press_summary = BenchmarkSummary::default(); + let mut live_edit_count = 0_u64; + let mut edit_timer = live_edit.map(|_| { + let mut timer = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_millis(500), + Duration::from_millis(edit_interval_ms), + ); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + timer + }); loop { if press_summary.submitted_frames >= press_frames { @@ -626,11 +667,19 @@ async fn main() { break; } } - bytes = frame_rx.recv() => { - if let Some(bytes) = bytes { - press_summary.record_callback_frame(bytes); + frame = frame_rx.recv() => { + if let Some((bytes, received_at)) = frame { + press_summary.record_callback_frame(bytes, received_at); } } + _ = async { edit_timer.as_mut().unwrap().tick().await }, if edit_timer.is_some() => { + let offset = if live_edit_count % 2 == 0 { 1.0 } else { 0.0 }; + project_tx.send_modify(|project| match live_edit.unwrap() { + LiveEdit::Background => project.background.padding = base_padding + offset, + LiveEdit::CursorSpring => project.cursor.tension = base_tension + offset as f32, + }); + live_edit_count += 1; + } _ = &mut deadline => { break; } @@ -645,8 +694,13 @@ async fn main() { while let Ok(event) = telemetry_rx.try_recv() { press_summary.record_event(event); } - while let Ok(bytes) = frame_rx.try_recv() { - press_summary.record_callback_frame(bytes); + while let Ok((bytes, received_at)) = frame_rx.try_recv() { + press_summary.record_callback_frame(bytes, received_at); + } + if let Some(kind) = live_edit { + println!( + "Live edits: kind={kind:?} interval_ms={edit_interval_ms} sent={live_edit_count}" + ); } let press_time_secs = press_start_frame as f64 / fps as f64; @@ -744,6 +798,7 @@ async fn main() { summary.render_finish_submit.print("finish submit"); summary.render_immediate_flush.print("immediate flush"); summary.callback.print("callback packing"); + summary.callback_gaps.print("callback gap"); DurationStats::print_values("render steady", steady_render_values); println!("\nRenderer prepare stages:"); diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index ea0569daa9a..511d484eedd 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -27,6 +27,19 @@ pub(super) const PREVIEW_RENDER_MAX_ATTEMPTS: u32 = 3; pub(super) const PREVIEW_RENDER_RETRY_DELAY_MS: u64 = 120; const PREVIEW_CURSOR_CACHE_CAPACITY: usize = 2; +pub(super) fn cursor_timeline_settings(cursor: &cap_project::CursorConfiguration) -> [u32; 7] { + let click_spring = cursor.click_spring_config(); + [ + u32::from(cursor.raw), + cursor.tension.to_bits(), + cursor.mass.to_bits(), + cursor.friction.to_bits(), + click_spring.tension.to_bits(), + click_spring.mass.to_bits(), + click_spring.friction.to_bits(), + ] +} + #[derive(Default)] pub(super) struct PreviewCursorCache { entries: Vec, @@ -73,15 +86,7 @@ impl PreviewCursorCache { friction: project.cursor.friction, }; let click_spring = project.cursor.click_spring_config(); - let settings = [ - u32::from(project.cursor.raw), - smoothing.tension.to_bits(), - smoothing.mass.to_bits(), - smoothing.friction.to_bits(), - click_spring.tension.to_bits(), - click_spring.mass.to_bits(), - click_spring.friction.to_bits(), - ]; + let settings = cursor_timeline_settings(&project.cursor); if let Some(index) = self.entries.iter().position(|entry| { entry.recording_clip == recording_clip @@ -1962,6 +1967,9 @@ mod tests { project.cursor.rotation_amount += 0.1; project.cursor.stop_movement_in_last_seconds = Some(0.5); project.cursor.click_spring = Some(project.cursor.click_spring_config()); + project.background.padding += 1.0; + project.audio.mic_volume_db += 1.0; + project.cursor.motion_blur += 0.1; let repeated = cache.get(0, &Arc::clone(&cursor), &project).unwrap(); assert!(Arc::ptr_eq(&first, &repeated)); diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs index 95696fa680e..b27e2b2e1ca 100644 --- a/crates/editor/src/playback.rs +++ b/crates/editor/src/playback.rs @@ -25,7 +25,7 @@ use tracing::{info, warn}; use crate::{ audio_output::{AudioOutput, PlaySpec}, editor, - editor_instance::SegmentMedia, + editor_instance::{SegmentMedia, cursor_timeline_settings}, segments::get_audio_segments, telemetry::{ PlaybackFrameSource, PlaybackSkipReason, PlaybackTelemetry, PlaybackTelemetryEvent, @@ -1412,6 +1412,8 @@ impl Playback { let next_project = self.project.borrow_and_update().clone(); let audio_changed = cached_project.audio != next_project.audio || clip_audio_changed(&cached_project, &next_project); + let cursor_changed = cursor_timeline_settings(&cached_project.cursor) + != cursor_timeline_settings(&next_project.cursor); cached_project = next_project; if adopted.is_none() && audio_changed @@ -1429,7 +1431,9 @@ impl Playback { generation, ); } - cursor_timelines = build_cursor_timelines(&cached_project); + 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); } From 03ebccfc977eed5aaa96be3b7b531f3b001befbb Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Tue, 13 Oct 2026 14:14:02 -0400 Subject: [PATCH 2/2] fix(editor): preserve live-edit toggles across benchmark presses --- crates/editor/examples/editor-playback-benchmark.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/editor/examples/editor-playback-benchmark.rs b/crates/editor/examples/editor-playback-benchmark.rs index d526b22fe64..e1d15e212fb 100644 --- a/crates/editor/examples/editor-playback-benchmark.rs +++ b/crates/editor/examples/editor-playback-benchmark.rs @@ -614,6 +614,7 @@ async fn main() { let mut summary = BenchmarkSummary::default(); let mut measured_elapsed = 0.0f64; + let mut live_edit_high = false; for (press_idx, (press_start_frame, press_frames)) in presses.iter().copied().enumerate() { let playback = Playback { @@ -673,7 +674,8 @@ async fn main() { } } _ = async { edit_timer.as_mut().unwrap().tick().await }, if edit_timer.is_some() => { - let offset = if live_edit_count % 2 == 0 { 1.0 } else { 0.0 }; + live_edit_high = !live_edit_high; + let offset = if live_edit_high { 1.0 } else { 0.0 }; project_tx.send_modify(|project| match live_edit.unwrap() { LiveEdit::Background => project.background.padding = base_padding + offset, LiveEdit::CursorSpring => project.cursor.tension = base_tension + offset as f32,