From a3ac27c3446e40dfd4d8936aea143640415fbcbe Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Fri, 2 Oct 2026 19:46:07 -0700 Subject: [PATCH] fix(preview): publish preview frames per fps slot, not per rounded-up ms should_publish_preview_frame turned a target rate into a whole millisecond interval rounded up: 30 fps became 34 ms. The render loop delivers frames about 33 ms apart, so the gate refused every other frame and the preview bus carried 15 to 19 fps to anyone asking for 30. The Remote live preview's acceptance lane measured exactly that on a 480 px VP8 track whose encoder was keeping pace. The gate now publishes once per 1/fps slot of the uptime clock: a frame publishes when it lands in a later slot than the last published frame. Slots hand out the target rate on average, absorb render jitter in either direction, and still cap a fast loop at a slow target (a 60 fps loop against 2 fps publishes twice a second). The slot index is fixed point so the comparison stays exact past u32 milliseconds of uptime, which the existing test now covers from a slot boundary. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017QqD4e2C7kLCBtZBEfJyTx --- .../src/render_thread/pipeline_runtime.rs | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs index f1a9277a8..664eec33a 100644 --- a/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs +++ b/crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs @@ -1117,8 +1117,15 @@ fn should_publish_preview_frame( let Some(target_fps) = target_fps else { return true; }; - let interval_ms = 1000_u64.div_ceil(u64::from(target_fps.max(1))); - last_publish_ms.is_none_or(|last_sent| elapsed_ms.saturating_sub(last_sent) >= interval_ms) + // Publish once per 1/fps slot of the uptime clock rather than once per + // whole-millisecond interval. A 30 fps target rounded up to a 34 ms + // interval refused every other frame of a 33 ms render cadence, so the + // preview ran at half rate; slots deliver the target rate on average and + // absorb render jitter in either direction. A fixed-point slot index + // keeps the comparison exact past u32 milliseconds of uptime. + let target_fps = u128::from(target_fps.max(1)); + let slot = |at_ms: u64| u128::from(at_ms) * target_fps / 1000; + last_publish_ms.is_none_or(|last_sent| slot(elapsed_ms) > slot(last_sent)) } fn preview_publication_due( @@ -3265,7 +3272,9 @@ mod tests { #[test] fn preview_cadence_remains_live_after_u32_millisecond_uptime() { - let last_publish_ms = u64::from(u32::MAX) + 1_000; + // A slot boundary sits at every multiple of 1000/60 ms; start the + // clock on one so the two assertions straddle exactly one slot. + let last_publish_ms = (u64::from(u32::MAX) + 1_000) / 50 * 50; assert!(!should_publish_preview_frame( last_publish_ms + 15, @@ -3279,6 +3288,42 @@ mod tests { )); } + #[test] + fn preview_cadence_keeps_a_30_fps_render_loop_at_30_fps() { + // Frames land every 33 ms, as a 30 fps render loop measured in whole + // milliseconds does. The old 34 ms interval gate refused every other + // one; slots publish all but the occasional frame that shares a slot + // with its predecessor. + let mut last = None; + let mut published = 0; + for frame in 0..300_u64 { + let elapsed = frame * 33; + if should_publish_preview_frame(elapsed, last, Some(30)) { + published += 1; + last = Some(elapsed); + } + } + assert!( + (290..=300).contains(&published), + "published {published} of 300" + ); + } + + #[test] + fn preview_cadence_caps_a_fast_render_loop_at_the_target() { + // A 60 fps loop against a 2 fps target publishes twice a second. + let mut last = None; + let mut published = 0; + for frame in 0..600_u64 { + let elapsed = frame * 1000 / 60; + if should_publish_preview_frame(elapsed, last, Some(2)) { + published += 1; + last = Some(elapsed); + } + } + assert_eq!(published, 20); + } + #[test] fn output_frame_source_reuses_routed_outputs_when_dependencies_match() { let mut reuse = OutputReuseState::default();