Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions crates/rustmotion-studio/src/editor/diff_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,14 +47,17 @@ fn frame_for_change(shared: &Shared, change: &ElementChange) -> Option<u32> {
}
_ => 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)
}

Expand Down
38 changes: 20 additions & 18 deletions crates/rustmotion-studio/src/editor/frames.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -38,30 +38,37 @@ 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<FrameTask>,
scenario: Arc<ResolvedScenario>,
tasks: Arc<Vec<FrameTask>>,
}

fn baseline_cache() -> &'static Mutex<Option<BaselineCache>> {
static CACHE: OnceLock<Mutex<Option<BaselineCache>>> = OnceLock::new();
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<Vec<u8>, 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<ResolvedScenario>, Arc<Vec<FrameTask>>), String> {
let hash = source_hash(source);
let mut guard = baseline_cache().lock().unwrap_or_else(|e| e.into_inner());

Expand All @@ -81,18 +88,13 @@ pub fn render_baseline_frame(path: &Path, source: &str, frame: u32) -> Result<Ve
*guard = Some(BaselineCache {
path: path.to_path_buf(),
source_hash: hash,
scenario,
tasks,
scenario: Arc::new(scenario),
tasks: Arc::new(tasks),
});
}

let cache = guard.as_ref().expect("baseline cache just filled");
// Same panic fence as the live-frame handler: a Skia panic must not poison
// the cache mutex.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
render_frame(&cache.scenario, &cache.tasks, frame, 1.0)
}))
.map_err(|_| "baseline render panicked".to_string())
Ok((hash, cache.scenario.clone(), cache.tasks.clone()))
}

/// A clickable element box in percentage-of-frame coords, with its kind.
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion-studio/src/editor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod export;
pub mod frames;
mod inspector;
mod playback;
mod prefetch;
mod topbar;
mod view;

Expand Down
Loading
Loading