diff --git a/crates/rustmotion-studio/src/editor/diff_panel.rs b/crates/rustmotion-studio/src/editor/diff_panel.rs index 84818b0..1824a5e 100644 --- a/crates/rustmotion-studio/src/editor/diff_panel.rs +++ b/crates/rustmotion-studio/src/editor/diff_panel.rs @@ -8,7 +8,8 @@ use crate::components::button::{Button, ButtonSize, ButtonVariant}; use crate::scenario::{ChangeKind, ElementChange, Shared}; /// Which state the canvas renders in diff mode: A = baseline, B = current. -#[derive(Clone, Copy, PartialEq)] +/// `Eq + Hash` so it can key the frame-prefetch cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DiffSide { A, B, @@ -46,14 +47,17 @@ fn frame_for_change(shared: &Shared, change: &ElementChange) -> Option { } _ => return None, }; - let m = shared.lock().unwrap_or_else(|e| e.into_inner()); - let fps = m.scenario.video.fps.max(1); - let base = m.tasks.iter().position(|t| { + // Arc snapshot; the task scan runs without the model lock. + let (fps, tasks) = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + (m.scenario.video.fps.max(1), m.tasks.clone()) + }; + let base = tasks.iter().position(|t| { matches!(t, rustmotion::encode::video::FrameTask::Normal { view_idx, scene_idx, .. } if *view_idx == view && *scene_idx == scene) })?; let offset = (change.start_at.unwrap_or(0.0).max(0.0) * fps as f64) as usize; - let frame = (base + offset).min(m.tasks.len().saturating_sub(1)); + let frame = (base + offset).min(tasks.len().saturating_sub(1)); Some(frame as u32) } diff --git a/crates/rustmotion-studio/src/editor/frames.rs b/crates/rustmotion-studio/src/editor/frames.rs index 2922b68..a3b6e2d 100644 --- a/crates/rustmotion-studio/src/editor/frames.rs +++ b/crates/rustmotion-studio/src/editor/frames.rs @@ -1,6 +1,6 @@ use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use rustmotion::encode::video::FrameTask; use rustmotion::schema::ResolvedScenario; @@ -38,12 +38,13 @@ pub fn render_frame( /// Cached baseline scenario for the diff mode's A-side render, keyed by /// (path, source hash) so it is rebuilt only when the baseline itself changes -/// (Set baseline) — never per frame. +/// (Set baseline) — never per frame. Arc snapshots so callers render OUTSIDE +/// this cache's lock. struct BaselineCache { path: PathBuf, source_hash: u64, - scenario: ResolvedScenario, - tasks: Vec, + scenario: Arc, + tasks: Arc>, } fn baseline_cache() -> &'static Mutex> { @@ -51,17 +52,23 @@ fn baseline_cache() -> &'static Mutex> { CACHE.get_or_init(|| Mutex::new(None)) } -fn source_hash(source: &str) -> u64 { +/// Stable hash of a baseline source string (also used as the side-A cache +/// generation in the prefetch frame cache). +pub(crate) fn source_hash(source: &str) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); source.hash(&mut h); h.finish() } -/// Render a frame of the BASELINE scenario (diff mode, A side) from its source -/// string: JSON is loaded directly, HTML is re-transpiled — both entirely from -/// the string, never from the (already edited) file on disk. The resolved -/// scenario + frame tasks are cached; only the JPEG encode runs per frame. -pub fn render_baseline_frame(path: &Path, source: &str, frame: u32) -> Result, String> { +/// Arc snapshot of the BASELINE scenario built from its source string: JSON is +/// loaded directly, HTML is re-transpiled — both entirely from the string, +/// never from the (already edited) file on disk. Cached by (path, source +/// hash); returns `(source_hash, scenario, tasks)` so the caller renders +/// without holding any lock. +pub fn baseline_arcs( + path: &Path, + source: &str, +) -> Result<(u64, Arc, Arc>), String> { let hash = source_hash(source); let mut guard = baseline_cache().lock().unwrap_or_else(|e| e.into_inner()); @@ -81,18 +88,13 @@ pub fn render_baseline_frame(path: &Path, source: &str, frame: u32) -> Result` requests it +//! while the prefetcher is mid-render (accepted — no cross-coordination). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use dioxus::prelude::*; + +use rustmotion::encode::video::FrameTask; +use rustmotion::schema::ResolvedScenario; + +use crate::scenario::{baseline_slot, get_baseline, Shared}; + +use super::diff_panel::DiffSide; +use super::frames::{baseline_arcs, render_frame}; + +/// Cache bound (~120 JPEG frames). +pub const CACHE_CAP: usize = 120; +/// Frames prefetched ahead of the playhead. +pub const WINDOW_AHEAD: u32 = 30; +/// Frames kept behind the playhead while paused (scrub-back comfort). +pub const WINDOW_BEHIND: u32 = 5; + +/// Cache key: which model state (`generation` = model generation for side B, +/// baseline source hash for side A), which side, which frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FrameKey { + pub generation: u64, + pub side: DiffSide, + pub frame: u32, +} + +// ── Pure logic ─────────────────────────────────────────────────────────────── + +/// The frames to prefetch, nearest-first from `current`. Playing → forward +/// only `[current, current+AHEAD]`; paused → also `WINDOW_BEHIND` back. +/// Clamped to `[0, total)`; forward wins distance ties. +pub fn prefetch_window(current: u32, playing: bool, total: u32) -> Vec { + if total == 0 { + return Vec::new(); + } + let current = current.min(total - 1); + let behind = if playing { 0 } else { WINDOW_BEHIND }; + let lo = current.saturating_sub(behind); + let hi = current.saturating_add(WINDOW_AHEAD).min(total - 1); + + // Nearest-first from `current`, forward before backward on equal distance. + let mut out = Vec::with_capacity((hi - lo + 1) as usize); + out.push(current); + for d in 1..=(WINDOW_AHEAD.max(behind)) { + let fwd = current.saturating_add(d); + if fwd <= hi { + out.push(fwd); + } + if d <= behind && current >= d && current - d >= lo { + out.push(current - d); + } + } + out +} + +/// Which keys to drop: 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. +pub fn select_evictions( + keys: &[FrameKey], + gen_b: Option, + gen_a: Option, + head: u32, + cap: usize, +) -> 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 mut evict: Vec = keys.iter().filter(|k| is_stale(k)).copied().collect(); + + let mut fresh: Vec = keys.iter().filter(|k| !is_stale(k)).copied().collect(); + if fresh.len() > cap { + // Farthest from the playhead go first. + fresh.sort_by_key(|k| std::cmp::Reverse(k.frame.abs_diff(head))); + evict.extend(fresh.drain(..fresh.len() - cap)); + } + evict +} + +// ── Cache ──────────────────────────────────────────────────────────────────── + +/// Bounded JPEG frame cache. All mutation goes through [`FrameCache::insert`], +/// which applies the eviction policy. +#[derive(Default)] +pub struct FrameCache { + map: HashMap>>, +} + +impl FrameCache { + pub fn get(&self, key: &FrameKey) -> Option>> { + self.map.get(key).cloned() + } + + pub fn contains(&self, key: &FrameKey) -> bool { + self.map.contains_key(key) + } + + /// Entry count — test-only introspection for the bound assertions. + #[cfg(test)] + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.map.len() + } + + /// Insert a frame then enforce the eviction policy around the playhead. + pub fn insert( + &mut self, + key: FrameKey, + bytes: Vec, + gen_b: Option, + gen_a: Option, + head: u32, + ) { + 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) { + self.map.remove(&k); + } + } +} + +pub type SharedFrameCache = Arc>; + +/// The app-global frame cache. +pub fn frame_cache() -> SharedFrameCache { + static SLOT: OnceLock = OnceLock::new(); + SLOT.get_or_init(|| Arc::new(Mutex::new(FrameCache::default()))) + .clone() +} + +// ── Prefetcher ─────────────────────────────────────────────────────────────── + +/// What the UI wants prefetched, published every time the playhead/side/model +/// moves. Holds Arc snapshots so the thread renders without the model lock. +#[derive(Clone)] +pub struct PrefetchTarget { + pub current: u32, + pub playing: bool, + pub generation: u64, + pub side: DiffSide, + pub scenario: Option>, + pub tasks: Option>>, + /// Scenario path, for the baseline lookup when side A is active. + pub path: Option, +} + +impl Default for PrefetchTarget { + fn default() -> Self { + Self { + current: 0, + playing: false, + generation: 0, + side: DiffSide::B, + scenario: None, + tasks: None, + path: None, + } + } +} + +fn prefetch_slot() -> Arc> { + static SLOT: OnceLock>> = OnceLock::new(); + SLOT.get_or_init(|| Arc::new(Mutex::new(PrefetchTarget::default()))) + .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) +} + +/// Spawn the singleton prefetch thread (idempotent). +pub fn ensure_prefetcher() { + static STARTED: OnceLock<()> = OnceLock::new(); + STARTED.get_or_init(|| { + std::thread::spawn(prefetch_loop); + }); +} + +fn prefetch_loop() { + loop { + std::thread::sleep(Duration::from_millis(12)); + let target = prefetch_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + + // Resolve what to render for the ACTIVE side only. + let (gen, scenario, tasks) = match target.side { + DiffSide::B => match (&target.scenario, &target.tasks) { + (Some(s), Some(t)) => (target.generation, s.clone(), t.clone()), + _ => continue, + }, + DiffSide::A => { + let Some(path) = target.path.as_deref() else { + continue; + }; + let Some(b) = get_baseline(&baseline_slot(), path) else { + continue; + }; + match baseline_arcs(path, &b.source) { + Ok((hash, s, t)) => (hash, s, t), + Err(_) => continue, + } + } + }; + + let total = tasks.len() as u32; + let window = prefetch_window(target.current, target.playing, total); + let fingerprint = target_fingerprint(&target); + // 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 { + DiffSide::B => (Some(gen), None), + DiffSide::A => (Some(target.generation), Some(gen)), + }; + + for frame in window { + let key = FrameKey { + generation: gen, + side: target.side, + frame, + }; + if frame_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(&key) + { + 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); + } + + // Abort the pass as soon as the UI retargets (seek, play/pause, + // reload, side flip) so the new window starts immediately. + let now = prefetch_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if target_fingerprint(&now) != fingerprint { + break; + } + } + } +} + +/// Publish the prefetch target from the UI (and start the thread on first +/// use). Re-runs whenever the playhead, play state, diff side, or the model +/// (via `rev`) changes; takes only a brief model lock to clone the Arcs. +pub fn use_prefetch_publisher( + shared: Shared, + current: Signal, + playing: Signal, + rev: Signal, + diff_active: Signal, + diff_side: Signal, +) { + ensure_prefetcher(); + use_effect(move || { + let cur = current(); + let play = playing(); + let _ = rev(); // re-publish fresh Arcs after every hot reload + let side = if diff_active() { + diff_side() + } else { + DiffSide::B + }; + let (scenario, tasks, generation, path) = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + ( + Some(m.scenario.clone()), + Some(m.tasks.clone()), + m.generation, + m.path.clone(), + ) + }; + *prefetch_slot().lock().unwrap_or_else(|e| e.into_inner()) = PrefetchTarget { + current: cur, + playing: play, + generation, + side, + scenario, + tasks, + path, + }; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(gen: u64, side: DiffSide, frame: u32) -> FrameKey { + FrameKey { + generation: gen, + side, + frame, + } + } + + // ── Window ────────────────────────────────────────────────────────── + + #[test] + fn window_playing_is_forward_only_ascending() { + let w = prefetch_window(10, true, 1000); + assert_eq!(w.len(), (WINDOW_AHEAD + 1) as usize); + assert_eq!(w.first(), Some(&10)); + assert_eq!(w.last(), Some(&(10 + WINDOW_AHEAD))); + assert!( + w.windows(2).all(|p| p[0] < p[1]), + "ascending = nearest-first" + ); + } + + #[test] + fn window_paused_is_nearest_first_with_backtrack() { + let w = prefetch_window(10, false, 1000); + assert_eq!(w.len(), (WINDOW_AHEAD + WINDOW_BEHIND + 1) as usize); + // Nearest-first, forward wins ties: 10, 11, 9, 12, 8, … + assert_eq!(&w[0..5], &[10, 11, 9, 12, 8]); + assert!(w.contains(&5) && w.contains(&40)); + assert!(!w.contains(&4) && !w.contains(&41)); + } + + #[test] + fn window_clamps_at_start() { + let w = prefetch_window(2, false, 1000); + assert!(w.iter().all(|&f| f <= 2 + WINDOW_AHEAD)); + assert!(w.contains(&0) && w.contains(&1) && w.contains(&2)); + assert_eq!( + w.len(), + (WINDOW_AHEAD + 1 + 2) as usize, + "only 2 back frames exist" + ); + } + + #[test] + fn window_clamps_at_end() { + let w = prefetch_window(99, true, 100); + assert_eq!(w, vec![99]); + // Current beyond total clamps into range instead of exceeding it. + let w = prefetch_window(500, true, 100); + assert!(w.iter().all(|&f| f < 100)); + } + + #[test] + fn window_empty_when_no_frames() { + assert!(prefetch_window(0, true, 0).is_empty()); + assert!(prefetch_window(10, false, 0).is_empty()); + } + + // ── Eviction ──────────────────────────────────────────────────────── + + #[test] + fn evictions_drop_stale_generations_first() { + let keys = vec![ + key(1, DiffSide::B, 0), // stale (gen_b = 2) + key(2, DiffSide::B, 5), + 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); + 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"); + } + + #[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); + assert!(out.is_empty(), "A staleness unknowable without gen_a"); + } + + #[test] + fn evictions_then_farthest_from_head() { + // 6 fresh entries, cap 4 → evict the 2 farthest from head=10. + let keys: Vec = [10u32, 11, 9, 30, 50, 12] + .iter() + .map(|&f| key(1, DiffSide::B, f)) + .collect(); + let out = select_evictions(&keys, Some(1), None, 10, 4); + assert_eq!(out.len(), 2); + assert!(out.contains(&key(1, DiffSide::B, 50))); + assert!(out.contains(&key(1, DiffSide::B, 30))); + } + + #[test] + 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); + // 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))); + assert!(out.contains(&key(2, DiffSide::B, 40))); + assert!(!out.contains(&key(2, DiffSide::B, 0))); + } + + // ── 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); + 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!( + c.get(&key(2, DiffSide::B, 3)).is_none(), + "generation is part of the key" + ); + } + + #[test] + 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); + } + assert!(c.len() <= CACHE_CAP); + assert!( + c.contains(&key(1, DiffSide::B, 0)), + "nearest to head survives" + ); + assert!( + !c.contains(&key(1, DiffSide::B, CACHE_CAP as u32 + 19)), + "farthest evicted" + ); + } + + #[test] + 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); + } + // First insert of the new generation purges every old-gen entry. + c.insert(key(2, DiffSide::B, 0), vec![0], Some(2), None, 0); + assert_eq!(c.len(), 1); + assert!(c.contains(&key(2, DiffSide::B, 0))); + } +} diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index 466c266..7a9d7bf 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -10,9 +10,10 @@ use crate::scenario::{ use super::annotations::AnnotationsPanel; use super::diff_panel::{DiffPanel, DiffSide}; -use super::frames::{frame_hits, render_baseline_frame, render_frame, scene_prefix, HitPct}; +use super::frames::{baseline_arcs, frame_hits, render_frame, scene_prefix, HitPct}; use super::inspector::InspectorPanel; use super::playback::{use_hot_reload, use_playback_clock, PlaybackBar}; +use super::prefetch::{frame_cache, use_prefetch_publisher, FrameKey}; use super::topbar::TopBar; /// Overlay element boxes: invisible by default. Only the single hovered element @@ -50,12 +51,12 @@ pub fn StudioApp(view: Signal) -> Element { let diff_side = use_signal(|| DiffSide::B); // Asset handler: GET /frame/{idx} -> JPEG of that frame. `?side=a` renders - // the BASELINE scenario instead (diff mode flip), from the cached baseline - // model. - // - // Safety note: rendering is wrapped in `catch_unwind` so a Skia panic - // converts to an error instead of poisoning the Mutex and killing the - // session for all future requests. + // the BASELINE scenario instead (diff mode flip). Cache-first: a prefetched + // frame is served straight from memory (no model lock, no render). On miss, + // the Arcs are cloned under a brief lock and the render runs OUTSIDE any + // lock (catch_unwind keeps a Skia panic from poisoning anything), then the + // frame is inserted so the next request hits. The prefetcher may race this + // path and render the same frame once more — accepted, the cache dedupes. let handler_shared = shared.clone(); use_asset_handler( "frame", @@ -74,15 +75,32 @@ pub fn StudioApp(view: Signal) -> Element { let m = handler_shared.lock().unwrap_or_else(|e| e.into_inner()); m.path.clone() }; - path.and_then(|p| get_baseline(&baseline_slot(), &p).map(|b| (p, b))) + match path + .and_then(|p| get_baseline(&baseline_slot(), &p).map(|b| (p, b))) .ok_or(()) - .and_then(|(p, b)| render_baseline_frame(&p, &b.source, idx).map_err(|_| ())) + .and_then(|(p, b)| baseline_arcs(&p, &b.source).map_err(|_| ())) + { + Ok((hash, scenario, tasks)) => { + let key = FrameKey { + generation: hash, + side: DiffSide::A, + frame: idx, + }; + serve_or_render(key, idx, &scenario, &tasks, None) + } + Err(()) => Err(()), + } } else { - let m = handler_shared.lock().unwrap_or_else(|e| e.into_inner()); - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - render_frame(&m.scenario, &m.tasks, idx, 1.0) - })) - .map_err(|_| ()) + let (scenario, tasks, generation) = { + let m = handler_shared.lock().unwrap_or_else(|e| e.into_inner()); + (m.scenario.clone(), m.tasks.clone(), m.generation) + }; + let key = FrameKey { + generation, + side: DiffSide::B, + frame: idx, + }; + serve_or_render(key, idx, &scenario, &tasks, Some(generation)) }; match result { Ok(jpeg) => { @@ -111,6 +129,15 @@ pub fn StudioApp(view: Signal) -> Element { use_playback_clock(shared.clone(), current, playing); use_hot_reload(shared.clone(), rev); + // Publish the playhead/side/model snapshot for the background prefetcher. + use_prefetch_publisher( + shared.clone(), + current, + playing, + rev, + diff_active, + diff_side, + ); let (total, err, write_err, title, annotations) = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); @@ -272,6 +299,40 @@ pub fn StudioApp(view: Signal) -> Element { } } +/// Cache-first frame fetch for the asset handler: serve the prefetched JPEG +/// when present, otherwise render outside any lock (panic-fenced) and insert +/// into the cache for the next request. `gen_b` is `Some(model generation)` +/// when serving side B (drives stale-generation eviction); side A passes the +/// baseline hash inside `key.generation` and `None` here. +fn serve_or_render( + key: FrameKey, + idx: u32, + scenario: &rustmotion::schema::ResolvedScenario, + tasks: &[rustmotion::encode::video::FrameTask], + gen_b: Option, +) -> Result, ()> { + if let Some(bytes) = frame_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&key) + { + return Ok((*bytes).clone()); + } + let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + render_frame(scenario, tasks, idx, 1.0) + })) + .map_err(|_| ())?; + let (gen_b, gen_a) = match key.side { + DiffSide::B => (gen_b, None), + DiffSide::A => (None, Some(key.generation)), + }; + frame_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key, rendered.clone(), gen_b, gen_a, idx); + Ok(rendered) +} + /// The preview canvas: the rendered frame plus the clickable element overlay. /// It owns the reads of `current`/`rev`/`show_hits`, so a playback tick or a /// model reload (e.g. after an inspector edit) re-renders ONLY this subtree — @@ -289,23 +350,25 @@ fn Canvas( diff_marks: Vec<(String, ChangeKind)>, ) -> Element { let shared = use_context::(); - let (max, vw, vh) = { + // Arc snapshots under a brief lock; the hit-map layout render below runs + // WITHOUT the model lock. + let (scenario, tasks) = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); - ( - m.total_frames.saturating_sub(1), - m.scenario.video.width, - m.scenario.video.height, - ) + (m.scenario.clone(), m.tasks.clone()) }; + let (vw, vh) = (scenario.video.width, scenario.video.height); + let max = (tasks.len() as u32).saturating_sub(1); let cur = current().min(max); let r = rev(); 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 m = shared.lock().unwrap_or_else(|e| e.into_inner()); - let prefix = scene_prefix(&m.raw, &m.tasks, cur); - frame_hits(&m.scenario, &m.tasks, cur, &prefix) + let prefix = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + scene_prefix(&m.raw, &tasks, cur) + }; + frame_hits(&scenario, &tasks, cur, &prefix) } else { Vec::new() }; diff --git a/crates/rustmotion-studio/src/scenario/model.rs b/crates/rustmotion-studio/src/scenario/model.rs index fc6a99d..824b8cc 100644 --- a/crates/rustmotion-studio/src/scenario/model.rs +++ b/crates/rustmotion-studio/src/scenario/model.rs @@ -6,9 +6,14 @@ use rustmotion::schema::ResolvedScenario; /// Live studio state shared between the file watcher and the UI/asset handler. /// Wrapped in `Arc>` (`Shared`) so the watcher thread can swap in a /// reloaded scenario while the webview reads frames. +/// +/// `scenario`/`tasks` are `Arc` snapshots: consumers that render clone the +/// Arcs under a brief lock and work WITHOUT the model lock (frame handler, +/// hit-map, prefetcher). Watcher reloads build fresh Arcs; in-flight renders +/// keep the old snapshot alive until they finish. pub struct StudioModel { - pub scenario: ResolvedScenario, - pub tasks: Vec, + pub scenario: Arc, + pub tasks: Arc>, pub total_frames: u32, /// Load/parse error surfaced as a full-screen banner in the editor. pub error: Option, @@ -57,8 +62,8 @@ impl StudioModel { let tasks = rustmotion::encode::build_frame_tasks(&scenario); let total_frames = tasks.len() as u32; Self { - scenario, - tasks, + scenario: Arc::new(scenario), + tasks: Arc::new(tasks), total_frames, error, write_error: None,