Repository navigation
perf(desktop-gpui): keep waveform paths cached while the timeline scrolls #2763
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
796b13e
0b7a431
7231621
c3aca3b
6388745
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -712,6 +712,96 @@ impl PartialEq for WaveformGeometry { | |
| } | ||
| } | ||
|
|
||
| /// The span of a segment (segment-local output seconds) its cached waveform | ||
| /// path covers. The visible slice is grown to a window anchored on a grid of | ||
| /// a quarter of the viewport, so a timeline that scrolls -- playback following | ||
| /// the playhead while zoomed in -- reuses one path for a quarter-viewport of | ||
| /// travel instead of rebuilding it every frame. A segment that is entirely | ||
| /// visible covers exactly that, as before. | ||
| pub fn waveform_window(visible: (f64, f64), zoom: f64, duration: f64) -> (f64, f64) { | ||
| if !zoom.is_finite() || zoom <= 0. || !duration.is_finite() || duration <= 0. { | ||
| return visible; | ||
| } | ||
| let grid = zoom / 4.; | ||
| let step = (visible.0 / grid).floor() - 1.; | ||
| let start = (step * grid).max(0.).min(visible.0); | ||
| let end = (start + zoom * 1.5).max(visible.1).min(duration); | ||
| (start, end) | ||
| } | ||
|
|
||
| /// Where a segment's waveform path sits inside its canvas. Everything is | ||
| /// canvas-local, so a timeline that only scrolls keeps hitting the cache; the | ||
| /// path is moved into place when painted. | ||
| #[derive(Debug, Clone, Copy, PartialEq)] | ||
| struct WaveformLayout { | ||
| range: (f64, f64), | ||
| origin: gpui::Point<Pixels>, | ||
| size: gpui::Size<Pixels>, | ||
| samples: usize, | ||
| clip_bounds: gpui::Bounds<Pixels>, | ||
| } | ||
|
|
||
| /// `view` is the timeline's (position, zoom), `segment` its (start, output | ||
| /// duration) and `canvas` the (canvas height, lane width, lane height, wave | ||
| /// height). `None` when the segment is off screen. | ||
| fn waveform_layout( | ||
| view: (f64, f64), | ||
| segment: (f64, f64), | ||
| full_width: f64, | ||
| canvas: (Pixels, Pixels, Pixels, Pixels), | ||
| ) -> Option<WaveformLayout> { | ||
| let (view_start, zoom) = view; | ||
| let (timeline_start, output_duration) = segment; | ||
| let (canvas_height, lane_width, lane_height, wave_height) = canvas; | ||
| // The visible slice, in segment-local output seconds | ||
| // (`TL/ClipTrack.tsx:202-245`). | ||
| let view_end = view_start + zoom; | ||
| let visible_start = view_start.max(timeline_start) - timeline_start; | ||
| let visible_end = view_end.min(timeline_start + output_duration) - timeline_start; | ||
| if visible_end <= visible_start { | ||
| return None; | ||
| } | ||
| let px_per_sec = full_width / output_duration; | ||
| let range = waveform_window((visible_start, visible_end), zoom, output_duration); | ||
| let window_width = (range.1 - range.0) * px_per_sec; | ||
| Some(WaveformLayout { | ||
| range, | ||
| origin: gpui::point(px((range.0 * px_per_sec) as f32), px(0.)), | ||
| size: gpui::size(px(window_width as f32), wave_height), | ||
| samples: waveform_window_sample_count(window_width, zoom * px_per_sec), | ||
| clip_bounds: gpui::Bounds { | ||
| origin: gpui::point(px(0.), canvas_height - lane_height), | ||
| size: gpui::size(lane_width, lane_height), | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| /// `waveform_sample_count` for a window wider than the viewport: the sample | ||
| /// cap grows with the window, so the slice on screen keeps the density it had | ||
| /// when only the visible slice was built. | ||
| fn waveform_window_sample_count(window_width: f64, viewport_width: f64) -> usize { | ||
| let scale = if viewport_width.is_finite() && viewport_width > 0. { | ||
| (window_width / viewport_width).max(1.) | ||
| } else { | ||
| 1. | ||
| }; | ||
| let cap = (MAX_WAVEFORM_SAMPLES as f64 * scale).ceil() as usize; | ||
| ((window_width * WAVEFORM_SAMPLES_PER_PIXEL).ceil() as usize).min(cap) | ||
| } | ||
|
|
||
| /// `path` moved by `offset`: cached waveform paths are built relative to their | ||
| /// segment and placed where the segment is drawn this frame. | ||
| fn translated_path( | ||
| mut path: gpui::Path<Pixels>, | ||
| offset: gpui::Point<Pixels>, | ||
| ) -> gpui::Path<Pixels> { | ||
| for vertex in &mut path.vertices { | ||
| vertex.xy_position = vertex.xy_position + offset; | ||
| } | ||
| path.bounds.origin = path.bounds.origin + offset; | ||
| path | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | ||
| pub enum WaveformTrack { | ||
| Mic, | ||
|
|
@@ -3217,27 +3307,13 @@ fn render_waveform( | |
| gpui::canvas( | ||
| |bounds, _window, _cx| bounds, | ||
| move |_, bounds, window, _cx| { | ||
| // The visible slice, in segment-local output seconds | ||
| // (`TL/ClipTrack.tsx:202-245`). Off screen entirely: nothing. | ||
| let view_start = transform.position; | ||
| let view_end = view_start + transform.zoom; | ||
| let visible_start = view_start.max(timeline_start) - timeline_start; | ||
| let visible_end = view_end.min(timeline_start + output_duration) - timeline_start; | ||
| if visible_end <= visible_start { | ||
| let Some(layout) = waveform_layout( | ||
| (transform.position, transform.zoom), | ||
| (timeline_start, output_duration), | ||
| full_width, | ||
| (bounds.size.height, px(width), px(height), px(wave_height)), | ||
| ) else { | ||
| return; | ||
| } | ||
| let px_per_sec = full_width / output_duration; | ||
| let origin = gpui::point( | ||
| bounds.origin.x + px((visible_start * px_per_sec) as f32), | ||
| bounds.origin.y, | ||
| ); | ||
| let slice_width = ((visible_end - visible_start) * px_per_sec) as f32; | ||
| let size = gpui::size(px(slice_width), px(wave_height)); | ||
| let samples = waveform_sample_count(slice_width as f64); | ||
|
|
||
| let clip_bounds = gpui::Bounds { | ||
| origin: gpui::point(bounds.origin.x, bounds.bottom() - px(height)), | ||
| size: gpui::size(px(width), px(height)), | ||
| }; | ||
|
|
||
| for (track, peaks, scale) in [ | ||
|
|
@@ -3246,16 +3322,17 @@ fn render_waveform( | |
| ] { | ||
| let geometry = WaveformGeometry { | ||
| peaks: peaks.clone(), | ||
| range: (visible_start, visible_end), | ||
| target_samples: samples, | ||
| range: layout.range, | ||
| target_samples: layout.samples, | ||
| holds: holds.clone(), | ||
| source_start: segment_start, | ||
| origin, | ||
| size, | ||
| origin: layout.origin, | ||
| size: layout.size, | ||
| scale, | ||
| clip_bounds, | ||
| clip_bounds: layout.clip_bounds, | ||
| }; | ||
| if let Some(path) = paths.get_or_build((index, track), geometry) { | ||
| let path = translated_path(path, bounds.origin); | ||
| window.with_content_mask(Some(gpui::ContentMask { bounds }), |window| { | ||
| window.paint_path(path, wave_color) | ||
| }); | ||
|
|
@@ -4659,6 +4736,125 @@ mod tests { | |
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_waveform_window_always_covers_the_visible_slice() { | ||
| let duration = 120.; | ||
| let zoom = 20.; | ||
| let mut start = 0.; | ||
| while start + zoom <= duration { | ||
| let visible = (start, start + zoom); | ||
| let window = waveform_window(visible, zoom, duration); | ||
| assert!( | ||
| window.0 <= visible.0 && window.1 >= visible.1, | ||
| "{window:?} {visible:?}" | ||
| ); | ||
| assert!(window.1 - window.0 <= zoom * 1.5 + 1e-9); | ||
| start += 0.37; | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_fully_visible_segment_covers_exactly_its_visible_slice() { | ||
| assert_eq!(waveform_window((0., 8.), 30., 8.), (0., 8.)); | ||
| assert_eq!(waveform_window((0., 30.), 30., 30.), (0., 30.)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn scrolling_reuses_the_window_for_a_quarter_viewport() { | ||
| let zoom = 20.; | ||
| let duration = 600.; | ||
| let mut windows = std::collections::HashSet::new(); | ||
| // 60 s of follow-mode playback at 60 fps. | ||
| for frame in 0..3600 { | ||
| let start = 100. + frame as f64 / 60.; | ||
| let window = waveform_window((start, start + zoom), zoom, duration); | ||
| windows.insert((window.0.to_bits(), window.1.to_bits())); | ||
| } | ||
| assert!(windows.len() <= 13, "{} distinct windows", windows.len()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_translated_relative_path_matches_one_built_in_place() { | ||
| let peaks = speech_like_peaks(400, 7.); | ||
| let offset = gpui::point(px(250.5), px(312.25)); | ||
| let relative = waveform_geometry(peaks.clone()); | ||
| let path = waveform_path(&relative).unwrap(); | ||
| let moved = translated_path(path.clone(), offset); | ||
| for (before, after) in path.vertices.iter().zip(&moved.vertices) { | ||
| assert_eq!(after.xy_position, before.xy_position + offset); | ||
| } | ||
| assert_eq!(moved.bounds.origin, path.bounds.origin + offset); | ||
| assert_eq!(moved.bounds.size, path.bounds.size); | ||
|
|
||
| // Building at the absolute position gives the same shape; the | ||
| // tessellator's triangle order can differ with float rounding, so the | ||
| // comparison is on the vertex count and the bounds. | ||
| let absolute = WaveformGeometry { | ||
| origin: relative.origin + offset, | ||
| clip_bounds: gpui::Bounds { | ||
| origin: relative.clip_bounds.origin + offset, | ||
| size: relative.clip_bounds.size, | ||
| }, | ||
| ..relative.clone() | ||
| }; | ||
| let built = waveform_path(&absolute).unwrap(); | ||
| assert_eq!(built.vertices.len(), moved.vertices.len()); | ||
| let close = |a: Pixels, b: Pixels| (f32::from(a) - f32::from(b)).abs() < 0.01; | ||
| assert!(close(built.bounds.origin.x, moved.bounds.origin.x)); | ||
| assert!(close(built.bounds.origin.y, moved.bounds.origin.y)); | ||
| assert!(close(built.bounds.size.width, moved.bounds.size.width)); | ||
| assert!(close(built.bounds.size.height, moved.bounds.size.height)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn follow_mode_paths_are_built_once_per_window() { | ||
| let paths = WaveformPaths::default(); | ||
| let slot = (0, WaveformTrack::Mic); | ||
| let peaks = speech_like_peaks(2000, 9.); | ||
| let zoom = 20.; | ||
| let segment = (12., 190.); | ||
| let full_width = 190. * 60.; | ||
| let canvas = (px(44.), px(full_width as f32), px(44.), px(18.)); | ||
| for frame in 0..600 { | ||
| let position = 40. + frame as f64 / 60.; | ||
| let layout = waveform_layout((position, zoom), segment, full_width, canvas).unwrap(); | ||
| let geometry = WaveformGeometry { | ||
| range: layout.range, | ||
| target_samples: layout.samples, | ||
| origin: layout.origin, | ||
| size: layout.size, | ||
| clip_bounds: layout.clip_bounds, | ||
| ..waveform_geometry(peaks.clone()) | ||
| }; | ||
|
Comment on lines
+4821
to
+4828
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Build the test geometry through the same code as Prompt To Fix With AIThis is a comment left during a code review.
Path: apps/desktop-gpui/src/editor_timeline.rs
Line: 4766-4769
Comment:
**Scrolling test misses changing positions**
`follow_mode_paths_are_built_once_per_window` changes only `range`; `origin`, `size`, and `clip_bounds` stay fixed in the helper. Those fields are part of the cache key, and changing screen coordinates caused the original cache misses. This test would still pass if `render_waveform` accidentally used screen coordinates again.
Build the test geometry through the same code as `render_waveform`, vary the canvas position, and check that scrolling within one window still hits the cache.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| paths.get_or_build(slot, geometry).unwrap(); | ||
| } | ||
| let stats = paths.stats(); | ||
| assert!(stats.builds <= 3, "{stats:?}"); | ||
| assert_eq!(stats.builds + stats.hits, 600); | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_off_screen_segment_has_no_waveform_layout() { | ||
| let canvas = (px(44.), px(600.), px(44.), px(18.)); | ||
| assert!(waveform_layout((0., 20.), (30., 10.), 600., canvas).is_none()); | ||
| assert!(waveform_layout((50., 20.), (30., 10.), 600., canvas).is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_wider_window_keeps_the_on_screen_sample_density_at_the_cap() { | ||
| for (window, viewport) in [(15_000., 10_000.), (4_500., 3_000.), (900., 600.)] { | ||
| let samples = waveform_window_sample_count(window, viewport); | ||
| let window_density = samples as f64 / window; | ||
| let visible_density = waveform_sample_count(viewport) as f64 / viewport; | ||
| assert!( | ||
| (window_density - visible_density).abs() < 1e-3, | ||
| "{window} {viewport}: {window_density} vs {visible_density}" | ||
| ); | ||
| } | ||
| assert_eq!(waveform_window_sample_count(300., 600.), 600); | ||
| assert_eq!(waveform_window_sample_count(300., 0.), 600); | ||
| } | ||
|
|
||
| #[test] | ||
| fn cached_waveform_matches_an_uncached_build_after_every_input_change() { | ||
| let paths = WaveformPaths::default(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wider-window sample cap is truncated by waveform_path's MAX clamp
Medium Severity
The defect lives in the unchanged
waveform_path(apps/desktop-gpui/src/editor_timeline.rs:599-601), which computesnum_samples = target_samples.clamp(50, MAX_WAVEFORM_SAMPLES).min(native_samples); the changed helper at line 789 only affects the result through that clamp. Whenever the scaled cap actually matters — a window wider than 3000px, i.e. a zoomed-out long recording wherewindow_width * 2 > 6000, e.g. viewport 6000px and window 9000px —waveform_window_sample_countreturns up to 9000, andwaveform_pathsilently truncates it to 6000. The rendered sample count is therefore stillmin(window_width * 2, MAX_WAVEFORM_SAMPLES), byte-for-byte the oldwaveform_sample_count(window_width)behavior, so the commit's stated goal (the on-screen slice keeping its pre-scrolling density in a wider cached window) is never realized and the on-screen density still drops by up to 1/3 in exactly the scenario the commit targets. The new testthe_wider_window_keeps_the_on_screen_sample_density_at_the_cappasses only because it exerciseswaveform_window_sample_countin isolation and never builds a path with its result. Fix by raising the cap inwaveform_pathto the same scaled bound (e.g. pass the scaled cap alongsidetarget_samples, or avoid down-truncating before the clamp) and cover the fix with a test that round-trips throughwaveform_path.