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
167 changes: 167 additions & 0 deletions crates/rustmotion-studio/src/editor/diff_panel.rs
Original file line number Diff line number Diff line change
@@ -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<u32> {
// "/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::<usize>().ok()?),
["", "composition", v, "scenes", n, ..] => {
(v.parse::<usize>().ok()?, n.parse::<usize>().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<ElementChange>,
selected: Signal<Option<(u32, String, String)>>,
current: Signal<u32>,
diff_active: Signal<bool>,
) -> Element {
// Group labels in first-appearance order (entries arrive grouped by
// construction, but be robust to interleaving).
let mut groups: Vec<String> = 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<Option<(u32, String, String)>>,
current: Signal<u32>,
) -> Element {
let shared = use_context::<Shared>();
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)}" }
}
}
}
}
}
}
64 changes: 64 additions & 0 deletions crates/rustmotion-studio/src/editor/frames.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<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 {
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> {
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 {
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 @@ -2,6 +2,7 @@
//! inspector, and the comments/annotations panel.

mod annotations;
mod diff_panel;
mod export;
pub mod frames;
mod inspector;
Expand Down
32 changes: 31 additions & 1 deletion crates/rustmotion-studio/src/editor/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,23 @@ pub fn use_hot_reload(shared: Shared, mut rev: Signal<u64>) {
}

/// 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<u32>, playing: Signal<bool>, total: u32) -> Element {
pub fn PlaybackBar(
current: Signal<u32>,
playing: Signal<bool>,
total: u32,
diff_active: Signal<bool>,
mut diff_side: Signal<super::diff_panel::DiffSide>,
) -> 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);",
Expand All @@ -68,6 +80,24 @@ pub fn PlaybackBar(current: Signal<u32>, playing: Signal<bool>, 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,
Expand Down
Loading
Loading