From d7ca65ff2716cff4db01d078352334e7a8d1de81 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 20 Jul 2026 01:25:07 +0200 Subject: [PATCH 1/3] perf(studio): preview scale selector and prefetch worker pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heavy scenarios (glass, camera, parallax) cost ~150ms CPU per frame at full resolution — 4.5x the 33ms budget of 30fps playback — and the single prefetch thread rendered at scale 1.0, so the cache drained and playback stalled while the exported MP4 (parallel, offline) was smooth. - render preview frames at a UI-selected scale (100/75/50/25%, default 50%); the scale is part of the frame cache key and a scale switch purges stale-scale entries on first insert - replace the single prefetch thread with a worker pool (cores-2, clamped to [2,6]); workers spread over the window through a shared claim set so no frame is rendered twice concurrently - the transport bar gains a preview-quality select; export still renders at 100% --- crates/rustmotion-studio/src/editor/frames.rs | 9 + .../rustmotion-studio/src/editor/playback.rs | 32 +++ .../rustmotion-studio/src/editor/prefetch.rs | 245 +++++++++++++++--- crates/rustmotion-studio/src/editor/view.rs | 24 +- 4 files changed, 269 insertions(+), 41 deletions(-) diff --git a/crates/rustmotion-studio/src/editor/frames.rs b/crates/rustmotion-studio/src/editor/frames.rs index a3b6e2d..a4cb8ab 100644 --- a/crates/rustmotion-studio/src/editor/frames.rs +++ b/crates/rustmotion-studio/src/editor/frames.rs @@ -185,6 +185,15 @@ mod tests { assert_eq!(&jpeg[0..2], &[0xFF, 0xD8], "must be a JPEG"); } + #[test] + fn renders_frame_at_reduced_scale() { + let scenario = rustmotion::loader::load_scenario_from_source(None, Some(SCENARIO)).unwrap(); + let tasks = rustmotion::encode::build_frame_tasks(&scenario); + let jpeg = render_frame(&scenario, &tasks, 0, 0.5); + let img = image::load_from_memory(&jpeg).expect("decodable JPEG"); + assert_eq!((img.width(), img.height()), (640, 360), "half of 1280x720"); + } + #[test] fn frame_hits_are_in_percent_and_have_kind() { let json = r##"{ "video": { "width": 800, "height": 600, "background": "#101418" }, "scenes": [ { "duration": 1.0, "children": [ { "type": "text", "content": "Hi", "style": { "font-size": 40 } } ] } ] }"##; diff --git a/crates/rustmotion-studio/src/editor/playback.rs b/crates/rustmotion-studio/src/editor/playback.rs index 4cff975..5b50669 100644 --- a/crates/rustmotion-studio/src/editor/playback.rs +++ b/crates/rustmotion-studio/src/editor/playback.rs @@ -4,8 +4,11 @@ use dioxus::prelude::*; use dioxus_icons::lucide::{Pause, Play}; use crate::components::button::{Button, ButtonSize, ButtonVariant}; +use crate::components::select::{Select, SelectOption}; use crate::scenario::Shared; +use super::prefetch::{set_preview_scale_pct, PREVIEW_SCALE_CHOICES}; + /// What a playback keyboard shortcut does (see [`playback_action`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlaybackAction { @@ -93,6 +96,7 @@ pub fn PlaybackBar( total: u32, diff_active: Signal, mut diff_side: Signal, + mut preview_scale: Signal, ) -> Element { use super::diff_panel::DiffSide; @@ -162,6 +166,34 @@ pub fn PlaybackBar( } }, } + // Preview quality: render scale of the preview frames only (the + // export always renders at 100%). Lower = smoother playback on + // heavy scenarios (glass, camera, parallax). + div { + title: "Preview quality (export is always 100%)", + style: "width:96px; flex:none;", + Select:: { + default_value: Some(preview_scale()), + on_value_change: move |v: Option| { + if let Some(pct) = v { + // Atomic first: the render threads and the asset + // handler must see the new scale before the signal + // change triggers the refetch. + set_preview_scale_pct(pct); + preview_scale.set(pct); + } + }, + for (i, pct) in PREVIEW_SCALE_CHOICES.iter().enumerate() { + SelectOption:: { + key: "{pct}", + index: i, + value: *pct, + text_value: "{pct}%", + "{pct}%" + } + } + } + } div { style: "min-width:120px; text-align:right; color:var(--rm-text-muted);", "{cur} / {max}" } diff --git a/crates/rustmotion-studio/src/editor/prefetch.rs b/crates/rustmotion-studio/src/editor/prefetch.rs index a13557d..303ab39 100644 --- a/crates/rustmotion-studio/src/editor/prefetch.rs +++ b/crates/rustmotion-studio/src/editor/prefetch.rs @@ -8,8 +8,9 @@ //! mutex; a frame can at worst be rendered twice when the `` requests it //! while the prefetcher is mid-render (accepted — no cross-coordination). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -30,13 +31,37 @@ pub const WINDOW_AHEAD: u32 = 30; /// Frames kept behind the playhead while paused (scrub-back comfort). pub const WINDOW_BEHIND: u32 = 5; +/// Preview render scale in percent. Applies to every preview render (prefetch +/// workers and the on-demand asset handler); the export path always renders at +/// full resolution. Atomic so non-UI threads read it without a Dioxus runtime. +pub const DEFAULT_PREVIEW_SCALE_PCT: u16 = 50; +/// The scales offered by the transport-bar quality selector. +pub const PREVIEW_SCALE_CHOICES: [u16; 4] = [100, 75, 50, 25]; + +static PREVIEW_SCALE_PCT: AtomicU16 = AtomicU16::new(DEFAULT_PREVIEW_SCALE_PCT); + +pub fn preview_scale_pct() -> u16 { + PREVIEW_SCALE_PCT.load(Ordering::Relaxed) +} + +pub fn set_preview_scale_pct(pct: u16) { + PREVIEW_SCALE_PCT.store(pct.clamp(10, 100), Ordering::Relaxed); +} + +/// Percent → render scale factor for [`render_frame`]. +pub fn scale_factor(pct: u16) -> f32 { + pct as f32 / 100.0 +} + /// Cache key: which model state (`generation` = model generation for side B, -/// baseline source hash for side A), which side, which frame. +/// baseline source hash for side A), which side, which frame, at which +/// preview scale (a frame rendered at 50% must never be served for 100%). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct FrameKey { pub generation: u64, pub side: DiffSide, pub frame: u32, + pub scale_pct: u16, } // ── Pure logic ─────────────────────────────────────────────────────────────── @@ -68,7 +93,8 @@ pub fn prefetch_window(current: u32, playing: bool, total: u32) -> Vec { out } -/// Which keys to drop: every stale-generation entry for a side whose expected +/// Which keys to drop: every entry at a different preview scale than +/// `scale_pct`, every stale-generation entry for a side whose expected /// generation is known (`None` = unknowable, keep), then — if the map would /// still exceed `cap` — the entries farthest from the playhead (`head`) until /// it fits. @@ -78,10 +104,14 @@ pub fn select_evictions( gen_a: Option, head: u32, cap: usize, + scale_pct: u16, ) -> Vec { - let is_stale = |k: &FrameKey| match k.side { - DiffSide::B => gen_b.is_some_and(|g| k.generation != g), - DiffSide::A => gen_a.is_some_and(|g| k.generation != g), + let is_stale = |k: &FrameKey| { + k.scale_pct != scale_pct + || match k.side { + DiffSide::B => gen_b.is_some_and(|g| k.generation != g), + DiffSide::A => gen_a.is_some_and(|g| k.generation != g), + } }; let mut evict: Vec = keys.iter().filter(|k| is_stale(k)).copied().collect(); @@ -121,6 +151,8 @@ impl FrameCache { } /// Insert a frame then enforce the eviction policy around the playhead. + /// `scale_pct` is the expected preview scale: entries at any other scale + /// are purged (a scale change instantly frees the whole stale set). pub fn insert( &mut self, key: FrameKey, @@ -128,10 +160,11 @@ impl FrameCache { gen_b: Option, gen_a: Option, head: u32, + scale_pct: u16, ) { self.map.insert(key, Arc::new(bytes)); let keys: Vec = self.map.keys().copied().collect(); - for k in select_evictions(&keys, gen_b, gen_a, head, CACHE_CAP) { + for k in select_evictions(&keys, gen_b, gen_a, head, CACHE_CAP, scale_pct) { self.map.remove(&k); } } @@ -146,6 +179,41 @@ pub fn frame_cache() -> SharedFrameCache { .clone() } +// ── Claim set ──────────────────────────────────────────────────────────────── + +/// In-flight render claims shared by the prefetch workers: a worker renders a +/// frame only after winning its claim, so N workers spread over the window +/// instead of rendering the same nearest frame N times. +#[derive(Default)] +pub struct ClaimSet { + set: HashSet, +} + +impl ClaimSet { + /// `true` when the caller now owns the claim; `false` when another worker + /// already holds it. + pub fn try_claim(&mut self, key: FrameKey) -> bool { + self.set.insert(key) + } + + pub fn release(&mut self, key: &FrameKey) { + self.set.remove(key); + } +} + +fn claims() -> &'static Mutex { + static SLOT: OnceLock> = OnceLock::new(); + SLOT.get_or_init(|| Mutex::new(ClaimSet::default())) +} + +/// Prefetch worker count for a machine with `cores` logical cores: leave two +/// for the UI/webview and the asset handler, keep at least two workers so a +/// slow frame never serializes the window, and cap at six (JPEG frames past +/// that saturate the cache mutex instead of helping). +pub fn worker_count(cores: usize) -> usize { + cores.saturating_sub(2).clamp(2, 6) +} + // ── Prefetcher ─────────────────────────────────────────────────────────────── /// What the UI wants prefetched, published every time the playhead/side/model @@ -182,17 +250,24 @@ fn prefetch_slot() -> Arc> { .clone() } -/// (current, playing, generation, side) — the tuple whose change aborts an -/// in-flight prefetch pass so the window re-targets immediately. -fn target_fingerprint(t: &PrefetchTarget) -> (u32, bool, u64, DiffSide) { - (t.current, t.playing, t.generation, t.side) +/// (current, playing, generation, side, scale) — the tuple whose change aborts +/// an in-flight prefetch pass so the window re-targets immediately. +fn target_fingerprint(t: &PrefetchTarget, scale_pct: u16) -> (u32, bool, u64, DiffSide, u16) { + (t.current, t.playing, t.generation, t.side, scale_pct) } -/// Spawn the singleton prefetch thread (idempotent). +/// Spawn the prefetch worker pool (idempotent). Workers share the window +/// through the claim set: each renders the nearest unclaimed missing frame, +/// so throughput scales with cores instead of serializing on one thread. pub fn ensure_prefetcher() { static STARTED: OnceLock<()> = OnceLock::new(); STARTED.get_or_init(|| { - std::thread::spawn(prefetch_loop); + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + for _ in 0..worker_count(cores) { + std::thread::spawn(prefetch_loop); + } }); } @@ -226,7 +301,8 @@ fn prefetch_loop() { let total = tasks.len() as u32; let window = prefetch_window(target.current, target.playing, total); - let fingerprint = target_fingerprint(&target); + let scale = preview_scale_pct(); + let fingerprint = target_fingerprint(&target, scale); // Expected generations for eviction: the model generation is always // known; the baseline hash only while side A is active. let (gen_b, gen_a) = match target.side { @@ -239,6 +315,7 @@ fn prefetch_loop() { generation: gen, side: target.side, frame, + scale_pct: scale, }; if frame_cache() .lock() @@ -247,25 +324,46 @@ fn prefetch_loop() { { continue; } - // Panic fence: a Skia panic must skip the frame, not kill the - // prefetcher for the whole session. - let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - render_frame(&scenario, &tasks, frame, 1.0) - })); - if let Ok(bytes) = rendered { - frame_cache() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(key, bytes, gen_b, gen_a, target.current); + // Another worker already rendering this frame → take the next one. + if !claims() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .try_claim(key) + { + continue; + } + // Double-check after winning the claim: the asset handler may have + // rendered and inserted this frame while we raced for it. + let already = frame_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(&key); + if !already { + // Panic fence: a Skia panic must skip the frame, not kill the + // worker for the whole session. + let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + render_frame(&scenario, &tasks, frame, scale_factor(scale)) + })); + if let Ok(bytes) = rendered { + frame_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key, bytes, gen_b, gen_a, target.current, scale); + } } + claims() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .release(&key); // Abort the pass as soon as the UI retargets (seek, play/pause, - // reload, side flip) so the new window starts immediately. + // reload, side flip, scale change) so the new window starts + // immediately. let now = prefetch_slot() .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); - if target_fingerprint(&now) != fingerprint { + if target_fingerprint(&now, preview_scale_pct()) != fingerprint { break; } } @@ -323,6 +421,7 @@ mod tests { generation: gen, side, frame, + scale_pct: 100, } } @@ -387,7 +486,7 @@ mod tests { key(7, DiffSide::A, 5), // stale (gen_a = 9) key(9, DiffSide::A, 6), ]; - let out = select_evictions(&keys, Some(2), Some(9), 5, 100); + let out = select_evictions(&keys, Some(2), Some(9), 5, 100, 100); assert!(out.contains(&key(1, DiffSide::B, 0))); assert!(out.contains(&key(7, DiffSide::A, 5))); assert_eq!(out.len(), 2, "fresh entries stay under cap"); @@ -396,7 +495,7 @@ mod tests { #[test] fn evictions_unknown_side_a_generation_keeps_a_entries() { let keys = vec![key(7, DiffSide::A, 5), key(2, DiffSide::B, 5)]; - let out = select_evictions(&keys, Some(2), None, 5, 100); + let out = select_evictions(&keys, Some(2), None, 5, 100, 100); assert!(out.is_empty(), "A staleness unknowable without gen_a"); } @@ -407,7 +506,7 @@ mod tests { .iter() .map(|&f| key(1, DiffSide::B, f)) .collect(); - let out = select_evictions(&keys, Some(1), None, 10, 4); + let out = select_evictions(&keys, Some(1), None, 10, 4, 100); assert_eq!(out.len(), 2); assert!(out.contains(&key(1, DiffSide::B, 50))); assert!(out.contains(&key(1, DiffSide::B, 30))); @@ -417,7 +516,7 @@ mod tests { fn evictions_stale_then_distance_combined() { let mut keys: Vec = (0..5).map(|f| key(1, DiffSide::B, f)).collect(); // stale keys.extend((0..6).map(|f| key(2, DiffSide::B, f * 10))); // fresh: 0,10,20,30,40,50 - let out = select_evictions(&keys, Some(2), None, 0, 4); + let out = select_evictions(&keys, Some(2), None, 0, 4, 100); // All 5 stale go, plus the 2 farthest fresh (50, 40) to reach cap 4. assert_eq!(out.len(), 7); assert!(out.contains(&key(2, DiffSide::B, 50))); @@ -425,13 +524,27 @@ mod tests { assert!(!out.contains(&key(2, DiffSide::B, 0))); } + #[test] + fn evictions_drop_other_scale_entries() { + let old = FrameKey { + scale_pct: 100, + ..key(1, DiffSide::B, 5) + }; + let fresh = FrameKey { + scale_pct: 50, + ..key(1, DiffSide::B, 5) + }; + let out = select_evictions(&[old, fresh], Some(1), None, 5, 100, 50); + assert_eq!(out, vec![old], "same frame at the old scale is stale"); + } + // ── Cache integration (fake frames, no threads) ───────────────────── #[test] fn cache_roundtrip_and_side_separation() { let mut c = FrameCache::default(); - c.insert(key(1, DiffSide::B, 3), vec![0xB], Some(1), None, 3); - c.insert(key(1, DiffSide::A, 3), vec![0xA], None, Some(1), 3); + c.insert(key(1, DiffSide::B, 3), vec![0xB], Some(1), None, 3, 100); + c.insert(key(1, DiffSide::A, 3), vec![0xA], None, Some(1), 3, 100); assert_eq!(c.get(&key(1, DiffSide::B, 3)).unwrap().as_slice(), &[0xB]); assert_eq!(c.get(&key(1, DiffSide::A, 3)).unwrap().as_slice(), &[0xA]); assert!( @@ -444,7 +557,7 @@ mod tests { fn cache_stays_bounded_and_keeps_nearest() { let mut c = FrameCache::default(); for f in 0..(CACHE_CAP as u32 + 20) { - c.insert(key(1, DiffSide::B, f), vec![0], Some(1), None, 0); + c.insert(key(1, DiffSide::B, f), vec![0], Some(1), None, 0, 100); } assert!(c.len() <= CACHE_CAP); assert!( @@ -461,11 +574,73 @@ mod tests { fn cache_purges_old_generation_on_insert() { let mut c = FrameCache::default(); for f in 0..10 { - c.insert(key(1, DiffSide::B, f), vec![0], Some(1), None, 0); + c.insert(key(1, DiffSide::B, f), vec![0], Some(1), None, 0, 100); } // First insert of the new generation purges every old-gen entry. - c.insert(key(2, DiffSide::B, 0), vec![0], Some(2), None, 0); + c.insert(key(2, DiffSide::B, 0), vec![0], Some(2), None, 0, 100); assert_eq!(c.len(), 1); assert!(c.contains(&key(2, DiffSide::B, 0))); } + + #[test] + fn cache_purges_old_scale_on_insert() { + let mut c = FrameCache::default(); + for f in 0..10 { + c.insert(key(1, DiffSide::B, f), vec![0], Some(1), None, 0, 100); + } + // First insert after a scale switch purges every 100% entry. + let half = FrameKey { + scale_pct: 50, + ..key(1, DiffSide::B, 0) + }; + c.insert(half, vec![0], Some(1), None, 0, 50); + assert_eq!(c.len(), 1); + assert!(c.contains(&half)); + } + + // ── Claims / workers ──────────────────────────────────────────────── + + #[test] + fn claim_is_exclusive_until_released() { + let mut claims = ClaimSet::default(); + let k = key(1, DiffSide::B, 7); + assert!(claims.try_claim(k), "first claim wins"); + assert!(!claims.try_claim(k), "second claim loses while held"); + claims.release(&k); + assert!(claims.try_claim(k), "claimable again after release"); + } + + #[test] + fn claims_are_per_key() { + let mut claims = ClaimSet::default(); + assert!(claims.try_claim(key(1, DiffSide::B, 7))); + assert!(claims.try_claim(key(1, DiffSide::B, 8)), "other frame"); + assert!(claims.try_claim(key(1, DiffSide::A, 7)), "other side"); + let other_scale = FrameKey { + scale_pct: 50, + ..key(1, DiffSide::B, 7) + }; + assert!(claims.try_claim(other_scale), "other scale"); + } + + #[test] + fn worker_count_leaves_ui_cores_and_stays_bounded() { + assert_eq!(worker_count(1), 2, "floor: two workers even on tiny CPUs"); + assert_eq!(worker_count(4), 2); + assert_eq!(worker_count(8), 6); + assert_eq!(worker_count(12), 6, "cap at six"); + } + + #[test] + fn scale_setter_clamps_and_factor_converts() { + set_preview_scale_pct(50); + assert_eq!(preview_scale_pct(), 50); + set_preview_scale_pct(0); + assert_eq!(preview_scale_pct(), 10, "clamped to the floor"); + set_preview_scale_pct(200); + assert_eq!(preview_scale_pct(), 100, "clamped to full resolution"); + assert_eq!(scale_factor(50), 0.5); + assert_eq!(scale_factor(100), 1.0); + set_preview_scale_pct(DEFAULT_PREVIEW_SCALE_PCT); + } } diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index c8496c5..6158588 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -16,7 +16,9 @@ use super::inspector::InspectorPanel; use super::playback::{ playback_action, use_hot_reload, use_playback_clock, PlaybackAction, PlaybackBar, }; -use super::prefetch::{frame_cache, use_prefetch_publisher, FrameKey}; +use super::prefetch::{ + frame_cache, preview_scale_pct, scale_factor, use_prefetch_publisher, FrameKey, +}; use super::topbar::TopBar; /// The hot-reload revision signal, exposed via context so the optimistic edit @@ -58,6 +60,10 @@ pub fn StudioApp(view: Signal) -> Element { // Diff/review mode: toggle + which state the canvas shows (A = baseline). let diff_active = use_signal(|| false); let diff_side = use_signal(|| DiffSide::B); + // Preview render scale (percent). The atomic is the source of truth for + // the render threads; this signal mirrors it for the UI (selector value, + // URL cache-bust). + let preview_scale = use_signal(preview_scale_pct); // Asset handler: GET /frame/{idx} -> JPEG of that frame. `?side=a` renders // the BASELINE scenario instead (diff mode flip). Cache-first: a prefetched @@ -94,6 +100,7 @@ pub fn StudioApp(view: Signal) -> Element { generation: hash, side: DiffSide::A, frame: idx, + scale_pct: preview_scale_pct(), }; serve_or_render(key, idx, &scenario, &tasks, None) } @@ -108,6 +115,7 @@ pub fn StudioApp(view: Signal) -> Element { generation, side: DiffSide::B, frame: idx, + scale_pct: preview_scale_pct(), }; serve_or_render(key, idx, &scenario, &tasks, Some(generation)) }; @@ -317,8 +325,8 @@ pub fn StudioApp(view: Signal) -> Element { } div { style: "flex:1; display:flex; flex-direction:row; flex-wrap:nowrap; align-items:stretch; min-height:0; overflow:hidden;", div { style: "flex:1; min-width:0; display:flex; flex-direction:column; min-height:0;", - Canvas { current, rev, show_hits, selected, diff_active, diff_side, diff_marks } - PlaybackBar { current, playing, total, diff_active, diff_side } + Canvas { current, rev, show_hits, selected, diff_active, diff_side, diff_marks, preview_scale } + PlaybackBar { current, playing, total, diff_active, diff_side, preview_scale } } div { style: "flex:none; display:flex; overflow:hidden; transition:width 220ms ease; width:{panel_w};", @@ -364,7 +372,7 @@ fn serve_or_render( return Ok((*bytes).clone()); } let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - render_frame(scenario, tasks, idx, 1.0) + render_frame(scenario, tasks, idx, scale_factor(key.scale_pct)) })) .map_err(|_| ())?; let (gen_b, gen_a) = match key.side { @@ -374,7 +382,7 @@ fn serve_or_render( frame_cache() .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(key, rendered.clone(), gen_b, gen_a, idx); + .insert(key, rendered.clone(), gen_b, gen_a, idx, key.scale_pct); Ok(rendered) } @@ -393,6 +401,7 @@ fn Canvas( diff_active: Signal, diff_side: Signal, diff_marks: Vec<(String, ChangeKind)>, + preview_scale: Signal, ) -> Element { let shared = use_context::(); // Arc snapshots under a brief lock; the hit-map layout render below runs @@ -405,6 +414,9 @@ fn Canvas( let max = (tasks.len() as u32).saturating_sub(1); let cur = current().min(max); let r = rev(); + // In the URL purely as a cache-buster: a scale change re-requests the + // frame (the handler reads the scale itself from the atomic). + let s = preview_scale(); let side_a = diff_active() && diff_side() == DiffSide::A; let side_suffix = if side_a { "&side=a" } else { "" }; @@ -431,7 +443,7 @@ fn Canvas( // overlay stays exactly aligned. div { style: "position:relative; aspect-ratio:{vw} / {vh}; max-width:100%; max-height:100%; min-width:0; box-shadow:0 8px 40px rgba(0,0,0,0.5); line-height:0;", img { - src: "/frame/{cur}?v={r}{side_suffix}", + src: "/frame/{cur}?v={r}&s={s}{side_suffix}", style: "display:block; width:100%; height:100%;", } Overlay { hits, selected, diff_marks } From 67f073ae75d15a35cba67319b2cd6c44da187d6e Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 20 Jul 2026 01:44:06 +0200 Subject: [PATCH 2/3] fix(studio): survive render panics without freezing the preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation of an app freeze + OOM while playing dynamic-glass with the worker pool. Isolated soaks exonerate the native pipeline: 6 workers at 0.5/1.0 scale hold a flat RSS (124/368 MB over 360 heavy frames), and a 60s full-pipeline soak (moving 30fps playhead, simulated asset handler, canvas hit pass) stays bounded at 122-482 MB with 3 cache misses in 1200 served frames. The identified failure mode is a panic cascade: the global cosmic-text mutexes were locked with .unwrap(), so one Skia panic on any render thread (caught by the panic fences) poisoned FONT_SYSTEM/SWASH_CACHE and turned every subsequent text shape on every thread into a panic — preview permanently stuck, six workers re-rendering panicking frames in a tight loop. - cosmic: poison-tolerant font/swash locks + regression test that deliberately poisons the mutex and shapes through it - studio: FailLedger — a frame whose render panics gets MAX_RENDER_ATTEMPTS tries (workers and asset handler), then is left alone instead of looping - keep the ignored stress/soak diagnostics for future perf work --- .../rustmotion-core/src/engine/text/cosmic.rs | 27 ++- crates/rustmotion-studio/src/editor/frames.rs | 49 +++++ .../rustmotion-studio/src/editor/prefetch.rs | 168 +++++++++++++++++- crates/rustmotion-studio/src/editor/view.rs | 19 +- 4 files changed, 255 insertions(+), 8 deletions(-) diff --git a/crates/rustmotion-core/src/engine/text/cosmic.rs b/crates/rustmotion-core/src/engine/text/cosmic.rs index 7e36894..2f52815 100644 --- a/crates/rustmotion-core/src/engine/text/cosmic.rs +++ b/crates/rustmotion-core/src/engine/text/cosmic.rs @@ -96,7 +96,11 @@ pub fn measure_text(text: &str, style: &TextStyle) -> TextMetrics { height: metrics_for(style).line_height, }; } - let mut fs = font_system().lock().unwrap(); + // Poison-tolerant: a panic on another render thread (e.g. a Skia panic + // caught by a preview worker's panic fence) must not poison text shaping + // for every subsequent frame — the FontSystem stays usable, each shape + // call builds its own Buffer. + let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner()); let metrics = metrics_for(style); let mut buf = Buffer::new(&mut fs, metrics); buf.set_size(style.max_width, None); @@ -128,7 +132,8 @@ pub fn paint_text( if text.is_empty() { return; } - let mut fs = font_system().lock().unwrap(); + // Poison-tolerant for the same reason as in `measure_text`. + let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner()); let metrics = metrics_for(style); let mut buf = Buffer::new(&mut fs, metrics); buf.set_size(style.max_width, None); @@ -136,7 +141,7 @@ pub fn paint_text( buf.set_text(text, &attrs_for(style), Shaping::Advanced, None); buf.shape_until_scroll(&mut fs, false); - let mut sc = swash_cache().lock().unwrap(); + let mut sc = swash_cache().lock().unwrap_or_else(|e| e.into_inner()); let ccolor = CColor::rgba(color.r(), color.g(), color.b(), color.a()); for run in buf.layout_runs() { @@ -264,4 +269,20 @@ mod tests { let m = measure_text("the quick brown fox", &style); assert!(m.height < 16.0 * 1.2 * 2.0, "should be one line"); } + + /// A panic on another thread while it holds the font mutex (e.g. a Skia + /// panic caught by a preview worker's panic fence) poisons the lock. + /// Shaping must survive that — otherwise one panic turns every subsequent + /// text render on every thread into a panic cascade. + #[test] + fn measure_survives_poisoned_font_mutex() { + let _ = std::thread::spawn(|| { + let _guard = font_system().lock().unwrap_or_else(|e| e.into_inner()); + panic!("deliberate poison"); + }) + .join(); + assert!(font_system().is_poisoned(), "setup must have poisoned"); + let m = measure_text("still shaping", &TextStyle::default()); + assert!(m.width > 0.0, "measure works despite the poisoned mutex"); + } } diff --git a/crates/rustmotion-studio/src/editor/frames.rs b/crates/rustmotion-studio/src/editor/frames.rs index a4cb8ab..64b369e 100644 --- a/crates/rustmotion-studio/src/editor/frames.rs +++ b/crates/rustmotion-studio/src/editor/frames.rs @@ -185,6 +185,55 @@ mod tests { assert_eq!(&jpeg[0..2], &[0xFF, 0xD8], "must be a JPEG"); } + /// TEMP diagnostic (not part of CI): replicate the prefetch worker pool on + /// the dynamic-glass example and print RSS growth. Run with + /// `RM_STRESS_SCALE=0.5 cargo test -p rustmotion-studio --release stress_rss -- --ignored --nocapture` + #[test] + #[ignore] + fn stress_rss_worker_pool() { + use std::sync::Arc; + let Ok(src) = std::fs::read_to_string("../../examples/dynamic-glass.json") else { + eprintln!("skipped: examples/dynamic-glass.json not present"); + return; + }; + let scenario = + Arc::new(rustmotion::loader::load_scenario_from_source(None, Some(&src)).unwrap()); + let tasks = Arc::new(rustmotion::encode::build_frame_tasks(&scenario)); + let scale: f32 = std::env::var("RM_STRESS_SCALE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0.5); + let rss_kb = || -> u64 { + let out = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap() + }; + println!("scale={scale} start rss={} MB", rss_kb() / 1024); + let workers: Vec<_> = (0..6u32) + .map(|w| { + let s = scenario.clone(); + let t = tasks.clone(); + std::thread::spawn(move || { + for _pass in 0..6 { + for f in (130..190).filter(|f| f % 6 == w) { + let _ = render_frame(&s, &t, f, scale); + } + } + }) + }) + .collect(); + while workers.iter().any(|w| !w.is_finished()) { + std::thread::sleep(std::time::Duration::from_secs(2)); + println!("rss={} MB", rss_kb() / 1024); + } + for w in workers { + w.join().unwrap(); + } + println!("end rss={} MB", rss_kb() / 1024); + } + #[test] fn renders_frame_at_reduced_scale() { let scenario = rustmotion::loader::load_scenario_from_source(None, Some(SCENARIO)).unwrap(); diff --git a/crates/rustmotion-studio/src/editor/prefetch.rs b/crates/rustmotion-studio/src/editor/prefetch.rs index 303ab39..462896d 100644 --- a/crates/rustmotion-studio/src/editor/prefetch.rs +++ b/crates/rustmotion-studio/src/editor/prefetch.rs @@ -206,6 +206,39 @@ fn claims() -> &'static Mutex { SLOT.get_or_init(|| Mutex::new(ClaimSet::default())) } +// ── Failure ledger ─────────────────────────────────────────────────────────── + +/// Attempts a frame gets before workers and the handler stop re-rendering it. +/// One retry absorbs transient panics; beyond that a panicking frame must not +/// become an infinite render-panic loop that pegs the workers (the preview +/// shows the neighboring frame instead). +pub const MAX_RENDER_ATTEMPTS: u8 = 2; + +/// Panicked render attempts per key. Keys carry the generation, so a model +/// edit naturally retires old entries; the size cap is a runaway backstop. +#[derive(Default)] +pub struct FailLedger { + map: HashMap, +} + +impl FailLedger { + pub fn record_failure(&mut self, key: FrameKey) { + if self.map.len() > 4096 { + self.map.clear(); + } + *self.map.entry(key).or_insert(0) += 1; + } + + pub fn exhausted(&self, key: &FrameKey) -> bool { + self.map.get(key).is_some_and(|&n| n >= MAX_RENDER_ATTEMPTS) + } +} + +pub(crate) fn fail_ledger() -> &'static Mutex { + static SLOT: OnceLock> = OnceLock::new(); + SLOT.get_or_init(|| Mutex::new(FailLedger::default())) +} + /// Prefetch worker count for a machine with `cores` logical cores: leave two /// for the UI/webview and the asset handler, keep at least two workers so a /// slow frame never serializes the window, and cap at six (JPEG frames past @@ -324,6 +357,15 @@ fn prefetch_loop() { { continue; } + // A frame that keeps panicking gets MAX_RENDER_ATTEMPTS, then is + // left alone — never an infinite render-panic loop. + if fail_ledger() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .exhausted(&key) + { + continue; + } // Another worker already rendering this frame → take the next one. if !claims() .lock() @@ -344,11 +386,15 @@ fn prefetch_loop() { let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { render_frame(&scenario, &tasks, frame, scale_factor(scale)) })); - if let Ok(bytes) = rendered { - frame_cache() + match rendered { + Ok(bytes) => frame_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key, bytes, gen_b, gen_a, target.current, scale), + Err(_) => fail_ledger() .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(key, bytes, gen_b, gen_a, target.current, scale); + .record_failure(key), } } claims() @@ -623,6 +669,122 @@ mod tests { assert!(claims.try_claim(other_scale), "other scale"); } + /// TEMP diagnostic (not CI): full-pipeline soak — real worker pool, a + /// simulated 30fps playhead publishing targets, a simulated asset handler + /// serving frames, and the canvas hit-map pass. Prints RSS + serve stats. + /// `cargo test -p rustmotion-studio --release soak_full -- --ignored --nocapture` + #[test] + #[ignore] + fn soak_full_pipeline_rss() { + use crate::editor::frames::frame_hits; + use std::sync::atomic::{AtomicU32, Ordering}; + + let Ok(src) = std::fs::read_to_string("../../examples/dynamic-glass.json") else { + eprintln!("skipped: examples/dynamic-glass.json not present"); + return; + }; + let scenario = + Arc::new(rustmotion::loader::load_scenario_from_source(None, Some(&src)).unwrap()); + let tasks = Arc::new(rustmotion::encode::build_frame_tasks(&scenario)); + let total = tasks.len() as u32; + let rss_mb = || -> u64 { + let out = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .unwrap() + / 1024 + }; + + ensure_prefetcher(); + static SERVED: AtomicU32 = AtomicU32::new(0); + static MISSES: AtomicU32 = AtomicU32::new(0); + + // Simulated playback + asset handler + canvas hit pass, 30fps for 60s. + let s2 = scenario.clone(); + let t2 = tasks.clone(); + let sim = std::thread::spawn(move || { + let mut current = 0u32; + for _tick in 0..(30 * 60) { + std::thread::sleep(Duration::from_millis(33)); + current = (current + 1) % total; + *prefetch_slot().lock().unwrap_or_else(|e| e.into_inner()) = PrefetchTarget { + current, + playing: true, + generation: 1, + side: DiffSide::B, + scenario: Some(s2.clone()), + tasks: Some(t2.clone()), + path: None, + }; + let key = FrameKey { + generation: 1, + side: DiffSide::B, + frame: current, + scale_pct: preview_scale_pct(), + }; + let hit = frame_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(&key); + if !hit { + MISSES.fetch_add(1, Ordering::Relaxed); + let bytes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + crate::editor::frames::render_frame( + &s2, + &t2, + current, + scale_factor(key.scale_pct), + ) + })); + if let Ok(b) = bytes { + frame_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key, b, Some(1), None, current, key.scale_pct); + } + } + SERVED.fetch_add(1, Ordering::Relaxed); + // The canvas hit-map layout pass runs on every tick in the app. + let _ = frame_hits(&s2, &t2, current, "/scenes/0"); + } + }); + + while !sim.is_finished() { + std::thread::sleep(Duration::from_secs(5)); + println!( + "rss={} MB served={} misses={}", + rss_mb(), + SERVED.load(Ordering::Relaxed), + MISSES.load(Ordering::Relaxed) + ); + } + sim.join().unwrap(); + println!( + "end rss={} MB misses={}", + rss_mb(), + MISSES.load(Ordering::Relaxed) + ); + } + + #[test] + fn fail_ledger_exhausts_after_max_attempts() { + let mut ledger = FailLedger::default(); + let k = key(1, DiffSide::B, 7); + assert!(!ledger.exhausted(&k)); + ledger.record_failure(k); + assert!(!ledger.exhausted(&k), "one transient failure gets a retry"); + ledger.record_failure(k); + assert!(ledger.exhausted(&k), "gives up after MAX_RENDER_ATTEMPTS"); + assert!( + !ledger.exhausted(&key(2, DiffSide::B, 7)), + "a new generation retries the same frame" + ); + } + #[test] fn worker_count_leaves_ui_cores_and_stays_bounded() { assert_eq!(worker_count(1), 2, "floor: two workers even on tiny CPUs"); diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index 6158588..5aa2eee 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -17,7 +17,7 @@ use super::playback::{ playback_action, use_hot_reload, use_playback_clock, PlaybackAction, PlaybackBar, }; use super::prefetch::{ - frame_cache, preview_scale_pct, scale_factor, use_prefetch_publisher, FrameKey, + fail_ledger, frame_cache, preview_scale_pct, scale_factor, use_prefetch_publisher, FrameKey, }; use super::topbar::TopBar; @@ -371,10 +371,25 @@ fn serve_or_render( { return Ok((*bytes).clone()); } + // A frame whose render keeps panicking is not retried on every + // request — fail fast so the webview shows the previous frame instead of + // hammering a panic loop. + if fail_ledger() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .exhausted(&key) + { + return Err(()); + } let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { render_frame(scenario, tasks, idx, scale_factor(key.scale_pct)) })) - .map_err(|_| ())?; + .map_err(|_| { + fail_ledger() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .record_failure(key); + })?; let (gen_b, gen_a) = match key.side { DiffSide::B => (gen_b, None), DiffSide::A => (None, Some(key.generation)), From 8cb3b58f5fb78d04b0a3cad31d385c5bb2b9faf3 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 20 Jul 2026 09:53:01 +0200 Subject: [PATCH 3/3] fix(studio): stop painting the hit-map during playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the preview freeze on dynamic-glass, found by sampling the frozen process: the main thread sat at 100% in a Dioxus re-render loop, its stack deep in Skia SaveLayerRec / internalDrawDeviceWithFilter. The Inspect hit-map is computed by render_scene_hits, which runs a FULL paint pass — backdrop-filters, blur, noise, grain included — on a full-resolution raster surface, on the MAIN thread, once per frame. On a glass-heavy scene that is ~150ms/frame; at 30fps playback the main thread never keeps up and the UI wedges (deterministically, around the scene-1→2 transition where two glass cards raise the cost). Preview scale never helped because the hit paint ignores it. The hit-map only matters at rest (hover/click to inspect); recomputing it 30x/s during playback is pure waste. Gate it on !playing via the pure should_compute_hits(show_hits, side_a, playing); it returns instantly on pause. Fixes the freeze independently of the worker pool. --- crates/rustmotion-studio/src/editor/view.rs | 42 ++++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index 5aa2eee..043dced 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -325,7 +325,7 @@ pub fn StudioApp(view: Signal) -> Element { } div { style: "flex:1; display:flex; flex-direction:row; flex-wrap:nowrap; align-items:stretch; min-height:0; overflow:hidden;", div { style: "flex:1; min-width:0; display:flex; flex-direction:column; min-height:0;", - Canvas { current, rev, show_hits, selected, diff_active, diff_side, diff_marks, preview_scale } + Canvas { current, rev, show_hits, playing, selected, diff_active, diff_side, diff_marks, preview_scale } PlaybackBar { current, playing, total, diff_active, diff_side, preview_scale } } div { @@ -407,11 +407,24 @@ fn serve_or_render( /// never the editor chrome or the inspector, which keep their own state. /// In diff mode with side A, the image renders the baseline scenario (the /// overlay is hidden: its boxes come from the current model's layout). +/// Whether the clickable-element hit-map should be (re)computed this render. +/// +/// Computing it runs a FULL paint pass (backdrop-filters and all) on a +/// full-resolution raster surface on the MAIN thread — ~150ms per frame on a +/// glass-heavy scene. During playback the playhead moves at fps and nothing is +/// hoverable, so recomputing per frame just pins the main thread at 100% and +/// freezes the UI. The hit-map only matters at rest (hover/click the inspector), +/// so skip it while playing; it returns the instant playback pauses. +pub fn should_compute_hits(show_hits: bool, side_a: bool, playing: bool) -> bool { + show_hits && !side_a && !playing +} + #[component] fn Canvas( current: Signal, rev: Signal, show_hits: Signal, + playing: Signal, mut selected: Signal>, diff_active: Signal, diff_side: Signal, @@ -435,7 +448,7 @@ fn Canvas( let side_a = diff_active() && diff_side() == DiffSide::A; let side_suffix = if side_a { "&side=a" } else { "" }; - let hits = if show_hits() && !side_a { + let hits = if should_compute_hits(show_hits(), side_a, playing()) { let prefix = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); scene_prefix(&m.raw, &tasks, cur) @@ -551,3 +564,28 @@ fn Overlay( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hits_skipped_during_playback() { + // The whole point: playing never computes the (expensive) hit-map. + assert!(!should_compute_hits(true, false, true)); + } + + #[test] + fn hits_computed_at_rest_when_enabled() { + assert!(should_compute_hits(true, false, false)); + } + + #[test] + fn hits_never_computed_when_disabled_or_baseline() { + assert!(!should_compute_hits(false, false, false), "Inspect off"); + assert!( + !should_compute_hits(true, true, false), + "baseline (A) has no current-model hit-map" + ); + } +}