From 24b82f3b088dd16fa8079586436d882b16998009 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 18 Jul 2026 23:51:16 +0200 Subject: [PATCH] feat(studio): before/after diff mode for agent iterations - pure diff_scenarios over baseline/current raw JSON: added/removed/ modified elements by pointer, dot-flattened field changes with exact before/after, scene/video/document grouping, composition-aware; positional matching documented (reorders read as remove+add or same-type modifies); annotations excluded - baseline: app-global path-keyed snapshot captured insert-if-absent at open (watcher reloads are no-ops), Set-baseline button overwrites - flip A/B in the playback bar at the same frame; side A renders from the baseline source string (HTML retranspiled) through a hash-keyed scenario cache, panic-fenced; no overlay on side A so current-layout hit-boxes never lie about the baseline render - side B highlights modified (accent) and added (green) elements via the hit-map overlay; panel entries select and scrub to the element --- .../src/editor/diff_panel.rs | 167 ++++++ crates/rustmotion-studio/src/editor/frames.rs | 64 +++ crates/rustmotion-studio/src/editor/mod.rs | 1 + .../rustmotion-studio/src/editor/playback.rs | 32 +- crates/rustmotion-studio/src/editor/topbar.rs | 98 +++- crates/rustmotion-studio/src/editor/view.rs | 156 ++++-- .../src/scenario/baseline.rs | 94 ++++ crates/rustmotion-studio/src/scenario/diff.rs | 507 ++++++++++++++++++ crates/rustmotion-studio/src/scenario/mod.rs | 4 + .../rustmotion-studio/src/scenario/model.rs | 12 +- 10 files changed, 1092 insertions(+), 43 deletions(-) create mode 100644 crates/rustmotion-studio/src/editor/diff_panel.rs create mode 100644 crates/rustmotion-studio/src/scenario/baseline.rs create mode 100644 crates/rustmotion-studio/src/scenario/diff.rs diff --git a/crates/rustmotion-studio/src/editor/diff_panel.rs b/crates/rustmotion-studio/src/editor/diff_panel.rs new file mode 100644 index 0000000..84818b0 --- /dev/null +++ b/crates/rustmotion-studio/src/editor/diff_panel.rs @@ -0,0 +1,167 @@ +//! The diff/review panel: lists baseline→current changes grouped by scene, +//! and the A|B flip side selector state. Replaces the inspector panel slot +//! while diff mode is active. + +use dioxus::prelude::*; + +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)] +pub enum DiffSide { + A, + B, +} + +const GROUP_HEADER: &str = "color:var(--rm-text-muted); font-size:10px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; margin-top:6px;"; + +/// Badge glyph + color per change kind. +fn kind_badge(kind: &ChangeKind) -> (&'static str, &'static str) { + match kind { + ChangeKind::Added => ("+", "#22c55e"), + ChangeKind::Removed => ("−", "var(--rm-error)"), + ChangeKind::Modified => ("~", "var(--rm-accent)"), + } +} + +/// Display form for a field-diff endpoint ("" = the field was absent). +fn endpoint(s: &str) -> String { + if s.is_empty() { + "—".to_string() + } else { + s.to_string() + } +} + +/// Map a change's pointer to the first frame of its scene (plus `start_at` +/// offset when known) so clicking an entry scrubs to where the element lives. +fn frame_for_change(shared: &Shared, change: &ElementChange) -> Option { + // "/scenes/N/…" → (0, N); "/composition/V/scenes/N/…" → (V, N). + let segs: Vec<&str> = change.pointer.split('/').collect(); + let (view, scene) = match segs.as_slice() { + ["", "scenes", n, ..] => (0usize, n.parse::().ok()?), + ["", "composition", v, "scenes", n, ..] => { + (v.parse::().ok()?, n.parse::().ok()?) + } + _ => 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| { + 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)); + Some(frame as u32) +} + +/// The right-hand change list, grouped by scene. Clicking an entry selects the +/// element (Added/Modified — removals no longer exist in the current tree) and +/// scrubs to its first visible frame. +#[component] +pub fn DiffPanel( + changes: Vec, + selected: Signal>, + current: Signal, + diff_active: Signal, +) -> Element { + // Group labels in first-appearance order (entries arrive grouped by + // construction, but be robust to interleaving). + let mut groups: Vec = Vec::new(); + for c in &changes { + if !groups.contains(&c.group) { + groups.push(c.group.clone()); + } + } + + rsx! { + div { style: "width:300px; flex:none; min-height:0; background:var(--rm-surface); border-left:1px solid var(--rm-border); box-sizing:border-box; display:flex; flex-direction:column; overflow:auto;", + div { style: "padding:14px; display:flex; justify-content:space-between; align-items:center;", + div { + div { style: "color:var(--rm-accent); font-weight:600;", "Changes" } + div { style: "color:var(--rm-text-muted); font-size:11px;", + "{changes.len()} since baseline" + } + } + Button { + variant: ButtonVariant::Ghost, + size: ButtonSize::IconXs, + onclick: move |_| diff_active.set(false), + "✕" + } + } + if changes.is_empty() { + div { style: "padding:0 14px; color:var(--rm-text-muted);", "No changes since baseline." } + } + for group in groups { + div { style: "display:flex; flex-direction:column; gap:6px; padding:8px 14px; border-top:1px solid var(--rm-border);", + div { style: "{GROUP_HEADER}", "{group}" } + for change in changes.iter().filter(|c| c.group == group).cloned() { + ChangeEntry { change, selected, current } + } + } + } + } + } +} + +/// One change row: kind badge, label, and the field before→after list. +#[component] +fn ChangeEntry( + change: ElementChange, + mut selected: Signal>, + current: Signal, +) -> Element { + let shared = use_context::(); + let (glyph, color) = kind_badge(&change.kind); + let removable = change.kind != ChangeKind::Removed; + + let onclick = { + let shared = shared.clone(); + let change = change.clone(); + move |_| { + if let Some(frame) = frame_for_change(&shared, &change) { + current.set(frame); + } + // Removed elements have no counterpart in the current tree; the + // pointer would resolve to nothing (or the wrong element). + if removable { + selected.set(Some(( + u32::MAX, + change.pointer.clone(), + change.element_type.clone(), + ))); + } + } + }; + + rsx! { + div { + style: "border:1px solid var(--rm-border-2); border-radius:6px; padding:8px; display:flex; flex-direction:column; gap:5px; cursor:pointer;", + onclick, + div { style: "display:flex; align-items:center; gap:7px;", + span { style: "flex:none; width:16px; height:16px; border-radius:4px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:12px; color:{color}; border:1px solid {color};", + "{glyph}" + } + span { style: "color:var(--rm-text-strong); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;", + "{change.label}" + } + span { style: "color:var(--rm-text-muted); font-size:11px; margin-left:auto;", + "{change.element_type}" + } + } + for f in change.fields.iter() { + div { style: "display:flex; gap:6px; font-size:11px; align-items:baseline;", + span { style: "color:var(--rm-text-muted); flex:none;", "{f.field}" } + span { style: "overflow:hidden; text-overflow:ellipsis; white-space:nowrap;", + span { style: "color:var(--rm-error);", "{endpoint(&f.before)}" } + span { style: "color:var(--rm-text-muted);", " → " } + span { style: "color:#22c55e;", "{endpoint(&f.after)}" } + } + } + } + } + } +} diff --git a/crates/rustmotion-studio/src/editor/frames.rs b/crates/rustmotion-studio/src/editor/frames.rs index 089a803..2922b68 100644 --- a/crates/rustmotion-studio/src/editor/frames.rs +++ b/crates/rustmotion-studio/src/editor/frames.rs @@ -1,3 +1,8 @@ +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use rustmotion::encode::video::FrameTask; use rustmotion::schema::ResolvedScenario; /// Render one frame and encode it to JPEG bytes (preview-only; the final video @@ -31,6 +36,65 @@ pub fn render_frame( jpeg } +/// 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. +struct BaselineCache { + path: PathBuf, + source_hash: u64, + scenario: ResolvedScenario, + tasks: Vec, +} + +fn baseline_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(None)) +} + +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> { + let hash = source_hash(source); + let mut guard = baseline_cache().lock().unwrap_or_else(|e| e.into_inner()); + + let stale = !matches!(&*guard, Some(c) if c.path == path && c.source_hash == hash); + if stale { + let scenario = if rustmotion::loader::is_html_path(path) { + let value = rustmotion::loader::html_to_scenario_json(source) + .map_err(|e| format!("baseline transpile: {e}"))?; + let json = serde_json::to_string(&value).map_err(|e| format!("baseline json: {e}"))?; + rustmotion::loader::load_scenario_from_source(None, Some(&json)) + .map_err(|e| format!("baseline load: {e}"))? + } else { + rustmotion::loader::load_scenario_from_source(None, Some(source)) + .map_err(|e| format!("baseline load: {e}"))? + }; + let tasks = rustmotion::encode::build_frame_tasks(&scenario); + *guard = Some(BaselineCache { + path: path.to_path_buf(), + source_hash: hash, + scenario, + 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()) +} + /// A clickable element box in percentage-of-frame coords, with its kind. #[derive(Debug, Clone, PartialEq)] pub struct HitPct { diff --git a/crates/rustmotion-studio/src/editor/mod.rs b/crates/rustmotion-studio/src/editor/mod.rs index 1c01113..261636e 100644 --- a/crates/rustmotion-studio/src/editor/mod.rs +++ b/crates/rustmotion-studio/src/editor/mod.rs @@ -2,6 +2,7 @@ //! inspector, and the comments/annotations panel. mod annotations; +mod diff_panel; mod export; pub mod frames; mod inspector; diff --git a/crates/rustmotion-studio/src/editor/playback.rs b/crates/rustmotion-studio/src/editor/playback.rs index b4bbb88..27a6dcb 100644 --- a/crates/rustmotion-studio/src/editor/playback.rs +++ b/crates/rustmotion-studio/src/editor/playback.rs @@ -54,11 +54,23 @@ pub fn use_hot_reload(shared: Shared, mut rev: Signal) { } /// The bottom transport bar: play/pause, step, scrub, and a frame counter. +/// In diff mode an A|B segmented control flips the canvas between the baseline +/// (A) and the current state (B) at the same frame — the classic motion-review +/// gesture. #[component] -pub fn PlaybackBar(current: Signal, playing: Signal, total: u32) -> Element { +pub fn PlaybackBar( + current: Signal, + playing: Signal, + total: u32, + diff_active: Signal, + mut diff_side: Signal, +) -> Element { + use super::diff_panel::DiffSide; + let max = total.saturating_sub(1); let cur = current().min(max); let is_playing = playing(); + let side = diff_side(); rsx! { div { style: "display:flex; align-items:center; gap:12px; padding:12px 20px; border-top:1px solid var(--rm-border); background:var(--rm-surface-2);", @@ -68,6 +80,24 @@ pub fn PlaybackBar(current: Signal, playing: Signal, total: u32) -> E onclick: move |_| playing.set(!playing()), if is_playing { "Pause" } else { "Play" } } + if diff_active() { + div { class: "rm-seg", style: "width:auto; flex:none;", + Button { + variant: if side == DiffSide::A { ButtonVariant::Primary } else { ButtonVariant::Ghost }, + size: ButtonSize::Sm, + title: "Baseline", + onclick: move |_| diff_side.set(DiffSide::A), + "A" + } + Button { + variant: if side == DiffSide::B { ButtonVariant::Primary } else { ButtonVariant::Ghost }, + size: ButtonSize::Sm, + title: "Current", + onclick: move |_| diff_side.set(DiffSide::B), + "B" + } + } + } Button { variant: ButtonVariant::Ghost, size: ButtonSize::IconSm, diff --git a/crates/rustmotion-studio/src/editor/topbar.rs b/crates/rustmotion-studio/src/editor/topbar.rs index c9b95b7..f4c222c 100644 --- a/crates/rustmotion-studio/src/editor/topbar.rs +++ b/crates/rustmotion-studio/src/editor/topbar.rs @@ -2,12 +2,17 @@ use std::time::Duration; use dioxus::prelude::*; use dioxus_icons::lucide::{ - ChevronLeft, Download, Eye, MessageSquare, Monitor, Moon, Play, Redo2, Sun, Undo2, + Camera, ChevronLeft, Download, Eye, GitCompareArrows, MessageSquare, Monitor, Moon, Play, + Redo2, Sun, Undo2, }; use crate::components::button::{Button, ButtonSize, ButtonVariant}; -use crate::scenario::{history_slot, redo, undo, Shared, SharedHistory, Theme, View}; +use crate::scenario::{ + baseline_slot, diff_scenarios, get_baseline, history_slot, redo, set_baseline, undo, Shared, + SharedHistory, Theme, View, +}; +use super::diff_panel::DiffSide; use super::export::{export_label, export_slot, start_export, use_export_poll, ExportStatus}; /// Snapshot of the history slot for the topbar UI (polled, set-on-change). @@ -49,13 +54,37 @@ fn use_history_poll(shared: Shared, slot: SharedHistory, mut sig: Signal) { + use_future(move || { + let shared = shared.clone(); + async move { + loop { + tokio::time::sleep(Duration::from_millis(300)).await; + let (path, raw) = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + (m.path.clone(), m.raw.clone()) + }; + let changed = path + .and_then(|p| get_baseline(&baseline_slot(), &p)) + .map(|b| !diff_scenarios(&b.raw, &raw).is_empty()) + .unwrap_or(false); + if changed != sig() { + sig.set(changed); + } + } + } + }); +} + /// The editor's top bar (open-slide style): a back-to-library control on the /// left, the centered document title, and the action cluster on the right -/// (theme swap, Inspect overlay toggle, Comments panel toggle, Export, and -/// Present). `write_error` is `Some` when the last inspector write failed; -/// shown as a discrete warning indicator using the `--rm-error` token. The -/// export state (slot + polling) lives here too — the topbar is its only -/// consumer. +/// (theme swap, Set baseline, Diff toggle, Inspect overlay toggle, Comments +/// panel toggle, Export, and Present). `write_error` is `Some` when the last +/// inspector write failed; shown as a discrete warning indicator using the +/// `--rm-error` token. The export state (slot + polling) lives here too — the +/// topbar is its only consumer. #[component] pub fn TopBar( view: Signal, @@ -66,6 +95,8 @@ pub fn TopBar( show_hits: Signal, comment_count: usize, write_error: Option, + diff_active: Signal, + diff_side: Signal, ) -> Element { let shared = use_context::(); let mut theme = use_context::>(); @@ -87,6 +118,12 @@ pub fn TopBar( use_history_poll(shared.clone(), history.clone(), history_ui); let hist = history_ui(); + // Diff availability (scenario differs from baseline). + let diff_available = use_signal(|| false); + use_diff_poll(shared.clone(), diff_available); + let diffing = diff_active(); + let can_diff = diff_available(); + rsx! { div { style: "position:relative; display:flex; align-items:center; justify-content:space-between; height:40px; padding:0 12px; border-bottom:1px solid var(--rm-border); background:var(--rm-surface-2); flex:none;", div { style: "display:flex; align-items:center; gap:8px; z-index:1;", @@ -170,6 +207,32 @@ pub fn TopBar( Theme::System => rsx! { Monitor { size: 15 } }, } } + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::IconSm, + title: "Set baseline (snapshot the current state for diff review)", + onclick: { + let shared = shared.clone(); + move |_| set_baseline_now(&shared) + }, + Camera { size: 15 } + } + Button { + variant: if diffing { ButtonVariant::Secondary } else { ButtonVariant::Outline }, + size: ButtonSize::Sm, + // Stays enabled while active so it can always be switched off. + disabled: !can_diff && !diffing, + title: if can_diff || diffing { "Compare against the baseline" } else { "No changes since baseline" }, + onclick: move |_| { + let next = !diff_active(); + diff_active.set(next); + if next { + diff_side.set(DiffSide::B); + } + }, + GitCompareArrows { size: 15 } + "Diff" + } Button { variant: if inspecting { ButtonVariant::Secondary } else { ButtonVariant::Outline }, size: ButtonSize::Sm, @@ -225,3 +288,24 @@ pub fn TopBar( } } } + +/// Re-snapshot the baseline from the current state: source text re-read from +/// disk, raw taken from the live model (so it matches what future models will +/// hold). Read failures surface through `write_error`. +fn set_baseline_now(shared: &Shared) { + let (path, raw) = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + (m.path.clone(), m.raw.clone()) + }; + let Some(path) = path else { + return; + }; + match std::fs::read_to_string(&path) { + Ok(source) => set_baseline(&baseline_slot(), &path, source, raw), + Err(e) => { + let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner()); + m.write_error = Some(format!("baseline: {e}")); + m.generation = m.generation.wrapping_add(1); + } + } +} diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index 7143fc6..466c266 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -4,11 +4,13 @@ use dioxus::desktop::{ use dioxus::prelude::*; use crate::scenario::{ - history_slot, list_annotations, read_field, read_style_object, redo, undo, Shared, View, + baseline_slot, diff_scenarios, get_baseline, history_slot, list_annotations, read_field, + read_style_object, redo, undo, ChangeKind, ElementChange, Shared, View, }; use super::annotations::AnnotationsPanel; -use super::frames::{frame_hits, render_frame, scene_prefix, HitPct}; +use super::diff_panel::{DiffPanel, DiffSide}; +use super::frames::{frame_hits, render_baseline_frame, render_frame, scene_prefix, HitPct}; use super::inspector::InspectorPanel; use super::playback::{use_hot_reload, use_playback_clock, PlaybackBar}; use super::topbar::TopBar; @@ -17,10 +19,14 @@ use super::topbar::TopBar; /// (`.hov`) gets a dashed outline, and the selected element (`.sel`) a solid /// blue box. "Hovered" is tracked explicitly (one node at a time) rather than /// via CSS `:hover`, which would light every overlapping box under the cursor. +/// In diff mode, changed elements keep a persistent outline: `.diff-add` +/// (green) for added, `.diff-mod` (accent) for modified. const HIT_CSS: &str = "\ .rm-hit { position:absolute; box-sizing:border-box; cursor:pointer; border:1px dashed transparent; background:transparent; transition:border-color 80ms, background 80ms; }\ .rm-hit.hov { border-color:var(--rm-overlay-border); background:var(--rm-overlay-hover); }\ -.rm-hit.sel { border:1px dashed var(--rm-overlay-border); background:transparent; }"; +.rm-hit.sel { border:1px dashed var(--rm-overlay-border); background:transparent; }\ +.rm-hit.diff-add { border:1px solid #22c55e; }\ +.rm-hit.diff-mod { border:1px solid var(--rm-accent); }"; /// The editor view. Reads the shared studio model from context, registers an /// asset handler that renders frames to JPEG on demand, and assembles the @@ -39,12 +45,17 @@ pub fn StudioApp(view: Signal) -> Element { let show_annotations = use_signal(|| false); // Whether the clickable element overlay is shown (the "Inspect" toggle). let show_hits = use_signal(|| true); + // Diff/review mode: toggle + which state the canvas shows (A = baseline). + let diff_active = use_signal(|| false); + let diff_side = use_signal(|| DiffSide::B); - // Asset handler: GET /frame/{idx} -> JPEG of that frame. + // 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: we clone the data we need *before* dropping the lock, then - // render *outside* the lock so a Skia panic cannot poison the Mutex and - // kill the session for all future requests. + // 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. let handler_shared = shared.clone(); use_asset_handler( "frame", @@ -56,16 +67,22 @@ pub fn StudioApp(view: Signal) -> Element { .and_then(|s| s.split('?').next()) .and_then(|s| s.parse().ok()) .unwrap_or(0); + let side_a = uri.contains("side=a"); - // Render inside the lock but wrapped in `catch_unwind` so a Skia - // panic converts to an error instead of poisoning the Mutex. - // `catch_unwind` converts the panic to `Err`; the MutexGuard then - // drops normally (no unwind ⟹ no poison). - let result = { + let result: Result, ()> = if side_a { + let path = { + 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))) + .ok_or(()) + .and_then(|(p, b)| render_baseline_frame(&p, &b.source, idx).map_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(|_| ()) }; match result { Ok(jpeg) => { @@ -79,7 +96,7 @@ pub fn StudioApp(view: Signal) -> Element { .unwrap(), ); } - Err(_) => { + Err(()) => { responder.respond( Response::builder() .status(500) @@ -130,6 +147,21 @@ pub fn StudioApp(view: Signal) -> Element { }) }); + // Baseline→current diff, recomputed on hot reload (rev) while diff mode is + // active. Reading it below also subscribes this component, so the panel and + // highlights refresh when an agent edit lands on disk. + let diff_shared = shared.clone(); + let diff_data = use_memo(move || { + if !diff_active() { + return None; + } + let _ = rev(); // recompute on every reload + let m = diff_shared.lock().unwrap_or_else(|e| e.into_inner()); + let path = m.path.clone()?; + let baseline = get_baseline(&baseline_slot(), &path)?; + Some(diff_scenarios(&baseline.raw, &m.raw)) + }); + if let Some(e) = err { return rsx! { div { style: "padding:24px; color:var(--rm-error); background:var(--rm-bg); min-height:100vh; font:13px sans-serif;", @@ -138,11 +170,30 @@ pub fn StudioApp(view: Signal) -> Element { }; } - // Inspector slot is ALWAYS mounted so its width can animate between 0 and - // 300px; the canvas (flex:1) reflows each frame of that transition, giving a - // smooth resize. Content is only rendered when something is selected. + // Persistent outlines for changed elements (B side only: the hit overlay + // is computed from the CURRENT model, so it can't mark baseline layouts). + let changes: Option> = diff_data(); + let diff_marks: Vec<(String, ChangeKind)> = if diff_side() == DiffSide::B { + changes + .as_deref() + .unwrap_or(&[]) + .iter() + .filter(|c| c.kind != ChangeKind::Removed) + .map(|c| (c.pointer.clone(), c.kind.clone())) + .collect() + } else { + Vec::new() + }; + + // Panel slot: the diff panel takes over the inspector's slot while diff + // mode is active; otherwise the inspector shows for the selected element. let panel = inspector(); - let panel_w = if panel.is_some() { "300px" } else { "0px" }; + let diff_on = diff_active(); + let panel_w = if diff_on || panel.is_some() { + "300px" + } else { + "0px" + }; // Undo/redo keyboard shortcuts: the root div is focusable (and focused on // mount) so Cmd+Z / Shift+Cmd+Z (Ctrl on non-mac) reach it — key events in @@ -189,18 +240,27 @@ pub fn StudioApp(view: Signal) -> Element { show_hits, comment_count, write_error: write_err, + diff_active, + diff_side, } 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 } - PlaybackBar { current, playing, total } + Canvas { current, rev, show_hits, selected, diff_active, diff_side, diff_marks } + PlaybackBar { current, playing, total, diff_active, diff_side } } div { style: "flex:none; display:flex; overflow:hidden; transition:width 220ms ease; width:{panel_w};", // Keep Cmd+Z inside inspector inputs/textareas native // (text-field undo), not intercepted by the editor. onkeydown: move |evt: KeyboardEvent| evt.stop_propagation(), - if let Some((pointer, style, kind, content)) = panel { + if diff_on { + DiffPanel { + changes: changes.unwrap_or_default(), + selected, + current, + diff_active, + } + } else if let Some((pointer, style, kind, content)) = panel { InspectorPanel { selected, pointer, kind, current, content, style } } } @@ -216,12 +276,17 @@ pub fn StudioApp(view: Signal) -> Element { /// 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 — /// 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). #[component] fn Canvas( current: Signal, rev: Signal, show_hits: Signal, mut selected: Signal>, + diff_active: Signal, + diff_side: Signal, + diff_marks: Vec<(String, ChangeKind)>, ) -> Element { let shared = use_context::(); let (max, vw, vh) = { @@ -234,8 +299,10 @@ fn Canvas( }; 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() { + 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) @@ -255,10 +322,10 @@ 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}", + src: "/frame/{cur}?v={r}{side_suffix}", style: "display:block; width:100%; height:100%;", } - Overlay { hits, selected } + Overlay { hits, selected, diff_marks } } } } @@ -268,24 +335,51 @@ fn Canvas( /// only the element directly under the cursor is outlined (the innermost box, /// since it paints on top and captures the pointer). Owns its `hovered` signal /// so moving the cursor re-renders just the overlay, not the whole editor. +/// `diff_marks` adds persistent outlines to changed elements (matched by +/// pointer) while diff mode shows the current state. #[component] -fn Overlay(hits: Vec, selected: Signal>) -> Element { +fn Overlay( + hits: Vec, + selected: Signal>, + diff_marks: Vec<(String, ChangeKind)>, +) -> Element { let mut hovered = use_signal(|| None::); - let selected_node = selected().map(|(id, _, _)| id); + // Selection matches by node id (canvas clicks) or pointer (diff panel + // clicks store u32::MAX as the id). + let (selected_node, selected_ptr) = match selected() { + Some((id, ptr, _)) => (Some(id), Some(ptr)), + None => (None, None), + }; let hov = hovered(); + let mark_class = |hit: &HitPct| -> &'static str { + let Some(ptr) = hit.pointer.as_deref() else { + return ""; + }; + match diff_marks.iter().find(|(p, _)| p == ptr) { + Some((_, ChangeKind::Added)) => " diff-add", + Some((_, ChangeKind::Modified)) => " diff-mod", + _ => "", + } + }; + rsx! { div { style: "position:absolute; inset:0;", style { "{HIT_CSS}" } for hit in hits.iter() { div { key: "{hit.node_id}", - class: if selected_node == Some(hit.node_id) { - "rm-hit sel" - } else if hov == Some(hit.node_id) { - "rm-hit hov" - } else { - "rm-hit" + class: { + let is_sel = selected_node == Some(hit.node_id) + || (hit.pointer.is_some() && hit.pointer.as_deref() == selected_ptr.as_deref()); + let base = if is_sel { + "rm-hit sel" + } else if hov == Some(hit.node_id) { + "rm-hit hov" + } else { + "rm-hit" + }; + format!("{base}{}", mark_class(hit)) }, style: format!( "left:{}%; top:{}%; width:{}%; height:{}%;", diff --git a/crates/rustmotion-studio/src/scenario/baseline.rs b/crates/rustmotion-studio/src/scenario/baseline.rs new file mode 100644 index 0000000..4b26b08 --- /dev/null +++ b/crates/rustmotion-studio/src/scenario/baseline.rs @@ -0,0 +1,94 @@ +//! Baseline snapshots for the diff/review mode. +//! +//! A baseline is the scenario as it was when first opened in the editor this +//! session (source text + parsed raw JSON), kept in an app-global map keyed by +//! path. Watcher reloads never touch it, and re-opening the same file keeps +//! its original baseline until the app quits or the user re-snapshots via +//! "Set baseline". + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use serde_json::Value; + +/// One snapshot: the source file text (JSON text or HTML markup) and the raw +/// scenario JSON derived from it (used for diffing). +#[derive(Clone)] +pub struct Baseline { + pub source: String, + pub raw: Value, +} + +pub type SharedBaselines = Arc>>; + +/// The app-global baseline map. +pub fn baseline_slot() -> SharedBaselines { + static SLOT: OnceLock = OnceLock::new(); + SLOT.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) + .clone() +} + +/// Insert a baseline for `path` if none exists yet (first open wins; watcher +/// reloads call this too and are no-ops). Skips null raw (unparseable source). +pub fn ensure_baseline(slot: &SharedBaselines, path: &Path, source: &str, raw: &Value) { + if raw.is_null() { + return; + } + let mut map = slot.lock().unwrap_or_else(|e| e.into_inner()); + map.entry(path.to_path_buf()).or_insert_with(|| Baseline { + source: source.to_string(), + raw: raw.clone(), + }); +} + +/// Re-snapshot: overwrite the baseline for `path` ("Set baseline" button). +pub fn set_baseline(slot: &SharedBaselines, path: &Path, source: String, raw: Value) { + let mut map = slot.lock().unwrap_or_else(|e| e.into_inner()); + map.insert(path.to_path_buf(), Baseline { source, raw }); +} + +/// Fetch the baseline for `path`, if any. +pub fn get_baseline(slot: &SharedBaselines, path: &Path) -> Option { + let map = slot.lock().unwrap_or_else(|e| e.into_inner()); + map.get(path).cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn local() -> SharedBaselines { + Arc::new(Mutex::new(HashMap::new())) + } + + #[test] + fn ensure_keeps_the_original_snapshot() { + let slot = local(); + let p = Path::new("/w/a.json"); + ensure_baseline(&slot, p, "v1", &json!({ "v": 1 })); + // A later ensure (e.g. watcher reload after an edit) must not replace it. + ensure_baseline(&slot, p, "v2", &json!({ "v": 2 })); + let b = get_baseline(&slot, p).unwrap(); + assert_eq!(b.source, "v1"); + assert_eq!(b.raw, json!({ "v": 1 })); + } + + #[test] + fn set_baseline_overwrites() { + let slot = local(); + let p = Path::new("/w/a.json"); + ensure_baseline(&slot, p, "v1", &json!(1)); + set_baseline(&slot, p, "v2".into(), json!(2)); + assert_eq!(get_baseline(&slot, p).unwrap().source, "v2"); + } + + #[test] + fn null_raw_is_not_snapshotted_and_missing_is_none() { + let slot = local(); + let p = Path::new("/w/broken.json"); + ensure_baseline(&slot, p, "{ bad", &Value::Null); + assert!(get_baseline(&slot, p).is_none()); + } +} diff --git a/crates/rustmotion-studio/src/scenario/diff.rs b/crates/rustmotion-studio/src/scenario/diff.rs new file mode 100644 index 0000000..456ff38 --- /dev/null +++ b/crates/rustmotion-studio/src/scenario/diff.rs @@ -0,0 +1,507 @@ +//! Pure scenario diff for the studio's before/after review mode. +//! +//! Compares the baseline raw scenario JSON against the current one and +//! reports element-level changes, identified by JSON pointer. Matching is +//! positional (by pointer): an element is compared with whatever sits at the +//! same index in the other tree. Consequently a reorder of different-typed +//! siblings shows as remove+add (type mismatch at both indices), and a +//! reorder of same-typed siblings shows as paired field modifications. +//! +//! Top-level `annotations` are deliberately ignored (studio feedback, not +//! scenario content). + +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, PartialEq)] +pub enum ChangeKind { + Added, + Removed, + Modified, +} + +/// One changed field of a modified element, dot-flattened (`style.font-size`). +/// `before`/`after` are display strings; an empty string means the field was +/// absent on that side. +#[derive(Debug, Clone, PartialEq)] +pub struct FieldChange { + pub field: String, + pub before: String, + pub after: String, +} + +/// One changed element (or the video config / document root), addressed by a +/// JSON pointer into the current scenario (or the baseline for removals). +#[derive(Debug, Clone, PartialEq)] +pub struct ElementChange { + pub pointer: String, + pub kind: ChangeKind, + /// Component `type` ("text", "card", …), or "scene" / "video" / "document". + pub element_type: String, + /// Short display label (content snippet or the type). + pub label: String, + /// Display group ("Video", "Scene 1", "View 2 · Scene 1", "Document"). + pub group: String, + /// Per-field before → after list (only for `Modified`). + pub fields: Vec, + /// The element's `start_at`, for click-to-scrub. + pub start_at: Option, +} + +/// Diff two raw scenario JSON documents. Pure. +pub fn diff_scenarios(baseline: &Value, current: &Value) -> Vec { + let mut out = Vec::new(); + + // Video config as one pseudo-element. + let empty = Map::new(); + let b_video = baseline + .get("video") + .and_then(|v| v.as_object()) + .unwrap_or(&empty); + let c_video = current + .get("video") + .and_then(|v| v.as_object()) + .unwrap_or(&empty); + let mut fields = Vec::new(); + diff_fields("", b_video, c_video, &mut fields); + if !fields.is_empty() { + out.push(ElementChange { + pointer: "/video".into(), + kind: ChangeKind::Modified, + element_type: "video".into(), + label: "video".into(), + group: "Video".into(), + fields, + start_at: None, + }); + } + + // Scene lists: top-level `scenes` and/or `composition/*/scenes`, keyed by + // pointer prefix so mismatched shapes fall out as remove+add naturally. + for (prefix, group_prefix) in scene_prefixes(baseline, current) { + let b_scenes = scenes_at(baseline, &prefix); + let c_scenes = scenes_at(current, &prefix); + let max = b_scenes.len().max(c_scenes.len()); + for i in 0..max { + let pointer = format!("{prefix}/{i}"); + let group = format!("{group_prefix}Scene {}", i + 1); + match (b_scenes.get(i), c_scenes.get(i)) { + (Some(b), Some(c)) => { + diff_element(&pointer, &group, b, c, "scene", &mut out); + } + (Some(b), None) => out.push(entry(&pointer, ChangeKind::Removed, b, &group)), + (None, Some(c)) => out.push(entry(&pointer, ChangeKind::Added, c, &group)), + (None, None) => {} + } + } + } + + // Any other top-level drift (fonts, audio, config, backgrounds…) except + // scenes/composition (walked above) and annotations (studio feedback). + let skip = ["video", "scenes", "composition", "annotations"]; + let b_root = baseline.as_object().cloned().unwrap_or_default(); + let c_root = current.as_object().cloned().unwrap_or_default(); + let b_rest: Map = b_root + .into_iter() + .filter(|(k, _)| !skip.contains(&k.as_str())) + .collect(); + let c_rest: Map = c_root + .into_iter() + .filter(|(k, _)| !skip.contains(&k.as_str())) + .collect(); + let mut rest_fields = Vec::new(); + diff_fields("", &b_rest, &c_rest, &mut rest_fields); + if !rest_fields.is_empty() { + out.push(ElementChange { + pointer: "/".into(), + kind: ChangeKind::Modified, + element_type: "document".into(), + label: "document".into(), + group: "Document".into(), + fields: rest_fields, + start_at: None, + }); + } + + out +} + +/// The scene-array pointer prefixes present in either document, with their +/// display group prefix (`""` or `"View N · "`), deduplicated, stable order. +fn scene_prefixes(baseline: &Value, current: &Value) -> Vec<(String, String)> { + let mut prefixes = Vec::new(); + let mut push = |p: String, g: String| { + if !prefixes.iter().any(|(q, _)| *q == p) { + prefixes.push((p, g)); + } + }; + for doc in [baseline, current] { + if doc.get("scenes").and_then(|s| s.as_array()).is_some() { + push("/scenes".to_string(), String::new()); + } + if let Some(views) = doc.get("composition").and_then(|c| c.as_array()) { + for (v, _) in views.iter().enumerate() { + push( + format!("/composition/{v}/scenes"), + format!("View {} · ", v + 1), + ); + } + } + } + prefixes +} + +/// The scene array at a pointer prefix (empty when absent). +fn scenes_at<'a>(doc: &'a Value, prefix: &str) -> &'a [Value] { + doc.pointer(prefix) + .and_then(|v| v.as_array()) + .map(|a| a.as_slice()) + .unwrap_or(&[]) +} + +/// Compare two elements at the same pointer. Same type → field diff (dot +/// flattened, recursive into objects like `style`, skipping `children`) then +/// recurse into children pairwise. Different type → remove + add. +fn diff_element( + pointer: &str, + group: &str, + b: &Value, + c: &Value, + fallback_type: &str, + out: &mut Vec, +) { + let b_type = b.get("type").and_then(|t| t.as_str()); + let c_type = c.get("type").and_then(|t| t.as_str()); + if b_type != c_type { + out.push(entry(pointer, ChangeKind::Removed, b, group)); + out.push(entry(pointer, ChangeKind::Added, c, group)); + return; + } + + let empty = Map::new(); + let b_obj = b.as_object().unwrap_or(&empty); + let c_obj = c.as_object().unwrap_or(&empty); + let mut fields = Vec::new(); + diff_fields("", b_obj, c_obj, &mut fields); + if !fields.is_empty() { + out.push(ElementChange { + pointer: pointer.to_string(), + kind: ChangeKind::Modified, + element_type: b_type.unwrap_or(fallback_type).to_string(), + label: label_of(c, fallback_type), + group: group.to_string(), + fields, + start_at: start_at_of(c), + }); + } + + let none: &[Value] = &[]; + let b_children = b + .get("children") + .and_then(|v| v.as_array()) + .map(|a| a.as_slice()) + .unwrap_or(none); + let c_children = c + .get("children") + .and_then(|v| v.as_array()) + .map(|a| a.as_slice()) + .unwrap_or(none); + let max = b_children.len().max(c_children.len()); + for j in 0..max { + let child_ptr = format!("{pointer}/children/{j}"); + match (b_children.get(j), c_children.get(j)) { + (Some(bc), Some(cc)) => diff_element(&child_ptr, group, bc, cc, "element", out), + (Some(bc), None) => out.push(entry(&child_ptr, ChangeKind::Removed, bc, group)), + (None, Some(cc)) => out.push(entry(&child_ptr, ChangeKind::Added, cc, group)), + (None, None) => {} + } + } +} + +/// Build an Added/Removed entry for a whole element (no recursion inside — one +/// entry per added/removed subtree). +fn entry(pointer: &str, kind: ChangeKind, el: &Value, group: &str) -> ElementChange { + let element_type = el + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("scene") + .to_string(); + ElementChange { + pointer: pointer.to_string(), + kind, + label: label_of(el, &element_type), + element_type, + group: group.to_string(), + fields: Vec::new(), + start_at: start_at_of(el), + } +} + +/// Dot-flattened field diff over two objects, recursive into nested objects +/// (style etc.). `children` is skipped at the element's top level only. +/// Key order: baseline keys first, then current-only keys. +fn diff_fields( + prefix: &str, + b: &Map, + c: &Map, + out: &mut Vec, +) { + let mut keys: Vec<&String> = b.keys().collect(); + for k in c.keys() { + if !b.contains_key(k) { + keys.push(k); + } + } + for k in keys { + if prefix.is_empty() && k == "children" { + continue; + } + let field = format!("{prefix}{k}"); + match (b.get(k), c.get(k)) { + (Some(bv), Some(cv)) if bv == cv => {} + (Some(Value::Object(bo)), Some(Value::Object(co))) => { + diff_fields(&format!("{field}."), bo, co, out); + } + (Some(bv), Some(cv)) => out.push(FieldChange { + field, + before: fmt(bv), + after: fmt(cv), + }), + (Some(bv), None) => out.push(FieldChange { + field, + before: fmt(bv), + after: String::new(), + }), + (None, Some(cv)) => out.push(FieldChange { + field, + before: String::new(), + after: fmt(cv), + }), + (None, None) => {} + } + } +} + +/// Display string for a JSON value (strings unquoted; floats keep one decimal +/// via serde's canonical form). +fn fmt(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Short display label: content snippet (24 chars max) or the type. +fn label_of(el: &Value, fallback: &str) -> String { + match el.get("content").and_then(|c| c.as_str()) { + Some(s) if !s.is_empty() => { + let mut label: String = s.chars().take(24).collect(); + if s.chars().count() > 24 { + label.push('…'); + } + label + } + _ => fallback.to_string(), + } +} + +fn start_at_of(el: &Value) -> Option { + el.get("start_at").and_then(|v| v.as_f64()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn base() -> Value { + json!({ + "video": { "width": 1920, "height": 1080 }, + "scenes": [ + { "duration": 3.0, "children": [ + { "type": "text", "content": "Hi", "style": { "font-size": 48, "color": "#fff" } } + ] } + ] + }) + } + + #[test] + fn equal_scenarios_produce_empty_diff() { + assert!(diff_scenarios(&base(), &base()).is_empty()); + } + + #[test] + fn added_element_detected() { + let mut cur = base(); + cur["scenes"][0]["children"] + .as_array_mut() + .unwrap() + .push(json!({ "type": "shape", "start_at": 1.5 })); + let d = diff_scenarios(&base(), &cur); + assert_eq!(d.len(), 1); + assert_eq!(d[0].kind, ChangeKind::Added); + assert_eq!(d[0].pointer, "/scenes/0/children/1"); + assert_eq!(d[0].element_type, "shape"); + assert_eq!(d[0].group, "Scene 1"); + assert_eq!(d[0].start_at, Some(1.5)); + } + + #[test] + fn removed_element_detected() { + let mut cur = base(); + cur["scenes"][0]["children"].as_array_mut().unwrap().clear(); + let d = diff_scenarios(&base(), &cur); + assert_eq!(d.len(), 1); + assert_eq!(d[0].kind, ChangeKind::Removed); + assert_eq!(d[0].pointer, "/scenes/0/children/0"); + assert_eq!(d[0].element_type, "text"); + assert_eq!(d[0].label, "Hi"); + } + + #[test] + fn style_modification_reports_exact_before_after() { + let mut cur = base(); + cur["scenes"][0]["children"][0]["style"]["font-size"] = json!(64); + let d = diff_scenarios(&base(), &cur); + assert_eq!(d.len(), 1); + assert_eq!(d[0].kind, ChangeKind::Modified); + assert_eq!(d[0].pointer, "/scenes/0/children/0"); + assert_eq!( + d[0].fields, + vec![FieldChange { + field: "style.font-size".into(), + before: "48".into(), + after: "64".into(), + }] + ); + } + + #[test] + fn content_modification_detected() { + let mut cur = base(); + cur["scenes"][0]["children"][0]["content"] = json!("Hello"); + let d = diff_scenarios(&base(), &cur); + assert_eq!(d.len(), 1); + assert_eq!( + d[0].fields, + vec![FieldChange { + field: "content".into(), + before: "Hi".into(), + after: "Hello".into(), + }] + ); + } + + #[test] + fn nested_children_modification_detected() { + let nested = json!({ + "video": { "width": 1, "height": 1 }, + "scenes": [ { "duration": 1.0, "children": [ + { "type": "card", "children": [ + { "type": "text", "content": "a" }, + { "type": "text", "content": "b" } + ] } + ] } ] + }); + let mut cur = nested.clone(); + cur["scenes"][0]["children"][0]["children"][1]["content"] = json!("B!"); + let d = diff_scenarios(&nested, &cur); + assert_eq!(d.len(), 1); + assert_eq!(d[0].pointer, "/scenes/0/children/0/children/1"); + assert_eq!(d[0].kind, ChangeKind::Modified); + assert_eq!(d[0].fields[0].field, "content"); + } + + #[test] + fn reorder_of_different_types_shows_remove_plus_add() { + let b = json!({ + "video": { "width": 1, "height": 1 }, + "scenes": [ { "duration": 1.0, "children": [ + { "type": "text", "content": "t" }, + { "type": "shape" } + ] } ] + }); + let mut cur = b.clone(); + cur["scenes"][0]["children"] + .as_array_mut() + .unwrap() + .reverse(); + let d = diff_scenarios(&b, &cur); + // Type mismatch at both indices → remove+add per slot. + assert_eq!(d.len(), 4); + assert_eq!( + d.iter().filter(|c| c.kind == ChangeKind::Removed).count(), + 2 + ); + assert_eq!(d.iter().filter(|c| c.kind == ChangeKind::Added).count(), 2); + } + + #[test] + fn scene_added_detected() { + let mut cur = base(); + cur["scenes"] + .as_array_mut() + .unwrap() + .push(json!({ "duration": 2.0 })); + let d = diff_scenarios(&base(), &cur); + assert_eq!(d.len(), 1); + assert_eq!(d[0].kind, ChangeKind::Added); + assert_eq!(d[0].pointer, "/scenes/1"); + assert_eq!(d[0].element_type, "scene"); + assert_eq!(d[0].group, "Scene 2"); + } + + #[test] + fn scene_duration_modification_detected() { + let mut cur = base(); + cur["scenes"][0]["duration"] = json!(5.0); + let d = diff_scenarios(&base(), &cur); + assert_eq!(d.len(), 1); + assert_eq!(d[0].pointer, "/scenes/0"); + assert_eq!(d[0].element_type, "scene"); + assert_eq!(d[0].fields[0].field, "duration"); + assert_eq!(d[0].fields[0].before, "3.0"); + assert_eq!(d[0].fields[0].after, "5.0"); + } + + #[test] + fn video_config_change_detected() { + let mut cur = base(); + cur["video"]["width"] = json!(1080); + let d = diff_scenarios(&base(), &cur); + assert_eq!(d.len(), 1); + assert_eq!(d[0].pointer, "/video"); + assert_eq!(d[0].group, "Video"); + assert_eq!( + d[0].fields, + vec![FieldChange { + field: "width".into(), + before: "1920".into(), + after: "1080".into(), + }] + ); + } + + #[test] + fn annotations_are_ignored() { + let mut cur = base(); + cur["annotations"] = json!([{ "id": "a", "note": "n", + "target": { "pointer": "/scenes/0" } }]); + assert!(diff_scenarios(&base(), &cur).is_empty()); + } + + #[test] + fn composition_scenes_get_view_scoped_pointers() { + let b = json!({ + "video": { "width": 1, "height": 1 }, + "composition": [ { "type": "slide", "scenes": [ + { "duration": 1.0, "children": [ { "type": "text", "content": "x" } ] } + ] } ] + }); + let mut cur = b.clone(); + cur["composition"][0]["scenes"][0]["children"][0]["content"] = json!("y"); + let d = diff_scenarios(&b, &cur); + assert_eq!(d.len(), 1); + assert_eq!(d[0].pointer, "/composition/0/scenes/0/children/0"); + assert_eq!(d[0].group, "View 1 · Scene 1"); + } +} diff --git a/crates/rustmotion-studio/src/scenario/mod.rs b/crates/rustmotion-studio/src/scenario/mod.rs index 0fd5c25..7bd56fb 100644 --- a/crates/rustmotion-studio/src/scenario/mod.rs +++ b/crates/rustmotion-studio/src/scenario/mod.rs @@ -1,11 +1,15 @@ //! Scenario data layer: the live studio model, JSON-pointer style edits, and //! the top-level view enum shared across features. +mod baseline; +mod diff; mod edit; mod history; mod model; mod sidecar; +pub use baseline::{baseline_slot, get_baseline, set_baseline}; +pub use diff::{diff_scenarios, ChangeKind, ElementChange}; pub use edit::{ append_annotation, list_annotations, read_field, read_style_object, remove_annotation, set_field, set_style, diff --git a/crates/rustmotion-studio/src/scenario/model.rs b/crates/rustmotion-studio/src/scenario/model.rs index c34a1a1..fc6a99d 100644 --- a/crates/rustmotion-studio/src/scenario/model.rs +++ b/crates/rustmotion-studio/src/scenario/model.rs @@ -38,16 +38,20 @@ impl StudioModel { .as_ref() .and_then(|p| { let s = std::fs::read_to_string(p).ok()?; - if rustmotion::loader::is_html_path(p) { + let raw = if rustmotion::loader::is_html_path(p) { let raw = rustmotion::loader::html_to_scenario_json(&s).ok()?; // A corrupt sidecar already failed the scenario load in the // loader (error banner); a read failure here can only be a // race, so fall back to the bare transpile. let annotations = super::sidecar::read_sidecar(p).unwrap_or_default(); - Some(super::sidecar::merge_annotations(raw, annotations)) + super::sidecar::merge_annotations(raw, annotations) } else { - serde_json::from_str(&s).ok() - } + serde_json::from_str(&s).ok()? + }; + // First-open snapshot for the diff/review mode. Insert-if-absent, + // so watcher reloads of an edited file keep the original baseline. + super::baseline::ensure_baseline(&super::baseline::baseline_slot(), p, &s, &raw); + Some(raw) }) .unwrap_or(serde_json::Value::Null); let tasks = rustmotion::encode::build_frame_tasks(&scenario);