diff --git a/Cargo.lock b/Cargo.lock index f46bbd86..059a5dad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -637,15 +637,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bindgen" version = "0.69.5" @@ -709,30 +700,15 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec 0.9.1", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bit-vec" version = "0.9.1" @@ -2064,17 +2040,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set 0.8.0", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fast-srgb8" version = "1.0.0" @@ -4170,12 +4135,6 @@ version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linktime-proc-macro" version = "0.2.3" @@ -4552,7 +4511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set 0.9.1", + "bit-set", "bitflags 2.13.2", "cfg-if", "cfg_aliases", @@ -5626,19 +5585,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64", - "indexmap", - "quick-xml 0.38.4", - "serde", - "time", -] - [[package]] name = "png" version = "0.17.16" @@ -5854,15 +5800,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.39.4" @@ -6660,10 +6597,8 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "similar", "skia-safe", "symphonia", - "syntect", "tiny-skia", "ureq", "usvg 0.44.0", @@ -6682,9 +6617,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "similar", "skia-safe", - "syntect", "thorvg", "tiny-skia", "usvg 0.44.0", @@ -6704,9 +6637,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "similar", "skia-safe", - "syntect", "taffy 0.10.1", "thiserror 2.0.18", "tiny-skia", @@ -7190,12 +7121,6 @@ dependencies = [ "quote", ] -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "simplecss" version = "0.2.2" @@ -7787,27 +7712,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "syntect" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" -dependencies = [ - "bincode", - "fancy-regex", - "flate2", - "fnv", - "once_cell", - "plist", - "regex-syntax", - "serde", - "serde_derive", - "serde_json", - "thiserror 2.0.18", - "walkdir", - "yaml-rust", -] - [[package]] name = "sys-locale" version = "0.3.2" @@ -8044,12 +7948,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -8058,16 +7960,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-keccak" version = "2.0.2" @@ -9114,8 +9006,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set 0.9.1", - "bit-vec 0.9.1", + "bit-set", + "bit-vec", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -9186,7 +9078,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.9.1", + "bit-set", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", @@ -10248,15 +10140,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "yazi" version = "0.2.1" diff --git a/crates/rustmotion-components/Cargo.toml b/crates/rustmotion-components/Cargo.toml index db478ee6..8ae11a63 100644 --- a/crates/rustmotion-components/Cargo.toml +++ b/crates/rustmotion-components/Cargo.toml @@ -26,7 +26,5 @@ usvg = "0.44" tiny-skia = "0.11" qrcode = "0.14" gif = "0.13" -syntect = { version = "5", default-features = false, features = ["default-fancy"] } -similar = "2" thorvg = { version = "0.5", optional = true } dashmap = { version = "6", optional = true } diff --git a/crates/rustmotion-components/src/avatar.rs b/crates/rustmotion-components/src/avatar.rs index 9f5aa8e3..7a358133 100644 --- a/crates/rustmotion-components/src/avatar.rs +++ b/crates/rustmotion-components/src/avatar.rs @@ -35,6 +35,13 @@ impl AvatarStatus { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`avatar` is a frozen composition (issue #333). Compose a circular avatar from a \ + `shape` (`circle`) or a circle-clipped `image` instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Avatar { pub src: String, #[serde(default = "default_avatar_size")] diff --git a/crates/rustmotion-components/src/avatar_group.rs b/crates/rustmotion-components/src/avatar_group.rs index aa8d02a2..09c3ce57 100644 --- a/crates/rustmotion-components/src/avatar_group.rs +++ b/crates/rustmotion-components/src/avatar_group.rs @@ -36,6 +36,14 @@ pub struct AvatarGroupItem { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`avatar_group` is a frozen composition (issue #333). Compose a stacked group \ + from repeated avatar `shape`s/`image`s in a row with negative `margin-left` \ + overlap instead — see crates/rustmotion/skills/rules/composition-recipes.md. \ + Kept for compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct AvatarGroup { pub avatars: Vec, #[serde(default)] diff --git a/crates/rustmotion-components/src/badge.rs b/crates/rustmotion-components/src/badge.rs index f4f2cdbb..f9fa9c54 100644 --- a/crates/rustmotion-components/src/badge.rs +++ b/crates/rustmotion-components/src/badge.rs @@ -44,6 +44,14 @@ impl BadgeSize { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`badge` is a frozen composition (issue #333). Compose a rounded `div`/`card` \ + pill + `icon` + `text` instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md and \ + examples/composition-pill-row.json. Kept for compatibility; scheduled for \ + removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Badge { pub text: String, #[serde(default)] diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 218c048e..4f5fe861 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -6,9 +6,10 @@ //! - `width` / `height` from the component's `size` field (if any) //! - `z-index` from the child's `z_index` field //! -//! Container components (Card / Flex / Grid / Container / Positioned) -//! recursively build child boxes. Leaf components produce an empty-children -//! box that the dispatcher will paint. +//! The single container component (`Component::Container`, tagged `div` and +//! aliased `container`/`card`/`flex`/`grid`/`positioned` — all six spellings +//! deserialize into the same struct) recursively builds child boxes. Leaf +//! components produce an empty-children box that the dispatcher will paint. //! //! The builder also returns a flat `Vec<&Component>` indexed by NodeId so //! the painter can resolve a node back to its component. @@ -19,6 +20,8 @@ use rustmotion_core::css::style::{AlignSelf, CssStyle, Position, Size as CSize}; use rustmotion_core::css::{apply_animated_props, LengthPercentage as CLP}; use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties}; use rustmotion_core::engine::box_tree::{BoxKind, BoxNode, NodeId}; +use rustmotion_core::engine::deps::NodeRef; +use rustmotion_core::expr::Scope; use rustmotion_core::schema::video::{AnimationEffect, MotionBlurConfig, TrailConfig}; use crate::callout::ArrowDirection as CalloutArrowDirection; @@ -125,11 +128,76 @@ pub fn build_scene_with_anim<'a>( /// Same as [`build_scene_with_root`] but accepts an iterator over /// `&ChildComponent` references. Useful when the caller has filtered or /// re-ordered the scene's children and doesn't want to clone. +/// +/// Builds with no outer [`Scope`] — see +/// [`build_scene_from_refs_with_scope`]'s doc for what that means and why +/// every other public entry point in this file (this one included) keeps +/// that parameter fixed at `None` rather than exposing it: a scenario using +/// no `vars` and no `node(...)` reference renders through exactly this +/// path, unchanged, whatever `rustmotion/src/engine/render/scene.rs` does +/// on its own richer path. pub fn build_scene_from_refs<'a, I>( + children: I, + viewport: (f32, f32), + root_css: CssStyle, + anim: Option, +) -> BuiltScene<'a> +where + I: IntoIterator, +{ + build_scene_from_refs_with_scope(children, viewport, root_css, anim, None) +} + +/// Full form of [`build_scene_from_refs`]: `outer_scope` is tried, after the +/// per-node [`rustmotion_core::css::FrameClock`], by every node's own +/// `style.expr` (issue #338) — see [`resolve_computed_style`]'s doc for the +/// exact composition (`rustmotion_core::css::ComposedScope`) and why the +/// clock always wins for its own six reserved names. +/// +/// `outer_scope` is caller-owned and frame-global: unlike `anim` (which +/// `build_child` remaps per node through each container's own +/// `time_scale`/`time_offset`), the same `&dyn Scope` reference is handed to +/// every node in this call — a declared `vars` name and a `node(...)` +/// reference both resolve against one shared, already-computed state for +/// the whole frame, not a per-node one. See +/// `rustmotion/src/engine/render/scene.rs`'s `EngineScope` for what +/// typically sits behind it (a `vars::VarScope` composed with an +/// `engine::deps::ResolvedFrame`) and why that composition happens one +/// level up rather than in this crate: this crate does not otherwise depend +/// on `rustmotion-core`'s `vars` or `engine::deps` modules by name, only on +/// the `Scope` trait object they both implement. +pub fn build_scene_from_refs_with_scope<'a, I>( + children: I, + viewport: (f32, f32), + root_css: CssStyle, + anim: Option, + outer_scope: Option<&dyn Scope>, +) -> BuiltScene<'a> +where + I: IntoIterator, +{ + build_scene_from_refs_with_scope_quiet(children, viewport, root_css, anim, outer_scope, true) +} + +/// Full form of [`build_scene_from_refs_with_scope`]: `warn_unresolved` +/// gates [`resolve_computed_style`]'s stderr warning for an expression that +/// fails to resolve against `outer_scope`. `false` for a build whose only +/// purpose is to seed a not-yet-complete `outer_scope` (the throwaway first +/// pass `render_with_new_pipeline_iter` runs for a scene with a `node(...)` +/// reference — see that function's doc): a `node(...)` call there fails +/// *by construction*, not because anything is actually wrong, and is +/// corrected by the very next build; warning about it would be a stderr +/// line that doesn't describe the frame that actually gets painted. Every +/// other build (the common no-outer-scope path, and any build whose +/// `outer_scope` is already complete) keeps warning — an expression that +/// still fails there really does keep its pre-expression value on screen. +pub fn build_scene_from_refs_with_scope_quiet<'a, I>( children: I, viewport: (f32, f32), mut root_css: CssStyle, anim: Option, + outer_scope: Option<&dyn Scope>, + warn_unresolved: bool, ) -> BuiltScene<'a> where I: IntoIterator, @@ -152,6 +220,9 @@ where 0.0, (1.0, 0.0), &root_css, + viewport, + outer_scope, + warn_unresolved, )); } @@ -177,6 +248,84 @@ where } } +/// Every declared `id` in this subtree, paired with the `node(...)` +/// references its own style expressions make — the +/// `(String, Vec)` shape `rustmotion_core::engine::deps::DepGraph::build` +/// wants. A node's references were already found once, at load (see +/// `rustmotion_core::css::computed::extract`'s `node_refs` field), so this +/// is a plain tree walk with no `Expr`/JSON work of its own — cheap enough +/// to call every frame, and callers on the hot per-frame path do (see +/// `rustmotion/src/engine/render/scene.rs`). +/// +/// Recurses into a container's children via [`container_children_of`], the +/// same `Component` variant match `container_children` uses (minus the +/// animation-context bookkeeping this walk doesn't need) to decide which +/// components nest children and under which field, so a component type +/// this file doesn't (yet) recurse into cannot silently diverge between +/// "what gets laid out" and "what gets scanned for references". +pub fn collect_node_refs<'a, I>(children: I) -> Vec<(String, Vec)> +where + I: IntoIterator, +{ + let mut out = Vec::new(); + for child in children { + collect_node_refs_into(child, &mut out); + } + out +} + +fn collect_node_refs_into(child: &ChildComponent, out: &mut Vec<(String, Vec)>) { + if let Some(id) = &child.id { + out.push(( + id.clone(), + component_style(&child.component).expr.node_refs.clone(), + )); + } + for c in container_children_of(&child.component) { + collect_node_refs_into(c, out); + } +} + +/// The child slice a container `Component` variant nests its own children +/// under, empty for anything else — the one list of "which variants nest +/// children and where" every tree walk in this file that needs to see the +/// *whole* subtree (not just what `container_children` lays out with an +/// animation context in hand) shares, rather than re-deriving its own copy +/// that could quietly drift from it. +fn container_children_of(component: &Component) -> &[ChildComponent] { + match component { + Component::Container(c) => &c.children, + _ => &[], + } +} + +/// True when *any* node in this subtree — declared `id` or not — makes at +/// least one `node(...)` reference. Deliberately not derived from +/// [`collect_node_refs`]'s own result: that function only ever records a +/// node's references under *that node's own* entry, and only when the node +/// itself has a declared `id` (matching `DepGraph::build`'s contract, which +/// needs an entry only for a reference *target*, never for a plain +/// referencer). The common shape — a node with no `id` of its own reading +/// `node("otherId", ...)` — would then never surface in +/// `collect_node_refs`'s output at all, silently skipping the second build +/// pass its own reference needs. This walks every node regardless of +/// whether it declares an `id`. +pub fn scene_uses_node_refs<'a, I>(children: I) -> bool +where + I: IntoIterator, +{ + children.into_iter().any(scene_uses_node_refs_in) +} + +fn scene_uses_node_refs_in(child: &ChildComponent) -> bool { + if !component_style(&child.component).expr.node_refs.is_empty() { + return true; + } + container_children_of(&child.component) + .iter() + .any(scene_uses_node_refs_in) +} + fn default_root_css(viewport: (f32, f32)) -> CssStyle { CssStyle { display: Some(rustmotion_core::css::style::Display::Flex), @@ -419,6 +568,14 @@ fn build_ghosts<'a>( /// containers' `stagger` fields. /// `time_remap` is the accumulated affine time transform `(scale, shift)` where /// `t_local = scale * t_global + shift`. Default is `(1.0, 0.0)` (identity). +/// `viewport` feeds [`rustmotion_core::css::FrameClock`] for this node's own +/// `style.expr` (issue #338) — see this file's "Per-frame expressions" doc +/// section, above [`resolve_computed_style`]. `outer_scope` is the same +/// frame-global `Scope` [`build_scene_from_refs_with_scope`] received, +/// passed straight through every recursion (never remapped per node the way +/// `anim`/`time_remap` are — see that function's own doc). `warn_unresolved` +/// is [`build_scene_from_refs_with_scope_quiet`]'s own flag, passed through +/// unchanged. #[allow(clippy::too_many_arguments)] fn build_child<'a>( child: &'a ChildComponent, @@ -431,6 +588,9 @@ fn build_child<'a>( stagger_delay: f64, time_remap: (f64, f64), parent_css: &CssStyle, + viewport: (f32, f32), + outer_scope: Option<&dyn Scope>, + warn_unresolved: bool, ) -> Vec { // Compute the local animation context for this node — remapped by the // accumulated affine time transform from ancestor containers. @@ -559,6 +719,14 @@ fn build_child<'a>( apply_glow_effect(&mut css, &effects); carry_paint_pass_effects(&mut css, &effects); } + resolve_computed_style( + &mut css, + &path, + actx, + viewport, + outer_scope, + warn_unresolved, + ); } // ── Audio-reactive binding ──────────────────────────────────────────────── @@ -652,6 +820,9 @@ fn build_child<'a>( stagger_delay, time_remap, &css, + viewport, + outer_scope, + warn_unresolved, ); let intrinsic = component_intrinsic(&child.component, &css); @@ -671,6 +842,101 @@ fn build_child<'a>( result } +// ── Per-frame expressions (issue #338) ────────────────────────────────────── +// +// `css.expr` (`rustmotion_core::css::ComputedStyle`) holds whatever `"= ..."` +// expressions `CssStyle`'s own `Deserialize` impl pulled off this node's +// style at load time — see that type's module doc. This is where the other +// half of the two-tier model in `rustmotion_core::expr`'s own doc happens: +// evaluating those expressions fresh every frame and applying the result +// through `apply_animated_props`, the exact same override path a resolved +// `style.animation` already goes through a few lines above this function's +// call site — an expression is a second source of the same kind of +// override, not a parallel application mechanism. +// +// `apply_animated_props` is called a second time (once for the resolved +// animation, once for this), so the two compose exactly the way it already +// composes an animation on top of a literal CSS value: `opacity` multiplies +// (both contribute — a literal, an animation, and an expression on the same +// node all multiply together), `width`/`height` last-write-wins (an +// expression overrides an animation's own resize, since this call runs +// after it), and the four covered `transform` leaves each append their own +// `TransformFn` (both contribute, associative — see +// `rustmotion_core::css::computed::extract`'s doc for why the neutral +// placeholder its extraction leaves behind makes that safe). +// +// The `Scope` used here composes `rustmotion_core::css::FrameClock` — the +// reserved scenario-clock names (`t`/`T`/`duration`/`W`/`H`/`fps`), built +// from data `BuildAnimationCtx` and `viewport` already carry down to this +// exact call site — with `outer_scope`, the frame-global `&dyn Scope` +// `build_scene_from_refs_with_scope` threads down unchanged through every +// recursive `build_child`/`container_children` call (see those functions' +// own doc for why it isn't remapped per node the way `anim`/`time_remap` +// are). `rustmotion_core::css::ComposedScope` is the composite: the clock's +// six reserved names always win (see that type's own doc for why), +// everything else — a declared `vars` name, a `node(...)` reference — falls +// through to `outer_scope`. `rustmotion/src/engine/render/scene.rs` is what +// actually builds one (its own `EngineScope`, composing a `vars::VarScope` +// with the `node(...)` dependency graph's `ResolvedFrame`) and passes it in +// as `outer_scope`; every other caller in this crate (tests, the studio hit +// probe, any scenario with no `vars` and no node `id`) passes `None`, which +// makes `ComposedScope` behave exactly like a bare `FrameClock` — see that +// type's doc for why that is byte-for-byte, not just "close enough". An +// expression naming a name neither the clock nor `outer_scope` answers +// still fails loudly and specifically (see below), never silently — except +// when `warn_unresolved` is `false` (a throwaway pass whose own build is +// about to be discarded/superseded; see +// `build_scene_from_refs_with_scope_quiet`'s doc), in which case the +// best-effort fallback still applies but stays silent, since the frame it +// would be describing is never the one that reaches the screen. +fn resolve_computed_style( + css: &mut CssStyle, + path: &str, + actx: BuildAnimationCtx, + viewport: (f32, f32), + outer_scope: Option<&dyn Scope>, + warn_unresolved: bool, +) { + if css.expr.is_empty() { + return; + } + let clock = rustmotion_core::css::FrameClock { + t: actx.time, + t_abs: actx.scenario_time, + duration: actx.scene_duration, + width: viewport.0 as f64, + height: viewport.1 as f64, + fps: actx.fps as f64, + }; + let scope = rustmotion_core::css::ComposedScope { + clock, + outer: outer_scope, + }; + match css.expr.resolve(&scope) { + Ok(props) => apply_animated_props(css, &props), + Err(e) => { + // Best-effort, same contract `deserialize_children` + // (`rustmotion/src/engine/render/scene.rs`) already uses for a + // single broken child: named and located (`e` carries the exact + // property and the underlying `ExprError`, `path` carries which + // node), but never a fatal error and never a silent zero — the + // property that failed simply keeps whatever value it already + // had (its literal, or whatever an animation already resolved + // it to) for this frame, instead of aborting or blanking the + // rest of this node's expressions too... except that it *does* + // abort the rest of *this* node's expressions this frame: + // `ComputedStyle::resolve` stops at the first failing property, + // so a later expression on the same node that would have + // succeeded is not evaluated either. See that type's own doc. + if warn_unresolved { + eprintln!( + "warning: {path}: {e} — this property keeps its pre-expression value this frame" + ); + } + } + } +} + /// The full effect list for a component at paint time: `style.animation`, /// plus the `timeline` steps whose `at` `t` has reached, shifted by their /// `at`, plus keyframes synthesized from timeline style-state changes @@ -758,7 +1024,17 @@ pub(crate) fn apply_style_states( base.insert(k, v); } } - if let Ok(merged) = serde_json::from_value::(serde_json::Value::Object(base)) { + // `CssStyle::expr` (issue #338) is `#[serde(skip)]` — neither `css` + // above nor a `step.style` survives this serialize round trip with its + // expressions intact, so `merged.expr` comes back empty regardless of + // what either side held. Restore `css`'s own pre-merge expressions + // (`.prefer` rather than a bare overwrite, so this stays correct even + // if that serialize-skip ever narrows) — otherwise a node with both a + // `style.expr` and any `timeline` style state would silently lose its + // expression the first time a state became due. + let saved_expr = css.expr.clone(); + if let Ok(mut merged) = serde_json::from_value::(serde_json::Value::Object(base)) { + merged.expr = merged.expr.prefer(saved_expr); *css = merged; } } @@ -1189,13 +1465,7 @@ fn component_intrinsic( crate::intrinsic::NumberWheelIntrinsic::from_number_wheel(w), )), Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))), - Terminal(t) => Some(Arc::new( - crate::intrinsic::TerminalIntrinsic::from_terminal(t), - )), Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))), - Codeblock(c) => Some(Arc::new( - crate::intrinsic::CodeblockIntrinsic::from_codeblock(c), - )), // M2: rich_text had no intrinsic measurer at all, so it laid out // 0×0 and rendered nothing unless the author guessed an explicit // width/height. @@ -1224,39 +1494,18 @@ fn container_children<'a>( inherited_delay: f64, time_remap: (f64, f64), parent_css: &CssStyle, + viewport: (f32, f32), + outer_scope: Option<&dyn Scope>, + warn_unresolved: bool, ) -> Vec { let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option, f64, f64) = match component { - Component::Card(c) => ( - &c.children, - c.stagger, - c.time_scale.unwrap_or(1.0), - c.time_offset.unwrap_or(0.0), - ), - Component::Flex(c) => ( - &c.children, - c.stagger, - c.time_scale.unwrap_or(1.0), - c.time_offset.unwrap_or(0.0), - ), - Component::Grid(c) => ( - &c.children, - c.stagger, - c.time_scale.unwrap_or(1.0), - c.time_offset.unwrap_or(0.0), - ), Component::Container(c) => ( &c.children, c.stagger, c.time_scale.unwrap_or(1.0), c.time_offset.unwrap_or(0.0), ), - Component::Positioned(c) => ( - &c.children, - None, - c.time_scale.unwrap_or(1.0), - c.time_offset.unwrap_or(0.0), - ), _ => return Vec::new(), }; @@ -1290,6 +1539,9 @@ fn container_children<'a>( inherited_delay + j as f64 * step, child_remap, parent_css, + viewport, + outer_scope, + warn_unresolved, )); } result @@ -1304,19 +1556,28 @@ fn component_css(component: &Component) -> CssStyle { css } -/// Set `display` from the component kind when the user didn't specify one. -/// `card` / `flex` → `flex`, `grid` → `grid`. The taffy bridge defaults to -/// `block` otherwise, which would silently ignore `flex-direction` & friends. +/// Set `display` on the container when the user didn't specify one. The +/// four former variants (`card`/`flex`/`grid`/`positioned`) are now a single +/// `Component::Container` with no field recording which spelling produced +/// it, so the old "which variant is this" dispatch can't tell `grid` apart +/// from the others anymore — the signal used instead is `grid-template-columns` +/// itself: a container that sets it (the only way `validate_schema`'s grid +/// checks let a scenario be valid in the first place) defaults to +/// `Display::Grid`; every other container defaults to `Display::Flex`. The +/// taffy bridge would otherwise default to `block`, silently ignoring +/// `flex-direction` & friends. fn apply_default_display(component: &Component, css: &mut CssStyle) { use rustmotion_core::css::style::Display; if css.display.is_some() { return; } - css.display = match component { - Component::Card(_) | Component::Flex(_) | Component::Container(_) => Some(Display::Flex), - Component::Grid(_) => Some(Display::Grid), - _ => return, - }; + if matches!(component, Component::Container(_)) { + css.display = Some(if css.grid_template_columns.is_some() { + Display::Grid + } else { + Display::Flex + }); + } } /// Measure a single line of text with the exact same Skia font metrics the @@ -1558,15 +1819,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { css.height = Some(CSize::Length(CLP::Px(h))); } } - Notification(c) => { - if css.width.is_none() { - css.width = Some(CSize::Length(CLP::Px(c.width))); - } - if css.height.is_none() { - let h = if c.message.is_some() { 96.0 } else { 64.0 }; - css.height = Some(CSize::Length(CLP::Px(h))); - } - } Rating(c) => { if css.width.is_none() { let count = c.max as f32; @@ -2083,7 +2335,6 @@ fn component_style(c: &Component) -> &CssStyle { Counter(c) => &c.style, Cursor(c) => &c.style, Caption(c) => &c.style, - Codeblock(c) => &c.style, Connector(c) => &c.style, Avatar(c) => &c.style, AvatarGroup(c) => &c.style, @@ -2104,7 +2355,6 @@ fn component_style(c: &Component) -> &CssStyle { Lottie(c) => &c.style, Marquee(c) => &c.style, Mockup(c) => &c.style, - Notification(c) => &c.style, Particle(c) => &c.style, PillNav(c) => &c.style, Progress(c) => &c.style, @@ -2122,14 +2372,9 @@ fn component_style(c: &Component) -> &CssStyle { RichText(c) => &c.style, Table(c) => &c.style, TagCloud(c) => &c.style, - Terminal(c) => &c.style, Timeline(c) => &c.style, Tooltip(c) => &c.style, Treemap(c) => &c.style, - Positioned(c) => &c.style, - Flex(c) => &c.style, - Grid(c) => &c.style, - Card(c) => &c.style, Container(c) => &c.style, AudioSpectrum(c) => &c.style, Waveform(c) => &c.style, @@ -2150,7 +2395,6 @@ pub fn component_kind(c: &Component) -> &'static str { Counter(_) => "counter", Cursor(_) => "cursor", Caption(_) => "caption", - Codeblock(_) => "codeblock", Connector(_) => "connector", Avatar(_) => "avatar", AvatarGroup(_) => "avatar_group", @@ -2171,7 +2415,6 @@ pub fn component_kind(c: &Component) -> &'static str { Lottie(_) => "lottie", Marquee(_) => "marquee", Mockup(_) => "mockup", - Notification(_) => "notification", Particle(_) => "particle", PillNav(_) => "pill_nav", Progress(_) => "progress", @@ -2189,18 +2432,14 @@ pub fn component_kind(c: &Component) -> &'static str { RichText(_) => "rich_text", Table(_) => "table", TagCloud(_) => "tag_cloud", - Terminal(_) => "terminal", Timeline(_) => "timeline", Tooltip(_) => "tooltip", Treemap(_) => "treemap", - Positioned(_) => "positioned", - Flex(_) => "flex", - Grid(_) => "grid", - Card(_) => "card", // The schema tag is `div` (`#[serde(rename = "div", alias = - // "container")]` on the enum in `lib.rs`) — `container` only - // survives as a deserialize alias, so naming it that way here told - // an author to look for a tag their scenario cannot contain. + // "container", alias = "card", alias = "flex", alias = "grid", alias + // = "positioned")]` on the enum in `lib.rs`) — the other five only + // survive as deserialize aliases, so naming this label anything else + // told an author to look for a tag their scenario cannot contain. Container(_) => "div", AudioSpectrum(_) => "audio_spectrum", Waveform(_) => "waveform", @@ -2275,6 +2514,7 @@ mod tests { #[test] fn start_at_rebases_the_entrance_animation_clock() { let scene = vec![ChildComponent { + id: None, component: serde_json::from_value(json!({ "type": "shape", "shape": "rect", @@ -2329,6 +2569,7 @@ mod tests { #[test] fn start_at_rebases_an_exit_animation_declared_after_the_entrance() { let scene = vec![ChildComponent { + id: None, component: serde_json::from_value(json!({ "type": "shape", "shape": "rect", @@ -2383,7 +2624,7 @@ mod tests { use serde_json::json; fn make_card(children: Vec, style: CssStyle) -> Component { - Component::Card(crate::card::Card { + Component::Container(crate::container::ContainerComponent { children, timing: Default::default(), style, @@ -2396,6 +2637,7 @@ mod tests { fn make_shape(width: f32, height: f32) -> ChildComponent { ChildComponent { + id: None, component: Component::Shape(crate::shape::Shape { shape: rustmotion_core::schema::ShapeType::Rect, text: None, @@ -2420,6 +2662,7 @@ mod tests { fn make_text(content: &str, style: CssStyle) -> ChildComponent { ChildComponent { + id: None, component: Component::Text(crate::text::Text { content: content.to_string(), max_width: None, @@ -2461,6 +2704,7 @@ mod tests { CssStyle::default(), ); let scene = vec![ChildComponent { + id: None, component: card, position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -2492,6 +2736,7 @@ mod tests { }, ); let scene = vec![ChildComponent { + id: None, component: card, position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -2529,6 +2774,7 @@ mod tests { #[test] fn absolute_child_uses_top_left() { let scene = vec![ChildComponent { + id: None, component: Component::Shape(crate::shape::Shape { shape: rustmotion_core::schema::ShapeType::Rect, text: None, @@ -2562,6 +2808,7 @@ mod tests { #[test] fn horizontal_divider_stretches_to_parent_width() { let divider = ChildComponent { + id: None, component: Component::Divider(crate::divider::Divider { direction: DividerDirection::Horizontal, thickness: 4.0, @@ -2592,12 +2839,13 @@ mod tests { // A flex column card with no fixed size — its children's intrinsic // sizes should determine the card's width/height. The text child // must be measured via cosmic-text, not collapse to 0×0. - use crate::card::Card; + use crate::container::ContainerComponent; use crate::text::Text; use rustmotion_core::css::units::Length; let text = ChildComponent { + id: None, component: Component::Text(Text { content: "Hello World".into(), max_width: None, @@ -2623,7 +2871,8 @@ mod tests { }; let card = ChildComponent { - component: Component::Card(Card { + id: None, + component: Component::Container(ContainerComponent { children: vec![text], timing: Default::default(), style: CssStyle { @@ -2676,6 +2925,7 @@ mod tests { #[test] fn arrow_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() { let arrow = ChildComponent { + id: None, component: Component::Arrow(crate::arrow::Arrow { x1: 10.0, y1: 20.0, @@ -2716,6 +2966,7 @@ mod tests { #[test] fn connector_intrinsic_size_uses_endpoint_bbox_plus_arrowhead() { let conn = ChildComponent { + id: None, component: Component::Connector(crate::connector::Connector { from: crate::connector::ConnectorPoint { x: 50.0, y: 0.0 }, to: crate::connector::ConnectorPoint { x: 150.0, y: 50.0 }, @@ -2757,6 +3008,7 @@ mod tests { use rustmotion_core::css::units::Length; let counter = ChildComponent { + id: None, component: Component::Counter(Counter { duration: None, from: 0.0, @@ -2808,6 +3060,7 @@ mod tests { use crate::badge::{Badge, BadgeSize, BadgeVariant}; let badge = ChildComponent { + id: None, component: Component::Badge(Badge { text: "New".into(), icon: None, @@ -2849,6 +3102,7 @@ mod tests { #[test] fn line_intrinsic_size_matches_endpoint_bounding_box() { let line = ChildComponent { + id: None, component: Component::Line(crate::line::Line { x1: 10.0, y1: 20.0, @@ -2887,6 +3141,7 @@ mod tests { use rustmotion_core::css::units::Length; let rich_text = ChildComponent { + id: None, component: Component::RichText(RichText { spans: vec![ RichTextSpan { @@ -3058,6 +3313,7 @@ mod tests { let component: Component = serde_json::from_value(json.clone()).unwrap_or_else(|e| panic!("{e}\n{json:#}")); ChildComponent { + id: None, component, position: None, x: None, @@ -3075,6 +3331,7 @@ mod tests { fn layout_in_auto_card(child_json: serde_json::Value) -> (f32, f32) { let card = make_card(vec![child_from_json(child_json)], CssStyle::default()); let scene = vec![ChildComponent { + id: None, component: card, position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -3230,6 +3487,7 @@ mod tests { }, ); let scene = vec![ChildComponent { + id: None, component: card, position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -3271,6 +3529,7 @@ mod tests { }, ); let scene = vec![ChildComponent { + id: None, component: card, position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -3305,6 +3564,7 @@ mod tests { }, ); let scene = vec![ChildComponent { + id: None, component: card, position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -3334,6 +3594,7 @@ mod tests { }, ); let scene = vec![ChildComponent { + id: None, component: card, position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -3351,6 +3612,7 @@ mod tests { fn make_aspect_shape(width: f32, aspect_ratio: f32) -> ChildComponent { ChildComponent { + id: None, component: Component::Shape(crate::shape::Shape { shape: rustmotion_core::schema::ShapeType::Rect, text: None, @@ -3421,6 +3683,7 @@ mod tests { fn make_aspect_shape_no_width(aspect_ratio: f32) -> ChildComponent { ChildComponent { + id: None, component: Component::Shape(crate::shape::Shape { shape: rustmotion_core::schema::ShapeType::Rect, text: None, diff --git a/crates/rustmotion-components/src/callout.rs b/crates/rustmotion-components/src/callout.rs index 8092d4aa..81052ccb 100644 --- a/crates/rustmotion-components/src/callout.rs +++ b/crates/rustmotion-components/src/callout.rs @@ -29,6 +29,14 @@ pub enum ArrowDirection { /// - `style.border-radius` — corner radius (default: `8`) /// - `style.font-size` — text size (default: `16`) #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`callout` is a frozen composition (issue #333), the same recipe as `tooltip`: \ + compose a small rounded `card` + a rotated triangle `shape` (the arrow) + `text` \ + instead — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Callout { pub text: String, #[serde(default)] diff --git a/crates/rustmotion-components/src/caption.rs b/crates/rustmotion-components/src/caption.rs index b234bc99..cf14d6a6 100644 --- a/crates/rustmotion-components/src/caption.rs +++ b/crates/rustmotion-components/src/caption.rs @@ -18,6 +18,14 @@ use rustmotion_core::schema::{CaptionStyle, CaptionWord, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`caption` is a frozen composition. Compose a `for-each` over the words with \ + `start_at`/`end_at` per word and an expression picking the active one instead \ + — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Caption { pub words: Vec, #[serde(default = "default_active_color")] diff --git a/crates/rustmotion-components/src/card.rs b/crates/rustmotion-components/src/card.rs deleted file mode 100644 index 88a80a64..00000000 --- a/crates/rustmotion-components/src/card.rs +++ /dev/null @@ -1,47 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use skia_safe::Canvas; - -use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::schema::TimelineStep; -use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; - -use crate::ChildComponent; - -/// Card container — backward-compatible with v1 `"type": "card"`. -/// Supports both flex and grid display modes via the `display` style field. -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct Card { - #[serde(default)] - pub children: Vec, - #[serde(flatten)] - pub timing: TimingConfig, - #[serde(default)] - pub style: CssStyle, - #[serde(default)] - pub timeline: Vec, - #[serde(default)] - pub stagger: Option, - #[serde(default)] - pub time_scale: Option, - #[serde(default)] - pub time_offset: Option, -} - -rustmotion_core::impl_traits!(Card { - Animatable => animation, - Timed => timing, - Styled => style, -}); - -impl Painter for Card { - fn paint_content( - &self, - _canvas: &Canvas, - _layout: &BoxLayout, - _props: &rustmotion_core::engine::animator::AnimatedProperties, - _ctx: &PaintCtx, - ) { - } -} diff --git a/crates/rustmotion-components/src/chart/mod.rs b/crates/rustmotion-components/src/chart/mod.rs index 72e1b5c6..643f33ae 100644 --- a/crates/rustmotion-components/src/chart/mod.rs +++ b/crates/rustmotion-components/src/chart/mod.rs @@ -94,6 +94,14 @@ pub struct RadarData { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`chart` is a frozen composition. Compose a `for-each` over the data with a \ + computed `height`/`width` expression per bar (proportional size, index-staggered \ + grow-in) instead — see crates/rustmotion/skills/rules/composition-recipes.md. \ + Kept for compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Chart { pub chart_type: ChartType, #[serde(default)] diff --git a/crates/rustmotion-components/src/codeblock/chrome.rs b/crates/rustmotion-components/src/codeblock/chrome.rs deleted file mode 100644 index 204f765f..00000000 --- a/crates/rustmotion-components/src/codeblock/chrome.rs +++ /dev/null @@ -1,64 +0,0 @@ -use skia_safe::{Canvas, Font, FontStyle, Rect}; - -use super::Codeblock; -use rustmotion_core::engine::renderer::{ - draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, - typeface_with_fallback, -}; - -pub(super) fn draw_chrome( - canvas: &Canvas, - layer: &Codeblock, - x: f32, - y: f32, - width: f32, - corner_radius: f32, -) { - let chrome = layer.chrome.as_ref().unwrap(); - let chrome_height = 36.0; - - let bar_color = chrome.color.as_deref().unwrap_or("#343d46"); - let bar_paint = paint_from_hex(bar_color); - let bar_rect = Rect::from_xywh(x, y, width, chrome_height); - let radii = [ - skia_safe::Point::new(corner_radius, corner_radius), - skia_safe::Point::new(corner_radius, corner_radius), - skia_safe::Point::new(0.0, 0.0), - skia_safe::Point::new(0.0, 0.0), - ]; - let rrect = skia_safe::RRect::new_rect_radii(bar_rect, &radii); - canvas.draw_rrect(rrect, &bar_paint); - - let dot_y = y + chrome_height / 2.0; - let dot_radius = 6.0; - let dot_start_x = x + 16.0; - let dot_spacing = 20.0; - let dot_colors = ["#FF5F56", "#FFBD2E", "#27C93F"]; - for (i, color) in dot_colors.iter().enumerate() { - let dot_x = dot_start_x + i as f32 * dot_spacing; - canvas.draw_circle((dot_x, dot_y), dot_radius, &paint_from_hex(color)); - } - - if let Some(ref title) = chrome.title { - let Ok(typeface) = typeface_with_fallback("Inter", FontStyle::normal()) else { - return; - }; - let title_font = Font::from_typeface(typeface, 13.0); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, 13.0)); - let title_width = measure_text_with_fallback(title, &title_font, &emoji_font, 0.0); - let title_x = x + width / 2.0 - title_width / 2.0; - let title_y = dot_y + 4.0; - let mut title_paint = paint_from_hex("#999999"); - title_paint.set_anti_alias(true); - draw_text_with_fallback( - canvas, - title, - &title_font, - &emoji_font, - 0.0, - title_x, - title_y, - &title_paint, - ); - } -} diff --git a/crates/rustmotion-components/src/codeblock/diff.rs b/crates/rustmotion-components/src/codeblock/diff.rs deleted file mode 100644 index 864d614a..00000000 --- a/crates/rustmotion-components/src/codeblock/diff.rs +++ /dev/null @@ -1,601 +0,0 @@ -use similar::{ChangeTag, TextDiff}; -use skia_safe::{Canvas, Font, PaintStyle, Rect}; -use syntect::highlighting::Theme; - -use super::dimensions::lerp; -use super::highlight::highlight_code; -use super::reveal::{draw_line_number_at, draw_single_highlighted_line}; -use super::Codeblock; -use rustmotion_core::engine::animator::ease; -use rustmotion_core::engine::renderer::paint_from_hex; -use rustmotion_core::schema::EasingType; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -pub(super) struct TransitionInfo { - pub(super) code_a: String, - pub(super) code_b: String, - pub(super) progress: f64, - #[allow(dead_code)] - pub(super) easing: EasingType, - pub(super) cursor_config: Option, -} - -#[derive(Debug)] -pub(super) enum LineDiffOp { - Equal { - #[allow(dead_code)] - line: String, - old_idx: usize, - new_idx: usize, - }, - Delete { - #[allow(dead_code)] - line: String, - old_idx: usize, - }, - Insert { - #[allow(dead_code)] - line: String, - new_idx: usize, - }, - Replace { - old_line: String, - new_line: String, - old_idx: usize, - new_idx: usize, - }, -} - -#[derive(Debug)] -pub(super) struct FragmentEdit { - col: usize, - delete: String, - insert: String, -} - -// ─── State management ──────────────────────────────────────────────────────── - -pub(super) fn determine_active_state( - layer: &Codeblock, - time: f64, -) -> (String, Option) { - if layer.states.is_empty() { - return (layer.code.clone(), None); - } - - let mut current_code = layer.code.clone(); - - for state in &layer.states { - let end = state.at + state.duration; - if time < state.at { - return (current_code, None); - } else if time < end { - let raw_progress = (time - state.at) / state.duration; - let progress = ease(raw_progress, &state.easing); - return ( - current_code.clone(), - Some(TransitionInfo { - code_a: current_code, - code_b: state.code.clone(), - progress, - easing: state.easing.clone(), - cursor_config: state.cursor.clone(), - }), - ); - } else { - current_code = state.code.clone(); - } - } - - (current_code, None) -} - -// ─── Diff backgrounds ──────────────────────────────────────────────────────── - -/// Draw colored backgrounds for diff lines: green for `+`, red for `-`. -pub(super) fn draw_diff_backgrounds( - canvas: &Canvas, - code: &str, - x: f32, - y: f32, - line_height: f32, - width: f32, - visible_lines: usize, -) { - for (i, line) in code.lines().enumerate() { - if i >= visible_lines { - break; - } - let trimmed = line.trim_start(); - let bg_color = if trimmed.starts_with('+') { - Some("#2D4F2D") - } else if trimmed.starts_with('-') { - Some("#4F2D2D") - } else { - None - }; - - if let Some(color) = bg_color { - let mut paint = paint_from_hex(color); - paint.set_anti_alias(false); - let rect = Rect::from_xywh(x, y + i as f32 * line_height, width, line_height); - canvas.draw_rect(rect, &paint); - } - } -} - -// ─── Diff transition rendering ─────────────────────────────────────────────── - -/// Describes how to render a single line during a diff transition. -struct AnimatedLinePlacement { - /// Interpolated Y position (in line-index space, multiply by line_height + add code_y later) - old_y_idx: f32, - new_y_idx: f32, - /// Opacity at start (progress=0) and end (progress=1) - opacity_start: f32, - opacity_end: f32, - /// Line numbers for old and new state (1-indexed) - old_line_number: usize, - new_line_number: usize, - /// What kind of content to render - content: AnimatedLineContent, -} - -enum AnimatedLineContent { - /// Render from highlighted_b at this index - FromB { idx: usize }, - /// Render from highlighted_a at this index (for delete) - FromA { idx: usize }, - /// Cursor-edited line (replace) - CursorEdit { - old_line: String, - new_line: String, - old_idx: usize, - new_idx: usize, - }, -} - -pub(super) fn render_diff_transition( - canvas: &Canvas, - layer: &Codeblock, - font: &Font, - theme: &Theme, - code_x: f32, - code_y: f32, - line_height: f32, - _gutter_width: f32, - pad_left: f32, - block_x: f32, - trans: &TransitionInfo, -) { - let progress = trans.progress as f32; - let (_sw, metrics) = font.metrics(); - let ascent = -metrics.ascent; - - let diff_ops = compute_line_diff(&trans.code_a, &trans.code_b); - let highlighted_a = highlight_code(&trans.code_a, &layer.language, theme); - let highlighted_b = highlight_code(&trans.code_b, &layer.language, theme); - - let cursor_enabled = trans.cursor_config.as_ref().is_none_or(|c| c.enabled); - let cursor_color = trans - .cursor_config - .as_ref() - .map_or("#FFFFFF", |c| c.color.as_str()); - let cursor_width = trans.cursor_config.as_ref().map_or(2.0, |c| c.width); - let cursor_blink = trans.cursor_config.as_ref().is_none_or(|c| c.blink); - - // Build animated line placements with proper interpolated positions. - // Track "virtual cursors" for old and new index space so that - // Insert/Delete lines get smooth starting/ending positions. - let mut placements: Vec = Vec::new(); - let mut _old_cursor: f32 = 0.0; - let mut _new_cursor: f32 = 0.0; - - for op in &diff_ops { - match op { - LineDiffOp::Equal { - old_idx, new_idx, .. - } => { - placements.push(AnimatedLinePlacement { - old_y_idx: *old_idx as f32, - new_y_idx: *new_idx as f32, - opacity_start: 1.0, - opacity_end: 1.0, - old_line_number: old_idx + 1, - new_line_number: new_idx + 1, - content: AnimatedLineContent::FromB { idx: *new_idx }, - }); - _old_cursor = *old_idx as f32 + 1.0; - _new_cursor = *new_idx as f32 + 1.0; - } - LineDiffOp::Delete { old_idx, .. } => { - // Line fades out at its old position (no Y movement) - placements.push(AnimatedLinePlacement { - old_y_idx: *old_idx as f32, - new_y_idx: *old_idx as f32, - opacity_start: 1.0, - opacity_end: 0.0, - old_line_number: old_idx + 1, - new_line_number: old_idx + 1, - content: AnimatedLineContent::FromA { idx: *old_idx }, - }); - _old_cursor = *old_idx as f32 + 1.0; - // _new_cursor does NOT advance for deletes - } - LineDiffOp::Insert { new_idx, .. } => { - // Line fades in at its final position (no Y movement) - placements.push(AnimatedLinePlacement { - old_y_idx: *new_idx as f32, - new_y_idx: *new_idx as f32, - opacity_start: 0.0, - opacity_end: 1.0, - old_line_number: new_idx + 1, - new_line_number: new_idx + 1, - content: AnimatedLineContent::FromB { idx: *new_idx }, - }); - _new_cursor = *new_idx as f32 + 1.0; - // _old_cursor does NOT advance for inserts - } - LineDiffOp::Replace { - old_line, - new_line, - old_idx, - new_idx, - } => { - placements.push(AnimatedLinePlacement { - old_y_idx: *old_idx as f32, - new_y_idx: *new_idx as f32, - opacity_start: 1.0, - opacity_end: 1.0, - old_line_number: old_idx + 1, - new_line_number: new_idx + 1, - content: AnimatedLineContent::CursorEdit { - old_line: old_line.clone(), - new_line: new_line.clone(), - old_idx: *old_idx, - new_idx: *new_idx, - }, - }); - _old_cursor = *old_idx as f32 + 1.0; - _new_cursor = *new_idx as f32 + 1.0; - } - } - } - - // Pre-compute fragment edits for Replace ops - let mut fragment_edits: Vec<(usize, usize, Vec)> = Vec::new(); - for op in &diff_ops { - if let LineDiffOp::Replace { - old_line, - new_line, - old_idx, - new_idx, - } = op - { - let edits = compute_word_diff(old_line, new_line); - fragment_edits.push((*old_idx, *new_idx, edits)); - } - } - - // Render all placements - let gutter_x = block_x + pad_left; - - for pl in &placements { - let y_pos = code_y + lerp(pl.old_y_idx, pl.new_y_idx, progress) * line_height; - let opacity = if pl.opacity_start == 0.0 && pl.opacity_end == 1.0 { - // Inserted lines: wait until the expand animation is mostly done - // before fading in the new code, to avoid overlapping text. - let fade_start = 0.95; - if progress < fade_start { - 0.0 - } else { - (progress - fade_start) / (1.0 - fade_start) - } - } else { - lerp(pl.opacity_start, pl.opacity_end, progress) - }; - - // Skip fully transparent lines - if opacity < 0.005 { - continue; - } - - // Draw line number — use old number at start, new number at end - if layer.show_line_numbers { - let line_num = if progress < 0.5 { - pl.old_line_number - } else { - pl.new_line_number - }; - draw_line_number_at(canvas, font, gutter_x, y_pos + ascent, line_num, opacity); - } - - // Draw content - match &pl.content { - AnimatedLineContent::FromB { idx } => { - if let Some(line) = highlighted_b.get(*idx) { - draw_single_highlighted_line( - canvas, - line, - font, - code_x, - y_pos + ascent, - opacity, - ); - } - } - AnimatedLineContent::FromA { idx } => { - if let Some(line) = highlighted_a.get(*idx) { - draw_single_highlighted_line( - canvas, - line, - font, - code_x, - y_pos + ascent, - opacity, - ); - } - } - AnimatedLineContent::CursorEdit { - old_line, - new_line, - old_idx, - new_idx, - } => { - if let Some((_oi, _ni, edits)) = fragment_edits - .iter() - .find(|(oi, ni, _)| oi == old_idx && ni == new_idx) - { - draw_cursor_edited_line( - canvas, - font, - old_line, - new_line, - edits, - code_x, - y_pos + ascent, - trans.progress, - cursor_enabled, - cursor_color, - cursor_width, - cursor_blink, - &layer.language, - theme, - ); - } - } - } - } -} - -// ─── Line diff computation ─────────────────────────────────────────────────── - -pub(super) fn compute_line_diff(code_a: &str, code_b: &str) -> Vec { - let diff = TextDiff::from_lines(code_a, code_b); - let mut ops = Vec::new(); - let mut old_idx = 0usize; - let mut new_idx = 0usize; - - for change in diff.iter_all_changes() { - let text = change.value().trim_end_matches('\n').to_string(); - match change.tag() { - ChangeTag::Equal => { - ops.push(LineDiffOp::Equal { - line: text, - old_idx, - new_idx, - }); - old_idx += 1; - new_idx += 1; - } - ChangeTag::Delete => { - ops.push(LineDiffOp::Delete { - line: text, - old_idx, - }); - old_idx += 1; - } - ChangeTag::Insert => { - let merged = matches!(ops.last(), Some(LineDiffOp::Delete { .. })); - if merged { - if let Some(LineDiffOp::Delete { - line: old_line, - old_idx: oi, - }) = ops.pop() - { - ops.push(LineDiffOp::Replace { - old_line, - new_line: text, - old_idx: oi, - new_idx, - }); - } - } else { - ops.push(LineDiffOp::Insert { - line: text, - new_idx, - }); - } - new_idx += 1; - } - } - } - - ops -} - -// ─── Word-level diff for cursor animation ──────────────────────────────────── - -pub(super) fn compute_word_diff(old_line: &str, new_line: &str) -> Vec { - let diff = TextDiff::from_words(old_line, new_line); - let mut edits = Vec::new(); - let mut col = 0usize; - let mut pending_delete = String::new(); - - for change in diff.iter_all_changes() { - match change.tag() { - ChangeTag::Equal => { - if !pending_delete.is_empty() { - edits.push(FragmentEdit { - col, - delete: pending_delete.clone(), - insert: String::new(), - }); - pending_delete.clear(); - } - col += change.value().chars().count(); - } - ChangeTag::Delete => { - pending_delete.push_str(change.value()); - } - ChangeTag::Insert => { - edits.push(FragmentEdit { - col, - delete: pending_delete.clone(), - insert: change.value().to_string(), - }); - pending_delete.clear(); - col += change.value().chars().count(); - } - } - } - - if !pending_delete.is_empty() { - edits.push(FragmentEdit { - col, - delete: pending_delete, - insert: String::new(), - }); - } - - edits -} - -// ─── Cursor-animated line editing ──────────────────────────────────────────── - -/// Byte offset of the `n`-th character, saturating at the end of the string. -/// -/// Every column in a `FragmentEdit` is a character count, because the reveal -/// interpolates a *fraction* of the total edit length: counting bytes makes the -/// animation land part-way through a multi-byte glyph, which both slices at an -/// invalid boundary and spends three frames revealing one CJK character. -fn byte_at(s: &str, char_idx: usize) -> usize { - s.char_indices() - .nth(char_idx) - .map(|(i, _)| i) - .unwrap_or(s.len()) -} - -pub(super) fn draw_cursor_edited_line( - canvas: &Canvas, - font: &Font, - old_line: &str, - new_line: &str, - edits: &[FragmentEdit], - x: f32, - y: f32, - progress: f64, - cursor_enabled: bool, - cursor_color: &str, - cursor_width: f32, - cursor_blink: bool, - language: &str, - theme: &Theme, -) { - if edits.is_empty() { - let highlighted = highlight_code(new_line, language, theme); - if let Some(line) = highlighted.first() { - draw_single_highlighted_line(canvas, line, font, x, y, 1.0); - } - return; - } - - let total_work: usize = edits - .iter() - .map(|e| e.delete.chars().count() + e.insert.chars().count()) - .sum(); - if total_work == 0 { - let highlighted = highlight_code(new_line, language, theme); - if let Some(line) = highlighted.first() { - draw_single_highlighted_line(canvas, line, font, x, y, 1.0); - } - return; - } - - let chars_progress = (progress * total_work as f64).round() as usize; - let mut current_line = old_line.to_string(); - let mut work_done = 0usize; - let mut cursor_col: Option = None; - let mut offset_adjust: i64 = 0; - - for edit in edits { - let adjusted_col = (edit.col as i64 + offset_adjust).max(0) as usize; - let delete_len = edit.delete.chars().count(); - let insert_len = edit.insert.chars().count(); - let edit_work = delete_len + insert_len; - - if work_done + edit_work <= chars_progress { - let start = byte_at(¤t_line, adjusted_col); - let end = byte_at(¤t_line, adjusted_col + delete_len); - current_line.replace_range(start..end.max(start), &edit.insert); - offset_adjust += insert_len as i64 - delete_len as i64; - work_done += edit_work; - } else { - let remaining_progress = chars_progress - work_done; - if remaining_progress < delete_len { - let chars_deleted = remaining_progress; - let first_deleted = adjusted_col + delete_len - chars_deleted; - let del_start = byte_at(¤t_line, first_deleted); - let del_end = byte_at(¤t_line, adjusted_col + delete_len); - if del_start < del_end { - current_line.replace_range(del_start..del_end, ""); - } - cursor_col = Some(first_deleted); - } else { - let chars_inserted = remaining_progress - delete_len; - let start = byte_at(¤t_line, adjusted_col); - let end = byte_at(¤t_line, adjusted_col + delete_len); - let partial_insert = &edit.insert[..byte_at(&edit.insert, chars_inserted)]; - current_line.replace_range(start..end.max(start), partial_insert); - cursor_col = Some(adjusted_col + chars_inserted); - } - break; - } - } - - let highlighted = highlight_code(¤t_line, language, theme); - if let Some(line) = highlighted.first() { - draw_single_highlighted_line(canvas, line, font, x, y, 1.0); - } - - if cursor_enabled { - if let Some(col) = cursor_col { - let should_show = if cursor_blink { - let blink_time = progress * 10.0; - (blink_time % 1.06).fract() < 0.53 - } else { - true - }; - - if should_show { - // `col` counts characters, like every other column here. - let prefix = ¤t_line[..byte_at(¤t_line, col)]; - let (prefix_width, _) = font.measure_str(prefix, None); - let cursor_x = x + prefix_width; - let mut cursor_paint = paint_from_hex(cursor_color); - cursor_paint.set_style(PaintStyle::Fill); - let (_sw, metrics) = font.metrics(); - let cursor_top = y - (-metrics.ascent); - let cursor_bottom = y + metrics.descent; - let cursor_rect = Rect::from_xywh( - cursor_x, - cursor_top, - cursor_width, - cursor_bottom - cursor_top, - ); - canvas.draw_rect(cursor_rect, &cursor_paint); - } - } - } -} diff --git a/crates/rustmotion-components/src/codeblock/dimensions.rs b/crates/rustmotion-components/src/codeblock/dimensions.rs deleted file mode 100644 index 4b02e8d8..00000000 --- a/crates/rustmotion-components/src/codeblock/dimensions.rs +++ /dev/null @@ -1,62 +0,0 @@ -use skia_safe::Font; - -use super::Codeblock; - -/// Computed dimensions for a code block -#[allow(dead_code)] -pub(crate) struct CodeDimensions { - pub(crate) line_count: usize, - pub(crate) max_line_width: f32, - pub(crate) gutter_width: f32, - pub(crate) total_width: f32, - pub(crate) total_height: f32, -} - -pub(crate) fn compute_code_dimensions( - code: &str, - font: &Font, - font_size: f32, - padding: (f32, f32, f32, f32), - chrome_height: f32, - layer: &Codeblock, -) -> CodeDimensions { - // `font_size` is now a caller-supplied parameter instead of being - // re-derived here from `layer.style` — the caller (`render.rs`, - // `intrinsic.rs`) already resolved it once (with the real `LengthContext` - // where one is available) to build `font`; re-deriving it a second time - // with the context-free accessor was exactly the kind of duplicate - // computation that let this and the caller's value silently diverge for - // relative units (lot B, wave S). - let actual_line_height = layer.style.line_height_for(font_size); - let lines: Vec<&str> = code.lines().collect(); - let line_count = lines.len().max(1); - - let gutter_width = if layer.show_line_numbers { - let digits = format!("{}", line_count).len(); - let digit_width = font.measure_str("0", None).0; - (digits as f32 * digit_width) + 24.0 - } else { - 0.0 - }; - - let max_line_width = lines - .iter() - .map(|l| font.measure_str(l, None).0) - .fold(0.0f32, f32::max); - - let (pad_top, pad_right, pad_bottom, pad_left) = padding; - let content_width = max_line_width + gutter_width + pad_left + pad_right; - let content_height = line_count as f32 * actual_line_height + pad_top + pad_bottom; - - CodeDimensions { - line_count, - max_line_width, - gutter_width, - total_width: content_width, - total_height: content_height + chrome_height, - } -} - -pub(super) fn lerp(a: f32, b: f32, t: f32) -> f32 { - a + (b - a) * t -} diff --git a/crates/rustmotion-components/src/codeblock/highlight.rs b/crates/rustmotion-components/src/codeblock/highlight.rs deleted file mode 100644 index 21ae8e94..00000000 --- a/crates/rustmotion-components/src/codeblock/highlight.rs +++ /dev/null @@ -1,617 +0,0 @@ -use skia_safe::{Font, FontStyle}; -use std::sync::OnceLock; -use syntect::easy::HighlightLines; -use syntect::highlighting::{ - Color as SynColor, FontStyle as SynFontStyle, ScopeSelectors, StyleModifier, Theme, ThemeItem, - ThemeSet, ThemeSettings, -}; -use syntect::parsing::SyntaxSet; - -use rustmotion_core::schema::FontWeight; - -// ─── Syntect caches ────────────────────────────────────────────────────────── - -static SYNTAX_SET: OnceLock = OnceLock::new(); -static THEME_SET: OnceLock = OnceLock::new(); - -pub(super) fn syntax_set() -> &'static SyntaxSet { - SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines) -} - -fn load_theme_from_str(xml: &str) -> Option { - let mut cursor = std::io::Cursor::new(xml.as_bytes()); - ThemeSet::load_from_reader(&mut cursor).ok() -} - -/// Parse a hex color string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) into a syntect Color -fn parse_syn_color(hex: &str) -> Option { - let hex = hex.trim_start_matches('#'); - let (r, g, b, a) = match hex.len() { - 3 => { - let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17; - let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17; - let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17; - (r, g, b, 255u8) - } - 4 => { - let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17; - let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17; - let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17; - let a = u8::from_str_radix(&hex[3..4], 16).ok()? * 17; - (r, g, b, a) - } - 6 => { - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - (r, g, b, 255u8) - } - 8 => { - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - let a = u8::from_str_radix(&hex[6..8], 16).ok()?; - (r, g, b, a) - } - _ => return None, - }; - Some(SynColor { r, g, b, a }) -} - -/// Parse a VS Code fontStyle string ("italic", "bold", "italic bold", "underline") into syntect FontStyle -fn parse_font_style(s: &str) -> Option { - let s = s.trim(); - if s.is_empty() || s == "normal" { - return Some(SynFontStyle::empty()); - } - let mut style = SynFontStyle::empty(); - for part in s.split_whitespace() { - match part { - "italic" => style |= SynFontStyle::ITALIC, - "bold" => style |= SynFontStyle::BOLD, - "underline" => style |= SynFontStyle::UNDERLINE, - _ => {} - } - } - Some(style) -} - -/// Load a VS Code JSON theme and convert it to a syntect Theme -fn load_vscode_theme(json: &str) -> Option { - let v: serde_json::Value = serde_json::from_str(json).ok()?; - - let name = v - .get("name") - .and_then(|n| n.as_str()) - .map(|s| s.to_string()); - - // Parse ThemeSettings from "colors" object - let mut settings = ThemeSettings::default(); - if let Some(colors) = v.get("colors").and_then(|c| c.as_object()) { - settings.foreground = colors - .get("editor.foreground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - settings.background = colors - .get("editor.background") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - settings.caret = colors - .get("editorCursor.foreground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - settings.line_highlight = colors - .get("editor.lineHighlightBackground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - settings.selection = colors - .get("editor.selectionBackground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - settings.selection_foreground = colors - .get("editor.selectionForeground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - settings.gutter = colors - .get("editorGutter.background") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - settings.gutter_foreground = colors - .get("editorLineNumber.foreground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - settings.find_highlight = colors - .get("editor.findMatchHighlightBackground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - } - - // Parse scopes from "tokenColors" array - let mut scopes = Vec::new(); - if let Some(token_colors) = v.get("tokenColors").and_then(|t| t.as_array()) { - for tc in token_colors { - let scope_str = match tc.get("scope") { - Some(serde_json::Value::String(s)) => s.clone(), - Some(serde_json::Value::Array(arr)) => arr - .iter() - .filter_map(|v| v.as_str()) - .collect::>() - .join(", "), - None => { - // Global settings entry (no scope) — apply to foreground/background - if let Some(s) = tc.get("settings").and_then(|s| s.as_object()) { - if settings.foreground.is_none() { - settings.foreground = s - .get("foreground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - } - if settings.background.is_none() { - settings.background = s - .get("background") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color); - } - } - continue; - } - _ => continue, - }; - - let scope = match scope_str.parse::() { - Ok(s) => s, - Err(_) => continue, - }; - - let tc_settings = match tc.get("settings").and_then(|s| s.as_object()) { - Some(s) => s, - None => continue, - }; - - let style = StyleModifier { - foreground: tc_settings - .get("foreground") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color), - background: tc_settings - .get("background") - .and_then(|v| v.as_str()) - .and_then(parse_syn_color), - font_style: tc_settings - .get("fontStyle") - .and_then(|v| v.as_str()) - .and_then(parse_font_style), - }; - - scopes.push(ThemeItem { scope, style }); - } - } - - Some(Theme { - name, - author: None, - settings, - scopes, - }) -} - -fn theme_set() -> &'static ThemeSet { - THEME_SET.get_or_init(|| { - let mut ts = ThemeSet::load_defaults(); - - // Catppuccin themes (tmTheme format) - let catppuccin_themes: &[(&str, &str)] = &[ - ( - "catppuccin-latte", - include_str!("../../themes/Catppuccin Latte.tmTheme"), - ), - ( - "catppuccin-frappe", - include_str!("../../themes/Catppuccin Frappe.tmTheme"), - ), - ( - "catppuccin-macchiato", - include_str!("../../themes/Catppuccin Macchiato.tmTheme"), - ), - ( - "catppuccin-mocha", - include_str!("../../themes/Catppuccin Mocha.tmTheme"), - ), - ]; - for (name, xml) in catppuccin_themes { - if let Some(theme) = load_theme_from_str(xml) { - ts.themes.insert(name.to_string(), theme); - } - } - - // VS Code / Shiki themes (JSON format) - let vscode_themes: &[(&str, &str)] = &[ - ( - "andromeeda", - include_str!("../../themes/vscode/andromeeda.json"), - ), - ( - "aurora-x", - include_str!("../../themes/vscode/aurora-x.json"), - ), - ( - "ayu-dark", - include_str!("../../themes/vscode/ayu-dark.json"), - ), - ( - "ayu-light", - include_str!("../../themes/vscode/ayu-light.json"), - ), - ( - "ayu-mirage", - include_str!("../../themes/vscode/ayu-mirage.json"), - ), - ( - "dark-plus", - include_str!("../../themes/vscode/dark-plus.json"), - ), - ("dracula", include_str!("../../themes/vscode/dracula.json")), - ( - "dracula-soft", - include_str!("../../themes/vscode/dracula-soft.json"), - ), - ( - "everforest-dark", - include_str!("../../themes/vscode/everforest-dark.json"), - ), - ( - "everforest-light", - include_str!("../../themes/vscode/everforest-light.json"), - ), - ( - "github-dark", - include_str!("../../themes/vscode/github-dark.json"), - ), - ( - "github-dark-default", - include_str!("../../themes/vscode/github-dark-default.json"), - ), - ( - "github-dark-dimmed", - include_str!("../../themes/vscode/github-dark-dimmed.json"), - ), - ( - "github-dark-high-contrast", - include_str!("../../themes/vscode/github-dark-high-contrast.json"), - ), - ( - "github-light", - include_str!("../../themes/vscode/github-light.json"), - ), - ( - "github-light-default", - include_str!("../../themes/vscode/github-light-default.json"), - ), - ( - "github-light-high-contrast", - include_str!("../../themes/vscode/github-light-high-contrast.json"), - ), - ( - "gruvbox-dark-hard", - include_str!("../../themes/vscode/gruvbox-dark-hard.json"), - ), - ( - "gruvbox-dark-medium", - include_str!("../../themes/vscode/gruvbox-dark-medium.json"), - ), - ( - "gruvbox-dark-soft", - include_str!("../../themes/vscode/gruvbox-dark-soft.json"), - ), - ( - "gruvbox-light-hard", - include_str!("../../themes/vscode/gruvbox-light-hard.json"), - ), - ( - "gruvbox-light-medium", - include_str!("../../themes/vscode/gruvbox-light-medium.json"), - ), - ( - "gruvbox-light-soft", - include_str!("../../themes/vscode/gruvbox-light-soft.json"), - ), - ("horizon", include_str!("../../themes/vscode/horizon.json")), - ( - "horizon-bright", - include_str!("../../themes/vscode/horizon-bright.json"), - ), - ("houston", include_str!("../../themes/vscode/houston.json")), - ( - "kanagawa-dragon", - include_str!("../../themes/vscode/kanagawa-dragon.json"), - ), - ( - "kanagawa-lotus", - include_str!("../../themes/vscode/kanagawa-lotus.json"), - ), - ( - "kanagawa-wave", - include_str!("../../themes/vscode/kanagawa-wave.json"), - ), - ( - "laserwave", - include_str!("../../themes/vscode/laserwave.json"), - ), - ( - "light-plus", - include_str!("../../themes/vscode/light-plus.json"), - ), - ( - "material-theme", - include_str!("../../themes/vscode/material-theme.json"), - ), - ( - "material-theme-darker", - include_str!("../../themes/vscode/material-theme-darker.json"), - ), - ( - "material-theme-lighter", - include_str!("../../themes/vscode/material-theme-lighter.json"), - ), - ( - "material-theme-ocean", - include_str!("../../themes/vscode/material-theme-ocean.json"), - ), - ( - "material-theme-palenight", - include_str!("../../themes/vscode/material-theme-palenight.json"), - ), - ( - "min-dark", - include_str!("../../themes/vscode/min-dark.json"), - ), - ( - "min-light", - include_str!("../../themes/vscode/min-light.json"), - ), - ("monokai", include_str!("../../themes/vscode/monokai.json")), - ( - "night-owl", - include_str!("../../themes/vscode/night-owl.json"), - ), - ( - "night-owl-light", - include_str!("../../themes/vscode/night-owl-light.json"), - ), - ("nord", include_str!("../../themes/vscode/nord.json")), - ( - "one-dark-pro", - include_str!("../../themes/vscode/one-dark-pro.json"), - ), - ( - "one-light", - include_str!("../../themes/vscode/one-light.json"), - ), - ("plastic", include_str!("../../themes/vscode/plastic.json")), - ( - "poimandres", - include_str!("../../themes/vscode/poimandres.json"), - ), - ("red", include_str!("../../themes/vscode/red.json")), - ( - "rose-pine", - include_str!("../../themes/vscode/rose-pine.json"), - ), - ( - "rose-pine-dawn", - include_str!("../../themes/vscode/rose-pine-dawn.json"), - ), - ( - "rose-pine-moon", - include_str!("../../themes/vscode/rose-pine-moon.json"), - ), - ( - "slack-dark", - include_str!("../../themes/vscode/slack-dark.json"), - ), - ( - "slack-ochin", - include_str!("../../themes/vscode/slack-ochin.json"), - ), - ( - "snazzy-light", - include_str!("../../themes/vscode/snazzy-light.json"), - ), - ( - "solarized-dark", - include_str!("../../themes/vscode/solarized-dark.json"), - ), - ( - "solarized-light", - include_str!("../../themes/vscode/solarized-light.json"), - ), - ( - "synthwave-84", - include_str!("../../themes/vscode/synthwave-84.json"), - ), - ( - "tokyo-night", - include_str!("../../themes/vscode/tokyo-night.json"), - ), - ("vesper", include_str!("../../themes/vscode/vesper.json")), - ( - "vitesse-black", - include_str!("../../themes/vscode/vitesse-black.json"), - ), - ( - "vitesse-dark", - include_str!("../../themes/vscode/vitesse-dark.json"), - ), - ( - "vitesse-light", - include_str!("../../themes/vscode/vitesse-light.json"), - ), - ]; - for (name, json) in vscode_themes { - if let Some(theme) = load_vscode_theme(json) { - ts.themes.insert(name.to_string(), theme); - } - } - - ts - }) -} - -// ─── Types ─────────────────────────────────────────────────────────────────── - -pub(super) struct ColoredSpan { - pub(super) text: String, - pub(super) r: u8, - pub(super) g: u8, - pub(super) b: u8, - pub(super) a: u8, -} - -pub(super) struct HighlightedLine { - pub(super) spans: Vec, -} - -// ─── Public API ────────────────────────────────────────────────────────────── - -pub(super) fn get_theme(name: &str) -> &'static Theme { - let ts = theme_set(); - ts.themes - .get(name) - .unwrap_or_else(|| ts.themes.values().next().unwrap()) -} - -pub(super) fn highlight_code(code: &str, language: &str, theme: &Theme) -> Vec { - let ss = syntax_set(); - let syntax = ss - .find_syntax_by_token(language) - .or_else(|| ss.find_syntax_by_name(language)) - .unwrap_or_else(|| ss.find_syntax_plain_text()); - - let mut highlighter = HighlightLines::new(syntax, theme); - let mut result = Vec::new(); - - for line in syntect::util::LinesWithEndings::from(code) { - let ranges = highlighter.highlight_line(line, ss).unwrap_or_default(); - let spans: Vec = ranges - .into_iter() - .map(|(style, text)| ColoredSpan { - text: text.trim_end_matches('\n').to_string(), - r: style.foreground.r, - g: style.foreground.g, - b: style.foreground.b, - a: style.foreground.a, - }) - .collect(); - result.push(HighlightedLine { spans }); - } - - result -} - -pub(crate) fn resolve_monospace_font(family: &str, size: f32, weight: FontWeight) -> Option { - let font_mgr = rustmotion_core::engine::renderer::font_mgr(); - let w: i32 = match weight { - FontWeight::Normal => 400, - FontWeight::Bold => 700, - FontWeight::Weight(w) => w as i32, - }; - let skia_weight = skia_safe::font_style::Weight::from(w); - let style = FontStyle::new( - skia_weight, - skia_safe::font_style::Width::NORMAL, - skia_safe::font_style::Slant::Upright, - ); - - // #7: a custom/Google font declared in the scenario for `family` must - // win over the hardcoded monospace fallback list below — check the - // custom registry directly, first. Previously the only place that - // consulted it was `typeface_with_fallback`, reached solely through the - // final `.or_else` below; but the `fallbacks` list's `match_family_style` - // calls (which try `family` itself first, among plain system families) - // already return *something* on essentially every real system — Skia's - // system `FontMgr` almost never returns `None` for "JetBrains Mono"/ - // "Fira Code"/"Menlo"/"Courier New"/"monospace" collectively — so that - // `.or_else` was never reached and a declared custom font (an Anton - // `.ttf`, a Google "IBM Plex Mono") was silently ignored (see commit - // b4603f9, which fixed the equivalent regression for `text`). - if let Some(typeface) = - rustmotion_core::engine::renderer::resolve_custom_typeface(family, style) - { - return Some(Font::from_typeface(typeface, size)); - } - - let fallbacks = [ - family, - "JetBrains Mono", - "Fira Code", - "Menlo", - "Courier New", - "monospace", - ]; - let typeface = fallbacks - .iter() - .filter_map(|name| font_mgr.match_family_style(name, style)) - .next() - .or_else(|| { - rustmotion_core::engine::renderer::typeface_with_fallback(family, style).ok() - })?; - Some(Font::from_typeface(typeface, size)) -} - -#[cfg(test)] -mod monospace_font_tests { - use super::*; - - /// #7 reproduction: a codeblock's declared custom/Google font must - /// actually be used, not silently shadowed by the hardcoded monospace - /// fallback chain. Registers a real display face (Anton — visually - /// nothing like any of "JetBrains Mono"/"Fira Code"/"Menlo"/"Courier - /// New"/"monospace") under a family name that collides with none of - /// them, and asserts `resolve_monospace_font` actually resolves to it. - /// Skips on a cold font cache (no network access in CI) — the render QA - /// in `examples/` (e.g. `cb_anton.json`) is the visual counterpart. - #[test] - fn declared_custom_font_wins_over_hardcoded_monospace_fallbacks() { - let path = format!( - "{}/.cache/rustmotion/fonts/anton-400.ttf", - std::env::var("HOME").unwrap_or_default() - ); - let Ok(bytes) = std::fs::read(&path) else { - return; // cold font cache → skip (render QA covers it) - }; - - let font_mgr = rustmotion_core::engine::renderer::font_mgr(); - let parsed = font_mgr - .new_from_data(&skia_safe::Data::new_copy(&bytes), None) - .expect("cached TTF must parse"); - let parsed_style = parsed.font_style(); - rustmotion_core::engine::renderer::register_custom_font_variant( - "RmProbeCodeblockAnton", - bytes, - *parsed_style.weight(), - false, - ); - - let font = resolve_monospace_font("RmProbeCodeblockAnton", 20.0, FontWeight::Normal) - .expect("resolve_monospace_font must succeed"); - assert_eq!( - font.typeface().family_name(), - "Anton", - "declared custom font must win over the hardcoded monospace fallback chain, got {}", - font.typeface().family_name() - ); - } - - /// Regression guard: an *undeclared* family (nothing in the custom - /// registry) must still fall through to a real monospace font via the - /// hardcoded chain, not fail or silently switch to some arbitrary - /// serif/sans system default. - #[test] - fn unregistered_family_still_falls_back_to_a_monospace_font() { - let font = resolve_monospace_font("RmProbeNoSuchFamilyXYZ", 20.0, FontWeight::Normal) - .expect("must still resolve a fallback font"); - // Not asserting a specific family name (host-dependent) — just that - // resolution succeeds and doesn't panic/None out. - assert!(font.size() > 0.0); - } -} diff --git a/crates/rustmotion-components/src/codeblock/mod.rs b/crates/rustmotion-components/src/codeblock/mod.rs deleted file mode 100644 index 462fe1bd..00000000 --- a/crates/rustmotion-components/src/codeblock/mod.rs +++ /dev/null @@ -1,83 +0,0 @@ -mod chrome; -mod diff; -pub(crate) mod dimensions; -pub(crate) mod highlight; -mod render; -mod reveal; - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use skia_safe::Canvas; - -use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::animator::AnimatedProperties; -use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::schema::{ - CodeblockChrome, CodeblockHighlight, CodeblockReveal, CodeblockState, TimelineStep, -}; -use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct Codeblock { - pub code: String, - #[serde(default = "default_language")] - pub language: String, - #[serde(default = "default_theme")] - pub theme: String, - #[serde(default)] - pub show_line_numbers: bool, - #[serde(default)] - pub chrome: Option, - #[serde(default)] - pub highlights: Vec, - #[serde(default)] - pub reveal: Option, - #[serde(default)] - pub states: Vec, - /// Enable diff mode: lines starting with `+` get green background, `-` get red background. - #[serde(default)] - pub diff: bool, - /// When the rendered content overflows the box vertically, scroll up so the - /// last revealed line stays visible. Default: `true`. Set to `false` to - /// require that all content fits — the geometry validator will fail - /// otherwise. Font size is never reduced. - #[serde(default = "default_auto_scroll")] - pub auto_scroll: bool, - #[serde(flatten)] - pub timing: TimingConfig, - #[serde(default)] - pub style: CssStyle, - #[serde(default)] - pub timeline: Vec, - #[serde(default)] - pub stagger: Option, -} - -fn default_auto_scroll() -> bool { - true -} - -rustmotion_core::impl_traits!(Codeblock { - Animatable => animation, - Timed => timing, - Styled => style, -}); - -impl Painter for Codeblock { - fn paint_content( - &self, - canvas: &Canvas, - layout: &BoxLayout, - props: &AnimatedProperties, - ctx: &PaintCtx, - ) { - render::render_codeblock(canvas, self, layout, props, ctx); - } -} - -fn default_language() -> String { - "plain".to_string() -} -fn default_theme() -> String { - "base16-ocean.dark".to_string() -} diff --git a/crates/rustmotion-components/src/codeblock/render.rs b/crates/rustmotion-components/src/codeblock/render.rs deleted file mode 100644 index ed7da565..00000000 --- a/crates/rustmotion-components/src/codeblock/render.rs +++ /dev/null @@ -1,287 +0,0 @@ -use skia_safe::{Canvas, Rect}; - -use rustmotion_core::css::style::{FontWeight as CssFontWeight, FontWeightKw}; -use rustmotion_core::engine::animator::AnimatedProperties; -use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::paint_from_hex; -use rustmotion_core::schema::FontWeight; -use rustmotion_core::traits::PaintCtx; - -use super::chrome::draw_chrome; -use super::diff::{determine_active_state, draw_diff_backgrounds, render_diff_transition}; -use super::dimensions::{compute_code_dimensions, lerp}; -use super::highlight::{get_theme, highlight_code, resolve_monospace_font}; -use super::reveal::{compute_reveal, draw_highlighted_lines, draw_highlights, draw_line_numbers}; -use super::Codeblock; - -pub(super) fn render_codeblock( - canvas: &Canvas, - layer: &Codeblock, - layout: &BoxLayout, - _props: &AnimatedProperties, - ctx: &PaintCtx, -) { - let time = ctx.time; - let font_family = layer.style.font_family_or("JetBrains Mono"); - // Resolved once, against the real per-frame viewport (`rem`/`vw`/`vh` on - // `font-size` now resolve instead of silently dropping to 0px — lot B, - // wave S) and threaded through every `compute_code_dimensions` call - // below instead of each one re-deriving its own (previously identical - // only by coincidence) value. - let font_size = layer.style.font_size_px_ctx( - &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), - 14.0, - ); - let font_weight = match &layer.style.font_weight { - Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => FontWeight::Bold, - Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold, - Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n), - _ => FontWeight::Normal, - }; - let actual_line_height = layer.style.line_height_for(font_size); - let Some(font) = resolve_monospace_font(font_family, font_size, font_weight) else { - return; - }; - let padding = { - let (t, r, b, l) = layer.style.padding_px(); - if t == 0.0 && r == 0.0 && b == 0.0 && l == 0.0 { - (16.0, 16.0, 16.0, 16.0) - } else { - (t, r, b, l) - } - }; - let theme = get_theme(&layer.theme); - - let (current_code, transition) = determine_active_state(layer, time); - - let chrome_enabled = layer.chrome.as_ref().is_some_and(|c| c.enabled); - let chrome_height = if chrome_enabled { 36.0 } else { 0.0 }; - - // Pre-compute the max gutter width across all states so line numbers - // never cause a sudden horizontal shift when a transition starts. - let max_gutter_width = if layer.show_line_numbers && !layer.states.is_empty() { - let max_lines = std::iter::once(layer.code.lines().count()) - .chain(layer.states.iter().map(|s| s.code.lines().count())) - .max() - .unwrap_or(1) - .max(1); - let digits = format!("{}", max_lines).len(); - let digit_width = font.measure_str("0", None).0; - (digits as f32 * digit_width) + 24.0 - } else { - 0.0 - }; - - // The taffy layout sets the outer box size. The natural content height is - // still computed (so we know whether to auto-scroll), but the box footprint - // is taken from the laid-out BoxLayout, not the legacy `layer.size`. - let natural_height = if let Some(ref trans) = transition { - let dims_a = compute_code_dimensions( - &trans.code_a, - &font, - font_size, - padding, - chrome_height, - layer, - ); - let dims_b = compute_code_dimensions( - &trans.code_b, - &font, - font_size, - padding, - chrome_height, - layer, - ); - lerp( - dims_a.total_height, - dims_b.total_height, - trans.progress as f32, - ) - } else { - compute_code_dimensions( - ¤t_code, - &font, - font_size, - padding, - chrome_height, - layer, - ) - .total_height - }; - - let gutter_width = if max_gutter_width > 0.0 { - max_gutter_width - } else if let Some(ref trans) = transition { - let dims_a = compute_code_dimensions( - &trans.code_a, - &font, - font_size, - padding, - chrome_height, - layer, - ); - let dims_b = compute_code_dimensions( - &trans.code_b, - &font, - font_size, - padding, - chrome_height, - layer, - ); - f32::max(dims_a.gutter_width, dims_b.gutter_width) - } else { - compute_code_dimensions( - ¤t_code, - &font, - font_size, - padding, - chrome_height, - layer, - ) - .gutter_width - }; - - let total_width = layout.width.round(); - let total_height = layout.height.round(); - let x = layout.x; - let y = layout.y; - - let (pad_top, pad_right, pad_bottom, pad_left) = padding; - let corner_radius = layer.style.border_radius_px_or(12.0); - - let bg_color = layer.style.background_color_str().unwrap_or("#2b303b"); - let bg_paint = paint_from_hex(bg_color); - let bg_rect = Rect::from_xywh(x, y, total_width, total_height); - let rrect = skia_safe::RRect::new_rect_xy(bg_rect, corner_radius, corner_radius); - - canvas.save(); - canvas.clip_rrect(rrect, skia_safe::ClipOp::Intersect, true); - - canvas.draw_rect(bg_rect, &bg_paint); - - if chrome_enabled { - draw_chrome(canvas, layer, x, y, total_width, corner_radius); - } - - let code_x = x + pad_left + gutter_width; - let code_y = y + chrome_height + pad_top; - - // #4: the non-transition (typewriter/reveal) path only paints - // `visible_lines` lines, not the full `current_code` — `natural_height` - // (used below) is the height of *all* the code, revealed or not. Using - // it for the scroll offset made the offset constant and maximal from - // t=0, translating the not-yet-revealed lines' eventual position - // upward by the full amount immediately: the first lines to reveal sit - // above the clip, invisible, until the reveal has caught up with that - // fixed offset (reproduced: 60% of a 4s typewriter reveal painted zero - // text pixels). Compute reveal state up front so the scroll offset can - // be based on what's actually drawn — matches `terminal.rs`'s - // `content_h = visible_lines * line_h + padding + chrome_h` formula, - // which has never had this bug. - let reveal_state = if transition.is_none() { - let highlighted = highlight_code(¤t_code, &layer.language, theme); - let (visible_lines, visible_chars, last_line_opacity) = - compute_reveal(layer, time, &highlighted); - Some((highlighted, visible_lines, visible_chars, last_line_opacity)) - } else { - None - }; - - // Diff transitions (`render_diff_transition`) always paint the entire - // lerped diff, with no partial reveal — `natural_height` (the lerped - // dims_a/dims_b height) already matches what gets drawn for that path, - // so it needs no `visible_lines` adjustment; only the reveal path did. - let drawn_height = match &reveal_state { - Some((_, visible_lines, _, _)) => { - *visible_lines as f32 * actual_line_height + pad_top + pad_bottom + chrome_height - } - None => natural_height, - }; - - let scroll_offset = if layer.auto_scroll { - (drawn_height - total_height).max(0.0) - } else { - 0.0 - }; - canvas.save(); - canvas.clip_rect( - Rect::from_xywh( - x, - y + chrome_height, - total_width, - total_height - chrome_height, - ), - skia_safe::ClipOp::Intersect, - true, - ); - if scroll_offset > 0.0 { - canvas.translate((0.0, -scroll_offset)); - } - - if let Some(ref trans) = transition { - render_diff_transition( - canvas, - layer, - &font, - theme, - code_x, - code_y, - actual_line_height, - gutter_width, - pad_left, - x, - trans, - ); - } else { - let (highlighted, visible_lines, visible_chars, last_line_opacity) = - reveal_state.expect("reveal_state is always Some when transition is None"); - - if layer.show_line_numbers { - draw_line_numbers( - canvas, - &font, - x + pad_left, - code_y, - actual_line_height, - visible_lines, - ); - } - - draw_highlights( - canvas, - &layer.highlights, - time, - x + pad_left, - code_y, - actual_line_height, - total_width - pad_left - pad_right, - ); - - if layer.diff { - draw_diff_backgrounds( - canvas, - ¤t_code, - x + pad_left, - code_y, - actual_line_height, - total_width - pad_left - pad_right, - visible_lines, - ); - } - - draw_highlighted_lines( - canvas, - &highlighted, - &font, - code_x, - code_y, - actual_line_height, - visible_lines, - visible_chars, - last_line_opacity, - ); - } - - canvas.restore(); - canvas.restore(); -} diff --git a/crates/rustmotion-components/src/codeblock/reveal.rs b/crates/rustmotion-components/src/codeblock/reveal.rs deleted file mode 100644 index 8f29fbeb..00000000 --- a/crates/rustmotion-components/src/codeblock/reveal.rs +++ /dev/null @@ -1,290 +0,0 @@ -use skia_safe::{Canvas, Font, Paint, Rect, TextBlob}; - -use super::highlight::HighlightedLine; -use super::Codeblock; -use rustmotion_core::engine::animator::ease; -use rustmotion_core::engine::renderer::{ - draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, -}; -use rustmotion_core::schema::{CodeblockHighlight, RevealMode}; - -// ─── Reveal ────────────────────────────────────────────────────────────────── - -pub(super) fn compute_reveal( - layer: &Codeblock, - time: f64, - highlighted: &[HighlightedLine], -) -> (usize, Option, f32) { - let total_lines = highlighted.len(); - if total_lines == 0 { - return (0, None, 1.0); - } - - match &layer.reveal { - None => (total_lines, None, 1.0), - Some(reveal) => { - if time < reveal.start { - return (0, None, 1.0); - } - let raw_progress = ((time - reveal.start) / reveal.duration).clamp(0.0, 1.0); - let progress = ease(raw_progress, &reveal.easing); - - match reveal.mode { - RevealMode::Typewriter => { - let total_chars: usize = highlighted - .iter() - .map(|l| l.spans.iter().map(|s| s.text.len()).sum::()) - .sum(); - let visible_chars = (total_chars as f64 * progress).round() as usize; - let mut chars_remaining = visible_chars; - let mut visible_lines = 0; - let mut last_line_chars = None; - for line in highlighted { - let line_chars: usize = line.spans.iter().map(|s| s.text.len()).sum(); - if chars_remaining >= line_chars { - chars_remaining -= line_chars; - visible_lines += 1; - } else { - visible_lines += 1; - last_line_chars = Some(chars_remaining); - break; - } - } - (visible_lines, last_line_chars, 1.0) - } - RevealMode::LineByLine => { - let visible_f = total_lines as f64 * progress; - let full_lines = visible_f.floor() as usize; - let fractional = (visible_f - full_lines as f64) as f32; - if full_lines >= total_lines { - (total_lines, None, 1.0) - } else { - (full_lines + 1, None, fractional.max(0.01)) - } - } - } - } - } -} - -// ─── Line numbers ──────────────────────────────────────────────────────────── - -pub(super) fn draw_line_numbers( - canvas: &Canvas, - font: &Font, - x: f32, - y: f32, - line_height: f32, - visible_lines: usize, -) { - let mut paint = paint_from_hex("#65737E"); - paint.set_anti_alias(true); - let (_sw, metrics) = font.metrics(); - let ascent = -metrics.ascent; - - for i in 0..visible_lines { - let num_str = format!("{}", i + 1); - let num_y = y + i as f32 * line_height + ascent; - if let Some(blob) = TextBlob::new(&num_str, font) { - canvas.draw_text_blob(&blob, (x + 12.0, num_y), &paint); - } - } -} - -/// Draw a single line number at an arbitrary Y with given opacity -pub(super) fn draw_line_number_at( - canvas: &Canvas, - font: &Font, - x: f32, - y: f32, - num: usize, - opacity: f32, -) { - let num_str = format!("{}", num); - let mut paint = paint_from_hex("#65737E"); - paint.set_anti_alias(true); - paint.set_alpha_f(opacity); - if let Some(blob) = TextBlob::new(&num_str, font) { - canvas.draw_text_blob(&blob, (x + 12.0, y), &paint); - } -} - -// ─── Highlights ────────────────────────────────────────────────────────────── - -pub(super) fn draw_highlights( - canvas: &Canvas, - highlights: &[CodeblockHighlight], - time: f64, - x: f32, - y: f32, - line_height: f32, - width: f32, -) { - for hl in highlights { - if let Some(start) = hl.start { - if time < start { - continue; - } - } - if let Some(end) = hl.end { - if time > end { - continue; - } - } - let mut hl_paint = paint_from_hex(&hl.color); - hl_paint.set_anti_alias(false); - - // Sort line numbers and merge consecutive runs into single rects - // to avoid sub-pixel seams between adjacent highlight lines. - let mut sorted_lines: Vec = hl.lines.iter().copied().filter(|&n| n > 0).collect(); - sorted_lines.sort_unstable(); - sorted_lines.dedup(); - - let mut i = 0; - while i < sorted_lines.len() { - let run_start = sorted_lines[i] - 1; // 0-based - let mut run_end = run_start; - while i + 1 < sorted_lines.len() && sorted_lines[i + 1] == sorted_lines[i] + 1 { - i += 1; - run_end = sorted_lines[i] - 1; - } - let ry = (y + run_start as f32 * line_height).floor(); - let ry_end = (y + (run_end + 1) as f32 * line_height).ceil(); - let hl_rect = Rect::from_ltrb(x.floor(), ry, (x + width).ceil(), ry_end); - canvas.draw_rect(hl_rect, &hl_paint); - i += 1; - } - } -} - -// ─── Draw highlighted lines ────────────────────────────────────────────────── - -pub(super) fn draw_highlighted_lines( - canvas: &Canvas, - highlighted: &[HighlightedLine], - font: &Font, - x: f32, - y: f32, - line_height: f32, - visible_lines: usize, - visible_chars_last_line: Option, - last_line_opacity: f32, -) { - let (_sw, metrics) = font.metrics(); - let ascent = -metrics.ascent; - - for (i, line) in highlighted.iter().enumerate() { - if i >= visible_lines { - break; - } - let is_last_visible = i == visible_lines - 1; - let line_y = y + i as f32 * line_height + ascent; - let char_limit = if is_last_visible { - visible_chars_last_line - } else { - None - }; - let opacity = if is_last_visible && last_line_opacity < 1.0 { - last_line_opacity - } else { - 1.0 - }; - draw_single_highlighted_line_partial(canvas, line, font, x, line_y, opacity, char_limit); - } -} - -pub(super) fn draw_single_highlighted_line_partial( - canvas: &Canvas, - line: &HighlightedLine, - font: &Font, - x: f32, - y: f32, - opacity: f32, - char_limit: Option, -) { - let mut cursor_x = x; - let mut chars_drawn = 0usize; - - for span in &line.spans { - let text_to_draw = if let Some(limit) = char_limit { - let remaining = limit.saturating_sub(chars_drawn); - if remaining == 0 { - break; - } - let chars: Vec = span.text.chars().collect(); - let take = remaining.min(chars.len()); - chars[..take].iter().collect::() - } else { - span.text.clone() - }; - - if text_to_draw.is_empty() { - chars_drawn += span.text.len(); - continue; - } - - let mut paint = Paint::default(); - paint.set_anti_alias(true); - paint.set_color4f( - skia_safe::Color4f::new( - span.r as f32 / 255.0, - span.g as f32 / 255.0, - span.b as f32 / 255.0, - (span.a as f32 / 255.0) * opacity, - ), - None, - ); - - let emoji_f = emoji_typeface().map(|tf| Font::from_typeface(tf, font.size())); - draw_text_with_fallback( - canvas, - &text_to_draw, - font, - &emoji_f, - 0.0, - cursor_x, - y, - &paint, - ); - let w = measure_text_with_fallback(&text_to_draw, font, &emoji_f, 0.0); - cursor_x += w; - chars_drawn += text_to_draw.len(); - - if let Some(limit) = char_limit { - if chars_drawn >= limit { - break; - } - } - } -} - -pub(super) fn draw_single_highlighted_line( - canvas: &Canvas, - line: &HighlightedLine, - font: &Font, - x: f32, - y: f32, - opacity: f32, -) { - let mut cursor_x = x; - for span in &line.spans { - if span.text.is_empty() { - continue; - } - let mut paint = Paint::default(); - paint.set_anti_alias(true); - paint.set_color4f( - skia_safe::Color4f::new( - span.r as f32 / 255.0, - span.g as f32 / 255.0, - span.b as f32 / 255.0, - (span.a as f32 / 255.0) * opacity, - ), - None, - ); - let emoji_f = emoji_typeface().map(|tf| Font::from_typeface(tf, font.size())); - draw_text_with_fallback(canvas, &span.text, font, &emoji_f, 0.0, cursor_x, y, &paint); - let w = measure_text_with_fallback(&span.text, font, &emoji_f, 0.0); - cursor_x += w; - } -} diff --git a/crates/rustmotion-components/src/comparison.rs b/crates/rustmotion-components/src/comparison.rs index 3b45e2a0..da1b3615 100644 --- a/crates/rustmotion-components/src/comparison.rs +++ b/crates/rustmotion-components/src/comparison.rs @@ -41,6 +41,14 @@ fn default_border_radius() -> f32 { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`comparison` is a frozen composition (issue #333). Compose a before/after view \ + from two clipped `card`/`image` panels and an animated `shape` divider instead \ + — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Comparison { #[serde(default = "default_left_color")] pub left_color: String, diff --git a/crates/rustmotion-components/src/container.rs b/crates/rustmotion-components/src/container.rs index b53d0bc6..5268d9d8 100644 --- a/crates/rustmotion-components/src/container.rs +++ b/crates/rustmotion-components/src/container.rs @@ -9,10 +9,20 @@ use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; use crate::ChildComponent; -/// Invisible flex container — the HTML `
` equivalent. -/// No visual defaults (no background, border-radius, or shadow). -/// Gets `display: flex` automatically, like every other layout container. -/// Accepts `"type": "div"` as an alias in JSON. +/// The single layout container — the HTML `
` equivalent. Lays out +/// `children` via flex (default) or grid, decorated purely by `style` +/// (`background`, `border-radius`, `box-shadow`, ... — all opt-in, none +/// applied by default) and gets `display: flex` automatically when `style` +/// doesn't set one, like every other layout container. +/// +/// Six spellings of `"type"` all deserialize into this same struct and +/// render identically: `"div"` (canonical), plus `"container"`, `"card"`, +/// `"flex"`, `"grid"`, and `"positioned"` kept as aliases for backward +/// compatibility. None of them carries different behavior — a `"card"` +/// with no `background` set is exactly as undecorated as a `"div"`, and a +/// `"positioned"` is exactly as capable of flex/grid flow as any other +/// spelling; `position: {x, y}` on a child works the same way inside all +/// six, since it's a property of the child, not of the container. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ContainerComponent { #[serde(default)] diff --git a/crates/rustmotion-components/src/countdown.rs b/crates/rustmotion-components/src/countdown.rs index e3464f78..998c4b30 100644 --- a/crates/rustmotion-components/src/countdown.rs +++ b/crates/rustmotion-components/src/countdown.rs @@ -57,6 +57,14 @@ fn default_border_radius() -> f32 { /// css.width.is_none()` never gets a chance to apply its slightly-off one. #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(from = "CountdownRaw")] +#[deprecated( + since = "0.7.1", + note = "`countdown` is a frozen composition (issue #333). Compose digit tiles from a \ + `card` + `text` per unit instead of the dedicated flip-clock — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` \ + (#335)." +)] pub struct Countdown { #[serde(default = "default_seconds")] pub seconds: f64, diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index e67af735..b4d3ccdc 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -19,6 +19,13 @@ use rustmotion_core::schema::{ use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`counter` is a frozen composition (issue #333). Compose a plain `text` node and \ + drive its displayed value with an animation/expression on content instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Counter { pub from: f64, pub to: f64, diff --git a/crates/rustmotion-components/src/divider.rs b/crates/rustmotion-components/src/divider.rs index 22f195d7..1a93d68e 100644 --- a/crates/rustmotion-components/src/divider.rs +++ b/crates/rustmotion-components/src/divider.rs @@ -28,6 +28,14 @@ pub enum DividerLineStyle { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`divider` is a frozen composition (issue #333). Compose a separator from a \ + single thin `shape` (`rect`), full width or full height, instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` \ + (#335)." +)] pub struct Divider { #[serde(default)] pub direction: DividerDirection, diff --git a/crates/rustmotion-components/src/flex.rs b/crates/rustmotion-components/src/flex.rs deleted file mode 100644 index 3dccf226..00000000 --- a/crates/rustmotion-components/src/flex.rs +++ /dev/null @@ -1,53 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use skia_safe::Canvas; - -use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::schema::{SizeDimension, TimelineStep}; -use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; - -use crate::ChildComponent; - -/// Flex size — each dimension can be fixed or auto. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct FlexSize { - pub width: SizeDimension, - pub height: SizeDimension, -} - -/// Flex container — children are positioned via flexbox layout. -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct Flex { - #[serde(default)] - pub children: Vec, - #[serde(flatten)] - pub timing: TimingConfig, - #[serde(default)] - pub style: CssStyle, - #[serde(default)] - pub timeline: Vec, - #[serde(default)] - pub stagger: Option, - #[serde(default)] - pub time_scale: Option, - #[serde(default)] - pub time_offset: Option, -} - -rustmotion_core::impl_traits!(Flex { - Animatable => animation, - Timed => timing, - Styled => style, -}); - -impl Painter for Flex { - fn paint_content( - &self, - _canvas: &Canvas, - _layout: &BoxLayout, - _props: &rustmotion_core::engine::animator::AnimatedProperties, - _ctx: &PaintCtx, - ) { - } -} diff --git a/crates/rustmotion-components/src/gauge.rs b/crates/rustmotion-components/src/gauge.rs index bb939c9d..4137a43b 100644 --- a/crates/rustmotion-components/src/gauge.rs +++ b/crates/rustmotion-components/src/gauge.rs @@ -45,6 +45,16 @@ fn default_animation_duration() -> f64 { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`gauge` is a frozen composition (issue #333). The closest primitive recipe is \ + an `svg` arc path animated with `draw_progress`; \ + crates/rustmotion/skills/rules/composition-recipes.md flags this one (with \ + `number_wheel`) as a reasonable exception to keep using directly, since the \ + arc is mechanically harder to reproduce than a card/text/shape composition. \ + Kept for compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Gauge { #[serde(default)] pub value: f64, diff --git a/crates/rustmotion-components/src/grid.rs b/crates/rustmotion-components/src/grid.rs deleted file mode 100644 index 0ea8ec52..00000000 --- a/crates/rustmotion-components/src/grid.rs +++ /dev/null @@ -1,46 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use skia_safe::Canvas; - -use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::schema::TimelineStep; -use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; - -use crate::ChildComponent; - -/// Grid container — children are positioned via CSS-like grid layout. -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct Grid { - #[serde(default)] - pub children: Vec, - #[serde(flatten)] - pub timing: TimingConfig, - #[serde(default)] - pub style: CssStyle, - #[serde(default)] - pub timeline: Vec, - #[serde(default)] - pub stagger: Option, - #[serde(default)] - pub time_scale: Option, - #[serde(default)] - pub time_offset: Option, -} - -rustmotion_core::impl_traits!(Grid { - Animatable => animation, - Timed => timing, - Styled => style, -}); - -impl Painter for Grid { - fn paint_content( - &self, - _canvas: &Canvas, - _layout: &BoxLayout, - _props: &rustmotion_core::engine::animator::AnimatedProperties, - _ctx: &PaintCtx, - ) { - } -} diff --git a/crates/rustmotion-components/src/heatmap.rs b/crates/rustmotion-components/src/heatmap.rs index bfb20e42..93ddae91 100644 --- a/crates/rustmotion-components/src/heatmap.rs +++ b/crates/rustmotion-components/src/heatmap.rs @@ -40,6 +40,13 @@ fn default_color_scale() -> Vec { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`heatmap` is a frozen composition. Compose a `for-each` over the cells with a \ + computed `fill` expression per cell instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Heatmap { /// 2D array of values (rows x columns). pub data: Vec>, diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 88c0dc0b..1c15ab07 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -9,12 +9,13 @@ use skia_safe::{Font, FontStyle as SkFontStyle, Typeface}; use rustmotion_core::css::style::{ CssStyle, FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, LineHeight, - WhiteSpace, TEXT_AUTOFIT_MIN_FONT_PX, + TextAlign as CssTextAlign, WhiteSpace, TEXT_AUTOFIT_MIN_FONT_PX, }; use rustmotion_core::engine::box_tree::{AvailableSpace, IntrinsicMeasure}; +use rustmotion_core::engine::deps::{TextMetrics, TextMetricsProvider}; use rustmotion_core::engine::renderer::{ - emoji_typeface, format_counter_value, measure_text_with_fallback, typeface_with_fallback, - wrap_text_with_tracking, + compute_glyph_metrics, emoji_typeface, format_counter_value, measure_text_with_fallback, + typeface_with_fallback, wrap_text_with_tracking, GlyphMetric, }; use crate::badge::{Badge, BadgeSize}; @@ -104,6 +105,13 @@ pub struct TextIntrinsic { letter_spacing: f32, max_width: Option, wrap: bool, + /// `style.text-align`, resolved to the three horizontal keywords that + /// matter for placing a line inside its box (`right`/`end` collapse to + /// `Right`, everything else — including `justify`, which this engine + /// doesn't implement — collapses to `Left`). Only consulted by + /// [`Self::text_metrics`] (issue #328): line placement for painting is + /// `Text`'s own concern and already reads `style.text_align` directly. + text_align: CssTextAlign, /// `style.text-autofit == Some(true)`, but only ever set by /// [`Self::from_text`] / [`GradientTextIntrinsic::from_gradient_text`] — /// see [`Self::with_autofit`]'s doc comment for why `from_parts`/ @@ -187,6 +195,11 @@ impl TextIntrinsic { let base_ctx = measure_time_font_size_ctx(0.0); let (font_size, letter_spacing, line_height_resolved) = style.typography_px_ctx(&base_ctx, 48.0); + let text_align = match style.text_align { + Some(CssTextAlign::Center) => CssTextAlign::Center, + Some(CssTextAlign::Right | CssTextAlign::End) => CssTextAlign::Right, + _ => CssTextAlign::Left, + }; Self { content: content.to_string(), font_family: style.font_family.clone(), @@ -197,6 +210,7 @@ impl TextIntrinsic { letter_spacing, max_width, wrap: true, + text_align, text_autofit: false, } } @@ -313,6 +327,117 @@ impl TextIntrinsic { let family = self.font_family.as_deref().unwrap_or("Inter"); typeface_with_fallback(family, self.sk_font_style()).ok() } + + /// Text/glyph metrics for a `node("id", ...)` expression read (issue + /// #328) — the `TextMetrics`/`Glyphs` families of + /// `crate::engine::deps::ResolvedNode::prop`. `content_box_width` is the + /// node's own resolved content-box width, post-`layout_pass` + /// (`BoxLayout::content_box().2`) — the same width `self.measure` + /// itself would receive as `known.0`/`available.0`, which is why this + /// can only run *after* layout, unlike [`Self::sk_font_style`]. + /// + /// `capHeight`/`ascender`/`baseline` are plain font metrics (wrap- + /// independent); `textWidth` and the glyph run depend on how `content` + /// wraps at `content_box_width`, computed the same way + /// [`Self::measure`] does (`wrap_text_with_tracking`, same font/tracking + /// inputs), so the box an expression reads back always agrees with the + /// box `layout_pass` actually reserved. Does not account for + /// `text-autofit`'s shrunk font size — this reports metrics at the + /// *requested* size regardless of whether autofit later shrinks it, a + /// known simplification (autofit and `node(...)` reads are an unusual + /// combination: autofit exists for content whose length can't be + /// predicted, which cuts against also anchoring another node to its + /// exact rendered size). + pub fn text_metrics(&self, content_box_width: f32) -> Option { + let typeface = self.typeface()?; + let font = Font::from_typeface(typeface, self.font_size); + let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, self.font_size)); + + let wrap_at = if self.wrap { + Some( + self.max_width + .map(|m| m.min(content_box_width)) + .unwrap_or(content_box_width), + ) + } else { + None + }; + let lines = wrap_text_with_tracking( + &self.content, + &font, + &emoji_font, + wrap_at, + self.letter_spacing, + ); + + let mut text_width = 0.0f32; + let mut glyphs: Vec = Vec::new(); + for line in &lines { + let advance = measure_text_with_fallback(line, &font, &emoji_font, self.letter_spacing); + text_width = text_width.max(advance); + // `self.text_align` is already normalised to Left/Center/Right + // at construction (see `from_parts`) — no other variant is ever + // stored here. + let line_x = match self.text_align { + CssTextAlign::Center => (content_box_width - advance) / 2.0, + CssTextAlign::Right => content_box_width - advance, + _ => 0.0, + }; + let line_glyphs = compute_glyph_metrics(line, &font, &emoji_font, self.letter_spacing); + glyphs.extend(line_glyphs.into_iter().map(|g| GlyphMetric { + x: g.x + line_x, + width: g.width, + })); + } + + let (_, metrics) = font.metrics(); + let ascender = -metrics.ascent; + let descender = metrics.descent; + // Mirrors `text.rs::paint`'s own `baseline_offset` formula exactly + // (centers the em box within the line box) so `baseline` describes + // where the glyphs this same struct paints actually sit. + let baseline = (self.line_height_resolved + ascender - descender) / 2.0; + + Some(TextMetrics { + text_width, + cap_height: metrics.cap_height, + ascender, + baseline, + glyphs, + }) + } +} + +/// [`TextMetricsProvider`] implementation for the two components whose +/// intrinsic measurer is [`TextIntrinsic`]-backed and expose plain text +/// content (`Text`, `GradientText`) — the bridge +/// `rustmotion_core::engine::deps` needs from this crate to resolve the +/// `TextMetrics`/`Glyphs` families without `rustmotion-core` knowing either +/// concrete type (see that module's doc comment on why this is a trait +/// rather than a direct call). +/// +/// `Caption`/`Counter`/`Kbd`/`Badge`/`RichText` also measure through +/// `TextIntrinsic`-shaped helpers but are not wired in here — extending +/// this is a matter of downcasting to each and calling the same +/// `TextIntrinsic::text_metrics`, not new measurement logic. +pub struct ComponentTextMetrics; + +impl TextMetricsProvider for ComponentTextMetrics { + fn text_metrics( + &self, + payload: &(dyn std::any::Any + Send + Sync), + content_box_width: f32, + ) -> Option { + if let Some(t) = payload.downcast_ref::() { + return TextIntrinsic::from_text(t).text_metrics(content_box_width); + } + if let Some(g) = payload.downcast_ref::() { + return GradientTextIntrinsic::from_gradient_text(g) + .0 + .text_metrics(content_box_width); + } + None + } } /// Wrap `content` at `font_size` (with `letter_spacing`/`line_height` @@ -784,93 +909,6 @@ fn synthesize_text_style(src: &CssStyle, font_size: f32, default_family: &str) - #[allow(dead_code)] fn _line_height_unused(_: Option<&LineHeight>) {} -// ───────────────────────────────────────────────────────────────────────────── -// Terminal intrinsic measurer -// ───────────────────────────────────────────────────────────────────────────── - -use crate::terminal::{ - resolve_typeface as resolve_terminal_typeface, Terminal, CHROME_HEIGHT, - FONT_SIZE as TERM_FONT_SIZE, LINE_HEIGHT as TERM_LINE_HEIGHT, PADDING as TERM_PADDING, -}; - -/// Intrinsic measurer for [`Terminal`]. -/// -/// Natural size formula (matches the painter exactly): -/// - `line_height = ceil(font_size × TERM_LINE_HEIGHT / TERM_FONT_SIZE)` -/// - `height = chrome_height + 2 × TERM_PADDING + n_lines × line_height` -/// - `width` = widest line text (prefix + content) + 2 × TERM_PADDING -/// -/// If the Skia font fails to load, returns (0, 0) so layout falls back to -/// whatever container constraints supply. -pub struct TerminalIntrinsic { - line_height: f32, - n_lines: usize, - chrome_height: f32, - padding: f32, - /// Maximum measured text width across all lines (including prefix). - max_line_width: f32, -} - -impl TerminalIntrinsic { - pub fn from_terminal(t: &Terminal) -> Self { - let font_size = t - .style - .font_size_px_ctx(&measure_time_font_size_ctx(0.0), TERM_FONT_SIZE); - let line_height = (font_size * TERM_LINE_HEIGHT / TERM_FONT_SIZE).ceil(); - let chrome_height = if t.show_chrome { CHROME_HEIGHT } else { 0.0 }; - - // Measure each line (prefix + text) with the same Skia font the painter uses. - let max_line_width = Self::measure_max_width(t, font_size); - - Self { - line_height, - n_lines: t.lines.len(), - chrome_height, - padding: TERM_PADDING, - max_line_width, - } - } - - fn measure_max_width(t: &Terminal, font_size: f32) -> f32 { - // Same resolver the painter calls — see `terminal::resolve_typeface`. - // Measuring with one face and painting with another is how text ends up - // overflowing a box the geometry pass has already approved. - let Some(typeface) = resolve_terminal_typeface(&t.style) else { - // Font unavailable (CI without fonts); return 0 — the layout will - // be width-unconstrained and the container drives the size. - return 0.0; - }; - let font = Font::from_typeface(typeface, font_size); - let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); - - t.lines - .iter() - .map(|line| { - let prefix = match line.line_type { - crate::terminal::TerminalLineType::Prompt => "$ ", - _ => "", - }; - let full = format!("{}{}", prefix, line.text); - measure_text_with_fallback(&full, &font, &emoji_font, 0.0) - }) - .fold(0.0f32, f32::max) - } -} - -impl IntrinsicMeasure for TerminalIntrinsic { - fn measure( - &self, - known: (Option, Option), - _available: (AvailableSpace, AvailableSpace), - ) -> (f32, f32) { - let w = known.0.unwrap_or(self.max_line_width + self.padding * 2.0); - let h = known.1.unwrap_or( - self.chrome_height + self.padding * 2.0 + self.n_lines as f32 * self.line_height, - ); - (w, h) - } -} - // ───────────────────────────────────────────────────────────────────────────── // Table intrinsic measurer // ───────────────────────────────────────────────────────────────────────────── @@ -933,88 +971,6 @@ impl IntrinsicMeasure for TableIntrinsic { } } -// ───────────────────────────────────────────────────────────────────────────── -// Codeblock intrinsic measurer -// ───────────────────────────────────────────────────────────────────────────── - -use crate::codeblock::dimensions::compute_code_dimensions; -use crate::codeblock::highlight::resolve_monospace_font; -use crate::codeblock::Codeblock; -use rustmotion_core::css::style::{FontWeight as CssFontWeight2, FontWeightKw as CssFontWeightKw2}; -use rustmotion_core::schema::FontWeight; - -/// Intrinsic measurer for [`Codeblock`]. -/// -/// Reuses `compute_code_dimensions` (same function as the painter) to derive: -/// - `width = max_line_width + gutter_width + pad_left + pad_right` -/// - `height = line_count × line_height + pad_top + pad_bottom + chrome_height` -/// -/// Computed once at construction from the initial `code` string. If a state -/// transition widens the content at paint time, `auto_scroll` handles vertical -/// overflow without needing the intrinsic to re-run. -pub struct CodeblockIntrinsic { - natural_width: f32, - natural_height: f32, -} - -impl CodeblockIntrinsic { - pub fn from_codeblock(c: &Codeblock) -> Self { - let font_family = c.style.font_family_or("JetBrains Mono"); - let font_size = c - .style - .font_size_px_ctx(&measure_time_font_size_ctx(0.0), 14.0); - let font_weight = match &c.style.font_weight { - Some(CssFontWeight2::Keyword(CssFontWeightKw2::Bold | CssFontWeightKw2::Bolder)) => { - FontWeight::Bold - } - Some(CssFontWeight2::Number(n)) if *n >= 600 => FontWeight::Bold, - Some(CssFontWeight2::Number(n)) => FontWeight::Weight(*n), - _ => FontWeight::Normal, - }; - - let Some(font) = resolve_monospace_font(font_family, font_size, font_weight) else { - return Self { - natural_width: 0.0, - natural_height: 0.0, - }; - }; - - let padding = { - let (t, r, b, l) = c.style.padding_px(); - if t == 0.0 && r == 0.0 && b == 0.0 && l == 0.0 { - (16.0, 16.0, 16.0, 16.0) - } else { - (t, r, b, l) - } - }; - - let chrome_height = if c.chrome.as_ref().is_some_and(|ch| ch.enabled) { - 36.0 - } else { - 0.0 - }; - - let dims = compute_code_dimensions(&c.code, &font, font_size, padding, chrome_height, c); - - Self { - natural_width: dims.total_width, - natural_height: dims.total_height, - } - } -} - -impl IntrinsicMeasure for CodeblockIntrinsic { - fn measure( - &self, - known: (Option, Option), - _available: (AvailableSpace, AvailableSpace), - ) -> (f32, f32) { - let w = known.0.unwrap_or(self.natural_width); - let h = known.1.unwrap_or(self.natural_height); - (w, h) - } -} - // ───────────────────────────────────────────────────────────────────────────── // RichText intrinsic measurer // ───────────────────────────────────────────────────────────────────────────── @@ -1911,4 +1867,127 @@ mod tests { "caption must ignore text-autofit entirely (nowrap bleeds exactly as before)" ); } + + // ─── `text_metrics` / `ComponentTextMetrics` (issue #328) ────────────── + + fn plain_text(content: &str, font_size: f32) -> Text { + Text { + content: content.into(), + max_width: None, + timing: Default::default(), + style: CssStyle { + font_size: Some(Length::Px(font_size)), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + text_shadow: None, + stroke: None, + text_background: None, + caret: None, + states: Vec::new(), + swap: None, + } + } + + #[test] + fn text_metrics_reports_glyph_count_matching_content_for_one_line() { + let text = plain_text("Sentence", 32.0); + let metrics = TextIntrinsic::from_text(&text) + .text_metrics(500.0) + .expect("host must have a fallback typeface"); + assert_eq!(metrics.glyphs.len(), "Sentence".chars().count()); + assert!(metrics.text_width > 0.0); + assert!(metrics.cap_height > 0.0); + assert!(metrics.ascender > 0.0); + } + + #[test] + fn text_metrics_last_glyph_sits_before_where_a_detached_char_would_go() { + // The issue #328 worked example: the author writes the sentence + // without its trailing `?` and anchors a separate node to the last + // glyph's right edge. + let text = plain_text("Sentence", 32.0); + let metrics = TextIntrinsic::from_text(&text).text_metrics(500.0).unwrap(); + let last = *metrics.glyphs.last().unwrap(); + let detached_question_mark_x = last.x + last.width; + assert!(detached_question_mark_x > last.x); + assert!(detached_question_mark_x <= metrics.text_width + 0.5); + } + + #[test] + fn text_metrics_width_matches_measure_when_unwrapped() { + let text = plain_text("no wrap needed", 24.0); + let intrinsic = TextIntrinsic::from_text(&text); + let (measured_w, _measured_h) = intrinsic.measure( + (None, None), + (AvailableSpace::MaxContent, AvailableSpace::MaxContent), + ); + let metrics = intrinsic.text_metrics(10_000.0).unwrap(); + assert!( + (metrics.text_width - measured_w).abs() < 0.5, + "text_metrics width {} should match measure() width {}", + metrics.text_width, + measured_w + ); + } + + #[test] + fn text_metrics_centers_glyphs_when_text_align_is_center() { + let mut text = plain_text("Hi", 32.0); + text.style.text_align = Some(rustmotion_core::css::style::TextAlign::Center); + let intrinsic = TextIntrinsic::from_text(&text); + let centered = intrinsic.text_metrics(400.0).unwrap(); + let left = plain_text("Hi", 32.0); + let left_metrics = TextIntrinsic::from_text(&left).text_metrics(400.0).unwrap(); + assert!( + centered.glyphs[0].x > left_metrics.glyphs[0].x, + "centered first glyph ({}) should start further right than left-aligned ({})", + centered.glyphs[0].x, + left_metrics.glyphs[0].x + ); + } + + #[test] + fn component_text_metrics_downcasts_text() { + let text = plain_text("Hello", 28.0); + let provider = ComponentTextMetrics; + let payload: &(dyn std::any::Any + Send + Sync) = &text; + let metrics = provider + .text_metrics(payload, 500.0) + .expect("Text must resolve through ComponentTextMetrics"); + assert_eq!(metrics.glyphs.len(), "Hello".chars().count()); + } + + #[test] + fn component_text_metrics_downcasts_gradient_text() { + let gt = GradientText { + content: "Gradient".into(), + colors: vec!["#3B82F6".into(), "#8B5CF6".into()], + angle: 90.0, + animate_angle: false, + speed: 0.5, + timing: Default::default(), + style: CssStyle { + font_size: Some(Length::Px(28.0)), + ..Default::default() + }, + timeline: Vec::new(), + stagger: None, + }; + let provider = ComponentTextMetrics; + let payload: &(dyn std::any::Any + Send + Sync) = > + let metrics = provider + .text_metrics(payload, 500.0) + .expect("GradientText must resolve through ComponentTextMetrics"); + assert_eq!(metrics.glyphs.len(), "Gradient".chars().count()); + } + + #[test] + fn component_text_metrics_returns_none_for_a_non_text_component() { + // Any non-text payload (a bare `i32` stands in for one here) must + // fall through cleanly rather than panicking on a failed downcast. + let payload: &(dyn std::any::Any + Send + Sync) = &42i32; + assert!(ComponentTextMetrics.text_metrics(payload, 500.0).is_none()); + } } diff --git a/crates/rustmotion-components/src/kbd.rs b/crates/rustmotion-components/src/kbd.rs index 6874cbf3..79f4b317 100644 --- a/crates/rustmotion-components/src/kbd.rs +++ b/crates/rustmotion-components/src/kbd.rs @@ -30,6 +30,13 @@ fn default_text_color() -> String { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`kbd` is a frozen composition (issue #333). Compose a keycap from a small `card` \ + (border + shadow) with a `text` label instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Kbd { pub key: String, #[serde(default = "default_font_size")] diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 4f3736a2..3f259a2c 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -4,8 +4,9 @@ //! Naming kept as "legacy" for now to avoid churn in callers; this is the //! sole dispatcher in use since every component implements `Painter`. //! -//! Containers (Card / Flex / Grid / Container / Positioned) are intentionally -//! skipped: paint_pass already paints their box decorations and recurses into +//! The container component (`Component::Container` — tagged `div`, aliased +//! `container`/`card`/`flex`/`grid`/`positioned`) is intentionally skipped: +//! paint_pass already paints its box decorations and recurses into //! children — so calling the container's own `paint_content` would do nothing //! anyway, and we save a no-op call. //! @@ -141,36 +142,19 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { // promises the canvas is already translated to the CONTENT-box // origin, with `layout` describing the content box — padding // reserved by taffy is consumed here, not left for the painter to - // rediscover. `Codeblock` is a deliberate, documented exception: it - // reads `style.padding` itself (`codeblock/render.rs` computes - // `code_x = x + pad_left + gutter_width` from the layout origin it - // receives) and paints its own background/border directly from the - // BORDER-box origin. Honoring the general contract for it too would - // double-apply padding — content shifted twice, background rect - // shrunk incorrectly — so it keeps receiving the untranslated - // border-box origin and dimensions, exactly as before this fix. - let is_self_padding = matches!(child.component, Component::Codeblock(_)); - + // rediscover. `Codeblock` used to be a deliberate, documented + // exception (it painted from the untranslated BORDER-box origin); + // it has been deleted, so every remaining `Painter` now honors the + // general contract uniformly. canvas.save(); - let local = if is_self_padding { - canvas.translate((layout.x, layout.y)); - BoxLayout { - x: 0.0, - y: 0.0, - width: layout.width, - height: layout.height, - ..Default::default() - } - } else { - let (cx, cy, cw, ch) = layout.content_box(); - canvas.translate((cx, cy)); - BoxLayout { - x: 0.0, - y: 0.0, - width: cw, - height: ch, - ..Default::default() - } + let (cx, cy, cw, ch) = layout.content_box(); + canvas.translate((cx, cy)); + let local = BoxLayout { + x: 0.0, + y: 0.0, + width: cw, + height: ch, + ..Default::default() }; let paint_ctx = PaintCtx { @@ -190,14 +174,7 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { } fn is_container(c: &Component) -> bool { - matches!( - c, - Component::Card(_) - | Component::Flex(_) - | Component::Grid(_) - | Component::Container(_) - | Component::Positioned(_) - ) + matches!(c, Component::Container(_)) } #[cfg(test)] @@ -216,6 +193,7 @@ mod tests { fn shape_child(w: f32, h: f32, x: f32, y: f32) -> ChildComponent { ChildComponent { + id: None, component: Component::Shape(Shape { shape: ShapeType::Rect, text: None, @@ -378,11 +356,12 @@ mod tests { fn card_background_painted_with_red_shape_inside() { // Card 100×80 at (40,30), green background, contains a red 30×20 shape // absolutely positioned at (10,10) inside the card. - use crate::card::Card; + use crate::container::ContainerComponent; use rustmotion_core::css::style::{Background, Color}; let red_shape = ChildComponent { + id: None, component: Component::Shape(Shape { shape: ShapeType::Rect, text: None, @@ -405,7 +384,8 @@ mod tests { }; let card = ChildComponent { - component: Component::Card(Card { + id: None, + component: Component::Container(ContainerComponent { children: vec![red_shape], timing: Default::default(), style: CssStyle { @@ -496,6 +476,7 @@ mod tests { let make_scene = || { let shape = ChildComponent { + id: None, component: Component::Shape(Shape { shape: ShapeType::Rect, text: None, diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 8a9b8615..ff0064d0 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -1,3 +1,28 @@ +// Issue #333 (phase B): the twenty-seven frozen-composition components +// (`stat`, `badge`, `gauge`, ... — see each type's own `#[deprecated]` note +// for its replacement recipe) carry a `#[deprecated]` attribute so a Rust +// consumer of this crate who writes `Badge { .. }`/`Stat { .. }`/etc. by hand +// is told, at their own call site, what to compose instead. +// +// That attribute also fires for every internal reference to the same type — +// the struct's own derive-generated impls, its `impl Painter`, every field +// read in `box_builder`'s intrinsic-sizing/style-extraction match arms, and +// the `Component` enum's own tagged-variant plumbing in this file — because +// deprecating a struct deprecates its fields too, and Rust does not +// distinguish "the engine implementing this component" from "an author +// constructing one". None of that internal traffic is an authoring site: +// rendering an existing `badge`/`stat`/... scenario byte-identically +// requires touching every one of those fields exactly as before, and a JSON +// scenario is deserialized through this crate's own generated +// `Deserialize` impls, never through hand-written Rust at the call site — so +// `serde_json::from_str::(..)` in `rustmotion`'s render path +// never lints here regardless of this attribute. This single crate-root +// allow silences only that internal noise; it does not extend to any other +// crate, so a hand-written construction in `rustmotion-html`, +// `rustmotion-studio`, or this crate's own `tests/` integration suite (each +// a separate compilation unit) still warns. Verified empirically before +// relying on it: see the phase-B report for issue #333. +#![allow(deprecated)] pub mod box_builder; pub mod intrinsic; pub mod legacy_dispatch; @@ -9,9 +34,7 @@ pub mod avatar_group; pub mod badge; pub mod callout; pub mod caption; -pub mod card; pub mod chart; -pub mod codeblock; pub mod comparison; pub mod connector; pub mod container; @@ -20,11 +43,9 @@ pub mod counter; pub mod cursor; pub mod divider; pub mod dot_map; -pub mod flex; pub mod gauge; pub mod gif; pub mod gradient_text; -pub mod grid; pub mod heatmap; pub mod icon; pub mod image; @@ -34,12 +55,10 @@ pub mod list; pub mod lottie; pub mod marquee; pub mod mockup; -pub mod notification; pub mod number_wheel; pub mod particle; pub mod pill_nav; pub mod pointer; -pub mod positioned; pub mod progress; pub mod qrcode; pub mod rating; @@ -55,7 +74,6 @@ pub mod svg; pub mod switch; pub mod table; pub mod tag_cloud; -pub mod terminal; pub mod text; pub mod timeline; pub mod tooltip; @@ -77,9 +95,7 @@ pub use avatar_group::AvatarGroup; pub use badge::Badge; pub use callout::Callout; pub use caption::Caption; -pub use card::Card; pub use chart::Chart; -pub use codeblock::Codeblock; pub use comparison::Comparison; pub use connector::Connector; pub use container::ContainerComponent; @@ -88,11 +104,9 @@ pub use counter::Counter; pub use cursor::Cursor; pub use divider::Divider; pub use dot_map::DotMap; -pub use flex::Flex; pub use gauge::Gauge; pub use gif::Gif; pub use gradient_text::GradientText; -pub use grid::Grid; pub use heatmap::Heatmap; pub use icon::Icon; pub use image::Image; @@ -102,12 +116,10 @@ pub use list::List; pub use lottie::Lottie; pub use marquee::Marquee; pub use mockup::Mockup; -pub use notification::Notification; pub use number_wheel::NumberWheel; pub use particle::Particle; pub use pill_nav::PillNav; pub use pointer::Pointer; -pub use positioned::Positioned; pub use progress::Progress; pub use qrcode::QrCode; pub use rating::Rating; @@ -123,7 +135,6 @@ pub use svg::Svg; pub use switch::Switch; pub use table::Table; pub use tag_cloud::TagCloud; -pub use terminal::Terminal; pub use text::Text; pub use timeline::Timeline; pub use tooltip::Tooltip; @@ -315,6 +326,16 @@ pub struct ChildComponent { pub y: Option, #[serde(default, rename = "z-index")] pub z_index: Option, + /// Author-declared name for this node, unique within its own scene + /// (issue #328). Read back by other nodes' expressions through + /// `node("id", "prop")` — see `rustmotion_core::engine::deps`'s module + /// doc for the per-frame dependency graph this feeds, and the "unique + /// within its scene" constraint that graph enforces at build time + /// (`DepsError::DuplicateId`). Optional: a node with no `id` simply + /// cannot be referenced by another one's expressions, and is otherwise + /// unaffected. + #[serde(default)] + pub id: Option, /// Declares that this component's job is to extend past the frame edge /// (e.g. a radial glow used as a base layer). Top-level field, not a /// `style` property — `CssStyle` is `deny_unknown_fields` and belongs to @@ -365,7 +386,6 @@ pub enum Component { Counter(Counter), Cursor(Cursor), Caption(Caption), - Codeblock(Codeblock), Connector(Connector), Avatar(Avatar), AvatarGroup(AvatarGroup), @@ -386,7 +406,6 @@ pub enum Component { Lottie(Lottie), Marquee(Marquee), Mockup(Mockup), - Notification(Notification), Particle(Particle), PillNav(PillNav), #[serde(alias = "progress_bar")] @@ -405,15 +424,17 @@ pub enum Component { RichText(RichText), Table(Table), TagCloud(TagCloud), - Terminal(Terminal), Timeline(Timeline), Tooltip(Tooltip), Treemap(Treemap), - Positioned(Positioned), - Flex(Flex), - Grid(Grid), - Card(Card), - #[serde(rename = "div", alias = "container")] + #[serde( + rename = "div", + alias = "container", + alias = "card", + alias = "flex", + alias = "grid", + alias = "positioned" + )] Container(ContainerComponent), Waveform(Waveform), } @@ -435,7 +456,6 @@ impl Component { Component::Counter(c) => Some(c), Component::Cursor(c) => Some(c), Component::Caption(c) => Some(c), - Component::Codeblock(c) => Some(c), Component::Avatar(c) => Some(c), Component::AvatarGroup(c) => Some(c), Component::Arrow(c) => Some(c), @@ -456,7 +476,6 @@ impl Component { Component::Lottie(c) => Some(c), Component::Marquee(c) => Some(c), Component::Mockup(c) => Some(c), - Component::Notification(c) => Some(c), Component::Particle(c) => Some(c), Component::PillNav(c) => Some(c), Component::Progress(c) => Some(c), @@ -474,15 +493,10 @@ impl Component { Component::RichText(c) => Some(c), Component::Table(c) => Some(c), Component::TagCloud(c) => Some(c), - Component::Terminal(c) => Some(c), Component::Timeline(c) => Some(c), Component::Tooltip(c) => Some(c), Component::Treemap(c) => Some(c), - Component::Flex(c) => Some(c), - Component::Grid(c) => Some(c), - Component::Card(c) => Some(c), Component::Container(c) => Some(c), - Component::Positioned(c) => Some(c), } } @@ -499,7 +513,6 @@ impl Component { Component::Gif(c) => Some(c), Component::Counter(c) => Some(c), Component::Cursor(c) => Some(c), - Component::Codeblock(c) => Some(c), Component::Avatar(c) => Some(c), Component::AvatarGroup(c) => Some(c), Component::Arrow(c) => Some(c), @@ -520,7 +533,6 @@ impl Component { Component::Lottie(c) => Some(c), Component::Marquee(c) => Some(c), Component::Mockup(c) => Some(c), - Component::Notification(c) => Some(c), Component::Particle(c) => Some(c), Component::PillNav(c) => Some(c), Component::Progress(c) => Some(c), @@ -538,16 +550,11 @@ impl Component { Component::RichText(c) => Some(c), Component::Table(c) => Some(c), Component::TagCloud(c) => Some(c), - Component::Terminal(c) => Some(c), Component::Timeline(c) => Some(c), Component::Tooltip(c) => Some(c), Component::Treemap(c) => Some(c), - Component::Flex(c) => Some(c), - Component::Grid(c) => Some(c), - Component::Card(c) => Some(c), Component::Container(c) => Some(c), Component::Caption(c) => Some(c), - Component::Positioned(c) => Some(c), } } @@ -565,7 +572,6 @@ impl Component { Component::Counter(c) => c, Component::Cursor(c) => c, Component::Caption(c) => c, - Component::Codeblock(c) => c, Component::Avatar(c) => c, Component::AvatarGroup(c) => c, Component::Arrow(c) => c, @@ -586,7 +592,6 @@ impl Component { Component::Lottie(c) => c, Component::Marquee(c) => c, Component::Mockup(c) => c, - Component::Notification(c) => c, Component::Particle(c) => c, Component::PillNav(c) => c, Component::Progress(c) => c, @@ -604,14 +609,9 @@ impl Component { Component::RichText(c) => c, Component::Table(c) => c, Component::TagCloud(c) => c, - Component::Terminal(c) => c, Component::Timeline(c) => c, Component::Tooltip(c) => c, Component::Treemap(c) => c, - Component::Positioned(c) => c, - Component::Flex(c) => c, - Component::Grid(c) => c, - Component::Card(c) => c, Component::Container(c) => c, } } @@ -625,11 +625,7 @@ impl Component { match self { Component::AudioSpectrum(c) => Some(c), Component::Waveform(c) => Some(c), - Component::Card(c) => Some(c), Component::Container(c) => Some(c), - Component::Flex(c) => Some(c), - Component::Grid(c) => Some(c), - Component::Positioned(c) => Some(c), Component::Divider(c) => Some(c), Component::Shape(c) => Some(c), Component::Image(c) => Some(c), @@ -659,7 +655,6 @@ impl Component { Component::Rating(c) => Some(c), Component::Stepper(c) => Some(c), Component::Comparison(c) => Some(c), - Component::Notification(c) => Some(c), Component::Tooltip(c) => Some(c), Component::PillNav(c) => Some(c), Component::List(c) => Some(c), @@ -677,8 +672,6 @@ impl Component { Component::Treemap(c) => Some(c), Component::DotMap(c) => Some(c), Component::Table(c) => Some(c), - Component::Codeblock(c) => Some(c), - Component::Terminal(c) => Some(c), Component::Chart(c) => Some(c), Component::Line(c) => Some(c), Component::Arrow(c) => Some(c), @@ -700,9 +693,9 @@ impl Component { /// `font_weight`/`font_style` read with no cascade in between — not by /// guessing from the component's name. Several read only `font-size`/ /// `font-family` and keep their own dedicated field for text colour - /// (`Kbd::text_color`, `PillNav::text_color`, `Terminal`'s theme) — - /// still members, since those two properties alone are enough for the - /// same defect: a `font-size` set on a card never reaching the child. + /// (`Kbd::text_color`, `PillNav::text_color`) — still members, since + /// those two properties alone are enough for the same defect: a + /// `font-size` set on a card never reaching the child. /// /// Exhaustive on purpose, no wildcard arm: adding a new `Component` /// variant is a compile error here until this match says whether it @@ -737,11 +730,9 @@ impl Component { | Component::Kbd(_) | Component::List(_) | Component::Marquee(_) - | Component::Notification(_) | Component::NumberWheel(_) | Component::PillNav(_) | Component::Table(_) - | Component::Terminal(_) | Component::Tooltip(_) => true, Component::AudioSpectrum(_) | Component::Shape(_) @@ -750,7 +741,6 @@ impl Component { | Component::Video(_) | Component::Gif(_) | Component::Cursor(_) - | Component::Codeblock(_) | Component::Connector(_) | Component::Avatar(_) | Component::AvatarGroup(_) @@ -779,10 +769,6 @@ impl Component { | Component::TagCloud(_) | Component::Timeline(_) | Component::Treemap(_) - | Component::Positioned(_) - | Component::Flex(_) - | Component::Grid(_) - | Component::Card(_) | Component::Container(_) | Component::Waveform(_) => false, }; @@ -804,11 +790,9 @@ impl Component { Component::Kbd(c) => c.style_config_mut(), Component::List(c) => c.style_config_mut(), Component::Marquee(c) => c.style_config_mut(), - Component::Notification(c) => c.style_config_mut(), Component::NumberWheel(c) => c.style_config_mut(), Component::PillNav(c) => c.style_config_mut(), Component::Table(c) => c.style_config_mut(), - Component::Terminal(c) => c.style_config_mut(), Component::Tooltip(c) => c.style_config_mut(), _ => unreachable!("classified as typographic by the match above"), }; diff --git a/crates/rustmotion-components/src/list.rs b/crates/rustmotion-components/src/list.rs index 4e7fd71e..9036cefb 100644 --- a/crates/rustmotion-components/src/list.rs +++ b/crates/rustmotion-components/src/list.rs @@ -49,6 +49,13 @@ pub struct ListItem { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`list` is a frozen composition (issue #333). Compose each row from `icon` + \ + `text` inside a `for-each` instead of the dedicated list — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct List { pub items: Vec, #[serde(default)] diff --git a/crates/rustmotion-components/src/marquee.rs b/crates/rustmotion-components/src/marquee.rs index 401baaa1..c3982dca 100644 --- a/crates/rustmotion-components/src/marquee.rs +++ b/crates/rustmotion-components/src/marquee.rs @@ -35,6 +35,14 @@ pub enum MarqueeDirection { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`marquee` is a frozen composition (issue #333). Compose continuous scroll from a \ + `text`/`flex` row whose translate is keyframed/looped past the frame edge instead \ + — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Marquee { pub content: String, #[serde(default = "default_speed")] diff --git a/crates/rustmotion-components/src/mockup.rs b/crates/rustmotion-components/src/mockup.rs index 8520a92f..738a7de6 100644 --- a/crates/rustmotion-components/src/mockup.rs +++ b/crates/rustmotion-components/src/mockup.rs @@ -48,6 +48,13 @@ impl MockupTheme { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`mockup` is a frozen composition. Compose a `shape` frame around an `image` \ + instead — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Mockup { pub device: MockupDevice, pub src: String, diff --git a/crates/rustmotion-components/src/notification.rs b/crates/rustmotion-components/src/notification.rs deleted file mode 100644 index 4d63a057..00000000 --- a/crates/rustmotion-components/src/notification.rs +++ /dev/null @@ -1,464 +0,0 @@ -use rustmotion_core::css::CssStyle; -use rustmotion_core::error::Result; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, RRect, Rect}; - -use rustmotion_core::engine::animator::AnimatedProperties; -use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::{ - asset_cache, draw_text_with_fallback, emoji_typeface, fetch_icon_svg, paint_from_hex, - typeface_with_fallback, -}; -use rustmotion_core::schema::TimelineStep; -use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; - -fn default_width() -> f32 { - 360.0 -} -fn default_slide_in_at() -> f64 { - 0.5 -} -fn default_slide_duration() -> f64 { - 0.15 -} -fn default_stack_gap() -> f32 { - 12.0 -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -#[derive(Default)] -pub enum NotificationVariant { - #[default] - Info, - Success, - Warning, - Error, -} - -impl NotificationVariant { - fn default_color(&self) -> &str { - match self { - NotificationVariant::Info => "#3B82F6", - NotificationVariant::Success => "#22C55E", - NotificationVariant::Warning => "#F59E0B", - NotificationVariant::Error => "#EF4444", - } - } -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct Notification { - pub title: String, - #[serde(default)] - pub message: Option, - #[serde(default)] - pub icon: Option, - #[serde(default)] - pub variant: NotificationVariant, - #[serde(default = "default_width")] - pub width: f32, - #[serde(default = "default_slide_in_at")] - pub slide_in_at: f64, - #[serde(default)] - pub slide_out_at: Option, - #[serde(default = "default_slide_duration")] - pub slide_duration: f64, - #[serde(default)] - pub accent_color: Option, - /// Timestamps at which this notification gets pushed down one slot - /// (i.e. when another notification appears above it). - #[serde(default)] - pub push_at: Vec, - /// Gap between stacked notifications in pixels (default 12). - #[serde(default = "default_stack_gap")] - pub stack_gap: f32, - /// Delay fade-in until after other notifications finish their push animation. - /// Set to true when this notification triggers a push_at on others. - #[serde(default)] - pub wait_for_push: bool, - #[serde(flatten)] - pub timing: TimingConfig, - #[serde(default)] - pub style: CssStyle, - #[serde(default)] - pub timeline: Vec, - #[serde(default)] - pub stagger: Option, -} - -rustmotion_core::impl_traits!(Notification { - Animatable => animation, - Timed => timing, - Styled => style, -}); - -impl Notification { - fn resolved_accent_color(&self) -> &str { - self.accent_color - .as_deref() - .unwrap_or_else(|| self.variant.default_color()) - } - - /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/ - /// `vh` now resolve instead of silently dropping to 0px — lot B, wave - /// S). `em`/`%` on `font-size` itself remain approximate — see - /// `crate::intrinsic::font_size_ctx`'s doc comment. - fn title_font_size(&self, ctx: &PaintCtx) -> f32 { - self.style.font_size_px_ctx( - &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), - 16.0, - ) - } - - fn message_font_size(&self, ctx: &PaintCtx) -> f32 { - self.title_font_size(ctx) * 0.85 - } - - fn make_font(&self, bold: bool, size: f32) -> Option { - let font_style = if bold { - skia_safe::FontStyle::bold() - } else { - skia_safe::FontStyle::normal() - }; - let family = self.style.font_family.as_deref().unwrap_or("Inter"); - let typeface = typeface_with_fallback(family, font_style).ok()?; - Some(skia_safe::Font::from_typeface(typeface, size)) - } - - fn compute_opacity(&self, time: f64) -> f32 { - // Effective start: if wait_for_push, delay by slide_duration so push animations finish first - let effective_start = if self.wait_for_push { - self.slide_in_at + self.slide_duration - } else { - self.slide_in_at - }; - - // Before fade in - if time < effective_start { - return 0.0; - } - - // During fade in - let fade_in_end = effective_start + self.slide_duration; - if time < fade_in_end { - let t = ((time - effective_start) / self.slide_duration) as f32; - return (t * t * (3.0 - 2.0 * t)).clamp(0.0, 1.0); // smoothstep - } - - // Check fade out - if let Some(slide_out_at) = self.slide_out_at { - if time >= slide_out_at { - let fade_out_end = slide_out_at + self.slide_duration; - if time >= fade_out_end { - return 0.0; - } - let t = ((time - slide_out_at) / self.slide_duration) as f32; - return (1.0 - t * t * (3.0 - 2.0 * t)).clamp(0.0, 1.0); - } - } - - // Fully visible - 1.0 - } - - fn render_icon_svg( - &self, - canvas: &Canvas, - icon_id: &str, - color: &str, - x: f32, - y: f32, - size: f32, - ) -> Result<()> { - let icon_w = size.round() as u32; - let icon_h = size.round() as u32; - let cache_key = format!("icon:{}:{}:{}x{}", icon_id, color, icon_w, icon_h); - - let cache = asset_cache(); - let img = if let Some(cached) = cache.get(&cache_key) { - cached.clone() - } else if let Ok(svg_data) = fetch_icon_svg(icon_id, color, icon_w, icon_h) { - let opt = usvg::Options::default(); - if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) { - let svg_size = tree.size(); - if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) { - let sx = icon_w as f32 / svg_size.width(); - let sy = icon_h as f32 / svg_size.height(); - resvg::render( - &tree, - tiny_skia::Transform::from_scale(sx, sy), - &mut pixmap.as_mut(), - ); - let img_data = skia_safe::Data::new_copy(pixmap.data()); - let info = ImageInfo::new( - (icon_w as i32, icon_h as i32), - ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, - ); - if let Some(decoded) = - skia_safe::images::raster_from_data(&info, img_data, icon_w as usize * 4) - { - cache.insert(cache_key, decoded.clone()); - decoded - } else { - return Ok(()); - } - } else { - return Ok(()); - } - } else { - return Ok(()); - } - } else { - return Ok(()); - }; - - let dst = Rect::from_xywh(x, y, size, size); - canvas.draw_image_rect(img, None, dst, &Paint::default()); - Ok(()) - } -} - -impl Notification { - fn paint( - &self, - canvas: &Canvas, - layout_w: f32, - layout_h: f32, - time: f64, - ctx: &PaintCtx, - ) -> Result<()> { - let w = layout_w; - let h = layout_h; - let opacity = self.compute_opacity(time); - - if opacity <= 0.0 { - return Ok(()); - } - - // Resolve the title font before any canvas.save() so an early return - // on font failure keeps save/restore balanced. - let Some(title_font) = self.make_font(true, self.title_font_size(ctx)) else { - return Ok(()); - }; - - // Stack offset: count how many push_at timestamps have passed, - // each one shifts this notification down by one slot with animation. - let slot_size = h + self.stack_gap; - let mut stack_y = 0.0_f32; - let transition_dur = self.slide_duration; - for &push_time in &self.push_at { - if time >= push_time { - let t = ((time - push_time) / transition_dur).clamp(0.0, 1.0) as f32; - let eased = t * t * (3.0 - 2.0 * t); // smoothstep - stack_y += slot_size * eased; - } - } - - canvas.save(); - if stack_y > 0.0 { - canvas.translate((0.0, stack_y)); - } - if opacity < 1.0 { - let mut layer_paint = Paint::default(); - layer_paint.set_alpha_f(opacity); - canvas.save_layer(&skia_safe::canvas::SaveLayerRec::default().paint(&layer_paint)); - } - - let bg_color = self.style.background_color_str().unwrap_or("#1E293B"); - let radius = self.style.border_radius_px_or(12.0); - let accent_color = self.resolved_accent_color(); - let accent_width = 4.0; - - // Background rounded rect - let bg_rect = Rect::from_xywh(0.0, 0.0, w, h); - let bg_rrect = RRect::new_rect_xy(bg_rect, radius, radius); - let mut bg_paint = paint_from_hex(bg_color); - bg_paint.set_style(PaintStyle::Fill); - bg_paint.set_anti_alias(true); - canvas.draw_rrect(bg_rrect, &bg_paint); - - // Left accent stripe - let accent_rect = Rect::from_xywh(0.0, 0.0, accent_width, h); - let accent_rrect = RRect::new_rect_radii( - accent_rect, - &[ - (radius, radius).into(), - (0.0, 0.0).into(), - (0.0, 0.0).into(), - (radius, radius).into(), - ], - ); - let mut accent_paint = paint_from_hex(accent_color); - accent_paint.set_style(PaintStyle::Fill); - accent_paint.set_anti_alias(true); - canvas.draw_rrect(accent_rrect, &accent_paint); - - // Content area - let h_pad = 16.0; - let v_pad = 16.0; - let icon_size = self.title_font_size(ctx) * 1.5; - let mut content_x = accent_width + h_pad; - - // Icon - if let Some(icon_id) = &self.icon { - let icon_y = (h - icon_size) / 2.0; - self.render_icon_svg(canvas, icon_id, accent_color, content_x, icon_y, icon_size)?; - content_x += icon_size + 12.0; - } - - // Title - let title_fs = self.title_font_size(ctx); - let emoji_font_title = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, title_fs)); - let title_color = self.style.color_str_or("#FFFFFF"); - let mut title_paint = paint_from_hex(title_color); - title_paint.set_anti_alias(true); - - let (_, title_metrics) = title_font.metrics(); - let title_y = v_pad + (-title_metrics.ascent); - - draw_text_with_fallback( - canvas, - &self.title, - &title_font, - &emoji_font_title, - 0.0, - content_x, - title_y, - &title_paint, - ); - - // Message - if let Some(message) = &self.message { - let msg_fs = self.message_font_size(ctx); - if let Some(msg_font) = self.make_font(false, msg_fs) { - let emoji_font_msg = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, msg_fs)); - let mut msg_paint = paint_from_hex("#9CA3AF"); - msg_paint.set_anti_alias(true); - - let (_, msg_metrics) = msg_font.metrics(); - let msg_y = title_y + 4.0 + title_fs * 0.3 + (-msg_metrics.ascent); - - draw_text_with_fallback( - canvas, - message, - &msg_font, - &emoji_font_msg, - 0.0, - content_x, - msg_y, - &msg_paint, - ); - } - } - - if opacity < 1.0 { - canvas.restore(); - } - canvas.restore(); - Ok(()) - } -} - -impl Painter for Notification { - fn paint_content( - &self, - canvas: &Canvas, - layout: &BoxLayout, - _props: &AnimatedProperties, - ctx: &PaintCtx, - ) { - let _ = self.paint(canvas, layout.width, layout.height, ctx.time, ctx); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rustmotion_core::css::CssStyle; - use rustmotion_core::css::Length; - - fn test_ctx() -> PaintCtx { - PaintCtx { - time: 1.0, - scenario_time: 1.0, - scene_duration: 2.0, - frame_index: 30, - fps: 30, - video_width: 400, - video_height: 200, - stagger_offset: 0.0, - } - } - - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - - #[test] - fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. - let notification = Notification { - title: "Hello".to_string(), - message: None, - icon: None, - variant: NotificationVariant::Info, - width: default_width(), - slide_in_at: 0.0, - slide_out_at: None, - slide_duration: default_slide_duration(), - accent_color: None, - push_at: Vec::new(), - stack_gap: default_stack_gap(), - wait_for_push: false, - timing: Default::default(), - style: CssStyle { - font_size: Some(Length::String("2rem".into())), - ..Default::default() - }, - timeline: Vec::new(), - stagger: None, - }; - const W: i32 = 400; - const H: i32 = 200; - let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); - { - let canvas = surface.canvas(); - notification - .paint(canvas, 360.0, 100.0, 1.0, &test_ctx()) - .expect("paint succeeds"); - } - let snapshot = surface.image_snapshot(); - let info = skia_safe::ImageInfo::new( - (W, H), - skia_safe::ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, - ); - let mut buf = vec![0u8; (W * H * 4) as usize]; - let ok = snapshot.read_pixels( - &info, - &mut buf, - (W * 4) as usize, - skia_safe::IPoint::new(0, 0), - skia_safe::image::CachingHint::Disallow, - ); - assert!(ok, "pixel read should succeed"); - // Title text is white (#FFFFFF default) on a dark #1E293B card — - // probe for near-white ink specifically. - let text_ink = buf - .as_chunks::<4>() - .0 - .iter() - .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200) - .count(); - assert!( - text_ink > 10, - "notification at font-size: 2rem must paint visible text, got {text_ink} pixels" - ); - } -} diff --git a/crates/rustmotion-components/src/number_wheel.rs b/crates/rustmotion-components/src/number_wheel.rs index ded5fbb0..ec78622b 100644 --- a/crates/rustmotion-components/src/number_wheel.rs +++ b/crates/rustmotion-components/src/number_wheel.rs @@ -61,6 +61,15 @@ fn default_wheel_easing() -> EasingType { /// An odometer-style number where each digit rolls into place. #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`number_wheel` is in the frozen-composition set (issue #333), but \ + crates/rustmotion/skills/rules/composition-recipes.md flags it (with `gauge`) \ + as a reasonable exception to keep using directly: reproducing genuine \ + per-digit scroll physics from card/text/shape primitives is mechanically \ + harder than the odometer effect itself. Kept for compatibility; scheduled for \ + removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct NumberWheel { /// The figure to land on, as written — `"30,222"`, `"5.7"`, `"98%"`. /// Digits roll; every other character (separators, signs, units) is diff --git a/crates/rustmotion-components/src/particle.rs b/crates/rustmotion-components/src/particle.rs index 535e7a95..8ff93a5f 100644 --- a/crates/rustmotion-components/src/particle.rs +++ b/crates/rustmotion-components/src/particle.rs @@ -37,6 +37,14 @@ impl Default for SizeRange { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`particle` is a frozen composition. Compose a `for-each` over N items with \ + `rand(seed, $i)` for placement and `sin($t)` for drift instead — deterministic \ + by construction. See crates/rustmotion/skills/rules/composition-recipes.md. \ + Kept for compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Particle { pub particle_type: ParticleType, #[serde(default = "default_count")] diff --git a/crates/rustmotion-components/src/pill_nav.rs b/crates/rustmotion-components/src/pill_nav.rs index 3039de35..cf2cd35f 100644 --- a/crates/rustmotion-components/src/pill_nav.rs +++ b/crates/rustmotion-components/src/pill_nav.rs @@ -44,6 +44,14 @@ pub struct PillTransition { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`pill_nav` is a frozen composition (issue #333). Compose tabs from a row of \ + `text`/`div` items plus a `shape` pill animated between their positions instead \ + — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct PillNav { pub items: Vec, #[serde(default)] diff --git a/crates/rustmotion-components/src/positioned.rs b/crates/rustmotion-components/src/positioned.rs deleted file mode 100644 index 83aa7a9b..00000000 --- a/crates/rustmotion-components/src/positioned.rs +++ /dev/null @@ -1,47 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use skia_safe::Canvas; - -use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::schema::TimelineStep; -use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; - -use crate::ChildComponent; - -/// Positioned container — children are placed at fixed absolute coordinates. -/// Like Flutter's Stack/Positioned: each child uses its `position: {x, y}` field. -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct Positioned { - #[serde(default)] - pub children: Vec, - #[serde(default)] - pub style: CssStyle, - #[serde(flatten)] - pub timing: TimingConfig, - #[serde(default)] - pub timeline: Vec, - #[serde(default)] - pub time_scale: Option, - #[serde(default)] - pub time_offset: Option, -} - -rustmotion_core::impl_traits!(Positioned { - Animatable => animation, - Timed => timing, - Styled => style, -}); - -impl Painter for Positioned { - fn paint_content( - &self, - _canvas: &Canvas, - _layout: &BoxLayout, - _props: &rustmotion_core::engine::animator::AnimatedProperties, - _ctx: &PaintCtx, - ) { - // Containers paint nothing of their own. Box decorations are - // handled by paint_pass; children are recursed by paint_tree. - } -} diff --git a/crates/rustmotion-components/src/progress.rs b/crates/rustmotion-components/src/progress.rs index 6cef46b9..fc4c691b 100644 --- a/crates/rustmotion-components/src/progress.rs +++ b/crates/rustmotion-components/src/progress.rs @@ -39,6 +39,16 @@ pub enum ProgressVariant { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`progress` is a frozen composition (issue #333). Compose a track + fill from \ + two `shape`s — a static track and a `rounded_rect` fill whose `style.width` \ + is keyframed — instead. See \ + crates/rustmotion/skills/rules/composition-recipes.md's progress-bar recipe \ + and examples/composition-progress-bars.json. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` \ + (#335)." +)] pub struct Progress { #[serde(default)] pub progress: f64, diff --git a/crates/rustmotion-components/src/rating.rs b/crates/rustmotion-components/src/rating.rs index 98efd5a4..353bea6f 100644 --- a/crates/rustmotion-components/src/rating.rs +++ b/crates/rustmotion-components/src/rating.rs @@ -32,6 +32,14 @@ fn default_animation_duration() -> f64 { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`rating` is a frozen composition (issue #333). Compose stars from repeated \ + `icon`s in a `for-each`, with a partial fill via two overlapping clipped \ + copies — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Rating { #[serde(default)] pub value: f64, diff --git a/crates/rustmotion-components/src/shape.rs b/crates/rustmotion-components/src/shape.rs index 88443163..fc58d762 100644 --- a/crates/rustmotion-components/src/shape.rs +++ b/crates/rustmotion-components/src/shape.rs @@ -3,15 +3,17 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, Paint, PaintStyle, Point}; -use rustmotion_core::css::CssStyle; +use rustmotion_core::css::{CssStyle, FrameClock}; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ build_shape_path, color4f_from_hex, draw_shape_path, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex, typeface_with_fallback, wrap_text_with_tracking, }; +use rustmotion_core::expr::{Computed, Expr, Scope}; use rustmotion_core::schema::{ - Fill, FontWeight, GradientType, ShapeText, ShapeType, Stroke, TextAlign, TimelineStep, + Fill, FontWeight, GradientType, LineCap, LineJoin, ShapeText, ShapeType, Stroke, TextAlign, + TimelineStep, }; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -40,22 +42,180 @@ rustmotion_core::impl_traits!(Shape { Styled => style, }); +/// This node's frame clock, reused as the `Scope` for both `stroke. +/// dash_offset` and any `${...}` marker in `path.data` — the same reserved +/// names (`$t`/`$T`/`$duration`/`$W`/`$H`/`$fps`) `CssStyle`'s own `opacity`/ +/// `width`/`height` expressions already resolve against, via +/// `rustmotion_core::css::computed`'s doc. Neither `$i`/`$count`/`vars`/ +/// `node(...)` is answered here: those are resolved as plain text before +/// this component ever deserializes (`for-each`'s own binding pass, at +/// load), so a marker or `dash_offset` naming one of them either arrives +/// already substituted or was never valid in the first place. +fn frame_clock(ctx: &PaintCtx) -> FrameClock { + FrameClock { + t: ctx.time, + t_abs: ctx.scenario_time, + duration: ctx.scene_duration, + width: ctx.video_width as f64, + height: ctx.video_height as f64, + fps: ctx.fps as f64, + } +} + +fn skia_line_cap(cap: LineCap) -> skia_safe::PaintCap { + match cap { + LineCap::Butt => skia_safe::PaintCap::Butt, + LineCap::Round => skia_safe::PaintCap::Round, + LineCap::Square => skia_safe::PaintCap::Square, + } +} + +fn skia_line_join(join: LineJoin) -> skia_safe::PaintJoin { + match join { + LineJoin::Miter => skia_safe::PaintJoin::Miter, + LineJoin::Round => skia_safe::PaintJoin::Round, + LineJoin::Bevel => skia_safe::PaintJoin::Bevel, + } +} + +/// Resolve `stroke.dash_offset` against this frame's `scope`. Absent +/// (`None`) and a failed expression both fall back to `0.0` — the same +/// phase every dashed stroke had before this field existed — rather than +/// aborting the whole shape over one bad phase value; see `render_shape_ +/// text` below for the same swallow-and-degrade convention already used in +/// this file, applied to a different `Result`. +fn resolve_dash_offset(offset: &Option>, scope: &dyn Scope) -> f32 { + match offset { + None => 0.0, + Some(Computed::Literal(v)) => *v, + Some(Computed::Expr(src)) => Expr::parse(src) + .and_then(|e| e.eval(scope)) + .map(|v| v as f32) + .unwrap_or(0.0), + } +} + +/// Resolve every `${...}` marker in a string into a literal, each marker's +/// contents evaluated as an expression (`rustmotion_core::expr`) against +/// `scope`. Used for both `path.data` (`"M ${...} ${...} L ..."`) and a +/// `fill`/`stroke` hex color built from computed channels +/// (`"#${r|02x}${g|02x}${b|02x}"`) — same mechanism, two string shapes. +/// +/// Why markers, not the whole string as one `Computed`: both +/// `path.data` and a hex color are prose with numbers embedded in them, not +/// a single value, and an expression only ever produces one `f64` — folding +/// the *entire* string as `"= ..."` could at best replace it with one +/// number, never a multi-point path or a `#rrggbb` triplet. `${...}` scopes +/// the expression grammar down to exactly the substrings that are numbers, +/// leaving the surrounding syntax (path-command letters, the `#` and digit +/// grouping) untouched. +/// +/// A marker is `${expr}` (decimal, 4 places — what `path.data`'s +/// coordinates want) or `${expr|02x}` (the value rounded, clamped to +/// `0..=255`, and formatted as a zero-padded lowercase hex byte — what a +/// color channel wants). The `|` splits the two unambiguously: the +/// expression grammar's ternary is the only place it uses `:`, and it never +/// uses `|` at all. Any other format spec is a named failure, not a guess. +/// +/// A `for-each`'s own `$i`/`$count`/item-field substitution +/// (`crate::variables`/`crate::expand`) already ran, textually, over the +/// whole document before this component ever deserialized — so by the time +/// a marker reaches here it holds only arithmetic plus whatever +/// `$t`/`$T`/`$W`/`$H`/`$fps`/`$duration` it still names, which `scope` +/// (this node's `FrameClock`) answers. +/// +/// Returns `Ok(None)` when the string has no marker at all — the common, +/// zero-cost case; the caller keeps using it unchanged. Returns +/// `Ok(Some(resolved))` on success. Returns `Err(())` when a marker is +/// unterminated, its expression fails to parse or evaluate, or its format +/// spec is unrecognized: unlike `resolve_dash_offset`, this is not +/// swallowed to a placeholder, because splicing a wrong value into the +/// *middle* of path or color syntax can silently produce a +/// plausible-looking but wrong result (a stray point, an off-hue facet) +/// rather than an obviously-broken one — the caller skips painting instead. +fn resolve_template(s: &str, scope: &dyn Scope) -> std::result::Result, ()> { + if !s.contains("${") { + return Ok(None); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + loop { + match rest.find("${") { + None => { + out.push_str(rest); + break; + } + Some(start) => { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let end = after.find('}').ok_or(())?; + let marker = &after[..end]; + let (src, format_spec) = match marker.rsplit_once('|') { + Some((src, spec)) => (src, Some(spec)), + None => (marker, None), + }; + let value = Expr::parse(src) + .map_err(|_| ())? + .eval(scope) + .map_err(|_| ())?; + match format_spec { + None => out.push_str(&format!("{value:.4}")), + Some("02x") => { + let byte = value.round().clamp(0.0, 255.0) as i64; + out.push_str(&format!("{byte:02x}")); + } + Some(_) => return Err(()), + } + rest = &after[end + 1..]; + } + } + } + Ok(Some(out)) +} + impl Painter for Shape { fn paint_content( &self, canvas: &Canvas, layout: &BoxLayout, props: &AnimatedProperties, - _ctx: &PaintCtx, + ctx: &PaintCtx, ) { let w = layout.width; let h = layout.height; let corner_radius = self.style.border_radius_px(); + // `resolved_path` only ever holds something when `self.shape` is a + // `ShapeType::Path` whose `data` carries a `${...}` marker — see + // `resolve_template`'s own doc. Every other shape (and a `Path` + // with no marker) leaves it `None` and `shape` below is a plain + // reference to `self.shape`, unchanged from before this existed. + let clock = frame_clock(ctx); + let mut resolved_path = None; + if let ShapeType::Path { data } = &self.shape { + match resolve_template(data, &clock) { + Ok(Some(resolved)) => resolved_path = Some(ShapeType::Path { data: resolved }), + Ok(None) => {} + // A marker didn't parse or evaluate: nothing valid to paint + // this frame rather than a shape built from a half-spliced + // string — see `resolve_template`'s doc for why this is not + // swallowed to a placeholder the way `dash_offset` is. + Err(()) => return, + } + } + let shape = resolved_path.as_ref().unwrap_or(&self.shape); + if let Some(fill) = &self.fill { - let mut paint = match fill { - Fill::Solid(color) => paint_from_hex(color), - Fill::Gradient(gradient) => { + let paint: Option = match fill { + // A `${...}`-marked solid color (e.g. a per-facet + // `"#${r|02x}${g|02x}${b|02x}"` computed from an angle) that + // fails to resolve just skips painting the fill — the + // stroke/text below are independent layers and still paint. + Fill::Solid(color) => match resolve_template(color, &clock) { + Ok(resolved) => Some(paint_from_hex(resolved.as_deref().unwrap_or(color))), + Err(()) => None, + }, + Fill::Gradient(gradient) => Some({ let colors: Vec = gradient .colors .iter() @@ -120,10 +280,12 @@ impl Painter for Shape { paint.set_dither(true); } paint - } + }), }; - paint.set_style(PaintStyle::Fill); - draw_shape_path(canvas, &self.shape, 0.0, 0.0, w, h, corner_radius, &paint); + if let Some(mut paint) = paint { + paint.set_style(PaintStyle::Fill); + draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint); + } } if let Some(stroke) = &self.stroke { @@ -135,9 +297,21 @@ impl Painter for Shape { stroke.width }; paint.set_stroke_width(stroke_w); + paint.set_stroke_cap(skia_line_cap(stroke.line_cap)); + paint.set_stroke_join(skia_line_join(stroke.line_join)); - if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { - if let Some(path) = build_shape_path(&self.shape, 0.0, 0.0, w, h, corner_radius) { + // An explicit `dashed` pattern is a deliberate authoring choice + // — honour it (plus its `dash_offset` phase) over the + // `draw_progress` reveal below, which would otherwise overwrite + // the same `Paint::path_effect` slot with its own synthetic + // two-interval dash and silently discard the author's pattern. + if let Some(intervals) = stroke.dashed.as_ref().filter(|v| v.len() >= 2) { + let phase = resolve_dash_offset(&stroke.dash_offset, &clock); + if let Some(dash) = skia_safe::PathEffect::dash(intervals, phase) { + paint.set_path_effect(dash); + } + } else if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { + if let Some(path) = build_shape_path(shape, 0.0, 0.0, w, h, corner_radius) { let mut measure = skia_safe::PathMeasure::new(&path, false, None); let path_len = measure.length(); if path_len > 0.0 { @@ -150,7 +324,7 @@ impl Painter for Shape { } } - draw_shape_path(canvas, &self.shape, 0.0, 0.0, w, h, corner_radius, &paint); + draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint); } if let Some(text) = &self.text { diff --git a/crates/rustmotion-components/src/skeleton.rs b/crates/rustmotion-components/src/skeleton.rs index 0106344f..ea1f77a5 100644 --- a/crates/rustmotion-components/src/skeleton.rs +++ b/crates/rustmotion-components/src/skeleton.rs @@ -37,6 +37,14 @@ pub enum SkeletonVariant { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`skeleton` is a frozen composition (issue #333). Compose a loading placeholder \ + from a plain `shape`/`card` with a `shimmer` finish instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md and \ + rules/text-polish.md's `shimmer`. Kept for compatibility; scheduled for \ + removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Skeleton { #[serde(default)] pub variant: SkeletonVariant, diff --git a/crates/rustmotion-components/src/slider.rs b/crates/rustmotion-components/src/slider.rs index c24f2966..34a7a9d2 100644 --- a/crates/rustmotion-components/src/slider.rs +++ b/crates/rustmotion-components/src/slider.rs @@ -52,6 +52,14 @@ fn default_thumb_color() -> String { /// treating local (0,0) as the thumb's top-left. #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(from = "SliderRaw")] +#[deprecated( + since = "0.7.1", + note = "`slider` is a frozen composition (issue #333), the same recipe as `switch`: \ + compose two `shape`s (track + thumb) with the thumb's position keyframed \ + instead — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Slider { #[serde(default = "default_slider_value")] pub value: f64, diff --git a/crates/rustmotion-components/src/sparkline.rs b/crates/rustmotion-components/src/sparkline.rs index da125f3f..d3b405fa 100644 --- a/crates/rustmotion-components/src/sparkline.rs +++ b/crates/rustmotion-components/src/sparkline.rs @@ -31,6 +31,14 @@ fn default_animation_duration() -> f64 { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`sparkline` is a frozen composition (issue #333). Compose a tiny trend line \ + from an `svg` polyline (or a `line` component) over normalized data points \ + instead — see crates/rustmotion/skills/rules/composition-recipes.md. Kept for \ + compatibility; scheduled for removal in a future major version via \ + `rustmotion migrate` (#335)." +)] pub struct Sparkline { pub data: Vec, #[serde(default = "default_color")] diff --git a/crates/rustmotion-components/src/stat.rs b/crates/rustmotion-components/src/stat.rs index fe181b1f..057325d0 100644 --- a/crates/rustmotion-components/src/stat.rs +++ b/crates/rustmotion-components/src/stat.rs @@ -51,6 +51,14 @@ pub struct StatTrend { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`stat` is a frozen composition (issue #333). Compose a `card` + `icon` + \ + `text` (value, large) + `text` (label, small) instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md's \"KPI card\" recipe \ + and examples/composition-kpi-row.json. Kept for compatibility; scheduled for \ + removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Stat { pub value: String, #[serde(default)] diff --git a/crates/rustmotion-components/src/stepper.rs b/crates/rustmotion-components/src/stepper.rs index 080646fc..ebdcaa4f 100644 --- a/crates/rustmotion-components/src/stepper.rs +++ b/crates/rustmotion-components/src/stepper.rs @@ -56,6 +56,15 @@ pub struct StepItem { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`stepper` is a frozen composition (issue #333). Compose `card` circles \ + (nodes) + `text` labels + `shape` connectors instead, one `for-each` item \ + emitting a node and its trailing connector as sibling output — see \ + crates/rustmotion/skills/rules/composition-recipes.md's step-flow recipe and \ + examples/composition-step-flow.json. Kept for compatibility; scheduled for \ + removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Stepper { /// The steps to display. pub steps: Vec, diff --git a/crates/rustmotion-components/src/success_check.rs b/crates/rustmotion-components/src/success_check.rs index 239118a4..4a1bb1f8 100644 --- a/crates/rustmotion-components/src/success_check.rs +++ b/crates/rustmotion-components/src/success_check.rs @@ -39,6 +39,15 @@ fn default_check_duration() -> f64 { /// A checkmark that draws itself inside a halo. #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`success_check` is a frozen composition (issue #333). Compose an `svg` \ + checkmark (`reveal: \"stroke\"` draw-on) inside a `shape` circle halo with a \ + pop-in scale instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` \ + (#335)." +)] pub struct SuccessCheck { /// Diameter of the halo in px. The stroke scales with it. #[serde(default = "default_check_size")] diff --git a/crates/rustmotion-components/src/switch.rs b/crates/rustmotion-components/src/switch.rs index 0d6f2a94..067a222c 100644 --- a/crates/rustmotion-components/src/switch.rs +++ b/crates/rustmotion-components/src/switch.rs @@ -44,6 +44,14 @@ fn default_transition_duration() -> f64 { /// `box_builder.rs` ever sees this component, without touching that file. #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(from = "SwitchRaw")] +#[deprecated( + since = "0.7.1", + note = "`switch` is a frozen composition (issue #333). Compose a toggle from two \ + `shape`s (track + thumb) with the thumb's position keyframed instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` \ + (#335)." +)] pub struct Switch { #[serde(default)] pub value: bool, diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs index 06e90ff4..eaa3c191 100644 --- a/crates/rustmotion-components/src/table.rs +++ b/crates/rustmotion-components/src/table.rs @@ -37,6 +37,13 @@ fn default_show_borders() -> bool { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`table` is a frozen composition. Compose a `for-each` over the rows inside a \ + grid-styled container instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Table { pub headers: Vec, pub rows: Vec>, diff --git a/crates/rustmotion-components/src/tag_cloud.rs b/crates/rustmotion-components/src/tag_cloud.rs index 93fdf21e..d56f61ba 100644 --- a/crates/rustmotion-components/src/tag_cloud.rs +++ b/crates/rustmotion-components/src/tag_cloud.rs @@ -42,6 +42,14 @@ pub struct TagItem { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`tag_cloud` is a frozen composition (issue #333). Compose weighted tags from a \ + `for-each` over `text` nodes with `font-size` scaled by weight instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` \ + (#335)." +)] pub struct TagCloud { pub tags: Vec, #[serde(default = "default_min_font_size")] diff --git a/crates/rustmotion-components/src/terminal.rs b/crates/rustmotion-components/src/terminal.rs deleted file mode 100644 index be6224ec..00000000 --- a/crates/rustmotion-components/src/terminal.rs +++ /dev/null @@ -1,549 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use skia_safe::{Canvas, PaintStyle, RRect, Rect}; - -use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::animator::{ease, AnimatedProperties}; -use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::{ - draw_text_with_fallback, emoji_typeface, paint_from_hex, resolve_custom_typeface, - typeface_with_fallback, -}; -use rustmotion_core::schema::{CodeblockReveal, RevealMode, TimelineStep}; -use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -#[derive(Default)] -pub enum TerminalLineType { - Prompt, - Command, - #[default] - Output, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct TerminalLine { - pub text: String, - #[serde(default)] - pub line_type: TerminalLineType, - #[serde(default)] - pub color: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -#[derive(Default)] -pub enum TerminalTheme { - #[default] - Dark, - Light, -} - -impl TerminalTheme { - fn bg(&self) -> &str { - match self { - TerminalTheme::Dark => "#1E1E1E", - TerminalTheme::Light => "#F5F5F5", - } - } - - fn chrome_bg(&self) -> &str { - match self { - TerminalTheme::Dark => "#2D2D2D", - TerminalTheme::Light => "#E5E5E5", - } - } - - fn prompt_color(&self) -> &str { - match self { - TerminalTheme::Dark => "#22C55E", - TerminalTheme::Light => "#16A34A", - } - } - - fn command_color(&self) -> &str { - match self { - TerminalTheme::Dark => "#FFFFFF", - TerminalTheme::Light => "#000000", - } - } - - fn output_color(&self) -> &str { - match self { - TerminalTheme::Dark => "#A0A0A0", - TerminalTheme::Light => "#555555", - } - } - - fn title_color(&self) -> &str { - match self { - TerminalTheme::Dark => "#808080", - TerminalTheme::Light => "#666666", - } - } -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct Terminal { - pub lines: Vec, - #[serde(default)] - pub theme: TerminalTheme, - #[serde(default)] - pub title: Option, - #[serde(default = "default_show_chrome")] - pub show_chrome: bool, - #[serde(default)] - pub reveal: Option, - /// When rendered content overflows the box vertically, scroll up so the - /// last revealed line stays visible. Default: `true`. Set to `false` to - /// require all lines fit — the geometry validator will fail otherwise. - /// Font size is never reduced. - #[serde(default = "default_auto_scroll")] - pub auto_scroll: bool, - #[serde(flatten)] - pub timing: TimingConfig, - #[serde(default)] - pub style: CssStyle, - #[serde(default)] - pub timeline: Vec, - #[serde(default)] - pub stagger: Option, -} - -fn default_show_chrome() -> bool { - true -} - -fn default_auto_scroll() -> bool { - true -} - -rustmotion_core::impl_traits!(Terminal { - Animatable => animation, - Timed => timing, - Styled => style, -}); - -pub const CORNER_RADIUS: f32 = 10.0; -pub(crate) const CHROME_HEIGHT: f32 = 36.0; -pub(crate) const FONT_SIZE: f32 = 14.0; -pub(crate) const LINE_HEIGHT: f32 = 22.0; -pub(crate) const PADDING: f32 = 16.0; - -/// The typeface both the painter and the intrinsic measurement resolve. -/// -/// These must never diverge. The measurement reserves the box the painter then -/// fills, so a different face on either side produces text that overflows a box -/// the geometry validator has already declared safe — the exact failure mode the -/// audit found across the text components. Both sides used to hardcode -/// `"SF Mono"` independently, which agreed only by coincidence and ignored an -/// explicit `font-family` outright, custom or not. -pub(crate) fn resolve_typeface(style: &CssStyle) -> Option { - let font_style = skia_safe::FontStyle::normal(); - let family = style.font_family_or("SF Mono"); - resolve_custom_typeface(family, font_style) - .or_else(|| typeface_with_fallback(family, font_style).ok()) -} - -impl Terminal { - /// `font_size` is resolved once by the caller (`paint`, against a real - /// `LengthContext`) and threaded through here and [`Self::line_height`] - /// instead of each independently re-deriving it via the context-free - /// `font_size_px_or` — four separate call sites used to do exactly that, - /// which is how a relative unit could silently diverge between them - /// (lot B, wave S). - fn make_font(&self, font_size: f32) -> Option { - let typeface = resolve_typeface(&self.style)?; - Some(skia_safe::Font::from_typeface(typeface, font_size)) - } - - fn line_height(&self, font_size: f32) -> f32 { - (font_size * LINE_HEIGHT / FONT_SIZE).ceil() - } - - /// Get the prefix string for a line type. - fn line_prefix(line_type: &TerminalLineType) -> &'static str { - match line_type { - TerminalLineType::Prompt => "$ ", - TerminalLineType::Command | TerminalLineType::Output => "", - } - } - - /// Compute reveal visibility: (visible_lines, partial_chars_on_last_line, last_line_opacity) - fn compute_reveal(&self, time: f64) -> (usize, Option, f32) { - let total_lines = self.lines.len(); - if total_lines == 0 { - return (0, None, 1.0); - } - - let reveal = match &self.reveal { - None => return (total_lines, None, 1.0), - Some(r) => r, - }; - - if time < reveal.start { - return (0, None, 1.0); - } - - let raw_progress = ((time - reveal.start) / reveal.duration).clamp(0.0, 1.0); - let progress = ease(raw_progress, &reveal.easing); - - match reveal.mode { - RevealMode::Typewriter => { - // Count total characters including prefixes - let total_chars: usize = self - .lines - .iter() - .map(|l| Self::line_prefix(&l.line_type).len() + l.text.len()) - .sum(); - - let visible_chars = (total_chars as f64 * progress).round() as usize; - let mut chars_remaining = visible_chars; - let mut visible_lines = 0; - let mut partial_chars = None; - - for line in &self.lines { - let line_chars = Self::line_prefix(&line.line_type).len() + line.text.len(); - if chars_remaining >= line_chars { - chars_remaining -= line_chars; - visible_lines += 1; - } else { - visible_lines += 1; - partial_chars = Some(chars_remaining); - break; - } - } - - (visible_lines, partial_chars, 1.0) - } - RevealMode::LineByLine => { - let visible_f = total_lines as f64 * progress; - let full_lines = visible_f.floor() as usize; - let fractional = (visible_f - full_lines as f64) as f32; - - if full_lines >= total_lines { - (total_lines, None, 1.0) - } else { - (full_lines + 1, None, fractional.max(0.01)) - } - } - } - } -} - -impl Terminal { - fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) { - let time = ctx.time; - let w = layout_w; - let h = layout_h; - - // Resolved once, against the real per-frame viewport (`rem`/`vw`/ - // `vh` on `font-size` now resolve instead of silently dropping to - // 0px — lot B, wave S) and threaded through every call below that - // used to independently re-derive it. - let font_size = self.style.font_size_px_ctx( - &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), - FONT_SIZE, - ); - - // Background - let bg_rect = Rect::from_xywh(0.0, 0.0, w, h); - let bg_rrect = RRect::new_rect_xy(bg_rect, CORNER_RADIUS, CORNER_RADIUS); - let mut bg_paint = paint_from_hex(self.theme.bg()); - bg_paint.set_style(PaintStyle::Fill); - bg_paint.set_anti_alias(true); - canvas.draw_rrect(bg_rrect, &bg_paint); - - // Resolve the terminal font up front — bail before any canvas.save() - // so save/restore stays balanced if no font is available. - let Some(font) = self.make_font(font_size) else { - return; - }; - - // Always clip content to the rounded box so scrolled lines fade - // cleanly at the edges and never escape the device viewport. - canvas.save(); - canvas.clip_rrect(bg_rrect, skia_safe::ClipOp::Intersect, true); - - let mut y_offset = 0.0; - - // Chrome (title bar) - if self.show_chrome { - // Chrome background - let chrome_rect = Rect::from_xywh(0.0, 0.0, w, CHROME_HEIGHT); - canvas.save(); - canvas.clip_rrect(bg_rrect, skia_safe::ClipOp::Intersect, true); - let mut chrome_paint = paint_from_hex(self.theme.chrome_bg()); - chrome_paint.set_style(PaintStyle::Fill); - canvas.draw_rect(chrome_rect, &chrome_paint); - canvas.restore(); - - // Traffic light dots - let dot_colors = ["#FF5F57", "#FEBC2E", "#28C840"]; - let dot_y = CHROME_HEIGHT / 2.0; - for (i, color) in dot_colors.iter().enumerate() { - let dot_x = 14.0 + i as f32 * 20.0; - let mut dot_paint = paint_from_hex(color); - dot_paint.set_style(PaintStyle::Fill); - dot_paint.set_anti_alias(true); - canvas.draw_circle((dot_x, dot_y), 6.0, &dot_paint); - } - - // Title - if let Some(title) = &self.title { - let emoji_font = - emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); - let mut title_paint = paint_from_hex(self.theme.title_color()); - title_paint.set_anti_alias(true); - let title_w = rustmotion_core::engine::renderer::measure_text_with_fallback( - title, - &font, - &emoji_font, - 0.0, - ); - let x = (w - title_w) / 2.0; - let (_, metrics) = font.metrics(); - let y = CHROME_HEIGHT / 2.0 + (-metrics.ascent) / 2.0; - draw_text_with_fallback(canvas, title, &font, &emoji_font, 0.0, x, y, &title_paint); - } - - y_offset = CHROME_HEIGHT; - } - - // Compute reveal visibility - let (visible_lines, partial_chars, last_line_opacity) = self.compute_reveal(time); - - // Lines - let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); - let (_, metrics) = font.metrics(); - let ascent = -metrics.ascent; - - y_offset += PADDING; - - // auto_scroll: when the rendered content is taller than the box, - // translate the lines upward so the latest revealed line stays in - // view. We open a nested clip below the chrome so scrolled lines - // never bleed onto the title bar. - let chrome_h = if self.show_chrome { CHROME_HEIGHT } else { 0.0 }; - canvas.save(); - canvas.clip_rect( - Rect::from_xywh(0.0, chrome_h, w, h - chrome_h), - skia_safe::ClipOp::Intersect, - true, - ); - if self.auto_scroll { - let line_h = self.line_height(font_size); - let content_h = visible_lines as f32 * line_h + PADDING * 2.0 + chrome_h; - let overflow = content_h - h; - if overflow > 0.0 { - canvas.translate((0.0, -overflow)); - } - } - - for (i, line) in self.lines.iter().enumerate() { - if i >= visible_lines { - break; - } - - let is_last_visible = i == visible_lines - 1; - let opacity = if is_last_visible { - last_line_opacity - } else { - 1.0 - }; - - let prefix = Self::line_prefix(&line.line_type); - let (prefix_color, text_color) = match line.line_type { - TerminalLineType::Prompt => (self.theme.prompt_color(), self.theme.prompt_color()), - TerminalLineType::Command => ("", self.theme.command_color()), - TerminalLineType::Output => ("", self.theme.output_color()), - }; - - let color = line.color.as_deref().unwrap_or(text_color); - let y = y_offset + ascent; - let mut x = PADDING; - - // Determine what to draw based on partial_chars for typewriter mode - let (draw_prefix, draw_text) = if is_last_visible { - if let Some(char_limit) = partial_chars { - // Truncate: prefix first, then text - let prefix_len = prefix.len(); - if char_limit <= prefix_len { - // Only partial prefix visible - let partial: String = prefix.chars().take(char_limit).collect(); - (partial, String::new()) - } else { - // Full prefix + partial text - let text_chars = char_limit - prefix_len; - let partial: String = line.text.chars().take(text_chars).collect(); - (prefix.to_string(), partial) - } - } else { - (prefix.to_string(), line.text.clone()) - } - } else { - (prefix.to_string(), line.text.clone()) - }; - - // Draw prompt prefix - if !draw_prefix.is_empty() { - let mut prefix_paint = paint_from_hex(prefix_color); - prefix_paint.set_anti_alias(true); - prefix_paint.set_alpha_f(opacity); - let prefix_w = rustmotion_core::engine::renderer::measure_text_with_fallback( - &draw_prefix, - &font, - &emoji_font, - 0.0, - ); - draw_text_with_fallback( - canvas, - &draw_prefix, - &font, - &emoji_font, - 0.0, - x, - y, - &prefix_paint, - ); - x += prefix_w + 2.0; - } - - // Draw text - if !draw_text.is_empty() { - let mut text_paint = paint_from_hex(color); - text_paint.set_anti_alias(true); - text_paint.set_alpha_f(opacity); - let text_w = rustmotion_core::engine::renderer::measure_text_with_fallback( - &draw_text, - &font, - &emoji_font, - 0.0, - ); - draw_text_with_fallback( - canvas, - &draw_text, - &font, - &emoji_font, - 0.0, - x, - y, - &text_paint, - ); - x += text_w; - } - - // Blinking cursor on the last visible line during typewriter reveal - if is_last_visible && self.reveal.is_some() && partial_chars.is_some() { - let blink = ((time * 2.0) as i32) % 2 == 0; - if blink { - let cursor_w = font_size * 0.55; - let cursor_h = font_size * 1.2; - let cursor_y = y - font_size; - let cursor_rect = Rect::from_xywh(x + 1.0, cursor_y, cursor_w, cursor_h); - let mut cursor_paint = paint_from_hex(self.theme.command_color()); - cursor_paint.set_style(PaintStyle::Fill); - cursor_paint.set_anti_alias(true); - canvas.draw_rect(cursor_rect, &cursor_paint); - } - } - - y_offset += self.line_height(font_size); - } - - canvas.restore(); // close inner content clip - canvas.restore(); // close outer rrect clip - } -} - -impl Painter for Terminal { - fn paint_content( - &self, - canvas: &Canvas, - layout: &BoxLayout, - _props: &AnimatedProperties, - ctx: &PaintCtx, - ) { - self.paint(canvas, layout.width, layout.height, ctx); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - - #[test] - fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`, at all four call sites that used - // to independently re-derive it in `paint`. - let terminal = Terminal { - lines: vec![TerminalLine { - text: "hello world".to_string(), - line_type: TerminalLineType::Output, - color: None, - }], - theme: TerminalTheme::default(), - title: None, - show_chrome: false, - reveal: None, - auto_scroll: true, - timing: Default::default(), - style: CssStyle { - font_size: Some(rustmotion_core::css::Length::String("2rem".into())), - ..Default::default() - }, - timeline: Vec::new(), - stagger: None, - }; - const W: i32 = 400; - const H: i32 = 200; - let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); - let ctx = PaintCtx { - time: 0.0, - scenario_time: 0.0, - scene_duration: 1.0, - frame_index: 0, - fps: 30, - video_width: 400, - video_height: 200, - stagger_offset: 0.0, - }; - { - let canvas = surface.canvas(); - terminal.paint(canvas, W as f32, H as f32, &ctx); - } - let snapshot = surface.image_snapshot(); - let info = skia_safe::ImageInfo::new( - (W, H), - skia_safe::ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, - ); - let mut buf = vec![0u8; (W * H * 4) as usize]; - let ok = snapshot.read_pixels( - &info, - &mut buf, - (W * 4) as usize, - skia_safe::IPoint::new(0, 0), - skia_safe::image::CachingHint::Disallow, - ); - assert!(ok, "pixel read should succeed"); - // Background always paints (opaque bg_rrect), so probe for text ink - // specifically: pixels that are not the dark theme background color - // (#1E1E1E) and not fully transparent. - let text_ink = buf - .as_chunks::<4>() - .0 - .iter() - .filter(|p| p[3] > 0 && !(p[0] < 40 && p[1] < 40 && p[2] < 40)) - .count(); - assert!( - text_ink > 20, - "terminal at font-size: 2rem must paint visible text ink, got {text_ink} pixels" - ); - } -} diff --git a/crates/rustmotion-components/src/timeline.rs b/crates/rustmotion-components/src/timeline.rs index d51a0660..5dab7590 100644 --- a/crates/rustmotion-components/src/timeline.rs +++ b/crates/rustmotion-components/src/timeline.rs @@ -14,6 +14,14 @@ use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; /// A horizontal or vertical pipeline/timeline component. #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`timeline` is a frozen composition (issue #333), the same recipe as \ + `stepper`: compose `card`/`shape` nodes and connectors instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md's step-flow recipe and \ + examples/composition-step-flow.json. Kept for compatibility; scheduled for \ + removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Timeline { /// The steps in the pipeline. pub steps: Vec, diff --git a/crates/rustmotion-components/src/tooltip.rs b/crates/rustmotion-components/src/tooltip.rs index 432c1690..118864f1 100644 --- a/crates/rustmotion-components/src/tooltip.rs +++ b/crates/rustmotion-components/src/tooltip.rs @@ -42,6 +42,13 @@ pub enum TooltipArrow { } #[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[deprecated( + since = "0.7.1", + note = "`tooltip` is a frozen composition (issue #333). Compose a small rounded `card` + \ + a rotated triangle `shape` (the arrow) + `text` instead — see \ + crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \ + scheduled for removal in a future major version via `rustmotion migrate` (#335)." +)] pub struct Tooltip { pub text: String, #[serde(default)] diff --git a/crates/rustmotion-components/tests/audit_ws_i.rs b/crates/rustmotion-components/tests/audit_ws_i.rs index 46a94cbd..ea66e2e1 100644 --- a/crates/rustmotion-components/tests/audit_ws_i.rs +++ b/crates/rustmotion-components/tests/audit_ws_i.rs @@ -20,6 +20,7 @@ use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; fn single_child_scene(json: serde_json::Value) -> ChildComponent { let component: Component = serde_json::from_value(json).expect("deserialize component"); ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -324,9 +325,9 @@ fn cascaded_components_match_the_verified_set() { // guarantee says nothing about an *existing* variant silently drifting // (a copy-paste that drops one into the wrong arm, or a "cleanup" that // moves one back) — this test is what catches that, by covering all - // sixty variants directly rather than a sample. + // fifty-seven variants directly rather than a sample. // - // The eighteen `true` cases (beyond `text`, already fixed by a prior + // The sixteen `true` cases (beyond `text`, already fixed by a prior // pass) were found by reading every `impl Painter for X` in this crate // for a direct `self.style.color`/`font_family`/`font_size`/ // `font_weight`/`font_style` read with no cascade in between — not by @@ -397,11 +398,6 @@ fn cascaded_components_match_the_verified_set() { true, serde_json::json!({"type":"marquee","content":"x"}), ), - ( - "notification", - true, - serde_json::json!({"type":"notification","title":"x"}), - ), ( "number_wheel", true, @@ -417,11 +413,6 @@ fn cascaded_components_match_the_verified_set() { true, serde_json::json!({"type":"table","headers":["A"],"rows":[]}), ), - ( - "terminal", - true, - serde_json::json!({"type":"terminal","lines":[]}), - ), ( "tooltip", true, @@ -454,11 +445,6 @@ fn cascaded_components_match_the_verified_set() { serde_json::json!({"type":"gif","src":"x.gif"}), ), ("cursor", false, serde_json::json!({"type":"cursor"})), - ( - "codeblock", - false, - serde_json::json!({"type":"codeblock","code":"x"}), - ), ( "connector", false, @@ -577,8 +563,8 @@ fn cascaded_components_match_the_verified_set() { assert_eq!( cases.len(), - 60, - "this table must cover every Component variant (currently 60) — the \ + 57, + "this table must cover every Component variant (currently 57) — the \ compiler enforces that with_cascaded_style itself classifies every \ variant, but only this list enforces that the classification stays \ the one this workstream verified" @@ -766,10 +752,6 @@ fn newly_cascaded_components_inherit_unset_typography_from_the_parent() { "marquee", serde_json::json!({"type":"marquee","content":"x"}), ), - ( - "notification", - serde_json::json!({"type":"notification","title":"x"}), - ), ( "number_wheel", serde_json::json!({"type":"number_wheel","value":"1"}), @@ -782,10 +764,6 @@ fn newly_cascaded_components_inherit_unset_typography_from_the_parent() { "table", serde_json::json!({"type":"table","headers":["A"],"rows":[]}), ), - ( - "terminal", - serde_json::json!({"type":"terminal","lines":[]}), - ), ("tooltip", serde_json::json!({"type":"tooltip","text":"x"})), ]; diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index 59ecf7b9..4e71899e 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -27,6 +27,7 @@ fn render_caption(json: serde_json::Value, time: f64) -> Vec { fn render_caption_at(json: serde_json::Value, time: f64, y: f32) -> Vec { let component: Component = serde_json::from_value(json).expect("deserialize caption"); let child = ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 0.0, y }), x: None, diff --git a/crates/rustmotion-components/tests/codeblock_auto_scroll.rs b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs deleted file mode 100644 index e1668a31..00000000 --- a/crates/rustmotion-components/tests/codeblock_auto_scroll.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Pixel test for codeblock `auto_scroll` during a typewriter reveal -//! (audit finding #4). -//! -//! Routes a `codeblock` through the real pipeline (box_builder, run_layout, -//! paint_tree) exactly like `caption_presets.rs` does, and counts painted -//! (non-background) pixels at several points during a typewriter reveal. -//! Before the fix, `scroll_offset` was computed from the *full* code's -//! natural height regardless of how many lines the reveal had actually -//! painted, so the box stayed empty for the majority of the reveal — the -//! newly-revealed lines were translated above the clip and invisible until -//! the reveal caught up with that constant offset. - -use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; -use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; -use rustmotion_components::{ChildComponent, Component, PositionMode}; -use rustmotion_core::css::taffy_bridge::ConversionContext; -use rustmotion_core::engine::layout_pass::run_layout; -use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; - -const W: u32 = 1000; -const H: u32 = 600; -const SCENE_DURATION: f64 = 4.5; - -fn codeblock_json() -> serde_json::Value { - let lines: Vec = (0..40).map(|i| format!("let line_{i} = {i};")).collect(); - let code = lines.join("\n"); - serde_json::json!({ - "type": "codeblock", - "code": code, - "language": "rust", - "auto_scroll": true, - "reveal": { "mode": "typewriter", "start": 0.0, "duration": 4.0 }, - "style": { "width": "900px", "height": "300px", "font-size": 20 } - }) -} - -/// Count pixels whose color differs measurably from the codeblock's own -/// dark background (`#2b303b` when unset) — i.e. actual glyph ink, not just -/// "anything non-transparent" (the background rect itself is opaque and -/// covers the whole box). -fn text_ink_pixels(buf: &[u8]) -> usize { - // Background is #2b303b ~ (43, 48, 59). Count pixels that deviate from - // that by a wide margin in any channel — syntect's theme colors are all - // much brighter than the near-black background. - buf.as_chunks::<4>() - .0 - .iter() - .filter(|p| { - let (r, g, b, a) = (p[0] as i32, p[1] as i32, p[2] as i32, p[3] as i32); - a > 200 && ((r - 43).abs() > 40 || (g - 48).abs() > 40 || (b - 59).abs() > 40) - }) - .count() -} - -fn render_codeblock_at(time: f64) -> Vec { - let component: Component = serde_json::from_value(codeblock_json()).expect("deserialize"); - let child = ChildComponent { - component, - position: Some(PositionMode::Absolute { x: 50.0, y: 150.0 }), - x: None, - y: None, - z_index: None, - bleed: false, - }; - let children = vec![child]; - - let mut surface = - skia_safe::surfaces::raster_n32_premul((W as i32, H as i32)).expect("raster surface"); - let canvas = surface.canvas(); - canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); - - let built = build_scene_with_anim( - &children, - (W as f32, H as f32), - BuildAnimationCtx { - time, - scenario_time: time, - scene_duration: SCENE_DURATION, - fps: 30, - }, - ); - let layout = run_layout( - &built.root, - (W as f32, H as f32), - &ConversionContext::default(), - ); - let dispatcher = LegacyPaintDispatcher::for_scene(&built); - let frame = PaintFrame { - time, - scenario_time: time, - frame_index: (time * 30.0) as u32, - fps: 30, - video_width: W, - video_height: H, - scene_duration: SCENE_DURATION, - camera: None, - }; - paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); - - let row_bytes = W as usize * 4; - let mut pixels = vec![0u8; row_bytes * H as usize]; - let info = skia_safe::ImageInfo::new( - (W as i32, H as i32), - skia_safe::ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, - ); - surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); - pixels -} - -#[test] -fn early_reveal_shows_text_not_an_empty_box() { - // At t=0.5s (12.5% into a 4s typewriter reveal, well past the first - // line), some text must already be visible — before the fix, this - // stayed at 0 ink pixels until ~75% of the reveal had elapsed. - let ink = text_ink_pixels(&render_codeblock_at(0.5)); - assert!( - ink > 20, - "expected visible text ink early in the reveal (t=0.5s), got {ink} ink pixels" - ); -} - -#[test] -fn mid_reveal_shows_text_not_an_empty_box() { - // t=2.0s: 50% into the reveal — well within the range the audit - // measured as a completely empty box (0 ink pixels at t=0.5/1.0/2.0). - let ink = text_ink_pixels(&render_codeblock_at(2.0)); - assert!( - ink > 20, - "expected visible text ink mid-reveal (t=2.0s), got {ink} ink pixels" - ); -} - -#[test] -fn ink_grows_monotonically_enough_across_the_reveal() { - // Sanity: as more of the reveal completes, more text should be on - // screen (loosely monotonic — auto_scroll keeps only the visible - // window, so it won't be strictly increasing forever, but the box must - // never regress to empty once text has started appearing). - let t_early = text_ink_pixels(&render_codeblock_at(0.5)); - let t_late = text_ink_pixels(&render_codeblock_at(3.9)); - assert!(t_early > 0, "must have visible ink at t=0.5s, got 0"); - assert!(t_late > 0, "must have visible ink at t=3.9s, got 0"); -} diff --git a/crates/rustmotion-components/tests/degenerate_inputs.rs b/crates/rustmotion-components/tests/degenerate_inputs.rs index fb950e56..f12ad6c6 100644 --- a/crates/rustmotion-components/tests/degenerate_inputs.rs +++ b/crates/rustmotion-components/tests/degenerate_inputs.rs @@ -27,6 +27,7 @@ const H: u32 = 300; fn paint(json: serde_json::Value, time: f64) { let component: Component = serde_json::from_value(json).expect("component is schema-valid"); let children = vec![ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -153,35 +154,3 @@ fn dot_map_terminates_on_a_zero_dot_spacing() { "dot_map with dot_spacing: 0", ); } - -#[test] -fn codeblock_diff_survives_multibyte_text() { - // The edit script counts bytes while the reveal interpolates a fraction of - // that count, so mid-animation offsets landed inside a multi-byte character - // and `replace_range` aborted with "not a char boundary". - for (from, to) in [ - ("let a = 1;", "let café = «héllo→»;"), - ("let x = 1;", "let y = \"éàü\";"), - ("a", "日本語のテキスト"), - ("ok", "🎬 clap"), - ] { - // Sweep the reveal: the panic only fires on the frames where progress - // lands part-way through a glyph. - for step in 0..=20 { - paint( - serde_json::json!({ - "type": "codeblock", - "code": from, - "language": "rust", - "diff": true, - "states": [ - { "code": from, "at": 0.0 }, - { "code": to, "at": 0.4, "cursor": { "enabled": true } } - ], - "style": { "width": 380, "height": 120 } - }), - f64::from(step) * 0.1, - ); - } - } -} diff --git a/crates/rustmotion-components/tests/relative_font_size.rs b/crates/rustmotion-components/tests/relative_font_size.rs index c40e8fe0..5aff28eb 100644 --- a/crates/rustmotion-components/tests/relative_font_size.rs +++ b/crates/rustmotion-components/tests/relative_font_size.rs @@ -22,6 +22,7 @@ const H: u32 = 300; fn render(json: serde_json::Value) -> Vec { let component: Component = serde_json::from_value(json).expect("deserialize component"); let child = ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 20.0, y: 20.0 }), x: None, diff --git a/crates/rustmotion-components/tests/text_autofit.rs b/crates/rustmotion-components/tests/text_autofit.rs index 358ee843..14387a83 100644 --- a/crates/rustmotion-components/tests/text_autofit.rs +++ b/crates/rustmotion-components/tests/text_autofit.rs @@ -3,7 +3,6 @@ //! `TextIntrinsic`/`Text::paint` unit-level tests in `intrinsic.rs`/ //! `text.rs`. Routes a `text` component through the same //! `build_scene_with_anim` → `run_layout` → `paint_tree` sequence -//! `codeblock_auto_scroll.rs` uses — the same sequence //! `render_with_new_pipeline_iter` runs once per rendered frame in the real //! encoder. This is the strongest available proof that `TextIntrinsic:: //! measure` (which determines the box `run_layout` reserves) and @@ -45,6 +44,7 @@ fn render_at(content: &str, autofit: bool, time: f64) -> Vec { let component: Component = serde_json::from_value(text_json(content, autofit)).expect("deserialize"); let child = ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: BOX_X, y: BOX_Y }), x: None, diff --git a/crates/rustmotion-components/tests/transition_interpolation.rs b/crates/rustmotion-components/tests/transition_interpolation.rs index 4e02d9d2..29972618 100644 --- a/crates/rustmotion-components/tests/transition_interpolation.rs +++ b/crates/rustmotion-components/tests/transition_interpolation.rs @@ -45,6 +45,7 @@ fn div_with_transition( fn css_at(json: serde_json::Value, time: f64) -> CssStyle { let component: Component = serde_json::from_value(json).expect("deserialize component"); let child = ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, diff --git a/crates/rustmotion-core/Cargo.toml b/crates/rustmotion-core/Cargo.toml index a0160e8f..b785b605 100644 --- a/crates/rustmotion-core/Cargo.toml +++ b/crates/rustmotion-core/Cargo.toml @@ -19,8 +19,6 @@ rayon = "1" dashmap = "6" thiserror = "2" ureq = "3" -syntect = { version = "5", default-features = false, features = ["default-fancy"] } -similar = "2" qrcode = "0.14" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } notify = "7" diff --git a/crates/rustmotion-core/src/audio/dsp.rs b/crates/rustmotion-core/src/audio/dsp.rs new file mode 100644 index 00000000..019c05e1 --- /dev/null +++ b/crates/rustmotion-core/src/audio/dsp.rs @@ -0,0 +1,454 @@ +//! Low-level, dependency-free DSP primitives: the four oscillator +//! waveforms, a white-noise generator, a biquad filter (RBJ "Audio EQ +//! Cookbook" coefficients), an ADSR envelope, and the two master-bus +//! processors (compressor, limiter). Nothing here knows about [`super::voices::Voice`] +//! or [`super::score::Score`] — those layer the JSON schema and the +//! event timeline on top of these plain functions. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// One period of phase, `[0, 1)`, mapped to a bipolar sample in `[-1, 1]`. +pub fn sine(phase: f32) -> f32 { + (phase * std::f32::consts::TAU).sin() +} + +/// 50% duty cycle: `+1` for the first half of the period, `-1` for the second. +pub fn square(phase: f32) -> f32 { + if phase < 0.5 { + 1.0 + } else { + -1.0 + } +} + +/// A rising ramp from `-1` to `+1` across the period. Naive (not +/// band-limited) — acceptable aliasing for a small synth with no +/// broadcast-grade anti-aliasing requirement. +pub fn saw(phase: f32) -> f32 { + 2.0 * phase - 1.0 +} + +/// `-1` at `phase = 0`, rising linearly to `+1` at `phase = 0.5`, falling +/// back to `-1` at `phase = 1`. +pub fn triangle(phase: f32) -> f32 { + if phase < 0.5 { + 4.0 * phase - 1.0 + } else { + 3.0 - 4.0 * phase + } +} + +/// Deterministic white-noise source: a 32-bit xorshift PRNG (Marsaglia), +/// seeded explicitly so two renders of the same score reproduce the exact +/// same noise samples — a `rand`-style thread-seeded generator would break +/// the "two renders are byte-identical" guarantee this whole module exists +/// to uphold. +#[derive(Debug, Clone, Copy)] +pub struct Xorshift32 { + state: u32, +} + +impl Xorshift32 { + /// A seed of `0` is a fixed point of xorshift (it would only ever + /// produce `0`), so it is remapped to a fixed nonzero constant. + pub fn new(seed: u32) -> Self { + Xorshift32 { + state: if seed == 0 { 0x9E37_79B9 } else { seed }, + } + } + + pub fn next_u32(&mut self) -> u32 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.state = x; + x + } + + /// A sample uniformly distributed in `[-1, 1]`. + pub fn next_f32(&mut self) -> f32 { + (self.next_u32() as f32 / u32::MAX as f32) * 2.0 - 1.0 + } +} + +/// Oscillator waveform a [`super::voices::Voice`] selects — the sine/ +/// square/saw/triangle quartet, plus `noise` for a non-tonal voice (hats, +/// snares). Lives here rather than in `voices.rs` because it is exactly +/// the vocabulary [`sine`]/[`square`]/[`saw`]/[`triangle`]/[`Xorshift32`] +/// above already speak. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum OscKind { + Sine, + Square, + Saw, + Triangle, + Noise, +} + +/// The four biquad topologies this synth implements — deliberately just +/// these four (deliverable #2's list), no shelving/peaking EQ. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FilterKind { + Lowpass, + Highpass, + Bandpass, + Notch, +} + +/// A biquad's per-sample memory (Direct Form I): the last two inputs and +/// the last two outputs. Separate from [`BiquadCoeffs`] so one coefficient +/// set could in principle drive several independent states — not needed +/// today (each [`super::voices::Voice`] owns exactly one filter), but it +/// keeps "what changes per sample" and "what is fixed for the voice" +/// apart. +#[derive(Debug, Clone, Copy, Default)] +pub struct BiquadState { + x1: f32, + x2: f32, + y1: f32, + y2: f32, +} + +/// Normalized (`a0 = 1`) biquad coefficients for one of [`FilterKind`]'s +/// four topologies, computed from the RBJ "Audio EQ Cookbook" formulas. +#[derive(Debug, Clone, Copy)] +pub struct BiquadCoeffs { + b0: f32, + b1: f32, + b2: f32, + a1: f32, + a2: f32, +} + +impl BiquadCoeffs { + /// `freq` is clamped below Nyquist and `q` away from zero so a + /// carelessly authored score (a filter freq at or above half the + /// sample rate, or `q: 0`) cannot divide by zero or fold the + /// coefficients into `NaN` — it is silently made safe rather than + /// rejected, the same posture [`super::voices::Voice::render_grain`] + /// takes for its own inputs. + pub fn design(kind: FilterKind, freq: f32, q: f32, sample_rate: f32) -> Self { + let freq = freq.clamp(1.0, sample_rate * 0.499); + let q = q.max(0.01); + let w0 = std::f32::consts::TAU * freq / sample_rate; + let cos_w0 = w0.cos(); + let sin_w0 = w0.sin(); + let alpha = sin_w0 / (2.0 * q); + + let (b0, b1, b2, a0, a1, a2) = match kind { + FilterKind::Lowpass => { + let b1 = 1.0 - cos_w0; + let b0 = b1 / 2.0; + (b0, b1, b0, 1.0 + alpha, -2.0 * cos_w0, 1.0 - alpha) + } + FilterKind::Highpass => { + let b1 = -(1.0 + cos_w0); + let b0 = (1.0 + cos_w0) / 2.0; + (b0, b1, b0, 1.0 + alpha, -2.0 * cos_w0, 1.0 - alpha) + } + FilterKind::Bandpass => (alpha, 0.0, -alpha, 1.0 + alpha, -2.0 * cos_w0, 1.0 - alpha), + FilterKind::Notch => ( + 1.0, + -2.0 * cos_w0, + 1.0, + 1.0 + alpha, + -2.0 * cos_w0, + 1.0 - alpha, + ), + }; + + BiquadCoeffs { + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + } + } + + /// Direct Form I: `y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]`. + pub fn process(&self, state: &mut BiquadState, x: f32) -> f32 { + let y = self.b0 * x + self.b1 * state.x1 + self.b2 * state.x2 + - self.a1 * state.y1 + - self.a2 * state.y2; + state.x2 = state.x1; + state.x1 = x; + state.y2 = state.y1; + state.y1 = y; + y + } +} + +/// An attack/decay/sustain/hold/release envelope, in seconds (`sustain` is +/// a level, `0..=1`). There is no note-off in this synth's score model +/// (every score event is a trigger instant, never an on/off pair — see +/// `super::score::ScoreEvent`), so `hold` stands in for "how long to sit +/// at the sustain level" before `release` brings it back to zero: a voice +/// always finishes on its own, which is what lets +/// [`super::voices::Voice::render_grain`] size a finite buffer up front. +/// The common case (`sustain: 0`, `hold: 0`, `release: 0`, the drum-machine +/// defaults `super::voices::Voice` gives every field but `decay`) collapses +/// this to a plain attack/decay percussive shape. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Adsr { + pub attack: f32, + pub decay: f32, + pub sustain: f32, + pub hold: f32, + pub release: f32, +} + +impl Adsr { + pub fn total_duration(&self) -> f32 { + self.attack.max(0.0) + self.decay.max(0.0) + self.hold.max(0.0) + self.release.max(0.0) + } + + /// The envelope's linear gain at `t` seconds after the trigger. + pub fn level_at(&self, t: f32) -> f32 { + if t < 0.0 { + return 0.0; + } + let a = self.attack.max(0.0); + let d = self.decay.max(0.0); + let h = self.hold.max(0.0); + let r = self.release.max(0.0); + let sustain = self.sustain.clamp(0.0, 1.0); + + if t < a { + if a <= 0.0 { + 1.0 + } else { + t / a + } + } else if t < a + d { + let dt = (t - a) / d.max(1e-9); + 1.0 + (sustain - 1.0) * dt + } else if t < a + d + h { + sustain + } else if t < a + d + h + r { + let dt = (t - (a + d + h)) / r.max(1e-9); + sustain * (1.0 - dt) + } else { + 0.0 + } + } +} + +/// `master.compressor` (deliverable #1's `{"threshold": -14, "ratio": 4}`): +/// a standard feedforward compressor, `threshold`/gain in dB, `ratio` as +/// `N:1`. `attack`/`release` are exposed as knobs but not part of the +/// issue's example — [`super::score::CompressorConfig`] defaults them to +/// 5ms/50ms. +#[derive(Debug, Clone, Copy)] +pub struct CompressorParams { + pub threshold_db: f32, + pub ratio: f32, + pub attack_ms: f32, + pub release_ms: f32, +} + +fn db_to_lin(db: f32) -> f32 { + 10f32.powf(db / 20.0) +} + +fn lin_to_db(lin: f32) -> f32 { + 20.0 * lin.max(1e-9).log10() +} + +/// One-pole envelope-follower smoothing coefficient for a given time +/// constant: the classic `exp(-1 / (time_seconds * sample_rate))`. `ms +/// <= 0` is instantaneous (no smoothing at all). +fn time_const_coeff(ms: f32, sample_rate: f32) -> f32 { + if ms <= 0.0 { + 0.0 + } else { + (-1.0 / (ms / 1000.0 * sample_rate)).exp() + } +} + +/// Feedforward compressor, applied in place to a mono buffer. A peak +/// envelope follower (fast on the way up per `attack_ms`, slow on the way +/// down per `release_ms`) drives the gain-reduction curve above +/// `threshold_db`. +pub fn apply_compressor(buffer: &mut [f32], params: CompressorParams, sample_rate: u32) { + let sr = sample_rate as f32; + let attack_coeff = time_const_coeff(params.attack_ms, sr); + let release_coeff = time_const_coeff(params.release_ms, sr); + let ratio = params.ratio.max(1.0); + let mut envelope = 0.0f32; + + for sample in buffer.iter_mut() { + let input_level = sample.abs(); + let coeff = if input_level > envelope { + attack_coeff + } else { + release_coeff + }; + envelope = coeff * envelope + (1.0 - coeff) * input_level; + + let gain = if envelope > 1e-9 { + let envelope_db = lin_to_db(envelope); + let over_db = envelope_db - params.threshold_db; + if over_db > 0.0 { + let gain_reduction_db = over_db * (1.0 - 1.0 / ratio); + db_to_lin(-gain_reduction_db) + } else { + 1.0 + } + } else { + 1.0 + }; + *sample *= gain; + } +} + +/// The ceiling [`apply_limiter`] holds every sample under. Deliberately +/// below the issue's own "-0.1 dBTP" acceptance bound (not equal to it): +/// this is a sample-peak limiter, not a true-peak (oversampled) one, so a +/// reconstruction filter downstream can still overshoot a ceiling set +/// exactly at the bound. -0.3 dB gives that margin. +pub const LIMITER_CEILING_DB: f32 = -0.3; + +/// Master-bus peak limiter, applied in place to a mono buffer — deliverable +/// #4/#5: on by default, and the reason it exists at all (the SVG reel's +/// author found his first mix clipping at 0 dB only by reading `ffmpeg` +/// output after the fact). A fast attack / slower release envelope +/// follower drives a soft gain reduction, and every sample is *also* hard +/// clamped to `[-ceiling, ceiling]` afterwards — belt and braces: the +/// smoothed gain alone cannot guarantee zero overshoot on a sample that +/// jumps before the envelope catches up, and this function's entire +/// purpose is that guarantee, not an approximation of it. +pub fn apply_limiter(buffer: &mut [f32], sample_rate: u32) { + let ceiling = db_to_lin(LIMITER_CEILING_DB); + let sr = sample_rate as f32; + let attack_coeff = time_const_coeff(1.0, sr); + let release_coeff = time_const_coeff(50.0, sr); + let mut envelope = 0.0f32; + + for sample in buffer.iter_mut() { + let input_level = sample.abs(); + let coeff = if input_level > envelope { + attack_coeff + } else { + release_coeff + }; + envelope = coeff * envelope + (1.0 - coeff) * input_level; + + let gain = if envelope > ceiling && envelope > 1e-9 { + ceiling / envelope + } else { + 1.0 + }; + *sample = (*sample * gain).clamp(-ceiling, ceiling); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oscillators_are_bounded_and_hit_their_landmarks() { + assert!((sine(0.0)).abs() < 1e-6); + assert!((sine(0.25) - 1.0).abs() < 1e-6); + assert_eq!(square(0.0), 1.0); + assert_eq!(square(0.75), -1.0); + assert!((saw(0.0) - -1.0).abs() < 1e-6); + assert!((saw(1.0) - 1.0).abs() < 1e-6); + assert!((triangle(0.0) - -1.0).abs() < 1e-6); + assert!((triangle(0.5) - 1.0).abs() < 1e-6); + } + + #[test] + fn xorshift32_is_deterministic_for_a_given_seed() { + let mut a = Xorshift32::new(42); + let mut b = Xorshift32::new(42); + let seq_a: Vec = (0..64).map(|_| a.next_f32()).collect(); + let seq_b: Vec = (0..64).map(|_| b.next_f32()).collect(); + assert_eq!(seq_a, seq_b); + assert!(seq_a.iter().all(|&s| (-1.0..=1.0).contains(&s))); + } + + #[test] + fn xorshift32_seed_zero_does_not_lock_up() { + let mut z = Xorshift32::new(0); + let vals: Vec = (0..8).map(|_| z.next_u32()).collect(); + assert!(vals.iter().any(|&v| v != 0)); + } + + #[test] + fn lowpass_attenuates_a_tone_well_above_cutoff_more_than_one_well_below() { + let sr = 48_000.0f32; + let coeffs = BiquadCoeffs::design(FilterKind::Lowpass, 500.0, 0.707, sr); + + let rms_after = |freq: f32| -> f32 { + let mut state = BiquadState::default(); + let n = 4800usize; + let mut acc = 0.0f64; + for i in 0..n { + let phase = (i as f32 * freq / sr).fract(); + let x = sine(phase); + let y = coeffs.process(&mut state, x); + if i > n / 2 { + acc += (y as f64) * (y as f64); + } + } + ((acc / (n / 2) as f64).sqrt()) as f32 + }; + + let low = rms_after(100.0); + let high = rms_after(8000.0); + assert!( + high < low * 0.5, + "8kHz through a 500Hz lowpass ({high}) should be much quieter than 100Hz ({low})" + ); + } + + #[test] + fn adsr_percussive_default_reaches_silence_after_attack_plus_decay() { + let env = Adsr { + attack: 0.01, + decay: 0.1, + sustain: 0.0, + hold: 0.0, + release: 0.0, + }; + assert_eq!(env.level_at(-1.0), 0.0); + assert!(env.level_at(0.0) < env.level_at(0.01)); + assert!((env.level_at(0.01) - 1.0).abs() < 1e-4); + assert!(env.level_at(0.05) < 1.0); + assert_eq!(env.level_at(0.11), 0.0); + assert!((env.total_duration() - 0.11).abs() < 1e-6); + } + + #[test] + fn compressor_reduces_gain_above_threshold_and_leaves_quiet_signal_alone() { + let sr = 48_000; + let mut loud = vec![0.9f32; 4800]; + let mut quiet = vec![0.05f32; 4800]; + let params = CompressorParams { + threshold_db: -12.0, + ratio: 4.0, + attack_ms: 1.0, + release_ms: 20.0, + }; + apply_compressor(&mut loud, params, sr); + apply_compressor(&mut quiet, params, sr); + assert!(loud.last().unwrap() < &0.9); + assert!((quiet.last().unwrap() - 0.05).abs() < 1e-4); + } + + #[test] + fn limiter_never_exceeds_its_ceiling_even_on_a_full_scale_step() { + let sr = 48_000; + let mut buf = vec![1.0f32; 4800]; + buf[0] = 1.0; + apply_limiter(&mut buf, sr); + let ceiling = db_to_lin(LIMITER_CEILING_DB); + assert!(buf.iter().all(|&s| s.abs() <= ceiling + 1e-6)); + } +} diff --git a/crates/rustmotion-core/src/audio/mod.rs b/crates/rustmotion-core/src/audio/mod.rs new file mode 100644 index 00000000..3a027e66 --- /dev/null +++ b/crates/rustmotion-core/src/audio/mod.rs @@ -0,0 +1,30 @@ +//! Declarative audio synthesis (issue #331): a scenario carries its own +//! soundtrack, synthesised from oscillators/noise/filters on the *same* +//! beat grid as the cuts — no audio file required. An LLM can author a +//! [`score::Score`] (a JSON object of `voices` + `score` + `master`); it +//! cannot hand over a WAV. +//! +//! - [`dsp`] — dependency-free primitives: oscillators, a biquad filter, +//! an ADSR envelope, a compressor, a limiter. +//! - [`voices`] — [`voices::Voice`], one instrument definition, and the +//! code that renders one trigger of it into a grain of samples. +//! - [`score`] — [`score::Score`]/[`score::ScoreEvent`], the JSON schema +//! for the timeline, and the [`crate::schema::time::TimePoint`] +//! resolution that expands a repeating event into concrete hit instants. +//! - [`synth`] — [`synth::render`], the single entry point that ties the +//! above together into a finished, mixed, mastered buffer. +//! +//! `schema::scenario::AudioConfig` (the object form of `Scenario::audio`) +//! is this module's only caller inside `rustmotion-core`; the offline +//! render-to-WAV-then-mux bridge lives in `rustmotion`'s `encode` crate, +//! which joins the synthesised buffer into the existing file-based +//! [`crate::schema::AudioTrack`] mixer rather than replacing it. + +pub mod dsp; +pub mod score; +pub mod synth; +pub mod voices; + +pub use score::{CompressorConfig, MasterBus, Score, ScoreError, ScoreEvent}; +pub use synth::{render, SYNTH_SAMPLE_RATE}; +pub use voices::{FilterKind, FilterSpec, FreqSpec, OscKind, Voice}; diff --git a/crates/rustmotion-core/src/audio/score.rs b/crates/rustmotion-core/src/audio/score.rs new file mode 100644 index 00000000..a7fe1356 --- /dev/null +++ b/crates/rustmotion-core/src/audio/score.rs @@ -0,0 +1,413 @@ +//! [`Score`]: the declarative timeline — `voices` (see +//! [`super::voices::Voice`]) plus a list of [`ScoreEvent`]s that trigger +//! them, resolved against the scenario's own beat grid via +//! [`crate::schema::time::TimePoint`]/[`crate::schema::time::TimeCtx`] (the +//! frozen contract this whole workstream shares with `Scene::at`). + +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::schema::time::{TimeCtx, TimeError, TimePoint}; + +use super::voices::Voice; + +/// One entry of `score`: trigger `voice` once at `at`, or repeatedly every +/// `every` between `from` (default: the scenario's start) and `to` +/// (default: the scenario's end), phase-shifted by `offset`. Exactly one +/// of `at`/`every` must be given — [`Score::resolve_hits`] rejects an +/// event with neither. +/// +/// `at`/`from`/`to` are **instants**: resolved with the scenario's real +/// `bpm`/`beat_offset`, exactly like [`crate::schema::Scene::at`]. `every`/ +/// `offset` are **durations**: also [`TimePoint`]s, resolved through the +/// same [`TimeCtx`] machinery (the frozen contract — nothing here reshapes +/// `TimePoint`/`TimeCtx`), but against a context whose `beat_offset` is +/// forced to zero first (see [`resolve_duration`]'s doc) — `beat_offset` is +/// a *phase*, and must not multiply into how long one beat lasts. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ScoreEvent { + /// Key into [`Score::voices`]. + pub voice: String, + #[serde(default)] + pub at: Option, + #[serde(default)] + pub every: Option, + #[serde(default)] + pub from: Option, + #[serde(default)] + pub to: Option, + #[serde(default)] + pub offset: Option, + /// Per-event gain override, applied on top of the voice's own `gain`. + /// Linear, not dB (unlike [`CompressorConfig::threshold`] and + /// [`MasterBus::gain`]) — matching [`Voice::gain`], which this + /// multiplies against. + #[serde(default)] + pub gain: Option, +} + +fn default_compressor_attack_ms() -> f32 { + 5.0 +} + +fn default_compressor_release_ms() -> f32 { + 50.0 +} + +/// `master.compressor` — the issue's `{"threshold": -14, "ratio": 4}`. +/// `threshold` is in dB, `ratio` is the `N` of an `N:1` ratio. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CompressorConfig { + pub threshold: f32, + pub ratio: f32, + #[serde(default = "default_compressor_attack_ms")] + pub attack: f32, + #[serde(default = "default_compressor_release_ms")] + pub release: f32, +} + +fn default_limiter_on() -> bool { + true +} + +/// The score's master bus: an optional gain trim (dB), an optional +/// compressor, and the limiter — deliverable #4/#5, **on by default** +/// (`limiter` defaults to `true`; omitting `master` entirely still applies +/// it). See [`super::dsp::apply_limiter`]'s doc for why it is a hard +/// guarantee, not a best-effort setting. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MasterBus { + #[serde(default)] + pub gain: Option, + #[serde(default)] + pub compressor: Option, + #[serde(default = "default_limiter_on")] + pub limiter: bool, +} + +impl Default for MasterBus { + fn default() -> Self { + MasterBus { + gain: None, + compressor: None, + limiter: true, + } + } +} + +/// The full synth block — `Scenario::audio`'s object form +/// (`schema::scenario::AudioConfig`) carries one of these directly. See +/// the issue's JSON example: `voices` + `score` + `master`, with `bpm`/ +/// `beat_offset` living one level up on `AudioConfig` (and, when absent +/// there, on the scenario itself — see `AudioConfig::as_score`'s caller in +/// `rustmotion`'s `encode` crate for exactly how that fallback works). +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Score { + #[serde(default)] + pub voices: HashMap, + #[serde(default)] + pub score: Vec, + #[serde(default)] + pub master: MasterBus, +} + +/// Everything that can go wrong turning a [`Score`] into a set of hit +/// instants. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum ScoreError { + #[error("score event references unknown voice '{0}' (not declared in `voices`)")] + UnknownVoice(String), + #[error("score event for voice '{voice}' has neither 'at' nor 'every' — one is required")] + MissingTiming { voice: String }, + #[error( + "score event for voice '{voice}': 'every' resolves to a non-positive interval ({interval}s)" + )] + NonPositiveInterval { voice: String, interval: f64 }, + #[error( + "score event for voice '{voice}' would produce more than {limit} hits — \ + check 'every'/'from'/'to' for a runaway repeat" + )] + TooManyHits { voice: String, limit: usize }, + #[error("score event for voice '{voice}': {source}")] + Time { + voice: String, + #[source] + source: TimeError, + }, +} + +/// Safety cap on how many times a single [`ScoreEvent`] may repeat, so a +/// typo like `"every": "1ms"` over a multi-minute scenario fails fast with +/// a named error instead of allocating a multi-gigabyte hit list. +const MAX_HITS_PER_EVENT: usize = 100_000; + +impl Score { + pub fn is_empty(&self) -> bool { + self.voices.is_empty() && self.score.is_empty() + } + + /// Every event resolved into a flat list of absolute-scenario-seconds + /// hit instants, grouped by voice name. `scenario_duration` is the + /// default `to` for a repeating event that never names one (the `hat` + /// voice in the issue's example) — see the caller in `rustmotion`'s + /// `encode` crate for how that duration is computed. + pub fn resolve_hits( + &self, + ctx: &TimeCtx, + scenario_duration: f64, + ) -> Result>, ScoreError> { + let mut hits: HashMap> = HashMap::new(); + for event in &self.score { + if !self.voices.contains_key(&event.voice) { + return Err(ScoreError::UnknownVoice(event.voice.clone())); + } + let times = resolve_event_hits(event, ctx, scenario_duration)?; + hits.entry(event.voice.clone()).or_default().extend(times); + } + Ok(hits) + } +} + +/// `at`/`from`/`to`: instants, resolved with the real `beat_offset` — +/// identical semantics to [`crate::schema::Scene::at`] (score events are +/// never nested inside a scene, so `ctx.scene_start` is always `0.0` and +/// `resolve_relative`/`resolve_absolute` agree). +fn resolve_instant(tp: &TimePoint, ctx: &TimeCtx, voice: &str) -> Result { + tp.resolve_relative(ctx).map_err(|source| ScoreError::Time { + voice: voice.to_string(), + source, + }) +} + +/// `every`/`offset`: durations. `TimePoint::eval_spec`'s beat term is +/// `beat_offset + n * 60/bpm` — correct for "where does beat `n` land" +/// (an instant), wrong for "how long is `n` beats" (a duration), which +/// must not carry `beat_offset` at all. Reuses the exact same +/// [`TimePoint::resolve_relative`] the instant fields go through (the +/// frozen contract: `TimePoint`/`TimeCtx` are not reshaped), just against a +/// [`TimeCtx`] copy with `beat_offset` zeroed — `bpm`, the only thing that +/// actually determines a beat's length, is untouched, so `{"every": "1b"}` +/// still means exactly one beat of the scenario's own grid. +fn resolve_duration(tp: &TimePoint, ctx: &TimeCtx, voice: &str) -> Result { + let duration_ctx = TimeCtx { + beat_offset: 0.0, + ..*ctx + }; + tp.resolve_relative(&duration_ctx) + .map_err(|source| ScoreError::Time { + voice: voice.to_string(), + source, + }) +} + +fn resolve_event_hits( + event: &ScoreEvent, + ctx: &TimeCtx, + scenario_duration: f64, +) -> Result, ScoreError> { + if let Some(at) = &event.at { + let t = resolve_instant(at, ctx, &event.voice)?; + return Ok(vec![t]); + } + + let Some(every) = &event.every else { + return Err(ScoreError::MissingTiming { + voice: event.voice.clone(), + }); + }; + let interval = resolve_duration(every, ctx, &event.voice)?; + if interval <= 0.0 { + return Err(ScoreError::NonPositiveInterval { + voice: event.voice.clone(), + interval, + }); + } + + let from = match &event.from { + Some(tp) => resolve_instant(tp, ctx, &event.voice)?, + None => 0.0, + }; + let offset = match &event.offset { + Some(tp) => resolve_duration(tp, ctx, &event.voice)?, + None => 0.0, + }; + let to = match &event.to { + Some(tp) => resolve_instant(tp, ctx, &event.voice)?, + None => scenario_duration, + }; + + let start = from + offset; + let mut hits = Vec::new(); + let mut t = start; + while t <= to + 1e-9 { + if t >= 0.0 { + hits.push(t); + } + if hits.len() > MAX_HITS_PER_EVENT { + return Err(ScoreError::TooManyHits { + voice: event.voice.clone(), + limit: MAX_HITS_PER_EVENT, + }); + } + t += interval; + } + Ok(hits) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(bpm: Option, beat_offset: f64) -> TimeCtx { + TimeCtx { + bpm, + beat_offset, + scene_start: 0.0, + } + } + + fn tp(s: &str) -> TimePoint { + TimePoint::Spec(s.to_string()) + } + + #[test] + fn every_1b_is_one_beat_regardless_of_beat_offset() { + // bpm=120 -> one beat is 0.5s. A nonzero beat_offset must not leak + // into the *interval* length (only into where "from" lands). + let c = ctx(Some(120.0), 2.2); + let d = resolve_duration(&tp("1b"), &c, "kick").unwrap(); + assert!( + (d - 0.5).abs() < 1e-9, + "expected exactly one beat (0.5s), got {d}" + ); + } + + #[test] + fn issues_two_voice_example_kick_lands_on_every_beat_from_2_2s_to_12_6s() { + let c = ctx(Some(115.0), 0.0); + let event = ScoreEvent { + voice: "kick".to_string(), + at: None, + every: Some(tp("1b")), + from: Some(tp("@2.2s")), + to: Some(tp("@12.6s")), + offset: None, + gain: None, + }; + let hits = resolve_event_hits(&event, &c, 999.0).unwrap(); + let beat = 60.0 / 115.0; + assert!((hits[0] - 2.2).abs() < 1e-9); + assert!((hits[1] - (2.2 + beat)).abs() < 1e-9); + assert!(*hits.last().unwrap() <= 12.6 + 1e-6); + assert!(*hits.last().unwrap() > 12.6 - beat); + } + + #[test] + fn hat_with_no_to_plays_until_the_given_scenario_duration() { + let c = ctx(Some(115.0), 0.0); + let event = ScoreEvent { + voice: "hat".to_string(), + at: None, + every: Some(tp("1b")), + from: Some(tp("@2.2s")), + to: None, + offset: Some(tp("0.5b")), + gain: None, + }; + let hits = resolve_event_hits(&event, &c, 10.0).unwrap(); + let beat = 60.0 / 115.0; + assert!((hits[0] - (2.2 + 0.5 * beat)).abs() < 1e-9); + assert!(*hits.last().unwrap() <= 10.0 + 1e-6); + } + + #[test] + fn event_with_neither_at_nor_every_is_a_named_error() { + let c = ctx(Some(115.0), 0.0); + let event = ScoreEvent { + voice: "kick".to_string(), + at: None, + every: None, + from: None, + to: None, + offset: None, + gain: None, + }; + assert_eq!( + resolve_event_hits(&event, &c, 10.0), + Err(ScoreError::MissingTiming { + voice: "kick".to_string() + }) + ); + } + + #[test] + fn unknown_voice_reference_is_a_named_error() { + let score = Score { + voices: HashMap::new(), + score: vec![ScoreEvent { + voice: "ghost".to_string(), + at: Some(tp("1s")), + every: None, + from: None, + to: None, + offset: None, + gain: None, + }], + master: MasterBus::default(), + }; + let c = ctx(None, 0.0); + assert_eq!( + score.resolve_hits(&c, 10.0), + Err(ScoreError::UnknownVoice("ghost".to_string())) + ); + } + + #[test] + fn runaway_interval_is_capped_not_infinite() { + let c = ctx(None, 0.0); + let event = ScoreEvent { + voice: "kick".to_string(), + at: None, + every: Some(TimePoint::Seconds(0.0001)), + from: None, + to: None, + offset: None, + gain: None, + }; + let err = resolve_event_hits(&event, &c, 100.0).unwrap_err(); + assert!(matches!(err, ScoreError::TooManyHits { .. })); + } + + #[test] + fn master_defaults_to_limiter_on_with_no_compressor() { + let m = MasterBus::default(); + assert!(m.limiter); + assert!(m.compressor.is_none()); + } + + #[test] + fn score_deserializes_from_the_issues_json_shape() { + let json = serde_json::json!({ + "voices": { + "kick": { "type": "sine", "freq": [150, 42], "sweep": 0.14, "decay": 0.38 }, + "hat": { "type": "noise", "filter": {"type":"highpass","freq":7500}, "decay": 0.05 } + }, + "score": [ + { "voice": "kick", "every": "1b", "from": "@2.2s", "to": "@12.6s" }, + { "voice": "hat", "every": "1b", "offset": "0.5b", "from": "@2.2s" } + ], + "master": { "compressor": { "threshold": -14, "ratio": 4 }, "limiter": true } + }); + let score: Score = serde_json::from_value(json).expect("issue's score shape must parse"); + assert_eq!(score.voices.len(), 2); + assert_eq!(score.score.len(), 2); + assert!(score.master.limiter); + assert_eq!(score.master.compressor.unwrap().ratio, 4.0); + } +} diff --git a/crates/rustmotion-core/src/audio/synth.rs b/crates/rustmotion-core/src/audio/synth.rs new file mode 100644 index 00000000..76a9c603 --- /dev/null +++ b/crates/rustmotion-core/src/audio/synth.rs @@ -0,0 +1,253 @@ +//! [`render`]: the single entry point that turns a [`Score`] into a +//! finished, mixed, mastered stereo buffer — the "small, boring synth" +//! deliverable's top-level orchestration. Everything else in this module +//! (`dsp`, `voices`, `score`) is a building block this function assembles; +//! nothing outside `rustmotion-core::audio` needs to call anything but +//! this. + +use std::collections::HashMap; + +use crate::schema::time::TimeCtx; + +use super::dsp; +use super::score::{Score, ScoreError}; + +/// Sample rate the synth renders at, per deliverable #2 ("rendered offline +/// to an f32 buffer at 48 kHz"). The file-based [`crate::schema::AudioTrack`] +/// mixer downstream (`rustmotion`'s `encode` crate) declares a different, +/// fixed rate for its own muxed PCM — the exact same resampling path an +/// ordinary 48kHz source file already goes through there carries this +/// buffer down to that rate, so nothing in this crate needs to duplicate +/// it. +pub const SYNTH_SAMPLE_RATE: u32 = 48_000; + +/// Renders `score` into an interleaved stereo `f32` buffer, `duration_secs` +/// long at [`SYNTH_SAMPLE_RATE`] (mono voices, duplicated to both +/// channels — this synth has no panning model). `ctx` is the scenario's own +/// [`TimeCtx`] (real `bpm`/`beat_offset`, `scene_start: 0.0` — a score is +/// never nested inside a scene): every `TimePoint` in `score.score` resolves +/// against it, which is what puts a kick on the same instant as a beat-grid +/// scene cut. +/// +/// Deterministic: same `score` + `ctx` + `duration_secs` always produces +/// the same bytes (no wall-clock or thread-seeded randomness anywhere in +/// this crate's synth — see [`dsp::Xorshift32`]'s doc). That is the +/// property `rustmotion`'s `--frames a-b` slicing and the "two renders are +/// byte-identical" acceptance criterion both depend on. +pub fn render(score: &Score, ctx: TimeCtx, duration_secs: f64) -> Result, ScoreError> { + let duration_secs = duration_secs.max(0.0); + let num_samples = (duration_secs * SYNTH_SAMPLE_RATE as f64).ceil() as usize; + let mut mono = vec![0.0f32; num_samples]; + + let hits = score.resolve_hits(&ctx, duration_secs)?; + + // Iterated in a fixed (sorted) order, not `HashMap`'s own — std's + // hasher is randomized per process, and mixing three or more voices' + // grains into the *same* sample index is a floating-point sum whose + // bit pattern can depend on accumulation order. Without this sort, + // "two renders are byte-identical" (this module's whole determinism + // promise, and an explicit acceptance criterion) would hold almost + // always and occasionally not, which is worse than never. + let mut voice_names: Vec<&String> = hits.keys().collect(); + voice_names.sort(); + + // Every trigger of a given voice renders to the identical grain (see + // `Voice::render_grain`'s doc) — render each voice once and stamp it + // onto the timeline per hit, rather than re-synthesizing per hit. + let mut grains: HashMap<&str, Vec> = HashMap::new(); + for name in &voice_names { + if let Some(voice) = score.voices.get(*name) { + grains.insert(name.as_str(), voice.render_grain(SYNTH_SAMPLE_RATE)); + } + } + + for name in &voice_names { + let times = &hits[*name]; + let Some(grain) = grains.get(name.as_str()) else { + continue; + }; + for &t in times { + if t < 0.0 { + continue; + } + let start = (t * SYNTH_SAMPLE_RATE as f64).round() as i64; + for (i, &s) in grain.iter().enumerate() { + let idx = start + i as i64; + if idx < 0 { + continue; + } + let idx = idx as usize; + if idx >= mono.len() { + break; + } + mono[idx] += s; + } + } + } + + if let Some(gain_db) = score.master.gain { + let gain = 10f32.powf(gain_db / 20.0); + for sample in mono.iter_mut() { + *sample *= gain; + } + } + if let Some(comp) = &score.master.compressor { + dsp::apply_compressor( + &mut mono, + dsp::CompressorParams { + threshold_db: comp.threshold, + ratio: comp.ratio, + attack_ms: comp.attack, + release_ms: comp.release, + }, + SYNTH_SAMPLE_RATE, + ); + } + if score.master.limiter { + dsp::apply_limiter(&mut mono, SYNTH_SAMPLE_RATE); + } + + let mut stereo = Vec::with_capacity(mono.len() * 2); + for sample in mono { + stereo.push(sample); + stereo.push(sample); + } + Ok(stereo) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::score::{CompressorConfig, MasterBus, ScoreEvent}; + use crate::audio::voices::{FilterKind, FilterSpec, FreqSpec, OscKind, Voice}; + use crate::schema::time::TimePoint; + + fn issue_example_score() -> Score { + let mut voices = HashMap::new(); + voices.insert( + "kick".to_string(), + Voice { + kind: OscKind::Sine, + freq: Some(FreqSpec::Sweep([150.0, 42.0])), + sweep: Some(0.14), + filter: None, + attack: 0.002, + decay: 0.38, + sustain: 0.0, + hold: 0.0, + release: 0.0, + gain: 1.0, + seed: None, + }, + ); + voices.insert( + "hat".to_string(), + Voice { + kind: OscKind::Noise, + freq: None, + sweep: None, + filter: Some(FilterSpec { + kind: FilterKind::Highpass, + freq: 7500.0, + q: 0.707, + }), + attack: 0.002, + decay: 0.05, + sustain: 0.0, + hold: 0.0, + release: 0.0, + gain: 1.0, + seed: Some(11), + }, + ); + Score { + voices, + score: vec![ + ScoreEvent { + voice: "kick".to_string(), + at: None, + every: Some(TimePoint::Spec("1b".to_string())), + from: Some(TimePoint::Spec("@2.2s".to_string())), + to: Some(TimePoint::Spec("@12.6s".to_string())), + offset: None, + gain: None, + }, + ScoreEvent { + voice: "hat".to_string(), + at: None, + every: Some(TimePoint::Spec("1b".to_string())), + from: Some(TimePoint::Spec("@2.2s".to_string())), + to: None, + offset: Some(TimePoint::Spec("0.5b".to_string())), + gain: None, + }, + ], + master: MasterBus { + gain: None, + compressor: Some(CompressorConfig { + threshold: -14.0, + ratio: 4.0, + attack: 5.0, + release: 50.0, + }), + limiter: true, + }, + } + } + + fn ctx() -> TimeCtx { + TimeCtx { + bpm: Some(115.0), + beat_offset: 0.0, + scene_start: 0.0, + } + } + + #[test] + fn render_produces_a_nonsilent_stereo_buffer_of_the_requested_length() { + let buf = render(&issue_example_score(), ctx(), 13.0).expect("render must succeed"); + assert_eq!( + buf.len(), + (13.0 * SYNTH_SAMPLE_RATE as f64).ceil() as usize * 2 + ); + assert!(buf.iter().any(|&s| s.abs() > 0.01), "must not be silent"); + } + + #[test] + fn render_is_deterministic_across_two_calls() { + let a = render(&issue_example_score(), ctx(), 13.0).unwrap(); + let b = render(&issue_example_score(), ctx(), 13.0).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn render_with_limiter_on_never_exceeds_the_limiter_ceiling() { + let buf = render(&issue_example_score(), ctx(), 13.0).unwrap(); + let ceiling = 10f32.powf(dsp::LIMITER_CEILING_DB / 20.0); + assert!(buf.iter().all(|&s| s.abs() <= ceiling + 1e-6)); + } + + #[test] + fn stereo_channels_are_identical_mono_duplicated() { + let buf = render(&issue_example_score(), ctx(), 13.0).unwrap(); + for pair in buf.as_chunks::<2>().0 { + assert_eq!(pair[0], pair[1]); + } + } + + #[test] + fn unknown_voice_propagates_as_a_score_error() { + let mut score = issue_example_score(); + score.score.push(ScoreEvent { + voice: "cowbell".to_string(), + at: Some(TimePoint::Seconds(1.0)), + every: None, + from: None, + to: None, + offset: None, + gain: None, + }); + let err = render(&score, ctx(), 13.0).unwrap_err(); + assert!(matches!(err, ScoreError::UnknownVoice(v) if v == "cowbell")); + } +} diff --git a/crates/rustmotion-core/src/audio/voices.rs b/crates/rustmotion-core/src/audio/voices.rs new file mode 100644 index 00000000..208e2eae --- /dev/null +++ b/crates/rustmotion-core/src/audio/voices.rs @@ -0,0 +1,265 @@ +//! [`Voice`]: one instrument definition in a [`super::score::Score`] — the +//! `"kick"`/`"hat"` entries of the issue's `voices` map. A `Voice` is +//! stateless data; [`Voice::render_grain`] is the only place it turns into +//! samples, producing one self-contained "grain" (attack through release, +//! silence before and after) that [`super::synth::render`] then stamps +//! onto the timeline once per resolved hit. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::dsp; + +pub use dsp::{FilterKind, OscKind}; + +/// A voice's frequency: either fixed (a sustained tone), or a two-point +/// sweep — `"freq": [150, 42]` in the issue's `kick` example, read start-to-end +/// against `sweep` (seconds). Linear interpolation, not exponential: simpler, +/// and "small, boring synth" (deliverable #2) does not ask for a +/// perceptually-uniform pitch glide. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum FreqSpec { + Fixed(f32), + Sweep([f32; 2]), +} + +fn default_q() -> f32 { + // Butterworth Q — maximally flat passband, the least surprising + // default when a score gives a filter's `freq` but not its `q` (the + // issue's `hat` example does exactly this). + 0.707 +} + +/// `voices.hat.filter` in the issue's example +/// (`{"type":"highpass","freq":7500}`). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct FilterSpec { + #[serde(rename = "type")] + pub kind: FilterKind, + pub freq: f32, + #[serde(default = "default_q")] + pub q: f32, +} + +fn default_attack() -> f32 { + // 2ms: enough to avoid a hard-edge click on a one-shot trigger, + // short enough to still read as instant next to `decay` (tens/hundreds + // of ms) on a drum voice. + 0.002 +} + +fn default_gain() -> f32 { + 1.0 +} + +/// One instrument: an oscillator or a noise source, an optional filter, +/// and an ADSR envelope (`attack`/`decay`/`sustain`/`hold`/`release` — see +/// [`dsp::Adsr`]'s doc for why `hold` stands in for a note-off this score +/// model never sends). Every field but `type` and `decay` has a +/// drum-machine-flavoured default, so the issue's own two-voice example +/// (`"kick": {"type":"sine","freq":[150,42],"sweep":0.14,"decay":0.38}`) +/// needs nothing else. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Voice { + #[serde(rename = "type")] + pub kind: OscKind, + /// Required for every `type` but `noise`; ignored for `noise`. Falls + /// back to 440Hz if omitted on a tonal voice rather than erroring — + /// consistent with this module's general posture (see + /// [`dsp::BiquadCoeffs::design`]'s doc) of making an underspecified + /// voice audible rather than rejecting it. + #[serde(default)] + pub freq: Option, + /// Seconds the `freq` sweep takes to go from its first to its second + /// value. Ignored unless `freq` is [`FreqSpec::Sweep`]. + #[serde(default)] + pub sweep: Option, + #[serde(default)] + pub filter: Option, + #[serde(default = "default_attack")] + pub attack: f32, + #[serde(default)] + pub decay: f32, + #[serde(default)] + pub sustain: f32, + #[serde(default)] + pub hold: f32, + #[serde(default)] + pub release: f32, + #[serde(default = "default_gain")] + pub gain: f32, + /// Seeds this voice's [`dsp::Xorshift32`] noise source. Two `noise` + /// voices with no explicit seed share the same fixed default and so + /// sound identical to each other — a minor aesthetic simplification + /// documented here rather than hidden; give each its own `seed` to + /// tell them apart. + #[serde(default)] + pub seed: Option, +} + +impl Voice { + fn envelope(&self) -> dsp::Adsr { + dsp::Adsr { + attack: self.attack.max(0.0), + decay: self.decay.max(0.0), + sustain: self.sustain.clamp(0.0, 1.0), + hold: self.hold.max(0.0), + release: self.release.max(0.0), + } + } + + /// Renders exactly one trigger of this voice — a self-contained mono + /// grain, `envelope().total_duration()` seconds long at `sample_rate`, + /// starting and ending at silence. Identical every time (no dependency + /// on *when* it is triggered), so [`super::synth::render`] renders it + /// once per voice and reuses it for every resolved hit. + pub fn render_grain(&self, sample_rate: u32) -> Vec { + let envelope = self.envelope(); + let sr = sample_rate as f32; + let total = envelope.total_duration().max(1.0 / sr); + let n = (total * sr).ceil() as usize; + + let mut filter = self.filter.map(|f| { + ( + dsp::BiquadCoeffs::design(f.kind, f.freq, f.q, sr), + dsp::BiquadState::default(), + ) + }); + let mut noise = dsp::Xorshift32::new(self.seed.unwrap_or(0x1234_5678)); + + let (f0, f1, sweep) = match self.freq { + Some(FreqSpec::Fixed(f)) => (f, f, 0.0), + Some(FreqSpec::Sweep([a, b])) => (a, b, self.sweep.unwrap_or(0.0).max(0.0)), + None => (440.0, 440.0, 0.0), + }; + + let mut phase = 0.0f32; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let t = i as f32 / sr; + let freq = if sweep > 0.0 && t < sweep { + f0 + (f1 - f0) * (t / sweep) + } else { + f1 + }; + + let mut sample = match self.kind { + OscKind::Sine => dsp::sine(phase), + OscKind::Square => dsp::square(phase), + OscKind::Saw => dsp::saw(phase), + OscKind::Triangle => dsp::triangle(phase), + OscKind::Noise => noise.next_f32(), + }; + if self.kind != OscKind::Noise { + phase = (phase + freq / sr).rem_euclid(1.0); + } + + if let Some((coeffs, state)) = filter.as_mut() { + sample = coeffs.process(state, sample); + } + + out.push(sample * envelope.level_at(t) * self.gain); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kick() -> Voice { + Voice { + kind: OscKind::Sine, + freq: Some(FreqSpec::Sweep([150.0, 42.0])), + sweep: Some(0.14), + filter: None, + attack: default_attack(), + decay: 0.38, + sustain: 0.0, + hold: 0.0, + release: 0.0, + gain: default_gain(), + seed: None, + } + } + + fn hat() -> Voice { + Voice { + kind: OscKind::Noise, + freq: None, + sweep: None, + filter: Some(FilterSpec { + kind: FilterKind::Highpass, + freq: 7500.0, + q: default_q(), + }), + attack: default_attack(), + decay: 0.05, + sustain: 0.0, + hold: 0.0, + release: 0.0, + gain: default_gain(), + seed: Some(7), + } + } + + #[test] + fn kick_grain_starts_and_ends_at_silence_and_is_within_range() { + let grain = kick().render_grain(48_000); + assert!(!grain.is_empty()); + assert!( + grain[0].abs() < 1e-3, + "grain must start near silence (attack ramp)" + ); + assert!( + grain.last().unwrap().abs() < 1e-3, + "grain must decay to silence by the end of its envelope" + ); + assert!(grain.iter().all(|s| s.abs() <= 1.0 + 1e-3)); + } + + #[test] + fn hat_grain_is_filtered_noise_not_silence() { + let grain = hat().render_grain(48_000); + assert!(!grain.is_empty()); + assert!( + grain.iter().any(|&s| s.abs() > 0.01), + "a highpassed noise burst must not be silent" + ); + } + + #[test] + fn voice_deserializes_from_the_issues_json_shape() { + let json = serde_json::json!({ + "type": "sine", + "freq": [150, 42], + "sweep": 0.14, + "decay": 0.38 + }); + let voice: Voice = serde_json::from_value(json).expect("kick voice parses"); + assert_eq!(voice.kind, OscKind::Sine); + assert_eq!(voice.freq, Some(FreqSpec::Sweep([150.0, 42.0]))); + assert_eq!(voice.decay, 0.38); + assert_eq!(voice.attack, default_attack()); + + let json = serde_json::json!({ + "type": "noise", + "filter": {"type": "highpass", "freq": 7500}, + "decay": 0.05 + }); + let voice: Voice = serde_json::from_value(json).expect("hat voice parses"); + assert_eq!(voice.kind, OscKind::Noise); + assert_eq!(voice.filter.unwrap().freq, 7500.0); + } + + #[test] + fn rendering_the_same_voice_twice_is_byte_identical() { + let a = hat().render_grain(48_000); + let b = hat().render_grain(48_000); + assert_eq!(a, b); + } +} diff --git a/crates/rustmotion-core/src/css/animation.rs b/crates/rustmotion-core/src/css/animation.rs index c14e48ef..4260e524 100644 --- a/crates/rustmotion-core/src/css/animation.rs +++ b/crates/rustmotion-core/src/css/animation.rs @@ -6,6 +6,20 @@ //! This is a transitional module: once all animation surfaces are CSS-native, //! the animator can produce `CssStyle` overrides directly and this bridge //! disappears. +//! +//! # A second caller (issue #338) +//! +//! [`apply_animated_props`] has exactly one caller-shape requirement: an +//! [`AnimatedProperties`] whose non-default fields are the ones to apply. +//! `crate::css::computed::ComputedStyle::resolve` produces exactly that +//! shape from a node's per-frame expressions, so `box_builder.rs` calls this +//! same function a second time per node — once for a resolved animation, +//! once for resolved expressions — rather than this module growing a +//! parallel "apply an expression override" path. See that module's doc for +//! why an expression is "a second source of the same kind of override," and +//! `box_builder.rs` for the resulting precedence (expressions apply after +//! animations, so they compose the same way animations already compose with +//! a literal `CssStyle` value). use crate::css::style::CssStyle; use crate::css::style::{FilterFn, Size, TransformFn}; diff --git a/crates/rustmotion-core/src/css/computed.rs b/crates/rustmotion-core/src/css/computed.rs new file mode 100644 index 00000000..876c593c --- /dev/null +++ b/crates/rustmotion-core/src/css/computed.rs @@ -0,0 +1,659 @@ +//! Where a `"= ..."` expression on a style property actually lands once it +//! survives past `rustmotion::loader::fold_static_expressions` — i.e. once +//! it reads something only known per frame (`$t`, `$T`, `$beat`, a declared +//! `vars` name, or a `node(...)` reference) and therefore cannot be folded +//! to a literal at load time. See [`crate::expr`]'s module doc for the full +//! two-tier model this is the dynamic half of (issue #338). +//! +//! [`Computed`](crate::expr::Computed) already solves "a JSON value is +//! either a literal `T` or an `"= ..."` expression" *per field*, but +//! retyping [`crate::css::CssStyle`]'s existing fields (`opacity: Option`, +//! ...) to `Computed` would change their Rust type for every one of the +//! many consumers across this workspace that read them directly — not this +//! workstream's call to make. [`ComputedStyle`] is the alternative: a +//! sibling, additive field on `CssStyle` that holds the *parsed* [`Expr`] +//! for whichever of a small, fixed set of properties carried one, leaving +//! every existing typed field exactly as it was (either a literal, or +//! simply unset when the author wrote an expression instead). See +//! [`extract`] for how a `"= ..."` string moves from the raw JSON into this +//! side channel, and `CssStyle`'s own hand-written `Deserialize` impl +//! (`style.rs`) for where that happens — exactly once, at load, never +//! per frame. + +use serde_json::{Map, Value}; + +use crate::engine::animator::AnimatedProperties; +use crate::engine::deps::NodeRef; +use crate::expr::{Expr, ExprError, Scope}; + +/// Parsed, not-yet-evaluated `"= ..."` expressions pulled off a +/// [`crate::css::CssStyle`] at deserialize time — one [`Expr`] per style +/// property that accepts one. A property is either a literal (its normal +/// typed field on `CssStyle`) or an expression (a field here), never both: +/// [`extract`] removes the `"= ..."` string from the JSON before the typed +/// field is deserialized, so the typed field is simply absent when this one +/// is populated. +/// +/// Every [`Expr`] here was parsed exactly once, by [`extract`], when the +/// scenario was loaded — [`Self::resolve`] only ever calls [`Expr::eval`] +/// on it afterwards, once per sampled frame. See [`crate::expr`]'s "Two +/// evaluation tiers" doc: this struct is that second tier's landing zone. +/// +/// # Coverage +/// +/// `opacity`, `width`, `height`, and — inside a `style.transform` array +/// entry — the `x`/`y` of `translate`/`translate-x`/`translate-y`/`scale`/ +/// `scale-x`/`scale-y` and the `deg` of `rotate`. Every other `CssStyle` +/// property (colors, other lengths, enums, `z`/3d transform functions, +/// `skew*`, `perspective`, `matrix`/`matrix3d`, ...) does not accept an +/// expression yet — a `"= ..."` string there still fails deserialization +/// exactly as it did before this module existed. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ComputedStyle { + pub opacity: Option, + pub width: Option, + pub height: Option, + pub translate_x: Option, + pub translate_y: Option, + pub scale_x: Option, + pub scale_y: Option, + pub rotate: Option, + /// Every `node("id", "prop")` call found across this node's own + /// populated expressions above — the `(String, Vec)` shape + /// [`crate::engine::deps::DepGraph::build`] wants, pre-computed once at + /// [`extract`] time (issue #328's join to #338). [`Expr`] itself + /// compiles the same information internally but does not expose it + /// (see `engine::deps`'s module doc, "Why text metrics are a trait" — + /// no, rather its `scan_node_refs` doc — for why this crate re-derives + /// it from the raw `"= ..."` source text instead of reaching into + /// `Expr`'s private fields), so [`extract`] runs + /// [`crate::engine::deps::scan_node_refs`] on each expression's source + /// the moment it has it in hand, right before that source is discarded. + /// Empty for the overwhelming common case (no expression at all, or an + /// expression that only reads `$name`s) — same zero-cost-when-unused + /// shape as every other field here. + pub node_refs: Vec, +} + +impl ComputedStyle { + /// True when this node's style carries no expression at all — the + /// common case. `box_builder.rs` checks this before building a + /// [`Scope`] or calling [`Self::resolve`], so a scenario that never + /// writes `"= ..."` on a style property pays nothing beyond this one + /// field-count check, per node, per frame — no `Expr::eval`, no `Scope` + /// construction, no [`AnimatedProperties`] built or applied. + pub fn is_empty(&self) -> bool { + self.opacity.is_none() + && self.width.is_none() + && self.height.is_none() + && self.translate_x.is_none() + && self.translate_y.is_none() + && self.scale_x.is_none() + && self.scale_y.is_none() + && self.rotate.is_none() + } + + /// `self`'s own populated fields win; `base`'s fill in whatever `self` + /// left `None`. Used by `box_builder.rs::apply_style_states`, whose + /// timeline-style-state merge round-trips `CssStyle` through + /// `Serialize`/`Deserialize` — `expr` is `#[serde(skip)]` on that round + /// trip (see `style.rs`), so the node's original expressions would + /// otherwise vanish the instant it has any `timeline` style state. A + /// state's own `style` block can itself carry a fresh expression on the + /// same property (rare, but not disallowed) — that one is `self` here, + /// and takes precedence. + pub fn prefer(self, base: ComputedStyle) -> ComputedStyle { + // `node_refs` is a per-field-agnostic union rather than a + // per-field "self wins" pick like every field above: knowing + // *which* of the 8 slots each `NodeRef` came from would need + // per-field storage this struct doesn't keep (see the field's own + // doc). A union can only ever add a dependency-graph edge that a + // precise per-field pick wouldn't have — never drop a real one — + // so the worst case is an unnecessary ordering constraint, never a + // reference silently failing to resolve. Duplicate `NodeRef`s + // across `self`/`base` are harmless: `DepGraph::build` counts an + // edge once per occurrence on both sides of Kahn's algorithm, so a + // duplicate self-corrects instead of leaving a dangling count. + let mut node_refs = self.node_refs; + node_refs.extend(base.node_refs.iter().cloned()); + ComputedStyle { + opacity: self.opacity.or(base.opacity), + width: self.width.or(base.width), + height: self.height.or(base.height), + translate_x: self.translate_x.or(base.translate_x), + translate_y: self.translate_y.or(base.translate_y), + scale_x: self.scale_x.or(base.scale_x), + scale_y: self.scale_y.or(base.scale_y), + rotate: self.rotate.or(base.rotate), + node_refs, + } + } + + /// Evaluate every populated expression against `scope` and return the + /// result shaped as an [`AnimatedProperties`] — ready for + /// [`crate::css::apply_animated_props`], the exact same per-frame + /// override path a resolved animation already goes through (see + /// `css::animation`'s module doc and issue #338). `AnimatedProperties`'s + /// own `Default` already carries the correct neutral/identity value for + /// every field this touches (opacity 1.0, scale 1.0, translate/rotation + /// 0.0, width/height the `-1.0` "unset" sentinel — see that type's own + /// `Default` impl), so an unpopulated field here is simply left at that + /// default rather than needing its own sentinel logic. + /// + /// Stops at the first property whose expression fails to evaluate, + /// naming which one in [`ComputedError::property`] — never a silent + /// zero, never a panic (issue #338's deliverable #4). + pub fn resolve(&self, scope: &dyn Scope) -> Result { + let mut props = AnimatedProperties::default(); + if let Some(e) = &self.opacity { + props.opacity = eval_named(e, scope, "opacity")? as f32; + } + if let Some(e) = &self.width { + props.width = eval_named(e, scope, "width")? as f32; + } + if let Some(e) = &self.height { + props.height = eval_named(e, scope, "height")? as f32; + } + if let Some(e) = &self.translate_x { + props.translate_x = eval_named(e, scope, "transform.translate-x.x")? as f32; + } + if let Some(e) = &self.translate_y { + props.translate_y = eval_named(e, scope, "transform.translate-y.y")? as f32; + } + if let Some(e) = &self.scale_x { + props.scale_x = eval_named(e, scope, "transform.scale.x")? as f32; + } + if let Some(e) = &self.scale_y { + props.scale_y = eval_named(e, scope, "transform.scale.y")? as f32; + } + if let Some(e) = &self.rotate { + props.rotation = eval_named(e, scope, "transform.rotate.deg")? as f32; + } + Ok(props) + } +} + +fn eval_named(expr: &Expr, scope: &dyn Scope, property: &str) -> Result { + expr.eval(scope).map_err(|source| ComputedError { + property: property.to_string(), + source, + }) +} + +/// A named, located expression failure — either at extraction (a `"= ..."` +/// string that doesn't parse) or at per-frame evaluation (a `Scope` that +/// can't answer one of the expression's free variables or `node(...)` +/// calls). Always says which style property it was on and why +/// ([`ExprError`]'s own message) — issue #338's deliverable #4: never a +/// silent zero, never a panic. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +#[error("style.{property}: {source}")] +pub struct ComputedError { + pub property: String, + #[source] + pub source: ExprError, +} + +/// Pull every `"= ..."` expression out of a raw style JSON object, in +/// place, replacing each with a value its normal typed field can still +/// deserialize successfully: +/// - `opacity`/`width`/`height`: the key is removed outright, so the typed +/// field simply deserializes as unset (`None`) — [`ComputedStyle::resolve`] +/// supplies the per-frame value later, through the same override path a +/// literal-then-overridden-by-animation value already goes through. +/// - A covered `transform` leaf (see this module's doc, "Coverage"): the +/// key's value is replaced with that function's neutral/identity literal +/// (`0` for a translate/rotate component, `1` for a scale component). +/// This is what lets [`ComputedStyle::resolve`]'s result be *appended* as +/// a fresh `TransformFn` (via `apply_animated_props`) rather than needing +/// to patch the original array entry in place: composing an identity +/// transform with the expression's per-frame value is the same net +/// transform as if the original entry had held that value directly, for +/// every one of these single-axis functions. +/// +/// Called exactly once per node, from `CssStyle`'s own `Deserialize` impl +/// (`style.rs`) — never from the per-frame path, which only ever calls +/// [`ComputedStyle::resolve`] on the result. +pub fn extract(obj: &mut Map) -> Result { + let mut node_refs = Vec::new(); + let mut out = ComputedStyle { + opacity: take_scalar(obj, "opacity", &mut node_refs)?, + width: take_scalar(obj, "width", &mut node_refs)?, + height: take_scalar(obj, "height", &mut node_refs)?, + ..Default::default() + }; + if let Some(Value::Array(items)) = obj.get_mut("transform") { + for item in items.iter_mut() { + extract_transform_leaf(item, &mut out, &mut node_refs)?; + } + } + out.node_refs = node_refs; + Ok(out) +} + +fn as_expr_source(v: &Value) -> Option<&str> { + match v { + Value::String(s) if s.trim_start().starts_with('=') => Some(s.as_str()), + _ => None, + } +} + +fn take_scalar( + obj: &mut Map, + key: &str, + node_refs: &mut Vec, +) -> Result, ComputedError> { + let Some(src) = obj.get(key).and_then(as_expr_source) else { + return Ok(None); + }; + let expr = Expr::parse(src).map_err(|source| ComputedError { + property: key.to_string(), + source, + })?; + node_refs.extend(crate::engine::deps::scan_node_refs(src)); + obj.remove(key); + Ok(Some(expr)) +} + +fn extract_transform_leaf( + item: &mut Value, + out: &mut ComputedStyle, + node_refs: &mut Vec, +) -> Result<(), ComputedError> { + let Some(map) = item.as_object_mut() else { + return Ok(()); + }; + let Some(tag) = map.get("fn").and_then(Value::as_str).map(str::to_string) else { + return Ok(()); + }; + match tag.as_str() { + "translate" => { + take_leaf(map, "x", 0.0, &tag, &mut out.translate_x, node_refs)?; + take_leaf(map, "y", 0.0, &tag, &mut out.translate_y, node_refs)?; + } + "translate-x" => take_leaf(map, "x", 0.0, &tag, &mut out.translate_x, node_refs)?, + "translate-y" => take_leaf(map, "y", 0.0, &tag, &mut out.translate_y, node_refs)?, + "scale" => { + take_leaf(map, "x", 1.0, &tag, &mut out.scale_x, node_refs)?; + take_leaf(map, "y", 1.0, &tag, &mut out.scale_y, node_refs)?; + } + "scale-x" => take_leaf(map, "x", 1.0, &tag, &mut out.scale_x, node_refs)?, + "scale-y" => take_leaf(map, "y", 1.0, &tag, &mut out.scale_y, node_refs)?, + "rotate" => take_leaf(map, "deg", 0.0, &tag, &mut out.rotate, node_refs)?, + _ => {} + } + Ok(()) +} + +fn take_leaf( + map: &mut Map, + field: &str, + neutral: f64, + tag: &str, + slot: &mut Option, + node_refs: &mut Vec, +) -> Result<(), ComputedError> { + let Some(src) = map.get(field).and_then(as_expr_source) else { + return Ok(()); + }; + let expr = Expr::parse(src).map_err(|source| ComputedError { + property: format!("transform.{tag}.{field}"), + source, + })?; + node_refs.extend(crate::engine::deps::scan_node_refs(src)); + *slot = Some(expr); + map.insert(field.to_string(), serde_json::json!(neutral)); + Ok(()) +} + +/// A minimal, self-contained [`Scope`] answering exactly the reserved +/// scenario-clock names [`crate::expr::eval`]'s own `is_dynamic_var_name` +/// treats specially (`t`, `T`, `duration` — see [`crate::expr`]'s "Two +/// evaluation tiers" doc) plus `W`/`H`/`fps`, all derivable from the same +/// per-frame context `box_builder.rs` already threads through the box tree +/// (`BuildAnimationCtx` plus the viewport size) — no `vars` table, no +/// `node(...)` dependency graph. `box_builder.rs` builds one of these for +/// every frame that has any expression to resolve at all, with zero new +/// parameters on any of its existing, publicly-called functions — see that +/// file's own doc for why that constraint mattered. +/// +/// `beat` is deliberately not answered here: nothing reaching this scope +/// knows the scenario's BPM. An expression naming `$beat` (or a declared +/// `vars` name, or a `node(...)` call) gets a real +/// [`crate::expr::ExprError::UnknownIdent`] back from [`Scope::var`]/ +/// [`Scope::node_prop`]'s default `None` — the same "not defined in this +/// scope" contract every [`Scope`] impl in this codebase already uses, not +/// a special case. A caller with a richer context (a `vars::VarScope`, a +/// `node(...)` dependency graph) answers those by composing its own `Scope` +/// impl instead of this one — see `vars::scope`'s module doc for exactly +/// this composition pattern. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FrameClock { + /// Scene-local time in seconds — resets to 0 at the start of each scene. + pub t: f64, + /// Scenario-absolute time in seconds — never resets. + pub t_abs: f64, + pub duration: f64, + pub width: f64, + pub height: f64, + pub fps: f64, +} + +impl Scope for FrameClock { + fn var(&self, name: &str) -> Option { + match name { + "t" => Some(self.t), + "T" => Some(self.t_abs), + "duration" => Some(self.duration), + "W" => Some(self.width), + "H" => Some(self.height), + "fps" => Some(self.fps), + _ => None, + } + } +} + +/// Composes [`FrameClock`] with a caller-supplied outer [`Scope`] — the +/// join issue #326's decomposition left open: a declared `vars` name and a +/// `node(...)` reference both answer through *some* richer `Scope`, but +/// nothing upstream of `box_builder.rs`'s per-node [`ComputedStyle::resolve`] +/// call had one to hand it. [`vars::scope`](crate::vars::scope)'s own module +/// doc explains why this is a struct holding both rather than one `Scope` +/// wrapping another: a `Scope` is only ever consumed behind `&dyn Scope`, +/// and trait objects don't nest. +/// +/// # Order +/// +/// [`Scope::var`] tries `clock` first, `outer` second. The six names +/// [`FrameClock`] answers (`t`, `T`, `duration`, `W`, `H`, `fps`) are +/// reserved — see that type's own doc — and must always win over a +/// same-named declared variable rather than being shadowable by one; trying +/// `clock` first is what makes that true regardless of what `outer` +/// happens to answer. [`Scope::node_prop`] only ever reaches `outer` — +/// `FrameClock` has no notion of another node's state and never will (it is +/// built fresh, per node, from data with no dependency-graph position of +/// its own). +/// +/// `outer` is `None` for a build that has no richer context to offer (no +/// declared `vars`, no node ids in the scene) — [`Scope::var`] then behaves +/// exactly as a bare [`FrameClock`] would, byte for byte, which is what +/// keeps an expression-free-of-`vars`-and-`node(...)` scenario's render +/// unaffected by this type existing at all. +pub struct ComposedScope<'a> { + pub clock: FrameClock, + pub outer: Option<&'a dyn Scope>, +} + +impl Scope for ComposedScope<'_> { + fn var(&self, name: &str) -> Option { + self.clock + .var(name) + .or_else(|| self.outer.and_then(|o| o.var(name))) + } + + fn node_prop(&self, id: &str, prop: &str) -> Option { + self.outer.and_then(|o| o.node_prop(id, prop)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn obj(json: &str) -> Map { + match serde_json::from_str(json).unwrap() { + Value::Object(m) => m, + _ => panic!("not an object"), + } + } + + #[test] + fn extracts_opacity_and_removes_the_key() { + let mut o = obj(r#"{ "opacity": "= $t * 2", "z-index": 3 }"#); + let computed = extract(&mut o).unwrap(); + assert!(computed.opacity.is_some()); + assert!(!o.contains_key("opacity")); + assert_eq!(o.get("z-index"), Some(&Value::from(3))); + } + + #[test] + fn leaves_literal_opacity_untouched() { + let mut o = obj(r#"{ "opacity": 0.5 }"#); + let computed = extract(&mut o).unwrap(); + assert!(computed.opacity.is_none()); + assert_eq!(o.get("opacity"), Some(&Value::from(0.5))); + } + + #[test] + fn width_and_height_survive_as_expressions() { + let mut o = obj(r#"{ "width": "= $W / 2", "height": "= $H / 2" }"#); + let computed = extract(&mut o).unwrap(); + assert!(computed.width.is_some()); + assert!(computed.height.is_some()); + assert!(!o.contains_key("width")); + assert!(!o.contains_key("height")); + } + + #[test] + fn transform_translate_x_extracts_and_neutralizes() { + let mut o = obj(r#"{ "transform": [ { "fn": "translate-x", "x": "= $t * 100" } ] }"#); + let computed = extract(&mut o).unwrap(); + assert!(computed.translate_x.is_some()); + let arr = o.get("transform").unwrap().as_array().unwrap(); + assert_eq!(arr[0]["x"], Value::from(0.0)); + } + + #[test] + fn transform_scale_extracts_both_axes_and_neutralizes_to_one() { + let mut o = + obj(r#"{ "transform": [ { "fn": "scale", "x": "= 1 + $t", "y": "= 1 + $t * 2" } ] }"#); + let computed = extract(&mut o).unwrap(); + assert!(computed.scale_x.is_some()); + assert!(computed.scale_y.is_some()); + let arr = o.get("transform").unwrap().as_array().unwrap(); + assert_eq!(arr[0]["x"], Value::from(1.0)); + assert_eq!(arr[0]["y"], Value::from(1.0)); + } + + #[test] + fn transform_rotate_extracts_deg_and_neutralizes_to_zero() { + let mut o = obj(r#"{ "transform": [ { "fn": "rotate", "deg": "= $t * 90" } ] }"#); + let computed = extract(&mut o).unwrap(); + assert!(computed.rotate.is_some()); + let arr = o.get("transform").unwrap().as_array().unwrap(); + assert_eq!(arr[0]["deg"], Value::from(0.0)); + } + + #[test] + fn uncovered_transform_fn_is_left_completely_alone() { + let mut o = obj(r#"{ "transform": [ { "fn": "skew-x", "x": 12.0 } ] }"#); + let computed = extract(&mut o).unwrap(); + assert!(computed.is_empty()); + let arr = o.get("transform").unwrap().as_array().unwrap(); + assert_eq!(arr[0]["x"], Value::from(12.0)); + } + + #[test] + fn a_bad_expression_is_a_named_located_parse_error() { + let mut o = obj(r#"{ "opacity": "= $t +" }"#); + let err = extract(&mut o).unwrap_err(); + assert_eq!(err.property, "opacity"); + assert!(matches!(err.source, ExprError::Parse { .. })); + } + + #[test] + fn is_empty_is_true_for_the_default() { + assert!(ComputedStyle::default().is_empty()); + } + + #[test] + fn resolve_applies_frame_clock_and_yields_animated_properties() { + let mut o = obj(r#"{ "opacity": "= 0.5 + $t / 10" }"#); + let computed = extract(&mut o).unwrap(); + let clock = FrameClock { + t: 2.0, + t_abs: 2.0, + duration: 5.0, + width: 1920.0, + height: 1080.0, + fps: 30.0, + }; + let props = computed.resolve(&clock).unwrap(); + assert!((props.opacity - 0.7).abs() < 1e-6); + } + + #[test] + fn resolve_reports_a_named_located_error_for_an_unresolvable_scope_reference() { + let mut o = obj(r#"{ "opacity": "= $keyDraw" }"#); + let computed = extract(&mut o).unwrap(); + let clock = FrameClock { + t: 0.0, + t_abs: 0.0, + duration: 1.0, + width: 1.0, + height: 1.0, + fps: 30.0, + }; + let err = computed.resolve(&clock).unwrap_err(); + assert_eq!(err.property, "opacity"); + assert_eq!(err.source, ExprError::UnknownIdent("keyDraw".to_string())); + } + + #[test] + fn prefer_keeps_self_over_base_per_field() { + let a = ComputedStyle { + opacity: Some(Expr::parse("= 1").unwrap()), + ..Default::default() + }; + let b = ComputedStyle { + opacity: Some(Expr::parse("= 2").unwrap()), + width: Some(Expr::parse("= 3").unwrap()), + ..Default::default() + }; + let merged = a.prefer(b); + assert_eq!(merged.opacity, Some(Expr::parse("= 1").unwrap())); + assert_eq!(merged.width, Some(Expr::parse("= 3").unwrap())); + } + + #[test] + fn extract_collects_a_node_ref_from_a_covered_property() { + let mut o = obj(r#"{ "opacity": "= node(\"badge\", \"tx\")" }"#); + let computed = extract(&mut o).unwrap(); + assert_eq!( + computed.node_refs, + vec![NodeRef { + id: "badge".to_string(), + prop: "tx".to_string() + }] + ); + } + + #[test] + fn extract_collects_node_refs_from_a_transform_leaf() { + let mut o = + obj(r#"{ "transform": [ { "fn": "translate-x", "x": "= node(\"chip\", \"tx\")" } ] }"#); + let computed = extract(&mut o).unwrap(); + assert_eq!( + computed.node_refs, + vec![NodeRef { + id: "chip".to_string(), + prop: "tx".to_string() + }] + ); + } + + #[test] + fn extract_finds_no_node_refs_in_a_var_only_expression() { + let mut o = obj(r#"{ "opacity": "= $fade" }"#); + let computed = extract(&mut o).unwrap(); + assert!(computed.node_refs.is_empty()); + } + + #[test] + fn prefer_unions_node_refs_from_both_sides() { + let mut a_obj = obj(r#"{ "opacity": "= node(\"a\", \"tx\")" }"#); + let a = extract(&mut a_obj).unwrap(); + let mut b_obj = obj(r#"{ "width": "= node(\"b\", \"width\")" }"#); + let b = extract(&mut b_obj).unwrap(); + let merged = a.prefer(b); + assert_eq!(merged.node_refs.len(), 2); + assert!(merged + .node_refs + .iter() + .any(|r| r.id == "a" && r.prop == "tx")); + assert!(merged + .node_refs + .iter() + .any(|r| r.id == "b" && r.prop == "width")); + } + + struct StaticOuter; + impl Scope for StaticOuter { + fn var(&self, name: &str) -> Option { + match name { + "fade" => Some(0.25), + "t" => Some(999.0), // must never win: `clock` is reserved. + _ => None, + } + } + fn node_prop(&self, id: &str, prop: &str) -> Option { + match (id, prop) { + ("badge", "tx") => Some(42.0), + _ => None, + } + } + } + + #[test] + fn composed_scope_prefers_the_clocks_reserved_names() { + let clock = FrameClock { + t: 1.0, + t_abs: 1.0, + duration: 1.0, + width: 1.0, + height: 1.0, + fps: 30.0, + }; + let composed = ComposedScope { + clock, + outer: Some(&StaticOuter), + }; + assert_eq!(composed.var("t"), Some(1.0)); + } + + #[test] + fn composed_scope_falls_back_to_the_outer_scope_for_vars_and_node_refs() { + let clock = FrameClock { + t: 1.0, + t_abs: 1.0, + duration: 1.0, + width: 1.0, + height: 1.0, + fps: 30.0, + }; + let composed = ComposedScope { + clock, + outer: Some(&StaticOuter), + }; + assert_eq!(composed.var("fade"), Some(0.25)); + assert_eq!(composed.node_prop("badge", "tx"), Some(42.0)); + assert_eq!(composed.node_prop("nope", "tx"), None); + } + + #[test] + fn composed_scope_with_no_outer_behaves_exactly_like_a_bare_frame_clock() { + let clock = FrameClock { + t: 3.0, + t_abs: 4.0, + duration: 5.0, + width: 6.0, + height: 7.0, + fps: 30.0, + }; + let composed = ComposedScope { clock, outer: None }; + assert_eq!(composed.var("t"), clock.var("t")); + assert_eq!(composed.var("W"), clock.var("W")); + assert_eq!(composed.var("unknown"), clock.var("unknown")); + assert_eq!(composed.node_prop("x", "y"), None); + } +} diff --git a/crates/rustmotion-core/src/css/mod.rs b/crates/rustmotion-core/src/css/mod.rs index 9f28aaec..37cbf59e 100644 --- a/crates/rustmotion-core/src/css/mod.rs +++ b/crates/rustmotion-core/src/css/mod.rs @@ -11,10 +11,12 @@ pub mod animation; pub mod cascade; +pub mod computed; pub mod style; pub mod taffy_bridge; pub mod units; pub use animation::apply_animated_props; +pub use computed::{ComposedScope, ComputedError, ComputedStyle, FrameClock}; pub use style::CssStyle; pub use units::{Length, LengthContext, LengthPercentage}; diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 6be471f3..5a52698c 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -7,6 +7,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use super::computed; use super::units::{Length, LengthContext, LengthPercentage, ParsedLength}; // `GradientBorder` / `InnerShadow` are reused from the schema layer rather // than mirrored: same crate, same serde/JsonSchema derives, identical JSON @@ -60,7 +61,21 @@ pub const TEXT_AUTOFIT_MIN_FONT_PX: f32 = MIN_LEGIBLE_FONT_RATIO * 1080.0; /// Top-level CSS style block. All fields are optional; `None` means "not set" /// and lets the cascade fill in inherited / initial values. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +/// +/// # `Deserialize` is hand-written, not derived +/// +/// See [`computed`]'s module doc for why: a `"= ..."` expression on +/// `opacity`/`width`/`height`/a covered `transform` leaf has to survive +/// deserialization without retyping the field it was written on (every +/// other property still deserializes through the ordinary derived logic, +/// via the private `CssStyleWire` mirror below — `#[serde(remote = +/// "CssStyle")]` lets that derive construct a real `CssStyle` directly, so +/// this hand-written impl only has to do two things: peel `"= ..."` strings +/// off the raw JSON first ([`computed::extract`]), then hand the cleaned +/// JSON to the derived logic and attach the extracted [`computed::ComputedStyle`] +/// afterwards. `Serialize` stays derived, unaffected — `expr` is +/// `#[serde(skip)]` and never round-trips through JSON. +#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)] #[serde(default, deny_unknown_fields, rename_all = "kebab-case")] pub struct CssStyle { // ---- Layout / box ---- @@ -155,12 +170,6 @@ pub struct CssStyle { /// width (this is `text`/`gradient_text`'s answer to Remotion's /// `fitText()`), it does not start wrapping. /// - /// **`auto_scroll`** (`codeblock`/`terminal`). Unrelated: `text-autofit` - /// is only read by `text`/`gradient_text`'s own painter/intrinsic — - /// `codeblock`/`terminal` never look at this field, so there is no - /// precedence to resolve between the two; `auto_scroll` keeps scrolling - /// (never shrinking) exactly as documented in `CLAUDE.md`. - /// /// **The floor.** Never shrinks below [`TEXT_AUTOFIT_MIN_FONT_PX`] — the /// same calibrated legibility ratio `check_legibility` /// (`rustmotion/src/cli/commands/geometry.rs`) already enforces, not a @@ -233,6 +242,152 @@ pub struct CssStyle { // ---- Audio reactive binding ---- #[serde(default)] pub audio_reactive: Option, + + // ---- Per-frame expression overrides (issue #338) ---- + /// The `"= ..."` expressions [`computed::extract`] pulled off this + /// node's `opacity`/`width`/`height`/covered `transform` leaves at + /// deserialize time — never part of the wire format (`#[serde(skip)]`: + /// it neither reads from nor writes to JSON), populated only by + /// `CssStyle`'s own hand-written `Deserialize` impl below. See + /// [`computed`]'s module doc. + #[serde(skip)] + #[schemars(skip)] + pub expr: computed::ComputedStyle, +} + +/// Field-for-field mirror of [`CssStyle`], used only as a `#[serde(remote)]` +/// deserialization target — see [`CssStyle`]'s own doc, "`Deserialize` is +/// hand-written, not derived". Never constructed directly; the derive macro +/// generates `CssStyleWire::deserialize(d) -> Result` as +/// an inherent function, which is all [`CssStyle`]'s hand-written impl below +/// calls. Keep this in sync with [`CssStyle`]'s own field list — a field +/// added to one and not the other fails to compile (a `remote` mismatch is a +/// type error, never a silent divergence), so drift cannot survive `cargo +/// check`. +#[derive(Deserialize, Default)] +#[serde( + remote = "CssStyle", + default, + deny_unknown_fields, + rename_all = "kebab-case" +)] +struct CssStyleWire { + display: Option, + position: Option, + top: Option, + right: Option, + bottom: Option, + left: Option, + + width: Option, + height: Option, + min_width: Option, + min_height: Option, + max_width: Option, + max_height: Option, + + margin: Option, + padding: Option, + border: Option, + box_sizing: Option, + aspect_ratio: Option, + + flex_direction: Option, + flex_wrap: Option, + justify_content: Option, + align_items: Option, + align_self: Option, + align_content: Option, + gap: Option, + flex_grow: Option, + flex_shrink: Option, + flex_basis: Option, + order: Option, + + grid_template_columns: Option>, + grid_template_rows: Option>, + grid_column: Option, + grid_row: Option, + grid_auto_flow: Option, + justify_items: Option, + justify_self: Option, + + font_family: Option, + font_size: Option, + font_weight: Option, + font_style: Option, + line_height: Option, + letter_spacing: Option, + text_align: Option, + color: Option, + white_space: Option, + overflow_wrap: Option, + text_overflow: Option, + text_decoration: Option, + text_autofit: Option, + + background: Option, + border_radius: Option, + box_shadow: Option>, + text_shadow: Option>, + opacity: Option, + mix_blend_mode: Option, + clip_path: Option, + gradient_border: Option, + + backdrop_blur: Option, + inner_shadow: Option, + + filter: Option>, + backdrop_filter: Option>, + + transform: Option>, + transform_origin: Option, + perspective: Option, + perspective_origin: Option, + + depth: Option, + + overflow: Option, + overflow_x: Option, + overflow_y: Option, + z_index: Option, + visibility: Option, + + #[serde(default, deserialize_with = "deserialize_animation_effects")] + animation: Vec, + transition: Option, + + #[serde(default)] + audio_reactive: Option, + + #[serde(skip)] + expr: computed::ComputedStyle, +} + +impl<'de> Deserialize<'de> for CssStyle { + /// Two steps, in order: (1) [`computed::extract`] peels any `"= ..."` + /// expression off `opacity`/`width`/`height`/a covered `transform` leaf, + /// mutating a `serde_json::Value` in place so the fields it touched are + /// left either absent or holding a neutral literal; (2) the cleaned + /// `Value` goes through [`CssStyleWire`]'s derived (ordinary, + /// `deny_unknown_fields`) logic for everything else. A style object that + /// isn't even a JSON object (malformed input) skips step 1 — there is + /// nothing to extract from — and step 2 then fails exactly as the old + /// derived impl would have. + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut value = serde_json::Value::deserialize(deserializer)?; + let expr = match value.as_object_mut() { + Some(obj) => computed::extract(obj).map_err(serde::de::Error::custom)?, + None => computed::ComputedStyle::default(), + }; + let mut style = CssStyleWire::deserialize(value).map_err(serde::de::Error::custom)?; + style.expr = expr; + Ok(style) + } } /// `transition` config: bare number = duration in seconds with the default @@ -1426,6 +1581,30 @@ pub enum ClipPath { Path { d: String, }, + /// Clip to another node's own path geometry, by id, instead of a literal + /// `d` frozen at author time. The motivating shape: two copies of the + /// same gem, each clipped by the same animated "crack" node, so the two + /// halves separate along a coherent, shared edge — a literal + /// `ClipPath::Path` on each copy could not track the crack's own + /// animation without duplicating it (and letting the two drift out of + /// sync the moment one copy's `d` is edited and the other isn't). + /// + /// This mirrors `node("id", "prop")` in `crate::expr`'s grammar, which + /// reads another node's already-resolved *scalar* through + /// `Scope::node_prop`; this variant instead names a node whose + /// *geometry* (its own resolved path — a `shape` with + /// `ShapeType::Path`, most naturally) should be read, so the `(id, + /// prop)` pair that call uses does not apply — `id` alone is enough to + /// say which node. + /// + /// Resolving `id` into an actual clip (finding the node, reading its + /// current-frame path, intersecting the canvas clip with it) is the + /// paint pipeline's job, not this enum's — same division as every other + /// `ClipPath` variant, none of which carry their own clipping logic + /// either. + NodePath { + id: String, + }, } // ---- Tests ---- diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index ba343334..715e3cc5 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -338,6 +338,20 @@ pub fn ease(t: f64, easing: &EasingType) -> f64 { EasingType::Bounce => bounce_ease_out(t), EasingType::Spring => t, // Spring handled separately EasingType::CubicBezier { x1, y1, x2, y2 } => cubic_bezier_ease(t, *x1, *y1, *x2, *y2), + // CSS `steps(n, jump-end)`: hold at step `i`'s level (`i / n`) for + // the whole `[i/n, (i+1)/n)` span, then jump. `t == 1.0` always + // lands exactly on `1.0` — the final jump — rather than on the + // last held step, which the `floor` below would otherwise produce + // (`floor(1.0 * n) / n == 1.0` only by coincidence of exact + // arithmetic; guarding it explicitly avoids relying on that). + EasingType::Steps(n) => { + let n = (*n).max(1) as f64; + if t >= 1.0 { + 1.0 + } else { + (t * n).floor() / n + } + } } } @@ -913,7 +927,7 @@ pub fn resolve_animations( for anim in all_animations { let anim_time = if should_loop { - loop_time(anim, time) + cycle_time(anim, time, &config) } else { time }; @@ -931,8 +945,23 @@ pub fn resolve_animations( props } -/// Wrap time within the animation's keyframe range for looping -fn loop_time(anim: &Animation, time: f64) -> f64 { +/// Maps `time` into the animation's own repeat cycle, honouring +/// `config.repeat_count` (finite vs. infinite), `config.yoyo` (ping-pong +/// direction) and `config.repeat_delay` (a pause held at each cycle's +/// resting value) — issue #330's generalisation of what used to be a +/// bare infinite modulo wrap. Only called when `config.repeat` is already +/// known true (see `resolve_animations`'s `should_loop` gate); a +/// non-looping animation never reaches this function. +/// +/// The default case — `repeat: true`, no `repeat_count`, `yoyo: false`, +/// `repeat_delay: 0.0` — reduces algebraically to `period == duration` and +/// `cycle_index` always even (never backward), so `within_cycle` is +/// exactly `(time - start) % duration` and the result is exactly +/// `start + (elapsed % duration)`: the old `loop_time` formula this +/// function replaces, byte-identical (see +/// `tests::repeat_true_with_no_new_fields_is_byte_identical_to_legacy_loop_time` +/// below). +fn cycle_time(anim: &Animation, time: f64, config: &PresetConfig) -> f64 { let keyframes = &anim.keyframes; if keyframes.len() < 2 { return time; @@ -943,7 +972,43 @@ fn loop_time(anim: &Animation, time: f64) -> f64 { if duration < 1e-9 || time < start { return time; } - start + ((time - start) % duration) + + // `repeat_delay` pads every cycle with a held pause before the next + // one starts — the period the clock wraps on is longer than the + // motion itself by exactly that pause. + let period = duration + config.repeat_delay.max(0.0); + if period < 1e-9 { + return time; + } + + let elapsed = time - start; + let mut cycle_index = (elapsed / period).floor() as i64; + if cycle_index < 0 { + cycle_index = 0; + } + + // A finite `repeat_count` freezes on the resting value of its last + // play once `time` runs past it, instead of continuing to cycle. + if let Some(count) = config.repeat_count { + let last_index = (count.max(1) - 1) as i64; + if cycle_index > last_index { + cycle_index = last_index; + } + } + + // Time spent inside this cycle's own motion window, clamped to + // `duration` — once past it, we're in the `repeat_delay` pause (or, + // for the clamped final cycle above, held there indefinitely). + let within_cycle = (elapsed - cycle_index as f64 * period) + .min(duration) + .max(0.0); + + let backward = config.yoyo && cycle_index % 2 == 1; + if backward { + end - within_cycle + } else { + start + within_cycle + } } /// Result of resolving an animation value — either a number or a color @@ -3516,3 +3581,215 @@ mod char_animation_tuning_tests { } } } + +#[cfg(test)] +mod easing_steps_tests { + //! Issue #330: `EasingType::Steps(n)` — a caret that jumps rather than + //! fades. `steps(1)` is the acceptance criterion's own example: hold + //! the start value for the whole segment, then jump to the end + //! exactly at `t = 1.0`. + use super::*; + + #[test] + fn steps_one_holds_the_start_value_until_the_very_end() { + let s = EasingType::Steps(1); + for t in [0.0, 0.1, 0.5, 0.9, 0.999_999] { + assert_eq!( + ease(t, &s), + 0.0, + "steps(1) must hold at 0.0 for the entire segment, t={t}" + ); + } + assert_eq!(ease(1.0, &s), 1.0, "steps(1) jumps to 1.0 exactly at t=1.0"); + } + + #[test] + fn steps_four_holds_four_discrete_levels() { + let s = EasingType::Steps(4); + assert_eq!(ease(0.0, &s), 0.0); + assert_eq!(ease(0.1, &s), 0.0); + assert_eq!(ease(0.24, &s), 0.0); + assert_eq!(ease(0.25, &s), 0.25); + assert_eq!(ease(0.49, &s), 0.25); + assert_eq!(ease(0.50, &s), 0.50); + assert_eq!(ease(0.75, &s), 0.75); + assert_eq!(ease(0.999, &s), 0.75); + assert_eq!(ease(1.0, &s), 1.0); + } + + #[test] + fn steps_zero_does_not_panic_or_divide_by_zero() { + let s = EasingType::Steps(0); + for t in [0.0, 0.5, 1.0] { + assert!(ease(t, &s).is_finite()); + } + } +} + +#[cfg(test)] +mod repeat_cycle_tests { + //! Issue #330: `PresetConfig::{repeat_count, yoyo, repeat_delay}` widen + //! what used to be a bare infinite-or-nothing `bool repeat`, resolved + //! by `cycle_time` (this module's private `loop_time` replacement). + use super::*; + + /// A single property ramping 0.0 -> 1.0 linearly over `[0, duration]`. + fn ramp(duration: f64) -> Animation { + kf_anim("x", 0.0, 0.0, duration, 1.0, EasingType::Linear) + } + + fn config(repeat_count: Option, yoyo: bool, repeat_delay: f64) -> PresetConfig { + PresetConfig { + repeat: true, + repeat_count, + yoyo, + repeat_delay, + ..Default::default() + } + } + + #[test] + fn repeat_true_with_no_new_fields_is_byte_identical_to_legacy_loop_time() { + // The exact formula the old `loop_time` used, kept here verbatim + // (not by calling `cycle_time`) as the independent reference this + // test checks `cycle_time` against. + fn legacy_loop_time(start: f64, duration: f64, time: f64) -> f64 { + if duration < 1e-9 || time < start { + return time; + } + start + ((time - start) % duration) + } + + let duration = 2.0; + let anim = ramp(duration); + let cfg = config(None, false, 0.0); + for t in [ + -1.0, 0.0, 0.3, 0.999_999, 1.0, 1.5, 1.999_999, 2.0, 2.000_001, 2.5, 3.999_999, 4.0, + 4.1, 10.3, 100.7, + ] { + let got = cycle_time(&anim, t, &cfg); + let legacy = legacy_loop_time(0.0, duration, t); + assert!( + (got - legacy).abs() < 1e-12, + "t={t}: cycle_time={got}, legacy loop_time={legacy}" + ); + } + } + + #[test] + fn a_finite_repeat_count_freezes_on_the_last_plays_resting_value() { + let duration = 1.0; + let anim = ramp(duration); + // 3 plays total: cycles [0,1), [1,2), [2,3). Past t=3 it must hold + // exactly the value cycle index 2 ends on (the ramp's own end, 1.0 + // in `x`-progress terms — checked here as resolved cycle_time). + let cfg = config(Some(3), false, 0.0); + let frozen_at = cycle_time(&anim, 3.0, &cfg); + for t in [3.0, 3.5, 10.0, 1_000.0] { + let got = cycle_time(&anim, t, &cfg); + assert!( + (got - frozen_at).abs() < 1e-9, + "t={t} must stay frozen at the last play's end ({frozen_at}), got {got}" + ); + } + // And the first two plays must still have actually cycled (not + // frozen from the start). + assert!((cycle_time(&anim, 0.5, &cfg) - 0.5).abs() < 1e-9); + assert!((cycle_time(&anim, 1.5, &cfg) - 0.5).abs() < 1e-9); + } + + #[test] + fn repeat_count_of_one_behaves_like_a_single_play() { + let duration = 1.0; + let anim = ramp(duration); + let looping = config(Some(1), false, 0.0); + for t in [0.0, 0.5, 1.0, 2.0, 5.0] { + let got = cycle_time(&anim, t, &looping); + let single_play = t.min(duration); + assert!((got - single_play).abs() < 1e-9, "t={t}: got {got}"); + } + } + + #[test] + fn yoyo_reverses_every_other_cycle() { + let duration = 1.0; + let anim = ramp(duration); + let cfg = config(None, true, 0.0); + // Cycle 0 (forward): local time == elapsed. + assert!((cycle_time(&anim, 0.25, &cfg) - 0.25).abs() < 1e-9); + // Cycle 1 (backward, elapsed in [1,2)): mapped time counts back + // down from the end (1.0) instead of up from the start. + assert!((cycle_time(&anim, 1.25, &cfg) - 0.75).abs() < 1e-9); + assert!((cycle_time(&anim, 1.75, &cfg) - 0.25).abs() < 1e-9); + // Cycle 2 (forward again): back to counting up from the start. + assert!((cycle_time(&anim, 2.25, &cfg) - 0.25).abs() < 1e-9); + } + + #[test] + fn yoyo_produces_a_continuous_value_at_every_cycle_boundary() { + // A ping-pong must never visibly jump at the seam between two + // cycles — the resolved value approaching a boundary from either + // side must converge to the same number. + let duration = 1.0; + let anim = ramp(duration); + let cfg = config(None, true, 0.0); + for boundary in [1.0, 2.0, 3.0] { + let just_before = cycle_time(&anim, boundary - 1e-6, &cfg); + let at = cycle_time(&anim, boundary, &cfg); + assert!( + (just_before - at).abs() < 1e-3, + "boundary {boundary}: just_before={just_before}, at={at}" + ); + } + } + + #[test] + fn repeat_delay_holds_the_resting_value_between_plays() { + let duration = 1.0; + let anim = ramp(duration); + let cfg = config(None, false, 0.5); // period = 1.5 + // Motion window [0,1): still animating. + assert!((cycle_time(&anim, 0.5, &cfg) - 0.5).abs() < 1e-9); + // Pause window [1,1.5): held at the end of the motion window (1.0). + assert!((cycle_time(&anim, 1.0, &cfg) - 1.0).abs() < 1e-9); + assert!((cycle_time(&anim, 1.3, &cfg) - 1.0).abs() < 1e-9); + // Next cycle starts fresh at 1.5. + assert!((cycle_time(&anim, 1.5, &cfg) - 0.0).abs() < 1e-9); + assert!((cycle_time(&anim, 2.0, &cfg) - 0.5).abs() < 1e-9); + } + + #[test] + fn resolve_animations_actually_applies_yoyo_and_repeat_count_end_to_end() { + // Same scenario at the public `resolve_animations` entry point + // (not just the private `cycle_time` helper), proving the fields + // reach the solver through `PresetConfig` for a real `Animation` + // list, not only for presets. `translate_x` is used instead of + // `ramp`'s own `"x"` property, which `apply_property` doesn't + // recognise. + let animations = vec![kf_anim( + "translate_x", + 0.0, + 0.0, + 1.0, + 1.0, + EasingType::Linear, + )]; + let cfg = config(Some(2), true, 0.0); + let at = |t: f64| -> f64 { + resolve_animations(&animations, None, Some(&cfg), t, 10.0).translate_x as f64 + }; + assert!((at(0.25) - 0.25).abs() < 1e-4, "forward play: {}", at(0.25)); + assert!( + (at(1.25) - 0.75).abs() < 1e-4, + "yoyo'd second play: {}", + at(1.25) + ); + // repeat_count: 2 -> only 2 plays; past t=2 it holds frozen. + let frozen = at(2.0); + assert!( + (at(5.0) - frozen).abs() < 1e-4, + "must stay frozen: {}", + at(5.0) + ); + } +} diff --git a/crates/rustmotion-core/src/engine/box_tree.rs b/crates/rustmotion-core/src/engine/box_tree.rs index 5142639d..b0ba1aec 100644 --- a/crates/rustmotion-core/src/engine/box_tree.rs +++ b/crates/rustmotion-core/src/engine/box_tree.rs @@ -1,7 +1,7 @@ //! Box tree — the intermediate representation between a JSON scenario and //! the layout/paint passes. Each node carries a resolved [`CssStyle`], a //! discriminator pointing to the source component, and an optional intrinsic -//! measurement callback (text, image, codeblock, chart, ...). +//! measurement callback (text, image, table, chart, ...). use std::sync::Arc; @@ -19,7 +19,7 @@ pub struct BoxNode { pub css: CssStyle, pub children: Vec, /// Optional intrinsic measurement (used by taffy's `measure_fn` for - /// leaves like text / codeblock / image). `None` = pure container. + /// leaves like text / table / image). `None` = pure container. pub intrinsic: Option>, /// JSON path of this node relative to its scene's `children` array, e.g. /// "/children/2/children/0". `None` for synthetic nodes (the scene root). diff --git a/crates/rustmotion-core/src/engine/deps.rs b/crates/rustmotion-core/src/engine/deps.rs new file mode 100644 index 00000000..3f9ece61 --- /dev/null +++ b/crates/rustmotion-core/src/engine/deps.rs @@ -0,0 +1,1046 @@ +//! Cross-node references — `node("id", "prop")` — for the expression engine +//! (issue #328). +//! +//! [`crate::expr`] parses `node("id", "prop")` and resolves it through +//! [`crate::expr::Scope::node_prop`] at eval time; this module is what a +//! [`Scope`](crate::expr::Scope) implementor calls to actually answer it. +//! Two problems, kept deliberately separate: +//! +//! - **Ordering.** A `line` whose `x2` reads `node("badge_3", "tx")` must +//! see `badge_3`'s transform *at the same frame*, never the previous +//! one. That only holds if `badge_3` is resolved before `line` on every +//! frame — declaration order in the JSON gives no such guarantee. [`scan_node_refs`] +//! finds every reference a node's own expressions make; [`DepGraph::build`] +//! turns those into a dependency graph, topologically sorted once at load +//! (Kahn's algorithm) so a per-frame resolution loop that visits ids in +//! [`DepGraph::order`] never reads a value that hasn't been computed yet +//! for the current frame. +//! - **Reading.** Once a node's per-frame state (its resolved +//! [`BoxLayout`], its animated transform, its text/glyph metrics if it has +//! any) exists, [`ResolvedNode::prop`] is the one place that maps a +//! `prop` string (`"x"`, `"tx"`, `"textWidth"`, `"glyph_x:3"`, ...) onto a +//! number. [`ResolvedFrame`] is the progressively-filled table a caller +//! builds while walking [`DepGraph::order`] — filled strictly in that +//! order, one node at a time, and never carried over from a previous +//! frame (a stale carry-over is exactly the one-frame-lag bug this module +//! exists to make impossible). +//! +//! # Why text metrics are a trait, not a direct call +//! +//! `rustmotion-core` doesn't know about `Text`/`GradientText` — those are +//! defined in `rustmotion-components`, which depends on this crate, not the +//! other way around. [`TextMetricsProvider`] is this module's half of that +//! bridge, the same role [`crate::engine::paint_pass::PaintDispatcher`] +//! already plays for painting: `rustmotion-components` provides the +//! concrete implementation (downcasting a node's `Arc` payload to +//! its real component type), this module only calls it. +//! +//! # What this module does not do +//! +//! It does not decide *which* JSON fields carry expressions, does not parse +//! or evaluate `Expr` itself (that's [`crate::expr`]), and does not run the +//! per-frame render loop (that lives in the higher-level crate). It is the +//! dependency graph plus the read side a caller's `Scope` impl and +//! evaluation loop are built on top of. + +use std::collections::{HashMap, HashSet, VecDeque}; + +use crate::engine::box_tree::{BoxKind, BoxNode}; +use crate::engine::layout_pass::{BoxLayout, LayoutResult}; +use crate::engine::paint_pass::animated_transform; +use crate::engine::renderer::GlyphMetric; + +// ─── Reference scanning ───────────────────────────────────────────────────── + +/// One `node("id", "prop")` occurrence found in an expression string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeRef { + pub id: String, + pub prop: String, +} + +/// Find every `node("id", "prop")` call in `src` (a raw scenario JSON +/// string value — typically an `"= ..."` expression, but this scans the +/// literal text regardless of the `=` prefix). +/// +/// Deliberately re-implements just the string-literal grammar +/// `crates/rustmotion-core/src/expr/lexer.rs` already defines for `node(...)` +/// arguments (double-quoted, `\"`/`\\` escapes) instead of depending on +/// [`crate::expr::Expr`]'s internals: `Expr` exposes [`crate::expr::Expr::free_vars`] +/// for `$name` references but deliberately not which node ids a `node(...)` +/// call touches (see that module's doc comment) — adding an accessor for +/// that would be reshaping a type this workstream must leave frozen. Building +/// the dependency graph only needs to *find* candidate references, not +/// evaluate anything, so a syntax-aware scan over the same two-string-literal +/// shape is sufficient and keeps this module self-contained. +pub fn scan_node_refs(src: &str) -> Vec { + let bytes = src.as_bytes(); + let mut out = Vec::new(); + let mut i = 0usize; + while i + 4 <= bytes.len() { + let is_word_start = i == 0 || !is_ident_byte(bytes[i - 1]); + if is_word_start && &bytes[i..i + 4] == b"node" { + let after_ident = i + 4; + let is_word_end = bytes.get(after_ident).is_none_or(|&b| !is_ident_byte(b)); + if is_word_end { + if let Some((node_ref, next)) = try_parse_node_call(src, after_ident) { + out.push(node_ref); + i = next; + continue; + } + } + } + i += 1; + } + out +} + +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +fn skip_ws(bytes: &[u8], mut i: usize) -> usize { + while i < bytes.len() && (bytes[i] as char).is_whitespace() { + i += 1; + } + i +} + +/// Having just consumed the identifier `node`, try to parse `(` string `,` +/// string `)` starting at `after_ident`. Returns the reference and the byte +/// offset right after the closing `)` on success. +fn try_parse_node_call(src: &str, after_ident: usize) -> Option<(NodeRef, usize)> { + let bytes = src.as_bytes(); + let mut i = skip_ws(bytes, after_ident); + if bytes.get(i) != Some(&b'(') { + return None; + } + i = skip_ws(bytes, i + 1); + let (id, next) = scan_string_literal(src, i)?; + i = skip_ws(bytes, next); + if bytes.get(i) != Some(&b',') { + return None; + } + i = skip_ws(bytes, i + 1); + let (prop, next) = scan_string_literal(src, i)?; + i = skip_ws(bytes, next); + if bytes.get(i) != Some(&b')') { + return None; + } + Some((NodeRef { id, prop }, i + 1)) +} + +/// Scan a double-quoted string literal starting at `start` (which must be +/// the opening `"`). Mirrors `expr::lexer::tokenize`'s string handling: +/// `\"` and `\\` are the only recognised escapes, everything else copies +/// through verbatim (full UTF-8, not just ASCII). +fn scan_string_literal(src: &str, start: usize) -> Option<(String, usize)> { + let bytes = src.as_bytes(); + if bytes.get(start) != Some(&b'"') { + return None; + } + let mut j = start + 1; + let mut s = String::new(); + loop { + match bytes.get(j) { + None => return None, + Some(b'"') => return Some((s, j + 1)), + Some(b'\\') if bytes.get(j + 1) == Some(&b'"') => { + s.push('"'); + j += 2; + } + Some(b'\\') if bytes.get(j + 1) == Some(&b'\\') => { + s.push('\\'); + j += 2; + } + Some(_) => { + let ch = src[j..].chars().next()?; + s.push(ch); + j += ch.len_utf8(); + } + } + } +} + +// ─── Dependency graph ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum DepsError { + /// A dependency cycle, e.g. `A -> B -> A`: `A` references `B` and `B` + /// (transitively) references `A` back. `chain` names every node on the + /// cycle in reference order, starting and ending on the same id. + #[error("node(\"...\") dependency cycle: {}", .chain.join(" -> "))] + Cycle { chain: Vec }, + /// `referencing` calls `node("{target}", ...)` but no node in this + /// scene declares that `id` — and it isn't declared in any other scene + /// either (see [`DepsError::CrossScene`] for that case). + #[error( + "node \"{referencing}\" references unknown id \"{target}\" via node(\"{target}\", ...)" + )] + UnknownId { referencing: String, target: String }, + /// `referencing` calls `node("{target}", ...)` where `target` is a real + /// id, but declared in a *different* scene. Rejected (`reference_cross_scene`): + /// scenes are independent render units, so a reference across that + /// boundary can never resolve to a real per-frame value. + #[error( + "node \"{referencing}\" references \"{target}\" from another scene \ + (reference_cross_scene: scenes are independent render units)" + )] + CrossScene { referencing: String, target: String }, + /// Two nodes in the same scene declare the same `id`. A node's `id` + /// must be unique within its scene (issue #328's own first deliverable) + /// — without this check, `node("dup", "x")` would silently resolve to + /// whichever of the two happened to be inserted into a + /// [`ResolvedFrame`] last, which is exactly the kind of load-time- + /// silent ambiguity this workstream exists to reject instead of guess + /// at. + #[error("duplicate node id \"{0}\" declared more than once in the same scene")] + DuplicateId(String), +} + +/// A scene's `node("id", ...)` dependency graph, topologically sorted once +/// at load. See the module doc for why the order matters. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DepGraph { + order: Vec, +} + +impl DepGraph { + /// Ids in dependency-first order: every id a node references appears + /// before that node itself. A node that makes no references of its + /// own — the common case, e.g. an orbiting badge nothing else depends + /// on for *its own* fields — can appear anywhere consistent with that + /// constraint; ties are broken by declaration order in the `nodes` + /// slice [`DepGraph::build`] was given, so the order is deterministic + /// across runs of the same scenario. + pub fn order(&self) -> &[String] { + &self.order + } + + /// Build and topologically sort the dependency graph for one scene. + /// + /// `nodes`: every id declared in this scene, paired with the + /// `node(...)` references its own expressions make. An id with no + /// references of its own still needs an entry (with an empty `Vec`) so + /// it appears in [`DepGraph::order`]. + /// + /// `other_scene_ids`: every id declared in *any other* scene of the + /// same scenario — consulted only to tell a genuinely unknown id apart + /// from one that exists but crosses a scene boundary. + pub fn build( + nodes: &[(String, Vec)], + other_scene_ids: &HashSet, + ) -> Result { + let mut known: HashSet<&str> = HashSet::with_capacity(nodes.len()); + for (id, _) in nodes { + if !known.insert(id.as_str()) { + return Err(DepsError::DuplicateId(id.clone())); + } + } + + // `dependents[x]` = nodes that reference `x` (must resolve after x). + let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut indegree: HashMap<&str, usize> = + nodes.iter().map(|(id, _)| (id.as_str(), 0)).collect(); + + for (id, refs) in nodes { + for r in refs { + if !known.contains(r.id.as_str()) { + if other_scene_ids.contains(&r.id) { + return Err(DepsError::CrossScene { + referencing: id.clone(), + target: r.id.clone(), + }); + } + return Err(DepsError::UnknownId { + referencing: id.clone(), + target: r.id.clone(), + }); + } + dependents + .entry(r.id.as_str()) + .or_default() + .push(id.as_str()); + *indegree.get_mut(id.as_str()).expect("id is known") += 1; + } + } + + // Kahn's algorithm, seeded in declaration order for determinism + // when several ids have no dependencies of their own. + let mut queue: VecDeque<&str> = nodes + .iter() + .map(|(id, _)| id.as_str()) + .filter(|id| indegree[id] == 0) + .collect(); + let mut order: Vec = Vec::with_capacity(nodes.len()); + while let Some(id) = queue.pop_front() { + order.push(id.to_string()); + if let Some(deps) = dependents.get(id) { + for &dep in deps { + let e = indegree.get_mut(dep).expect("dependent is known"); + *e -= 1; + if *e == 0 { + queue.push_back(dep); + } + } + } + } + + if order.len() != nodes.len() { + let resolved: HashSet<&str> = order.iter().map(|s| s.as_str()).collect(); + let residual: Vec<&str> = nodes + .iter() + .map(|(id, _)| id.as_str()) + .filter(|id| !resolved.contains(id)) + .collect(); + return Err(DepsError::Cycle { + chain: find_cycle_chain(&residual, nodes), + }); + } + + Ok(DepGraph { order }) + } +} + +/// Walk reference edges (`id -> the ids it references`) from the first +/// residual (unresolved) node until one repeats, and return that repeated +/// id's cycle as a chain (`A -> B -> A`). `residual` is guaranteed non-empty +/// by the only caller, and every node still in it has at least one +/// reference that is also in `residual` (that's *why* Kahn's algorithm +/// could never resolve it), so this always terminates by finding a repeat. +fn find_cycle_chain(residual: &[&str], nodes: &[(String, Vec)]) -> Vec { + let deps: HashMap<&str, Vec<&str>> = nodes + .iter() + .map(|(id, refs)| (id.as_str(), refs.iter().map(|r| r.id.as_str()).collect())) + .collect(); + let residual_set: HashSet<&str> = residual.iter().copied().collect(); + + let start = residual[0]; + let mut path: Vec<&str> = vec![start]; + let mut current = start; + loop { + let next = deps + .get(current) + .into_iter() + .flatten() + .find(|n| residual_set.contains(*n)) + .copied(); + match next { + Some(n) => { + if let Some(start_idx) = path.iter().position(|&x| x == n) { + let mut chain: Vec = + path[start_idx..].iter().map(|s| s.to_string()).collect(); + chain.push(n.to_string()); + return chain; + } + path.push(n); + current = n; + } + None => { + // Defensive: shouldn't happen for a genuine residual set, + // but never loop forever if it does. + return path.iter().map(|s| s.to_string()).collect(); + } + } + } +} + +// ─── Reading a resolved node ──────────────────────────────────────────────── + +/// Text/glyph measurements for one node, computed after `layout_pass` (its +/// wrap decision needs the node's resolved content-box width). See +/// [`TextMetricsProvider`]. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TextMetrics { + /// Widest wrapped line's measured width, in px. + pub text_width: f32, + pub cap_height: f32, + /// Distance from the top of the em box to the top of the tallest + /// glyphs, in px (font metric, wrap-independent). + pub ascender: f32, + /// Y offset of the first line's baseline, relative to the node's own + /// content-box top, in px. + pub baseline: f32, + /// Every glyph across every wrapped line, in reading order, `x` + /// relative to the node's own content-box left edge (line-local + /// position plus that line's `text-align` offset — see + /// [`crate::engine::renderer::GlyphMetric`]). + pub glyphs: Vec, +} + +/// Bridges `rustmotion-core` to the concrete component types (`Text`, +/// `GradientText`, ...) that only `rustmotion-components` knows about — see +/// the module doc's "Why text metrics are a trait" section. +pub trait TextMetricsProvider { + /// `payload` is a node's [`BoxKind::Component`]/[`BoxKind::Ghost`] + /// inner value; `content_box_width` is that node's own resolved + /// content-box width (`BoxLayout::content_box().2`, post-`layout_pass`). + /// Returns `None` for a non-text-bearing component, or one this + /// provider doesn't (yet) implement. + fn text_metrics( + &self, + payload: &(dyn std::any::Any + Send + Sync), + content_box_width: f32, + ) -> Option; +} + +/// A [`TextMetricsProvider`] that never has an answer — for callers with no +/// text-bearing nodes to resolve, or in tests that only exercise the +/// geometry/transform families. +pub struct NoTextMetrics; + +impl TextMetricsProvider for NoTextMetrics { + fn text_metrics( + &self, + _payload: &(dyn std::any::Any + Send + Sync), + _content_box_width: f32, + ) -> Option { + None + } +} + +/// One node's per-frame resolved state — everything [`ResolvedNode::prop`] +/// needs to answer any of the four families in issue #328's deliverable +/// table. +#[derive(Debug, Clone, Copy, Default)] +struct Transform { + tx: f32, + ty: f32, + scale: f32, + rotation: f32, + opacity: f32, +} + +#[derive(Debug, Clone)] +pub struct ResolvedNode { + layout: BoxLayout, + transform: Transform, + text: Option, +} + +impl ResolvedNode { + /// Build directly from already-resolved parts — for a caller that + /// doesn't have a [`BoxNode`] on hand (e.g. a test, or a synthetic + /// node). [`snapshot_node`] is the usual way to get one from a real box + /// tree. + pub fn new( + layout: BoxLayout, + transform: (f32, f32, f32, f32, f32), + text: Option, + ) -> Self { + let (tx, ty, scale, rotation, opacity) = transform; + Self { + layout, + transform: Transform { + tx, + ty, + scale, + rotation, + opacity, + }, + text, + } + } + + /// Resolve one `prop` string against this node's state. `None` means + /// "this node has no such property" (an unknown geometry/transform name, + /// or a text/glyph property on a node with no [`TextMetrics`], or an + /// out-of-range glyph index) — the caller's [`crate::expr::Scope::node_prop`] + /// turns that into [`crate::expr::ExprError::UnknownIdent`], same as any + /// other unresolved reference. + pub fn prop(&self, prop: &str) -> Option { + match prop { + "x" => Some(self.layout.x as f64), + "y" => Some(self.layout.y as f64), + "width" => Some(self.layout.width as f64), + "height" => Some(self.layout.height as f64), + "cx" => Some(self.layout.cx() as f64), + "cy" => Some(self.layout.cy() as f64), + "right" => Some(self.layout.right() as f64), + "bottom" => Some(self.layout.bottom() as f64), + "tx" => Some(self.transform.tx as f64), + "ty" => Some(self.transform.ty as f64), + "scale" => Some(self.transform.scale as f64), + "rotation" => Some(self.transform.rotation as f64), + "opacity" => Some(self.transform.opacity as f64), + "textWidth" => self.text.as_ref().map(|t| t.text_width as f64), + "capHeight" => self.text.as_ref().map(|t| t.cap_height as f64), + "ascender" => self.text.as_ref().map(|t| t.ascender as f64), + "baseline" => self.text.as_ref().map(|t| t.baseline as f64), + "glyph_count" => self.text.as_ref().map(|t| t.glyphs.len() as f64), + _ => glyph_prop(self.text.as_ref(), prop), + } + } +} + +/// `glyph_x:` / `glyph_cx:` — the glyph family's index is encoded in +/// the `prop` string itself (see the module doc: `node(...)`'s two +/// arguments are string literals, never sub-expressions, so there is no +/// third-argument call syntax to add without reshaping the frozen `expr` +/// grammar). `n` is a fixed literal the author writes, e.g. +/// `node("sentence", "glyph_x:7")` — consistent with issue #328's own +/// worked example (a `?` detaching from a sentence the author wrote +/// *without* its last character, so they already know the trailing index). +fn glyph_prop(text: Option<&TextMetrics>, prop: &str) -> Option { + let text = text?; + if let Some(n) = prop.strip_prefix("glyph_x:") { + let idx: usize = n.parse().ok()?; + return text.glyphs.get(idx).map(|g| g.x as f64); + } + if let Some(n) = prop.strip_prefix("glyph_cx:") { + let idx: usize = n.parse().ok()?; + return text.glyphs.get(idx).map(|g| (g.x + g.width / 2.0) as f64); + } + None +} + +/// Snapshot one [`BoxNode`]'s current-frame state — its resolved +/// [`BoxLayout`] (must already exist in `layout`, i.e. this runs after +/// `layout_pass`), its animated transform (decomposed from `node.css`, +/// already resolved for the current frame — see +/// [`crate::engine::paint_pass::animated_transform`]'s doc), and, if +/// `text_provider` recognises the node's component payload, its text/glyph +/// metrics. +/// +/// Returns `None` only when `node.id` has no entry in `layout` at all (the +/// node was never laid out — e.g. it doesn't exist in this frame's tree). +pub fn snapshot_node( + node: &BoxNode, + layout: &LayoutResult, + viewport: (f32, f32), + text_provider: &dyn TextMetricsProvider, +) -> Option { + let box_layout = *layout.get(node.id)?; + let transform = animated_transform(&node.css, &box_layout, viewport); + let text = match &node.kind { + BoxKind::Component(payload) | BoxKind::Ghost(payload) => { + let (_, _, content_w, _) = box_layout.content_box(); + text_provider.text_metrics(payload.as_ref(), content_w) + } + BoxKind::Container => None, + }; + Some(ResolvedNode::new(box_layout, transform, text)) +} + +/// A single frame's progressively-filled table of resolved nodes, keyed by +/// author `id`. Built by inserting one node at a time in [`DepGraph::order`] +/// — never in JSON/tree order, and never reused across frames (a fresh +/// `ResolvedFrame` every frame is what makes "no one-frame lag" structural +/// rather than a discipline the caller has to remember). +#[derive(Debug, Clone, Default)] +pub struct ResolvedFrame { + nodes: HashMap, +} + +impl ResolvedFrame { + pub fn new() -> Self { + Self::default() + } + + /// Record `id`'s resolved state. Called once per id, in + /// [`DepGraph::order`] — by the time a later id's own expressions are + /// evaluated (and therefore call [`Self::node_prop`]), every id it may + /// legally reference (per [`DepGraph::build`]'s cycle/cross-scene + /// checks) is already present. + pub fn insert(&mut self, id: impl Into, node: ResolvedNode) { + self.nodes.insert(id.into(), node); + } + + /// Resolve `node("id", "prop")`. `None` if `id` hasn't been inserted + /// yet (out-of-order use — a caller respecting [`DepGraph::order`] + /// never triggers this for a legal reference) or `prop` doesn't apply + /// to that node. + pub fn node_prop(&self, id: &str, prop: &str) -> Option { + self.nodes.get(id)?.prop(prop) + } +} + +/// Minimal [`crate::expr::Scope`] adapter over a [`ResolvedFrame`] — answers +/// `node_prop` only, `var` always `None`. A real per-frame `Scope` +/// (combining `$t`/`$W`/config variables with node references) implements +/// the trait itself and delegates its own `node_prop` to +/// [`ResolvedFrame::node_prop`]; this adapter exists for tests and any +/// caller with node references and nothing else to resolve. +pub struct FrameScope<'a>(pub &'a ResolvedFrame); + +impl crate::expr::Scope for FrameScope<'_> { + fn var(&self, _name: &str) -> Option { + None + } + + fn node_prop(&self, id: &str, prop: &str) -> Option { + self.0.node_prop(id, prop) + } +} + +#[cfg(test)] +mod scan_tests { + use super::*; + + #[test] + fn finds_a_single_reference() { + let refs = scan_node_refs(r#"= node("badge_3", "tx") + 1"#); + assert_eq!( + refs, + vec![NodeRef { + id: "badge_3".into(), + prop: "tx".into() + }] + ); + } + + #[test] + fn finds_multiple_references() { + let refs = scan_node_refs(r#"= node("a", "cx") - node("b", "cy")"#); + assert_eq!( + refs, + vec![ + NodeRef { + id: "a".into(), + prop: "cx".into() + }, + NodeRef { + id: "b".into(), + prop: "cy".into() + }, + ] + ); + } + + #[test] + fn tolerates_whitespace_variations() { + let refs = scan_node_refs(r#"=node( "a" ,"tx" )"#); + assert_eq!( + refs, + vec![NodeRef { + id: "a".into(), + prop: "tx".into() + }] + ); + } + + #[test] + fn handles_escaped_quotes_in_the_id() { + let refs = scan_node_refs(r#"= node("a\"b", "x")"#); + assert_eq!( + refs, + vec![NodeRef { + id: "a\"b".into(), + prop: "x".into() + }] + ); + } + + #[test] + fn does_not_match_node_as_a_substring_of_a_longer_identifier() { + assert!(scan_node_refs(r#"= anode("a", "x") + nodeFoo("b", "y")"#).is_empty()); + } + + #[test] + fn no_reference_in_plain_arithmetic() { + assert!(scan_node_refs("= $W / 2 + cos($i)").is_empty()); + } +} + +#[cfg(test)] +mod graph_tests { + use super::*; + + fn nodes(pairs: &[(&str, &[(&str, &str)])]) -> Vec<(String, Vec)> { + pairs + .iter() + .map(|(id, refs)| { + ( + id.to_string(), + refs.iter() + .map(|(rid, prop)| NodeRef { + id: rid.to_string(), + prop: prop.to_string(), + }) + .collect(), + ) + }) + .collect() + } + + #[test] + fn independent_nodes_keep_declaration_order() { + let n = nodes(&[("a", &[]), ("b", &[]), ("c", &[])]); + let g = DepGraph::build(&n, &HashSet::new()).unwrap(); + assert_eq!(g.order(), &["a", "b", "c"]); + } + + #[test] + fn a_dependent_resolves_after_its_dependency_even_when_declared_first() { + // `line` is declared before `badge` in the JSON, but depends on it — + // the graph must still put `badge` first. + let n = nodes(&[("line", &[("badge", "tx")]), ("badge", &[])]); + let g = DepGraph::build(&n, &HashSet::new()).unwrap(); + let pos = |id: &str| g.order().iter().position(|x| x == id).unwrap(); + assert!(pos("badge") < pos("line")); + } + + #[test] + fn chain_of_three_resolves_in_dependency_order() { + let n = nodes(&[("c", &[("b", "x")]), ("b", &[("a", "x")]), ("a", &[])]); + let g = DepGraph::build(&n, &HashSet::new()).unwrap(); + assert_eq!(g.order(), &["a", "b", "c"]); + } + + #[test] + fn direct_two_node_cycle_is_reported_with_both_names() { + let n = nodes(&[("a", &[("b", "x")]), ("b", &[("a", "x")])]); + let err = DepGraph::build(&n, &HashSet::new()).unwrap_err(); + match err { + DepsError::Cycle { chain } => { + assert!(chain.contains(&"a".to_string())); + assert!(chain.contains(&"b".to_string())); + assert_eq!(chain.first(), chain.last()); + } + other => panic!("expected Cycle, got {other:?}"), + } + } + + #[test] + fn three_node_cycle_is_reported() { + let n = nodes(&[ + ("a", &[("b", "x")]), + ("b", &[("c", "x")]), + ("c", &[("a", "x")]), + ]); + let err = DepGraph::build(&n, &HashSet::new()).unwrap_err(); + match err { + DepsError::Cycle { chain } => { + for id in ["a", "b", "c"] { + assert!( + chain.contains(&id.to_string()), + "chain missing {id}: {chain:?}" + ); + } + } + other => panic!("expected Cycle, got {other:?}"), + } + } + + #[test] + fn self_reference_is_a_one_node_cycle() { + let n = nodes(&[("a", &[("a", "x")])]); + let err = DepGraph::build(&n, &HashSet::new()).unwrap_err(); + assert!(matches!(err, DepsError::Cycle { .. })); + } + + #[test] + fn duplicate_id_in_the_same_scene_is_rejected() { + let n = nodes(&[("dup", &[]), ("other", &[]), ("dup", &[])]); + let err = DepGraph::build(&n, &HashSet::new()).unwrap_err(); + match err { + DepsError::DuplicateId(id) => assert_eq!(id, "dup"), + other => panic!("expected DuplicateId, got {other:?}"), + } + } + + #[test] + fn reference_to_an_id_declared_nowhere_is_unknown() { + let n = nodes(&[("a", &[("ghost", "x")])]); + let err = DepGraph::build(&n, &HashSet::new()).unwrap_err(); + match err { + DepsError::UnknownId { + referencing, + target, + } => { + assert_eq!(referencing, "a"); + assert_eq!(target, "ghost"); + } + other => panic!("expected UnknownId, got {other:?}"), + } + } + + #[test] + fn reference_to_an_id_from_another_scene_is_cross_scene() { + let n = nodes(&[("a", &[("other_scene_node", "x")])]); + let mut other = HashSet::new(); + other.insert("other_scene_node".to_string()); + let err = DepGraph::build(&n, &other).unwrap_err(); + match err { + DepsError::CrossScene { + referencing, + target, + } => { + assert_eq!(referencing, "a"); + assert_eq!(target, "other_scene_node"); + } + other => panic!("expected CrossScene, got {other:?}"), + } + } +} + +#[cfg(test)] +mod resolution_tests { + use super::*; + use crate::expr::Expr; + + fn layout(x: f32, y: f32, w: f32, h: f32) -> BoxLayout { + BoxLayout { + x, + y, + width: w, + height: h, + ..Default::default() + } + } + + #[test] + fn geometry_family_reads_from_layout() { + let mut frame = ResolvedFrame::new(); + frame.insert( + "box", + ResolvedNode::new( + layout(10.0, 20.0, 100.0, 50.0), + (0.0, 0.0, 1.0, 0.0, 1.0), + None, + ), + ); + assert_eq!(frame.node_prop("box", "x"), Some(10.0)); + assert_eq!(frame.node_prop("box", "y"), Some(20.0)); + assert_eq!(frame.node_prop("box", "width"), Some(100.0)); + assert_eq!(frame.node_prop("box", "height"), Some(50.0)); + assert_eq!(frame.node_prop("box", "cx"), Some(60.0)); + assert_eq!(frame.node_prop("box", "cy"), Some(45.0)); + assert_eq!(frame.node_prop("box", "right"), Some(110.0)); + assert_eq!(frame.node_prop("box", "bottom"), Some(70.0)); + } + + #[test] + fn animated_transform_family_reads_through() { + let mut frame = ResolvedFrame::new(); + frame.insert( + "chip", + ResolvedNode::new( + layout(0.0, 0.0, 10.0, 10.0), + (12.5, -3.0, 1.5, 45.0, 0.8), + None, + ), + ); + assert_eq!(frame.node_prop("chip", "tx"), Some(12.5)); + assert_eq!(frame.node_prop("chip", "ty"), Some(-3.0)); + assert_eq!(frame.node_prop("chip", "scale"), Some(1.5)); + assert_eq!(frame.node_prop("chip", "rotation"), Some(45.0)); + // `0.8` isn't exactly representable in binary floating point, so the + // f32 input promoted to f64 and an f64 literal `0.8` round + // differently in their last bit — compare against the *same* + // f32->f64 promotion rather than a fresh f64 literal. + assert_eq!(frame.node_prop("chip", "opacity"), Some(0.8_f32 as f64)); + } + + #[test] + fn text_and_glyph_families_are_none_without_text_metrics() { + let mut frame = ResolvedFrame::new(); + frame.insert( + "box", + ResolvedNode::new( + layout(0.0, 0.0, 10.0, 10.0), + (0.0, 0.0, 1.0, 0.0, 1.0), + None, + ), + ); + assert_eq!(frame.node_prop("box", "textWidth"), None); + assert_eq!(frame.node_prop("box", "glyph_count"), None); + assert_eq!(frame.node_prop("box", "glyph_x:0"), None); + } + + #[test] + fn text_and_glyph_families_read_through() { + let text = TextMetrics { + text_width: 88.0, + cap_height: 14.0, + ascender: 18.0, + baseline: 22.0, + glyphs: vec![ + GlyphMetric { + x: 0.0, + width: 10.0, + }, + GlyphMetric { + x: 10.0, + width: 12.0, + }, + ], + }; + let mut frame = ResolvedFrame::new(); + frame.insert( + "sentence", + ResolvedNode::new( + layout(0.0, 0.0, 100.0, 30.0), + (0.0, 0.0, 1.0, 0.0, 1.0), + Some(text), + ), + ); + assert_eq!(frame.node_prop("sentence", "textWidth"), Some(88.0)); + assert_eq!(frame.node_prop("sentence", "capHeight"), Some(14.0)); + assert_eq!(frame.node_prop("sentence", "ascender"), Some(18.0)); + assert_eq!(frame.node_prop("sentence", "baseline"), Some(22.0)); + assert_eq!(frame.node_prop("sentence", "glyph_count"), Some(2.0)); + assert_eq!(frame.node_prop("sentence", "glyph_x:1"), Some(10.0)); + assert_eq!(frame.node_prop("sentence", "glyph_cx:1"), Some(16.0)); + assert_eq!(frame.node_prop("sentence", "glyph_x:5"), None); + } + + #[test] + fn unresolved_reference_surfaces_as_unknown_ident_through_expr() { + let frame = ResolvedFrame::new(); + let scope = FrameScope(&frame); + let expr = Expr::parse(r#"= node("missing", "x")"#).unwrap(); + let err = expr.eval(&scope).unwrap_err(); + assert!(matches!(err, crate::expr::ExprError::UnknownIdent(_))); + } + + #[test] + fn expression_over_a_resolved_node_matches_hand_computed_value() { + let mut frame = ResolvedFrame::new(); + frame.insert( + "chip3", + ResolvedNode::new( + layout(500.0, 300.0, 40.0, 40.0), + (77.0, -12.0, 1.0, 0.0, 1.0), + None, + ), + ); + let scope = FrameScope(&frame); + // The acceptance example from issue #328: `x2 = node("chip3", "cx") + + // node("chip3", "tx")`. + let expr = Expr::parse(r#"= node("chip3", "cx") + node("chip3", "tx")"#).unwrap(); + let got = expr.eval(&scope).unwrap(); + assert_eq!(got, 520.0 + 77.0); + } + + // ─── The trap this workstream exists to avoid: topological, not tree, + // order (issue #328's own acceptance bar) ────────────────────────────── + + /// Builds a two-node scene ("badge" orbits, "line" reads + /// `node("badge", "tx"|"ty")`) across several frames, resolving strictly + /// in [`DepGraph::order`] every frame (a *fresh* [`ResolvedFrame`] each + /// time — no frame-to-frame carry-over). At every sampled frame, the + /// value `line`'s expression reads back must equal `badge`'s own + /// transform *at that same frame*, not the previous one. A resolver + /// that read `badge` in tree/declaration order instead of dependency + /// order (or reused a stale `ResolvedFrame` across frames) would report + /// last frame's `badge` position here — this test fails against that + /// implementation and passes against this module's. + #[test] + fn orbiting_node_reference_tracks_every_frame_with_no_one_frame_lag() { + let n = vec![ + ( + "line".to_string(), + vec![ + NodeRef { + id: "badge".to_string(), + prop: "tx".to_string(), + }, + NodeRef { + id: "badge".to_string(), + prop: "ty".to_string(), + }, + ], + ), + ("badge".to_string(), vec![]), + ]; + let graph = DepGraph::build(&n, &HashSet::new()).unwrap(); + assert_eq!(graph.order(), &["badge", "line"]); + + let orbit_transform = |angle_deg: f32| -> (f32, f32, f32, f32, f32) { + let (s, c) = angle_deg.to_radians().sin_cos(); + (c * 200.0, s * 200.0, 1.0, 0.0, 1.0) + }; + + for angle in [0.0f32, 37.0, 90.0, 181.0, 269.5, 350.0] { + // A brand-new frame every iteration: nothing here can leak a + // value from the previous angle. + let mut frame = ResolvedFrame::new(); + for id in graph.order() { + match id.as_str() { + "badge" => { + frame.insert( + "badge", + ResolvedNode::new( + layout(0.0, 0.0, 20.0, 20.0), + orbit_transform(angle), + None, + ), + ); + } + "line" => { + // `line`'s own endpoint is itself an expression — + // evaluated here, after `badge` (earlier in + // topological order) is already in `frame`. + let scope = FrameScope(&frame); + let x2 = Expr::parse(r#"= node("badge", "tx")"#) + .unwrap() + .eval(&scope) + .unwrap(); + let y2 = Expr::parse(r#"= node("badge", "ty")"#) + .unwrap() + .eval(&scope) + .unwrap(); + frame.insert( + "line", + ResolvedNode::new( + layout(x2 as f32, y2 as f32, 0.0, 0.0), + (0.0, 0.0, 1.0, 0.0, 1.0), + None, + ), + ); + } + other => panic!("unexpected id {other}"), + } + } + + let (expected_tx, expected_ty, ..) = orbit_transform(angle); + let line = &frame.nodes["line"]; + assert_eq!( + line.layout.x, expected_tx, + "angle {angle}: line.x2 lagged behind badge.tx" + ); + assert_eq!( + line.layout.y, expected_ty, + "angle {angle}: line.y2 lagged behind badge.ty" + ); + } + } + + /// Same scenario, but resolved in *declaration* (tree) order instead of + /// [`DepGraph::order`] — `line` is declared first in this scene, so a + /// caller that (incorrectly) walked the JSON in document order would + /// try to resolve `line`'s `node("badge", ...)` reference before + /// `badge` has ever been inserted into `frame`. That must fail loudly + /// (`ExprError::UnknownIdent`), not silently reuse whatever `badge` + /// last resolved to — proving there is no hidden fallback that would + /// mask the one-frame-lag bug the topological order exists to prevent. + #[test] + fn resolving_in_declaration_order_instead_of_topological_order_fails_loudly() { + let mut frame = ResolvedFrame::new(); + // Declaration order: "line" first, "badge" second — the wrong order. + let scope = FrameScope(&frame); + let err = Expr::parse(r#"= node("badge", "tx")"#) + .unwrap() + .eval(&scope) + .unwrap_err(); + assert!(matches!(err, crate::expr::ExprError::UnknownIdent(_))); + + // Only after inserting "badge" (i.e. respecting the dependency + // order) does the same expression resolve. + frame.insert( + "badge", + ResolvedNode::new( + layout(0.0, 0.0, 20.0, 20.0), + (99.0, 0.0, 1.0, 0.0, 1.0), + None, + ), + ); + let scope = FrameScope(&frame); + let got = Expr::parse(r#"= node("badge", "tx")"#) + .unwrap() + .eval(&scope) + .unwrap(); + assert_eq!(got, 99.0); + } +} diff --git a/crates/rustmotion-core/src/engine/layout_pass.rs b/crates/rustmotion-core/src/engine/layout_pass.rs index e9a03c5e..3f8188a0 100644 --- a/crates/rustmotion-core/src/engine/layout_pass.rs +++ b/crates/rustmotion-core/src/engine/layout_pass.rs @@ -29,6 +29,28 @@ pub struct Insets { } impl BoxLayout { + /// Horizontal centre of the border box, in absolute viewport + /// coordinates. One of the geometry properties a `node("id", "cx")` + /// expression reads post-`layout_pass` (issue #328). + pub fn cx(&self) -> f32 { + self.x + self.width / 2.0 + } + + /// Vertical centre of the border box. See [`Self::cx`]. + pub fn cy(&self) -> f32 { + self.y + self.height / 2.0 + } + + /// Right edge of the border box (`x + width`). See [`Self::cx`]. + pub fn right(&self) -> f32 { + self.x + self.width + } + + /// Bottom edge of the border box (`y + height`). See [`Self::cx`]. + pub fn bottom(&self) -> f32 { + self.y + self.height + } + pub fn content_box(&self) -> (f32, f32, f32, f32) { let x = self.x + self.border.left + self.padding.left; let y = self.y + self.border.top + self.padding.top; diff --git a/crates/rustmotion-core/src/engine/mod.rs b/crates/rustmotion-core/src/engine/mod.rs index 87c390ab..e9778ff0 100644 --- a/crates/rustmotion-core/src/engine/mod.rs +++ b/crates/rustmotion-core/src/engine/mod.rs @@ -1,9 +1,11 @@ pub mod animator; pub mod box_tree; +pub mod deps; pub mod heropatterns; pub mod layout_pass; pub mod paint_pass; pub mod renderer; +pub mod shake; pub mod text; pub mod transition; diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 4b93fe75..6b7eeaf8 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -1116,6 +1116,91 @@ fn apply_transform( } } +/// Decomposes a node's already-resolved `CssStyle.transform`/`opacity` into +/// the five scalars a `node("id", "tx"|"ty"|"scale"|"rotation"|"opacity")` +/// expression reads (issue #328) — the "animated transform" family, the one +/// the eight orbiting-badge lines this workstream exists for actually need. +/// +/// Reads straight off `css`/`layout`, both already resolved for the current +/// frame by the time this is called (animation resolution happens once per +/// frame, before the box tree is built — see `rustmotion-components`' +/// `box_builder::build_scene_at_time`), so this never reaches back into the +/// animator itself and never risks reading a stale frame. +/// +/// Compound transforms (more than one `translate`/`scale`/`rotate` function +/// on a single node, or a raw `matrix`/`matrix3d`) are approximated by +/// folding the individual functions in list order — summing translations, +/// multiplying scale factors, summing rotation degrees — rather than +/// composing an actual matrix and decomposing it. That is exact for the +/// single-function-per-frame case every `AnimationEffect` preset in this +/// engine produces (an orbiting badge's `orbit`/keyframe animation resolves +/// to one `translate`, not several), and is the documented limit for a +/// hand-authored `transform` list beyond that. `scale` collapses `scale_x`/ +/// `scale_y` to their average — exact for the overwhelmingly common uniform +/// case (`scale_x == scale_y`) and a reasonable single-scalar stand-in +/// otherwise, since the expression grammar has no vector return type to +/// hand back `(scale_x, scale_y)` separately. `rotation` sums only +/// `rotate`/`rotate_z`/`rotate3d`'s own `deg` — `rotate_x`/`rotate_y` tilt +/// out of the 2D plane a `line` endpoint lives in, so folding them into the +/// same scalar would misrepresent what's actually visible on screen. +pub fn animated_transform( + css: &CssStyle, + layout: &BoxLayout, + viewport: (f32, f32), +) -> (f32, f32, f32, f32, f32) { + let length_ctx = LengthContext { + viewport_width: viewport.0, + viewport_height: viewport.1, + parent_size: layout.width.max(layout.height), + font_size: css.font_size_px_or(16.0), + root_font_size: 16.0, + }; + let ctx_x = LengthContext { + parent_size: layout.width, + ..length_ctx + }; + let ctx_y = LengthContext { + parent_size: layout.height, + ..length_ctx + }; + + let mut tx = 0.0f32; + let mut ty = 0.0f32; + let mut scale_x = 1.0f32; + let mut scale_y = 1.0f32; + let mut rotation = 0.0f32; + for t in css.transform.as_deref().unwrap_or(&[]) { + match t { + TransformFn::Translate { x, y } => { + tx += x.resolve(&ctx_x); + ty += y.resolve(&ctx_y); + } + TransformFn::TranslateX { x } => tx += x.resolve(&ctx_x), + TransformFn::TranslateY { y } => ty += y.resolve(&ctx_y), + TransformFn::Translate3d { x, y, .. } => { + tx += x.resolve(&ctx_x); + ty += y.resolve(&ctx_y); + } + TransformFn::Scale { x, y } => { + scale_x *= x; + scale_y *= y; + } + TransformFn::ScaleX { x } => scale_x *= x, + TransformFn::ScaleY { y } => scale_y *= y, + TransformFn::Scale3d { x, y, .. } => { + scale_x *= x; + scale_y *= y; + } + TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => rotation += deg, + TransformFn::Rotate3d { deg, .. } => rotation += deg, + _ => {} + } + } + let scale = (scale_x + scale_y) / 2.0; + let opacity = css.opacity.unwrap_or(1.0); + (tx, ty, scale, rotation, opacity) +} + /// CSS `perspective(d)` projection matrix in row-major form. /// Maps (x, y, z, 1) → w' = 1 - z/d; perspective divide yields depth scaling. fn css_perspective_m44(d: f32) -> M44 { @@ -3173,3 +3258,86 @@ mod tests { assert_eq!(c, SColor::from_argb(255, 255, 0, 255)); } } + +#[cfg(test)] +mod animated_transform_tests { + use super::*; + use crate::css::units::LengthPercentage as CLP; + + fn layout(w: f32, h: f32) -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width: w, + height: h, + ..Default::default() + } + } + + #[test] + fn no_transform_is_identity_with_full_opacity() { + let css = CssStyle::default(); + let (tx, ty, scale, rotation, opacity) = + animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0)); + assert_eq!( + (tx, ty, scale, rotation, opacity), + (0.0, 0.0, 1.0, 0.0, 1.0) + ); + } + + #[test] + fn single_translate_and_opacity_roundtrip() { + let css = CssStyle { + transform: Some(vec![TransformFn::Translate { + x: CLP::Px(42.0), + y: CLP::Px(-7.0), + }]), + opacity: Some(0.5), + ..Default::default() + }; + let (tx, ty, _scale, _rotation, opacity) = + animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0)); + assert_eq!(tx, 42.0); + assert_eq!(ty, -7.0); + assert_eq!(opacity, 0.5); + } + + #[test] + fn uniform_scale_and_rotation() { + let css = CssStyle { + transform: Some(vec![ + TransformFn::Scale { x: 2.0, y: 2.0 }, + TransformFn::Rotate { deg: 30.0 }, + ]), + ..Default::default() + }; + let (_tx, _ty, scale, rotation, _opacity) = + animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0)); + assert_eq!(scale, 2.0); + assert_eq!(rotation, 30.0); + } + + #[test] + fn an_orbiting_node_at_two_different_frames_reports_two_different_positions() { + // The exact shape of the "eight lines to orbiting badges" scenario: + // a node whose `transform` is a single `translate` recomputed every + // frame by the (unowned) animator. Simulating two frames' worth of + // already-resolved CSS here proves `animated_transform` reads + // whatever it is handed, frame-fresh, with no memory of the last + // call — the property the per-frame, topological resolution in + // `engine::deps` depends on. + let orbit = |angle_deg: f32| { + let mut css = CssStyle::default(); + let (s, c) = angle_deg.to_radians().sin_cos(); + css.transform = Some(vec![TransformFn::Translate { + x: CLP::Px(c * 100.0), + y: CLP::Px(s * 100.0), + }]); + css + }; + let l = layout(10.0, 10.0); + let (tx0, ty0, ..) = animated_transform(&orbit(0.0), &l, (1920.0, 1080.0)); + let (tx1, ty1, ..) = animated_transform(&orbit(90.0), &l, (1920.0, 1080.0)); + assert!((tx0 - tx1).abs() > 1.0 || (ty0 - ty1).abs() > 1.0); + } +} diff --git a/crates/rustmotion-core/src/engine/renderer/assets.rs b/crates/rustmotion-core/src/engine/renderer/assets.rs index 97345599..00cf203a 100644 --- a/crates/rustmotion-core/src/engine/renderer/assets.rs +++ b/crates/rustmotion-core/src/engine/renderer/assets.rs @@ -137,8 +137,8 @@ fn icon_cache_file(cache_dir: &Path, icon: &str, color: &str, width: u32, height /// Fetch an icon's SVG bytes, checking the on-disk cache first and falling /// back to the Iconify API on a miss. Same public signature as before this -/// fix — every existing caller (icon.rs, preload.rs, badge.rs, -/// notification.rs, list.rs, stat.rs) gets the disk cache for free. +/// fix — every existing caller (icon.rs, preload.rs, badge.rs, list.rs, +/// stat.rs) gets the disk cache for free. pub fn fetch_icon_svg(icon: &str, color: &str, width: u32, height: u32) -> Result> { fetch_icon_svg_in(icon, color, width, height, &icon_cache_dir()) } diff --git a/crates/rustmotion-core/src/engine/renderer/fonts.rs b/crates/rustmotion-core/src/engine/renderer/fonts.rs index 879d86c5..8de53b60 100644 --- a/crates/rustmotion-core/src/engine/renderer/fonts.rs +++ b/crates/rustmotion-core/src/engine/renderer/fonts.rs @@ -125,15 +125,14 @@ fn custom_typeface(family: &str, style: FontStyle) -> Option { /// Look up only the custom/Google-font registry for `family` at the /// requested `style`, without falling through to any system font. Exposed -/// for callers (e.g. `codeblock`/`terminal`'s monospace font resolver) that +/// for callers with their own family-specific system fallback chain that /// need to check "did the scenario declare a custom font for this family" -/// *before* trying their own family-specific system fallback chain — unlike -/// [`typeface_with_fallback`], which interleaves a single system-family -/// lookup between the custom check and its own generic Helvetica/Arial -/// catch-all, an order that doesn't suit every caller (see issue: codeblock/ -/// terminal's hardcoded monospace fallback list was never reached because -/// `typeface_with_fallback`'s own system lookup already matched a decoy -/// system family, e.g. "JetBrains Mono"). +/// *before* trying that chain — unlike [`typeface_with_fallback`], which +/// interleaves a single system-family lookup between the custom check and +/// its own generic Helvetica/Arial catch-all, an order that doesn't suit +/// every caller (a caller with a hardcoded monospace fallback list, for +/// instance, would never reach it if `typeface_with_fallback`'s own system +/// lookup already matched a decoy system family, e.g. "JetBrains Mono"). pub fn resolve_custom_typeface(family: &str, style: FontStyle) -> Option { custom_typeface(family, style) } diff --git a/crates/rustmotion-core/src/engine/renderer/text.rs b/crates/rustmotion-core/src/engine/renderer/text.rs index 6d578977..59cb559d 100644 --- a/crates/rustmotion-core/src/engine/renderer/text.rs +++ b/crates/rustmotion-core/src/engine/renderer/text.rs @@ -118,6 +118,86 @@ pub fn make_text_blob_with_spacing(text: &str, font: &Font, spacing: f32) -> Opt TextBlob::from_pos_text(text, &positions, font) } +// ─── Glyph metrics (issue #328) ───────────────────────────────────────────── +// +// Glyph positions were already computed here — `str_to_glyphs_vec`, then +// `get_widths`, then a `positions` vector, exactly what +// `make_text_blob_with_spacing` above builds — but only ever consumed +// straight into a `TextBlob` and thrown away once painted. Nothing let an +// expression ask where, say, the last glyph of a typed sentence actually +// landed. The functions below retain that same per-glyph data instead of +// discarding it. + +/// One glyph's left edge and advance width, in a **line's own** coordinate +/// space: `x == 0.0` at the start of the line, before any `text-align` +/// offset a caller applies on top (mirrors [`draw_text_with_fallback`]'s own +/// `cursor_x`, which starts at the line's left edge for the same reason). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GlyphMetric { + pub x: f32, + pub width: f32, +} + +/// Per-glyph `(x, width)` for `text` measured against a single `font`, no +/// run segmentation — the same primitive [`make_text_blob_with_spacing`] +/// already computes (`str_to_glyphs_vec` + `get_widths`) to place each +/// glyph in a `TextBlob`, retained here as positions instead of being +/// consumed straight into one. +fn glyph_metrics_single_font(text: &str, font: &Font, letter_spacing: f32) -> Vec { + let glyphs = font.str_to_glyphs_vec(text); + if glyphs.is_empty() { + return Vec::new(); + } + let mut widths = vec![0.0f32; glyphs.len()]; + font.get_widths(&glyphs, &mut widths); + let mut out = Vec::with_capacity(glyphs.len()); + let mut x = 0.0f32; + for w in widths { + out.push(GlyphMetric { x, width: w }); + x += w + letter_spacing; + } + out +} + +/// Per-glyph `(x, width)` for one already-wrapped line of `text`, run-aware +/// (emoji-font and glyph-coverage fallback — see [`segment_text_runs`]) +/// exactly like [`draw_text_with_fallback`]: same runs, same per-run font +/// choice, so a glyph index into this list lines up with the glyph +/// `draw_text_with_fallback` actually paints at that position. `x` is +/// line-relative (see [`GlyphMetric`]'s doc) — a caller placing the line +/// inside a box via `text-align` adds its own line offset on top, the same +/// way `draw_text_with_fallback`'s callers already compute `line_x`. +pub fn compute_glyph_metrics( + text: &str, + font: &Font, + emoji_font: &Option, + letter_spacing: f32, +) -> Vec { + if text.is_empty() { + return Vec::new(); + } + if !needs_segmentation(text, font, emoji_font) { + return glyph_metrics_single_font(text, font, letter_spacing); + } + + let runs = segment_text_runs(text, font); + let mut out = Vec::new(); + let mut cursor_x = 0.0f32; + for run in &runs { + let segment = &text[run.start..run.end]; + let mut owned = None; + let f = resolve_run_font(&run.kind, segment, font, emoji_font, &mut owned); + let metrics = glyph_metrics_single_font(segment, f, letter_spacing); + let run_advance: f32 = metrics.iter().map(|m| m.width + letter_spacing).sum(); + out.extend(metrics.into_iter().map(|m| GlyphMetric { + x: cursor_x + m.x, + width: m.width, + })); + cursor_x += run_advance; + } + out +} + // ─── Emoji support ────────────────────────────────────────────────────────── /// Code points that render as emoji **by default**, in every context, @@ -350,12 +430,11 @@ fn needs_segmentation(text: &str, primary: &Font, emoji_font: &Option) -> text.chars().any(|c| { // ASCII short-circuits before the `unichar_to_glyph` FFI call: every // font this engine resolves covers printable ASCII, and callers - // that draw a lot of short spans per frame (codeblock's per-token - // syntax highlighting, in particular) call this once per span — - // skipping the Skia round-trip for the overwhelmingly common - // all-ASCII case keeps #3's fix from adding per-glyph FFI overhead - // to code that was never affected by the tofu/coverage bug it - // fixes. + // that draw a lot of short spans per frame (`rich_text`'s per-span + // styling, in particular) call this once per span — skipping the + // Skia round-trip for the overwhelmingly common all-ASCII case + // keeps #3's fix from adding per-glyph FFI overhead to code that + // was never affected by the tofu/coverage bug it fixes. !(c.is_ascii() || c.is_whitespace() || (c as u32) < 0x20) && primary.unichar_to_glyph(c as i32) == 0 }) @@ -1201,3 +1280,108 @@ mod glyph_fallback_tests { ); } } + +// ─── Tests: glyph metrics (issue #328) ────────────────────────────────────── + +#[cfg(test)] +mod glyph_metrics_tests { + use super::super::typeface_with_fallback; + use super::*; + use skia_safe::FontStyle as SkFontStyle; + + fn test_font(size: f32) -> Font { + let typeface = typeface_with_fallback("Helvetica", SkFontStyle::default()) + .expect("host must have a fallback typeface"); + Font::from_typeface(typeface, size) + } + + #[test] + fn empty_text_has_no_glyphs() { + let font = test_font(32.0); + assert!(compute_glyph_metrics("", &font, &None, 0.0).is_empty()); + } + + #[test] + fn glyph_count_matches_char_count_for_plain_ascii() { + let font = test_font(32.0); + let metrics = compute_glyph_metrics("Hello", &font, &None, 0.0); + assert_eq!(metrics.len(), 5); + } + + #[test] + fn first_glyph_starts_at_line_origin() { + let font = test_font(32.0); + let metrics = compute_glyph_metrics("Hello", &font, &None, 0.0); + assert_eq!(metrics[0].x, 0.0); + } + + #[test] + fn glyphs_are_monotonically_increasing_and_sum_to_the_advance_width() { + let font = test_font(48.0); + let text = "Sentence?"; + let metrics = compute_glyph_metrics(text, &font, &None, 0.0); + assert_eq!(metrics.len(), text.chars().count()); + for pair in metrics.windows(2) { + assert!( + pair[1].x >= pair[0].x, + "glyph x must be non-decreasing: {:?}", + metrics + ); + } + let last = metrics.last().unwrap(); + let total_advance = last.x + last.width; + let measured = measure_text_with_fallback(text, &font, &None, 0.0); + assert!( + (total_advance - measured).abs() < 0.5, + "last glyph's right edge ({total_advance}) should match the \ + measured advance width ({measured})" + ); + } + + #[test] + fn last_glyph_is_the_detachable_question_mark() { + // The motivating scenario (issue #328): the author writes the + // sentence *without* its trailing `?` and places a separate node at + // `x = node("sentence", "glyph_x:") + node("sentence", + // "glyph_x:").width` — proven here at the primitive level: the + // last glyph of "Sentence" really is the `e`, immediately before + // where a detached `?` would sit. + let font = test_font(32.0); + let metrics = compute_glyph_metrics("Sentence", &font, &None, 0.0); + let e_glyph = *metrics.last().unwrap(); + let question_x = e_glyph.x + e_glyph.width; + assert!(question_x > e_glyph.x); + } + + #[test] + fn letter_spacing_widens_the_gap_between_glyphs() { + let font = test_font(32.0); + let tight = compute_glyph_metrics("AB", &font, &None, 0.0); + let wide = compute_glyph_metrics("AB", &font, &None, 10.0); + assert_eq!(tight.len(), 2); + assert_eq!(wide.len(), 2); + assert!(wide[1].x > tight[1].x + 9.0); + } + + #[test] + fn single_font_path_matches_make_text_blob_with_spacing_positions() { + // `compute_glyph_metrics`'s fast path and `make_text_blob_with_spacing` + // must compute the exact same per-glyph x positions — they exist to + // describe the same drawn glyphs, just one keeps the positions and + // the other consumes them into a blob. + let font = test_font(40.0); + let text = "Hello"; + let spacing = 2.0; + let metrics = compute_glyph_metrics(text, &font, &None, spacing); + + let glyphs = font.str_to_glyphs_vec(text); + let mut widths = vec![0.0f32; glyphs.len()]; + font.get_widths(&glyphs, &mut widths); + let mut x = 0.0f32; + for (i, w) in widths.iter().enumerate() { + assert_eq!(metrics[i].x, x); + assert_eq!(metrics[i].width, *w); + x += w + spacing; + } + } +} diff --git a/crates/rustmotion-core/src/engine/shake.rs b/crates/rustmotion-core/src/engine/shake.rs new file mode 100644 index 00000000..a17da33e --- /dev/null +++ b/crates/rustmotion-core/src/engine/shake.rs @@ -0,0 +1,287 @@ +//! Evaluates [`crate::schema::shake::SceneShake`] at a given scene-local +//! time (issue #330). See that type's own doc for the damped-oscillation +//! formula this module implements verbatim; nothing here should ever +//! diverge from it — if the two disagree, the schema doc is the spec and +//! this file has a bug. +//! +//! [`shake_offset`] is a pure function: it knows nothing about +//! [`crate::schema::scenario::Camera`] or how a resolved camera transform +//! gets built. The offset it returns is meant to be added to whatever the +//! camera already resolved to independently — see [`SceneShake`]'s own doc +//! for why "additive" rather than "instead of". + +use crate::schema::shake::SceneShake; +use crate::schema::time::{TimeCtx, TimeError}; + +/// The camera's shake contribution at a scene-local `time`, in the same +/// units [`crate::schema::scenario::Camera`]'s own `x`/`y` (pixels) and +/// `rotation` (degrees) use. Additive: a caller adds this to the camera's +/// independently-resolved values rather than replacing them. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct ShakeOffset { + pub x: f64, + pub y: f64, + pub rotation: f64, +} + +impl ShakeOffset { + fn plus(self, other: ShakeOffset) -> ShakeOffset { + ShakeOffset { + x: self.x + other.x, + y: self.y + other.y, + rotation: self.rotation + other.rotation, + } + } +} + +/// Evaluates every impact in `shake.impacts` at `time` (scene-local +/// seconds — the same clock `PaintCtx::time` uses, not +/// `PaintCtx::scenario_time`) and sums their contributions. Each impact's +/// own `at` is a [`crate::schema::time::TimePoint`], resolved against `ctx` +/// via [`crate::schema::time::TimePoint::resolve_relative`] — the same +/// "relative to the scene's own start unless `@`-prefixed" rule every +/// other in-scene time follows. +/// +/// See [`SceneShake`]'s doc for the formula. Returns [`TimeError`] only +/// when an impact's `at` fails to resolve (e.g. a `b`-unit term with no +/// `bpm` declared) — a grammatically malformed `at` is caught earlier, at +/// scenario deserialization, by the same mechanism every other +/// [`crate::schema::time::TimePoint`] uses. +pub fn shake_offset( + shake: &SceneShake, + ctx: &TimeCtx, + time: f64, +) -> Result { + let mut total = ShakeOffset::default(); + for impact in &shake.impacts { + let at = impact.at.resolve_relative(ctx)?; + let dt = time - at; + if dt < 0.0 { + continue; + } + let envelope = impact.amplitude * (-shake.decay * dt).exp(); + let phase = std::f64::consts::TAU * shake.frequency * dt; + let x = envelope * phase.cos(); + let y = envelope * phase.sin(); + total = total.plus(ShakeOffset { + x, + y, + rotation: shake.rotation * x, + }); + } + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::shake::ShakeImpact; + use crate::schema::time::TimePoint; + + fn ctx() -> TimeCtx { + TimeCtx { + bpm: Some(120.0), + beat_offset: 0.0, + scene_start: 0.0, + } + } + + fn single_impact( + at: f64, + amplitude: f64, + decay: f64, + frequency: f64, + rotation: f64, + ) -> SceneShake { + SceneShake { + impacts: vec![ShakeImpact { + at: TimePoint::Seconds(at), + amplitude, + }], + decay, + frequency, + rotation, + } + } + + #[test] + fn an_impact_contributes_nothing_before_it_lands() { + let shake = single_impact(1.0, 20.0, 10.0, 20.0, 0.0); + for t in [0.0, 0.5, 0.999] { + let offset = shake_offset(&shake, &ctx(), t).unwrap(); + assert_eq!(offset, ShakeOffset::default(), "t={t} is before at=1.0"); + } + } + + #[test] + fn an_impact_peaks_at_its_own_at_along_x_with_zero_y() { + let shake = single_impact(1.0, 20.0, 10.0, 20.0, 0.0); + let offset = shake_offset(&shake, &ctx(), 1.0).unwrap(); + assert!((offset.x - 20.0).abs() < 1e-9, "x={}", offset.x); + assert!(offset.y.abs() < 1e-9, "y={}", offset.y); + } + + #[test] + fn the_envelope_decays_to_roughly_one_over_e_after_one_over_decay_seconds() { + let decay = 10.0; + let shake = single_impact(0.0, 20.0, decay, 0.0, 0.0); // frequency=0 -> x is the raw envelope + let offset = shake_offset(&shake, &ctx(), 1.0 / decay).unwrap(); + let expected = 20.0 / std::f64::consts::E; + assert!( + (offset.x - expected).abs() < 1e-6, + "x={}, expected {expected}", + offset.x + ); + } + + #[test] + fn multiple_impacts_sum_linearly() { + let a = single_impact(0.0, 10.0, 8.0, 15.0, 0.0); + let b = single_impact(0.3, 6.0, 8.0, 15.0, 0.0); + let combined = SceneShake { + impacts: [a.impacts.clone(), b.impacts.clone()].concat(), + decay: 8.0, + frequency: 15.0, + rotation: 0.0, + }; + for t in [0.0, 0.15, 0.3, 0.5, 1.0] { + let oa = shake_offset(&a, &ctx(), t).unwrap(); + let ob = shake_offset(&b, &ctx(), t).unwrap(); + let oc = shake_offset(&combined, &ctx(), t).unwrap(); + assert!((oc.x - (oa.x + ob.x)).abs() < 1e-9, "t={t}: x mismatch"); + assert!((oc.y - (oa.y + ob.y)).abs() < 1e-9, "t={t}: y mismatch"); + } + } + + #[test] + fn rotation_is_proportional_to_x_via_the_documented_coefficient() { + let coeff = 0.5; + let shake = single_impact(0.0, 20.0, 8.0, 15.0, coeff); + for t in [0.0, 0.05, 0.2, 0.5] { + let offset = shake_offset(&shake, &ctx(), t).unwrap(); + assert!( + (offset.rotation - coeff * offset.x).abs() < 1e-9, + "t={t}: rotation={}, x={}", + offset.rotation, + offset.x + ); + } + } + + #[test] + fn beat_grid_impacts_resolve_through_bpm_and_beat_offset() { + // bpm=120 -> 0.5s/beat. Impact at beat 2 lands at t=1.0s. + let shake = SceneShake { + impacts: vec![ShakeImpact { + at: TimePoint::Spec("2b".to_string()), + amplitude: 15.0, + }], + decay: 10.0, + frequency: 20.0, + rotation: 0.0, + }; + let before = shake_offset(&shake, &ctx(), 0.99).unwrap(); + assert_eq!(before, ShakeOffset::default()); + let at_impact = shake_offset(&shake, &ctx(), 1.0).unwrap(); + assert!((at_impact.x - 15.0).abs() < 1e-6); + } + + #[test] + fn a_beat_unit_impact_with_no_bpm_errors_instead_of_silently_resolving() { + let shake = SceneShake { + impacts: vec![ShakeImpact { + at: TimePoint::Spec("2b".to_string()), + amplitude: 15.0, + }], + decay: 10.0, + frequency: 20.0, + rotation: 0.0, + }; + let no_bpm_ctx = TimeCtx { + bpm: None, + beat_offset: 0.0, + scene_start: 0.0, + }; + assert!(shake_offset(&shake, &no_bpm_ctx, 1.0).is_err()); + } + + /// Six declarative impacts (as the acceptance criterion describes) + /// reproduce the same damped-sine shape the old workflow got from + /// sampling this exact formula 155 times into a hand-pasted camera + /// keyframe track. Since the original reel's Python loop isn't + /// available to diff against, this test is the strongest available + /// proof: it independently re-derives the formula from `SceneShake`'s + /// own doc comment (not by calling into this module's + /// implementation) and checks `shake_offset` agrees at 155 sampled + /// points across the shake's span — i.e. that a hand-rolled, + /// per-frame keyframe track built the way the old workflow built one + /// would be indistinguishable from what six `{at, amplitude}` pairs + /// now produce directly. + #[test] + fn six_impacts_reproduce_a_hand_sampled_155_keyframe_track() { + let decay = 12.0; + let frequency = 18.0; + let rotation = 0.4; + let impacts_at_amp = [ + (0.0, 24.0), + (0.5, 20.0), + (1.0, 20.0), + (1.5, 16.0), + (2.0, 16.0), + (2.5, 12.0), + ]; + let shake = SceneShake { + impacts: impacts_at_amp + .iter() + .map(|(at, amp)| ShakeImpact { + at: TimePoint::Seconds(*at), + amplitude: *amp, + }) + .collect(), + decay, + frequency, + rotation, + }; + + // Independent re-derivation of the doc's formula — a hand-rolled + // sampler, exactly as a Python loop pasting keyframes would build. + let hand_sampled = |t: f64| -> (f64, f64, f64) { + let mut x = 0.0; + let mut y = 0.0; + for (at, amp) in impacts_at_amp { + let dt = t - at; + if dt < 0.0 { + continue; + } + let envelope = amp * (-decay * dt).exp(); + let phase = std::f64::consts::TAU * frequency * dt; + x += envelope * phase.cos(); + y += envelope * phase.sin(); + } + (x, y, rotation * x) + }; + + let span_end = 3.0; // last impact at 2.5s, well decayed by 3.0s. + let samples = 155; + for i in 0..samples { + let t = span_end * i as f64 / (samples - 1) as f64; + let offset = shake_offset(&shake, &ctx(), t).unwrap(); + let (ex, ey, erot) = hand_sampled(t); + assert!( + (offset.x - ex).abs() < 1e-9, + "t={t}: x={}, expected {ex}", + offset.x + ); + assert!( + (offset.y - ey).abs() < 1e-9, + "t={t}: y={}, expected {ey}", + offset.y + ); + assert!( + (offset.rotation - erot).abs() < 1e-9, + "t={t}: rotation={}, expected {erot}", + offset.rotation + ); + } + } +} diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index a86ff45e..79d62f8d 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -263,6 +263,16 @@ pub enum RustmotionError { #[error("Failed to create decoder for '{path}': {reason}")] AudioDecoder { path: String, reason: String }, + // --- Audio synthesis (issue #331) --- + #[error("audio.score: {source}")] + AudioSynthScore { + #[from] + source: crate::audio::ScoreError, + }, + + #[error("failed to write synthesised audio to a temporary file '{path}': {reason}")] + AudioSynthWrite { path: String, reason: String }, + // --- Rendering --- #[error("Failed to create Skia surface")] SurfaceCreation, diff --git a/crates/rustmotion-core/src/expand.rs b/crates/rustmotion-core/src/expand.rs index c47860e1..40bdbe39 100644 --- a/crates/rustmotion-core/src/expand.rs +++ b/crates/rustmotion-core/src/expand.rs @@ -78,9 +78,11 @@ //! straight into the template, exactly like a `config` default would. The //! whole element is *also* bound to `$item` (for forwarding it wholesale, //! e.g. into a nested `use`'s `props` via `{"$var": "item"}`), and the -//! 0-based position is bound to `$index`. Explicit data always wins: if an -//! element's own field is named `index` or `item`, that value is kept and the -//! built-in is not inserted over it. +//! 0-based position is bound to `$index` — plus, for the arithmetic- +//! expression grammar in [`crate::expr`], the short aliases `$i` (same +//! value as `$index`) and `$count` (the array's length). Explicit data +//! always wins: if an element's own field is named `index`, `item`, `i` or +//! `count`, that value is kept and the built-in is not inserted over it. //! //! ## Pass ordering (load-bearing, tested in //! `rustmotion/tests/templates_iteration.rs`) @@ -542,7 +544,8 @@ fn expand_for_each_directive( } consume_node_budget(budget, items.len() as u64, file_label, location)?; - let mut out = Vec::with_capacity(items.len()); + let count = items.len(); + let mut out = Vec::with_capacity(count); for (idx, element) in items.into_iter().enumerate() { let mut bindings: HashMap = HashMap::new(); if let Value::Object(obj) = &element { @@ -558,6 +561,21 @@ fn expand_for_each_directive( bindings .entry("item".to_string()) .or_insert_with(|| element.clone()); + // `i`/`count` are the short aliases the expression grammar + // (`crates/rustmotion-core/src/expr/`) recognises for the same two + // facts `index` and the item count already give a template — see + // that module's doc for why `$i`/`$count` need to already be plain + // numeric text by the time an expression string reaches + // `crates/rustmotion/src/loader.rs`'s static-folding pass: that pass + // never sees `for-each` iteration state itself, only the document + // this substitution already rewrote. Bound the same way as + // `index`/`item` — explicit data wins, built-ins only fill a gap. + bindings + .entry("i".to_string()) + .or_insert_with(|| Value::from(idx)); + bindings + .entry("count".to_string()) + .or_insert_with(|| Value::from(count)); let mut node = directive.template.clone(); substitute(&mut node, &bindings, file_label)?; @@ -762,6 +780,26 @@ mod tests { assert_eq!(children[2]["content"], json!("2:c")); } + #[test] + fn for_each_binds_i_and_count_aliases_for_expr_grammar() { + let doc = json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "for-each": ["a", "b", "c"], + "template": { "type": "text", "content": "$i/$count" } + }] + }] + }); + let out = expand(doc).unwrap(); + let children = out["scenes"][0]["children"].as_array().unwrap(); + assert_eq!(children.len(), 3); + assert_eq!(children[0]["content"], json!("0/3")); + assert_eq!(children[1]["content"], json!("1/3")); + assert_eq!(children[2]["content"], json!("2/3")); + } + #[test] fn for_each_lets_explicit_item_fields_win_over_built_in_index() { let doc = json!({ diff --git a/crates/rustmotion-core/src/expr/ast.rs b/crates/rustmotion-core/src/expr/ast.rs new file mode 100644 index 00000000..74566297 --- /dev/null +++ b/crates/rustmotion-core/src/expr/ast.rs @@ -0,0 +1,52 @@ +//! The parse tree [`super::parser`] builds and [`super::eval::compile`] +//! lowers into the flat program actually executed. +//! +//! Kept as a plain, owned tree (not a flat token slice with indices) because +//! it only ever exists transiently, inside [`crate::expr::Expr::parse`]: +//! built once, walked once by the compiler, then dropped. Nothing here is +//! stored on [`crate::expr::Expr`] — see that struct's doc for why it keeps +//! the compiled program instead. + +use super::builtins::Builtin; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnOp { + Neg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BinOp { + Add, + Sub, + Mul, + Div, + Rem, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Ast { + Num(f64), + /// A `$name` reference, resolved against a [`super::Scope`] at eval time + /// — the only kind of identifier this grammar defers past parse time. + Var(String), + Unary(UnOp, Box), + Bin(BinOp, Box, Box), + /// `cond ? then : else`. Kept as its own node (not desugared into + /// something else) because it is the one place evaluation must *not* + /// walk both children — see `eval::Op::Ternary`'s doc for why that + /// matters beyond performance. + Ternary(Box, Box, Box), + /// A builtin call. The callee is already resolved to a fixed-arity + /// [`Builtin`] by the parser — there is no other kind of call. + Call(Builtin, Vec), + /// `node("id", "prop")` — resolved against [`super::Scope::node_prop`] + /// at eval time. The two arguments are string literals, not + /// sub-expressions: a node id/prop name is never itself computed. + NodeRef(String, String), +} diff --git a/crates/rustmotion-core/src/expr/builtins.rs b/crates/rustmotion-core/src/expr/builtins.rs new file mode 100644 index 00000000..85966280 --- /dev/null +++ b/crates/rustmotion-core/src/expr/builtins.rs @@ -0,0 +1,293 @@ +//! The closed set of functions an expression can call. +//! +//! There is no user-defined function and no recursion (see the module doc on +//! [`crate::expr`] for why that is a deliberate ceiling, not a missing +//! feature): every callable name an expression can use is one of the +//! variants below, resolved by name at *parse* time in +//! [`super::parser`] — not looked up in a [`super::Scope`] at eval time the +//! way a `$name` variable is. That is what lets a typo like `sni(x)` fail +//! immediately, at the same place `TooDeep`/`Arity` already fail, instead of +//! surfacing only once a frame happens to hit it. +//! +//! [`Builtin::call`] is a pure function of its arguments — no thread-local, +//! no global counter, no clock read — for [`Builtin::Rand`] and +//! [`Builtin::Noise`] included. That purity is load-bearing: two renders of +//! the same file must produce identical frames, and a render can evaluate +//! the same node's expression many times (once per sampled frame plus once +//! more during static folding for the parts that turn out foldable), so +//! anything but a pure function of the arguments would make the output +//! depend on evaluation order. + +/// A builtin function name, resolved once at parse time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Builtin { + Sin, + Cos, + Tan, + Atan2, + Sqrt, + Pow, + Abs, + Min, + Max, + Clamp, + Floor, + Ceil, + Round, + Sign, + Exp, + Log, + Lerp, + Smoothstep, + Rand, + Noise, +} + +impl Builtin { + /// Case-sensitive lookup by the identifier the lexer read. `None` means + /// "not a builtin" — the parser still has [`constant`] and the special + /// `node(...)` form to try before giving up with `UnknownIdent`. + pub(crate) fn from_name(name: &str) -> Option { + Some(match name { + "sin" => Self::Sin, + "cos" => Self::Cos, + "tan" => Self::Tan, + "atan2" => Self::Atan2, + "sqrt" => Self::Sqrt, + "pow" => Self::Pow, + "abs" => Self::Abs, + "min" => Self::Min, + "max" => Self::Max, + "clamp" => Self::Clamp, + "floor" => Self::Floor, + "ceil" => Self::Ceil, + "round" => Self::Round, + "sign" => Self::Sign, + "exp" => Self::Exp, + "log" => Self::Log, + "lerp" => Self::Lerp, + "smoothstep" => Self::Smoothstep, + "rand" => Self::Rand, + "noise" => Self::Noise, + _ => return None, + }) + } + + /// The name this variant was parsed from — used to build `ExprError` + /// messages (`Arity`) that name the function the way the author wrote it. + pub(crate) fn name(self) -> &'static str { + match self { + Self::Sin => "sin", + Self::Cos => "cos", + Self::Tan => "tan", + Self::Atan2 => "atan2", + Self::Sqrt => "sqrt", + Self::Pow => "pow", + Self::Abs => "abs", + Self::Min => "min", + Self::Max => "max", + Self::Clamp => "clamp", + Self::Floor => "floor", + Self::Ceil => "ceil", + Self::Round => "round", + Self::Sign => "sign", + Self::Exp => "exp", + Self::Log => "log", + Self::Lerp => "lerp", + Self::Smoothstep => "smoothstep", + Self::Rand => "rand", + Self::Noise => "noise", + } + } + + /// Fixed arity, checked at parse time against the argument list the + /// parser collected — no builtin is variadic. + pub(crate) fn arity(self) -> usize { + match self { + Self::Sin + | Self::Cos + | Self::Tan + | Self::Sqrt + | Self::Abs + | Self::Floor + | Self::Ceil + | Self::Round + | Self::Sign + | Self::Exp + | Self::Log + | Self::Rand => 1, + Self::Atan2 | Self::Pow | Self::Min | Self::Max | Self::Noise => 2, + Self::Clamp | Self::Lerp | Self::Smoothstep => 3, + } + } + + /// Evaluate. `args.len()` is guaranteed to equal [`Builtin::arity`] by + /// the parser (which checks it once, at parse time) — the stack machine + /// in [`super::eval`] never calls this with a mismatched slice. + pub(crate) fn call(self, args: &[f64]) -> f64 { + match self { + Self::Sin => args[0].sin(), + Self::Cos => args[0].cos(), + Self::Tan => args[0].tan(), + Self::Atan2 => args[0].atan2(args[1]), + Self::Sqrt => args[0].sqrt(), + Self::Pow => args[0].powf(args[1]), + Self::Abs => args[0].abs(), + Self::Min => args[0].min(args[1]), + Self::Max => args[0].max(args[1]), + Self::Clamp => clamp(args[0], args[1], args[2]), + Self::Floor => args[0].floor(), + Self::Ceil => args[0].ceil(), + Self::Round => args[0].round(), + Self::Sign => { + if args[0] > 0.0 { + 1.0 + } else if args[0] < 0.0 { + -1.0 + } else { + 0.0 + } + } + Self::Exp => args[0].exp(), + Self::Log => args[0].ln(), + Self::Lerp => lerp(args[0], args[1], args[2]), + Self::Smoothstep => smoothstep(args[0], args[1], args[2]), + Self::Rand => rand(args[0]), + Self::Noise => noise(args[0], args[1]), + } + } +} + +fn clamp(x: f64, lo: f64, hi: f64) -> f64 { + // `f64::clamp` panics when `lo > hi`; an author-supplied bound pair is + // not a Rust invariant violation, it is bad data, so fall back to a + // saturating order-independent clamp instead of propagating a panic out + // of an evaluator that is meant to be hang/crash-proof by construction. + let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) }; + x.max(lo).min(hi) +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +fn smoothstep(edge0: f64, edge1: f64, x: f64) -> f64 { + let t = if edge0 == edge1 { + if x < edge0 { + 0.0 + } else { + 1.0 + } + } else { + ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0) + }; + t * t * (3.0 - 2.0 * t) +} + +/// Named constant lookup for bare (non-`$`) identifiers that are not a +/// function call — the other half of what a bare `Ident` token can mean. +pub(crate) fn constant(name: &str) -> Option { + match name { + "PI" => Some(std::f64::consts::PI), + "TAU" => Some(std::f64::consts::TAU), + "E" => Some(std::f64::consts::E), + _ => None, + } +} + +/// `splitmix64`: a small, well-known bit-mixer, chosen only because it is +/// cheap and has no dependency — not because the noise needs to be +/// cryptographically strong. It is deterministic in `x` alone, which is the +/// whole point: no seeding from the clock, no `static` counter. +fn splitmix64(x: u64) -> u64 { + let x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// Map a mixed 64-bit word to `[0, 1)` using its top 53 bits — the standard +/// trick for producing a `f64` with uniform mantissa coverage. +fn unit_interval(bits: u64) -> f64 { + (bits >> 11) as f64 * (1.0 / (1u64 << 53) as f64) +} + +/// `rand(seed)` — deterministic, pure hash of `seed`'s bit pattern into +/// `[0, 1)`. Same `seed` in, same value out, on this render or the next one. +fn rand(seed: f64) -> f64 { + unit_interval(splitmix64(seed.to_bits())) +} + +fn lattice(i: i64, seed: f64) -> f64 { + let mixed = splitmix64((i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ seed.to_bits()); + unit_interval(mixed) +} + +fn smootherstep(t: f64) -> f64 { + t * t * t * (t * (t * 6.0 - 15.0) + 10.0) +} + +/// `noise(x, seed)` — 1D value noise: smoothly interpolates between +/// deterministic per-integer lattice values around `x`, both lattice points +/// seeded by `seed`. Same inputs, same curve, every time — no incremental +/// state to carry between calls, unlike a typical streaming noise generator. +fn noise(x: f64, seed: f64) -> f64 { + let i0 = x.floor() as i64; + let i1 = i0 + 1; + let t = x - i0 as f64; + let v0 = lattice(i0, seed); + let v1 = lattice(i1, seed); + let tt = smootherstep(t); + v0 + (v1 - v0) * tt +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rand_is_pure_and_deterministic() { + assert_eq!(rand(42.0), rand(42.0)); + assert_eq!(Builtin::Rand.call(&[42.0]), Builtin::Rand.call(&[42.0])); + } + + #[test] + fn rand_differs_across_seeds_and_stays_in_unit_interval() { + let a = rand(1.0); + let b = rand(2.0); + assert_ne!(a, b); + for seed in [0.0, 1.0, -5.0, 1e6, 0.0001] { + let v = rand(seed); + assert!((0.0..1.0).contains(&v), "rand({seed}) = {v} out of range"); + } + } + + #[test] + fn noise_is_pure_and_deterministic() { + assert_eq!(noise(3.25, 7.0), noise(3.25, 7.0)); + } + + #[test] + fn noise_is_continuous_at_lattice_points() { + // At an integer x, noise(x, seed) must equal the lattice value there + // (smootherstep(0) == 0), so neighbouring samples don't jump. + let seed = 11.0; + assert_eq!(noise(4.0, seed), lattice(4, seed)); + } + + #[test] + fn clamp_tolerates_swapped_bounds() { + assert_eq!(clamp(5.0, 10.0, 0.0), 5.0); + assert_eq!(clamp(-5.0, 10.0, 0.0), 0.0); + assert_eq!(clamp(50.0, 10.0, 0.0), 10.0); + } + + #[test] + fn lerp_and_smoothstep_basic() { + assert_eq!(lerp(0.0, 10.0, 0.5), 5.0); + assert_eq!(smoothstep(0.0, 1.0, -1.0), 0.0); + assert_eq!(smoothstep(0.0, 1.0, 2.0), 1.0); + assert_eq!(smoothstep(0.0, 1.0, 0.5), 0.5); + } +} diff --git a/crates/rustmotion-core/src/expr/eval.rs b/crates/rustmotion-core/src/expr/eval.rs new file mode 100644 index 00000000..7be0a959 --- /dev/null +++ b/crates/rustmotion-core/src/expr/eval.rs @@ -0,0 +1,304 @@ +//! Lowers an [`Ast`] into a flat program of [`Op`]s once, and runs that +//! program against a [`Scope`] as many times as needed afterwards. +//! +//! This is the piece that makes the two-tier evaluation model in the +//! [`crate::expr`] module doc actually cheap: [`compile`] walks the tree a +//! single time, at [`crate::expr::Expr::parse`] time, and everything it +//! produces — the op list, the interned variable-name table, the interned +//! node-reference table — is owned by the resulting [`Expr`](super::Expr) +//! and never rebuilt. [`run`] then executes that fixed program using a +//! stack-allocated `[f64; MAX_STACK]` array: no `Vec`, no `String`, no heap +//! traffic of any kind on the per-frame path, however many times a frame +//! loop calls it. +//! +//! Arithmetic (`Num`, `Var`, unary/binary operators, builtin calls) compiles +//! to genuinely flat, linear bytecode executed by a simple push/pop +//! interpreter over that array — a real stack machine for the part of the +//! grammar that has no branching. [`Op::Ternary`] is the one construct that +//! must *not* evaluate eagerly (a condition guards a division by zero, or a +//! branch none reads a node that doesn't exist this frame — evaluating both +//! sides unconditionally would surface an error the author's own branching +//! was written to avoid), so it holds its three arms as their own +//! independently-compiled flat programs and `run` recurses into exactly one +//! of them. That recursion is bounded by the same nesting cap `parser.rs` +//! already enforces at parse time, so it can never run deeper than +//! `MAX_DEPTH` stack frames — nowhere near enough to threaten the native +//! stack. + +use super::ast::{Ast, BinOp, UnOp}; +use super::builtins::Builtin; +use super::{ExprError, Scope}; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Op { + Num(f64), + /// Index into the compiled [`Expr`](super::Expr)'s variable-name table. + Var(u16), + Neg, + Add, + Sub, + Mul, + Div, + Rem, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + /// Pops `argc` values (in argument order), pushes one result. + Call(Builtin, u8), + /// Index into the compiled [`Expr`](super::Expr)'s node-reference table. + NodeProp(u16), + /// `(cond, then, else)`, each its own flat program — see the module doc + /// on why this can't be three ordinary operands on the value stack. + Ternary(Box<[Op]>, Box<[Op]>, Box<[Op]>), +} + +pub(crate) struct Compiled { + pub ops: Box<[Op]>, + pub var_names: Box<[Box]>, + pub node_refs: Box<[(Box, Box)]>, + pub free_vars: Vec, + pub is_static: bool, +} + +/// Names that can never be folded at load time because their value is only +/// known once the frame loop actually starts (or, for `duration`, because +/// computing it soundly at this stage would mean re-deriving the transition +/// arithmetic `crates/rustmotion/src/encode` owns — see +/// `crates/rustmotion/src/loader.rs`'s fold pass for where that arithmetic +/// actually lives). An expression naming any of these is never static, +/// regardless of what else it references. +fn is_dynamic_var_name(name: &str) -> bool { + matches!(name, "t" | "T" | "beat" | "duration") +} + +struct Compiler { + var_names: Vec>, + node_refs: Vec<(Box, Box)>, + has_node_ref: bool, +} + +impl Compiler { + fn var_idx(&mut self, name: &str) -> u16 { + if let Some(pos) = self.var_names.iter().position(|n| n.as_ref() == name) { + pos as u16 + } else { + self.var_names.push(name.into()); + (self.var_names.len() - 1) as u16 + } + } + + fn node_idx(&mut self, id: &str, prop: &str) -> u16 { + if let Some(pos) = self + .node_refs + .iter() + .position(|(i, p)| i.as_ref() == id && p.as_ref() == prop) + { + pos as u16 + } else { + self.node_refs.push((id.into(), prop.into())); + (self.node_refs.len() - 1) as u16 + } + } + + fn compile_ast(&mut self, ast: &Ast) -> Vec { + match ast { + Ast::Num(n) => vec![Op::Num(*n)], + Ast::Var(name) => vec![Op::Var(self.var_idx(name))], + Ast::Unary(UnOp::Neg, inner) => { + let mut ops = self.compile_ast(inner); + ops.push(Op::Neg); + ops + } + Ast::Bin(op, l, r) => { + let mut ops = self.compile_ast(l); + ops.extend(self.compile_ast(r)); + ops.push(match op { + BinOp::Add => Op::Add, + BinOp::Sub => Op::Sub, + BinOp::Mul => Op::Mul, + BinOp::Div => Op::Div, + BinOp::Rem => Op::Rem, + BinOp::Eq => Op::Eq, + BinOp::Ne => Op::Ne, + BinOp::Lt => Op::Lt, + BinOp::Le => Op::Le, + BinOp::Gt => Op::Gt, + BinOp::Ge => Op::Ge, + }); + ops + } + Ast::Ternary(cond, then_b, else_b) => { + let cond_ops = self.compile_ast(cond).into_boxed_slice(); + let then_ops = self.compile_ast(then_b).into_boxed_slice(); + let else_ops = self.compile_ast(else_b).into_boxed_slice(); + vec![Op::Ternary(cond_ops, then_ops, else_ops)] + } + Ast::Call(builtin, args) => { + let mut ops = Vec::new(); + for a in args { + ops.extend(self.compile_ast(a)); + } + ops.push(Op::Call(*builtin, args.len() as u8)); + ops + } + Ast::NodeRef(id, prop) => { + self.has_node_ref = true; + vec![Op::NodeProp(self.node_idx(id, prop))] + } + } + } +} + +pub(crate) fn compile(ast: &Ast) -> Compiled { + let mut compiler = Compiler { + var_names: Vec::new(), + node_refs: Vec::new(), + has_node_ref: false, + }; + let ops = compiler.compile_ast(ast).into_boxed_slice(); + let free_vars: Vec = compiler.var_names.iter().map(|n| n.to_string()).collect(); + let is_static = !compiler.has_node_ref && !free_vars.iter().any(|n| is_dynamic_var_name(n)); + Compiled { + ops, + var_names: compiler.var_names.into_boxed_slice(), + node_refs: compiler.node_refs.into_boxed_slice(), + free_vars, + is_static, + } +} + +/// Bound on the value stack `run` uses. Every op is compiled from an AST +/// whose nesting is itself capped at parse time (`parser::MAX_DEPTH`, 64), +/// and no single grammar construct pushes more than a small constant number +/// of pending values per nesting level, so this is never approached by any +/// expression that made it past `parse` — it exists purely so a bug in that +/// invariant fails loudly (an `ExprError`) instead of indexing out of +/// bounds. +const MAX_STACK: usize = 256; + +pub(crate) fn run( + ops: &[Op], + var_names: &[Box], + node_refs: &[(Box, Box)], + scope: &dyn Scope, +) -> Result { + let mut stack = [0.0_f64; MAX_STACK]; + let mut sp = 0usize; + + macro_rules! push { + ($v:expr) => {{ + if sp >= MAX_STACK { + return Err(ExprError::TooDeep(MAX_STACK)); + } + stack[sp] = $v; + sp += 1; + }}; + } + macro_rules! pop { + () => {{ + sp -= 1; + stack[sp] + }}; + } + + for op in ops { + match op { + Op::Num(n) => push!(*n), + Op::Var(idx) => { + let name = &var_names[*idx as usize]; + let v = scope + .var(name) + .ok_or_else(|| ExprError::UnknownIdent(name.to_string()))?; + push!(v); + } + Op::Neg => { + let a = pop!(); + push!(-a); + } + Op::Add => { + let b = pop!(); + let a = pop!(); + push!(a + b); + } + Op::Sub => { + let b = pop!(); + let a = pop!(); + push!(a - b); + } + Op::Mul => { + let b = pop!(); + let a = pop!(); + push!(a * b); + } + Op::Div => { + let b = pop!(); + let a = pop!(); + push!(a / b); + } + Op::Rem => { + let b = pop!(); + let a = pop!(); + push!(a % b); + } + Op::Eq => { + let b = pop!(); + let a = pop!(); + push!(if a == b { 1.0 } else { 0.0 }); + } + Op::Ne => { + let b = pop!(); + let a = pop!(); + push!(if a != b { 1.0 } else { 0.0 }); + } + Op::Lt => { + let b = pop!(); + let a = pop!(); + push!(if a < b { 1.0 } else { 0.0 }); + } + Op::Le => { + let b = pop!(); + let a = pop!(); + push!(if a <= b { 1.0 } else { 0.0 }); + } + Op::Gt => { + let b = pop!(); + let a = pop!(); + push!(if a > b { 1.0 } else { 0.0 }); + } + Op::Ge => { + let b = pop!(); + let a = pop!(); + push!(if a >= b { 1.0 } else { 0.0 }); + } + Op::Call(builtin, argc) => { + let n = *argc as usize; + let mut args = [0.0_f64; 3]; // max builtin arity is 3 (clamp/lerp/smoothstep) + for slot in args.iter_mut().take(n).rev() { + *slot = pop!(); + } + push!(builtin.call(&args[..n])); + } + Op::NodeProp(idx) => { + let (id, prop) = &node_refs[*idx as usize]; + let v = scope.node_prop(id, prop).ok_or_else(|| { + ExprError::UnknownIdent(format!("node(\"{id}\", \"{prop}\")")) + })?; + push!(v); + } + Op::Ternary(cond_ops, then_ops, else_ops) => { + let cond = run(cond_ops, var_names, node_refs, scope)?; + let v = if cond != 0.0 { + run(then_ops, var_names, node_refs, scope)? + } else { + run(else_ops, var_names, node_refs, scope)? + }; + push!(v); + } + } + } + + Ok(pop!()) +} diff --git a/crates/rustmotion-core/src/expr/lexer.rs b/crates/rustmotion-core/src/expr/lexer.rs new file mode 100644 index 00000000..bb6e35b1 --- /dev/null +++ b/crates/rustmotion-core/src/expr/lexer.rs @@ -0,0 +1,323 @@ +//! Character-level scanning: `&str` in, `Vec` out, or an +//! [`ExprError::Parse`] naming exactly what byte offset choked. +//! +//! Deliberately hand-rolled rather than pulled in as a dependency — the +//! token set is tiny (numbers, `$name` references, bare identifiers, string +//! literals for `node(...)`, and a fixed punctuation set) and a generated +//! lexer would cost more to read than it saves to write. + +use super::ExprError; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Token { + Num(f64), + /// `$name` — the `$` is consumed here, `name` is the bare identifier. + Var(String), + /// A bare identifier: a builtin name, a constant (`PI`/`TAU`/`E`), or + /// the `node` keyword. Which one it is gets resolved by the parser. + Ident(String), + /// A double-quoted string literal — only meaningful as a `node(...)` + /// argument; the parser rejects it anywhere else. + Str(String), + Plus, + Minus, + Star, + Slash, + Percent, + LParen, + RParen, + Comma, + Question, + Colon, + EqEq, + NotEq, + Lt, + Le, + Gt, + Ge, +} + +/// A generous but finite cap on source length, independent of the nesting +/// cap `parser.rs` enforces on structure: a pathological flat input (a +/// million `+` signs, no nesting at all) would sail through the depth guard +/// but still shouldn't be handed to the tokenizer — reject it up front with +/// a plain, cheap length check instead of discovering the cost mid-scan. +const MAX_SOURCE_LEN: usize = 8192; + +pub(crate) fn tokenize(src: &str) -> Result, ExprError> { + if src.len() > MAX_SOURCE_LEN { + return Err(ExprError::Parse { + src: src.to_string(), + reason: format!( + "expression is {} bytes long, exceeding the {MAX_SOURCE_LEN}-byte cap", + src.len() + ), + }); + } + + let bytes = src.as_bytes(); + let mut tokens = Vec::new(); + let mut i = 0usize; + + let err = |reason: String| ExprError::Parse { + src: src.to_string(), + reason, + }; + + while i < bytes.len() { + let c = bytes[i] as char; + match c { + ' ' | '\t' | '\n' | '\r' => i += 1, + '+' => { + tokens.push(Token::Plus); + i += 1; + } + '-' => { + tokens.push(Token::Minus); + i += 1; + } + '*' => { + tokens.push(Token::Star); + i += 1; + } + '/' => { + tokens.push(Token::Slash); + i += 1; + } + '%' => { + tokens.push(Token::Percent); + i += 1; + } + '(' => { + tokens.push(Token::LParen); + i += 1; + } + ')' => { + tokens.push(Token::RParen); + i += 1; + } + ',' => { + tokens.push(Token::Comma); + i += 1; + } + '?' => { + tokens.push(Token::Question); + i += 1; + } + ':' => { + tokens.push(Token::Colon); + i += 1; + } + '=' => { + if bytes.get(i + 1) == Some(&b'=') { + tokens.push(Token::EqEq); + i += 2; + } else { + return Err(err(format!( + "unexpected '=' at byte {i} (did you mean '=='?)" + ))); + } + } + '!' => { + if bytes.get(i + 1) == Some(&b'=') { + tokens.push(Token::NotEq); + i += 2; + } else { + return Err(err(format!("unexpected '!' at byte {i}"))); + } + } + '<' => { + if bytes.get(i + 1) == Some(&b'=') { + tokens.push(Token::Le); + i += 2; + } else { + tokens.push(Token::Lt); + i += 1; + } + } + '>' => { + if bytes.get(i + 1) == Some(&b'=') { + tokens.push(Token::Ge); + i += 2; + } else { + tokens.push(Token::Gt); + i += 1; + } + } + '$' => { + let start = i + 1; + let mut j = start; + while j < bytes.len() && is_ident_byte(bytes[j]) { + j += 1; + } + if j == start { + return Err(err(format!("'$' at byte {i} is not followed by a name"))); + } + tokens.push(Token::Var(src[start..j].to_string())); + i = j; + } + '"' => { + let mut j = i + 1; + let mut s = String::new(); + loop { + match bytes.get(j) { + None => { + return Err(err(format!("unterminated string starting at byte {i}"))) + } + Some(b'"') => { + j += 1; + break; + } + Some(b'\\') if bytes.get(j + 1) == Some(&b'"') => { + s.push('"'); + j += 2; + } + Some(b'\\') if bytes.get(j + 1) == Some(&b'\\') => { + s.push('\\'); + j += 2; + } + Some(_) => { + // Safe: we only ever step by one *byte* when it's + // part of a UTF-8 continuation too, but to keep + // this simple and correct for the identifier/ + // number cases above we only claim ASCII there. + // String bodies may be full UTF-8, so decode via + // the original &str instead of raw bytes here. + let ch = src[j..].chars().next().unwrap(); + s.push(ch); + j += ch.len_utf8(); + } + } + } + tokens.push(Token::Str(s)); + i = j; + } + c if c.is_ascii_digit() + || (c == '.' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)) => + { + let start = i; + let mut j = i; + while j < bytes.len() && bytes[j].is_ascii_digit() { + j += 1; + } + if bytes.get(j) == Some(&b'.') { + j += 1; + while j < bytes.len() && bytes[j].is_ascii_digit() { + j += 1; + } + } + if matches!(bytes.get(j), Some(b'e') | Some(b'E')) { + let mut k = j + 1; + if matches!(bytes.get(k), Some(b'+') | Some(b'-')) { + k += 1; + } + if bytes.get(k).is_some_and(u8::is_ascii_digit) { + k += 1; + while k < bytes.len() && bytes[k].is_ascii_digit() { + k += 1; + } + j = k; + } + } + let text = &src[start..j]; + let n: f64 = text + .parse() + .map_err(|_| err(format!("'{text}' at byte {start} is not a valid number")))?; + tokens.push(Token::Num(n)); + i = j; + } + c if c.is_ascii_alphabetic() || c == '_' => { + let start = i; + let mut j = i; + while j < bytes.len() && is_ident_byte(bytes[j]) { + j += 1; + } + tokens.push(Token::Ident(src[start..j].to_string())); + i = j; + } + other => { + return Err(err(format!("unexpected character '{other}' at byte {i}"))); + } + } + } + + Ok(tokens) +} + +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tokenizes_arithmetic() { + let toks = tokenize("1 + 2 * 3").unwrap(); + assert_eq!( + toks, + vec![ + Token::Num(1.0), + Token::Plus, + Token::Num(2.0), + Token::Star, + Token::Num(3.0), + ] + ); + } + + #[test] + fn tokenizes_var_and_ident_and_comparisons() { + let toks = tokenize("$W >= cos(PI)").unwrap(); + assert_eq!( + toks, + vec![ + Token::Var("W".to_string()), + Token::Ge, + Token::Ident("cos".to_string()), + Token::LParen, + Token::Ident("PI".to_string()), + Token::RParen, + ] + ); + } + + #[test] + fn tokenizes_string_literal_with_escapes() { + let toks = tokenize(r#"node("a\"b", "y")"#).unwrap(); + assert_eq!( + toks, + vec![ + Token::Ident("node".to_string()), + Token::LParen, + Token::Str("a\"b".to_string()), + Token::Comma, + Token::Str("y".to_string()), + Token::RParen, + ] + ); + } + + #[test] + fn tokenizes_scientific_notation() { + let toks = tokenize("1.5e-3").unwrap(); + assert_eq!(toks, vec![Token::Num(1.5e-3)]); + } + + #[test] + fn rejects_lone_dollar() { + assert!(tokenize("$ + 1").is_err()); + } + + #[test] + fn rejects_unterminated_string() { + assert!(tokenize("node(\"a, \"b\")").is_err()); + } + + #[test] + fn rejects_oversized_source() { + let huge = "1+".repeat(MAX_SOURCE_LEN); + assert!(tokenize(&huge).is_err()); + } +} diff --git a/crates/rustmotion-core/src/expr/mod.rs b/crates/rustmotion-core/src/expr/mod.rs new file mode 100644 index 00000000..92e49264 --- /dev/null +++ b/crates/rustmotion-core/src/expr/mod.rs @@ -0,0 +1,426 @@ +//! Arithmetic expressions for scenario values. +//! +//! Without this module, every number in a scenario is a frozen literal: a +//! generator that wants eight badges evenly spaced on a circle, or a facet +//! shaded by its angle to a light, has no way to say so in JSON and instead +//! has to compute the numbers itself, offline, and paste the results in — +//! unreadable in the studio, and no longer connected to the intent that +//! produced them. This module gives a scenario value an alternative to +//! being a literal: a small, deliberately not Turing-complete expression +//! language, written as a string. +//! +//! # The `=` prefix +//! +//! A JSON string is an expression only when it starts with `=`; anything +//! else is a plain literal string, exactly as before: +//! +//! ```json +//! { "x": "= $W/2 + cos($i / $count * TAU - PI/2) * 700 - 110" } +//! ``` +//! +//! [`Expr::parse`] itself is tolerant of either form — a leading `=` (with +//! optional surrounding whitespace) is stripped if present, and the rest is +//! parsed as-is otherwise — so callers can hand it either the raw JSON +//! string value or just the expression body without having to agree in two +//! places on who strips the sigil. The decision of *whether* a given string +//! is even attempted as an expression (i.e. whether it starts with `=` at +//! all) is made by the caller — see `crates/rustmotion/src/loader.rs`'s +//! static-folding pass, and eventually [`Computed`] below for the typed +//! schema fields that wrap this. +//! +//! # Grammar +//! +//! Numeric literals, `+ - * / %`, unary minus, parentheses, the six +//! comparisons (`== != < <= > >=`, each producing `0.0`/`1.0`), a ternary +//! `cond ? a : b`, and calls to a fixed, closed set of builtins (see +//! [`builtins::Builtin`]) plus the constants `PI`, `TAU`, `E`. No loops, no +//! user-defined functions, no recursion — see [`ExprError::TooDeep`] for +//! the nesting cap this buys: a hostile expression fails at *parse*, never +//! hangs a render. +//! +//! A `$name` token is a reference resolved against a [`Scope`] at +//! evaluation time — never at parse time — the one place this grammar looks +//! outside itself. `node("id", "prop")` is the other: a node-reference call +//! resolved via [`Scope::node_prop`], syntactically distinguished from an +//! ordinary builtin call because its two arguments are string literals +//! (an id and a property name), never sub-expressions. +//! +//! # Two evaluation tiers +//! +//! An [`Expr`] is parsed exactly once, in [`Expr::parse`], into a compiled +//! program (see [`eval`] for the compiler and the allocation-free stack +//! machine that runs it) — evaluating it later, however many times, never +//! re-parses the source string. What happens with that compiled `Expr` then +//! splits in two: +//! +//! - **Static fold, at load.** [`Expr::is_static`] is true when +//! [`Expr::free_vars`] names nothing time-varying (`t`, `T`, `beat`, +//! `duration` — see [`eval::is_dynamic_var_name`]) and the expression +//! contains no `node(...)` call. Such an expression can only ever +//! evaluate to one value for a given document, so it is evaluated once +//! during loading and the JSON literal it produces replaces it — zero +//! per-frame cost, and the reason a `for-each` placing eight badges on a +//! circle costs nothing more at render time than eight literal +//! coordinates would have. See `crates/rustmotion/src/loader.rs`. +//! - **Per frame, otherwise.** An expression reading `$t`/`$T`/`$beat`, a +//! [`Scope::var`]-backed animated variable, or a `node(...)` reference is +//! evaluated once per sampled frame against that frame's [`Scope`] — still +//! without re-parsing, and without allocating, since [`Expr::eval`] only +//! ever walks the already-compiled program. +//! +//! # Scope +//! +//! [`Scope::var`] is the single hook a caller implements to expose names to +//! an expression. This module does not itself decide what `$t`, `$W`, or an +//! arbitrary `$myVar` mean — it only defines the two categories +//! [`Expr::is_static`] treats specially (dynamic vs. potentially +//! load-time-known) so that callers building a [`Scope`] for either tier +//! know which names they are expected to answer. The scenario-level names +//! this workstream's issue names are `$t` (scene time), `$T` (absolute +//! time), `$beat`, `$i`/`$index`/`$item`/`$count` (bound by a surrounding +//! `for-each`), `$W`/`$H`/`$fps`/`$duration`, plus arbitrary `$name` for a +//! declared `config` variable or an animated variable (`Scope::var`) — the +//! same `$name` sigil `crates/rustmotion/src/variables.rs` already resolves +//! for whole-value and interpolated substitution, deliberately reused +//! rather than inventing a second one. +//! +//! ``` +//! use rustmotion_core::expr::{Expr, Scope}; +//! +//! struct Fixed; +//! impl Scope for Fixed { +//! fn var(&self, name: &str) -> Option { +//! match name { +//! "W" => Some(1080.0), +//! "i" => Some(3.0), +//! "count" => Some(8.0), +//! _ => None, +//! } +//! } +//! } +//! +//! let expr = Expr::parse("= $W/2 + cos($i / $count * TAU) * 700").unwrap(); +//! assert!(expr.is_static()); +//! let x = expr.eval(&Fixed).unwrap(); +//! assert!((x - (1080.0 / 2.0 + (3.0_f64 / 8.0 * std::f64::consts::TAU).cos() * 700.0)).abs() < 1e-9); +//! ``` + +mod ast; +mod builtins; +mod eval; +mod lexer; +mod parser; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// What an expression can read from its evaluation context: named +/// variables, and optionally another node's already-resolved property. +/// +/// Implemented by whoever is evaluating an [`Expr`] — the load-time static +/// folder (`crates/rustmotion/src/loader.rs`, a handful of reserved names +/// only), and eventually the per-frame engine context (animated variables +/// via [`Scope::var`], node references via [`Scope::node_prop`]). Object +/// safety (`&dyn Scope`) is deliberate: [`Expr::eval`] takes a trait object +/// so callers never have to make the compiled program generic over their +/// own context type. +pub trait Scope { + /// Resolve a `$name` reference. `None` means "not defined in this + /// scope" — [`Expr::eval`] turns that into + /// [`ExprError::UnknownIdent`], the same error a genuinely unknown name + /// produces, since from the expression's point of view the two are + /// indistinguishable. + fn var(&self, name: &str) -> Option; + + /// Resolve `node("id", "prop")`. Defaulted to always return `None` (not + /// made a required method) because most [`Scope`] implementations never + /// need to answer it: an expression containing a node reference is + /// never [`Expr::is_static`], so the load-time static-fold scope in + /// particular is never asked. + fn node_prop(&self, id: &str, prop: &str) -> Option { + let _ = (id, prop); + None + } +} + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum ExprError { + #[error("cannot parse expression `{src}`: {reason}")] + Parse { src: String, reason: String }, + #[error("unknown identifier `{0}`")] + UnknownIdent(String), + #[error("`{name}` takes {expected} argument(s), got {got}")] + Arity { + name: String, + expected: usize, + got: usize, + }, + #[error("expression nests deeper than {0} levels")] + TooDeep(usize), +} + +/// A parsed, compiled arithmetic expression. +/// +/// Construct with [`Expr::parse`]; the source string is consumed at that +/// point and never touched again — [`Expr::eval`] only ever walks the +/// compiled program built once during parsing. See the module doc for the +/// grammar and the two evaluation tiers. +#[derive(Debug, Clone, PartialEq)] +pub struct Expr { + ops: Box<[eval::Op]>, + var_names: Box<[Box]>, + node_refs: Box<[(Box, Box)]>, + free_vars: Vec, + is_static: bool, +} + +impl Expr { + /// Parse (and fully compile) an expression. Accepts either the raw + /// `"= ..."` JSON string value or just the body after the sigil — see + /// the module doc's "The `=` prefix" section. + pub fn parse(src: &str) -> Result { + let body = strip_expr_prefix(src); + let tree = parser::parse(body)?; + let compiled = eval::compile(&tree); + Ok(Expr { + ops: compiled.ops, + var_names: compiled.var_names, + node_refs: compiled.node_refs, + free_vars: compiled.free_vars, + is_static: compiled.is_static, + }) + } + + /// Evaluate the compiled program against `scope`. Allocation-free: see + /// the [`eval`] module doc. + pub fn eval(&self, scope: &dyn Scope) -> Result { + eval::run(&self.ops, &self.var_names, &self.node_refs, scope) + } + + /// Every distinct `$name` this expression references, in first-seen + /// order. Does not include `node(...)` reference ids/props — those are + /// string literals resolved through [`Scope::node_prop`], not + /// `$name` variables. + pub fn free_vars(&self) -> Vec { + self.free_vars.clone() + } + + /// True when [`Expr::free_vars`] names nothing time-varying and the + /// expression contains no `node(...)` call — see the module doc's + /// "Two evaluation tiers" section. A caller that gets `true` back may + /// evaluate once, at load time, and keep the result forever; `false` + /// means the value can change from frame to frame and must be + /// re-evaluated each time. + pub fn is_static(&self) -> bool { + self.is_static + } +} + +fn strip_expr_prefix(src: &str) -> &str { + let trimmed = src.trim_start(); + match trimmed.strip_prefix('=') { + Some(rest) => rest.trim_start(), + None => trimmed, + } +} + +/// A scenario value that is either a plain literal or, prefixed with `=`, +/// an expression to evaluate. +/// +/// `#[serde(untagged)]`: deserialization tries `Literal(T)` first, falling +/// back to `Expr(String)` only when the JSON value doesn't fit `T`. That +/// ordering is why this works cleanly for the numbers and strictly-typed +/// colours this issue targets — a JSON string can never satisfy a numeric +/// `T`, so `"= $W/2"` always falls through to `Expr` — but is a deliberate +/// non-goal for `Computed`: there, *every* JSON string +/// (`"= ..."` included) already satisfies `Literal(String)` first and +/// `Expr` is never reached. Schema fields that want expression support are +/// expected to use a strictly-typed `T` (a number, or a colour type with +/// its own validating `Deserialize`), not a bare `String`. +/// +/// This type carries no evaluation logic of its own — resolving an +/// `Expr(String)` variant means calling [`Expr::parse`] on its contents and +/// then [`Expr::eval`] (or folding it statically, see the [`crate::expr`] +/// module doc) — deliberately, so this crate does not have to hand back an +/// opinion on *when* that happens; that is a decision for whatever owns the +/// field (the static-folding pass, or the per-frame engine context). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum Computed { + Literal(T), + Expr(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f64::consts::{PI, TAU}; + + struct MapScope<'a>(&'a [(&'a str, f64)]); + impl Scope for MapScope<'_> { + fn var(&self, name: &str) -> Option { + self.0.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) + } + } + + struct EmptyScope; + impl Scope for EmptyScope { + fn var(&self, _name: &str) -> Option { + None + } + } + + #[test] + fn parses_with_and_without_equals_prefix() { + let a = Expr::parse("= 1 + 1").unwrap(); + let b = Expr::parse("1 + 1").unwrap(); + assert_eq!(a.eval(&EmptyScope).unwrap(), 2.0); + assert_eq!(b.eval(&EmptyScope).unwrap(), 2.0); + } + + #[test] + fn plain_arithmetic() { + let expr = Expr::parse("= (2 + 3) * 4 - 10 / 2 % 3").unwrap(); + // (2+3)*4 - (10/2 % 3) = 20 - (5.0 % 3.0) = 20 - 2.0 = 18.0 + assert_eq!(expr.eval(&EmptyScope).unwrap(), 18.0); + } + + #[test] + fn ternary_short_circuits() { + // The untaken branch divides by zero; if both were evaluated + // eagerly this would produce +inf instead of the taken branch's 5. + let expr = Expr::parse("= 1 > 0 ? 5 : (1 / 0)").unwrap(); + assert_eq!(expr.eval(&EmptyScope).unwrap(), 5.0); + } + + #[test] + fn ternary_untaken_branch_errors_are_not_raised() { + // The untaken branch references an unknown var; must not error. + let expr = Expr::parse("= 1 < 0 ? $missing : 42").unwrap(); + assert_eq!(expr.eval(&EmptyScope).unwrap(), 42.0); + } + + #[test] + fn constants_resolve() { + let expr = Expr::parse("= PI + TAU + E").unwrap(); + assert!((expr.eval(&EmptyScope).unwrap() - (PI + TAU + std::f64::consts::E)).abs() < 1e-12); + } + + #[test] + fn free_vars_and_is_static() { + let dynamic = Expr::parse("= $t * 2").unwrap(); + assert_eq!(dynamic.free_vars(), vec!["t".to_string()]); + assert!(!dynamic.is_static()); + + let static_expr = Expr::parse("= $W / 2 + $i").unwrap(); + assert_eq!( + static_expr.free_vars(), + vec!["W".to_string(), "i".to_string()] + ); + assert!(static_expr.is_static()); + + let duration_expr = Expr::parse("= $duration").unwrap(); + assert!(!duration_expr.is_static()); + } + + #[test] + fn node_ref_is_never_static() { + let expr = Expr::parse("= node(\"badge_0\", \"x\") + 1").unwrap(); + assert!(!expr.is_static()); + assert!(expr.free_vars().is_empty()); + } + + #[test] + fn node_ref_resolves_through_scope() { + struct NodeScope; + impl Scope for NodeScope { + fn var(&self, _name: &str) -> Option { + None + } + fn node_prop(&self, id: &str, prop: &str) -> Option { + if id == "badge_0" && prop == "x" { + Some(100.0) + } else { + None + } + } + } + let expr = Expr::parse("= node(\"badge_0\", \"x\") + 1").unwrap(); + assert_eq!(expr.eval(&NodeScope).unwrap(), 101.0); + } + + #[test] + fn unknown_var_errors_at_eval() { + let expr = Expr::parse("= $nope").unwrap(); + let err = expr.eval(&EmptyScope).unwrap_err(); + assert_eq!(err, ExprError::UnknownIdent("nope".to_string())); + } + + #[test] + fn eight_badges_on_a_circle_match_real_cosines() { + let count = 8; + for i in 0..count { + let scope = MapScope(&[("i", i as f64), ("count", count as f64), ("W", 1080.0)]); + let expr = Expr::parse("= $W/2 + cos($i / $count * TAU - PI/2) * 700").unwrap(); + assert!(expr.is_static()); + let got = expr.eval(&scope).unwrap(); + let want = 1080.0 / 2.0 + (i as f64 / count as f64 * TAU - PI / 2.0).cos() * 700.0; + assert!( + (got - want).abs() < 1e-9, + "badge {i}: got {got}, want {want}" + ); + } + } + + #[test] + fn rand_is_deterministic_across_separate_evaluations() { + let expr = Expr::parse("= rand(42)").unwrap(); + let a = expr.eval(&EmptyScope).unwrap(); + let b = expr.eval(&EmptyScope).unwrap(); + assert_eq!(a, b); + // And a fresh parse of the same source is identical too. + let c = Expr::parse("= rand(42)") + .unwrap() + .eval(&EmptyScope) + .unwrap(); + assert_eq!(a, c); + } + + #[test] + fn deeply_nested_expression_is_rejected_at_parse() { + let src = format!("={}1{}", "(".repeat(500), ")".repeat(500)); + let err = Expr::parse(&src).unwrap_err(); + assert!(matches!(err, ExprError::TooDeep(_)), "got {err:?}"); + } + + #[test] + fn huge_exponent_chain_is_rejected_at_parse() { + let mut body = "2".to_string(); + for _ in 0..500 { + body = format!("pow({body}, 2)"); + } + let src = format!("= {body}"); + let err = Expr::parse(&src).unwrap_err(); + assert!(matches!(err, ExprError::TooDeep(_)), "got {err:?}"); + } + + #[test] + fn computed_untagged_literal_vs_expr_for_numeric_t() { + let lit: Computed = serde_json::from_str("42.5").unwrap(); + assert_eq!(lit, Computed::Literal(42.5)); + + let expr: Computed = serde_json::from_str("\"= $W/2\"").unwrap(); + assert_eq!(expr, Computed::Expr("= $W/2".to_string())); + } + + #[test] + fn computed_round_trips_through_serde() { + let lit: Computed = Computed::Literal(10.0); + let json = serde_json::to_string(&lit).unwrap(); + assert_eq!(json, "10.0"); + + let expr: Computed = Computed::Expr("= 1 + 1".to_string()); + let json = serde_json::to_string(&expr).unwrap(); + assert_eq!(json, "\"= 1 + 1\""); + } +} diff --git a/crates/rustmotion-core/src/expr/parser.rs b/crates/rustmotion-core/src/expr/parser.rs new file mode 100644 index 00000000..4aa17f60 --- /dev/null +++ b/crates/rustmotion-core/src/expr/parser.rs @@ -0,0 +1,397 @@ +//! Recursive-descent parser: `Vec` in, [`Ast`] out. +//! +//! Precedence, low to high: ternary (`?:`, right-associative) → comparison +//! (`== != < <= > >=`, left-associative chaining) → additive (`+ -`) → +//! multiplicative (`* / %`) → unary minus → primary (literal, `$var`, +//! `(expr)`, builtin call, `node("id","prop")`, constant). +//! +//! ## The nesting cap +//! +//! [`MAX_DEPTH`] bounds how deep the *recursive descent itself* is allowed +//! to go, checked at every point the grammar re-enters "parse one full +//! sub-expression": parenthesised groups, each function/`node()` argument, +//! both ternary branches, and each link of a chained unary minus. Because +//! every other production in this grammar (comparison, additive, +//! multiplicative) is an iterative precedence-climb — a `while` loop, not a +//! function calling itself — those four sites are the *only* ways an +//! adversarial input can make the parser recurse, so guarding them is +//! sufficient to guarantee the whole parse (and the `Box` tree it +//! produces) is bounded, without having to thread the check through every +//! grammar rule individually. A pathological input like 5000 nested parens +//! or a `pow(pow(pow(...)))` chain hits [`ExprError::TooDeep`] here, at +//! parse time, rather than blowing the native call stack or building an +//! unbounded tree that a later pass would have to walk. + +use super::ast::{Ast, BinOp, UnOp}; +use super::builtins::{constant, Builtin}; +use super::lexer::{tokenize, Token}; +use super::ExprError; + +/// Recursion budget for the four self-recursive grammar entry points (see +/// module doc). 64 is far beyond any nesting a hand- or LLM-written +/// expression plausibly needs, and comfortably inside the native stack — +/// the point is to fail long before that becomes a concern either way. +const MAX_DEPTH: usize = 64; + +pub(crate) fn parse(src: &str) -> Result { + let tokens = tokenize(src)?; + let mut parser = Parser { + tokens, + pos: 0, + depth: 0, + src, + }; + let ast = parser.parse_expr()?; + parser.expect_end(src)?; + Ok(ast) +} + +struct Parser<'a> { + tokens: Vec, + pos: usize, + depth: usize, + src: &'a str, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.pos) + } + + fn advance(&mut self) -> Option { + let tok = self.tokens.get(self.pos).cloned(); + if tok.is_some() { + self.pos += 1; + } + tok + } + + fn err(&self, reason: impl Into) -> ExprError { + ExprError::Parse { + src: self.src.to_string(), + reason: reason.into(), + } + } + + fn expect_end(&self, src: &str) -> Result<(), ExprError> { + if self.pos != self.tokens.len() { + return Err(ExprError::Parse { + src: src.to_string(), + reason: format!( + "unexpected trailing input after a complete expression (token {})", + self.pos + ), + }); + } + Ok(()) + } + + fn enter(&mut self) -> Result<(), ExprError> { + self.depth += 1; + if self.depth > MAX_DEPTH { + self.depth -= 1; + return Err(ExprError::TooDeep(MAX_DEPTH)); + } + Ok(()) + } + + fn exit(&mut self) { + self.depth -= 1; + } + + /// Top-level rule and the guarded re-entry point for parenthesised + /// groups, each ternary branch, and every call argument. + fn parse_expr(&mut self) -> Result { + self.enter()?; + let result = self.parse_ternary(); + self.exit(); + result + } + + fn parse_ternary(&mut self) -> Result { + let cond = self.parse_comparison()?; + if matches!(self.peek(), Some(Token::Question)) { + self.advance(); + let then_branch = self.parse_expr()?; + self.expect(&Token::Colon)?; + let else_branch = self.parse_expr()?; + return Ok(Ast::Ternary( + Box::new(cond), + Box::new(then_branch), + Box::new(else_branch), + )); + } + Ok(cond) + } + + fn parse_comparison(&mut self) -> Result { + let mut left = self.parse_additive()?; + loop { + let op = match self.peek() { + Some(Token::EqEq) => BinOp::Eq, + Some(Token::NotEq) => BinOp::Ne, + Some(Token::Lt) => BinOp::Lt, + Some(Token::Le) => BinOp::Le, + Some(Token::Gt) => BinOp::Gt, + Some(Token::Ge) => BinOp::Ge, + _ => break, + }; + self.advance(); + let right = self.parse_additive()?; + left = Ast::Bin(op, Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_additive(&mut self) -> Result { + let mut left = self.parse_multiplicative()?; + loop { + let op = match self.peek() { + Some(Token::Plus) => BinOp::Add, + Some(Token::Minus) => BinOp::Sub, + _ => break, + }; + self.advance(); + let right = self.parse_multiplicative()?; + left = Ast::Bin(op, Box::new(left), Box::new(right)); + } + Ok(left) + } + + fn parse_multiplicative(&mut self) -> Result { + let mut left = self.parse_unary()?; + loop { + let op = match self.peek() { + Some(Token::Star) => BinOp::Mul, + Some(Token::Slash) => BinOp::Div, + Some(Token::Percent) => BinOp::Rem, + _ => break, + }; + self.advance(); + let right = self.parse_unary()?; + left = Ast::Bin(op, Box::new(left), Box::new(right)); + } + Ok(left) + } + + /// Guarded separately from [`Parser::parse_expr`] because a chain of + /// unary minuses (`----1`) recurses into itself directly, never passing + /// back through `parse_expr` — see the module doc. + fn parse_unary(&mut self) -> Result { + if matches!(self.peek(), Some(Token::Minus)) { + self.advance(); + self.enter()?; + let inner = self.parse_unary(); + self.exit(); + return Ok(Ast::Unary(UnOp::Neg, Box::new(inner?))); + } + // No unary `+`: the frozen grammar names only unary minus. Adding + // a second unguarded-by-default recursive entry point here for a + // no-op sign isn't worth the extra surface, so `+1` is a parse + // error rather than a silent alias for `1`. + self.parse_primary() + } + + fn parse_primary(&mut self) -> Result { + match self.advance() { + Some(Token::Num(n)) => Ok(Ast::Num(n)), + Some(Token::Var(name)) => Ok(Ast::Var(name)), + Some(Token::LParen) => { + let inner = self.parse_expr()?; + self.expect(&Token::RParen)?; + Ok(inner) + } + Some(Token::Ident(name)) => self.parse_ident(name), + Some(Token::Str(_)) => { + Err(self.err("a string literal is only valid as a node(\"id\", \"prop\") argument")) + } + Some(other) => Err(self.err(format!("unexpected token {other:?}"))), + None => Err(self.err("unexpected end of expression")), + } + } + + fn parse_ident(&mut self, name: String) -> Result { + if name == "node" { + return self.parse_node_ref(); + } + if let Some(builtin) = Builtin::from_name(&name) { + return self.parse_call(builtin); + } + if let Some(value) = constant(&name) { + if matches!(self.peek(), Some(Token::LParen)) { + return Err(self.err(format!("'{name}' is a constant, not a function"))); + } + return Ok(Ast::Num(value)); + } + Err(ExprError::UnknownIdent(name)) + } + + fn parse_call(&mut self, builtin: Builtin) -> Result { + self.expect(&Token::LParen)?; + let mut args = Vec::new(); + if !matches!(self.peek(), Some(Token::RParen)) { + loop { + args.push(self.parse_expr()?); + if matches!(self.peek(), Some(Token::Comma)) { + self.advance(); + continue; + } + break; + } + } + self.expect(&Token::RParen)?; + let expected = builtin.arity(); + if args.len() != expected { + return Err(ExprError::Arity { + name: builtin.name().to_string(), + expected, + got: args.len(), + }); + } + Ok(Ast::Call(builtin, args)) + } + + fn parse_node_ref(&mut self) -> Result { + self.expect(&Token::LParen)?; + let id = self.expect_string("node")?; + self.expect(&Token::Comma)?; + let prop = self.expect_string("node")?; + self.expect(&Token::RParen)?; + Ok(Ast::NodeRef(id, prop)) + } + + fn expect_string(&mut self, ctx: &str) -> Result { + match self.advance() { + Some(Token::Str(s)) => Ok(s), + Some(other) => Err(self.err(format!( + "{ctx}(...) expects a string literal argument, found {other:?}" + ))), + None => Err(self.err(format!("{ctx}(...) expects a string literal argument"))), + } + } + + fn expect(&mut self, want: &Token) -> Result<(), ExprError> { + match self.advance() { + Some(ref t) if t == want => Ok(()), + Some(other) => Err(self.err(format!("expected {want:?}, found {other:?}"))), + None => Err(self.err(format!("expected {want:?}, found end of expression"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_precedence() { + // 1 + 2 * 3 == 1 + (2 * 3) + let ast = parse("1 + 2 * 3").unwrap(); + assert_eq!( + ast, + Ast::Bin( + BinOp::Add, + Box::new(Ast::Num(1.0)), + Box::new(Ast::Bin( + BinOp::Mul, + Box::new(Ast::Num(2.0)), + Box::new(Ast::Num(3.0)) + )) + ) + ); + } + + #[test] + fn parses_ternary_and_comparison() { + let ast = parse("$x > 0 ? 1 : -1").unwrap(); + match ast { + Ast::Ternary(cond, then_b, else_b) => { + assert_eq!( + *cond, + Ast::Bin( + BinOp::Gt, + Box::new(Ast::Var("x".to_string())), + Box::new(Ast::Num(0.0)) + ) + ); + assert_eq!(*then_b, Ast::Num(1.0)); + assert_eq!(*else_b, Ast::Unary(UnOp::Neg, Box::new(Ast::Num(1.0)))); + } + other => panic!("expected ternary, got {other:?}"), + } + } + + #[test] + fn parses_node_ref() { + let ast = parse("node(\"badge_3\", \"x\") + 1").unwrap(); + assert_eq!( + ast, + Ast::Bin( + BinOp::Add, + Box::new(Ast::NodeRef("badge_3".to_string(), "x".to_string())), + Box::new(Ast::Num(1.0)) + ) + ); + } + + #[test] + fn unknown_function_name_is_unknown_ident() { + let err = parse("sni(1)").unwrap_err(); + assert_eq!(err, ExprError::UnknownIdent("sni".to_string())); + } + + #[test] + fn wrong_arity_is_reported() { + let err = parse("sin(1, 2)").unwrap_err(); + assert_eq!( + err, + ExprError::Arity { + name: "sin".to_string(), + expected: 1, + got: 2, + } + ); + } + + #[test] + fn constant_is_not_callable() { + assert!(parse("PI(1)").is_err()); + } + + #[test] + fn trailing_garbage_is_a_parse_error() { + assert!(parse("1 + 1 )").is_err()); + } + + #[test] + fn deeply_nested_parens_hit_the_depth_cap() { + let src = format!("{}1{}", "(".repeat(200), ")".repeat(200)); + let err = parse(&src).unwrap_err(); + assert_eq!(err, ExprError::TooDeep(MAX_DEPTH)); + } + + #[test] + fn deeply_chained_unary_minus_hits_the_depth_cap() { + let src = format!("{}1", "-".repeat(200)); + let err = parse(&src).unwrap_err(); + assert_eq!(err, ExprError::TooDeep(MAX_DEPTH)); + } + + #[test] + fn huge_nested_pow_chain_hits_the_depth_cap() { + // A hostile "huge exponent" shape: pow(pow(pow(...2...))), nested + // deep enough to be the kind of input that should never reach eval. + let mut src = "2".to_string(); + for _ in 0..200 { + src = format!("pow({src}, 2)"); + } + let err = parse(&src).unwrap_err(); + assert_eq!(err, ExprError::TooDeep(MAX_DEPTH)); + } + + #[test] + fn reasonable_nesting_is_accepted() { + let src = format!("{}1{}", "(".repeat(10), ")".repeat(10)); + assert!(parse(&src).is_ok()); + } +} diff --git a/crates/rustmotion-core/src/lib.rs b/crates/rustmotion-core/src/lib.rs index 3afb31ea..73567f6e 100644 --- a/crates/rustmotion-core/src/lib.rs +++ b/crates/rustmotion-core/src/lib.rs @@ -1,9 +1,12 @@ +pub mod audio; pub mod error; #[macro_use] pub mod macros; pub mod css; pub mod engine; pub mod expand; +pub mod expr; pub mod schema; pub mod traits; pub mod variables; +pub mod vars; diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index bf398426..8cd80fed 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -72,6 +72,15 @@ pub enum EasingType { x2: f64, y2: f64, }, + /// Discrete jump easing (CSS `steps(n, jump-end)`): `t` snaps to one of + /// `n` equally-sized steps instead of interpolating continuously. The + /// value holds at step `i`'s level (`i / n`) for the whole `[i/n, + /// (i+1)/n)` span of `t`, then jumps — it never eases through the + /// space between two states. `steps(1)` is the degenerate, most useful + /// case: hold the start value for the entire segment, then jump to the + /// end exactly at `t = 1.0` — a blinking caret wants this (a discrete + /// on/off), not a fade. See `engine::animator::ease` for the formula. + Steps(u32), } fn default_easing() -> EasingType { @@ -201,9 +210,36 @@ pub struct PresetConfig { pub delay: f64, #[serde(default = "default_preset_duration")] pub duration: f64, - /// Loop the animation continuously + /// Loop the animation continuously. Kept as a plain bool — `true` + /// covers both "loop forever" (`repeat_count: None`) and "loop a known + /// number of times" (`repeat_count: Some(n)`) — so every reader that + /// only ever checked this flag (there were several, outside this + /// workstream's owned files) keeps treating a finite `repeat_count` as + /// "this loops" rather than mis-reading it as a one-shot animation. #[serde(default, rename = "loop")] pub repeat: bool, + /// How many times the animation plays when `AnimationTiming`'s widened + /// `"loop"` field named an exact integer count rather than a bare + /// bool (issue #330). `None` defers entirely to `repeat`: `true` loops + /// forever, `false` plays once. See + /// `AnimationTiming::{repeat, repeat_count}` for where this is parsed + /// from JSON, and `engine::animator::cycle_time` for how it's played + /// back. + #[serde(default)] + pub repeat_count: Option, + /// Reverse direction on every other play (ping-pong) instead of + /// snapping back to the start each cycle — GSAP calls this `yoyo`. + /// Only meaningful when the animation actually repeats (`repeat` or + /// `repeat_count`); a no-op otherwise. Applies to any looping + /// animation, not a fixed set of presets — see + /// `engine::animator::cycle_time`. + #[serde(default)] + pub yoyo: bool, + /// Pause between plays, in seconds, held at the resting value of the + /// play that just finished before the next one starts. `0.0` (default) + /// is a seamless loop. + #[serde(default)] + pub repeat_delay: f64, /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%). #[serde(default)] pub overshoot: Option, @@ -225,6 +261,9 @@ impl Default for PresetConfig { delay: 0.0, duration: 0.8, repeat: false, + repeat_count: None, + yoyo: false, + repeat_delay: 0.0, overshoot: None, spring: None, amplitude: None, diff --git a/crates/rustmotion-core/src/schema/codeblock_types.rs b/crates/rustmotion-core/src/schema/codeblock_types.rs deleted file mode 100644 index be28fc81..00000000 --- a/crates/rustmotion-core/src/schema/codeblock_types.rs +++ /dev/null @@ -1,97 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use super::animation::EasingType; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CodeblockChrome { - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default)] - pub title: Option, - #[serde(default)] - pub color: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CodeblockHighlight { - pub lines: Vec, - #[serde(default = "default_highlight_color")] - pub color: String, - #[serde(default)] - pub start: Option, - #[serde(default)] - pub end: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CodeblockReveal { - pub mode: RevealMode, - #[serde(default)] - pub start: f64, - #[serde(default = "default_reveal_duration")] - pub duration: f64, - #[serde(default)] - pub easing: EasingType, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RevealMode { - Typewriter, - LineByLine, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CodeblockState { - pub code: String, - pub at: f64, - #[serde(default = "default_state_duration")] - pub duration: f64, - #[serde(default = "default_state_easing")] - pub easing: EasingType, - #[serde(default)] - pub cursor: Option, - #[serde(default)] - pub highlights: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct CodeblockCursor { - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default = "default_cursor_color")] - pub color: String, - #[serde(default = "default_cursor_width")] - pub width: f32, - #[serde(default = "default_true")] - pub blink: bool, -} - -fn default_true() -> bool { - true -} - -fn default_highlight_color() -> String { - "#FFFF0033".to_string() -} - -fn default_reveal_duration() -> f64 { - 1.0 -} - -fn default_state_duration() -> f64 { - 0.6 -} - -fn default_state_easing() -> EasingType { - EasingType::EaseInOut -} - -fn default_cursor_color() -> String { - "#FFFFFF".to_string() -} - -fn default_cursor_width() -> f32 { - 2.0 -} diff --git a/crates/rustmotion-core/src/schema/mod.rs b/crates/rustmotion-core/src/schema/mod.rs index 43d1a2fe..1dfdc33e 100644 --- a/crates/rustmotion-core/src/schema/mod.rs +++ b/crates/rustmotion-core/src/schema/mod.rs @@ -1,15 +1,17 @@ pub mod animation; pub mod background; -pub mod codeblock_types; pub mod scenario; +pub mod shake; pub mod style; +pub mod time; pub mod video; pub use animation::*; pub use background::*; -pub use codeblock_types::*; pub use scenario::*; +pub use shake::*; pub use style::*; +pub use time::*; pub use video::*; pub fn generate_json_schema() -> serde_json::Value { diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 72d56a47..f8267ba2 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -8,7 +8,9 @@ use super::background::{ deserialize_animated_backgrounds, deserialize_background_value, AnimatedBackground, BackgroundValue, ResolvedBackground, }; +use super::shake::SceneShake; use super::style::{CardAlign, CardDirection, CardJustify}; +use super::time::TimePoint; /// Definition of a variable in a structural component. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -32,14 +34,65 @@ pub enum VariableType { Array, } -#[derive(Debug, Serialize, Deserialize, JsonSchema)] +/// One entry of [`Scenario::components`] — a named, reusable template. +/// +/// Schema-only twin of `rustmotion_core::expand::ComponentDefinition` +/// (private to that module — this type exists so `components` has a real, +/// documented shape in the exported schema; see [`Scenario::components`]'s +/// doc for why it can't just reuse that private struct, and why this one is +/// never populated in practice). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ComponentTemplateDef { + /// Parameters this template accepts. Same shape as + /// [`ComponentTemplateParam`] — *not* [`VariableDefinition`] (the + /// scenario-level `config` entry shape): a template parameter's + /// `default` is itself optional, and omitting it is what makes the + /// parameter required at every `use` site. `config`'s `default` has no + /// such omission story — it is always required there. + #[serde(default)] + pub params: HashMap, + /// The subtree to instantiate: a single component object, or an array + /// of sibling component objects (a fragment spliced in place). May + /// itself contain nested `for-each`/`use` directives. + pub template: serde_json::Value, +} + +/// One parameter declared by a [`ComponentTemplateDef`]. See +/// [`ComponentTemplateDef::params`] for how this differs from +/// [`VariableDefinition`]. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ComponentTemplateParam { + #[serde(rename = "type")] + pub param_type: VariableType, + /// Omitting this makes the parameter required at every `use` site. + #[serde(default)] + pub default: Option, + #[serde(default)] + pub description: Option, +} + +/// Deliberately **not** `#[derive(Deserialize)]` — see +/// [`ScenarioDe`]/`impl From for Scenario` just below for why: +/// this type needs a post-deserialize pass (propagating `bpm`/`beat_offset`/ +/// `timing`/`snap` down onto every reachable [`Scene`]) that a plain derive +/// can't express, and every call site that builds a `Scenario` from JSON +/// (`loader.rs`, `include.rs`'s own included-file loading) is outside this +/// workstream's owned files this wave — the propagation has to happen +/// *inside* `Scenario::deserialize` itself so those callers need no changes. +#[derive(Debug, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Scenario { #[serde(default = "default_version")] pub version: String, pub video: VideoConfig, + /// File-based tracks (`"audio": [...]`, unchanged since before issue + /// #331) or, for a scenario that synthesises its own soundtrack, a + /// single object carrying both (`"audio": {"tracks": [...], "voices": + /// {...}, "score": [...], "master": {...}}`) — see [`AudioValue`]. #[serde(default)] - pub audio: Vec, + pub audio: AudioValue, #[serde(default)] pub fonts: Vec, #[serde(default, deserialize_with = "deserialize_scene_entries")] @@ -53,10 +106,220 @@ pub struct Scenario { /// Named background templates that scenes can reference via `$ref`. #[serde(default)] pub backgrounds: HashMap, + /// Reusable component template definitions — `"components": { "name": { + /// "params": {...}, "template": {...} } }`, instantiated from a + /// `children` entry via `{"use": "name", "props": {...}}` (see + /// [`ComponentTemplateDef`]). Documented in `CLAUDE.md`'s + /// "Factorisation" section. + /// + /// This field exists so the exported JSON Schema (`rustmotion schema`) + /// declares the shape the engine actually accepts — it is **not** how + /// `components` is consumed at runtime. `rustmotion_core::expand:: + /// expand_directives` (a sibling workstream's file, outside this one's + /// scope) resolves every `for-each`/`use` against this block and then + /// *removes the key entirely*, before this struct is ever deserialized + /// — the same way `variables::apply_variables` consumes `config`'s + /// `$name` placeholders without `config` disappearing from the struct. + /// The practical difference: `config` stays meaningful after expansion + /// (`Scenario::config` is read elsewhere), while `components` is spent + /// in full during expansion, so this field is always empty by the time + /// any code outside `schema/` could read it. + #[serde(default)] + pub components: HashMap, /// Studio feedback annotations. Persisted in the scenario but never read by /// the renderer or the geometry validator. Skipped on serialization when empty. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub annotations: Vec, + /// Beats per minute for this scenario's beat grid (issue #336). `None` + /// (default) means no grid exists: any [`TimePoint`] beat (`b`) unit + /// then fails to resolve with `unresolved_beat_unit` (schema: + /// [`crate::schema::TimeError::NoBpm`]). + #[serde(default)] + pub bpm: Option, + /// Where beat 0 sits on the scenario's absolute timeline, in seconds. + /// The grid is `beat_offset + n * 60 / bpm` — not decoration: a reel + /// whose first beat lands at 2.2s anchors its grid there, not at 0. + #[serde(default)] + pub beat_offset: f64, + /// Optional override of the scenario's total rendered duration, in + /// seconds. Reserved for downstream consumers (audio/export tooling); + /// the frame-task scheduler in `rustmotion`'s `encode` module does not + /// read it — under `timing: "v2"` the total is always `at_last + + /// duration_last`, computed from the scenes themselves. + #[serde(default)] + pub duration: Option, + /// Scene-placement semantics. See [`TimingMode`]. + #[serde(default)] + pub timing: TimingMode, + /// Rounds resolved times onto a grid before scheduling. See [`SnapMode`]. + #[serde(default)] + pub snap: Option, + /// Scenario-level variables (issue #329): a scalar declared once, + /// animated on the scenario's absolute timeline, and readable from any + /// expression in any scene as `$name`. Visible everywhere; shadowed by + /// a scene's own [`Scene::vars`] of the same name. See + /// `rustmotion_core::vars` for the resolver and the `Scope` + /// implementation that reads this. + #[serde(default)] + pub vars: crate::vars::VarSet, +} + +/// The wire shape of [`Scenario`] — identical field-for-field, attribute-for- +/// attribute — used only to give `Scenario` a `Deserialize` impl that runs +/// [`Scenario::propagate_time_ctx`] before handing the value to its caller. +/// Kept private: nothing outside this module should ever construct or see +/// one directly. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ScenarioDe { + #[serde(default = "default_version")] + version: String, + video: VideoConfig, + #[serde(default)] + audio: AudioValue, + #[serde(default)] + fonts: Vec, + #[serde(default, deserialize_with = "deserialize_scene_entries")] + scenes: Vec, + #[serde(default)] + composition: Option>, + #[serde(default)] + config: Option>, + #[serde(default)] + backgrounds: HashMap, + #[serde(default)] + components: HashMap, + #[serde(default)] + annotations: Vec, + #[serde(default)] + bpm: Option, + #[serde(default)] + beat_offset: f64, + #[serde(default)] + duration: Option, + #[serde(default)] + timing: TimingMode, + #[serde(default)] + snap: Option, + #[serde(default)] + vars: crate::vars::VarSet, +} + +impl<'de> Deserialize<'de> for Scenario { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = ScenarioDe::deserialize(deserializer)?; + let mut scenario = Scenario { + version: raw.version, + video: raw.video, + audio: raw.audio, + fonts: raw.fonts, + scenes: raw.scenes, + composition: raw.composition, + config: raw.config, + backgrounds: raw.backgrounds, + components: raw.components, + annotations: raw.annotations, + bpm: raw.bpm, + beat_offset: raw.beat_offset, + duration: raw.duration, + timing: raw.timing, + snap: raw.snap, + vars: raw.vars, + }; + scenario.propagate_time_ctx(); + Ok(scenario) + } +} + +impl Scenario { + /// Stamps every scene reachable from this `Scenario` — its own + /// top-level `scenes`, and every view's `scenes` under `composition` — + /// with this scenario's own `bpm`/`beat_offset`/`timing`/`snap`, via + /// [`Scene::resolved_time_ctx`]/[`Scene::resolved_timing`]/ + /// [`Scene::resolved_snap`], and (issue #329) with this scenario's own + /// `vars` via [`Scene::resolved_scenario_vars`] — same reasoning, same + /// mechanism, reused rather than duplicated. Runs once, inside + /// [`Scenario::deserialize`], so it applies uniformly whether the + /// scenario came from a file, a remote `include`, or an inline `--json` + /// string. + /// + /// An `include`d file is deserialized as its own `Scenario` (see + /// `include.rs`), so this only ever propagates a scenario's own + /// declared grid — and its own `vars` — to its own scenes: an included + /// file that wants beat-synced content must declare its own `bpm`, and + /// one that wants scenario-level variables must declare its own `vars`. + fn propagate_time_ctx(&mut self) { + let ctx = super::time::TimeCtx { + bpm: self.bpm, + beat_offset: self.beat_offset, + scene_start: 0.0, + }; + let timing = self.timing; + let snap = self.snap; + let scenario_vars = self.vars.clone(); + stamp_entries(&mut self.scenes, ctx, timing, snap, &scenario_vars); + if let Some(views) = &mut self.composition { + for view in views { + stamp_entries(&mut view.scenes, ctx, timing, snap, &scenario_vars); + } + } + } +} + +/// Stamps every [`Scene`] in `entries` (skipping `Include` directives, which +/// carry no `Scene` yet) with the given beat-grid context and scenario-level +/// variables. Free function rather than a closure over `&mut self` so it can +/// be called once for the top-level `scenes` and once per `composition` view +/// without fighting the borrow checker over `self`. +fn stamp_entries( + entries: &mut [SceneEntry], + ctx: super::time::TimeCtx, + timing: TimingMode, + snap: Option, + scenario_vars: &crate::vars::VarSet, +) { + for entry in entries { + if let SceneEntry::Scene(scene) = entry { + scene.resolved_time_ctx = ctx; + scene.resolved_timing = timing; + scene.resolved_snap = snap; + scene.resolved_scenario_vars = scenario_vars.clone(); + } + } +} + +/// Scene-placement semantics for a scenario (issue #336). See +/// [`Scene::at`] and [`Scene::tail`] for what `"v2"` unlocks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TimingMode { + /// Today's behaviour, byte-identical for every scenario that predates + /// issue #336: a transition's frames *replace* frames at the tail of + /// the outgoing scene and the head of the incoming one, so the + /// scenario's rendered length is `sum(scene durations) - sum(transition + /// durations)`. + #[default] + V1, + /// Absolute scene placement with a declared overlap: scene *i* occupies + /// `[at_i, at_i + duration_i)`, and a transition entering it + /// additionally renders the *previous* scene during `[at_i, at_i + + /// transition duration)` — past its own end, per that scene's + /// [`SceneTail`]. Total duration is `at_last + duration_last`: no + /// subtraction anywhere. + V2, +} + +/// What `Scenario.snap` rounds resolved times onto. Currently only the beat +/// grid; the variant exists so the field reads as intent (`"beat"`) rather +/// than a bare boolean. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SnapMode { + /// Round to the nearest point on `beat_offset + n * 60 / bpm`. + Beat, } /// Lifecycle of a studio annotation. @@ -157,6 +420,16 @@ pub struct View { } /// A scenario with all includes expanded — safe to pass to the rendering pipeline. +/// +/// Deliberately does **not** carry `bpm`/`beat_offset`/`timing`/`snap` +/// itself (issue #336): `include.rs`, which builds this struct, is a file +/// no single workstream owns in this wave, so those scenario-level values +/// are threaded through per-[`Scene`] instead — +/// [`Scene::resolved_time_ctx`], [`Scene::resolved_timing`], +/// [`Scene::resolved_snap`] — populated once, for every scene reachable +/// from a `Scenario` (including through `composition`), by +/// `Scenario`'s own `Deserialize` impl. That happens before `include.rs` +/// ever runs, so it needs no cooperation from that file. #[derive(Debug)] pub struct ResolvedScenario { pub video: VideoConfig, @@ -291,6 +564,120 @@ pub struct FontEntry { pub weights: Option>, } +/// `Scenario::audio`'s wire shape (issue #331): either the legacy bare +/// array of file-based [`AudioTrack`]s, or a single object that can carry +/// both file tracks *and* a synthesised score, mixed into one bus. Which +/// shape a given `"audio"` value is is unambiguous from its JSON kind +/// (array vs. object), so `#[serde(untagged)]` needs no help distinguishing +/// them. +/// +/// An old scenario using the bare-array form is unaffected byte-for-byte — +/// [`AudioValue::Tracks`] round-trips through exactly the type `audio` used +/// to be, and the `rustmotion` crate's include-resolution pass (outside +/// this crate) never has to look past [`AudioValue::into_tracks`]. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum AudioValue { + Tracks(Vec), + Config(Box), +} + +/// Not derivable: `#[derive(Default)]`'s `#[default]` attribute only +/// accepts a unit variant, and [`AudioValue::Tracks`] carries a `Vec`. +impl Default for AudioValue { + fn default() -> Self { + AudioValue::Tracks(Vec::new()) + } +} + +impl AudioValue { + /// The file-based tracks this value carries — all of them for + /// [`AudioValue::Tracks`], just the `tracks` field for + /// [`AudioValue::Config`]. What `include::resolve_includes` (outside + /// this workstream's owned files) merges across `include`d scenarios; + /// the object form's `voices`/`score`/`master` are deliberately not + /// part of that merge — see [`AudioValue::config`]'s doc. + pub fn tracks(&self) -> &[AudioTrack] { + match self { + AudioValue::Tracks(tracks) => tracks, + AudioValue::Config(config) => &config.tracks, + } + } + + pub fn into_tracks(self) -> Vec { + match self { + AudioValue::Tracks(tracks) => tracks, + AudioValue::Config(config) => config.tracks, + } + } + + /// The object form's full config, when `audio` is one — `None` for the + /// legacy array form. A synthesised score only ever comes from the + /// *root* scenario's own `audio`: `include.rs` merges `tracks()` across + /// `include`d files (same as before this issue), but has no equivalent + /// merge for a score, the same boundary `Scenario::bpm`/`beat_offset` + /// already draw (see `Scenario::propagate_time_ctx`'s doc) — an + /// included file that wants its own synthesised audio would need its + /// own render pass, out of scope here. + pub fn config(&self) -> Option<&AudioConfig> { + match self { + AudioValue::Tracks(_) => None, + AudioValue::Config(config) => Some(config), + } + } +} + +/// The object form of [`AudioValue`] — file tracks plus, optionally, a +/// synthesised score (issue #331's `voices`/`score`/`master`, matching the +/// issue body's example verbatim). `bpm`/`beat_offset` default to the +/// *scenario's* own [`Scenario::bpm`]/[`Scenario::beat_offset`] when +/// absent here — see `AudioConfig::as_score`'s caller in `rustmotion`'s +/// `encode` crate, which is where that fallback is actually applied (this +/// crate only carries the override, it does not resolve it: `Scenario` +/// alone does not know its own `bpm` by the time `include::resolve_includes` +/// has consumed it — the caller captures both before that happens). +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AudioConfig { + #[serde(default)] + pub tracks: Vec, + /// Overrides the scenario's own `bpm` for this score only. Normally + /// left absent — sharing the scenario's real grid (deliverable #1's + /// whole point) is the common case, not the exception. + #[serde(default)] + pub bpm: Option, + #[serde(default)] + pub beat_offset: Option, + #[serde(default)] + pub voices: HashMap, + #[serde(default)] + pub score: Vec, + #[serde(default)] + pub master: crate::audio::MasterBus, +} + +impl AudioConfig { + /// Whether this config declares anything to synthesise — `false` for a + /// value that only uses the object form to carry `tracks` (e.g. to set + /// `bpm` alongside a purely file-based scenario, before `voices`/ + /// `score` are ever added). + pub fn has_synth(&self) -> bool { + !self.voices.is_empty() || !self.score.is_empty() + } + + /// Packages `voices`/`score`/`master` into a [`crate::audio::Score`] + /// for [`crate::audio::synth::render`]. Does not resolve `bpm`/ + /// `beat_offset` — see this struct's doc for why that fallback lives + /// with the caller instead. + pub fn as_score(&self) -> crate::audio::Score { + crate::audio::Score { + voices: self.voices.clone(), + score: self.score.clone(), + master: self.master.clone(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AudioTrack { pub src: String, @@ -374,6 +761,14 @@ pub struct Scene { /// Virtual camera with animatable x, y, zoom, rotation. #[serde(default)] pub camera: Option, + /// Declarative camera shake (issue #330): a list of beat-synced + /// impacts, each a damped harmonic oscillator, summed and **additive** + /// over `camera` above — a pan and a shake ride the same frame instead + /// of fighting over one keyframe track. See [`SceneShake`] for the + /// formula and `crate::engine::shake::shake_offset` for the function + /// that evaluates it. + #[serde(default)] + pub shake: Option, /// Position of this scene in the 2D world (used by world views). #[serde(default, rename = "world-position")] pub world_position: Option, @@ -384,10 +779,158 @@ pub struct Scene { /// Effects are additive and applied in declaration order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effects: Vec, + /// Where this scene starts, on the scenario's absolute timeline (issue + /// #336). `at` is absolute by definition — see the anchoring rule + /// documented once on [`TimePoint`]. Defaults to [`SceneStart::Auto`]: + /// immediately after the previous scene's own window ends. Only + /// consulted by `rustmotion`'s frame-task scheduler under + /// `Scenario.timing = "v2"`; a `"v1"` scenario places scenes + /// back-to-back regardless of what this field says. + #[serde(default, skip_serializing_if = "SceneStart::is_auto")] + pub at: SceneStart, + /// How this scene behaves when the *next* scene's transition overlaps + /// into it, rendering it past its own `duration` (issue #336, `timing: + /// "v2"` only). Defaults to [`SceneTail::Freeze`]. + #[serde(default, skip_serializing_if = "SceneTail::is_default_freeze")] + pub tail: SceneTail, /// Post-resolution background (populated by include.rs, ignored by serde). #[serde(skip)] #[schemars(skip)] pub resolved_background: ResolvedBackground, + /// The beat-grid context (`bpm`/`beat_offset`) of the `Scenario` this + /// scene was declared in — populated by [`Scenario`]'s own + /// `Deserialize` impl, *before* `include.rs` resolution ever runs, not + /// by `include.rs` itself (issue #336; see the doc on + /// [`ResolvedScenario`] for why). `scene_start` is always `0.0` here: + /// [`Scene::at`] is resolved with + /// [`TimePoint::resolve_absolute`](super::time::TimePoint::resolve_absolute), + /// which ignores it. + #[serde(skip)] + #[schemars(skip)] + pub resolved_time_ctx: super::time::TimeCtx, + /// The `Scenario.timing` this scene was declared under. Same + /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`]. + #[serde(skip)] + #[schemars(skip)] + pub resolved_timing: TimingMode, + /// The `Scenario.snap` this scene was declared under. Same + /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`]. + #[serde(skip)] + #[schemars(skip)] + pub resolved_snap: Option, + /// This scene's own declared variables (issue #329): shadow a + /// same-named scenario-level variable ([`Scenario::vars`]) for + /// expressions evaluated inside this scene, and are themselves + /// invisible from any other scene — see `rustmotion_core::vars::VarScope` + /// for the shadowing rule and why that isolation needs no special-case + /// error handling. A variable with no `animation` here is a constant, + /// same as at the scenario level. + #[serde(default)] + pub vars: crate::vars::VarSet, + /// The enclosing [`Scenario::vars`] this scene was declared under — + /// populated by `Scenario`'s own `Deserialize` impl, *before* + /// `include.rs` resolution ever runs, not by `include.rs` itself. Same + /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`]: + /// [`ResolvedScenario`] carries no scenario-level fields of its own, so + /// anything a scene needs to remember about its enclosing `Scenario` + /// has to already be sitting on the `Scene` by the time `include.rs` — + /// a file no single workstream owns — merges included scenes in. + #[serde(skip)] + #[schemars(skip)] + pub resolved_scenario_vars: crate::vars::VarSet, +} + +/// The literal string `"auto"` — the only value [`SceneStart::Auto`] can +/// hold. Its own tiny externally-tagged enum so it round-trips as the bare +/// string `"auto"` (serde's default representation for a fieldless +/// variant), matching every other `snake_case` string enum in this file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SceneStartAuto { + Auto, +} + +/// When a scene starts, on the scenario's absolute timeline. See +/// [`Scene::at`]. +/// +/// Not `#[derive(Deserialize)]` (see [`SceneStartDe`] / its manual +/// `Deserialize` impl just below): a syntactically malformed `at` — `"at": +/// "banana"` — must be rejected the moment the scenario is deserialized, +/// not discovered later as a silent fallback to automatic placement. A +/// derive can't express that extra grammar check, only the untagged +/// try-`"auto"`-then-`TimePoint` shape. +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum SceneStart { + /// Immediately after the previous scene's own window ends. Serializes + /// and deserializes as the bare string `"auto"`. + Auto(SceneStartAuto), + /// An explicit point on the scenario's absolute timeline. Grammar is + /// validated at deserialize time via + /// [`TimePoint::validate_grammar`](super::time::TimePoint::validate_grammar) + /// — resolving it (which additionally needs `bpm` for a beat unit) is a + /// separate, later step: `rustmotion validate`'s schema pass, which can + /// name the offending scene and report `unresolved_beat_unit` — see + /// `crates/rustmotion/src/cli/commands/validate_schema.rs`. + At(TimePoint), +} + +/// The wire shape of [`SceneStart`] — see that type's doc for why this +/// exists instead of a derive. Kept private: nothing outside this module +/// should ever see one directly. +#[derive(Deserialize)] +#[serde(untagged)] +enum SceneStartDe { + Auto(SceneStartAuto), + At(TimePoint), +} + +impl<'de> Deserialize<'de> for SceneStart { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + match SceneStartDe::deserialize(deserializer)? { + SceneStartDe::Auto(a) => Ok(SceneStart::Auto(a)), + SceneStartDe::At(tp) => { + tp.validate_grammar().map_err(D::Error::custom)?; + Ok(SceneStart::At(tp)) + } + } + } +} + +impl Default for SceneStart { + fn default() -> Self { + SceneStart::Auto(SceneStartAuto::Auto) + } +} + +impl SceneStart { + fn is_auto(&self) -> bool { + matches!(self, SceneStart::Auto(_)) + } +} + +/// How a scene behaves when it is rendered past its own `duration` because +/// the *next* scene's transition overlaps into it. See [`Scene::tail`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SceneTail { + /// Hold the scene's last frame for the overlap — the same + /// fixed-non-advancing-time effect `freeze_at` produces. + #[default] + Freeze, + /// Let the scene's own animations keep running past its declared + /// `duration` for the overlap. + Continue, +} + +impl SceneTail { + fn is_default_freeze(&self) -> bool { + matches!(self, SceneTail::Freeze) + } } /// Virtual camera for pan/zoom/rotation effects at the scene level. @@ -550,6 +1093,27 @@ pub enum PostEffect { #[serde(default = "default_blur_max_radius")] max_radius: f32, }, + /// A full-frame flash that decays from `intensity` to zero over + /// `duration`, starting at `at` — issue #330's companion to + /// [`super::shake::SceneShake`]: the reference reel this pair was + /// built for lights one of these on every shake impact, so a beat-grid + /// `at` can share the same value the matching + /// [`super::shake::ShakeImpact::at`] uses. `at` is a [`TimePoint`], + /// resolved the same way as any other in-scene time (relative to the + /// scene's own start unless `@`-prefixed). + Flash { + /// When the flash starts, on the scene's own timeline. + at: TimePoint, + /// Flash colour as a hex string. Default: `"#FFFFFF"`. + #[serde(default = "default_flash_color")] + color: String, + /// Peak strength, clamped to 0..1, at `t = at`. Default: 0.6. + #[serde(default = "default_flash_intensity")] + intensity: f32, + /// How long the flash takes to decay to zero, in seconds. Default: 0.15. + #[serde(default = "default_flash_duration")] + duration: f32, + }, } fn default_grain_intensity() -> f32 { @@ -576,6 +1140,15 @@ fn default_blur_start() -> f32 { fn default_blur_max_radius() -> f32 { 12.0 } +fn default_flash_color() -> String { + "#FFFFFF".to_string() +} +fn default_flash_intensity() -> f32 { + 0.6 +} +fn default_flash_duration() -> f32 { + 0.15 +} /// Scene-level flex layout configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -1094,3 +1667,200 @@ mod camera_keyframe_property_tests { ); } } + +/// Issue #336 follow-up: an unresolvable `Scene.at` must be a hard, +/// located error, not a silent fallback — grammar at deserialize time, +/// `unresolved_beat_unit` (needs `bpm`) at `rustmotion validate`'s schema +/// pass (`crates/rustmotion/src/cli/commands/validate_schema.rs`, not +/// exercised from this crate). +#[cfg(test)] +mod scene_at_grammar_tests { + use super::*; + + fn scenario_with_at(at: &str) -> String { + format!( + r#"{{ + "video": {{ "width": 100, "height": 100 }}, + "scenes": [ + {{ "duration": 1.0, "children": [] }}, + {{ "duration": 1.0, "children": [], "at": {at} }} + ] + }}"# + ) + } + + #[test] + fn syntactically_malformed_at_is_rejected_at_deserialize_time() { + let json = scenario_with_at(r#""banana""#); + let err = serde_json::from_str::(&json) + .expect_err("a malformed `at` must fail to deserialize, not fall back silently"); + let msg = err.to_string(); + assert!( + msg.contains("cannot parse time"), + "expected the grammar error to surface, got: {msg}" + ); + assert!(msg.contains("banana"), "got: {msg}"); + } + + #[test] + fn grammatically_valid_beat_unit_deserializes_even_with_no_bpm() { + // Resolving "@8b" needs `bpm`; the grammar alone does not, and + // resolution is a separate, later step (validate's schema pass). + let json = scenario_with_at(r#""@8b""#); + let scenario: Scenario = + serde_json::from_str(&json).expect("grammar-valid `at` must deserialize"); + let SceneEntry::Scene(ref scene) = scenario.scenes[1] else { + panic!("expected a Scene entry"); + }; + assert!(matches!(scene.at, SceneStart::At(TimePoint::Spec(ref s)) if s == "@8b")); + } + + #[test] + fn auto_and_plain_seconds_still_deserialize() { + let json = scenario_with_at(r#""auto""#); + let scenario: Scenario = serde_json::from_str(&json).expect("auto must deserialize"); + let SceneEntry::Scene(ref scene) = scenario.scenes[1] else { + panic!("expected a Scene entry"); + }; + assert!(matches!(scene.at, SceneStart::Auto(_))); + + let json = scenario_with_at("2.5"); + let scenario: Scenario = serde_json::from_str(&json).expect("bare number must deserialize"); + let SceneEntry::Scene(ref scene) = scenario.scenes[1] else { + panic!("expected a Scene entry"); + }; + assert!(matches!(scene.at, SceneStart::At(TimePoint::Seconds(s)) if s == 2.5)); + } +} + +/// Issue #330: `Scene.shake` and `PostEffect::Flash`. +#[cfg(test)] +mod scene_shake_and_flash_tests { + use super::*; + + #[test] + fn scene_shake_is_absent_by_default() { + let json = r#"{ "duration": 1.0, "children": [] }"#; + let scene: Scene = serde_json::from_str(json).unwrap(); + assert!(scene.shake.is_none()); + } + + #[test] + fn scene_shake_deserializes_with_six_impacts_on_the_beat_grid() { + let json = r##"{ + "duration": 4.0, + "children": [], + "shake": { + "impacts": [ + { "at": "0b", "amplitude": 24.0 }, + { "at": "4b", "amplitude": 20.0 }, + { "at": "8b", "amplitude": 20.0 }, + { "at": "12b", "amplitude": 16.0 }, + { "at": "16b", "amplitude": 16.0 }, + { "at": "20b", "amplitude": 12.0 } + ], + "decay": 12.0, + "frequency": 22.0, + "rotation": 0.3 + } + }"##; + let scene: Scene = serde_json::from_str(json).unwrap(); + let shake = scene.shake.expect("shake must deserialize"); + assert_eq!(shake.impacts.len(), 6); + assert_eq!(shake.decay, 12.0); + assert_eq!(shake.frequency, 22.0); + assert_eq!(shake.rotation, 0.3); + } + + #[test] + fn scene_shake_and_camera_coexist_on_the_same_scene() { + // Additive over `camera` (issue #330's own requirement): a scene + // must be able to declare both without either being rejected or + // silently dropping the other. + let json = r#"{ + "duration": 2.0, + "children": [], + "camera": { "zoom": 1.2 }, + "shake": { "impacts": [ { "at": 0.5, "amplitude": 10.0 } ] } + }"#; + let scene: Scene = serde_json::from_str(json).unwrap(); + assert!(scene.camera.is_some()); + assert!(scene.shake.is_some()); + } + + #[test] + fn misspelled_shake_field_is_rejected() { + let json = r#"{ + "video": { "width": 100, "height": 100 }, + "scenes": [ { + "duration": 1.0, + "children": [], + "shake": { "impacts": [], "decayy": 5.0 } + } ] + }"#; + let err = serde_json::from_str::(json).expect_err("must fail"); + assert!(err.to_string().contains("decayy"), "got: {err}"); + } + + #[test] + fn post_effect_flash_deserializes_and_defaults() { + let json = r#"{ "type": "flash", "at": "4b" }"#; + let effect: PostEffect = serde_json::from_str(json).unwrap(); + match effect { + PostEffect::Flash { + at, + color, + intensity, + duration, + } => { + assert_eq!(at, TimePoint::Spec("4b".to_string())); + assert_eq!(color, "#FFFFFF"); + assert_eq!(intensity, 0.6); + assert_eq!(duration, 0.15); + } + other => panic!("expected Flash, got {other:?}"), + } + } + + #[test] + fn post_effect_flash_accepts_every_known_field() { + let json = r##"{ + "type": "flash", + "at": 1.2, + "color": "#FF3300", + "intensity": 0.9, + "duration": 0.08 + }"##; + let effect: PostEffect = serde_json::from_str(json).unwrap(); + match effect { + PostEffect::Flash { + at, + color, + intensity, + duration, + } => { + assert_eq!(at, TimePoint::Seconds(1.2)); + assert_eq!(color, "#FF3300"); + assert_eq!(intensity, 0.9); + assert_eq!(duration, 0.08); + } + other => panic!("expected Flash, got {other:?}"), + } + } + + #[test] + fn scene_effects_accept_a_flash_alongside_other_post_effects() { + let json = r#"{ + "duration": 1.0, + "children": [], + "effects": [ + { "type": "flash", "at": "0b" }, + { "type": "grain", "intensity": 0.1 } + ] + }"#; + let scene: Scene = serde_json::from_str(json).unwrap(); + assert_eq!(scene.effects.len(), 2); + assert!(matches!(scene.effects[0], PostEffect::Flash { .. })); + assert!(matches!(scene.effects[1], PostEffect::Grain { .. })); + } +} diff --git a/crates/rustmotion-core/src/schema/shake.rs b/crates/rustmotion-core/src/schema/shake.rs new file mode 100644 index 00000000..0952200f --- /dev/null +++ b/crates/rustmotion-core/src/schema/shake.rs @@ -0,0 +1,167 @@ +//! Declarative camera shake (issue #330): a list of beat-synced impacts +//! instead of a hand-generated camera keyframe track — the reel this +//! feature was built for spent 155 sampled keyframes (a Python loop +//! evaluating a damped sine, pasted into the JSON) on what six `{at, +//! amplitude}` pairs now express directly. +//! +//! See [`SceneShake`] for the damped-oscillation formula and +//! `crate::engine::shake::shake_offset` for the function that evaluates it +//! at a given time. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::time::TimePoint; + +/// One beat-synced hit that kicks the camera. `at` resolves the same way +/// as any other in-scene [`TimePoint`] — relative to the scene's own start +/// unless it carries the `@` prefix (see the anchoring rule documented +/// once on [`TimePoint`]) — so an impact can land exactly on a cut: +/// `{"at": "4b", "amplitude": 24.0}`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ShakeImpact { + /// When this impact lands, on the scene's own timeline. + pub at: TimePoint, + /// Peak displacement at `t = at`, in pixels, before decay. + pub amplitude: f64, +} + +/// Declarative camera shake: every impact in [`SceneShake::impacts`] is an +/// independent damped harmonic oscillator, summed together. **Additive** +/// over whatever [`super::scenario::Scene::camera`] already resolves to — +/// `camera.keyframes` is a single value-over-time series per property, +/// with no way to layer a second signal onto it without replacing the +/// first, so a shake needs its own field to coexist with a pan instead of +/// fighting over one keyframe track. +/// +/// # Formula +/// +/// For an impact of `amplitude` `A` landing at `t0`, its contribution at +/// `t >= t0` (exactly zero before `t0` — an impact never affects time +/// before it lands) is: +/// +/// ```text +/// Δt = t - t0 +/// envelope = A * exp(-decay * Δt) +/// phase = 2π * frequency * Δt +/// x = envelope * cos(phase) +/// y = envelope * sin(phase) +/// rotation = shake.rotation * x (degrees) +/// ``` +/// +/// `x` and `y` are 90° out of phase, so the combined offset traces a +/// decaying spiral rather than a straight line back and forth — this is +/// what reads as a *shake* instead of a *bounce*. `decay` (1/seconds) is +/// the exponential rate: the envelope reaches `1/e` (~37%) of its peak +/// after `1/decay` seconds. `frequency` (Hz) is how many oscillations per +/// second it makes while decaying. `rotation` is a degrees-per-pixel +/// coefficient applied to the already-computed `x` offset — not an +/// independent oscillator — so the twist always stays in phase with the +/// translation instead of drifting against it; `0.0` (the default) +/// disables rotational shake entirely. +/// +/// Multiple impacts are summed — not replaced — at every instant, so a +/// fast retrigger before the previous impact has decayed accumulates +/// rather than resetting: the same physical intuition as striking a bell +/// twice in quick succession. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SceneShake { + /// Every hit contributing to this scene's shake. Impacts are summed, + /// not last-wins — see [`SceneShake`]'s formula. + pub impacts: Vec, + /// Exponential decay rate, in 1/seconds. Higher decays faster: the + /// envelope reaches `1/e` of its peak after `1/decay` seconds. + /// Default: 10.0 (~0.1s to 1/e, fully read as settled well within half + /// a second). + #[serde(default = "default_shake_decay")] + pub decay: f64, + /// Oscillations per second while the envelope decays. Default: 20.0. + #[serde(default = "default_shake_frequency")] + pub frequency: f64, + /// Degrees of rotational shake per pixel of the (already-computed) `x` + /// offset — see [`SceneShake`]'s formula. `0.0` (default) disables + /// rotational shake. + #[serde(default)] + pub rotation: f64, +} + +fn default_shake_decay() -> f64 { + 10.0 +} + +fn default_shake_frequency() -> f64 { + 20.0 +} + +#[cfg(test)] +mod schema_tests { + use super::*; + use serde_json::json; + + #[test] + fn deserializes_with_every_known_field() { + let json = json!({ + "impacts": [ + { "at": "4b", "amplitude": 24.0 }, + { "at": 1.5, "amplitude": 12.0 } + ], + "decay": 8.0, + "frequency": 18.0, + "rotation": 0.5 + }); + let shake: SceneShake = serde_json::from_value(json).unwrap(); + assert_eq!(shake.impacts.len(), 2); + assert_eq!(shake.impacts[0].at, TimePoint::Spec("4b".to_string())); + assert_eq!(shake.impacts[0].amplitude, 24.0); + assert_eq!(shake.impacts[1].at, TimePoint::Seconds(1.5)); + assert_eq!(shake.decay, 8.0); + assert_eq!(shake.frequency, 18.0); + assert_eq!(shake.rotation, 0.5); + } + + #[test] + fn decay_frequency_and_rotation_default() { + let json = json!({ "impacts": [{ "at": 0.0, "amplitude": 10.0 }] }); + let shake: SceneShake = serde_json::from_value(json).unwrap(); + assert_eq!(shake.decay, default_shake_decay()); + assert_eq!(shake.frequency, default_shake_frequency()); + assert_eq!(shake.rotation, 0.0); + } + + #[test] + fn a_typo_on_scene_shake_is_rejected() { + let json = json!({ "impacts": [], "decy": 8.0 }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field on SceneShake must be rejected, not silently ignored"); + assert!(err.to_string().contains("decy"), "got: {err}"); + } + + #[test] + fn a_typo_on_a_shake_impact_is_rejected() { + let json = json!({ "impacts": [{ "at": 0.0, "amplitud": 10.0 }] }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field on ShakeImpact must be rejected, not silently ignored"); + assert!(err.to_string().contains("amplitud"), "got: {err}"); + } + + #[test] + fn amplitude_is_required_on_every_impact() { + let json = json!({ "impacts": [{ "at": 0.0 }] }); + assert!(serde_json::from_value::(json).is_err()); + } + + #[test] + fn six_impacts_round_trip_through_json() { + let impacts: Vec<_> = (0..6) + .map(|i| json!({ "at": format!("{}b", i * 4), "amplitude": 20.0 - i as f64 * 2.0 })) + .collect(); + let json = json!({ "impacts": impacts }); + let shake: SceneShake = serde_json::from_value(json).unwrap(); + assert_eq!(shake.impacts.len(), 6); + let back = serde_json::to_value(&shake).unwrap(); + let round_tripped: SceneShake = serde_json::from_value(back).unwrap(); + assert_eq!(shake, round_tripped); + } +} diff --git a/crates/rustmotion-core/src/schema/time.rs b/crates/rustmotion-core/src/schema/time.rs new file mode 100644 index 00000000..f6a9d804 --- /dev/null +++ b/crates/rustmotion-core/src/schema/time.rs @@ -0,0 +1,423 @@ +//! The beat-grid time vocabulary (issue #336): a single point in time that +//! can be a plain number of seconds, or a small expression anchored to a +//! musical beat grid — so a cut can land on a beat instead of a +//! hand-tuned duration. +//! +//! **The one rule for anchoring** (stated once, here, and referenced rather +//! than repeated elsewhere): [`Scene::at`](super::scenario::Scene::at) is +//! absolute by definition — it always resolves via +//! [`TimePoint::resolve_absolute`], regardless of whether its value carries +//! the `@` prefix. Everything *inside* a scene (a component's `start_at`, +//! `delay`, and similar fields) is relative to that scene's own start, and +//! resolves via [`TimePoint::resolve_relative`] — *unless* its `TimePoint` +//! carries an explicit `@` prefix, which forces it onto the scenario's +//! absolute timeline regardless of where it is nested. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// What a [`TimePoint`] needs to resolve to a concrete number of seconds: +/// the scenario's beat grid, and where the enclosing scene starts on the +/// scenario's absolute timeline. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TimeCtx { + /// Beats per minute, if the scenario declares a grid. `None` makes any + /// `b`-unit term in a [`TimePoint::Spec`] fail with [`TimeError::NoBpm`]. + pub bpm: Option, + /// Where beat 0 sits on the scenario's absolute timeline, in seconds. + /// The grid is `beat_offset + n * 60 / bpm` — not decoration: a reel + /// whose first kick lands at 2.2s anchors its grid there, not at 0. + pub beat_offset: f64, + /// Where the enclosing scene starts on the scenario's absolute + /// timeline, in seconds. Only consulted by + /// [`TimePoint::resolve_relative`]/[`TimePoint::resolve_absolute`] for a + /// [`TimePoint`] that is *not* itself absolute (no `@` prefix). + pub scene_start: f64, +} + +/// No grid, no offset, no enclosing scene — the neutral context a plain +/// number of seconds resolves against unchanged. Exists so +/// [`crate::schema::Scene`]'s `#[serde(skip)]` copy of this type has +/// something to default to before `Scenario`'s `Deserialize` impl +/// overwrites it with the real value. +impl Default for TimeCtx { + fn default() -> Self { + TimeCtx { + bpm: None, + beat_offset: 0.0, + scene_start: 0.0, + } + } +} + +/// Everything that can go wrong turning a [`TimePoint`] into seconds. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum TimeError { + /// A `b`-unit term appeared in `{0}`, but [`TimeCtx::bpm`] is `None` — + /// the scenario never declared a beat grid to measure beats against. + #[error("beat unit in `{0}` but the scenario declares no bpm")] + NoBpm(String), + /// `{0}` is not a valid [`TimePoint::Spec`]: empty, a term with no + /// recognised unit (`s`/`ms`/`b`), or a term whose number doesn't parse. + #[error("cannot parse time `{0}`")] + Unparseable(String), +} + +/// A point in time: a bare JSON number of seconds, or a small string +/// expression anchored to the beat grid. +/// +/// # Grammar (for the [`Spec`](TimePoint::Spec) string form) +/// +/// An optional leading `@` (forces the value onto the scenario's absolute +/// timeline — see the module doc), followed by one or more terms joined by +/// `+` or `-`. Each term is ``, where `unit` is one of: +/// +/// - `s` — seconds, taken as-is. +/// - `ms` — milliseconds, divided by 1000. +/// - `b` — beats: `beat_offset + n * 60 / bpm` (errors with +/// [`TimeError::NoBpm`] when the scenario declares no `bpm`). A term's +/// sign multiplies this *whole* grid position, `beat_offset` included — +/// so `"@2.2s-1b"` is "2.2s before wherever beat 1 lands," not "1 raw +/// beat length before 2.2s." +/// +/// Valid examples: `"2.5s"`, `"8b"`, `"120ms"`, `"8b+120ms"`, `"@8b"`, +/// `"@2.2s-1b"`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum TimePoint { + /// A bare JSON number: seconds, always relative to whatever context + /// resolves it (see [`TimePoint::resolve_relative`]). + Seconds(f64), + /// The string expression form — see the grammar above. + Spec(String), +} + +impl TimePoint { + /// Seconds from `ctx.scene_start` — the natural reading for a value + /// nested inside a scene (`start_at`, `delay`, ...). A [`Spec`](TimePoint::Spec) + /// carrying the `@` prefix ignores `scene_start` and returns the same + /// absolute value [`resolve_absolute`](Self::resolve_absolute) would — + /// that is the whole point of the prefix. + pub fn resolve_relative(&self, ctx: &TimeCtx) -> Result { + // `eval_sum` is already the delta from `scene_start` for a + // non-absolute value, and already the absolute value (with + // `scene_start` deliberately not added) for an `@`-prefixed one — + // both cases return it unchanged. + self.eval_sum(ctx) + } + + /// Seconds from the scenario's start — the natural reading for + /// [`Scene::at`](super::scenario::Scene::at), which is absolute by + /// definition. A non-absolute value (no `@`) is anchored onto the + /// timeline by adding `ctx.scene_start`; an absolute one (`@`-prefixed, + /// or already a beat/second sum with `@`) is returned as-is. + pub fn resolve_absolute(&self, ctx: &TimeCtx) -> Result { + let value = self.eval_sum(ctx)?; + if self.is_absolute() { + Ok(value) + } else { + Ok(ctx.scene_start + value) + } + } + + /// Whether this value is pinned to the scenario's absolute timeline + /// regardless of where it is nested — true only for a + /// [`Spec`](TimePoint::Spec) whose string starts with `@`. A bare + /// [`Seconds`](TimePoint::Seconds) is never absolute: it always resolves + /// relative to whatever `scene_start` its caller provides. + pub fn is_absolute(&self) -> bool { + match self { + TimePoint::Seconds(_) => false, + TimePoint::Spec(spec) => spec.starts_with('@'), + } + } + + /// The parsed value of this `TimePoint` in seconds, with `@` already + /// stripped — i.e. before either resolve method decides whether to add + /// `ctx.scene_start`. + fn eval_sum(&self, ctx: &TimeCtx) -> Result { + match self { + TimePoint::Seconds(seconds) => Ok(*seconds), + TimePoint::Spec(spec) => eval_spec(spec, ctx), + } + } + + /// Checks that this value is syntactically well-formed **without** + /// needing `bpm` — every term parses (``, a recognised + /// unit, a non-empty numeric part) whether or not a `b` term could + /// actually be *resolved* yet. This is deliberately the weaker of the + /// two checks a `b` term is subject to: + /// + /// - Grammar (this method) never needs `bpm` and can run the moment a + /// scenario is deserialized — a malformed spec like `"banana"` is a + /// typo, true regardless of what the scenario declares. + /// - Resolution (`resolve_relative`/`resolve_absolute`) needs `bpm` for + /// any `b` term, and can only run once a whole `Scenario` — not just + /// this value in isolation — is available. A grammatically valid `"@8b"` + /// in a scenario with no `bpm` fails *there*, with + /// [`TimeError::NoBpm`], not here. + /// + /// Implemented by evaluating against a placeholder context that always + /// supplies a `bpm` (so [`TimeError::NoBpm`] can never fire) — any error + /// that still comes out is therefore, by construction, a genuine + /// [`TimeError::Unparseable`]. + pub fn validate_grammar(&self) -> Result<(), TimeError> { + match self { + TimePoint::Seconds(_) => Ok(()), + TimePoint::Spec(_) => { + let placeholder = TimeCtx { + bpm: Some(1.0), + beat_offset: 0.0, + scene_start: 0.0, + }; + match self.eval_sum(&placeholder) { + Ok(_) => Ok(()), + Err(TimeError::NoBpm(_)) => { + unreachable!("the placeholder context always supplies a bpm") + } + Err(e @ TimeError::Unparseable(_)) => Err(e), + } + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimeUnit { + Seconds, + Millis, + Beats, +} + +/// Splits `body` (already stripped of any leading `@`) into signed terms — +/// `('+' | '-' | , "")` — by scanning for +/// `+`/`-` operators. A `+`/`-` at position 0 is the first term's own sign, +/// not an operator between two terms. +fn split_signed_terms(body: &str) -> Vec<(f64, String)> { + let mut terms = Vec::new(); + let mut sign = 1.0; + let mut current = String::new(); + for (i, ch) in body.char_indices() { + match ch { + '+' if i == 0 => {} + '-' if i == 0 => sign = -1.0, + '+' | '-' if i != 0 => { + terms.push((sign, std::mem::take(&mut current))); + sign = if ch == '-' { -1.0 } else { 1.0 }; + } + _ => current.push(ch), + } + } + terms.push((sign, current)); + terms +} + +/// Splits a single term's trailing unit off its numeric part. Order matters: +/// `"ms"` is checked before the single-character `"s"`, or every +/// millisecond term would be misparsed as a malformed seconds term. +fn split_unit(term: &str) -> Option<(&str, TimeUnit)> { + if let Some(number) = term.strip_suffix("ms") { + Some((number, TimeUnit::Millis)) + } else if let Some(number) = term.strip_suffix('s') { + Some((number, TimeUnit::Seconds)) + } else if let Some(number) = term.strip_suffix('b') { + Some((number, TimeUnit::Beats)) + } else { + None + } +} + +fn eval_spec(original: &str, ctx: &TimeCtx) -> Result { + let body = original.strip_prefix('@').unwrap_or(original); + if body.is_empty() { + return Err(TimeError::Unparseable(original.to_string())); + } + + let mut total = 0.0; + for (sign, term) in split_signed_terms(body) { + let (number_str, unit) = + split_unit(&term).ok_or_else(|| TimeError::Unparseable(original.to_string()))?; + if number_str.is_empty() { + return Err(TimeError::Unparseable(original.to_string())); + } + let number: f64 = number_str + .parse() + .map_err(|_| TimeError::Unparseable(original.to_string()))?; + + let term_value = match unit { + TimeUnit::Seconds => number, + TimeUnit::Millis => number / 1000.0, + TimeUnit::Beats => { + let bpm = ctx + .bpm + .ok_or_else(|| TimeError::NoBpm(original.to_string()))?; + ctx.beat_offset + number * 60.0 / bpm + } + }; + total += sign * term_value; + } + + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx(bpm: Option, beat_offset: f64, scene_start: f64) -> TimeCtx { + TimeCtx { + bpm, + beat_offset, + scene_start, + } + } + + #[test] + fn bare_number_deserializes_as_seconds() { + let tp: TimePoint = serde_json::from_str("2.5").unwrap(); + assert_eq!(tp, TimePoint::Seconds(2.5)); + } + + #[test] + fn string_deserializes_as_spec() { + let tp: TimePoint = serde_json::from_str("\"2.5s\"").unwrap(); + assert_eq!(tp, TimePoint::Spec("2.5s".to_string())); + } + + #[test] + fn seconds_spec_resolves_plainly() { + let tp = TimePoint::Spec("2.5s".to_string()); + let c = ctx(None, 0.0, 10.0); + assert_eq!(tp.resolve_relative(&c).unwrap(), 2.5); + assert_eq!(tp.resolve_absolute(&c).unwrap(), 12.5); + } + + #[test] + fn millis_spec_resolves() { + let tp = TimePoint::Spec("120ms".to_string()); + let c = ctx(None, 0.0, 0.0); + assert_eq!(tp.resolve_relative(&c).unwrap(), 0.12); + } + + #[test] + fn beat_spec_resolves_with_beat_offset() { + // bpm=120 -> 0.5s/beat; beat_offset=2.2 (the reel's real anchor). + let tp = TimePoint::Spec("8b".to_string()); + let c = ctx(Some(120.0), 2.2, 0.0); + let expected = 2.2 + 8.0 * 60.0 / 120.0; // 2.2 + 4.0 = 6.2 + assert!((tp.resolve_relative(&c).unwrap() - expected).abs() < 1e-9); + } + + #[test] + fn compound_beat_plus_millis_spec_resolves() { + // bpm=120, beat_offset=0 -> beat 8 lands at 4.0s; +120ms -> 4.12s. + let tp = TimePoint::Spec("8b+120ms".to_string()); + let c = ctx(Some(120.0), 0.0, 0.0); + assert!((tp.resolve_relative(&c).unwrap() - 4.12).abs() < 1e-9); + } + + #[test] + fn at_prefix_is_absolute_and_ignores_scene_start() { + let tp = TimePoint::Spec("@8b".to_string()); + assert!(tp.is_absolute()); + // bpm=100, beat_offset=2.2 -> beat 8 at 2.2 + 8*0.6 = 7.0s. + let c = ctx(Some(100.0), 2.2, 1000.0); // absurd scene_start to prove it's ignored + let expected = 2.2 + 8.0 * 60.0 / 100.0; + assert!((tp.resolve_relative(&c).unwrap() - expected).abs() < 1e-9); + assert!((tp.resolve_absolute(&c).unwrap() - expected).abs() < 1e-9); + } + + #[test] + fn at_prefix_with_subtracted_beat_term() { + // bpm=120, beat_offset=0 -> "@2.2s-1b" = 2.2 - (0 + 1*0.5) = 1.7 + let tp = TimePoint::Spec("@2.2s-1b".to_string()); + let c = ctx(Some(120.0), 0.0, 0.0); + assert!((tp.resolve_absolute(&c).unwrap() - 1.7).abs() < 1e-9); + } + + #[test] + fn plain_seconds_variant_is_never_absolute() { + let tp = TimePoint::Seconds(3.0); + assert!(!tp.is_absolute()); + let c = ctx(None, 0.0, 5.0); + assert_eq!(tp.resolve_relative(&c).unwrap(), 3.0); + assert_eq!(tp.resolve_absolute(&c).unwrap(), 8.0); + } + + #[test] + fn beat_unit_without_bpm_errors() { + let tp = TimePoint::Spec("8b".to_string()); + let c = ctx(None, 0.0, 0.0); + assert_eq!( + tp.resolve_relative(&c), + Err(TimeError::NoBpm("8b".to_string())) + ); + } + + #[test] + fn beat_unit_without_bpm_errors_even_nested_in_a_sum() { + let tp = TimePoint::Spec("120ms+8b".to_string()); + let c = ctx(None, 0.0, 0.0); + assert_eq!( + tp.resolve_relative(&c), + Err(TimeError::NoBpm("120ms+8b".to_string())) + ); + } + + #[test] + fn malformed_spec_is_unparseable() { + for bad in ["", "abc", "5xyz", "s5", "5", "+", "-", "5s+"] { + let tp = TimePoint::Spec(bad.to_string()); + let c = ctx(Some(120.0), 0.0, 0.0); + assert_eq!( + tp.resolve_relative(&c), + Err(TimeError::Unparseable(bad.to_string())), + "expected `{bad}` to be unparseable" + ); + } + } + + #[test] + fn error_display_matches_the_frozen_messages() { + let no_bpm = TimeError::NoBpm("8b".to_string()); + assert_eq!( + no_bpm.to_string(), + "beat unit in `8b` but the scenario declares no bpm" + ); + let unparseable = TimeError::Unparseable("bogus".to_string()); + assert_eq!(unparseable.to_string(), "cannot parse time `bogus`"); + } + + #[test] + fn validate_grammar_accepts_every_frozen_example_with_no_bpm_in_scope() { + for good in ["2.5s", "8b", "120ms", "8b+120ms", "@8b", "@2.2s-1b"] { + let tp = TimePoint::Spec(good.to_string()); + assert_eq!( + tp.validate_grammar(), + Ok(()), + "`{good}` must pass grammar validation without needing bpm" + ); + } + assert_eq!(TimePoint::Seconds(3.0).validate_grammar(), Ok(())); + } + + #[test] + fn validate_grammar_rejects_the_same_strings_resolution_would() { + for bad in ["", "abc", "5xyz", "s5", "5", "+", "-", "5s+"] { + let tp = TimePoint::Spec(bad.to_string()); + assert_eq!( + tp.validate_grammar(), + Err(TimeError::Unparseable(bad.to_string())), + "expected `{bad}` to fail grammar validation" + ); + } + } + + #[test] + fn validate_grammar_never_reports_no_bpm() { + // A beat unit's *grammar* is fine with no scenario in scope at all — + // only resolving it to a number needs `bpm`. + let tp = TimePoint::Spec("@8b".to_string()); + assert_eq!(tp.validate_grammar(), Ok(())); + } +} diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 0496632a..d93a7321 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -199,8 +199,7 @@ impl AnimationEffect { // minimal repro before relying on it. Without this, `validate_attrs.rs` // never sees inside `style.animation[*]` (it only walks component-level // keys), so a typo silently no-ops instead of erroring. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone, JsonSchema, PartialEq)] pub struct AnimationTiming { /// Delay before animation starts (seconds). #[serde(default)] @@ -208,9 +207,42 @@ pub struct AnimationTiming { /// Animation duration (seconds). #[serde(default = "default_animation_duration")] pub duration: f64, - /// Loop the animation continuously. - #[serde(default, rename = "loop")] + /// Loop the animation continuously. Kept as a plain bool for source + /// compatibility with every reader that only ever checked this flag + /// (several live outside this workstream's owned files) — `true` + /// covers both "loop forever" (`repeat_count: None`) and "loop a known + /// number of times" (`repeat_count: Some(n)`), so a finite count is + /// never mistaken for a one-shot animation by code that only reads + /// this field. The JSON `"loop"` key this deserializes from accepts + /// either shape (see [`AnimationTimingWire`]); `repeat` alone can't + /// tell you which one was written — check `repeat_count` for that. + #[serde(rename = "loop")] pub repeat: bool, + /// How many times the animation plays, when the JSON `"loop"` value + /// was a positive integer rather than a bare bool (issue #330) — e.g. + /// `"loop": 12` for GSAP's `repeat: 11` (11 *re*plays, 12 plays + /// total — this field counts total plays, not replays). `None` + /// defers entirely to `repeat`: `true` loops forever, `false` plays + /// once — today's behaviour, unchanged. `0` and `1` both fold back + /// into `repeat: false, repeat_count: None` at parse time (see + /// [`RepeatSpec::into_parts`]): there is no visible difference + /// between "play once" and "loop zero times", so there's no reason to + /// carry a count that never changes anything downstream. + #[serde(default)] + pub repeat_count: Option, + /// Reverse direction on every other play (ping-pong) instead of + /// snapping back to the start each cycle — GSAP calls this `yoyo`. + /// Only meaningful when the animation actually repeats (`repeat` or + /// `repeat_count`); a no-op otherwise. Works on any animation this + /// timing drives, not a fixed set of presets — see + /// `engine::animator::cycle_time`. + #[serde(default)] + pub yoyo: bool, + /// Pause between plays, in seconds, held at the resting value of the + /// play that just finished before the next one starts. `0.0` (default) + /// is a seamless loop. + #[serde(default)] + pub repeat_delay: f64, /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%). #[serde(default)] pub overshoot: Option, @@ -233,6 +265,132 @@ fn default_animation_duration() -> f64 { 0.8 } +/// The `"loop"` field as written in JSON: a bare boolean — `true` loops +/// forever, `false` (the default) plays once, exactly as before this type +/// widened — or a positive integer naming an exact play count. Only ever +/// used as the wire shape [`AnimationTimingWire`] folds into +/// [`AnimationTiming::repeat`]/[`AnimationTiming::repeat_count`] (and back, +/// for `Serialize` — see [`RepeatSpec::from_parts`]); nothing downstream +/// matches on this type directly. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +enum RepeatSpec { + Loop(bool), + Count(u32), +} + +impl Default for RepeatSpec { + fn default() -> Self { + RepeatSpec::Loop(false) + } +} + +impl RepeatSpec { + /// Splits the wire value into `AnimationTiming`'s two fields. `0`/`1` + /// fold back to the boolean form: a count only starts meaning anything + /// once there's a second play to differ from the first. + fn into_parts(self) -> (bool, Option) { + match self { + RepeatSpec::Loop(b) => (b, None), + RepeatSpec::Count(0) | RepeatSpec::Count(1) => (false, None), + RepeatSpec::Count(n) => (true, Some(n)), + } + } + + /// Inverse of [`Self::into_parts`]: reconstructs the wire value that + /// would have produced this `(repeat, repeat_count)` pair, so + /// `AnimationTiming`'s hand-written `Serialize` impl round-trips + /// through the same single `"loop"` key its `Deserialize` impl reads — + /// never a separate `repeat_count` key alongside it. + fn from_parts(repeat: bool, repeat_count: Option) -> Self { + match repeat_count { + Some(n) => RepeatSpec::Count(n), + None => RepeatSpec::Loop(repeat), + } + } +} + +/// The wire shape of [`AnimationTiming`] — identical field-for-field except +/// `"loop"`, which is [`RepeatSpec`] here instead of the plain `bool` +/// [`AnimationTiming::repeat`] exposes. Exists only to give +/// `AnimationTiming` hand-written `Serialize`/`Deserialize` impls that can +/// split one JSON key into two Rust fields (`repeat`/`repeat_count`) and +/// merge them back — a derive can't express that. Kept private: nothing +/// outside this module should ever construct or see one directly. Carries +/// its own `deny_unknown_fields` so a typo'd field is still rejected +/// exactly as it was before this type existed (constat #8's guarantee, +/// preserved). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AnimationTimingWire { + #[serde(default)] + delay: f64, + #[serde(default = "default_animation_duration")] + duration: f64, + #[serde(default, rename = "loop")] + repeat: RepeatSpec, + #[serde(default)] + yoyo: bool, + #[serde(default)] + repeat_delay: f64, + #[serde(default)] + overshoot: Option, + #[serde(default)] + spring: Option, + #[serde(default)] + amplitude: Option, +} + +impl From for AnimationTiming { + fn from(wire: AnimationTimingWire) -> Self { + let (repeat, repeat_count) = wire.repeat.into_parts(); + AnimationTiming { + delay: wire.delay, + duration: wire.duration, + repeat, + repeat_count, + yoyo: wire.yoyo, + repeat_delay: wire.repeat_delay, + overshoot: wire.overshoot, + spring: wire.spring, + amplitude: wire.amplitude, + } + } +} + +impl From<&AnimationTiming> for AnimationTimingWire { + fn from(t: &AnimationTiming) -> Self { + AnimationTimingWire { + delay: t.delay, + duration: t.duration, + repeat: RepeatSpec::from_parts(t.repeat, t.repeat_count), + yoyo: t.yoyo, + repeat_delay: t.repeat_delay, + overshoot: t.overshoot, + spring: t.spring.clone(), + amplitude: t.amplitude, + } + } +} + +impl<'de> Deserialize<'de> for AnimationTiming { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + AnimationTimingWire::deserialize(deserializer).map(AnimationTiming::from) + } +} + +impl Serialize for AnimationTiming { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + AnimationTimingWire::from(self).serialize(serializer) + } +} + /// Configuration for the `tilt_in` animation with configurable 3D transform values. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -263,6 +421,9 @@ impl Default for AnimationTiming { delay: 0.0, duration: 0.8, repeat: false, + repeat_count: None, + yoyo: false, + repeat_delay: 0.0, overshoot: None, spring: None, amplitude: None, @@ -485,6 +646,9 @@ impl AnimationTiming { delay: self.delay, duration: self.duration, repeat: self.repeat, + repeat_count: self.repeat_count, + yoyo: self.yoyo, + repeat_delay: self.repeat_delay, overshoot: self.overshoot, spring: self.spring.clone(), } @@ -913,6 +1077,56 @@ pub struct Stroke { pub color: String, #[serde(default = "default_stroke_width")] pub width: f32, + /// `skia_safe::PathEffect::dash` interval list (on-length, off-length, + /// repeating) — the same spelling and shape `arrow`/`connector`/`line` + /// already use for their own `dashed` field. `None` (the default) + /// strokes solid, exactly as every `Stroke` did before this field + /// existed. + #[serde(default)] + pub dashed: Option>, + /// Phase offset (px) into `dashed`'s pattern — `skia_safe::PathEffect:: + /// dash`'s second argument, hardcoded to `0.0` on `arrow`/`connector`/ + /// `line` today. A literal number is a constant phase; an `"= ..."` + /// expression (see `crate::expr`) is re-evaluated every frame against + /// the node's `crate::css::FrameClock` (`$t`/`$T`/`$duration`/`$W`/ + /// `$H`/`$fps`), the same per-frame mechanism `CssStyle`'s `opacity`/ + /// `width`/`height` already use — this is what makes a dashed stroke's + /// "draw-on" (dash length == path length, offset animating from the + /// full length down to `0`) expressible without the engine's classic + /// keyframe/easing `AnimatedProperties` pipeline. + #[serde(default)] + pub dash_offset: Option>, + /// `stroke-linecap`. Default `LineCap::Butt` — skia's own default, so + /// a stroke that never set this renders byte-identical to before this + /// field existed. + #[serde(default)] + pub line_cap: LineCap, + /// `stroke-linejoin`. Default `LineJoin::Miter` — skia's own default, + /// same backward-compatibility guarantee as `line_cap` above. + #[serde(default)] + pub line_join: LineJoin, +} + +/// `stroke-linecap` — how an open subpath's two ends are drawn. See +/// `Stroke::line_cap`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum LineCap { + #[default] + Butt, + Round, + Square, +} + +/// `stroke-linejoin` — how two stroked segments meet at a vertex. See +/// `Stroke::line_join`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum LineJoin { + #[default] + Miter, + Round, + Bevel, } #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -1486,3 +1700,138 @@ mod motion_path_schema_tests { } } } + +#[cfg(test)] +mod animation_timing_repeat_widening_tests { + use super::*; + use serde_json::json; + + // ---- issue #330: `"loop"` widens from a bare bool to bool-or-integer. + // Every test in this module that only sets `"loop": true`/`false` (or + // omits it) must produce byte-identical `AnimationTiming` values to + // what the old plain-bool deserializer produced — that's the + // acceptance criterion that matters most here. ---- + + #[test] + fn loop_true_is_unchanged_infinite_repeat() { + let json = json!({ "name": "pulse", "loop": true }); + let effect: AnimationEffect = serde_json::from_value(json).unwrap(); + match effect { + AnimationEffect::Pulse(t) => { + assert!(t.repeat, "\"loop\": true must still set repeat = true"); + assert_eq!( + t.repeat_count, None, + "a bare `true` carries no count — infinite, exactly as before" + ); + assert!(!t.yoyo); + assert_eq!(t.repeat_delay, 0.0); + } + other => panic!("expected Pulse, got {other:?}"), + } + } + + #[test] + fn loop_false_and_omitted_are_unchanged_and_identical() { + let explicit: AnimationTiming = serde_json::from_value(json!({ "loop": false })).unwrap(); + let omitted: AnimationTiming = serde_json::from_value(json!({})).unwrap(); + assert_eq!( + explicit, omitted, + "an explicit `false` and an omitted `loop` must resolve identically" + ); + assert!(!explicit.repeat); + assert_eq!(explicit.repeat_count, None); + assert_eq!(explicit, AnimationTiming::default()); + } + + #[test] + fn loop_as_a_positive_integer_sets_repeat_and_the_count() { + // GSAP's `repeat: 11` means 11 *re*plays — 12 plays total. This + // field counts total plays, so the JSON author writes 12. + let t: AnimationTiming = serde_json::from_value(json!({ "loop": 12 })).unwrap(); + assert!(t.repeat, "a finite count still loops — see the field doc"); + assert_eq!(t.repeat_count, Some(12)); + } + + #[test] + fn loop_as_zero_or_one_folds_back_to_the_boolean_form() { + for n in [0, 1] { + let t: AnimationTiming = serde_json::from_value(json!({ "loop": n })).unwrap(); + assert!(!t.repeat, "loop: {n} must not set repeat"); + assert_eq!(t.repeat_count, None, "loop: {n} must not carry a count"); + } + } + + #[test] + fn yoyo_and_repeat_delay_default_to_off_and_zero() { + let t: AnimationTiming = serde_json::from_value(json!({})).unwrap(); + assert!(!t.yoyo); + assert_eq!(t.repeat_delay, 0.0); + } + + #[test] + fn yoyo_and_repeat_delay_are_accepted_on_any_animation_timing() { + let json = json!({ + "name": "shake", + "duration": 0.035, + "loop": 12, + "yoyo": true, + "repeat_delay": 0.01 + }); + let effect: AnimationEffect = serde_json::from_value(json).unwrap(); + match effect { + AnimationEffect::Shake(t) => { + assert!(t.yoyo); + assert_eq!(t.repeat_delay, 0.01); + assert_eq!(t.repeat_count, Some(12)); + } + other => panic!("expected Shake, got {other:?}"), + } + } + + #[test] + fn a_typo_is_still_rejected_through_the_wire_type() { + let json = json!({ "name": "pulse", "duratoin": 1.0 }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field must still be rejected, not silently ignored"); + assert!(err.to_string().contains("duratoin"), "got: {err}"); + } + + #[test] + fn loop_as_a_negative_number_is_rejected_not_silently_coerced() { + let json = json!({ "name": "pulse", "loop": -1 }); + assert!( + serde_json::from_value::(json).is_err(), + "a negative \"loop\" is neither a bool nor a valid play count" + ); + } + + #[test] + fn a_finite_repeat_count_round_trips_through_a_single_loop_key() { + // Regression: `AnimationTiming` used to derive `Serialize` + // directly off its own fields, which emitted a *separate* + // `"repeat_count"` key alongside `"loop"` — a shape + // `AnimationTimingWire`'s `deny_unknown_fields` (rightly) never + // accepted on the way back in, so a scenario that had merely been + // parsed and re-serialized (e.g. by tooling, or `--fix`) failed to + // parse again. `AnimationTiming` now hand-writes `Serialize` to + // fold back onto one `"loop"` key, matching `Deserialize` exactly. + let t: AnimationTiming = serde_json::from_value(json!({ "loop": 12 })).unwrap(); + let json = serde_json::to_value(&t).unwrap(); + assert!( + json.get("repeat_count").is_none(), + "must not emit a separate repeat_count key: {json}" + ); + assert_eq!(json["loop"], serde_json::json!(12)); + let back: AnimationTiming = serde_json::from_value(json).unwrap(); + assert_eq!(t, back); + } + + #[test] + fn easing_steps_round_trips_and_is_distinct_from_cubic_bezier() { + let json = json!({ "steps": 4 }); + let easing: EasingType = serde_json::from_value(json).unwrap(); + assert_eq!(easing, EasingType::Steps(4)); + let back = serde_json::to_value(&easing).unwrap(); + assert_eq!(back, json!({ "steps": 4 })); + } +} diff --git a/crates/rustmotion-core/src/traits/painter.rs b/crates/rustmotion-core/src/traits/painter.rs index a9fc2ab0..af2c0f9e 100644 --- a/crates/rustmotion-core/src/traits/painter.rs +++ b/crates/rustmotion-core/src/traits/painter.rs @@ -66,7 +66,7 @@ pub trait Painter { ); /// Optional intrinsic measurement for leaves like `text`, `image`, - /// `codeblock`. Return `None` to let taffy compute the size from the + /// `table`. Return `None` to let taffy compute the size from the /// CSS style alone. `available` mirrors taffy's `AvailableSpace`. fn intrinsic_size(&self, _available: AvailableSize, _ctx: &MeasureCtx) -> Option<(f32, f32)> { None diff --git a/crates/rustmotion-core/src/variables.rs b/crates/rustmotion-core/src/variables.rs index 85b0fdcb..9c0a26a9 100644 --- a/crates/rustmotion-core/src/variables.rs +++ b/crates/rustmotion-core/src/variables.rs @@ -240,6 +240,27 @@ pub fn find_unresolved(value: &Value) -> Vec { fn find_unresolved_recursive(value: &Value, out: &mut Vec) { match value { Value::String(s) => { + // An `"= ..."` string is an arithmetic expression (see + // `rustmotion_core::expr`'s module doc), not `$name` reference + // content this scan understands — it has its own free-variable + // resolution (`Scope::var`, evaluated by + // `crates/rustmotion/src/loader.rs`'s `fold_static_expressions` + // and, for included files, `include.rs`) and its own error type + // (`ExprError::UnknownIdent`, surfaced as a precisely-located + // hard error, not a warning). Scanning inside it for bare `$word` + // occurrences would flag every reserved scope name this + // substitution pass was never meant to resolve — `$W`, `$H`, + // `$fps`, `$duration`, `$t`, `$T`, `$beat`, and any + // expression-only variable an author declares — as a false + // "unresolved variable" on every single scenario that uses one, + // whether or not the expression fold that runs later actually + // resolves it. `Expr::parse`/`fold_static_expressions` are the + // authority on whether an expression's identifiers are valid; + // this scan defers to them entirely rather than duplicating (and + // getting wrong) a second, narrower opinion. + if s.trim_start().starts_with('=') { + return; + } let mut chars = s.chars().peekable(); while let Some(ch) = chars.next() { if ch == '$' { @@ -568,6 +589,32 @@ mod tests { assert!(unresolved.contains(&"also_missing".to_string())); } + /// An `"= ..."` expression is a different sub-language with its own + /// identifier resolution (`rustmotion_core::expr`) — this scan must not + /// flag `$W`/`$H`/reserved scope names, or any other expression + /// variable, as an unresolved `$var` reference. A leading `=` after + /// trimming whitespace is enough to opt the whole string out, regardless + /// of which names appear inside it. + #[test] + fn find_unresolved_does_not_scan_inside_an_expression_string() { + let val = json!({ + "x": "= $W/2 + cos($i / $count * TAU) * 700", + "y": " = $H/2", + "plain": "$still_flagged" + }); + let unresolved = find_unresolved(&val); + assert!( + !unresolved + .iter() + .any(|n| n == "W" || n == "H" || n == "i" || n == "count"), + "expression identifiers must not be reported as unresolved variables: {unresolved:?}" + ); + assert!( + unresolved.contains(&"still_flagged".to_string()), + "a plain (non-expression) string must still be scanned: {unresolved:?}" + ); + } + #[test] fn test_apply_defaults() { let mut val = json!({ @@ -632,7 +679,7 @@ mod tests { // `config` key (RED first) ---- /// A document with **no** `config` block and a literal `$` in unrelated - /// content (a `terminal` line's `$PATH`) — this already succeeds today + /// content (a `list` item's `$PATH`) — this already succeeds today /// (the bug is the *other* direction; this locks in it keeps working). fn doc_with_literal_dollar_no_config() -> serde_json::Value { json!({ @@ -640,7 +687,7 @@ mod tests { "scenes": [{ "duration": 3.0, "children": [ - { "type": "terminal", "lines": ["echo $PATH", "cd $HOME/project"] }, + { "type": "list", "items": ["echo $PATH", "cd $HOME/project"] }, { "type": "text", "content": "Price: $100 today only" } ] }] @@ -663,7 +710,7 @@ mod tests { "duration": 3.0, "children": [ { "type": "text", "content": "$title" }, - { "type": "terminal", "lines": ["echo $PATH", "cd $HOME/project"] }, + { "type": "list", "items": ["echo $PATH", "cd $HOME/project"] }, { "type": "text", "content": "Price: $100 today only" } ] }] @@ -673,12 +720,11 @@ mod tests { #[test] fn literal_dollar_without_config_block_already_succeeds() { let mut doc = doc_with_literal_dollar_no_config(); - apply_defaults(&mut doc).expect( - "a literal '$' in terminal/text content with no config block must not be fatal", - ); + apply_defaults(&mut doc) + .expect("a literal '$' in list/text content with no config block must not be fatal"); // Content is left as-is: nothing declared these as variables. assert_eq!( - doc["scenes"][0]["children"][0]["lines"][0], + doc["scenes"][0]["children"][0]["items"][0], json!("echo $PATH") ); } @@ -698,7 +744,7 @@ mod tests { ); assert_eq!(doc["scenes"][0]["children"][0]["content"], json!("Demo")); assert_eq!( - doc["scenes"][0]["children"][1]["lines"][0], + doc["scenes"][0]["children"][1]["items"][0], json!("echo $PATH") ); assert_eq!( diff --git a/crates/rustmotion-core/src/vars/mod.rs b/crates/rustmotion-core/src/vars/mod.rs new file mode 100644 index 00000000..00a676b3 --- /dev/null +++ b/crates/rustmotion-core/src/vars/mod.rs @@ -0,0 +1,225 @@ +//! Scenario and scene variables (issue #329): a scalar declared once, +//! animated on the scenario's absolute timeline independently of any one +//! scene, and readable from any [`crate::expr`] expression as an ordinary +//! `$name`. +//! +//! # Why this exists +//! +//! An [`crate::expr::Expr`] has no memory of its own — every evaluation +//! starts from the same free variables the caller's [`crate::expr::Scope`] +//! happens to answer for that frame. That is fine for `$t`-driven motion +//! (a wiggle, a fade) but it cannot produce a value that keeps moving +//! *across* scene boundaries, or that several unrelated expressions need to +//! agree on simultaneously — a rotation every edge of a wireframe reads its +//! own opacity from, a draw-on progress a dozen strokes share, a counter +//! driving both a number and a bar's width. Asked how one of the reels this +//! chantier is chasing was built, its author described exactly this +//! indirection: GSAP animates a plain `{x, y, s, r}` object, and a function +//! reads it on every frame to produce attributes. This module is that +//! object: a named, animated scalar, resolved once per frame into a +//! [`crate::expr::Scope`] every expression can read. +//! +//! # Declaring a variable +//! +//! ```json +//! "vars": { +//! "keyDraw": { "default": 0, +//! "animation": [{ "at": "@4.85s", "to": 1, "duration": "3b", "easing": "ease_in_out" }] } +//! } +//! ``` +//! +//! See [`VarDef`] and [`VarKeyframe`] for the full shape. `at` and +//! `duration` are [`crate::schema::time::TimePoint`] — the scenario's beat +//! grid, if it declares one (`bpm`/`beat_offset`), so a variable's tween +//! can land on a beat exactly like a scene cut can. A variable with an +//! empty (or absent) `animation` is a constant — see [`VarDef::is_static`] +//! and [`dynamic_names`] for how a loader is expected to fold it away +//! entirely, the same as any other literal. +//! +//! # Resolving it +//! +//! [`VarTable::compile`] turns a whole [`VarSet`] into cheap-to-sample +//! numbers once, at load; [`VarTable::value_at`] then answers any absolute +//! time in O(keyframes). See [`track`]'s module doc for the exact +//! before-first/after-last/gap rule and why a `"b"`-unit `duration` is +//! resolved differently from an `at`. +//! +//! # Reading it from an expression +//! +//! [`VarScope`] implements [`crate::expr::Scope`] over one or two compiled +//! [`VarTable`]s (scenario-level, and an optional scene-level one that +//! shadows it) at one instant `t`. See [`scope`]'s module doc for how it is +//! meant to compose with a node-reference-answering `Scope` (the +//! `engine::deps` side of this chantier) inside a single composite context, +//! and for why a scene-local variable referenced from another scene surfaces +//! as the ordinary "unknown identifier" error rather than a dedicated one. +//! +//! # What is *not* here +//! +//! This module owns none of: the `Scenario`/`Scene` fields that carry a +//! [`VarSet`] (`schema::scenario::Scenario`/`Scene` — see this crate's +//! `vars`-partition notes for the exact line to add), the per-frame +//! dependency graph that decides *when* to re-resolve a [`VarTable`] and +//! feeds its output into [`VarScope`] (`engine::deps`), or the loader change +//! that keeps an expression naming a [`dynamic_names`] variable from being +//! folded at load — `crates/rustmotion/src/loader.rs`'s +//! `fold_static_expressions` only knows the fixed `t`/`T`/`beat`/`duration` +//! list today (see [`crate::expr::Expr::is_static`]'s doc) and needs to +//! additionally consult [`dynamic_names`] before folding any expression, or +//! `"= $keyDraw * 360"` fails to load instead of surviving to the per-frame +//! tier. This module only guarantees the *information* — [`dynamic_names`] +//! — is there to consult. + +pub mod schema; +pub mod scope; +pub mod track; + +pub use schema::{dynamic_names, VarDef, VarKeyframe, VarSet}; +pub use scope::VarScope; +pub use track::{VarTable, VarTrack, VarsError}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::expr::Expr; + use crate::schema::animation::EasingType; + use crate::schema::time::{TimeCtx, TimePoint}; + use std::f64::consts::TAU; + + /// The acceptance test named in this workstream's brief: a `for-each` + /// over edges, each edge's opacity an expression over a rotation + /// variable, rendering as something that actually rotates. The + /// `for-each`/JSON side of this can't be exercised yet — this crate's + /// `vars` partition does not own `Scenario.vars` (see the module doc's + /// "What is not here") — so this builds the same shape directly: a + /// [`VarSet`] with one `rotY` variable, and eight per-edge opacity + /// expressions, exactly what a `for-each` over 8 items would expand to + /// once its own `$i`/`$count` are folded and only `$rotY` is left live. + #[test] + fn wireframe_edges_rotate_via_a_shared_variable() { + let mut vars = VarSet::new(); + vars.insert( + "rotY".to_string(), + VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Seconds(0.0), + to: TAU, + duration: TimePoint::Seconds(4.0), + easing: EasingType::Linear, + }], + }, + ); + let ctx = TimeCtx { + bpm: None, + beat_offset: 0.0, + scene_start: 0.0, + }; + let scenario = VarTable::compile(&vars, &ctx).unwrap(); + + const EDGE_COUNT: usize = 8; + // Per-edge opacity by (simulated) depth: a Y-rotation wireframe's + // classic "front edges brighter than back edges" shading, exactly + // the kind of `for-each`-generated, per-edge-static, cos(rotY + angle) + // expression the issue's crystal example describes. + // `rotY` is animated, so it belongs in `dynamic_names` — the signal + // a loader must consult before folding any expression that reads + // it (see this module's "What is not here" section: `Expr::is_static` + // alone cannot tell `$rotY` apart from a genuine constant). + assert_eq!(dynamic_names(&vars).collect::>(), vec!["rotY"]); + + let edge_exprs: Vec = (0..EDGE_COUNT) + .map(|i| { + let angle = i as f64 / EDGE_COUNT as f64 * TAU; + Expr::parse(&format!("= (cos($rotY + {angle}) + 1) / 2")).unwrap() + }) + .collect(); + + let sample_times = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; + let mut frames: Vec> = Vec::new(); + for &t in &sample_times { + let scope = VarScope::new(&scenario, None, t); + + let rot_y = scope.resolve("rotY").unwrap(); + let expected_rot_y = TAU * (t / 4.0).min(1.0); + assert!((rot_y - expected_rot_y).abs() < 1e-9); + + let opacities: Vec = edge_exprs.iter().map(|e| e.eval(&scope).unwrap()).collect(); + for (i, &opacity) in opacities.iter().enumerate() { + let angle = i as f64 / EDGE_COUNT as f64 * TAU; + let expected = (f64::cos(rot_y + angle) + 1.0) / 2.0; + assert!((opacity - expected).abs() < 1e-9); + } + frames.push(opacities); + } + + // It actually rotates: consecutive frames inside the tween must + // differ — a frozen wireframe would repeat the same opacities. + for (i, pair) in frames.windows(2).enumerate() { + if i < 4 { + assert_ne!( + pair[0], + pair[1], + "wireframe did not move between sampled frames {i} and {}", + i + 1 + ); + } + } + // After the last keyframe (t=4) it holds at TAU, matching "Value + // outside the keyframes" in `track`'s module doc — not a wrap-around + // back to `default`. + assert_eq!( + frames[4], frames[5], + "must hold, not reset, past the last keyframe" + ); + } + + /// Deliverable 5: an un-animated variable folds like any other literal. + /// `Expr::is_static` only special-cases the fixed `t`/`T`/`beat`/`duration` + /// names (frozen in `expr::eval::is_dynamic_var_name`) — an arbitrary + /// `$name` it has never heard of is, by its own contract, eligible to + /// fold already. `dynamic_names` is the other half: the set a loader + /// must additionally treat as non-foldable for the *animated* ones, + /// since `Expr::is_static` alone cannot tell `$constant` and `$rotY` + /// apart. + #[test] + fn unanimated_variable_is_classified_static_and_folds() { + let mut vars = VarSet::new(); + vars.insert( + "constant".to_string(), + VarDef { + default: 7.0, + animation: Vec::new(), + }, + ); + vars.insert( + "rotY".to_string(), + VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Seconds(0.0), + to: 1.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::Linear, + }], + }, + ); + + let dynamic: Vec<&str> = dynamic_names(&vars).collect(); + assert_eq!(dynamic, vec!["rotY"]); + + // `Expr::is_static` itself, unaware of `vars`, already agrees a + // bare `$constant` reference is fold-eligible. + let constant_expr = Expr::parse("= $constant * 2").unwrap(); + assert!(constant_expr.is_static()); + + let ctx = TimeCtx { + bpm: None, + beat_offset: 0.0, + scene_start: 0.0, + }; + let table = VarTable::compile(&vars, &ctx).unwrap(); + let scope = VarScope::new(&table, None, 0.0); + assert_eq!(constant_expr.eval(&scope).unwrap(), 14.0); + } +} diff --git a/crates/rustmotion-core/src/vars/schema.rs b/crates/rustmotion-core/src/vars/schema.rs new file mode 100644 index 00000000..6aaa17e6 --- /dev/null +++ b/crates/rustmotion-core/src/vars/schema.rs @@ -0,0 +1,213 @@ +//! The declared shape of a scenario or scene variable: [`VarDef`] and its +//! [`VarKeyframe`] animation segments. See the [module doc](super) for the +//! resolver that turns this into numbers and the +//! [`Scope`](crate::expr::Scope) implementation that lets an expression +//! read them. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::schema::animation::EasingType; +use crate::schema::time::TimePoint; + +/// The `vars` map a scenario or a scene declares: variable name to +/// definition. `BTreeMap` rather than a `HashMap` so serialization and the +/// generated JSON schema are deterministic — the reason the rest of this +/// crate's schema types reach for it over a `HashMap` whenever map order +/// could otherwise vary between runs. +/// +/// Not to be confused with `config`/`VariableDefinition` in +/// [`crate::variables`]: that mechanism substitutes a whole JSON value +/// (string, number, boolean, object or array) once, at load, from a +/// `--var` override or a `for-each` binding. A [`VarDef`] is narrower +/// (always a scalar `f64`) and wider (it can move on the timeline) — the +/// two share the `$name` sigil deliberately, but answer different +/// questions: "what value was this configured with" versus "what is this +/// worth right now". +pub type VarSet = BTreeMap; + +/// A single declared variable: the value it holds before any keyframe +/// fires (and its whole value, forever, if `animation` is empty — see +/// [`VarDef::is_static`]), plus an ordered list of keyframes that tween it +/// across the scenario's absolute timeline. +/// +/// ```json +/// "keyDraw": { "default": 0, +/// "animation": [{ "at": "@4.85s", "to": 1, "duration": "3b", "easing": "ease_in_out" }] } +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VarDef { + /// This variable's value before its first keyframe fires, and its only + /// value if `animation` is empty. + pub default: f64, + /// Ordered tweens on the scenario's absolute timeline. Must be + /// strictly increasing by resolved `at` — see + /// [`super::track::VarTrack::compile`]. + #[serde(default)] + pub animation: Vec, +} + +impl VarDef { + /// True when this variable has no `animation` — a constant. Mirrors + /// [`crate::expr::Expr::is_static`]: a caller that gets `true` back may + /// substitute `default` once, at load, and never revisit this variable + /// for the rest of the render. See [`dynamic_names`] for the hook a + /// loader's static-folding pass needs to act on this. + pub fn is_static(&self) -> bool { + self.animation.is_empty() + } +} + +/// One tween: hold at the running value until `at`, then ease to `to` over +/// `duration`, then hold at `to` until the next keyframe fires (or +/// forever, for the last one). See [`super::track`]'s module doc, "Value +/// outside the keyframes", for the exact before-first/after-last rule. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VarKeyframe { + /// Where this segment starts, on the scenario's **absolute** timeline. + /// Resolved via [`TimePoint::resolve_absolute`] with `scene_start` + /// pinned to `0.0` — a variable has no enclosing scene of its own, so + /// the leading `@` this grammar otherwise requires for an absolute + /// reading is accepted but never changes anything here: `"4b"` and + /// `"@4b"` resolve identically for a [`VarKeyframe`]. + pub at: TimePoint, + /// The value this segment eases towards. + pub to: f64, + /// How long the ease from the running value to `to` takes. + /// + /// **Not resolved the way you'd expect from reading [`TimePoint`]'s own + /// contract.** `TimePoint`'s `"b"` unit is defined as a position on the + /// beat grid — `beat_offset + n * 60 / bpm`, `beat_offset` included, by + /// design, because every other `TimePoint` field in this schema (`at`, + /// `Scene::at`, a shake impact's `at`, …) names a *point in time*, where + /// picking up the grid's anchor is exactly right. `duration` is the one + /// exception: it names a *span* — "3 beats long" — and a span must not + /// shift just because the grid happens to start somewhere other than + /// zero. Resolving `"3b"` through [`TimePoint::resolve_relative`] + /// unchanged would compute `beat_offset + 3 * 60 / bpm`, silently + /// stretching every tween by the scenario's own `beat_offset` on top of + /// its declared length. [`super::track::VarTrack::compile`] resolves + /// this field against a copy of the scenario's `TimeCtx` with + /// `beat_offset` zeroed instead, so `"3b"` always means exactly + /// `3 * 60 / bpm` seconds — see that function's doc, and + /// [`super::track`]'s module doc, for the full reasoning and a test + /// (`beat_offset_does_not_leak_into_duration`) pinning it down. + /// + /// Defaults to an instant snap (`0` seconds) when omitted. + #[serde(default = "default_duration")] + pub duration: TimePoint, + /// Defaults to [`EasingType::Linear`] — the same default + /// [`EasingType`]'s own `#[default]` picks, deliberately *not* + /// `schema::animation::Animation`'s node-animation default + /// (`EaseOut`): a variable has no established "usual feel" the way an + /// entrance animation does, so an un-set easing should be the + /// arithmetically neutral choice. + #[serde(default)] + pub easing: EasingType, +} + +fn default_duration() -> TimePoint { + TimePoint::Seconds(0.0) +} + +/// Every name in `vars` whose [`VarDef::is_static`] is false — the set a +/// loader's static-folding pass (`crates/rustmotion/src/loader.rs`'s +/// `is_dynamic_var_name`) needs to treat as dynamic in addition to its own +/// fixed list (`t`, `T`, `beat`, `duration`), so an expression reading an +/// animated variable is kept for the per-frame tier instead of being +/// folded to whatever value it happened to hold at load time. A variable +/// with no `animation` is intentionally *not* included here — it is a +/// constant and the loader is free to fold expressions that only read it. +pub fn dynamic_names(vars: &VarSet) -> impl Iterator { + vars.iter() + .filter(|(_, def)| !def.is_static()) + .map(|(name, _)| name.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn var_def_without_animation_is_static() { + let def = VarDef { + default: 42.0, + animation: Vec::new(), + }; + assert!(def.is_static()); + } + + #[test] + fn var_def_with_animation_is_not_static() { + let def = VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Spec("@1s".to_string()), + to: 1.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::Linear, + }], + }; + assert!(!def.is_static()); + } + + #[test] + fn dynamic_names_only_reports_animated_variables() { + let mut vars = VarSet::new(); + vars.insert( + "constant".to_string(), + VarDef { + default: 1.0, + animation: Vec::new(), + }, + ); + vars.insert( + "moving".to_string(), + VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Seconds(0.0), + to: 1.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::Linear, + }], + }, + ); + + let names: Vec<&str> = dynamic_names(&vars).collect(); + assert_eq!(names, vec!["moving"]); + } + + #[test] + fn keyframe_deserializes_from_the_issue_example() { + let json = r#"{ + "default": 0, + "animation": [{ "at": "@4.85s", "to": 1, "duration": "3b", "easing": "ease_in_out" }] + }"#; + let def: VarDef = serde_json::from_str(json).unwrap(); + assert_eq!(def.default, 0.0); + assert_eq!(def.animation.len(), 1); + assert_eq!(def.animation[0].at, TimePoint::Spec("@4.85s".to_string())); + assert_eq!(def.animation[0].to, 1.0); + assert_eq!(def.animation[0].duration, TimePoint::Spec("3b".to_string())); + assert_eq!(def.animation[0].easing, EasingType::EaseInOut); + } + + #[test] + fn keyframe_duration_defaults_to_an_instant_snap() { + let json = r#"{ "default": 0, "animation": [{ "at": "1s", "to": 1 }] }"#; + let def: VarDef = serde_json::from_str(json).unwrap(); + assert_eq!(def.animation[0].duration, TimePoint::Seconds(0.0)); + assert_eq!(def.animation[0].easing, EasingType::Linear); + } + + #[test] + fn unknown_field_is_rejected() { + let json = r#"{ "default": 0, "typo": 1 }"#; + assert!(serde_json::from_str::(json).is_err()); + } +} diff --git a/crates/rustmotion-core/src/vars/scope.rs b/crates/rustmotion-core/src/vars/scope.rs new file mode 100644 index 00000000..6ee3ef6c --- /dev/null +++ b/crates/rustmotion-core/src/vars/scope.rs @@ -0,0 +1,192 @@ +//! [`VarScope`]: the [`Scope`] implementation an expression actually reads +//! `$name` through, composing a scenario-wide [`VarTable`] with an optional +//! scene-level one that shadows it. + +use crate::expr::Scope; + +use super::track::VarTable; + +/// Reads declared scenario/scene variables for one absolute instant `t`. +/// +/// Scene-level shadows scenario-level, by name: a scene that redeclares a +/// scenario variable's name gets its own value for that name inside that +/// scene, and the scenario's is invisible there (not summed, not merged — +/// entirely replaced). +/// +/// # Scene isolation +/// +/// A [`VarScope`] built for scene B never holds scene A's table, so an +/// expression in scene A that names a variable declared only in scene B's +/// `vars` gets `None` back from [`VarScope::var`] — exactly what it would +/// get for a name that was never declared anywhere. +/// [`Scope::var`](crate::expr::Scope::var)'s own doc already treats those +/// two cases as indistinguishable ("`None` means not defined in this +/// scope" turns into the same [`crate::expr::ExprError::UnknownIdent`] a +/// genuinely unknown name produces), which is what makes a cross-scene +/// variable reference surface as the same error a cross-scene node +/// reference does — this module does not need to special-case it, only to +/// never construct a [`VarScope`] that can see another scene's table. +/// +/// # Composing with `node_prop` +/// +/// This type answers [`Scope::var`] only; [`Scope::node_prop`] keeps its +/// default `None`. A caller that also needs node references (see +/// `engine::deps`) does not wrap a [`VarScope`] inside another `Scope` impl +/// — a `Scope` is consumed behind `&dyn Scope`, and trait objects don't +/// compose that way — it instead holds a [`VarScope`] (or the two +/// [`VarTable`]s and a `t`, if that is more convenient for its own +/// lifetimes) as a field alongside its node lookups, and its own `var` +/// implementation tries [`VarScope::resolve`] first, falling back to +/// whatever else it answers for. [`VarScope::resolve`] is exposed as a +/// plain method — not only reachable through the `Scope` impl — precisely +/// so it can be called that way without going through a trait object: +/// +/// ``` +/// use rustmotion_core::expr::Scope; +/// use rustmotion_core::vars::VarScope; +/// +/// struct EngineScope<'a> { +/// vars: VarScope<'a>, +/// // ... node lookups owned elsewhere ... +/// } +/// +/// impl Scope for EngineScope<'_> { +/// fn var(&self, name: &str) -> Option { +/// self.vars.resolve(name) /* .or_else(|| self.node_derived(name)) */ +/// } +/// +/// fn node_prop(&self, id: &str, prop: &str) -> Option { +/// let _ = (id, prop); +/// None // delegate to the node-dependency graph here +/// } +/// } +/// ``` +pub struct VarScope<'a> { + scenario: &'a VarTable, + scene: Option<&'a VarTable>, + t: f64, +} + +impl<'a> VarScope<'a> { + /// `scenario` is visible everywhere; `scene`, when given, shadows it by + /// name for expressions evaluated inside that one scene. `t` is the + /// absolute scenario time (seconds) this scope answers for. + pub fn new(scenario: &'a VarTable, scene: Option<&'a VarTable>, t: f64) -> Self { + VarScope { scenario, scene, t } + } + + /// Resolve `name`, scene table first. Usable directly, without going + /// through the [`Scope`] trait object — see the composing note above. + pub fn resolve(&self, name: &str) -> Option { + self.scene + .and_then(|scene| scene.value_at(name, self.t)) + .or_else(|| self.scenario.value_at(name, self.t)) + } +} + +impl Scope for VarScope<'_> { + fn var(&self, name: &str) -> Option { + self.resolve(name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::expr::Expr; + use crate::schema::animation::EasingType; + use crate::schema::time::{TimeCtx, TimePoint}; + use crate::vars::schema::{VarDef, VarKeyframe, VarSet}; + + fn ctx() -> TimeCtx { + TimeCtx { + bpm: None, + beat_offset: 0.0, + scene_start: 0.0, + } + } + + fn table_with(name: &str, def: VarDef) -> VarTable { + let mut vars = VarSet::new(); + vars.insert(name.to_string(), def); + VarTable::compile(&vars, &ctx()).unwrap() + } + + #[test] + fn expression_reads_a_scenario_level_variable() { + let scenario = table_with( + "keyDraw", + VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Seconds(0.0), + to: 1.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::Linear, + }], + }, + ); + let scope = VarScope::new(&scenario, None, 0.5); + + // `Expr::is_static` only special-cases the fixed `t`/`T`/`beat`/ + // `duration` names — it has no notion of a declared `vars` set, so + // in isolation it still says `true` here. That is expected, not a + // bug: a caller must additionally consult + // `crate::vars::dynamic_names` before folding, which is exactly + // what makes `keyDraw` land on the dynamic side — see this + // module's doc and `mod.rs`'s "What is not here" section. + let expr = Expr::parse("= $keyDraw * 360").unwrap(); + assert!(expr.is_static()); + assert_eq!(expr.eval(&scope).unwrap(), 180.0); + } + + #[test] + fn scene_level_variable_shadows_scenario_level() { + let scenario = table_with( + "speed", + VarDef { + default: 1.0, + animation: Vec::new(), + }, + ); + let scene = table_with( + "speed", + VarDef { + default: 9.0, + animation: Vec::new(), + }, + ); + + let shadowed = VarScope::new(&scenario, Some(&scene), 0.0); + assert_eq!(shadowed.resolve("speed"), Some(9.0)); + + let unshadowed = VarScope::new(&scenario, None, 0.0); + assert_eq!(unshadowed.resolve("speed"), Some(1.0)); + } + + #[test] + fn scene_local_variable_is_invisible_outside_its_scene() { + // Scene B declares `onlyInB`; a scope built without scene B's + // table (as if evaluating an expression in scene A) must not see + // it — same `None` a genuinely unknown name produces. + let scenario = VarTable::compile(&VarSet::new(), &ctx()).unwrap(); + let scene_b = table_with( + "onlyInB", + VarDef { + default: 5.0, + animation: Vec::new(), + }, + ); + let _ = &scene_b; // would be passed as `scene` only while evaluating scene B + + let scope_in_scene_a = VarScope::new(&scenario, None, 0.0); + assert_eq!(scope_in_scene_a.resolve("onlyInB"), None); + + let expr = Expr::parse("= $onlyInB").unwrap(); + let err = expr.eval(&scope_in_scene_a).unwrap_err(); + assert_eq!( + err, + crate::expr::ExprError::UnknownIdent("onlyInB".to_string()) + ); + } +} diff --git a/crates/rustmotion-core/src/vars/track.rs b/crates/rustmotion-core/src/vars/track.rs new file mode 100644 index 00000000..63b18afe --- /dev/null +++ b/crates/rustmotion-core/src/vars/track.rs @@ -0,0 +1,452 @@ +//! Compiling a [`VarSet`] into cheap-to-sample numbers. +//! +//! [`VarTable::compile`] resolves every [`TimePoint`](crate::schema::time::TimePoint) +//! once, at load, so [`VarTable::value_at`] does nothing more per frame than +//! a linear scan of a handful of plain-`f64` [`Segment`]s and one call to +//! [`crate::engine::animator::ease`] — no string parsing, no +//! [`TimePoint`](crate::schema::time::TimePoint) arithmetic, no +//! [`TimeError`] to propagate, ever again. +//! +//! # Value outside the keyframes +//! +//! Before a variable's first keyframe fires, it is worth [`VarDef::default`] +//! — the variable simply hasn't started moving yet. After its last +//! keyframe's segment ends, it holds at that keyframe's `to` forever, the +//! same way a CSS animation with `animation-fill-mode: forwards` behaves — +//! it does not snap back to `default`. Between two keyframes whose segments +//! don't touch (the second's `at` is later than the first's +//! `at + duration`), the variable holds at the first's `to` for the gap: a +//! keyframe names when the variable *starts* moving again, not a hole where +//! its value is undefined. +//! +//! # Duration is a span, not a point +//! +//! [`VarKeyframe::at`](super::VarKeyframe::at) is resolved via +//! `TimePoint::resolve_absolute`: a `"b"`-unit term there names a position +//! on the beat grid, [`TimeCtx::beat_offset`] included, by +//! [`TimePoint`](crate::schema::time::TimePoint)'s own contract (see +//! `schema::time`'s module doc). `VarKeyframe::duration`, however, is a +//! *length* of time — "3 beats long" — and must not pick up `beat_offset`, +//! which is where beat 0 sits, not a scale factor on a span. Resolving +//! `"3b"` through `TimePoint::resolve_relative` unchanged would compute +//! `beat_offset + 3 * 60 / bpm` for that single term (every `b`-unit term +//! adds the full `beat_offset` independently — see `eval_spec` in +//! `schema::time`), silently shifting every animated variable's tween +//! length by the scenario's beat offset. [`VarTrack::compile`] instead +//! resolves `duration` against a copy of the scenario's [`TimeCtx`] with +//! `beat_offset` zeroed, so `"3b"` means exactly `3 * 60 / bpm` seconds +//! regardless of where the grid itself is anchored. + +use std::collections::BTreeMap; + +use crate::engine::animator::ease; +use crate::schema::animation::EasingType; +use crate::schema::time::{TimeCtx, TimeError}; + +use super::schema::{VarDef, VarSet}; + +/// Everything that can go wrong compiling a [`VarSet`] into a [`VarTable`]. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum VarsError { + /// A keyframe's `at` or `duration` failed to resolve — most commonly a + /// `"b"`-unit term with no `bpm` declared on the scenario. + #[error("variable `{name}`: {source}")] + Time { + name: String, + #[source] + source: TimeError, + }, + /// Keyframes must be strictly increasing by resolved `at`; this one + /// wasn't. + #[error( + "variable `{name}`: keyframe {index} starts at {at}s, which is not after keyframe {prev_index}'s start ({prev_at}s) — keyframes must be strictly increasing" + )] + KeyframesNotIncreasing { + name: String, + index: usize, + at: f64, + prev_index: usize, + prev_at: f64, + }, + /// A keyframe's `duration` resolved to a negative number of seconds. + #[error("variable `{name}`: keyframe {index} has a negative duration ({duration}s)")] + NegativeDuration { + name: String, + index: usize, + duration: f64, + }, +} + +/// One resolved tween, in plain seconds — no +/// [`TimePoint`](crate::schema::time::TimePoint) left to re-parse. `start` +/// is the value going into the segment: [`VarDef::default`] +/// for the first one, the previous segment's `to` for every other one. +#[derive(Debug, Clone, PartialEq)] +struct Segment { + at: f64, + duration: f64, + start: f64, + to: f64, + easing: EasingType, +} + +impl Segment { + /// `t` is expected to already be inside `[at, at + duration]`; a `t` + /// outside that range is still handled safely (the ratio clamps to + /// `0.0`/`1.0`) but callers should prefer [`VarTrack::value_at`]'s + /// explicit before/after/gap handling instead of relying on that. + fn value_at(&self, t: f64) -> f64 { + if self.duration <= 0.0 { + return self.to; + } + let raw = ((t - self.at) / self.duration).clamp(0.0, 1.0); + let eased = ease(raw, &self.easing); + self.start + (self.to - self.start) * eased + } +} + +/// A single variable's compiled animation: its `default` plus the resolved +/// [`Segment`]s built from its [`VarKeyframe`]s, ready to sample at any +/// absolute time with [`VarTrack::value_at`]. +#[derive(Debug, Clone, PartialEq)] +pub struct VarTrack { + default: f64, + segments: Vec, +} + +impl VarTrack { + /// Compile a single [`VarDef`] against the scenario's beat grid. `name` + /// is used only to attribute errors. + pub fn compile(name: &str, def: &VarDef, ctx: &TimeCtx) -> Result { + let mut segments = Vec::with_capacity(def.animation.len()); + let mut running = def.default; + let mut prev: Option<(usize, f64)> = None; + + for (index, kf) in def.animation.iter().enumerate() { + // A variable has no enclosing scene of its own — `at` always + // resolves against the scenario's absolute timeline, `@` + // prefix or not. See the struct doc on `VarKeyframe::at`. + let at_ctx = TimeCtx { + scene_start: 0.0, + ..*ctx + }; + let at = kf + .at + .resolve_absolute(&at_ctx) + .map_err(|source| VarsError::Time { + name: name.to_string(), + source, + })?; + + if let Some((prev_index, prev_at)) = prev { + if at <= prev_at { + return Err(VarsError::KeyframesNotIncreasing { + name: name.to_string(), + index, + at, + prev_index, + prev_at, + }); + } + } + prev = Some((index, at)); + + // `duration` is a span, not a point — see the module doc. + let duration_ctx = TimeCtx { + beat_offset: 0.0, + scene_start: 0.0, + ..*ctx + }; + let duration = kf + .duration + .resolve_relative(&duration_ctx) + .map_err(|source| VarsError::Time { + name: name.to_string(), + source, + })?; + if duration < 0.0 { + return Err(VarsError::NegativeDuration { + name: name.to_string(), + index, + duration, + }); + } + + segments.push(Segment { + at, + duration, + start: running, + to: kf.to, + easing: kf.easing.clone(), + }); + running = kf.to; + } + + Ok(VarTrack { + default: def.default, + segments, + }) + } + + /// This variable's value at absolute time `t` seconds — see the module + /// doc's "Value outside the keyframes" section for the before-first, + /// gap and after-last rules. + pub fn value_at(&self, t: f64) -> f64 { + let mut value = self.default; + for seg in &self.segments { + if t < seg.at { + return value; + } + let end = seg.at + seg.duration; + if t <= end { + return seg.value_at(t); + } + value = seg.to; + } + value + } +} + +/// A whole [`VarSet`] — all of a scenario's vars, or all of one scene's — +/// compiled once. Cheap to hold onto for an entire render: +/// [`VarTable::compile`] is the only place +/// [`TimePoint`](crate::schema::time::TimePoint) arithmetic happens. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct VarTable { + tracks: BTreeMap, +} + +impl VarTable { + /// Compile every variable in `vars` against the scenario's beat grid. + /// Fails on the first variable whose keyframes don't resolve — see + /// [`VarsError`]. + pub fn compile(vars: &VarSet, ctx: &TimeCtx) -> Result { + let mut tracks = BTreeMap::new(); + for (name, def) in vars { + tracks.insert(name.clone(), VarTrack::compile(name, def, ctx)?); + } + Ok(VarTable { tracks }) + } + + /// `name`'s value at absolute time `t` seconds, or `None` if this table + /// has no variable by that name. + pub fn value_at(&self, name: &str, t: f64) -> Option { + self.tracks.get(name).map(|track| track.value_at(t)) + } + + /// Every variable name this table declares. + pub fn names(&self) -> impl Iterator { + self.tracks.keys().map(String::as_str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::time::TimePoint; + use crate::vars::schema::VarKeyframe; + + fn ctx(bpm: Option, beat_offset: f64) -> TimeCtx { + TimeCtx { + bpm, + beat_offset, + scene_start: 0.0, + } + } + + fn linear_0_to_1_over_3_beats() -> VarDef { + VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Spec("@4.85s".to_string()), + to: 1.0, + duration: TimePoint::Spec("3b".to_string()), + easing: EasingType::Linear, + }], + } + } + + #[test] + fn value_before_first_keyframe_is_default() { + let def = linear_0_to_1_over_3_beats(); + let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 0.0)).unwrap(); + assert_eq!(track.value_at(0.0), 0.0); + assert_eq!(track.value_at(4.84), 0.0); + } + + #[test] + fn value_at_several_sampled_instants_across_the_tween() { + let def = linear_0_to_1_over_3_beats(); + // bpm=120 -> 0.5s/beat -> duration = 3 * 0.5 = 1.5s, spanning + // [4.85, 6.35]. + let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 0.0)).unwrap(); + + assert_eq!(track.value_at(4.85), 0.0); // exactly at `at` + assert!((track.value_at(5.6) - 0.5).abs() < 1e-9); // halfway + assert_eq!(track.value_at(6.35), 1.0); // exactly at `at + duration` + } + + #[test] + fn value_after_last_keyframe_holds_at_to() { + let def = linear_0_to_1_over_3_beats(); + let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 0.0)).unwrap(); + assert_eq!(track.value_at(6.36), 1.0); + assert_eq!(track.value_at(1000.0), 1.0); + } + + #[test] + fn beat_offset_does_not_leak_into_duration() { + // Same track, but the grid is anchored 2.2s in. `at` (absolute, + // "@4.85s") is untouched by beat_offset since it's a plain seconds + // spec; the "3b" duration must still be exactly 1.5s, not + // 1.5 + 2.2. + let def = linear_0_to_1_over_3_beats(); + let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 2.2)).unwrap(); + assert_eq!(track.value_at(4.85), 0.0); + assert_eq!(track.value_at(6.35), 1.0); // 4.85 + 1.5, not 4.85 + 3.7 + } + + #[test] + fn multiple_keyframes_chain_start_values() { + let def = VarDef { + default: 0.0, + animation: vec![ + VarKeyframe { + at: TimePoint::Seconds(1.0), + to: 10.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::Linear, + }, + VarKeyframe { + at: TimePoint::Seconds(3.0), + to: 0.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::Linear, + }, + ], + }; + let track = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap(); + assert_eq!(track.value_at(0.0), 0.0); + assert_eq!(track.value_at(1.0), 0.0); + assert_eq!(track.value_at(1.5), 5.0); + assert_eq!(track.value_at(2.0), 10.0); + // Gap between segment 1's end (2.0) and segment 2's start (3.0): + // holds at the first segment's `to`. + assert_eq!(track.value_at(2.5), 10.0); + assert_eq!(track.value_at(3.0), 10.0); + assert_eq!(track.value_at(3.5), 5.0); + assert_eq!(track.value_at(4.0), 0.0); + assert_eq!(track.value_at(100.0), 0.0); + } + + #[test] + fn zero_duration_keyframe_snaps() { + let def = VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Seconds(1.0), + to: 1.0, + duration: TimePoint::Seconds(0.0), + easing: EasingType::Linear, + }], + }; + let track = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap(); + assert_eq!(track.value_at(0.999), 0.0); + assert_eq!(track.value_at(1.0), 1.0); + assert_eq!(track.value_at(2.0), 1.0); + } + + #[test] + fn ease_in_out_is_not_linear_at_the_midpoint() { + let def = VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Seconds(0.0), + to: 1.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::EaseInOut, + }], + }; + let track = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap(); + // ease_in_out_cubic(0.25) is well below the linear 0.25. + let quarter = track.value_at(0.25); + assert!( + quarter < 0.25, + "expected eased value below linear, got {quarter}" + ); + // Midpoint of an odd-symmetric ease-in-out curve is still 0.5. + assert!((track.value_at(0.5) - 0.5).abs() < 1e-9); + } + + #[test] + fn beat_unit_without_bpm_errors_with_the_variable_name() { + let def = linear_0_to_1_over_3_beats(); + let err = VarTrack::compile("keyDraw", &def, &ctx(None, 0.0)).unwrap_err(); + assert!(matches!(err, VarsError::Time { name, .. } if name == "keyDraw")); + } + + #[test] + fn non_increasing_keyframes_are_rejected() { + let def = VarDef { + default: 0.0, + animation: vec![ + VarKeyframe { + at: TimePoint::Seconds(2.0), + to: 1.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::Linear, + }, + VarKeyframe { + at: TimePoint::Seconds(2.0), + to: 0.0, + duration: TimePoint::Seconds(1.0), + easing: EasingType::Linear, + }, + ], + }; + let err = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap_err(); + assert!(matches!( + err, + VarsError::KeyframesNotIncreasing { index: 1, .. } + )); + } + + #[test] + fn negative_duration_is_rejected() { + let def = VarDef { + default: 0.0, + animation: vec![VarKeyframe { + at: TimePoint::Spec("@2s-3s".to_string()), + to: 1.0, + duration: TimePoint::Spec("1s-3s".to_string()), + easing: EasingType::Linear, + }], + }; + let err = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap_err(); + assert!(matches!(err, VarsError::NegativeDuration { .. })); + } + + #[test] + fn var_table_compiles_and_resolves_by_name() { + let mut vars = VarSet::new(); + vars.insert("keyDraw".to_string(), linear_0_to_1_over_3_beats()); + vars.insert( + "constant".to_string(), + VarDef { + default: 42.0, + animation: Vec::new(), + }, + ); + + let table = VarTable::compile(&vars, &ctx(Some(120.0), 0.0)).unwrap(); + assert_eq!(table.value_at("constant", 0.0), Some(42.0)); + assert_eq!(table.value_at("constant", 999.0), Some(42.0)); + assert_eq!(table.value_at("keyDraw", 0.0), Some(0.0)); + assert_eq!(table.value_at("keyDraw", 1000.0), Some(1.0)); + assert_eq!(table.value_at("nope", 0.0), None); + + let mut names: Vec<&str> = table.names().collect(); + names.sort_unstable(); + assert_eq!(names, vec!["constant", "keyDraw"]); + } +} diff --git a/crates/rustmotion-core/tests/audit_ws_k.rs b/crates/rustmotion-core/tests/audit_ws_k.rs index c6a68899..948fc3f6 100644 --- a/crates/rustmotion-core/tests/audit_ws_k.rs +++ b/crates/rustmotion-core/tests/audit_ws_k.rs @@ -135,6 +135,27 @@ const KNOWN_INERT_FIELDS: &[(&str, &str)] = &[ Allowlisted rather than asserted dead; flagged in the workstream K report for a human to \ confirm one way or the other.", ), + ( + "scene_start", + "Issue #336: TimeCtx.scene_start IS read — every time, unconditionally — but only from \ + inside TimePoint::resolve_relative/resolve_absolute's own body (crates/rustmotion-core/\ + src/schema/time.rs, `ctx.scene_start`), which is itself inside crates/rustmotion-core/\ + src/schema/ and so invisible to this grep. Every external caller (crates/rustmotion/src/\ + encode/video/tasks.rs, cli/commands/validate_schema.rs, cli/commands/geometry.rs) goes \ + through those two methods and never types `.scene_start` directly — by design: the type \ + is meant to be consumed through its API, not by reaching into the context struct. Same \ + shape of false positive as the `target` entry above, different mechanical reason.", + ), + ( + "param_type", + "Issue #336 follow-up: ComponentTemplateParam.param_type exists purely so `rustmotion \ + schema` declares the real shape of a `components[name].params` entry — the field is \ + never populated in practice (Scenario.components itself is always empty by the time \ + any Rust code could read it; see that field's doc comment) and so is never read via \ + `.param_type` anywhere, by the same design as the `components`/`params`/`template`/ \ + `default`/`description` fields it sits beside — those simply share a name with an \ + already-read field elsewhere and so don't trip this grep, `param_type` doesn't.", + ), ]; /// `crates/rustmotion-core` -> `crates` -> ``. diff --git a/crates/rustmotion-studio/src/editor/properties.rs b/crates/rustmotion-studio/src/editor/properties.rs index 83ef69c1..df6387f2 100644 --- a/crates/rustmotion-studio/src/editor/properties.rs +++ b/crates/rustmotion-studio/src/editor/properties.rs @@ -208,7 +208,6 @@ pub fn css_effective_default(prop: &str) -> Option { pub fn text_default_font_size(tag: &str) -> Option { match tag { "text" | "caption" | "gradient_text" => Some(48.0), - "terminal" | "codeblock" => Some(14.0), _ => None, } } @@ -584,9 +583,7 @@ pub enum CssFamily { pub fn css_family(tag: &str) -> CssFamily { match tag { "text" | "caption" | "gradient_text" | "rich_text" | "counter" | "kbd" | "badge" - | "marquee" | "callout" | "tooltip" | "codeblock" | "terminal" | "list" | "tag_cloud" => { - CssFamily::TextLike - } + | "marquee" | "callout" | "tooltip" | "list" | "tag_cloud" => CssFamily::TextLike, "container" | "div" | "card" | "flex" | "grid" | "positioned" => CssFamily::Container, _ => CssFamily::Plain, } @@ -649,7 +646,7 @@ mod tests { #[test] fn excluded_fields_never_appear() { - for tag in ["text", "counter", "card", "chart", "rich_text"] { + for tag in ["text", "counter", "div", "chart", "rich_text"] { let Some(props) = component_props(tag) else { panic!("{tag} missing from schema registry"); }; @@ -843,13 +840,11 @@ mod tests { #[test] fn font_size_default_is_per_component() { - assert_eq!(text_default_font_size("terminal"), Some(14.0)); - assert_eq!(text_default_font_size("codeblock"), Some(14.0)); assert_eq!(text_default_font_size("text"), Some(48.0)); assert_eq!(text_default_font_size("made_up_tag"), None); assert_eq!( - css_display_default("terminal", "font-size"), - Some(Value::from(14.0)) + css_display_default("text", "font-size"), + Some(Value::from(48.0)) ); assert_eq!( css_display_default("text", "color"), diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index 2c54f5df..370a9c22 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -9,10 +9,9 @@ Tout JSON de scénario généré doit être validé avec `rustmotion validate` a ## Sécurité géométrique (viewport) -Aucun contenu textuel ne doit dépasser du device. Quatre propriétés contrôlent ce comportement : +Aucun contenu textuel ne doit dépasser du device. Trois propriétés contrôlent ce comportement : - `style.white-space` (default `normal`, donc wrap actif) sur `text` : le texte wrap sur la largeur du parent par défaut. `white-space: "nowrap"` (ou `"pre"`) est légitime uniquement si un `max-width` fini + `font-size` raisonnable garantissent que la ligne tient. Le validateur émet `unwrappable_text_overflow` sinon. Il n'existe pas de champ `style.wrap` — c'est un vocabulaire hérité de l'ancien modèle de style, supprimé de `CssStyle`. Voir [rules/geometry-safety.md](.claude/skills/rustmotion/rules/geometry-safety.md). -- `auto_scroll` (default `true`) sur `codeblock` et `terminal` : quand le contenu dépasse la hauteur du `size`, le moteur scrolle (clip + translate) sans réduire la `font-size`. `auto_scroll: false` → `auto_scroll_disabled_overflow`. - `style.text-autofit` (default absent) sur `text` et `gradient_text` : réduit la `font-size` jusqu'à ce que le contenu tienne dans sa boîte. À réserver au texte piloté par des données, dont on ne peut pas connaître la longueur à l'avance — pas pour compenser une mise en page qu'on peut simplement dimensionner. Le rétrécissement s'arrête à un plancher de lisibilité calibré ; si ça ne suffit pas, **la violation est toujours signalée**. Seuls ces deux composants l'implémentent : le déclarer ailleurs est inerte. - `style.overflow` (default `visible`) sur les conteneurs : sémantique CSS. `hidden` clippe au bord du parent. Le validateur ne se plaint que si le contenu sort du **viewport**, pas d'un parent `visible`. @@ -20,7 +19,7 @@ Aucun contenu textuel ne doit dépasser du device. Quatre propriétés contrôle CLI : - `rustmotion validate -f file.json` — schema + geometry -- `--fix` — auto-fix sûr : `auto_scroll: true` sur `auto_scroll_disabled_overflow`, retrait de `style.white-space` sur `unwrappable_text_overflow` (retour au wrapping), et `text-autofit: true` sur `content_overflows_box` pour `text`/`gradient_text`. Les débordements de viewport restent non corrigés : ils demandent un arbitrage de mise en page. `--fix` **refuse** d'écrire sur un scénario templaté, utilisant `include`, ou utilisant `for-each`/`use` — les index de chemin ne correspondraient plus à la source. +- `--fix` — auto-fix sûr : retrait de `style.white-space` sur `unwrappable_text_overflow` (retour au wrapping), et `text-autofit: true` sur `content_overflows_box` pour `text`/`gradient_text`. Les débordements de viewport restent non corrigés : ils demandent un arbitrage de mise en page. `--fix` **refuse** d'écrire sur un scénario templaté, utilisant `include`, ou utilisant `for-each`/`use` — les index de chemin ne correspondraient plus à la source. - `--report r.json` — rapport JSON - `--strict-anim` — vérification frame par frame ; ajoute la détection `animated_text_overflow` (transform animé qui sort du viewport à un instant échantillonné). L'échantillonnage s'arrête à `scene.freeze_at`, puisque rien n'est rendu au-delà. - `--strict-attrs` — promeut en erreurs les attributs inconnus (détection schéma + did-you-mean, activée par défaut en warnings) @@ -70,15 +69,13 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en > `grid_dots` marks the intersections and reads as a texture; `grid_lines` is a grid of ruled **lines** and reads as a structure — the one to put behind a chart or a code panel. Config: `cell`, `weight`, `color`, plus `major_every`/`major_weight` for the graph-paper effect. -## Composants disponibles (60) +## Composants disponibles (53) ### Basiques `text`, `shape`, `image`, `icon`, `svg`, `video`, `gif`, `caption`, `rich_text`, `gradient_text` ### Conteneurs -`card`, `flex`, `grid`, `div` (alias de `container`), `container`, `positioned` - -> `div` = layout pur sans décoration visuelle (HTML `
`). `card` = même chose mais avec fond/border-radius/ombre attendus. +`div` — seul type de conteneur. `card`, `flex`, `grid`, `container`, `positioned` sont acceptés comme alias JSON du même composant (compat historique avec les six types qui existaient avant leur fusion) : aucune différence de comportement, de style par défaut ou de décoration entre ces orthographes. `style.display` (`flex` par défaut, ou `grid`) pilote le layout — pas le nom du tag ; fond, `border-radius`, ombre sont de simples propriétés `style` disponibles sur ce composant comme sur n'importe quel autre, pas un attribut réservé à l'une des anciennes variantes. ### Data Visualization - `chart` — 12 types: bar, line, pie, donut, horizontal_bar, area, stacked_bar, radar, scatter, radial_bar, funnel, waterfall. Supporte axes/grilles/labels. @@ -90,7 +87,7 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en - `dot_map` — carte mondiale en dot-pattern avec points de données, pulse, lat/lng - `progress` — barre linéaire ou circulaire - `counter` — compteur animé (standalone uniquement, pas dans les cards) -- `number_wheel` — digits that scroll like a mechanical odometer and land on the figure. Not to be confused with `counter`, which interpolates a value and rewrites the number (its glyphs jump). See [rules/number-wheel.md](.claude/skills/rustmotion/rules/number-wheel.md). +- `number_wheel` — digits that scroll like a mechanical odometer and land on the figure. Not to be confused with `counter`, which interpolates a value and rewrites the number (its glyphs jump). Le réglage se fait par `digits`, `duration` et `easing` sur le composant. - `table` — tableau avec column_widths, column_align, cell_padding, show_borders ### UI Components @@ -101,7 +98,6 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en - `rating` — étoiles avec remplissage partiel animé - `kbd` — touche clavier visuelle (effet 3D) - `tooltip` — label flottant avec flèche directionnelle -- `notification` — toast fade-in/out avec stack push (info/success/warning/error) - `pill_nav` — tabs avec pill indicator animé entre onglets - `list` — liste bullet/numbered/checklist avec icônes - `stepper` — étapes numérotées connectées avec progression animée @@ -115,10 +111,6 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en - `success_check` — a checkmark that draws itself inside a halo, with a pop and a settling rotation - `pointer` — a simulated **mouse** cursor (arrow + click ring) following waypoints. `cursor` is a text caret, not this. See [rules/pointer-walkthrough.md](.claude/skills/rustmotion/rules/pointer-walkthrough.md). -### Code & Terminal -- `codeblock` — code syntax-highlighted avec reveal, diff mode (`diff: true`), state transitions -- `terminal` — terminal avec chrome macOS, reveal typewriter + curseur clignotant - ### Diagrammes `arrow`, `connector`, `timeline`, `line` @@ -153,7 +145,7 @@ The seven `char_*` presets are tuned via `direction` (up/down/left/right), `dist Le moteur utilise un pipeline **box_tree → layout_pass → paint_pass** inspiré des navigateurs web : 1. **box_tree** (`box_builder.rs`) — construit un arbre de `BoxNode { css: CssStyle, children, intrinsic }` depuis les composants JSON résolus -2. **layout_pass** (`engine/layout_pass.rs`) — orchestre taffy pour calculer les `BoxLayout { x, y, width, height }` de chaque nœud. Les feuilles avec un `IntrinsicMeasure` (texte, image, codeblock) sont mesurées via une `measure_fn`. +2. **layout_pass** (`engine/layout_pass.rs`) — orchestre taffy pour calculer les `BoxLayout { x, y, width, height }` de chaque nœud. Les feuilles avec un `IntrinsicMeasure` (texte, image, table) sont mesurées via une `measure_fn`. 3. **paint_pass** (`engine/paint_pass.rs`) — descend l'arbre, applique transform/opacity, peint les décorations (background, border, shadow), délègue au `Painter` du composant pour le contenu. Chaque composant implémente le trait `Painter` : @@ -191,7 +183,6 @@ crates/ │ │ ├── style.rs # Specialized types (CardBorder, CardShadow, Fill, etc.) │ │ ├── background.rs # AnimatedBackground, BackgroundPreset │ │ ├── animation.rs # EasingType, AnimationPreset, PresetConfig -│ │ ├── codeblock_types.rs # CodeblockChrome, CodeblockState │ │ └── video.rs # AnimationEffect, Size, ShapeType, Stroke │ └── traits/ │ ├── painter.rs # Painter trait + PaintCtx + AvailableSize + MeasureCtx diff --git a/crates/rustmotion/Cargo.toml b/crates/rustmotion/Cargo.toml index ce87eba3..eb4196ea 100644 --- a/crates/rustmotion/Cargo.toml +++ b/crates/rustmotion/Cargo.toml @@ -44,8 +44,6 @@ rubato = "0.16" notify = "7" rayon = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } -syntect = { version = "5", default-features = false, features = ["default-fancy"] } -similar = "2" resvg = "0.44" usvg = "0.44" tiny-skia = "0.11" diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 5ddd7003..a56d4add 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -45,8 +45,7 @@ Rustmotion's JSON API is a direct superset of HTML/CSS. When composing a scene, | HTML/CSS | Rustmotion JSON | |---|---| | `` | `"layout": {"direction": "column", "align_items": "center", "justify_content": "center"}` | -| `
` neutre — layout pur, zéro décoration visuelle | `{"type":"div"}` — flex par défaut, pas de fond/border-radius/ombre | -| `
` — avec fond, border-radius, ombre | `{"type":"card"}` — flex par défaut, styling visuel | +| `
`, plain or decorated | `{"type":"div"}` — flex par défaut; ajoute `background`/`border-radius`/`box-shadow` dans `style` pour un panneau décoré, ou laisse-les absents pour un groupement pur. `card`/`flex`/`grid`/`positioned`/`container` sont des alias historiques du même type — aucune différence de comportement, `div` est la forme canonique. | | `
` | `{"type":"div","style":{"flex-direction":"row","gap":24}}` | | `
` | `{"type":"div","style":{"display":"grid","grid-template-columns":["1fr","1fr"],"gap":16}}` | | `

Title

` — inline, no position | `{"type":"text","content":"Title"}` — flow child, no `x`/`y` | @@ -99,6 +98,24 @@ Tout espace, alignement, et distribution se règle via des propriétés sur le * --- +## Composition over cataloguing + +Fifty-three component types exist, but three different things hide behind that one number: + +1. **Algorithms** (`dot_map`, `treemap`, `lottie`, `video`, `gif`, `qr_code`, `waveform`/`audio_spectrum`, `image`) render something a JSON tree of shapes and text genuinely cannot — a land bitmap, an FFT, Reed-Solomon error correction, recursive slice-and-dice. Reach for these directly. (`codeblock` used to belong here for syntax highlighting; it was deleted outright, not deprecated — the tokenising didn't need to live in the engine, since whoever writes the scenario is a language model that already knows the grammars and can emit `rich_text` with a coloured span per token directly. See [rules/composition-recipes.md](rules/composition-recipes.md) for the recipe.) +2. **Primitives** (`text`, `rich_text`, `gradient_text`, `shape`, `svg`, `icon`, `line`, `arrow`, `connector`, `div`, `cursor`, `pointer`) are the alphabet. Everything else is built from these. +3. **Composite UI widgets** (`stat`, `badge`, `gauge`, `sparkline`, `progress`, `counter`, `number_wheel`, `kbd`, `tooltip`, `list`, `stepper`, `comparison`, `countdown`, `pill_nav`, `avatar`, `avatar_group`, `rating`, `switch`, `slider`, `skeleton`, `tag_cloud`, `callout`, `divider`, `success_check`, `timeline`, `marquee`, `chart`, `heatmap`, `table`, `particle`, `caption`, `mockup`) are frozen arrangements of primitives — a `div` + `text` + `shape` + an animation, baked into a single JSON type with its own field names. They **still exist and still render byte-identically** — nothing here changes what a scenario produces. Issue #333 phase B put a Rust-level `#[deprecated]` attribute on 16 of the original 27 struct definitions (the other 11 carry the same message as a doc comment instead, because a struct-level `#[deprecated]` also deprecates field *reads*, and a few of these are read directly by CLI-internal code outside this crate — see each type's own note in `crates/rustmotion-components/src/*.rs`). That attribute is a signal for a Rust contributor writing `Badge { .. }`/`Stat { .. }`/etc. by hand in this codebase or a downstream crate; it never fires for JSON scenario authoring, which goes through this crate's own generated deserializer. They are simply no longer documented here, and no longer the reflex to reach for. (`notification` used to be a member of this class too; like `codeblock`, it was deleted outright rather than merely deprecated — see the `div`-sliding-toast recipe in [rules/composition-recipes.md](rules/composition-recipes.md).) + + `chart`, `heatmap`, `table`, `particle`, `caption`, and `mockup` joined this class later, once this chantier's `for-each` gained arithmetic expressions, deterministic `rand(seed, i)`, computed path data, and node references — the exact machinery that makes a five-bar chart a `for-each` over five items with one `height` expression instead of a dedicated component. All six carry the real `#[deprecated]` struct attribute (see [rules/composition-recipes.md](rules/composition-recipes.md) for what composes each), with a narrow `#[allow(deprecated)]` on the handful of CLI call sites that read `caption`/`mockup` fields directly (`crates/rustmotion/src/cli/commands/{geometry,validate_schema,info}.rs`) rather than a blanket one. (`terminal` was also in this later-joining group; it was deleted outright, not deprecated — see the `div`-title-bar-plus-`text` recipe in [rules/composition-recipes.md](rules/composition-recipes.md).) + +**Why:** a shipped widget library is still a default that anchors a generator toward filling in blanks (`stat` with a value and a label) instead of designing the actual layout the brief calls for. Art direction is per video; a component defined *inside* the scenario and instantiated with `components` + `for-each` gives the same reuse without importing someone else's opinion about what a KPI card looks like. An example teaches composition; a library teaches filling in blanks. + +**The decision, recorded:** nothing is added to the frozen-widget class from here on. A generator that needs a stat card, a progress bar, a stepper, or any other shape a UI kit would hand over **defines it in the scenario** with `div`/`text`/`shape`, and reaches for `components` + `for-each` the moment more than one instance is needed. See [rules/composition-recipes.md](rules/composition-recipes.md) — read it before reaching for a component not in the catalog below — and the worked examples under `examples/composition-*.json`. + +If a subject genuinely needs one of the 32 frozen widgets by name (they are still valid JSON, still render, and are exercised in `examples/component-showcase.json` and `examples/mega-showcase.json`), using it is not an error. The point is that a generator should no longer see them first and reach for them by default. + +--- + ## Video Creation Wizard When the user provides a **video idea or subject** (not a technical question), activate this guided wizard flow. Examples of triggers: "je veux créer une vidéo pour...", "make a video about...", "une vidéo de présentation de...", or any prompt describing video content to produce. @@ -151,17 +168,17 @@ Each scene must include: | User's idea | Recommended components | |---|---| -| Stats / numbers | `counter` (animated) + `card` | -| Features / benefits | `card` grid + `icon` + `badge` | -| Code / technical | `codeblock` + `terminal` | -| Process / steps | `timeline` component | +| Stats / numbers | a KPI `card` (`div`/`card` + `text` + `shape`) defined once as a `components` entry, instantiated with `for-each` — see `examples/composition-kpi-row.json` | +| Features / benefits | `card` grid + `icon`, one `components` entry per card instantiated with `for-each` | +| Code / technical | `rich_text` with a coloured span per token for the code, plus a terminal-style `div` (title bar + monospace `text` lines under `typewriter`) — see [rules/composition-recipes.md](rules/composition-recipes.md) | +| Process / steps | `connector`/`line` + `text` labels, one step defined as a `components` entry and repeated with `for-each` — see [rules/composition-recipes.md](rules/composition-recipes.md) | | Comparison | `flex` row with 2 `card` side by side | | Testimonial | `card` with `shape` circle (avatar) + `text` italic | -| Pricing | `card` with `counter` + `text` | +| Pricing | `card` with `text` + `shape` (see the KPI card pattern above — the number is static, not counted up) | | Partner logos | `flex` row + `icon` (simple-icons:xxx) | -| CTA / call to action | `badge` + glow + `particle` confetti | +| CTA / call to action | a pill (`div`/`shape` + `icon` + `text`) + glow + confetti (`for-each` + `rand`/`sin` drift — see [rules/composition-recipes.md](rules/composition-recipes.md)) | | Hero / intro | `text` with `char_scale_in` + main `icon` (hero role: 160-200px mobile / 80-100px desktop) | -| Transition / ambiance | `particle` stars/confetti + `animated-background` | +| Transition / ambiance | confetti/stars (`for-each` + `rand`/`sin` drift) + `animated-background` | | Grouped transforms | `div` wrapping children + shared `timeline` scale/fade | The user validates or adjusts the plan before proceeding. @@ -173,9 +190,8 @@ The user validates or adjusts the plan before proceeding. 1. All font sizes meet the floor for the target device (see [rules/typography-readability.md](rules/typography-readability.md)) 2. `start_at + delay + duration ≤ scene_duration` for every animation (see [rules/animation-completion-budget.md](rules/animation-completion-budget.md)) 3. Text color contrasts correctly with the scene/card background (dark bg → white text, light bg → dark text) -4. If a `counter` is inside a card (this is fine — it centers correctly), make sure the card/parent is at least as wide as the counter's worst-case digit width, since the counter box never shrinks to fit (see [rules/counter-standalone.md](rules/counter-standalone.md)) -5. Scene duration ≥ reading time of all text (`word_count ÷ 2.5`) (see [rules/scene-pacing.md](rules/scene-pacing.md)) -6. If dynamism level ≥ 2: at least one non-text element per scene has a continuous effect (`float_3d`/`wiggle`/`orbit` with `loop: true`). Never apply continuous motion to primary text. See [rules/dynamic-depth.md](rules/dynamic-depth.md). +4. Scene duration ≥ reading time of all text (`word_count ÷ 2.5`) (see [rules/scene-pacing.md](rules/scene-pacing.md)) +5. If dynamism level ≥ 2: at least one non-text element per scene has a continuous effect (`float_3d`/`wiggle`/`orbit` with `loop: true`). Never apply continuous motion to primary text. See [rules/dynamic-depth.md](rules/dynamic-depth.md). For each scene in the validated plan: 1. Generate the JSON for the scene @@ -219,7 +235,8 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/even-dimensions.md](rules/even-dimensions.md) - Use even width/height for H.264 encoding -- [rules/counter-standalone.md](rules/counter-standalone.md) - Counter centers correctly in a card; size the parent for its worst-case digit width or it overflows silently +- [rules/composition-recipes.md](rules/composition-recipes.md) - **Read this before reaching for a UI-widget component.** Composing KPI cards, pill rows, progress bars, and other former "frozen composition" shapes from primitives, `components`, and `for-each` +- [rules/templates-and-iteration.md](rules/templates-and-iteration.md) - `for-each`/`components`/`use` mechanics: bindings, param defaults, ordering of passes, named errors - [rules/vertical-align.md](rules/vertical-align.md) - Shape text vertical_align: use "top"/"middle"/"bottom" (NOT "center") - [rules/stagger-animations.md](rules/stagger-animations.md) - Stagger animations with increasing style.animation.delay - [rules/layer-order.md](rules/layer-order.md) - Layer order matters: first in array = behind, last = front @@ -241,10 +258,6 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/video-wizard.md](rules/video-wizard.md) - Video creation wizard: iterative scene-by-scene construction best practices - [rules/responsive-device-sizing.md](rules/responsive-device-sizing.md) - CRITICAL: Scale all sizes to target device using Tailwind 4 type scale (×3 mobile, ×1.5 desktop) - [rules/chart-types.md](rules/chart-types.md) - Chart type selection guide (12 types: bar, line, area, donut, funnel, waterfall, radar, scatter, etc.) -- [rules/stat-cards.md](rules/stat-cards.md) - Stat/KPI cards best practices (trend, sparkline, dashboard layout) -- [rules/data-viz-components.md](rules/data-viz-components.md) - Data visualization component selection (gauge vs progress, sparkline vs chart, skeleton patterns) -- [rules/ui-controls.md](rules/ui-controls.md) - Switch, slider, rating: animated interactive control patterns -- [rules/notification-stacking.md](rules/notification-stacking.md) - Notification stacking: push_at, wait_for_push, variant colors - [rules/dot-map-coordinates.md](rules/dot-map-coordinates.md) - Dot map: use real lat/lng coordinates, common city reference table ### Design quality (nouvelles règles) @@ -257,7 +270,6 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/depth-layering.md](rules/depth-layering.md) - **NEW:** Visual depth — 3 planes (bg/mid/fg), z-index, blur, shadow hierarchy, scale gradient, 3D tilt - [rules/dynamic-depth.md](rules/dynamic-depth.md) - **NEW:** Multi-element parallax — wiggle seeds, float_3d preset, camera zoom, orbit phases, frequency hierarchy - [rules/component-field-placement.md](rules/component-field-placement.md) - **CRITICAL:** Field placement (root vs style) — `width`/`height`/`animation` inside `style`; `fill`/`stroke`/`timeline`/`stagger` at root; `box-shadow` as array; silently-dropped component pitfalls -- [rules/badge-video-sizing.md](rules/badge-video-sizing.md) - Badge sizing for video resolution — `badge_size` sm/md/lg is too small at 1080px; use `style.font-size` to override (40px recommended for 1080×1920) - [rules/glassmorphism.md](rules/glassmorphism.md) - Frosted-glass card recipe: `backdrop-filter: blur`, translucent background, subtle border, layered over a colorful background - [rules/audio-reactive.md](rules/audio-reactive.md) - Bind `style.audio-reactive` to an `audio` track — drives `waveform`/`audio_spectrum` and reactive scale/opacity on any component - [rules/captions-workflow.md](rules/captions-workflow.md) - Generating `caption` word timings from a transcript/audio track @@ -281,7 +293,13 @@ The two examples below are short excerpts. For full, validated, end-to-end scena | `examples/dynamic-glass.json` | 1920×1080 | 3 | Glassmorphism, `backdrop-filter`, depth layering | | `examples/rustmotion-promo.json` | 1920×1080 | 6 | Product promo pacing, stagger, char animations | | `examples/ferriskey-presentation.json` | 1920×1080 | 6 | Slide-deck style presentation, heavy char/word stagger | -| `examples/mega-showcase.json` | 1920×1080 | 9 | Largest example — grid layout, timeline component, most component types in one file | +| `examples/mega-showcase.json` | 1920×1080 | 9 | Largest example — grid layout, timeline component, most component types in one file (the catalogue-tour file; not a composition model — see below) | +| `examples/composition-kpi-row.json` | 1920×1080 | 1 | A `stat`-style KPI card built from `card`+`icon`+`shape`+`text`, defined once as a `components` entry and instantiated four times with `for-each` | +| `examples/composition-pill-row.json` | 1920×1080 | 1 | A `badge`-style pill built from `div`+`icon`+`text`, repeated with `for-each` | +| `examples/composition-progress-bars.json` | 1920×1080 | 1 | A `progress`-style bar from two primitives (a `card` track + an animated-width `shape` fill), four instances via `for-each` | +| `examples/composition-step-flow.json` | 1920×1080 | 1 | A `stepper`-style flow from `card`+`text`+`shape` connectors, each `for-each` item emitting a sibling pair (node + connector) | + +These four are the worked reference for [rules/composition-recipes.md](rules/composition-recipes.md) — read that file first when a brief calls for something that used to be one of the frozen UI-widget components. ### Example 1: Marketing Card (Portrait) @@ -369,20 +387,52 @@ The two examples below are short excerpts. For full, validated, end-to-end scena } }, { - "type": "codeblock", - "code": "fn main() {\n println!(\"Hello, world!\");\n}", - "language": "rust", - "theme": "tokyo-night", - "show_line_numbers": true, - "chrome": { "enabled": true, "title": "src/main.rs" }, - "reveal": { "mode": "typewriter", "start": 0.5, "duration": 3.0 }, - "style": { "width": 1400, "height": 400, "font-size": 22, "padding": 24, "border-radius": 16 }, - "states": [ + "type": "div", + "style": { + "flex-direction": "column", + "background": "#1a1b26", + "border-radius": 16, + "overflow": "hidden", + "width": 1400, + "height": 400, + "animation": [{ "name": "fade_in_up", "delay": 0.3, "duration": 0.5 }] + }, + "children": [ { - "code": "fn main() {\n let name = \"rustmotion\";\n println!(\"Hello, {}!\", name);\n}", - "at": 5.0, - "duration": 2.5, - "cursor": { "enabled": true, "blink": true } + "type": "div", + "style": { "flex-direction": "row", "align-items": "center", "gap": 8, "padding": { "top": 10, "right": 14, "bottom": 10, "left": 14 }, "background": "#16161e" }, + "children": [ + { "type": "shape", "shape": "circle", "fill": "#ff5f56", "style": { "width": 12, "height": 12 } }, + { "type": "shape", "shape": "circle", "fill": "#ffbd2e", "style": { "width": 12, "height": 12 } }, + { "type": "shape", "shape": "circle", "fill": "#27c93f", "style": { "width": 12, "height": 12 } }, + { "type": "text", "content": "src/main.rs", "style": { "font-size": 13, "color": "#8b949e", "margin": { "left": 8 } } } + ] + }, + { + "type": "div", + "stagger": 0.5, + "style": { "flex-direction": "column", "padding": 24, "gap": 4 }, + "children": [ + { + "type": "rich_text", + "spans": [ + { "text": "fn ", "color": "#bb9af7" }, + { "text": "main", "color": "#7aa2f7" }, + { "text": "() {", "color": "#c0caf5" } + ], + "style": { "font-family": "JetBrains Mono", "font-size": 22, "white-space": "pre", "animation": [{ "name": "typewriter", "duration": 0.5 }] } + }, + { + "type": "rich_text", + "spans": [{ "text": " println!(\"Hello, world!\");", "color": "#c0caf5" }], + "style": { "font-family": "JetBrains Mono", "font-size": 22, "white-space": "pre", "animation": [{ "name": "typewriter", "duration": 0.5 }] } + }, + { + "type": "rich_text", + "spans": [{ "text": "}", "color": "#c0caf5" }], + "style": { "font-family": "JetBrains Mono", "font-size": 22, "white-space": "pre", "animation": [{ "name": "typewriter", "duration": 0.5 }] } + } + ] } ] }, @@ -770,7 +820,12 @@ Default duration: `0.5` seconds. ### Component Types -The engine has **57** component types total (`Component` enum, `crates/rustmotion-components/src/lib.rs:194-254`). The catalog below has a dedicated write-up with a JSON example for most of them; the rest are containers (`card`/`flex`, `div`, `grid`, `positioned`) covered in the "Mental Model: Think HTML/CSS" section above, plus `waveform`/`audio_spectrum` covered in [rules/audio-reactive.md](rules/audio-reactive.md). +The engine has **53** component types total (`Component` enum, `crates/rustmotion-components/src/lib.rs`). Two of the three classes get a dedicated write-up below: + +- **Algorithms** — cannot be composed from drawing primitives, so they stay as first-class components: `qr_code`, `dot_map`, `treemap`, `lottie`, `video`, `gif`, `waveform`/`audio_spectrum` (see [rules/audio-reactive.md](rules/audio-reactive.md)), `image`. Nine total. (`codeblock` used to be here for syntax highlighting; deleted outright — see [rules/composition-recipes.md](rules/composition-recipes.md) for the `rich_text`-per-token recipe that replaces it.) +- **Primitives** — `text`, `rich_text`, `gradient_text`, `shape`, `svg`, `icon`, `line`, `arrow`, `connector`, `div`, `cursor`, `pointer` (see [rules/pointer-walkthrough.md](rules/pointer-walkthrough.md)). The container (`div` — `card`/`flex`/`grid`/`positioned`/`container` are the same type, kept as JSON aliases) is covered in the "Mental Model: Think HTML/CSS" section above. These are the building blocks for everything else — see [rules/composition-recipes.md](rules/composition-recipes.md). + +The **third class — composite UI widgets** (`stat`, `badge`, `gauge`, `sparkline`, `progress`, `counter`, `number_wheel`, `kbd`, `tooltip`, `list`, `stepper`, `comparison`, `countdown`, `pill_nav`, `avatar`, `avatar_group`, `rating`, `switch`, `slider`, `skeleton`, `tag_cloud`, `callout`, `divider`, `success_check`, `timeline`, `marquee`, `chart`, `heatmap`, `table`, `particle`, `caption`, `mockup`) still exists in the engine and still renders byte-identically — nothing described here is removed, and the JSON you write for a scenario is unaffected. Since issue #333 phase B, most of them carry a Rust-level `#[deprecated]` marker at their struct definition, naming the primitive recipe that replaces them (a signal for Rust code, not for scenario JSON); `chart`/`heatmap`/`table`/`particle`/`caption`/`mockup` joined the same class once `for-each` gained the arithmetic/rand/computed-path machinery to reproduce them (see [rules/composition-recipes.md](rules/composition-recipes.md)). It is intentionally **not catalogued below**. See "Composition over cataloguing" near the top of this document for why, and [rules/composition-recipes.md](rules/composition-recipes.md) for how to get the same result from primitives. (`notification` and `terminal` used to be members of this class; both were deleted outright rather than deprecated — see [rules/composition-recipes.md](rules/composition-recipes.md) for the `div`-based recipes that replace them.) All components are discriminated by `"type"`. Rendered in array order (first = bottom). See Rule 7. @@ -839,7 +894,7 @@ All components are discriminated by `"type"`. Rendered in array order (first = b --- -### 1. `text` +### `text` ```json { @@ -920,7 +975,7 @@ Animates each character or word independently with staggered timing. Use `char_* } ``` -### 2. `shape` +### `shape` ```json { @@ -991,7 +1046,7 @@ Types: `linear`, `radial`. `vertical_align`: `"top"`, `"middle"`, `"bottom"` (default: `"middle"`). See Rule 5. -### 3. `image` +### `image` ```json { @@ -1010,7 +1065,7 @@ Types: `linear`, `radial`. Style: `width`, `height` (default: uses image dimensions) -### 4. `svg` +### `svg` ```json { @@ -1043,7 +1098,7 @@ draws its outline first, then takes its colour. "keyframes": [{ "time": 0, "value": 0 }, { "time": 2.2, "value": 1 }] }] }] } } ``` -### 5. `icon` +### `icon` Renders an icon from the **Iconify** open-source framework (200,000+ icons from 150+ sets). Icons are fetched from the Iconify API at render time. Browse all icons: https://icon-sets.iconify.design/ @@ -1064,7 +1119,7 @@ Style: `width`, `height` (default `24`), `color` (default `"#FFFFFF"`) Common prefixes: `lucide` (UI), `mdi` (Material), `heroicons`, `ph` (Phosphor), `tabler`, `simple-icons` (brand logos), `devicon` (dev tools) -### 6. `video` +### `video` ```json { @@ -1089,7 +1144,7 @@ Common prefixes: `lucide` (UI), `mdi` (Material), `heroicons`, `ph` (Phosphor), Style: `width`, `height` (required) -### 7. `gif` +### `gif` ```json { @@ -1108,89 +1163,44 @@ Style: `width`, `height` (required) Style: `width`, `height` (default: intrinsic GIF dimensions) -### 8. `caption` - -Timed word-by-word captions with active word highlighting. - -```json -{ - "type": "caption", - "words": [ - { "text": "Hello", "start": 0.0, "end": 0.5 }, - { "text": "World", "start": 0.5, "end": 1.0 } - ], - "mode": "highlight", - "max_width": 900, - "style": { "font-size": 48, "color": "#FFFFFF" } -} -``` - -| Field | Type | Default | -| -------------- | -------- | -------------------------------------------------------------------------- | -| `words` | array | required — `[{ "text", "start", "end" }]` | -| `position` | `{x, y}` | `{0, 0}` | -| `mode` | enum | `"default"` — `"default"`, `"highlight"`, `"karaoke"`, `"bounce"` | -| `active_color` | string | `"#FFD700"` | -| `max_width` | f32 | `null` | +### `div` -Style: `font-size` (48.0), `font-family`, `color` (#FFFFFF), `background` +The one container type: a box with CSS-like flex & grid layout that lays out `children`. Decoration (`background`, `border-radius`, `border`, `box-shadow`) is entirely opt-in through `style` — set none of them for an invisible grouping wrapper (HTML `
`), or set them for a visually decorated panel. There is no separate "decorated" type: the box is the same either way, only `style` differs. -### 9. `counter` +`card`, `flex`, `grid`, `positioned`, and `container` are accepted as JSON aliases for `"type": "div"` — old scenarios using any of them keep working, and they render byte-identically to `div`, because they deserialize into the exact same component. Write new scenarios as `div`. -Animated number counter. Works fine inside a card (centers correctly) — see checklist item 4 / [rules/counter-standalone.md](rules/counter-standalone.md) for sizing the parent to its worst-case digit width. - -```json -{ - "type": "counter", - "from": 0, - "to": 1250, - "decimals": 0, - "separator": " ", - "suffix": "€", - "easing": "ease_out", - "start_at": 0.5, - "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center" } -} -``` - -`end_at` is a visibility toggle, not an animation-completion boundary — setting it on a `counter` makes the number **disappear** once that time passes, since the counter's own animation is driven by `ctx.time / scene_duration`, not by `start_at`/`end_at`. Use `start_at` only. - -**Root fields:** `from`, `to`, `decimals`, `separator`, `prefix`, `suffix`, `easing` - -**Easing options:** `linear`, `ease_in`, `ease_out`, `ease_in_out`, `ease_in_quad`, `ease_out_quad`, `ease_in_cubic`, `ease_out_cubic`, `ease_in_expo`, `ease_out_expo`, `spring` - -Style: `font-size` (48.0), `color` (#FFFFFF), `font-family` (Inter), `font-weight`, `text-align`, `letter-spacing`, `text-shadow`, `stroke` - -### 10. Absolute Positioning (via `card`) - -To place children at fixed absolute coordinates, use a `card` with transparent background and explicit size. Each child uses `position: {x, y}` relative to the card's top-left. Children with `position` become absolute inside a `card`. +Each dimension (`width`/`height` in `style`) can be a number or `"auto"`. +**Flex example** (default `display`): ```json { - "type": "card", - "style": { "width": 1920, "height": 1080, "background": "#00000000", "padding": 0 }, + "type": "div", + "style": { "width": 800, "height": 100, "flex-direction": "row", "gap": 16 }, "children": [ - { "type": "shape", "shape": "rect", "fill": "#1E293B", "position": { "x": 0, "y": 0 }, "style": { "width": 400, "height": 300, "border-radius": 16 } }, - { "type": "icon", "icon": "lucide:phone-off", "position": { "x": 170, "y": 120 }, "style": { "width": 64, "height": 64, "color": "#FFFFFF" } } + { "type": "shape", "shape": "rect", "fill": "#FF0000", "style": { "width": 100, "height": 100 } }, + { "type": "shape", "shape": "rect", "fill": "#00FF00", "style": { "width": 100, "height": 100, "flex-grow": 1 } }, + { "type": "shape", "shape": "rect", "fill": "#0000FF", "style": { "width": 100, "height": 100 } } ] } ``` -### 11. `card` / `flex` - -Visual container with CSS-like flex & grid layout. `flex` is an alias for `card`. See Rule 8. - -Each dimension (`width`/`height` in `style`) can be a number or `"auto"`. - -**Flex example:** +**Decorated panel** (same type, `style` adds a background/border-radius): ```json { - "type": "card", - "style": { "width": 800, "height": 100, "flex-direction": "row", "gap": 16 }, + "type": "div", + "style": { + "width": 800, + "height": "auto", + "flex-direction": "row", + "align-items": "center", + "gap": 16, + "padding": 24, + "background": "#1E293B", + "border-radius": 16 + }, "children": [ - { "type": "shape", "shape": "rect", "fill": "#FF0000", "style": { "width": 100, "height": 100 } }, - { "type": "shape", "shape": "rect", "fill": "#00FF00", "style": { "width": 100, "height": 100, "flex-grow": 1 } }, - { "type": "shape", "shape": "rect", "fill": "#0000FF", "style": { "width": 100, "height": 100 } } + { "type": "icon", "icon": "lucide:check-circle", "style": { "width": 48, "height": 48, "color": "#22C55E" } }, + { "type": "text", "content": "Feature enabled", "style": { "font-size": 32, "color": "#FFFFFF" } } ] } ``` @@ -1198,7 +1208,7 @@ Each dimension (`width`/`height` in `style`) can be a number or `"auto"`. **Grid example (2x2):** Note: grid containers need explicit `height` (not `"auto"`) — see [rules/grid-card-height.md](rules/grid-card-height.md). `grid-template-columns`/`grid-template-rows` is `Vec`, an **untagged** enum: a bare number means px, a quoted string like `"1fr"` carries the unit, `"auto"` is the keyword. The object forms `{"fr": N}` / `{"px": N}` shown in older docs do **not** match any variant and drop the whole component. ```json { - "type": "card", + "type": "div", "style": { "width": 600, "height": 400, @@ -1218,6 +1228,18 @@ Each dimension (`width`/`height` in `style`) can be a number or `"auto"`. } ``` +**Absolute positioning of children:** any container's children can carry `position: {x, y}` — it's a property of the child, not a special container mode. Give the container a transparent background and explicit size, and each `position`-ed child is placed relative to its top-left; children without `position` still lay out with flex/grid. +```json +{ + "type": "div", + "style": { "width": 1920, "height": 1080, "background": "#00000000", "padding": 0 }, + "children": [ + { "type": "shape", "shape": "rect", "fill": "#1E293B", "position": { "x": 0, "y": 0 }, "style": { "width": 400, "height": 300, "border-radius": 16 } }, + { "type": "icon", "icon": "lucide:phone-off", "position": { "x": 170, "y": 120 }, "style": { "width": 64, "height": 64, "color": "#FFFFFF" } } + ] +} +``` + **Style fields:** | Style field | Type | Default | @@ -1244,329 +1266,9 @@ Each dimension (`width`/`height` in `style`) can be a number or `"auto"`. - `grid-column` (object) — `{ "start": 1, "span": 2 }` (1-indexed) - `grid-row` (object) — `{ "start": 1, "span": 2 }` (1-indexed) -`position: "absolute"` (root field, sibling of `style`) works on a child of **any** container — `card`, `div`, `grid`, `positioned`, or the scene root — not just inside `positioned`. `positioned` is simply a semantic Stack-like container with no visual decoration; it does not unlock `position` — every container already supports it. Children without `position` are laid out using flex/grid style properties. - -### 12. `div` - -Invisible flex wrapper — groupe des enfants pour un layout pur ou des transforms partagés. Comme `card`/`flex` mais **sans background, border, shadow, ni clipping**. Équivalent de `
` en HTML. - -Utiliser `div` quand il faut grouper des éléments sans décoration visuelle (ex: grille de cards, ligne d'icônes, animation partagée sur un groupe). - -```json -{ - "type": "div", - "style": { - "flex-direction": "column", - "align-items": "center", - "gap": 36 - }, - "timeline": [ - { "at": 3.5, "animation": [{ "name": "keyframes", "keyframes": [ - { "property": "scale", "keyframes": [{ "time": 0, "value": 1 }, { "time": 0.8, "value": 4 }], "easing": "ease_in" }, - { "property": "opacity", "keyframes": [{ "time": 0, "value": 1 }, { "time": 0.7, "value": 0 }], "easing": "ease_in" } - ]}]} - ], - "children": [ - { "type": "icon", "icon": "lucide:zap", "style": { "width": 80, "height": 80, "color": "#25D366" } }, - { "type": "text", "content": "Grouped content", "style": { "font-size": 48, "color": "#FFFFFF" } } - ] -} -``` - -`timeline` and `stagger` are **root fields**, not `style` — `CssStyle` has no `timeline` key and `deny_unknown_fields` drops the whole component if you nest it there. Supporte toutes les propriétés CSS flex/grid (`flex-direction`, `align-items`, `justify-content`, `gap`, `padding`, `display: "grid"`, `grid-template-columns`) dans `style`, plus `timeline`/`stagger` au niveau racine. Préférer `div` à `card` avec fond transparent pour tout layout sans styling visuel. - -### 12. `codeblock` - -Code block with syntax highlighting, chrome, reveal animations, and animated diff transitions. - -```json -{ - "type": "codeblock", - "code": "fn main() {\n println!(\"Hello\");\n}", - "language": "rust", - "theme": "base16-ocean.dark", - "show_line_numbers": true, - "chrome": { "enabled": true, "title": "main.rs" }, - "reveal": { "mode": "typewriter", "start": 0, "duration": 2.5 }, - "style": { "font-size": 18, "border-radius": 12, "padding": 16 }, - "states": [ - { - "code": "fn main() {\n println!(\"Hello, world!\");\n}", - "at": 5.0, - "duration": 2.0, - "cursor": { "enabled": true } - } - ] -} -``` - -**Root fields:** `code` (required), `language`, `theme`, `show_line_numbers`, `chrome`, `highlights`, `reveal`, `states`, `diff` (bool — enables diff mode: lines starting with `+` get green background, `-` get red background), `auto_scroll` (bool, default `true` — when content overflows the box vertically, scrolls so the last revealed line stays visible; font is never reduced. See [rules/geometry-safety.md](rules/geometry-safety.md)) - -Style: `width`, `height` (set to constrain the visible area; content scrolls if it overflows vertically when `auto_scroll: true`) - -**Diff mode example:** -```json -{ - "type": "codeblock", - "language": "diff", - "diff": true, - "code": " fn render() {\n- let old = bar();\n+ let new = donut();\n }", - "chrome": { "enabled": true, "title": "changes.rs" } -} -``` - -| Style field | Type | Default | -| --------------- | ------ | -------------------- | -| `font-family` | string | `"JetBrains Mono"` | -| `font-size` | f32 | `14.0` | -| `font-weight` | enum | `"normal"` | -| `line-height` | f32 | `1.5` (multiplier) | -| `background` | string | `null` (uses theme) | -| `border-radius` | f32 | `12.0` | -| `padding` | f32 or obj | `16` | - -**Available themes (72):** `base16-ocean.dark`, `base16-ocean.light`, `base16-eighties.dark`, `base16-mocha.dark`, `InspiredGitHub`, `Solarized (dark)`, `Solarized (light)`, `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`, `catppuccin-mocha`, `andromeeda`, `aurora-x`, `ayu-dark`, `ayu-light`, `ayu-mirage`, `dark-plus`, `dracula`, `dracula-soft`, `everforest-dark`, `everforest-light`, `github-dark`, `github-dark-default`, `github-dark-dimmed`, `github-dark-high-contrast`, `github-light`, `github-light-default`, `github-light-high-contrast`, `gruvbox-dark-hard`, `gruvbox-dark-medium`, `gruvbox-dark-soft`, `gruvbox-light-hard`, `gruvbox-light-medium`, `gruvbox-light-soft`, `horizon`, `horizon-bright`, `houston`, `kanagawa-dragon`, `kanagawa-lotus`, `kanagawa-wave`, `laserwave`, `light-plus`, `material-theme`, `material-theme-darker`, `material-theme-lighter`, `material-theme-ocean`, `material-theme-palenight`, `min-dark`, `min-light`, `monokai`, `night-owl`, `night-owl-light`, `nord`, `one-dark-pro`, `one-light`, `plastic`, `poimandres`, `red`, `rose-pine`, `rose-pine-dawn`, `rose-pine-moon`, `slack-dark`, `slack-ochin`, `snazzy-light`, `solarized-dark`, `solarized-light`, `synthwave-84`, `tokyo-night`, `vesper`, `vitesse-black`, `vitesse-dark`, `vitesse-light` - -### 13. `divider` - -Visual separator line. - -```json -{ - "type": "divider", - "direction": "horizontal", - "thickness": 2, - "line_style": "solid", - "style": { "color": "#4B5563" } -} -``` - -**Root fields:** `direction` (horizontal/vertical), `thickness` (default 2.0), `line_style` (solid/dashed/dotted), `length` (optional fixed length) - -Style: `color` (default `"#FFFFFF"`) - -### 14. `badge` - -Compact pill-shaped label with optional icon, dot indicator, pulse animation, and count badge. - -```json -{ - "type": "badge", - "text": "Messages", - "icon": "lucide:mail", - "variant": "solid", - "badge_size": "lg", - "dot": true, - "dot_color": "#22C55E", - "pulse": true, - "count": 12, - "style": { "background": "#3B82F6" } -} -``` - -**Root fields:** `text` (required), `icon` (Iconify id), `variant` (solid/outline), `badge_size` (sm/md/lg), `dot` (bool — colored dot top-right), `dot_color` (hex, defaults to badge color), `pulse` (bool — animated pulse ring on dot), `count` (u32 — red count badge top-right, caps at "99+") - -Style: `background` (default `"#3B82F6"`) — badge color, `font-size`, `font-family` - -### 15. `avatar` - -Circular image with optional border and status indicator. - -```json -{ - "type": "avatar", - "src": "photo.jpg", - "size": 80, - "border_color": "#3B82F6", - "border_width": 3, - "status": "online" -} -``` - -**Root fields:** `src` (required), `size` (diameter, default 64), `border_color`, `border_width`, `status` (online/offline/away/none), `status_color` - -### 16. `callout` - -Speech bubble with directional arrow. - -```json -{ - "type": "callout", - "text": "Hello!", - "arrow_direction": "bottom", - "arrow_size": 12, - "style": { "background": "#333333", "color": "#FFFFFF", "border-radius": 8, "font-size": 16 } -} -``` - -**Root fields:** `text` (required), `arrow_direction` (top/bottom/left/right), `arrow_size` (default 12), `size` - -Style: `background` (default `"#333333"`), `color` (default `"#FFFFFF"`), `border-radius` (default 8), `font-size` (default 16), `font-family` - -### 17. `terminal` - -Terminal window with colored lines and chrome. - -```json -{ - "type": "terminal", - "title": "Terminal", - "theme": "dark", - "reveal": { "mode": "typewriter", "start": 0.5, "duration": 3.0 }, - "lines": [ - { "text": "npm install", "line_type": "prompt" }, - { "text": "added 42 packages", "line_type": "output" } - ], - "style": { "width": 600, "height": 300 } -} -``` - -**Root fields:** `lines` (required — `[{ "text", "line_type", "color" }]`), `theme` (dark/light), `title`, `show_chrome` (default true), `reveal`, `auto_scroll` (bool, default `true` — vertical scroll when content > box, font never shrinks. See [rules/geometry-safety.md](rules/geometry-safety.md)) - -Style: `width`, `height` (set to constrain the visible area) - -**Reveal:** `{ "mode": "typewriter"|"line_by_line", "start": 0, "duration": 1.0, "easing": "linear" }` — animates line/word appearance. In typewriter mode, a blinking cursor appears at the typing position. - -Line types: `"prompt"` ($ prefix in green), `"command"` (white), `"output"` (gray) - -Style: `font-size` (default 14) - -### 18. `table` - -Data table with headers, styled rows, configurable column widths and alignment. - -```json -{ - "type": "table", - "headers": ["Metric", "Value", "Change"], - "rows": [["Revenue", "1.2M", "+24%"], ["Users", "45K", "+12%"]], - "column_widths": [300, 200, 150], - "column_align": ["left", "right", "right"], - "cell_padding": 20, - "show_borders": true, - "style": { "width": 650, "height": 150, "color": "#E2E8F0", "font-size": 15, "border-radius": 12 } -} -``` - -**Root fields:** `headers` (required), `rows` (required), `header_color` (#374151), `row_colors` (alternating array), `border_color` (#4B5563), `header_text_color`, `column_widths` (array of f32 — explicit pixel widths per column), `column_align` (array — `"left"` / `"center"` / `"right"` per column), `cell_padding` (f32, default 12), `show_borders` (bool, default true) - -Style: `color` (default `"#FFFFFF"`) — cell text color, `font-size` (default 14), `font-family`, `border-radius` - -### 19. `chart` - -Data visualization with animation. Supports 12 chart types. - -```json -{ - "type": "chart", - "chart_type": "area", - "data": [ - { "value": 10, "label": "Jan" }, - { "value": 25, "label": "Feb" }, - { "value": 18, "label": "Mar" }, - { "value": 42, "label": "Apr" } - ], - "smooth": true, - "fill_opacity": 0.3, - "show_grid": true, - "show_x_labels": true, - "show_y_labels": true, - "style": { "width": 600, "height": 300 } -} -``` - -**Chart types:** `bar`, `line`, `pie`, `donut`, `horizontal_bar`, `area`, `stacked_bar`, `radar`, `scatter`, `radial_bar`, `funnel`, `waterfall` - -**Root fields:** `chart_type` (required), `data` (`[{ "value", "label"?, "color"? }]`), `animated` (default true), `animation_duration` (default 1.5s), `colors` (custom palette) - -Style: `width`, `height` — **required**. `chart` has no intrinsic sizing (no fallback in the engine — verified against `box_builder.rs`'s `component_intrinsic`/`apply_intrinsic_overrides`); omit them in a flex/grid container and the chart lays out at 0×0 and renders nothing. - -**Axes & grid (bar, line, area, stacked_bar, scatter, waterfall):** `show_grid`, `show_x_labels`, `show_y_labels`, `grid_color` (#FFFFFF15), `label_color` (#888888), `label_font_size` (12) - -**Type-specific fields:** - -| Field | Chart Types | Default | Description | -| --- | --- | --- | --- | -| `inner_radius` | donut | `0.6` | Hole size ratio (0.1–0.95) | -| `fill_opacity` | area | `0.3` | Gradient fill opacity | -| `smooth` | area | `false` | Catmull-Rom spline smoothing | -| `show_labels` | horizontal_bar, funnel | `false` | Labels inside bars/segments | -| `direction` | funnel | `"vertical"` | `"vertical"` or `"horizontal"` | -| `categories` | stacked_bar | `[]` | X-axis category names | -| `series` | stacked_bar | `[]` | `[{ "name", "data": [f64], "color"? }]` | -| `axes` | radar | `[]` | Axis labels | -| `radar_data` | radar | `[]` | `[{ "values": [f64], "color"? }]` | -| `points` | scatter | `[]` | `[{ "x", "y", "size"?, "color"? }]` | - -**Stacked bar example:** -```json -{ - "type": "chart", - "chart_type": "stacked_bar", - "categories": ["Q1", "Q2", "Q3", "Q4"], - "series": [ - { "name": "Product A", "data": [30, 40, 35, 50], "color": "#3B82F6" }, - { "name": "Product B", "data": [20, 15, 25, 30], "color": "#22C55E" } - ], - "show_grid": true, "show_x_labels": true, "show_y_labels": true -} -``` - -**Funnel example (horizontal):** -```json -{ - "type": "chart", - "chart_type": "funnel", - "direction": "horizontal", - "data": [ - { "value": 10000, "label": "Visitors", "color": "#3B82F6" }, - { "value": 6500, "label": "Leads", "color": "#6366F1" }, - { "value": 3200, "label": "Qualified", "color": "#8B5CF6" } - ], - "show_labels": true -} -``` - -**Waterfall** uses green for positive values, red for negative, with dashed connectors between bars. - -Default palette: `#3B82F6`, `#EF4444`, `#22C55E`, `#F59E0B`, `#8B5CF6`, `#EC4899`, `#06B6D4`, `#F97316` - -### 20. `mockup` - -Device frame with image content inside. - -```json -{ - "type": "mockup", - "device": "iphone", - "src": "screenshot.png", - "theme": "dark" -} -``` - -**Root fields:** `device` (required — iphone/android/laptop/browser), `src` (required — path to image), `theme` (dark/light), `size` +`timeline` and `stagger` are **root fields**, not `style` — `CssStyle` has no `timeline` key and `deny_unknown_fields` drops the whole component if you nest it there. -Default sizes: iPhone 375x812, Android 360x800, Laptop 800x550, Browser 800x600 - -### 21. `particle` - -Animated particle system for visual effects. - -```json -{ - "type": "particle", - "particle_type": "confetti", - "count": 80, - "speed": 1.2, - "seed": 42 -} -``` - -**Root fields:** `particle_type` (required — confetti/snow/stars/bubbles/halo), `count` (default 50), `colors`, `speed` (default 1.0), `size_range` ({min, max}, default {4, 12}), `seed` (default 42) - -Behaviors: confetti=falling rotating rects, snow=falling circles, stars=twinkling fixed positions, bubbles=rising circles, halo=soft glowing circles drifting with pulsing opacity (use larger size_range like {30, 80} and low count ~10-15) - -### 22. `arrow` +### `arrow` Directional arrow with optional bezier curves. Supports `draw_in` / `stroke_reveal` animation presets. @@ -1602,7 +1304,7 @@ Directional arrow with optional bezier curves. Supports `draw_in` / `stroke_reve | `arrow_size` | f32 | `12.0` | Arrowhead size | | `dashed` | array of f32 | `null` | Dash pattern (e.g. `[8, 4]`) | -### 23. `connector` +### `connector` Connects two points with automatic routing (straight, curved, or elbow). Useful for diagrams and flowcharts. @@ -1634,50 +1336,7 @@ Connects two points with automatic routing (straight, curved, or elbow). Useful | `arrow_size` | f32 | `10.0` | Arrowhead size | | `dashed` | array of f32 | `null` | Dash pattern (e.g. `[6, 3]`) | -### 24. `timeline` - -Step-by-step timeline with animated progress bar, node icons, and labels. - -```json -{ - "type": "timeline", - "width": 800, - "direction": "horizontal", - "fill_progress": 0.75, - "bar_fill_color": "#58A6FF", - "steps": [ - { "label": "Design", "sublabel": "Week 1", "color": "#58A6FF", "icon": "1" }, - { "label": "Build", "sublabel": "Week 2-3", "color": "#58A6FF", "icon": "2" }, - { "label": "Test", "sublabel": "Week 4", "color": "#58A6FF", "icon": "3" }, - { "label": "Ship", "sublabel": "Week 5", "color": "#22C55E", "icon": "🚀" } - ] -} -``` - -| Field | Type | Default | Description | -| ---------------- | ------ | ------------ | --------------------------------------------------- | -| `steps` | array | required | `[{ "label", "sublabel"?, "color"?, "icon"? }]` | -| `width` | f32 | `800.0` | Total timeline width | -| `direction` | enum | `"horizontal"` | `"horizontal"` or `"vertical"` | -| `node_radius` | f32 | `24.0` | Radius of step circles | -| `bar_color` | string | `"#333333"` | Background bar color | -| `bar_fill_color` | string | `"#58A6FF"` | Filled bar color | -| `bar_height` | f32 | `4.0` | Bar thickness | -| `fill_progress` | f32 | `1.0` | Progress from 0.0 to 1.0 (animatable) | -| `font_size` | f32 | `16.0` | Label font size | -| `label_color` | string | `"#FFFFFF"` | Label text color | -| `sublabel_color` | string | `"#8B949E"` | Sublabel text color | - -**Step fields:** - -| Field | Type | Default | Description | -| ---------- | ------ | ----------- | ------------------------------------ | -| `label` | string | required | Step label text | -| `sublabel` | string | `null` | Secondary label below/right of label | -| `color` | string | `"#58A6FF"` | Node fill color when active | -| `icon` | string | `null` | Emoji or single character in node | - -### 25. `lottie` +### `lottie` Renders Lottie animations from pre-rendered PNG frame sequences. Requires frames to be pre-generated externally. @@ -1704,7 +1363,7 @@ Style: `width`, `height` (default: Lottie intrinsic size) **Generating frames:** Use tools like `npx lottie-to-frames animation.json --output frames/` or puppeteer/lottie-web to pre-render Lottie frames as numbered PNGs. -### 26. `cursor` +### `cursor` Animated cursor with click effects, blinking, and path animation between waypoints. @@ -1755,7 +1414,7 @@ Animated cursor with click effects, blinking, and path animation between waypoin **Notes:** When `auto_path` is set, click animations trigger automatically at each waypoint time. Cursor movement uses Catmull-Rom spline interpolation for smooth curves. -### 27. `line` +### `line` Simple line from (x1, y1) to (x2, y2). Supports `draw_in` / `stroke_reveal` animation. @@ -1782,7 +1441,7 @@ Simple line from (x1, y1) to (x2, y2). Supports `draw_in` / `stroke_reveal` anim | `color` | string | `"#FFFFFF"` | Line color | | `dashed`| array of f32 | `null` | Dash pattern (e.g. `[8, 4]`) | -### 28. `rich_text` +### `rich_text` Multi-styled text with individually styled spans on the same line. Inherits defaults from the component's `style`. @@ -1815,240 +1474,7 @@ Multi-styled text with individually styled spans on the same line. Inherits defa | `font-style` | enum | inherited | `"normal"`, `"italic"`, `"oblique"` | | `letter-spacing`| f32 | inherited | Letter spacing | -### 29. `progress` - -Progress bar with linear (default) or circular variant. - -```json -{ - "type": "progress", - "progress": 0.75, - "variant": "circular", - "width": 120, - "height": 120, - "fill_color": "#3B82F6", - "background_color": "#1E293B", - "track_width": 8, - "show_value": true -} -``` - -**Root fields:** `progress` (0.0–1.0), `variant` (`"linear"` or `"circular"`), `width` (default 300), `height` (default 20 linear / same as width circular), `fill_color` (#4CAF50), `background_color` (#333333), `border_radius` (linear only), `track_width` (circular only, default 8), `show_value` (circular only — shows percentage text) - -### 30. `gauge` - -Semi-circular arc gauge for KPIs and dashboards. - -```json -{ - "type": "gauge", - "value": 72, - "max": 100, - "label": "Performance", - "fill_color": "#3B82F6", - "track_color": "#1E293B", - "track_width": 16, - "show_value": true, - "style": { "width": 200, "height": 140 } -} -``` - -**Root fields:** `value` (required), `min` (0), `max` (100), `label`, `fill_color` (#3B82F6), `track_color` (#333333), `track_width` (16), `start_angle` (135), `end_angle` (405), `show_value` (true), `animated` (true), `animation_duration` (1.5s) - -Style: `width`, `height` — **required**, same as `chart`: `gauge` has no intrinsic sizing; without explicit dimensions it lays out at 0×0. - -### 31. `sparkline` - -Mini inline chart without axes — ideal inside cards next to counters. - -```json -{ - "type": "sparkline", - "data": [5, 12, 8, 20, 15, 25, 18, 30], - "color": "#22C55E", - "fill": true, - "fill_opacity": 0.2, - "stroke_width": 2, - "style": { "width": 120, "height": 40 } -} -``` - -**Root fields:** `data` (required — array of f64), `color` (#22C55E), `fill` (false — gradient fill under line), `fill_opacity` (0.2), `stroke_width` (2.0), `animated` (true), `animation_duration` (1.0s) - -Style: `width`, `height` — **required**: `sparkline` has no intrinsic sizing (confirmed empirically — omitted, it renders zero pixels); always set explicit dimensions, e.g. `120×40`. - -### 32. `stat` - -Composite KPI card: value + label + trend arrow + sparkline. - -```json -{ - "type": "stat", - "value": "45.2K", - "label": "Active Users", - "trend": { "value": "+12.5%", "direction": "up" }, - "sparkline_data": [20, 25, 22, 30, 28, 35, 32, 40, 38, 45], - "sparkline_color": "#22C55E", - "style": { "width": 280, "height": 180, "background": "#1E293B", "border-radius": 16 } -} -``` - -**Root fields:** `value` (required — display string), `label`, `trend` (`{ "value": string, "direction": "up"/"down"/"neutral", "color"? }`), `sparkline_data` (array of f64), `sparkline_color`, `value_font_size` (48), `label_font_size` (14), `value_color` (#FFFFFF), `label_color` (#94A3B8) - -Style: `width`, `height` — **required**: `stat` has no intrinsic sizing. Verified empirically — three `stat`s in a flex-row card with no explicit `width`/`height` render **zero pixels** (all three collapse to 0×0). Always set explicit dimensions, e.g. `280×180`. See [rules/stat-cards.md](rules/stat-cards.md). - -Trend uses `lucide:trending-up` / `lucide:trending-down` icons. Direction `"down"` with a positive connotation (e.g. churn decreasing) can use `"color": "#22C55E"` to override the default red. - -### 33. `skeleton` - -Loading placeholder with animated shimmer effect. Three variants for different content types. - -```json -{ - "type": "skeleton", - "variant": "text", - "lines": 3, - "style": { "width": 300, "height": 68 } -} -``` - -**Root fields:** `variant` (`"rectangle"` / `"circle"` / `"text"`), `base_color` (#1E293B), `shimmer_color` (#334155), `border_radius` (8), `speed` (1.5 — shimmer cycle duration), `lines` (3 — text variant only), `line_height` (16), `line_gap` (12) - -Style: `width`, `height` (default: rectangle 200×40, circle 48×48, text auto-computed from lines) - -Default sizes: rectangle 200x40, circle 48x48, text auto-computed from lines. - -### 34. `kbd` - -Visual keyboard key — for documenting shortcuts. - -```json -{ - "type": "kbd", - "key": "Cmd" -} -``` - -**Root fields:** `key` (required — text displayed), `font_size` (14), `background_color` (#1E293B), `border_color` (#475569), `text_color` (#E2E8F0) - -Auto-sizes based on text content. Has a 3D depth effect (shadow below). Uses monospace font. Style overrides: `background`, `color`, `font-size`. - -### 35. `tooltip` - -Floating label with directional arrow — for annotations and callouts. - -```json -{ - "type": "tooltip", - "text": "Click to expand", - "arrow": "bottom", - "background_color": "#1E293B", - "text_color": "#E2E8F0", - "border_color": "#334155" -} -``` - -**Root fields:** `text` (required), `arrow` (`"top"` / `"bottom"` / `"left"` / `"right"` / `"none"`, default `"bottom"`), `font_size` (13), `background_color` (#1E293B), `text_color` (#E2E8F0), `arrow_size` (8), `border_color` (optional) - -Style overrides: `background`, `color`, `font-size`, `border-radius` (8). - -### 36. `marquee` - -Continuous scrolling text — for tickers, breaking news, or decorative text bands. - -```json -{ - "type": "marquee", - "content": "Breaking news — rustmotion 2.0 released!", - "speed": 100, - "direction": "left", - "font_size": 24, - "color": "#3B82F6", - "style": { "width": 800, "height": 48 } -} -``` - -**Root fields:** `content` (required), `speed` (100 — pixels/second), `direction` (`"left"` / `"right"`), `font_size` (24), `color` (#FFFFFF), `separator` (spacing between repeats, default 5 spaces) - -Style: `width`, `height` — **required**: `marquee` has no intrinsic sizing; always set explicit dimensions (it's exempt from viewport-overflow checks since its role is to bleed, but it still needs a box to scroll within). - -### 37. `avatar_group` - -Stacked circular avatars with overlap and "+N" overflow badge. - -```json -{ - "type": "avatar_group", - "avatars": [ - { "src": "user1.png" }, - { "src": "user2.png" }, - { "src": "user3.png" }, - { "src": "user4.png" }, - { "src": "user5.png" } - ], - "max_display": 3, - "overlap": 16, - "size": 48 -} -``` - -**Root fields:** `avatars` (required — `[{ "src": string }]`), `max_display` (optional — limit visible avatars), `size` (48 — diameter), `overlap` (16 — px overlap between avatars), `border_width` (3), `border_color` (#0f172a — ring color, match your background) - -### 38. `switch` - -Animated toggle switch that flips state at a configurable time. - -```json -{ - "type": "switch", - "value": false, - "toggle_at": 1.5, - "label": "Dark Mode", - "width": 52, - "height": 28, - "track_color_on": "#4CAF50", - "track_color_off": "#CCCCCC" -} -``` - -**Root fields:** `value` (bool, default false), `toggle_at` (time to flip), `label`, `width` (52), `height` (28), `track_color_on` (#4CAF50), `track_color_off` (#CCCCCC), `thumb_color` (#FFFFFF), `transition_duration` (0.3) - -### 39. `slider` - -Horizontal slider that animates to a target value. - -```json -{ - "type": "slider", - "value": 0.3, - "animate_to": 0.85, - "animate_at": 1.0, - "animation_duration": 2.0, - "width": 300, - "fill_color": "#3B82F6", - "show_value": true -} -``` - -**Root fields:** `value` (0.0–1.0), `animate_to`, `animate_at` (time to start), `animation_duration` (1.0), `width` (300), `height` (8), `track_color` (#333333), `fill_color` (#3B82F6), `thumb_size` (20), `thumb_color` (#FFFFFF), `show_value` (false) - -### 40. `rating` - -Star rating display with animated fill. - -```json -{ - "type": "rating", - "value": 4.5, - "max": 5, - "size": 32, - "filled_color": "#F59E0B" -} -``` - -**Root fields:** `value` (f64), `max` (5), `size` (32 — star diameter), `gap` (4), `filled_color` (#F59E0B), `empty_color` (#374151), `animated` (true), `animation_duration` (1.0) - -### 41. `gradient_text` +### `gradient_text` Text with animated gradient fill. @@ -2068,163 +1494,7 @@ Text with animated gradient fill. Style: `font-size`, `font-weight`, `font-family` -### 42. `list` - -Feature list with bullet, numbered, or checklist items. - -```json -{ - "type": "list", - "items": [ - { "text": "Unlimited projects", "icon": "lucide:check" }, - { "text": "Priority support", "icon": "lucide:check" }, - { "text": "Advanced analytics", "icon": "lucide:x" } - ], - "variant": "checklist", - "icon_color": "#22C55E", - "unchecked_color": "#EF4444", - "gap": 16, - "width": 400, - "style": { "font-size": 18, "color": "#E2E8F0" } -} -``` - -**Root fields:** `items` (required — `[{ "text", "icon"?, "checked"? }]`), `variant` (`"bullet"` / `"numbered"` / `"checklist"`), `gap` (16), `icon_size` (20), `icon_color` (#22C55E), `unchecked_color` (#6B7280), `width` (400) - -### 43. `pill_nav` - -Horizontal tab navigation with animated pill indicator. - -```json -{ - "type": "pill_nav", - "items": ["Overview", "Analytics", "Settings"], - "active_index": 0, - "transitions": [ - { "to": 1, "at": 2.0 }, - { "to": 2, "at": 4.0 } - ], - "pill_color": "#3B82F6", - "height": 44 -} -``` - -**Root fields:** `items` (required — array of strings), `active_index` (0), `transitions` (`[{ "to": u32, "at": f64 }]`), `pill_color` (#3B82F6), `text_color` (#FFFFFF), `inactive_text_color` (#9CA3AF), `background_color` (#1E293B), `height` (44), `border_radius` (22), `gap` (4), `transition_duration` (0.3) - -### 44. `notification` - -Toast notification with fade-in/out and stack push animation. - -```json -{ - "type": "notification", - "title": "Deployment Complete", - "message": "v2.4.1 deployed to production", - "variant": "success", - "width": 380, - "slide_in_at": 0.8, - "slide_out_at": 4.5, - "push_at": [1.5], - "position": "absolute", - "x": 500, - "y": 100 -} -``` - -**Root fields:** `title` (required), `message`, `icon` (Iconify id), `variant` (info/success/warning/error), `width` (360), `slide_in_at` (0.5 — fade-in time), `slide_out_at` (fade-out time), `slide_duration` (0.15 — fade speed), `accent_color` (override variant color), `push_at` (array of timestamps — when to push down one slot), `stack_gap` (12), `wait_for_push` (bool — delay fade-in until push animation finishes) - -**Stacking notifications:** Place all at the same x/y. The first notification gets `push_at: [1.5]` (time when second appears). The second gets `wait_for_push: true`. This makes the first slide down, then the second fades in above it. - -### 45. `stepper` - -Step indicator with connected nodes and animated progression. - -```json -{ - "type": "stepper", - "steps": [ - { "label": "Sign Up" }, - { "label": "Configure" }, - { "label": "Deploy" } - ], - "active_step": 0, - "animate_to": 2, - "animate_at": 1.0, - "style": { "width": 600, "height": 80 } -} -``` - -**Root fields:** `steps` (required — `[{ "label", "description"? }]`), `active_step` (0), `animate_to` (target step), `animate_at` (time), `transition_duration` (0.5), `orientation` ("horizontal"), `active_color` (#3B82F6), `completed_color` (#22C55E), `pending_color` (#6B7280), `node_size` (32) - -Style: `width`, `height` - -### 46. `comparison` - -Before/after split view with animated divider. - -```json -{ - "type": "comparison", - "left_color": "#1E293B", - "right_color": "#3B82F6", - "left_label": "Before", - "right_label": "After", - "divider_position": 0.5, - "animate_from": 0.2, - "animate_to": 0.8, - "animate_at": 1.0, - "animation_duration": 2.0, - "border_radius": 16, - "style": { "width": 600, "height": 300 } -} -``` - -**Root fields:** `left_color`, `right_color`, `left_label`, `right_label`, `divider_position` (0.5), `animate_from`, `animate_to`, `animate_at`, `animation_duration` (2.0), `divider_color` (#FFFFFF), `divider_width` (3), `border_radius` (12) - -Style: `width`, `height` - -### 47. `countdown` - -Digital countdown timer with flip-clock style digit boxes. - -```json -{ - "type": "countdown", - "seconds": 3723, - "digit_size": 48, - "digit_color": "#FFFFFF", - "digit_background": "#1E293B", - "style": { "width": 400, "height": 80 } -} -``` - -**Root fields:** `seconds` (total countdown, counts down from ctx.time), `show_hours` (true), `show_minutes` (true), `show_seconds` (true), `digit_size` (64), `digit_color` (#FFFFFF), `digit_background` (#1E293B), `separator_color` (#6B7280), `gap` (12), `border_radius` (12) - -Style: `width`, `height` - -### 48. `heatmap` - -Grid of colored cells (GitHub contribution style). - -```json -{ - "type": "heatmap", - "data": [ - [0.1, 0.5, 0.9, 0.3, 0.7], - [0.4, 0.8, 0.2, 0.6, 0.5] - ], - "cell_size": 20, - "cell_gap": 3, - "cell_radius": 4, - "style": { "width": 400, "height": 200 } -} -``` - -**Root fields:** `data` (required — 2D array of f64, values 0.0–1.0), `color_scale` (array of hex, default GitHub green scale), `cell_size` (14), `cell_gap` (3), `cell_radius` (2), `animated` (true), `animation_duration` (1.5) - -Style: `width`, `height` - -### 49. `treemap` +### `treemap` Space-filling rectangles proportional to values. @@ -2247,29 +1517,7 @@ Space-filling rectangles proportional to values. Style: `width`, `height` -### 50. `tag_cloud` - -Word cloud with weighted font sizes. - -```json -{ - "type": "tag_cloud", - "tags": [ - { "text": "Rust", "weight": 10 }, - { "text": "TypeScript", "weight": 8 }, - { "text": "Python", "weight": 7 } - ], - "min_font_size": 14, - "max_font_size": 64, - "style": { "width": 500, "height": 300 } -} -``` - -**Root fields:** `tags` (required — `[{ "text", "weight", "color"? }]`), `min_font_size` (14), `max_font_size` (64), `colors` (custom palette), `animated` (true), `animation_duration` (1.5) - -Style: `width`, `height` - -### 51. `dot_map` +### `dot_map` World map in dot-pattern with data points at geographic coordinates. @@ -2295,7 +1543,7 @@ Style: `width`, `height` Points use real geographic coordinates (lat/lng). The world map is rendered as a dot grid using a 180×90 land bitmap. Points with `pulse: true` show expanding concentric rings. -### 52. `qr_code` +### `qr_code` Renders a scannable QR code from arbitrary content (URL, text, etc.). @@ -2709,5 +1957,4 @@ Before presenting a generated scenario to the user, verify: - [ ] `concentric_circles` animated-background on at least 4 scenes for visual depth - [ ] No `end_at` on counters (makes them disappear — use `start_at` only) - [ ] No text uses `style.white-space: "nowrap"`/`"pre"` unless a finite `max-width` keeps it inside the viewport (use `marquee` for intentional bleeding) — see [rules/geometry-safety.md](rules/geometry-safety.md) -- [ ] Long codeblocks/terminals leave `auto_scroll` at its default (`true`) — never set `false` unless content is guaranteed to fit - [ ] `rustmotion validate -f scenario.json` passes (zero schema **and** geometry violations) before presenting diff --git a/crates/rustmotion/skills/rules/badge-video-sizing.md b/crates/rustmotion/skills/rules/badge-video-sizing.md deleted file mode 100644 index 3a6ef2f0..00000000 --- a/crates/rustmotion/skills/rules/badge-video-sizing.md +++ /dev/null @@ -1,45 +0,0 @@ -# Rule: Badge Sizing for Video Resolution - -`badge_size` (sm/md/lg) is designed for UI screen pixels. At video resolutions (1080×1920), all three sizes appear tiny. Use `style.font-size` to override. - -## Size reference - -| badge_size | font-size | Appears at 1080px | -|---|---|---| -| `sm` | 14px | Unreadable dot | -| `md` | 18px | Tiny pill | -| `lg` | 22px | Still small | -| `style.font-size: 32` | 32px | Readable on 1080px | -| `style.font-size: 40` | 40px | **Recommended for 1080×1920** | -| `style.font-size: 52` | 52px | Hero / large emphasis | - -`style.font-size` overrides `badge_size`'s font. Padding and icon size scale proportionally via `resolved_params()`. - -## Template — 1080×1920 badge - -```json -{ - "type": "badge", - "text": "Premier plan", - "icon": "lucide:zap", - "color": "#6366F1", - "position": "absolute", - "x": 270, - "y": 580, - "style": { - "font-size": 40, - "z-index": 2, - "box-shadow": [{ "color": "#6366F1A0", "offset-y": 0, "blur": 60 }], - "animation": [ - { "name": "scale_in", "delay": 0.4, "duration": 0.5 }, - { "name": "wiggle", "property": "translate_y", "amplitude": 11, "frequency": 1.2, "seed": 42 } - ] - } -} -``` - -## Positioning for portrait 1080×1920 - -The badge is typically placed above a card. If the card is vertically centered (~y=710 for a 500px-tall card), place the badge at `y = card_top - badge_height - gap`. With `font-size: 40`, badge height ≈ 80px. Example: card at y=720 → badge at y=620. - -For a badge that spans most of the card width, place `x` so the badge pill center aligns with the card center (x = 1080/2 - badge_width/2). With font-size 40 and ~10 char text, width ≈ 280px → x ≈ 400. diff --git a/crates/rustmotion/skills/rules/card-flex-layout.md b/crates/rustmotion/skills/rules/card-flex-layout.md index c2908579..14e35646 100644 --- a/crates/rustmotion/skills/rules/card-flex-layout.md +++ b/crates/rustmotion/skills/rules/card-flex-layout.md @@ -1,6 +1,6 @@ -# Rule: Use card/flex/div for Layout +# Rule: Use `div` for Layout -`card` and `flex` (alias for `card`) use a CSS flexbox engine that auto-positions children. Use `card` for visual containers (background, border, shadow), `div` for invisible grouping and pure layout (no background, no border, no clipping). +`div` is the one container type: a CSS flexbox/grid engine that auto-positions `children`. Decoration (`background`, `border`, `box-shadow`, `border-radius`) is entirely opt-in through `style` — set none of them for invisible grouping and pure layout, or set them for a visually decorated panel. `card`, `flex`, `grid`, and `positioned` are accepted JSON aliases for the same type and render identically; write new scenarios as `div`. ## Scene = Implicit Flex Container @@ -24,7 +24,7 @@ You can customize the scene layout: } ``` -## Card/Flex Patterns +## Layout Patterns Key patterns: - **Horizontal row:** `"flex-direction": "row"` + `"gap"` @@ -33,13 +33,13 @@ Key patterns: - **Auto-height:** `"style": { "width": 800, "height": "auto" }` - **Grid:** `"display": "grid"` + `"grid-template-columns"` -Children flow in the flexbox. Use `positioned` container for absolute positioning. +Children flow in the flexbox by default. Any child can opt out with `position: {x, y}` — that's a property of the child, not a special container mode, so it works inside any `div` without needing a distinct type. -**Grid sizing:** `height: "auto"` on a grid container sizes correctly to content — you don't need an explicit `height` just to avoid stretching. See [rules/grid-card-height.md](rules/grid-card-height.md). +**Grid sizing:** `height: "auto"` on a grid container sizes correctly to content — you don't need an explicit `height` just to avoid stretching. See [grid-card-height.md](grid-card-height.md). ## 23 component types have no *intrinsic* size — they fall back to a documented default -Most components either measure their own content (`text`, `codeblock`, `counter`, `badge`, `table`, `terminal`, `caption`, `kbd`, `gradient_text`, `rich_text`) or get a computed fallback size from their own fields (`icon`-like shapes such as `avatar`, `divider`, `line`, `arrow`, `switch`, `slider`, `progress`, `list`, `timeline`, `notification`, `rating`, `qr_code`, `countdown`, `particle`, `cursor`, `connector`, `waveform`, `audio_spectrum`). The following **23 types have no intrinsic measurement** (they fall through `component_intrinsic`'s `_ => None` arm and don't override `Painter::intrinsic_size`), but every one of them gets a **default `width`/`height` applied by `apply_intrinsic_overrides`** in `crates/rustmotion-components/src/box_builder.rs` whenever the JSON doesn't already set `style.width`/`style.height` — an explicit size is an *override*, not a requirement: +Most components either measure their own content (`text`, `counter`, `badge`, `table`, `caption`, `kbd`, `gradient_text`, `rich_text`) or get a computed fallback size from their own fields (`icon`-like shapes such as `avatar`, `divider`, `line`, `arrow`, `switch`, `slider`, `progress`, `list`, `timeline`, `rating`, `qr_code`, `countdown`, `particle`, `cursor`, `connector`, `waveform`, `audio_spectrum`). The following **23 types have no intrinsic measurement** (they fall through `component_intrinsic`'s `_ => None` arm and don't override `Painter::intrinsic_size`), but every one of them gets a **default `width`/`height` applied by `apply_intrinsic_overrides`** in `crates/rustmotion-components/src/box_builder.rs` whenever the JSON doesn't already set `style.width`/`style.height` — an explicit size is an *override*, not a requirement: | Component | Default size | Where it comes from | |---|---|---| @@ -67,7 +67,7 @@ Most components either measure their own content (`text`, `codeblock`, `counter` A default only fills in the axis that's actually missing — `apply_default_size` respects an explicit `width` or `height` (and derives the other one from `style.aspect-ratio` when only one is set). Explicit `style.width`/`style.height` is still worth setting whenever the default doesn't match the layout you want (e.g. a `stat` narrower than 280px in a tight row), but omitting it no longer produces a blank frame — three `stat`s in a flex-row card with no explicit size now lay out at 280×180 each, confirmed by `box_builder.rs`'s own tests. -If a component isn't showing up despite that, look at [rules/component-field-placement.md](rules/component-field-placement.md) first — schema-field misplacement is the more common cause of an invisible component. +If a component isn't showing up despite that, look at [component-field-placement.md](component-field-placement.md) first — schema-field misplacement is the more common cause of an invisible component. **GOOD** (icon + text row): ```json diff --git a/crates/rustmotion/skills/rules/component-field-placement.md b/crates/rustmotion/skills/rules/component-field-placement.md index 3a88d939..73424d95 100644 --- a/crates/rustmotion/skills/rules/component-field-placement.md +++ b/crates/rustmotion/skills/rules/component-field-placement.md @@ -31,7 +31,7 @@ } ``` -This applies to ALL components: `text`, `card`, `badge`, `icon`, `shape`, `image`, `codeblock`, etc. +This applies to ALL components: `text`, `card`, `badge`, `icon`, `shape`, `image`, `table`, etc. ### width / height diff --git a/crates/rustmotion/skills/rules/composition-recipes.md b/crates/rustmotion/skills/rules/composition-recipes.md new file mode 100644 index 00000000..02855ccf --- /dev/null +++ b/crates/rustmotion/skills/rules/composition-recipes.md @@ -0,0 +1,199 @@ +# Rule: Compose, Don't Catalogue + +Read this before reaching for a UI-widget component (`stat`, `badge`, `gauge`, `progress`, `stepper`, and 27 others — the full list is in `SKILL.md`'s "Composition over cataloguing"). They still exist and still render, but they are frozen arrangements of primitives, baked with one particular art direction. A generator's default reflex should be to **compose the shape from primitives**, not to fill in a pre-built one. + +`terminal`, `codeblock`, and `notification` used to be three more entries in that frozen-widget class. They are not — they were deleted outright, not deprecated, so they don't belong in the lookup table below (a deleted type is not "instead of X, compose Y", it's just gone). Their recipes are kept — see "Recipe: terminal, code block, toast (deleted components)" below. + +This is the same reflex as [rules/templates-and-iteration.md](templates-and-iteration.md) — read that file for the mechanics of `components`/`for-each`/`use` (bindings, `$index`, param defaults, pass ordering, named errors). This file is the *when* and *what-to-build*; that one is the *how*. + +## The reflex + +1. **Sketch the shape as HTML/CSS**, same as any other layout (see the "Mental Model" section at the top of `SKILL.md`): a KPI card is a `card` with an icon, a big number, and a small label stacked in a column. A pill is a rounded `div` with an icon and text in a row. A progress bar is a track behind a fill. +2. **If it appears once**, just write it as a normal `card`/`div`/`text`/`shape` tree. +3. **The moment it appears more than once** — three KPI cards, five feature pills, a row of progress bars — define it once under `components` and instantiate it with `for-each`. Ten hand-copied cards is the most common failure mode in generation (one of them always drifts on a color or a forgotten field); one template with a data array cannot drift. + +## Lookup: frozen widget → primitive recipe + +| Instead of | Compose from | Worked example | +|---|---|---| +| `stat` | `card` (background/radius/shadow) + `icon` + `text` (value, large) + `text` (label, small) | `examples/composition-kpi-row.json` | +| `badge` | `div` (rounded pill, colored background) + `icon` + `text` | `examples/composition-pill-row.json` | +| `progress` (linear) | a `card` track (fixed width, flat color) containing a `shape` (`rounded_rect`) fill whose `style.width` is keyframed from a small value to the target | `examples/composition-progress-bars.json` | +| `stepper` / `timeline` | `card` circles (node) + `text` (label) + `shape` (`rect`) connectors, one `for-each` item emitting the node and its trailing connector as sibling output (`template` as an array — see below) | `examples/composition-step-flow.json` | +| `avatar` / `avatar_group` | `shape` (`circle`, or an `image` with `fit: "cover"` clipped by a circular container) with `margin-left` negative overlap in a `flex-direction: row` container | — | +| `tooltip` / `callout` | `card` (small, rounded) + `shape` (`triangle`, rotated for the arrow) + `text` | — | +| `switch` / `slider` | two `shape`s (track + thumb), thumb position/track fill keyframed like the progress-bar recipe | — | +| `divider` | a single `shape` (`rect`), full width or full height, 1-2px thick | — | +| `list` / `rating` | `for-each` over items, each rendering an `icon` + `text` (or repeated star `icon`s with a partial-fill trick via two overlapping copies, one clipped) | — | +| `chart` | `for-each` over the data with a computed `height`/`width` expression per bar — proportional size and an index-staggered grow-in from one expression, e.g. `"height": "= $v * 3.4 * clamp(($t - 0.2 - $i*0.12) * 1.6, 0, 1)"` | — | +| `heatmap` | `for-each` over the cells with a computed `fill` expression per cell, driven by the cell's own value | — | +| `table` | `for-each` over the rows inside a `grid`-styled container — one row per grid row, cells as `text` children | — | +| `particle` | `for-each` over N items with `rand(seed, $i)` for placement and `sin($t)` for drift — deterministic by construction, not actually random | — | +| `caption` | `for-each` over the words with `start_at`/`end_at` per word and an expression picking the active one | — | +| `mockup` | a `shape` frame (device silhouette) around an `image` | — | + +`gauge` (an arc) and `number_wheel` (rolling digit strips) are the two widest exceptions: reproducing them needs either `svg` path arcs with `draw_progress` or genuine per-digit scroll physics — mechanically harder than card/text/shape. Using the component directly for these two is reasonable; the point of this rule is the *default reflex*, not a ban. + +## Recipe: KPI card (`stat` replacement) + +```json +{ + "components": { + "kpi_card": { + "params": { + "label": { "type": "string" }, + "value": { "type": "string" }, + "accent": { "type": "string", "default": "#6366F1" } + }, + "template": { + "type": "card", + "style": { "width": 360, "height": 220, "background": "#111827", "border-radius": 20, "padding": 28, "flex-direction": "column", "justify-content": "space-between" }, + "children": [ + { "type": "text", "content": "$value", "style": { "font-size": 56, "color": "#FFFFFF", "font-weight": "bold" } }, + { "type": "text", "content": "$label", "style": { "font-size": 22, "color": "#94A3B8" } } + ] + } + } + }, + "children": [{ + "for-each": [ + { "label": "Active Users", "value": "45.2K", "accent": "#22C55E" }, + { "label": "Revenue", "value": "1.24M", "accent": "#3B82F6" } + ], + "template": { "use": "kpi_card", "props": { "label": "$label", "value": "$value", "accent": "$accent" } } + }] +} +``` + +Full version with icon, accent shape, and staggered entrance: `examples/composition-kpi-row.json`. + +## Recipe: animated progress bar (`progress` replacement) + +A track and a fill are two `shape`s, not one component. The fill's `style.width` is a normal `keyframes` animation, exactly like animating any other property — there is nothing progress-bar-specific about it: + +```json +{ + "type": "card", + "style": { "width": 760, "height": 18, "background": "#1E293B", "border-radius": 9, "padding": 0 }, + "children": [{ + "type": "shape", + "shape": "rounded_rect", + "fill": "#3B82F6", + "style": { + "width": 6, "height": 18, "border-radius": 9, + "animation": [{ + "name": "keyframes", + "keyframes": [{ "property": "width", "easing": "ease_out_cubic", + "keyframes": [{ "time": 0, "value": 6 }, { "time": 1.2, "value": 623 }] }] + }] + } + }] +} +``` + +Start the fill's keyframe `width` a few pixels above zero, not at zero — a `rounded_rect` with `width: 0` still has to paint the radius on both ends and can pop rather than grow. Full version with label/percent rows and four bars via `for-each`: `examples/composition-progress-bars.json`. + +## Recipe: `for-each` emitting siblings, not just one node + +A `for-each template` can be an **array**, not just a single object — each element of the array is inserted as a sibling, not nested. This is what a step-flow needs: each data item produces both a step node *and* the connector that follows it, without a second pass to "join the dots": + +```json +"template": [ + { "type": "card", "style": { "width": 88, "height": 88, "border-radius": 44 }, "children": [ ... ] }, + { "type": "shape", "shape": "rect", "fill": "$connector_color", "style": { "width": 140, "height": 4 } } +] +``` + +Give the **last** item's data row a transparent `connector_color` (`"#00000000"`) instead of trying to omit the trailing connector conditionally — `for-each` has no branching, so the cleanest way to special-case the last element is to make its data say so explicitly. Full version: `examples/composition-step-flow.json`. + +## Recipe: terminal, code block, toast (deleted components) + +`terminal`, `codeblock`, and `notification` are not deprecated like the widgets in the table above — they were removed from the engine entirely, files and all. Unlike a frozen widget, there is no fallback to "use the component directly for the one-off case": the type does not deserialize any more. These three recipes are what replaces them. + +**Terminal** — a `div` title bar (three coloured `shape` circles + a `text` label) over a column of monospace `text` lines, one node per printed line so each can carry its own colour (a prompt line in green, output in grey, a highlighted result line in a different colour again — a single component could never do that per-line). A `typewriter` animation on each line, staggered via the column's own `stagger` field, reproduces the old line-by-line/typewriter reveal: + +```json +{ + "type": "div", + "style": { "flex-direction": "column", "background": "#1E1E1E", "border-radius": 10, "overflow": "hidden", "width": 900, "height": 300 }, + "children": [ + { + "type": "div", + "style": { "flex-direction": "row", "align-items": "center", "gap": 8, "padding": { "top": 10, "right": 14, "bottom": 10, "left": 14 }, "background": "#2D2D2D" }, + "children": [ + { "type": "shape", "shape": "circle", "fill": "#ff5f56", "style": { "width": 12, "height": 12 } }, + { "type": "shape", "shape": "circle", "fill": "#ffbd2e", "style": { "width": 12, "height": 12 } }, + { "type": "shape", "shape": "circle", "fill": "#27c93f", "style": { "width": 12, "height": 12 } }, + { "type": "text", "content": "rustmotion", "style": { "font-size": 13, "color": "#808080", "margin": { "left": 8 } } } + ] + }, + { + "type": "div", + "stagger": 0.4, + "style": { "flex-direction": "column", "padding": 20, "gap": 6 }, + "children": [ + { "type": "text", "content": "$ rustmotion validate -f scene.json", "style": { "font-family": "JetBrains Mono", "font-size": 16, "color": "#22C55E", "white-space": "pre", "animation": [{ "name": "typewriter", "duration": 0.4 }] } }, + { "type": "text", "content": "schema: pass", "style": { "font-family": "JetBrains Mono", "font-size": 16, "color": "#A0A0A0", "white-space": "pre", "animation": [{ "name": "typewriter", "duration": 0.4 }] }, "caret": { "shape": "block", "hide_when_done": true } } + ] + } + ] +} +``` + +The old `TerminalTheme::Dark` palette (bg `#1E1E1E`, chrome bar `#2D2D2D`, prompt `#22C55E`, command `#FFFFFF`, output `#A0A0A0`, title `#808080`) is worth keeping verbatim — it's a calibrated three-colour system, not an arbitrary choice. `text.caret` on the last line reproduces the blinking reveal-head cursor. + +**Code block** — one `rich_text` per source line, each with a hand-tokenized `spans` array (`{"text": "fn", "color": "#bb9af7"}`, one span per keyword/identifier/punctuation run — merge adjacent same-coloured tokens rather than emitting one span per character). This is the piece that used to be `syntect`-backed inside the engine; it moves to the generator because whoever is writing the JSON already knows the grammar of the language in the snippet — the tokenising never needed to be a runtime feature. Wrap the lines in the same title-bar `div` as the terminal recipe, and give each `rich_text` its own `typewriter` animation (staggered the same way) if the original had a `reveal`: + +```json +{ + "type": "rich_text", + "spans": [ + { "text": "fn ", "color": "#bb9af7" }, + { "text": "main", "color": "#7aa2f7" }, + { "text": "() {", "color": "#c0caf5" } + ], + "style": { "font-family": "JetBrains Mono", "font-size": 16, "white-space": "pre", "animation": [{ "name": "typewriter", "duration": 0.3 }] } +} +``` + +Beware a bare `=` ending up as its own span (an HTML tokenizer splitting `width="1920"` at the `=` boundary between two differently-coloured neighbours, for instance): a scenario string starting with `=` is read as an expression by the loader (`fold_value`'s `s.strip_prefix('=')`), and a solitary `"="` becomes an empty one — glue it onto a neighbouring span rather than emitting it standalone. + +**Toast (`notification` replacement)** — a `div` card that gates its own visibility with `start_at`/`end_at` (the old `slide_in_at`/`slide_out_at` pair), with a `slide_in_*` entrance and a delayed `fade_out` exit so it doesn't just vanish at `end_at`: + +```json +{ + "type": "div", + "start_at": 1.5, + "end_at": 4.8, + "style": { + "flex-direction": "row", "align-items": "center", "gap": 14, + "background": "#111827", "border-radius": 14, "padding": 18, "width": 420, + "animation": [ + { "name": "slide_in_left", "duration": 0.4 }, + { "name": "fade_out", "delay": 2.9, "duration": 0.35 } + ] + }, + "children": [ + { "type": "div", "style": { "width": 4, "align-self": "stretch", "background": "#10b981", "border-radius": 4 } }, + { "type": "icon", "icon": "lucide:check-circle", "style": { "width": 28, "height": 28, "color": "#10b981" } }, + { "type": "div", "style": { "flex-direction": "column", "gap": 4 }, "children": [ + { "type": "text", "content": "Build succeeded", "style": { "font-size": 20, "font-weight": "bold", "color": "#f8fafc" } }, + { "type": "text", "content": "All tests passed", "style": { "font-size": 15, "color": "#94a3b8" } } + ] } + ] +} +``` + +The left accent strip (a 4px-wide `div`, `align-self: stretch`) stands in for a border-and-variant-colour system without needing `style.border` at all. `fade_out`'s `delay` should land comfortably before `end_at - start_at` (here 3.3s of visible window), or the node disappears via the timing gate before the exit animation finishes playing. + +All three recipes are exercised end-to-end in `examples/component-showcase.json`, `examples/mega-showcase.json`, `examples/rustmotion-promo.json`, and `examples/ferriskey-launch-60s.json` — each used to hold a `terminal`/`codeblock`/`notification` node and was rewritten with the compositions above. + +## Pitfall: a literal `$` in for-each data + +Variable substitution scans for `$name` tokens everywhere, including inside the *values* a `for-each` element supplies — not just inside the template. A KPI value like `"$1.24M"` gets read as an attempted reference to a variable named `1` and is left as literal text with a validator warning. Either escape it (`"$$1.24M"` → renders as `$1.24M`) or, simpler, keep the currency symbol out of the animated value and put it in a static label instead (`"Revenue (USD)"` / `"1.24M"`) — that's what `examples/composition-kpi-row.json` does. + +## What this buys over the frozen component + +- **Art direction is per video.** The frozen `stat` component has one accent-icon-in-a-circle look. A `card`+`icon`+`shape` recipe can match whatever palette and corner-radius language the rest of the video uses, because it's the same primitives as everything else in the scene — not a separately-styled widget bolted on. +- **It composes with everything else primitives already do** — `for-each` staggered entrances, `world` view camera framing, `components` nesting one recipe inside another (a KPI-card `components` entry can itself be used inside a dashboard-grid `components` entry). +- **Nothing is lost.** The frozen widgets keep working for the cases that genuinely warrant them (a one-off `gauge`, a `number_wheel` landing on a hero figure). This rule changes the default reflex, not the available vocabulary. diff --git a/crates/rustmotion/skills/rules/counter-standalone.md b/crates/rustmotion/skills/rules/counter-standalone.md deleted file mode 100644 index 77428b95..00000000 --- a/crates/rustmotion/skills/rules/counter-standalone.md +++ /dev/null @@ -1,48 +0,0 @@ -# Rule: Counter Centers Correctly — Size Its Parent for the Worst-Case Digit Width - -`counter` centers correctly whether it is a standalone scene child **or** nested inside a `card`. Verified by rendering both cases: with `text-align: center`, the counter's ink centre lands exactly on the content-box centre in both contexts, using the same paint-time centering math (`counter.rs`) regardless of parent — there is no separate "standalone" code path and no missing baseline correction inside cards. - -## The real constraint: the box never shrinks to fit - -`CounterIntrinsic` reserves space for the natural (unwrapped) width of the *largest absolute value* between `from` and `to` (worst-case digits) — and, because the counter is atomic (`wrap: false`), that measurement **ignores** any known/definite width the parent offers; it always requests its full natural width. So if the parent (a `card`, or any fixed-width container) is narrower than that worst-case width, the counter's box is still sized to its content and **overflows the parent on both sides**. - -**This is validator-silent when the parent uses the default `overflow: visible` and the overflow stays inside the video viewport** — `rustmotion validate`'s geometry pass only flags content that leaves the *viewport*, not a parent container (see `CLAUDE.md`'s "Sécurité géométrique" section). Confirmed by rendering: a counter needing up to 7 digits inside a 300px-wide card visibly spills past both edges of the card, while `rustmotion validate` reports `Valid scenario` with zero geometry violations — only a schema-level warning ("display width changes from N to M chars — ensure the parent container is at least wide enough"). - -So: size the parent to the counter's worst-case digit width (or wider), and don't rely on `rustmotion validate` to catch it if you don't. - -**BAD** (card narrower than the counter's worst-case width — overflows silently, `to: 9_999_999` needs 7 digits' worth of width): -```json -{ - "type": "card", - "style": { "width": 300, "height": "auto", "background": "#1E293B", "padding": 20 }, - "children": [ - { "type": "counter", "from": 0, "to": 9999999, "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center" } } - ] -} -``` - -**GOOD** (standalone — no parent width constraint): -```json -{ - "type": "counter", - "from": 0, - "to": 100, - "start_at": 0.5, - "end_at": 2.5, - "easing": "ease_out", - "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center" } -} -``` - -**GOOD** (inside a card — fine, as long as the card is wide enough for the worst-case digit width): -```json -{ - "type": "card", - "style": { "width": 700, "height": "auto", "background": "#1E293B", "padding": 20 }, - "children": [ - { "type": "counter", "from": 0, "to": 9999999, "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center", "width": "100%" } } - ] -} -``` - -`end_at` is a visibility toggle, not an animation-completion boundary — setting it on a `counter` makes the number **disappear** once that time passes (the counter's own animation is driven by `ctx.time / scene_duration`, not by `start_at`/`end_at`). Use `start_at` only to delay the count-up. diff --git a/crates/rustmotion/skills/rules/data-viz-components.md b/crates/rustmotion/skills/rules/data-viz-components.md deleted file mode 100644 index a60cc983..00000000 --- a/crates/rustmotion/skills/rules/data-viz-components.md +++ /dev/null @@ -1,38 +0,0 @@ -# Data Visualization Component Selection - -## Quick decision tree - -- **Single KPI number** → `stat` (with trend + sparkline) -- **Progress toward goal** → `gauge` (semi-circle) or `progress` (linear/circular) -- **Inline trend indicator** → `sparkline` (inside card or standalone) -- **Full data comparison** → `chart` (12 types available) -- **Loading state** → `skeleton` (rectangle/circle/text variants) -- **Data table** → `table` (with column_widths, column_align) - -## Gauge vs Progress (circular) - -- **Gauge**: semi-arc (135°–405°), shows a value with label, best for dashboard KPIs like CPU/memory -- **Progress circular**: full 360° ring, shows percentage, best for completion tracking - -## Sparkline vs Line chart - -- **Sparkline**: no axes, no labels, compact (120x40 default), inline use -- **Line chart**: axes, grid, labels, larger, standalone data viz - -## Skeleton loading pattern - -Use skeleton variants to match the content they replace: -```json -{ "type": "skeleton", "variant": "circle", "style": { "width": 64, "height": 64 } } -{ "type": "skeleton", "variant": "text", "lines": 3 } -{ "type": "skeleton", "variant": "rectangle", "style": { "width": 400, "height": 200 } } -``` - -## Combining components for dashboards - -A dashboard scene typically uses: -1. `stat` cards in a row (KPIs) -2. `chart` components (area/bar/donut) for detailed data -3. `table` for tabular data -4. `gauge` or `progress` for single metrics -5. `sparkline` inline within cards for trends diff --git a/crates/rustmotion/skills/rules/depth-layering.md b/crates/rustmotion/skills/rules/depth-layering.md index 62a8b484..bb55498c 100644 --- a/crates/rustmotion/skills/rules/depth-layering.md +++ b/crates/rustmotion/skills/rules/depth-layering.md @@ -25,7 +25,7 @@ Design each scene in three planes. Every element belongs to exactly one: | Plane | z-index | Role | Typical components | |---|---|---|---| | **Background** | 0 | Ambient texture, gradients, decorative shapes | `animated-background`, `shape` circles/blobs, `particle` | -| **Mid-ground** | 1 | Main content, cards, charts | `card`, `chart`, `codeblock`, `text` body | +| **Mid-ground** | 1 | Main content, cards, charts | `card`, `chart`, `table`, `text` body | | **Foreground** | 2 | Emphasis elements, badges, callouts | `badge`, `icon` hero, `text` headline | Use `"style": { "z-index": N }` to enforce render order when elements overlap. diff --git a/crates/rustmotion/skills/rules/geometry-safety.md b/crates/rustmotion/skills/rules/geometry-safety.md index 0601fc3a..f28c4a23 100644 --- a/crates/rustmotion/skills/rules/geometry-safety.md +++ b/crates/rustmotion/skills/rules/geometry-safety.md @@ -1,6 +1,6 @@ # Rule: Keep Content Inside the Viewport -No textual content may bleed out of the device viewport. The renderer enforces this through four opt-in mechanisms, all checked by `rustmotion validate`. +No textual content may bleed out of the device viewport. The renderer enforces this through three opt-in mechanisms, all checked by `rustmotion validate`. ## 1. Text wrapping (`style.white-space`) @@ -13,23 +13,7 @@ No textual content may bleed out of the device viewport. The renderer enforces t { "type": "text", "content": "Long sentence...", "style": { "white-space": "nowrap", "max-width": 800 } } ``` -## 2. Codeblock / Terminal `auto_scroll` - -When you give a `codeblock` or `terminal` a fixed `size` smaller than its natural content height, the renderer scrolls the content vertically (clip + translate) so the **last revealed line stays visible**. Font size is **never** reduced. - -- Default: `auto_scroll: true`. -- `auto_scroll: false` → validator fails with `auto_scroll_disabled_overflow` if content doesn't fit. - -```json -{ - "type": "codeblock", - "auto_scroll": true, - "code": "", - "style": { "width": 1160, "height": 480 } -} -``` - -## 3. Text shrink-to-fit (`style.text-autofit`) +## 2. Text shrink-to-fit (`style.text-autofit`) `text` and `gradient_text` accept `text-autofit: true`, which reduces their font size until the content fits the resolved box — width, and height when taffy resolves one. @@ -44,11 +28,11 @@ Three things to know: - **It has a floor.** Shrinking stops at a calibrated legibility threshold and never goes below it. If the text still does not fit at the floor, the geometry violation is **still reported** — `text-autofit` narrows that failure class, it does not silence it. - **`white-space` still decides whether the text wraps**; `text-autofit` only decides at what size. They compose. -- **Only these two components implement it.** Declaring it on a `caption`, a `codeblock` or a `table` is inert — those painters never read it. +- **Only these two components implement it.** Declaring it on a `caption` or a `table` is inert — those painters never read it. On a canvas taller than 1080, `validate` warns that autofit may shrink below the legibility floor for that frame height. That warning is about the *rendered* size, not the declared one. -## 4. Container `style.overflow` +## 3. Container `style.overflow` CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at the parent box. The validator only fails when content escapes the **viewport**, not a `visible` parent — a badge sticking out of a card is legal. @@ -58,12 +42,11 @@ CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at t ## What the validator catches -`rustmotion validate scenario.json` reports five geometry violation kinds: +`rustmotion validate scenario.json` reports four geometry violation kinds: - `viewport_overflow` — absolute bbox crosses the device edge - `unwrappable_text_overflow` — `white-space: "nowrap"`/`"pre"` but natural width > available width - `content_overflows_box` — wrapping text needs more room than the box it was actually assigned, typically a paragraph inside a card with a fixed `height` too small for it. Text painters never clip themselves, so this paints outside its box even when the box sits comfortably inside the frame — which is why the viewport check alone never caught it. -- `auto_scroll_disabled_overflow` — `auto_scroll: false` but content > box - `animated_text_overflow` — an animated transform (scale/translate/wiggle/orbit) pushes the bbox out of the viewport at some sampled time. Only checked with `--strict-anim` (default runs check the resting, untransformed layout only). `marquee` and `cursor` are exempt (their job is to bleed). A node is also exempt when it clips itself, or when any ancestor clips it — `overflow` set to anything other than `visible`. Deliberate bleed under a clipping parent is a composition technique, not a defect: that is how you get giant type running off the frame. @@ -79,7 +62,6 @@ rustmotion validate scenario.json --lenient # warnings only ``` `--fix` rewrites the file in place: -- `auto_scroll_disabled_overflow` → sets `auto_scroll: true`. Safe. - `unwrappable_text_overflow` → removes `style.white-space`, so the text falls back to the `normal` default and wraps again. Non-destructive: it only ever deletes the property that caused the violation. If you want the line to stay unbroken, widen the box or lower `font-size` by hand instead of running `--fix`. Position/size clamping (`viewport_overflow`) is never auto-applied — fix those by hand too. @@ -91,8 +73,6 @@ Position/size clamping (`viewport_overflow`) is never auto-applied — fix those | Long sentence cut at viewport edge | leave `white-space` unset (default wraps) and ensure parent has finite width | | Need a single-line title that must fit | set `max-width` and a small enough `font-size`, leave `white-space` unset | | Marquee / ticker text that intentionally scrolls past edges | use `marquee` (exempt) — never `text` with `white-space: "nowrap"` | -| Code listing taller than its box | leave `auto_scroll: true` (default) | -| Terminal log streaming many lines | leave `auto_scroll: true` | | Badge protruding from a card on purpose | container has `overflow: visible` (default) — no change needed | | Hard-clip children to a card border | container `style.overflow: "hidden"` | | Animated element (wiggle/orbit/keyframe scale) might drift off-screen | run `rustmotion validate --strict-anim` to sample frames, not just the resting layout | diff --git a/crates/rustmotion/skills/rules/html-css-mental-model.md b/crates/rustmotion/skills/rules/html-css-mental-model.md index 17619acb..5528f3e2 100644 --- a/crates/rustmotion/skills/rules/html-css-mental-model.md +++ b/crates/rustmotion/skills/rules/html-css-mental-model.md @@ -2,7 +2,7 @@ ## Principe -L'API JSON de Rustmotion est un superset de HTML/CSS. Chaque scène est un flex container, chaque `card` est une `
`, les propriétés CSS (`gap`, `padding`, `flex-direction`, `align-items`, etc.) ont exactement la même sémantique. +L'API JSON de Rustmotion est un superset de HTML/CSS. Chaque scène est un flex container, chaque conteneur (`{"type": "div"}`) est une `
`, les propriétés CSS (`gap`, `padding`, `flex-direction`, `align-items`, etc.) ont exactement la même sémantique. **Avant d'écrire du JSON, se poser cette question : "Comment j'écrirais ça en HTML/CSS ?"** @@ -14,15 +14,11 @@ L'API JSON de Rustmotion est un superset de HTML/CSS. Chaque scène est un flex | HTML/CSS | Rustmotion JSON | Quand l'utiliser | |---|---|---| -| `
` neutre | `{"type": "div"}` | Layout pur, sans décoration visuelle | -| `
` avec fond, border-radius | `{"type": "card"}` | Container avec styling visuel | +| `
` neutre ou décorée | `{"type": "div"}` | Layout pur (rien dans `style`), ou container visuel (`background`/`border-radius`/`box-shadow` dans `style`) — même type dans les deux cas | | `
` | `{"type": "div", "style": {"flex-direction": "row", "gap": 24}}` | Ligne horizontale | | `
` | `{"type": "div", "style": {"display": "grid", "grid-template-columns": ["1fr","1fr"]}}` | Grille | -**Règle de choix** : -- `div` → grouper des enfants sans fond, border-radius, ou ombre. Flex par défaut. -- `card` → conteneur avec background, border-radius, shadow. Flex par défaut. -- Les deux acceptent les mêmes propriétés CSS (`gap`, `padding`, `flex-direction`, `align-items`, `justify-content`, `grid-template-columns`, etc.) +**Règle de choix** : un seul type de conteneur, `div`. La décoration (fond, border-radius, ombre) est optionnelle et se règle entièrement via `style` — rien à choisir entre deux types différents. `card`, `flex`, `grid`, `positioned`, `container` sont des alias JSON historiques du même type (rétrocompatibilité) ; ils rendent à l'identique. Écrire `div` dans les nouveaux scénarios. ### Autres correspondances diff --git a/crates/rustmotion/skills/rules/hyperframes-mapping.md b/crates/rustmotion/skills/rules/hyperframes-mapping.md index 9c16492e..bbd28cbf 100644 --- a/crates/rustmotion/skills/rules/hyperframes-mapping.md +++ b/crates/rustmotion/skills/rules/hyperframes-mapping.md @@ -12,7 +12,7 @@ If you're asked for an effect from the Hyperframes catalogue (or an effect descr | Streaming Text | `char_blur_in` with `jitter`/`seed`/`ink_from` — see [streaming-text.md](streaming-text.md) | | Typewriter | `typewriter` preset + `text.caret` | | Text State Swap | `text.states` + `text.swap` | -| Number Wheel | `number_wheel` component — see [number-wheel.md](number-wheel.md) | +| Number Wheel | `number_wheel` component — `value` (string, e.g. `"30,222"`), `spin` (single/double/triple), `duration`, `stagger_per_column` | | Badge Pop | `badge` + `style.animation: [{ "name": "pop_in" }]` | | Success Check | `success_check` component | | Simulated Cursor | `pointer` component — see [pointer-walkthrough.md](pointer-walkthrough.md) | diff --git a/crates/rustmotion/skills/rules/module-structure.md b/crates/rustmotion/skills/rules/module-structure.md index 471d410d..50df2bef 100644 --- a/crates/rustmotion/skills/rules/module-structure.md +++ b/crates/rustmotion/skills/rules/module-structure.md @@ -41,7 +41,6 @@ src/ │ ├── style.rs # Specialized types: CardBorder, CardShadow, Fill, Gradient, etc. │ ├── background.rs # AnimatedBackground, BackgroundPreset │ ├── animation.rs # EasingType, AnimationPreset, PresetConfig, AnimationEffect -│ ├── codeblock_types.rs# CodeblockChrome, CodeblockState, CodeblockReveal │ └── video.rs # Size, ShapeType, Stroke, ImageFit, GlowConfig, OrbitConfig └── traits/ ├── painter.rs # Painter trait + PaintCtx + AvailableSize + MeasureCtx @@ -79,13 +78,6 @@ src/ │ ├── funnel.rs # render_funnel (horizontal + vertical) │ ├── waterfall.rs # render_waterfall │ └── axes.rs # draw_axes(categorical: bool), format_number, contrast_text_color -├── codeblock/ # Codeblock component -│ ├── mod.rs # render_codeblock_v2, Painter impl -│ ├── highlight.rs # Syntect integration, syntax highlighting -│ ├── chrome.rs # macOS title bar chrome -│ ├── reveal.rs # Typewriter, line-by-line reveal -│ ├── diff.rs # State transitions, word diff, cursor editing -│ └── dimensions.rs # compute_code_dimensions └── *.rs # One file per component (impl Painter) ``` diff --git a/crates/rustmotion/skills/rules/notification-stacking.md b/crates/rustmotion/skills/rules/notification-stacking.md deleted file mode 100644 index 1a09beaf..00000000 --- a/crates/rustmotion/skills/rules/notification-stacking.md +++ /dev/null @@ -1,38 +0,0 @@ -# Notification Stacking - -## How to stack multiple notifications - -Notifications appear via fade-in and push existing ones down when a new one arrives. - -### Pattern: 2 notifications -```json -{ - "type": "notification", - "title": "First Alert", - "variant": "success", - "slide_in_at": 0.5, - "push_at": [1.5], - "position": "absolute", "x": 100, "y": 100 -}, -{ - "type": "notification", - "title": "Second Alert", - "variant": "warning", - "slide_in_at": 1.5, - "wait_for_push": true, - "position": "absolute", "x": 100, "y": 100 -} -``` - -### Rules -1. All stacked notifications share the **same x/y** position (top anchor) -2. Earlier notifications get `push_at: [time_of_next]` — timestamps when they shift down -3. Later notifications get `wait_for_push: true` — delays fade-in until push animation completes -4. For 3+ notifications, chain: first gets `push_at: [1.5, 3.0]`, second gets `push_at: [3.0]`, third gets `wait_for_push: true` -5. `slide_out_at` controls when each notification fades out independently - -### Variant colors -- `info` → #3B82F6 (blue) -- `success` → #22C55E (green) -- `warning` → #F59E0B (amber) -- `error` → #EF4444 (red) diff --git a/crates/rustmotion/skills/rules/number-wheel.md b/crates/rustmotion/skills/rules/number-wheel.md deleted file mode 100644 index 5ceb72a9..00000000 --- a/crates/rustmotion/skills/rules/number-wheel.md +++ /dev/null @@ -1,44 +0,0 @@ -# Rule: `number_wheel` vs `counter` - -Two components display an animated number. They don't tell the same story. - -**`counter`** interpolates a *value* and rewrites the number every frame. It answers "how much, right now?" — a rising gauge, an accumulating total. Its glyphs jump, because 8,999 and then 9,000 have nothing in common. - -**`number_wheel`** scrolls strips of digits, like a mechanical odometer. It answers "the figure lands" — a KPI settling, a result being revealed. What you watch is the motion; what remains is the requested digit. - -```json -{ - "type": "number_wheel", - "value": "30,222", - "spin": "double", - "duration": 1.1, - "delay": 0.3, - "stagger_per_column": 0.09, - "style": { "font-size": 120, "font-weight": 700, "color": "#38BDF8" } -} -``` - -| Field | Role | Default | -|---|---|---| -| `value` | The figure exactly as written: `"30,222"`, `"5.7"`, `"98%"` | required | -| `spin` | `single` / `double` / `triple` — 0-9 loops before landing | `single` | -| `duration` | Landing time for **one** reel | `1.2` | -| `delay` | Before the first reel starts | `0` | -| `stagger_per_column` | Offset per column, left to right | `0.08` | -| `easing` | Curve of the travel | `ease_out_cubic` | - -## `value` is a string, not a number - -The digits roll; everything else — comma, dot, sign, unit — is painted where it stands, motionless. That's what lets you write `"1,204 €"` or `"98%"` without the separator going haywire. - -## `spin` changes the speed, not the duration - -Every reel takes `duration` no matter what. `triple` doesn't make the animation longer: it scrolls three times as many digits in the same time. A `triple` on a short `duration` turns into an unreadable blur. - -## `stagger_per_column: 0` is a default to avoid - -All the reels land together, which reads as a single flip. The left-to-right offset is what makes the last digit the one that *settles* the figure. - -## The box reserves space for the widest digit - -Each column is as wide as the widest digit in the font, not the width of the final digit — otherwise a `111` would reserve a narrow box and then overflow while a `0` scrolls past. The validator measures the same thing. diff --git a/crates/rustmotion/skills/rules/paint-context.md b/crates/rustmotion/skills/rules/paint-context.md index e0086640..42f5f812 100644 --- a/crates/rustmotion/skills/rules/paint-context.md +++ b/crates/rustmotion/skills/rules/paint-context.md @@ -16,7 +16,7 @@ pub trait Painter { ); /// Optional intrinsic measurement for taffy's measure_fn (text, image, - /// codeblock). Return None to let taffy size the node from CssStyle alone. + /// table). Return None to let taffy size the node from CssStyle alone. fn intrinsic_size(&self, available: AvailableSize, ctx: &MeasureCtx) -> Option<(f32, f32)> { None } diff --git a/crates/rustmotion/skills/rules/pixel-product-register.md b/crates/rustmotion/skills/rules/pixel-product-register.md index 41246513..fe193d6d 100644 --- a/crates/rustmotion/skills/rules/pixel-product-register.md +++ b/crates/rustmotion/skills/rules/pixel-product-register.md @@ -123,13 +123,16 @@ Entrances are `char_fade_in` with `granularity: "word"`, `stagger: 0.05`, --- -## 4. The terminal window — composed, not the `terminal` component - -**`terminal` cannot express this.** `TerminalLine` carries one `color` for the -whole line, and this register colours *fragments inside* a line — a product name -in the accent inside a grey version string, a flag in cyan inside a sentence. -Build the window from parts and use `rich_text`, whose spans do carry per-span -colour. +## 4. The terminal window — composed from primitives, not a `terminal` component + +**There is no `terminal` component to reach for.** It was deleted from the +engine outright — not merely inadequate here. Even while it existed it +couldn't have expressed this register anyway: `TerminalLine` carried one +`color` for the whole line, and this register colours *fragments inside* a +line — a product name in the accent inside a grey version string, a flag in +cyan inside a sentence. Build the window from parts (a `div` chrome bar over +the pane) and use `rich_text` for the transcript, whose spans do carry +per-span colour. ### Geometry, measured diff --git a/crates/rustmotion/skills/rules/responsive-device-sizing.md b/crates/rustmotion/skills/rules/responsive-device-sizing.md index a1944bbd..c3cb1be2 100644 --- a/crates/rustmotion/skills/rules/responsive-device-sizing.md +++ b/crates/rustmotion/skills/rules/responsive-device-sizing.md @@ -132,7 +132,6 @@ Reference: Tailwind CSS default spacing (4px base unit). Sizing rules above are guidelines — `rustmotion validate` is the source of truth. It refuses any scenario whose layout tree leaves the device viewport. See [geometry-safety.md](geometry-safety.md): - `text` wraps automatically at the parent's max width — leave `style.white-space` unset (default wraps) unless you have a finite `max-width`. -- `codeblock` / `terminal` auto-scroll when content exceeds their `size` — leave `auto_scroll: true` (default). - Long single-line content that should bleed must use `marquee`, never `text` with `white-space: "nowrap"`. ## BAD: Using desktop sizes on mobile diff --git a/crates/rustmotion/skills/rules/scene-pacing.md b/crates/rustmotion/skills/rules/scene-pacing.md index 745ce471..6d935ea3 100644 --- a/crates/rustmotion/skills/rules/scene-pacing.md +++ b/crates/rustmotion/skills/rules/scene-pacing.md @@ -24,7 +24,7 @@ Where: | Title + body text (30–50 words) | 5.0–7.0s | | 3× feature cards with text | 5.0–6.0s | | Counter animation | 3.0–4.0s (`animation_budget` + 1s dwell) | -| Codeblock typewriter reveal | 6.0–12.0s (depends on line count) | +| Code/terminal typewriter reveal | 6.0–12.0s (depends on line count) | | Data chart with labels | 5.0–7.0s | | Dashboard with multiple stats | 6.0–8.0s | | CTA / outro | 2.5–3.5s | diff --git a/crates/rustmotion/skills/rules/stat-cards.md b/crates/rustmotion/skills/rules/stat-cards.md deleted file mode 100644 index 50670db8..00000000 --- a/crates/rustmotion/skills/rules/stat-cards.md +++ /dev/null @@ -1,56 +0,0 @@ -# Stat / KPI Cards Best Practices - -## `stat` requires explicit `width`/`height` — it has no intrinsic size - -`stat` has **no intrinsic sizing** (verified against `crates/rustmotion-components/src/box_builder.rs`: `Stat` is absent from both `component_intrinsic` and `apply_intrinsic_overrides`, unlike `counter`/`text`/`badge`). Without an explicit `style.width`/`style.height`, a `stat` in a flex/grid layout lays out at 0×0 and paints nothing. - -**Measured:** three `stat`s placed in a flex-row card with no explicit size render **zero pixels** — not a smaller-than-expected card, literally nothing on screen. Always set `style.width`/`style.height` explicitly: - -## GOOD: Stat card with all features -```json -{ - "type": "stat", - "value": "45.2K", - "label": "Active Users", - "trend": { "value": "+12.5%", "direction": "up" }, - "sparkline_data": [20, 25, 22, 30, 28, 35, 32, 40, 38, 45], - "sparkline_color": "#22C55E", - "style": { "width": 280, "height": 180, "background": "#1E293B", "border-radius": 16 } -} -``` -This only works because `width`/`height` are set explicitly — that is a **requirement**, not a stylistic choice. - -## BAD: stat with no explicit size in a flex row — renders nothing -```json -{ - "type": "card", - "style": { "flex-direction": "row", "gap": 24 }, - "children": [ - { "type": "stat", "value": "45.2K", "label": "Active Users", "style": { "background": "#1E293B" } }, - { "type": "stat", "value": "12.8%", "label": "Growth", "style": { "background": "#1E293B" } }, - { "type": "stat", "value": "3.4s", "label": "Load time", "style": { "background": "#1E293B" } } - ] -} -``` -No `width`/`height` on any `stat` → all three collapse to 0×0. `rustmotion validate` does not catch this (it's a zero-size box, not an overflow) — the scene simply renders blank where the stats should be. - -## Trend direction & color - -- `"up"` → green arrow by default (positive metric) -- `"down"` → red arrow by default (negative metric) -- Override with `"color": "#22C55E"` when direction is good (e.g. churn **decreasing**) - -## `stat` vs `counter` — they are not interchangeable - -- `counter` **animates** its number over time (`from` → `to`, with `easing`). It centers correctly inside a card — verified: with `text-align: center`, its ink centre lands exactly on the content-box centre, both standalone and inside a card. The one real constraint is sizing the parent to its worst-case digit width (see [counter-standalone.md](counter-standalone.md)) — not centering. -- `stat` is a **static** composite (`value` is a plain string — there is no animated count-up). Use it when you want value + label + trend arrow + sparkline bundled in one box and don't need the number itself to animate. Use `counter` + `text` when the number needs to count up/down, or when you don't need trend/sparkline. - -Neither is a drop-in replacement for the other; pick based on whether the number animates and whether you need the trend/sparkline extras. - -## Dashboard layout pattern -Place 3-4 stat cards in a row with absolute positioning, 320px apart — each still needs its own explicit size: -```json -{ "type": "stat", "value": "45.2K", "label": "Active Users", "position": "absolute", "x": 80, "y": 100, "style": { "width": 280, "height": 180, "background": "#1E293B" } }, -{ "type": "stat", "value": "12.8%", "label": "Growth", "position": "absolute", "x": 400, "y": 100, "style": { "width": 280, "height": 180, "background": "#1E293B" } }, -{ "type": "stat", "value": "3.4s", "label": "Load time", "position": "absolute", "x": 720, "y": 100, "style": { "width": 280, "height": 180, "background": "#1E293B" } } -``` diff --git a/crates/rustmotion/skills/rules/time-remapping.md b/crates/rustmotion/skills/rules/time-remapping.md index 7737a17a..47f8d5bb 100644 --- a/crates/rustmotion/skills/rules/time-remapping.md +++ b/crates/rustmotion/skills/rules/time-remapping.md @@ -15,7 +15,7 @@ t_local = (t_global - time_offset) * time_scale Everything inside follows the remap: animation presets, keyframes, timeline steps, `start_at`/`end_at` windows, stagger, motion blur ghosts, and internal -animations (counter progress, `draw_in`, terminal typewriter…). +animations (counter progress, `draw_in`, `typewriter`…). **Slow-motion example** — the card's children fade in at half speed: diff --git a/crates/rustmotion/skills/rules/ui-controls.md b/crates/rustmotion/skills/rules/ui-controls.md deleted file mode 100644 index cebbee8e..00000000 --- a/crates/rustmotion/skills/rules/ui-controls.md +++ /dev/null @@ -1,30 +0,0 @@ -# UI Control Components - -## Switch, Slider, Rating — animated interactive controls - -These components simulate UI interactions with time-based animations. - -### Switch -- `toggle_at` triggers the flip — the thumb slides with ease_out_cubic -- Always add a `label` for context - -### Slider -- Set `value` for initial position, `animate_to` + `animate_at` for animation -- `show_value: true` displays the percentage above the thumb - -### Rating -- Partial stars work: `value: 4.5` shows 4 full + half star -- Stars are 5-pointed paths, not icons — no Iconify dependency - -## GOOD: Animated UI demo -```json -{ "type": "switch", "value": false, "toggle_at": 1.5, "label": "Dark Mode" }, -{ "type": "slider", "value": 0.2, "animate_to": 0.8, "animate_at": 1.0, "show_value": true }, -{ "type": "rating", "value": 4.5, "max": 5, "size": 32 } -``` - -## BAD: No animation timing -```json -{ "type": "switch", "value": true } -``` -Without `toggle_at`, the switch is static — always set a toggle time for videos. diff --git a/crates/rustmotion/skills/rules/validate-json.md b/crates/rustmotion/skills/rules/validate-json.md index 3df831f0..92ae06af 100644 --- a/crates/rustmotion/skills/rules/validate-json.md +++ b/crates/rustmotion/skills/rules/validate-json.md @@ -9,11 +9,10 @@ Every generated JSON scenario MUST be validated with `rustmotion validate` befor ## Geometry violations -The validator detects five overflow conditions: +The validator detects four overflow conditions: - `viewport_overflow` — absolute bbox crosses the device edge - `unwrappable_text_overflow` — `style.white-space: "nowrap"`/`"pre"` but natural width > box (there is no `wrap` field) - `content_overflows_box` — wrapping text needs more room than its own box, e.g. a paragraph in a fixed-height card. Fires even when the box stays inside the frame -- `auto_scroll_disabled_overflow` — `auto_scroll: false` on a codeblock/terminal with content > box - `animated_text_overflow` — an animated transform pushes the bbox out of the viewport at some sampled time (`--strict-anim` only) See [rules/geometry-safety.md](geometry-safety.md) for the underlying mechanisms. @@ -27,6 +26,6 @@ rustmotion validate -f /tmp/scenario.json --strict-anim # per-frame checks rustmotion validate -f /tmp/scenario.json --lenient # warnings only ``` -`--fix` handles the two mechanical cases: it sets `auto_scroll: true` on `auto_scroll_disabled_overflow`, and removes `style.white-space` on `unwrappable_text_overflow` (falling back to the `normal` default, i.e. wrapping). Viewport and content-box overflows are never auto-fixed — they need a layout decision only you can make. +`--fix` handles the mechanical case: it removes `style.white-space` on `unwrappable_text_overflow` (falling back to the `normal` default, i.e. wrapping). Viewport and content-box overflows are never auto-fixed — they need a layout decision only you can make. **FORBIDDEN:** Presenting JSON that has not been validated by `rustmotion validate`. diff --git a/crates/rustmotion/src/cli/commands/audio_report.rs b/crates/rustmotion/src/cli/commands/audio_report.rs new file mode 100644 index 00000000..ced7c3bb --- /dev/null +++ b/crates/rustmotion/src/cli/commands/audio_report.rs @@ -0,0 +1,532 @@ +//! Peak / RMS / true-peak measurement of a scenario's mixed soundtrack — +//! the second half of issue #334's second blind spot: "the audio I judged +//! only through peak and RMS numbers from `ffmpeg`, which is how I found +//! the mix clipping at 0dB after the fact." This module turns that +//! after-the-fact `ffmpeg -af astats` reading into a named, +//! validation-time [`AudioViolation`], surfaced through `--report` next to +//! [`super::geometry::GeometryViolation`]. +//! +//! Reuses [`rustmotion::encode::audio::mix_audio_tracks`] — the exact PCM +//! bytes the muxer writes, synthesised score included: a scenario's +//! `audio.voices`/`score` is rendered offline and appended to +//! [`rustmotion::schema::ResolvedScenario::audio`] as an ordinary +//! `AudioTrack` by `rustmotion::loader::resolve_includes_and_synthesize_audio` +//! well before this module ever runs (see that function's doc, and +//! `rustmotion::encode::audio::synthesize_score_into_track`, which it +//! calls). This module only measures; it never decodes, mixes, or +//! synthesises anything of its own. +//! +//! Wired into `validate.rs`'s `write_report`: every `--report` run measures +//! the mixed soundtrack and includes the result under the `"audio"` key, +//! next to `"geometry_violations"`. + +use rustmotion::schema::ResolvedScenario; +use serde::Serialize; + +/// dBFS floor substituted for `20*log10(0)` (`-inf`) — silence is reported +/// at this level rather than as a non-finite float, which JSON cannot +/// represent and `serde_json` refuses to serialise. +pub const SILENCE_FLOOR_DB: f32 = -120.0; + +/// A sample within this many dB of full scale counts as clipped/saturated. +/// i16 quantisation means a genuinely full-scale sample reads as +/// `20*log10(32767/32768)` ≈ -0.00027dB, never exactly `0.0` — this +/// tolerance is wide enough to catch that without also catching an +/// intentionally hot but non-clipping mix a few tenths of a dB below the +/// ceiling. +const CLIP_EPS_DB: f32 = 0.05; + +/// One measurement window's peak / RMS / true-peak, in dBFS, plus how many +/// individual samples were at or effectively at full scale. +#[derive(Debug, Clone, Copy, Serialize)] +pub struct AudioMeasurement { + pub peak_db: f32, + pub rms_db: f32, + /// A 4x-oversampled peak (Catmull-Rom cubic interpolation between + /// consecutive samples — see [`true_peak_channel`]'s doc), catching the + /// inter-sample overs a plain sample-peak reading misses. Not a + /// certified ITU-R BS.1770 true-peak meter (that spec's interpolation + /// is a specific polyphase FIR) — a heuristic close enough to flag them. + pub true_peak_db: f32, + pub clipped_samples: usize, +} + +impl AudioMeasurement { + fn silence() -> Self { + AudioMeasurement { + peak_db: SILENCE_FLOOR_DB, + rms_db: SILENCE_FLOOR_DB, + true_peak_db: SILENCE_FLOOR_DB, + clipped_samples: 0, + } + } + + fn is_clipping(&self) -> bool { + self.clipped_samples > 0 + } +} + +/// One beat window's measurement (issue #334 deliverable #2: "per beat +/// where the scenario declares a `bpm`"), `beat_index` counting up from the +/// scenario's own `beat_offset`. +#[derive(Debug, Clone, Serialize)] +pub struct BeatAudioMeasurement { + pub beat_index: usize, + pub start: f64, + pub end: f64, + pub measurement: AudioMeasurement, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AudioViolationKind { + /// The mixed soundtrack (or one beat window of it) contains at least + /// one clipped/saturated sample. + Clipping, +} + +/// One detected audio violation — the audio-side counterpart to +/// [`super::geometry::GeometryViolation`], intentionally shaped the same +/// way (a `kind`, a location, a measurement, and a human hint) so it slots +/// into `--report`'s existing JSON alongside `geometry_violations` rather +/// than inventing an unrelated second shape. +#[derive(Debug, Clone, Serialize)] +pub struct AudioViolation { + pub kind: AudioViolationKind, + /// `None` for a violation measured over the whole mixed track; + /// `Some(n)` for one beat window (see [`BeatAudioMeasurement`]). + pub beat_index: Option, + pub start: f64, + pub end: f64, + pub measurement: AudioMeasurement, + pub hint: String, +} + +/// The full audio report: the whole-track measurement, a per-beat +/// breakdown when the scenario declares a `bpm` (empty otherwise), and the +/// violations found in either. `overall` is `None` only when the scenario +/// has no audio at all — there is nothing to measure, not silence. +#[derive(Debug, Clone, Default, Serialize)] +pub struct AudioReport { + pub overall: Option, + pub beats: Vec, + pub violations: Vec, +} + +/// Measure `scenario`'s mixed soundtrack — the module's single entry point. +pub fn analyze_scenario_audio_levels(scenario: &ResolvedScenario) -> AudioReport { + if scenario.audio.is_empty() { + return AudioReport::default(); + } + + // The exact total-duration formula `synthesize_score_into_track` sizes + // a synthesised score's buffer against (see that function's doc) — not + // a second, independently-drifting derivation of "how long is this + // scenario." + let total_duration = rustmotion::encode::video_audio::resolved_scenario_duration(scenario); + if total_duration <= 0.0 { + return AudioReport::default(); + } + + // The exact PCM the muxer writes — decode/resample/gain/fade all + // already applied by `mix_audio_tracks`, so a beat window's samples here + // are the same bytes that beat's audio actually is in the rendered file. + let pcm = match rustmotion::encode::audio::mix_audio_tracks(&scenario.audio, total_duration) { + Ok(Some(bytes)) => bytes, + Ok(None) => return AudioReport::default(), + // A decode/mix failure here is `render`'s problem to surface loudly + // when it actually tries to encode; silently producing no audio + // report is preferable to duplicating that error path. + Err(_) => return AudioReport::default(), + }; + const CHANNELS: usize = 2; + let sample_rate = rustmotion::encode::audio::OUTPUT_SAMPLE_RATE; + let samples: Vec = pcm + .as_chunks::<2>() + .0 + .iter() + .map(|b| i16::from_le_bytes([b[0], b[1]])) + .collect(); + + let overall = measure(&samples, CHANNELS); + let mut violations = Vec::new(); + if overall.is_clipping() { + violations.push(AudioViolation { + kind: AudioViolationKind::Clipping, + beat_index: None, + start: 0.0, + end: total_duration, + measurement: overall, + hint: format!( + "the mixed soundtrack clips: {} sample(s) at or within {CLIP_EPS_DB:.2}dB of \ + 0dBFS (peak {:.2}dB, true peak {:.2}dB) across the whole {:.1}s track — lower \ + `master.gain` or a track's `volume`, or re-enable `master.limiter`.", + overall.clipped_samples, overall.peak_db, overall.true_peak_db, total_duration, + ), + }); + } + + let beats = beat_windows(scenario, total_duration) + .into_iter() + .map(|(beat_index, start, end)| { + let m = measure_range(&samples, sample_rate, CHANNELS, start, end); + if m.is_clipping() { + violations.push(AudioViolation { + kind: AudioViolationKind::Clipping, + beat_index: Some(beat_index), + start, + end, + measurement: m, + hint: format!( + "beat {beat_index} ({start:.3}s–{end:.3}s) clips: {} sample(s) (peak \ + {:.2}dB, true peak {:.2}dB).", + m.clipped_samples, m.peak_db, m.true_peak_db, + ), + }); + } + BeatAudioMeasurement { + beat_index, + start, + end, + measurement: m, + } + }) + .collect(); + + AudioReport { + overall: Some(overall), + beats, + violations, + } +} + +/// This scenario's own beat grid — the first scene that declares one, same +/// lookup `geometry.rs`'s `check_off_grid_cuts` uses (`ResolvedScenario` +/// itself carries no scenario-level `bpm`/`beat_offset`; see that struct's +/// doc). `(bpm, beat_offset)`. +fn scenario_beat_grid(scenario: &ResolvedScenario) -> Option<(f64, f64)> { + scenario + .views + .iter() + .flat_map(|v| v.scenes.iter()) + .find_map(|s| { + s.resolved_time_ctx + .bpm + .filter(|b| *b > 0.0) + .map(|bpm| (bpm, s.resolved_time_ctx.beat_offset)) + }) +} + +/// `(beat_index, start, end)` for every beat window `beat_offset + n * +/// 60/bpm` that overlaps `[0, total_duration)` — empty when the scenario +/// declares no `bpm`. A negative-starting first window (a positive +/// `beat_offset` shifts window 0 before scenario start) is clamped to 0 +/// rather than skipped, so nothing before the first full beat is left +/// unmeasured. +fn beat_windows(scenario: &ResolvedScenario, total_duration: f64) -> Vec<(usize, f64, f64)> { + let Some((bpm, beat_offset)) = scenario_beat_grid(scenario) else { + return Vec::new(); + }; + let beat_len = 60.0 / bpm; + if !beat_len.is_finite() || beat_len <= 0.0 { + return Vec::new(); + } + + let mut windows = Vec::new(); + let mut n = 0usize; + loop { + let start = beat_offset + n as f64 * beat_len; + if start >= total_duration { + break; + } + let end = (start + beat_len).min(total_duration); + let start = start.max(0.0); + if end > start { + windows.push((n, start, end)); + } + n += 1; + // A pathological (near-zero) `bpm`/duration combination must not + // spin forever; no real scenario needs more beats than this. + if n > 100_000 { + break; + } + } + windows +} + +fn lin_to_db(x: f32) -> f32 { + if x <= 1e-6 { + SILENCE_FLOOR_DB + } else { + (20.0 * x.log10()).max(SILENCE_FLOOR_DB) + } +} + +fn db_to_lin(db: f32) -> f32 { + 10f32.powf(db / 20.0) +} + +/// Peak / RMS / true-peak / clipped-sample-count of a whole interleaved i16 +/// PCM buffer. +fn measure(samples_i16: &[i16], channels: usize) -> AudioMeasurement { + measure_slice(samples_i16, channels) +} + +/// The same measurement, restricted to `[start, end)` seconds of the mix — +/// `start`/`end` are scenario-timeline seconds, matching +/// `mix_audio_tracks`'s own convention that sample 0 is scenario t=0. +fn measure_range( + samples_i16: &[i16], + sample_rate: u32, + channels: usize, + start: f64, + end: f64, +) -> AudioMeasurement { + let frame_count = samples_i16.len() / channels.max(1); + let start_frame = ((start * sample_rate as f64).round() as i64) + .max(0) + .min(frame_count as i64) as usize; + let end_frame = ((end * sample_rate as f64).round() as i64) + .max(0) + .min(frame_count as i64) as usize; + if end_frame <= start_frame { + return AudioMeasurement::silence(); + } + measure_slice( + &samples_i16[start_frame * channels..end_frame * channels], + channels, + ) +} + +fn measure_slice(samples_i16: &[i16], channels: usize) -> AudioMeasurement { + if samples_i16.is_empty() || channels == 0 { + return AudioMeasurement::silence(); + } + let norm: Vec = samples_i16.iter().map(|&s| s as f32 / 32768.0).collect(); + let clip_threshold = db_to_lin(-CLIP_EPS_DB); + + let mut peak = 0.0f32; + let mut sum_sq = 0.0f64; + let mut clipped = 0usize; + for &s in &norm { + let a = s.abs(); + peak = peak.max(a); + sum_sq += (s as f64) * (s as f64); + if a >= clip_threshold { + clipped += 1; + } + } + let rms = (sum_sq / norm.len() as f64).sqrt() as f32; + + let mut true_peak = peak; + for ch in 0..channels { + let channel_samples: Vec = norm.iter().skip(ch).step_by(channels).copied().collect(); + true_peak = true_peak.max(true_peak_channel(&channel_samples)); + } + + AudioMeasurement { + peak_db: lin_to_db(peak), + rms_db: lin_to_db(rms), + true_peak_db: lin_to_db(true_peak), + clipped_samples: clipped, + } +} + +/// Catmull-Rom-interpolated (4x) oversample of one channel's normalized +/// `[-1, 1]` samples, returning the maximum absolute value seen across the +/// original samples plus the 3 interpolated points between every +/// consecutive pair. +/// +/// Deliberately not linear interpolation: a linear interpolant is a convex +/// combination of its two neighbours and can mathematically never exceed +/// both of them, so it would report the sample peak back unchanged and +/// catch nothing a plain peak reading didn't already. Catmull-Rom (a +/// 4-point cubic through each pair, using the point before and after it for +/// tangent shape) *can* genuinely overshoot near a sharp transition — the +/// same kind of ringing a bandlimited reconstruction filter produces, which +/// is what a true-peak measurement exists to catch. +fn true_peak_channel(samples: &[f32]) -> f32 { + let n = samples.len(); + if n < 2 { + return samples.iter().fold(0.0f32, |m, &s| m.max(s.abs())); + } + const OVERSAMPLE: usize = 4; + let mut peak = samples.iter().fold(0.0f32, |m, &s| m.max(s.abs())); + for i in 0..n - 1 { + let p0 = if i == 0 { samples[0] } else { samples[i - 1] }; + let p1 = samples[i]; + let p2 = samples[i + 1]; + let p3 = if i + 2 < n { + samples[i + 2] + } else { + samples[n - 1] + }; + for k in 1..OVERSAMPLE { + let t = k as f32 / OVERSAMPLE as f32; + peak = peak.max(catmull_rom(p0, p1, p2, p3, t).abs()); + } + } + peak +} + +fn catmull_rom(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { + let t2 = t * t; + let t3 = t2 * t; + 0.5 * ((2.0 * p1) + + (-p0 + p2) * t + + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion::loader::load_scenario_from_source; + + fn parse(json: &str) -> ResolvedScenario { + load_scenario_from_source(None, Some(json)).expect("scenario parses") + } + + #[test] + fn no_audio_produces_an_empty_report() { + let json = r##"{ + "video": { "width": 320, "height": 180 }, + "scenes": [{ "duration": 1.0, "children": [] }] + }"##; + let report = analyze_scenario_audio_levels(&parse(json)); + assert!(report.overall.is_none()); + assert!(report.violations.is_empty()); + } + + #[test] + fn catmull_rom_reproduces_the_unclamped_linear_segment_at_its_endpoints() { + assert_eq!(catmull_rom(0.0, 1.0, 2.0, 3.0, 0.0), 1.0); + assert_eq!(catmull_rom(0.0, 1.0, 2.0, 3.0, 1.0), 2.0); + } + + #[test] + fn true_peak_channel_never_reports_less_than_the_sample_peak() { + let samples = vec![0.0, 0.5, -0.9, 0.2, -0.3, 0.95, 0.0]; + let sample_peak = samples.iter().fold(0.0f32, |m, s: &f32| m.max(s.abs())); + assert!(true_peak_channel(&samples) >= sample_peak - 1e-6); + } + + #[test] + fn measure_slice_reports_silence_floor_for_all_zero_samples() { + let m = measure_slice(&[0i16; 100], 2); + assert_eq!(m.peak_db, SILENCE_FLOOR_DB); + assert_eq!(m.rms_db, SILENCE_FLOOR_DB); + assert_eq!(m.clipped_samples, 0); + } + + #[test] + fn measure_slice_flags_full_scale_samples_as_clipped() { + let m = measure_slice(&[i16::MAX, i16::MIN, 0, 0], 2); + assert!(m.is_clipping()); + assert_eq!(m.clipped_samples, 2); + assert!( + m.peak_db > -0.1, + "expected near-0dBFS peak, got {}", + m.peak_db + ); + } + + // ─── End-to-end: a synthesised score, driven into clipping ───────────── + // + // `master.limiter` defaults to `true` (a hard guarantee — see + // `rustmotion_core::audio::dsp::apply_limiter`'s doc) specifically so an + // author cannot accidentally clip a synthesised score. Deliberately + // disabling it plus a large `master.gain` is the one way to construct a + // real clipping repro through the schema's own vocabulary — the + // equivalent of a human mixing engineer bypassing their own limiter. + + const SYNTH_CLIPPING_JSON: &str = r##"{ + "video": { "width": 320, "height": 180, "fps": 30 }, + "bpm": 120, + "audio": { + "voices": { + "tone": { + "type": "sine", "freq": 440, + "attack": 0.01, "decay": 0.05, "sustain": 1.0, + "hold": 0.3, "release": 0.05, "gain": 1.0 + } + }, + "score": [ + { "voice": "tone", "every": 0.5, "from": 0, "to": 2.0 } + ], + "master": { "gain": 12.0, "limiter": false } + }, + "scenes": [{ "duration": 2.0, "children": [] }] + }"##; + + const SYNTH_CLEAN_JSON: &str = r##"{ + "video": { "width": 320, "height": 180, "fps": 30 }, + "bpm": 120, + "audio": { + "voices": { + "tone": { + "type": "sine", "freq": 440, + "attack": 0.01, "decay": 0.05, "sustain": 1.0, + "hold": 0.3, "release": 0.05, "gain": 0.3 + } + }, + "score": [ + { "voice": "tone", "every": 0.5, "from": 0, "to": 2.0 } + ] + }, + "scenes": [{ "duration": 2.0, "children": [] }] + }"##; + + #[test] + fn a_synthesised_score_with_the_limiter_disabled_and_gain_cranked_clips() { + let scenario = parse(SYNTH_CLIPPING_JSON); + assert!( + !scenario.audio.is_empty(), + "the synthesised score must have been rendered and appended as an AudioTrack" + ); + let report = analyze_scenario_audio_levels(&scenario); + let overall = report.overall.expect("audio present"); + assert!( + overall.is_clipping(), + "expected the +12dB, limiter-disabled mix to clip, got {overall:?}" + ); + assert!( + report + .violations + .iter() + .any(|v| v.kind == AudioViolationKind::Clipping && v.beat_index.is_none()), + "expected a whole-track clipping violation: {:?}", + report.violations + ); + // At 120bpm every 0.5s scoring event lands exactly on a beat, so a + // beat-scoped violation is also expected for at least one beat. + assert!( + report + .violations + .iter() + .any(|v| v.kind == AudioViolationKind::Clipping && v.beat_index.is_some()), + "expected at least one beat-scoped clipping violation: {:?}", + report.violations + ); + assert!( + !report.beats.is_empty(), + "bpm is declared, so the per-beat breakdown must be populated" + ); + } + + #[test] + fn the_same_score_at_a_sane_gain_with_the_limiter_on_does_not_clip() { + let scenario = parse(SYNTH_CLEAN_JSON); + let report = analyze_scenario_audio_levels(&scenario); + let overall = report.overall.expect("audio present"); + assert!( + !overall.is_clipping(), + "expected a modest, limiter-protected mix not to clip: {overall:?}" + ); + assert!( + report.violations.is_empty(), + "expected no violations for a clean mix: {:?}", + report.violations + ); + } +} diff --git a/crates/rustmotion/src/cli/commands/geometry.rs b/crates/rustmotion/src/cli/commands/geometry.rs index 62d79540..5d75b273 100644 --- a/crates/rustmotion/src/cli/commands/geometry.rs +++ b/crates/rustmotion/src/cli/commands/geometry.rs @@ -13,8 +13,6 @@ //! * detect wrapping content whose natural size exceeds its own resolved //! box (`text`/`gradient_text`/`caption`/`rich_text`/`table` — #128 //! item 1: originally `text`-only) -//! * detect terminal/codeblock content that overflows their box when -//! `auto_scroll: false` //! * exempt `marquee` and `cursor` (designed to bleed) //! * never report a node clipped by an `overflow: hidden`/`clip`/`scroll`/ //! `auto` ancestor as a viewport overflow (H4) — the ancestor's own bbox @@ -56,8 +54,7 @@ use rustmotion::components::box_builder::{ build_scene_from_refs, component_kind, effective_effects, BuildAnimationCtx, }; use rustmotion::components::intrinsic::{ - CaptionIntrinsic, CodeblockIntrinsic, GradientTextIntrinsic, RichTextIntrinsic, TableIntrinsic, - TerminalIntrinsic, TextIntrinsic, + CaptionIntrinsic, GradientTextIntrinsic, RichTextIntrinsic, TableIntrinsic, TextIntrinsic, }; use rustmotion::components::{ChildComponent, Component}; use rustmotion::core::css::style::{ @@ -70,7 +67,7 @@ use rustmotion::core::engine::box_tree::{AvailableSpace, BoxKind, BoxNode, Intri use rustmotion::core::engine::layout_pass::{run_layout, BoxLayout, LayoutResult}; use rustmotion::engine::animator::{resolve_props_for_effects, AnimatedProperties}; use rustmotion::engine::render; -use rustmotion::schema::{Camera, ResolvedScenario, Scene, ViewType}; +use rustmotion::schema::{Camera, ResolvedScenario, ResolvedView, Scene, TransitionType, ViewType}; use serde::Serialize; /// One detected layout violation. @@ -109,8 +106,6 @@ pub enum ViolationKind { /// `white-space: nowrap`/`pre` set but the natural width exceeds the /// allocated width. UnwrappableTextOverflow, - /// terminal/codeblock has `auto_scroll: false` but content > box. - AutoScrollDisabledOverflow, /// Wrapping text's content, measured at the width its own box was /// actually assigned, needs more width (an unbreakable word/token) or /// height (wrapped lines) than that box's `content_box()` — e.g. a @@ -333,16 +328,6 @@ fn walk( out, ); } - check_auto_scroll( - &child.component, - &child_path, - layout, - own_bound, - viewport, - vi, - si, - out, - ); // Suppressed under a clipping ancestor (parent_clips) exactly // like check_viewport, and when the node clips its own overflow // (paint_pass applies a node's own `overflow: hidden`/clip/ @@ -454,10 +439,6 @@ fn bleeds(child: &ChildComponent) -> bool { fn container_children(c: &Component) -> Option<&[ChildComponent]> { match c { - Component::Card(card) => Some(&card.children), - Component::Flex(flex) => Some(&flex.children), - Component::Grid(grid) => Some(&grid.children), - Component::Positioned(pos) => Some(&pos.children), Component::Container(c) => Some(&c.children), _ => None, } @@ -789,14 +770,7 @@ fn hint_for_viewport(component: &Component, axis: Axis, bbox: &BBox, vp: (u32, u /// `intrinsic.rs`'s "M1 follow-up" doc comments), so their measured size /// agrees with what gets painted; always `false` for the rest. /// -/// Deliberately excludes `codeblock`/`terminal`: both have an `auto_scroll` -/// escape hatch (default `true`) that makes "natural content taller than -/// the assigned box" an *intentional*, painter-handled clip+scroll rather -/// than a defect — `check_auto_scroll` already covers the `auto_scroll: -/// false` case correctly. A blanket natural-vs-own-box comparison here would -/// false-positive on every ordinary `auto_scroll: true` codeblock/terminal -/// that's deliberately given a smaller-than-natural box to scroll within. -/// Also excludes atomic single-line components (`badge`/`kbd`/`counter`) — +/// Excludes atomic single-line components (`badge`/`kbd`/`counter`) — /// out of scope for this pass, see the workstream report. fn measurer_and_nowrap(component: &Component) -> Option<(Box, bool)> { fn is_nowrap(ws: &Option) -> bool { @@ -811,6 +785,7 @@ fn measurer_and_nowrap(component: &Component) -> Option<(Box Some(( Box::new(CaptionIntrinsic::from_caption(c)), is_nowrap(&c.style.white_space), @@ -822,8 +797,8 @@ fn measurer_and_nowrap(component: &Component) -> Option<(Box, - viewport: (u32, u32), - vi: usize, - si: usize, - out: &mut Vec, -) { - let max_content = (AvailableSpace::MaxContent, AvailableSpace::MaxContent); - match component { - Component::Codeblock(cb) if !cb.auto_scroll => { - let (_, natural_h) = - CodeblockIntrinsic::from_codeblock(cb).measure((None, None), max_content); - let mut bbox = bbox_of(layout); - if let Some((_, bh)) = container_bound { - bbox.h = bbox.h.min(bh); - } - if natural_h > bbox.h + 0.5 { - out.push(GeometryViolation { - view_index: vi, - scene_index: si, - path: path.to_string(), - component: "codeblock".to_string(), - axis: Axis::Y, - kind: ViolationKind::AutoScrollDisabledOverflow, - bbox, - viewport, - hint: format!( - "codeblock content needs ~{:.0}px but box is {:.0}px — enable auto_scroll or shorten code", - natural_h, bbox.h - ), - }); - } - } - Component::Terminal(t) if !t.auto_scroll => { - let (_, natural_h) = - TerminalIntrinsic::from_terminal(t).measure((None, None), max_content); - let (cx, cy, cw, ch) = layout.content_box(); - let ch = match container_bound { - Some((_, bh)) => ch.min(bh), - None => ch, - }; - if natural_h > ch + 0.5 { - out.push(GeometryViolation { - view_index: vi, - scene_index: si, - path: path.to_string(), - component: "terminal".to_string(), - axis: Axis::Y, - kind: ViolationKind::AutoScrollDisabledOverflow, - bbox: BBox { - x: cx, - y: cy, - w: cw, - h: ch, - }, - viewport, - hint: format!( - "terminal content needs ~{:.0}px but box is {:.0}px — enable auto_scroll or remove lines", - natural_h, ch - ), - }); - } - } - _ => {} - } -} - // ─── M4: legibility floor (issue #110 / #102) ────────────────────────────── // // "Fits in the frame" (checked above) is not "readable in a video". A table -// column, a badge, a codeblock line — any of them can validate perfectly +// column, a badge, a caption line — any of them can validate perfectly // clean while rendering at a font size nobody could read once the video is // scaled down from its native resolution, which is how video is normally // watched (embedded players, mobile feeds, thumbnails) unlike a web page, @@ -1183,8 +1055,8 @@ fn check_auto_scroll( /// Coverage: every component whose `Painter` resolves its rendered font /// size from `style.font-size` (falling back to that component's own /// documented default when unset) — text, rich_text, gradient_text, -/// caption, counter, table, terminal, codeblock, callout, list, -/// notification (title + message), pill_nav, badge, kbd, tooltip, marquee. +/// caption, counter, table, callout, list, pill_nav, badge, kbd, tooltip, +/// marquee. /// Not covered: components whose text sizing isn't a simple /// `style.font-size`-or-default resolution (chart axis/labels, gauge, stat, /// sparkline, heatmap, treemap, dot_map, avatar initials, progress label, @@ -1272,8 +1144,7 @@ fn walk_legibility( /// default each `Painter` falls back to when `style.font-size` is unset /// (see the file/line citations below — kept in sync by hand since these /// defaults live in `rustmotion-components`, out of this workstream's -/// scope). A component can report more than one size (e.g. a notification's -/// title and message use different sizes). +/// scope). A component can report more than one size. /// Whether this component's painter actually honours `style.text-autofit`. /// Deliberately the same two variants `TextIntrinsic::with_autofit` is called /// for — every other component ignores the field, so warning about them would @@ -1286,6 +1157,16 @@ fn declares_text_autofit(component: &Component) -> bool { } } +// `Counter`/`PillNav`/`Callout`/`List`/`Kbd`/`Tooltip`/`Marquee`/`Badge` are +// eight of the twenty-seven frozen-composition components deprecated by +// issue #333 — most of this function's own match arms read a field of one +// of them. (`Notification` used to be a ninth; it was deleted outright +// rather than merely deprecated.) Narrowest scope that still compiles: +// the whole function, not a per-arm `#[allow(deprecated)]` nine times over, +// since deprecating a struct deprecates every field read on it and this +// function's entire purpose is reading exactly those fields for the +// legibility-floor table below. +#[allow(deprecated)] fn text_sizes(component: &Component) -> Vec<(&'static str, f32)> { match component { // text.rs, rich_text.rs, gradient_text.rs, caption.rs, counter.rs: 48.0 @@ -1294,23 +1175,12 @@ fn text_sizes(component: &Component) -> Vec<(&'static str, f32)> { Component::GradientText(t) => vec![("gradient_text", t.style.font_size_px_or(48.0))], Component::Caption(t) => vec![("caption", t.style.font_size_px_or(48.0))], Component::Counter(c) => vec![("counter", c.style.font_size_px_or(48.0))], - // table.rs, terminal.rs, codeblock/{dimensions,render}.rs, pill_nav.rs: 14.0 + // table.rs, pill_nav.rs: 14.0 Component::Table(t) => vec![("table", t.style.font_size_px_or(14.0))], - Component::Terminal(t) => vec![("terminal", t.style.font_size_px_or(14.0))], - Component::Codeblock(c) => vec![("codeblock", c.style.font_size_px_or(14.0))], Component::PillNav(p) => vec![("pill_nav", p.style.font_size_px_or(14.0))], - // callout.rs, list.rs, notification.rs (title): 16.0 + // callout.rs, list.rs: 16.0 Component::Callout(c) => vec![("callout", c.style.font_size_px_or(16.0))], Component::List(l) => vec![("list", l.style.font_size_px_or(16.0))], - Component::Notification(n) => { - let title = n.style.font_size_px_or(16.0); - let mut sizes = vec![("notification title", title)]; - if n.message.is_some() { - // notification.rs: message_font_size() = title_font_size() * 0.85 - sizes.push(("notification message", title * 0.85)); - } - sizes - } // These carry their own `font_size` field (already serde-resolved // to its component default when absent from JSON), overridable by // `style.font-size` exactly like the rest — kbd.rs, tooltip.rs, @@ -1410,20 +1280,8 @@ pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec = if is_world { - indexed - .into_iter() - .filter(|(_, c)| !c.is_decorative()) - .collect() - } else { - indexed - }; - let raw_indices: Vec = indexed.iter().map(|(i, _)| *i).collect(); - let children: Vec = indexed.into_iter().map(|(_, c)| c).collect(); + let (children, raw_indices) = scene_geometry_children(view, scene); let viewport = (scenario.video.width, scenario.video.height); - let viewport_f = (viewport.0 as f32, viewport.1 as f32); let camera = scene .camera @@ -1448,35 +1306,28 @@ pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec Vec (Vec, Vec) { + let is_world = matches!(view.view_type, ViewType::World); + let indexed = deserialize_children_indexed(scene); + let indexed: Vec<(usize, ChildComponent)> = if is_world { + indexed + .into_iter() + .filter(|(_, c)| !c.is_decorative()) + .collect() + } else { + indexed + }; + let raw_indices: Vec = indexed.iter().map(|(i, _)| *i).collect(); + let children: Vec = indexed.into_iter().map(|(_, c)| c).collect(); + (children, raw_indices) +} + +/// Build the box tree at one specific `(time, scenario_time)` and walk it — +/// the inner body [`validate_geometry_animated`] runs once per sampled +/// instant, factored out so [`validate_geometry_transitions`] can run the +/// exact same construction at a transition frame's own local time, with one +/// extra knob an ordinary in-scene sample never needs: `transition_label`, +/// prefixed onto every violation's hint so a `--report` reader can tell +/// "only found during a transition frame" apart from "found on this scene's +/// own resting/animated sampling" without cross-referencing paths by hand. +#[allow(clippy::too_many_arguments)] +fn sample_scene_geometry( + scene: &Scene, + children: &[ChildComponent], + raw_indices: &[usize], + view: &ResolvedView, + vi: usize, + si: usize, + viewport: (u32, u32), + camera: Option<&Camera>, + path_root: &str, + fps: u32, + time: f64, + scenario_time: f64, + transition_label: Option<&str>, + seen: &mut HashSet<(usize, usize, String)>, + out: &mut Vec, +) { + let viewport_f = (viewport.0 as f32, viewport.1 as f32); + let root_css = render::root_style(scene.layout.as_ref(), view.view_type.clone()); + let anim = Some(BuildAnimationCtx { + time, + scenario_time, + scene_duration: scene.duration, + fps, + }); + let built = build_scene_from_refs(children.iter(), viewport_f, root_css, anim); + let layouts = run_layout( + &built.root, + viewport_f, + &ConversionContext::for_viewport(viewport_f.0, viewport_f.1), + ); + + walk_anim( + children, + &built.root.children, + &layouts, + &built.stagger_delays, + &built.time_params, + viewport, + vi, + si, + path_root, + Some(raw_indices), + /*parent_clips=*/ false, + camera, + transition_label, + time, + scene.duration, + seen, + out, + ); +} + /// `boxes` filtered down to principal nodes — motion-blur/trail ghosts /// (`BoxKind::Ghost`, only ever generated when the box tree is built with a /// real `BuildAnimationCtx`, see `build_ghosts` in `box_builder.rs`) are @@ -1520,6 +1459,10 @@ fn walk_anim( path_indices: Option<&[usize]>, parent_clips: bool, camera: Option<&Camera>, + // `Some` when this sample belongs to a `SlideTransition`/`ViewTransition` + // frame task rather than an ordinary in-scene sample — see + // `sample_scene_geometry`'s doc. + transition_label: Option<&str>, time: f64, scene_duration: f64, seen: &mut HashSet<(usize, usize, String)>, @@ -1638,7 +1581,13 @@ fn walk_anim( kind: ViolationKind::AnimatedTextOverflow, bbox: transformed, viewport, - hint: hint_for_animated(&child.component, &props, time, scene_duration), + hint: hint_for_animated( + &child.component, + &props, + time, + scene_duration, + transition_label, + ), }); } } @@ -1658,6 +1607,7 @@ fn walk_anim( None, parent_clips || container_clips(&child.component), camera, + transition_label, time, scene_duration, seen, @@ -1689,6 +1639,12 @@ fn hint_for_animated( props: &AnimatedProperties, time: f64, scene_duration: f64, + // `Some` when this violation came from `validate_geometry_transitions` + // rather than an ordinary `validate_geometry_animated` sample — see + // `sample_scene_geometry`'s doc. Prefixed onto the message so a + // `--report` reader can tell the two apart without cross-referencing + // paths by hand. + transition_label: Option<&str>, ) -> String { let ratio = if scene_duration > 1e-6 { time / scene_duration @@ -1704,15 +1660,282 @@ fn hint_for_animated( props.scale_x, props.scale_y, ); - match component_kind(component) { + let msg = match component_kind(component) { "text" | "rich_text" | "gradient_text" | "caption" | "counter" => format!( "{} — reduce font_size, soften the preset (e.g. fade_in instead of slide_in_left), or add max_width", base ), _ => format!("{} — soften the preset or pull the resting position inward", base), + }; + match transition_label { + Some(label) => format!("{label}: {msg}"), + None => msg, + } +} + +// ─── Transition-frame sampling (#334) ────────────────────────────────────── +// +// `validate_geometry`/`validate_geometry_animated` both iterate `view.scenes` +// and sample within `[0, scene_duration]`. Neither ever looks at a +// `FrameTask::SlideTransition`/`FrameTask::ViewTransition` — the composite of +// two already-rendered frame buffers `render_frame_task_scaled` builds +// between two scenes (or two views) is unvalidated on `main`, which is this +// engine's own copy of the blind spot issue #334 names: "the transitions, I +// never saw them play." +// +// This is not just "sample more densely": under `timing: "v2"` (issue #336), +// a transition entering scene `i+1` renders scene `i` an *additional* +// `transition_frames(i+1)` frames PAST its own `[0, scene_duration]` window +// instead of stealing from inside it (`build_slide_view_tasks_v2`, when the +// outgoing scene's `tail` is `"continue"`) — so the scene being sampled +// during a transition frame can be running at a local time +// `anim_sample_times` never generates for it at all, not merely one it +// happens to skip between two samples. `frame_a_idx`/`frame_in_transition` +// (read straight off the `FrameTask`, matching `render_frame_task_scaled`'s +// own arithmetic byte-for-byte) are the only reliable source for "what local +// time is this scene actually rendered at right now." + +/// Mirrors `engine::render::scene`'s private `SceneTime::clamp` — a scene +/// paints nothing past `freeze_at`, so a transition frame asking for a local +/// time beyond it must clamp the same way an ordinary `Normal` frame already +/// does. Duplicated rather than called: `SceneTime` is private to a file +/// outside this workstream's owned perimeter (`geometry.rs`/`validate.rs`/ +/// `validation.rs`) — the same reasoning `fold_static_camera`'s doc comment +/// gives for its own duplicated formula. +/// +/// Called from [`validate_geometry_transitions`], itself wired into +/// `validation.rs`'s `run_checks` under `--strict-anim`. +fn clamp_to_scene_freeze(scene: &Scene, raw: f64) -> f64 { + match scene.freeze_at { + Some(freeze_at) if raw > freeze_at => freeze_at, + _ => raw, } } +// `TransitionType::CameraPan` `SlideTransition`s are deliberately NOT +// sampled below (both sides skipped outright, like `ViewTransition`'s +// `world`-view sides just below). `camera_pan_transition` +// (`rustmotion_core::engine::transition`) genuinely translates each side's +// foreground on screen by up to the full `Scene::world_position` delta +// between the two scenes — commonly close to a full viewport width, since +// the usual use is "the next scene over". Sliding fully off (and the +// incoming scene fully on) is that mechanism working as designed, not a +// defect a validator should ever name — unlike an ordinary pixel-composite +// transition (fade/wipe/slide/…), where each side is rendered at its own +// undisturbed layout and *that* is exactly what this checker validates. +// Folding the pan's own translation in and then bounds-checking it would +// false-positive on every such transition, at both of its ends, every time. + +/// Sample every `SlideTransition`/`ViewTransition` frame task +/// [`rustmotion::encode::build_frame_tasks`] schedules, and report the same +/// `AnimatedTextOverflow` violations [`validate_geometry_animated`] reports +/// for an ordinary scene sample — reusing that exact `ViolationKind` (not a +/// new one) so this stays inside the frozen `--report` JSON shape and every +/// existing consumer of it (including `validate.rs`'s `apply_fixes`, whose +/// match over `ViolationKind` lives outside this workstream's owned files) +/// keeps compiling unchanged. +/// +/// Only ever called under `--strict-anim`, exactly like +/// `validate_geometry_animated` — see that call site in `validation.rs`'s +/// `run_checks`: sampling every transition frame at full layout cost is the +/// same trade this workstream already accepted for ordinary animated frames. +/// +/// `ViewTransition` sides are only sampled when that side's own view is +/// `ViewType::Slide` — a `world` view's boundary frame is a camera-composited +/// blend of several scenes (`render_world_frame_scaled`), which no per-scene +/// geometry walker in this file models (pre-existing limitation of +/// `validate_geometry`/`validate_geometry_animated` too: neither folds the +/// world camera's continuous pan into a scene's own bbox check). Sampling it +/// as if it were an ordinary scene would be actively wrong, not merely +/// incomplete, so it is skipped rather than guessed at. +pub fn validate_geometry_transitions(scenario: &ResolvedScenario) -> Vec { + use rustmotion::encode::video::FrameTask; + + let mut violations = Vec::new(); + let fps = scenario.video.fps; + if fps == 0 { + return violations; + } + let tasks = rustmotion::encode::build_frame_tasks(scenario); + let mut seen: HashSet<(usize, usize, String)> = HashSet::new(); + + for task in &tasks { + match task { + FrameTask::SlideTransition { + global_frame, + view_idx, + scene_a_idx, + scene_b_idx, + frame_in_transition, + scene_a_frame_offset, + scene_a_frame_advance, + transition_type, + .. + } => { + // See the module-level comment right above this function for + // why `CameraPan` is skipped outright rather than folded in + // and bounds-checked. + if matches!(transition_type, TransitionType::CameraPan) { + continue; + } + + let view = &scenario.views[*view_idx]; + let scene_a = &view.scenes[*scene_a_idx]; + let scene_b = &view.scenes[*scene_b_idx]; + let scenario_time = *global_frame as f64 / fps as f64; + + let frame_a_idx = if *scene_a_frame_advance { + scene_a_frame_offset + frame_in_transition + } else { + *scene_a_frame_offset + }; + let time_a = clamp_to_scene_freeze(scene_a, frame_a_idx as f64 / fps as f64); + let time_b = + clamp_to_scene_freeze(scene_b, *frame_in_transition as f64 / fps as f64); + + let label_a = format!( + "SlideTransition scene {scene_a_idx}->{scene_b_idx}, frame {frame_in_transition}, \ + outgoing side (its own local time reaches {time_a:.3}s)" + ); + let label_b = format!( + "SlideTransition scene {scene_a_idx}->{scene_b_idx}, frame {frame_in_transition}, \ + incoming side (local time {time_b:.3}s)" + ); + + let (children_a, raw_indices_a) = scene_geometry_children(view, scene_a); + let camera_a = scene_a + .camera + .as_ref() + .filter(|_| !scene_uses_depth(&children_a)); + let path_root_a = format!("views[{view_idx}].scenes[{scene_a_idx}]"); + sample_scene_geometry( + scene_a, + &children_a, + &raw_indices_a, + view, + *view_idx, + *scene_a_idx, + (scenario.video.width, scenario.video.height), + camera_a, + &path_root_a, + fps, + time_a, + scenario_time, + Some(&label_a), + &mut seen, + &mut violations, + ); + + let (children_b, raw_indices_b) = scene_geometry_children(view, scene_b); + let camera_b = scene_b + .camera + .as_ref() + .filter(|_| !scene_uses_depth(&children_b)); + let path_root_b = format!("views[{view_idx}].scenes[{scene_b_idx}]"); + sample_scene_geometry( + scene_b, + &children_b, + &raw_indices_b, + view, + *view_idx, + *scene_b_idx, + (scenario.video.width, scenario.video.height), + camera_b, + &path_root_b, + fps, + time_b, + scenario_time, + Some(&label_b), + &mut seen, + &mut violations, + ); + } + FrameTask::ViewTransition { + global_frame, + view_a_idx, + view_b_idx, + .. + } => { + let scenario_time = *global_frame as f64 / fps as f64; + + let view_a = &scenario.views[*view_a_idx]; + if matches!(view_a.view_type, ViewType::Slide) { + if let Some(last_idx) = view_a.scenes.len().checked_sub(1) { + let scene = &view_a.scenes[last_idx]; + let scene_frames = (scene.duration * fps as f64).round() as u32; + let time = clamp_to_scene_freeze( + scene, + scene_frames.saturating_sub(1) as f64 / fps as f64, + ); + let label = format!( + "ViewTransition view {view_a_idx}->{view_b_idx}, outgoing view's last frame" + ); + let (children, raw_indices) = scene_geometry_children(view_a, scene); + let camera = scene + .camera + .as_ref() + .filter(|_| !scene_uses_depth(&children)); + let path_root = format!("views[{view_a_idx}].scenes[{last_idx}]"); + sample_scene_geometry( + scene, + &children, + &raw_indices, + view_a, + *view_a_idx, + last_idx, + (scenario.video.width, scenario.video.height), + camera, + &path_root, + fps, + time, + scenario_time, + Some(&label), + &mut seen, + &mut violations, + ); + } + } + + let view_b = &scenario.views[*view_b_idx]; + if matches!(view_b.view_type, ViewType::Slide) { + if let Some(scene) = view_b.scenes.first() { + let time = clamp_to_scene_freeze(scene, 0.0); + let label = format!( + "ViewTransition view {view_a_idx}->{view_b_idx}, incoming view's first frame" + ); + let (children, raw_indices) = scene_geometry_children(view_b, scene); + let camera = scene + .camera + .as_ref() + .filter(|_| !scene_uses_depth(&children)); + let path_root = format!("views[{view_b_idx}].scenes[0]"); + sample_scene_geometry( + scene, + &children, + &raw_indices, + view_b, + *view_b_idx, + 0, + (scenario.video.width, scenario.video.height), + camera, + &path_root, + fps, + time, + scenario_time, + Some(&label), + &mut seen, + &mut violations, + ); + } + } + } + _ => {} + } + } + + violations +} + /// Render a violation for human consumption (multi-line, color-free). pub fn format_violation(v: &GeometryViolation) -> String { let axis_str = match v.axis { @@ -1723,7 +1946,6 @@ pub fn format_violation(v: &GeometryViolation) -> String { let kind_str = match v.kind { ViolationKind::ViewportOverflow => "viewport overflow", ViolationKind::UnwrappableTextOverflow => "wrap=false but text too wide", - ViolationKind::AutoScrollDisabledOverflow => "auto_scroll=false but content too tall", ViolationKind::ContentOverflowsBox => "wrapped content exceeds its own box", ViolationKind::ContentOverflowsCard => "component extends past its containing card", ViolationKind::AnimatedTextOverflow => "animation pushes content outside viewport", @@ -1746,6 +1968,106 @@ pub fn format_violation(v: &GeometryViolation) -> String { ) } +/// Advisory check (issue #336): when `bpm` is set, warn about a scene whose +/// resolved cut — the frame at which it actually starts appearing, once +/// `at`/transitions/`timing` are all accounted for — doesn't land on the +/// beat grid `beat_offset + n * 60 / bpm`. +/// +/// Always a warning, never a blocking error (unlike `unresolved_beat_unit` +/// in `validate_schema.rs`, which is about a cut that cannot be *computed* +/// at all): an off-grid cut still renders exactly as declared, it just +/// isn't rhythmic. `snap: "beat"` is the fix this points authors at. +/// +/// Reuses `rustmotion::encode::build_frame_tasks` rather than re-deriving +/// cut positions independently — that scheduler (a different workstream's +/// file within this crate, read here, not edited) is the single source of +/// truth for where a cut actually falls once transitions/gaps/`timing` are +/// applied; a second implementation here could silently drift from it. +/// Slide views only, matching that scheduler's own `timing: "v2"` scope — +/// a `world` view's continuous camera pan has no "cut" this check's model +/// applies to. +pub fn check_off_grid_cuts(scenario: &ResolvedScenario) -> Vec { + use rustmotion::encode::video::FrameTask; + use std::collections::HashMap; + + let mut warnings = Vec::new(); + let fps = scenario.video.fps; + if fps == 0 { + return warnings; + } + + let tasks = rustmotion::encode::build_frame_tasks(scenario); + + // First frame at which each (view, scene) becomes the *entering* side + // of a cut: either the first frame of the transition blending it in, + // or — with no transition — its own first Normal frame. + let mut cut_frame: HashMap<(usize, usize), u32> = HashMap::new(); + for task in &tasks { + match task { + FrameTask::SlideTransition { + global_frame, + view_idx, + scene_b_idx, + frame_in_transition: 0, + .. + } => { + cut_frame + .entry((*view_idx, *scene_b_idx)) + .or_insert(*global_frame); + } + FrameTask::Normal { + global_frame, + view_idx, + scene_idx, + .. + } => { + cut_frame + .entry((*view_idx, *scene_idx)) + .or_insert(*global_frame); + } + _ => {} + } + } + + for (vi, view) in scenario.views.iter().enumerate() { + for (si, scene) in view.scenes.iter().enumerate() { + // A view's first scene has nothing cutting *into* it. + if si == 0 { + continue; + } + let Some(bpm) = scene.resolved_time_ctx.bpm else { + continue; + }; + if bpm <= 0.0 { + continue; + } + let Some(&frame) = cut_frame.get(&(vi, si)) else { + continue; + }; + let time = frame as f64 / fps as f64; + let beat_offset = scene.resolved_time_ctx.beat_offset; + let beat_len = 60.0 / bpm; + let nearest_beat_n = ((time - beat_offset) / beat_len).round(); + let nearest_beat = beat_offset + nearest_beat_n * beat_len; + let drift = (time - nearest_beat).abs(); + // Half a frame is the unavoidable rounding a discrete frame + // grid imposes on a continuous beat position, not a drift an + // author could fix. + let tolerance = 0.5 / fps as f64; + if drift > tolerance { + warnings.push(format!( + "views[{vi}].scenes[{si}]: off_grid_cut — this cut lands at {time:.3}s, \ + {drift:.3}s off the nearest beat ({nearest_beat:.3}s at {bpm} bpm). Set \ + `snap: \"beat\"` on the scenario, or give this scene an explicit `at` on \ + the grid, for a rhythmic edit." + )); + } + } + } + + warnings +} + #[cfg(test)] mod tests { use super::*; @@ -1780,6 +2102,159 @@ mod tests { ); } + // ─── #334: transition-frame sampling ─────────────────────────────────── + // + // The demonstration this workstream exists for: a `timing: "v2"` scene + // whose `tail` is `"continue"` keeps sliding for the whole transition + // overlap PAST its own `duration` — a local time window + // `validate_geometry_animated`'s `anim_sample_times` never generates + // (bounded by `scene_duration`), so the text is comfortably on-screen at + // every one of that function's own samples yet well off it by the time + // the transition it never looks at is halfway done. + + /// video 640×360, text sliding from x=460 toward x=-440 (translate + /// 0 → -900px linearly over a 2.0s keyframe window) starting at the + /// scene's own t=0. At the scene's own last sample (t=1.0s, translate + /// -450px), the box's left edge sits at x=10 — inside the viewport with + /// room to spare. Scene 0's `tail: "continue"` lets it keep sliding + /// through the 0.5s (15-frame @30fps) transition into scene 1, reaching + /// t≈1.47s at the transition's last frame — translate ≈ -660px, left + /// edge ≈ -200px: off the left edge of the viewport. + const V2_TAIL_CONTINUE_TRANSITION_JSON: &str = r##"{ + "video": { "width": 640, "height": 360, "fps": 30 }, + "timing": "v2", + "scenes": [ + { + "duration": 1.0, + "tail": "continue", + "children": [{ + "type": "text", + "content": "EDGE", + "position": "absolute", + "x": 460, "y": 140, + "style": { + "color": "#ffffff", + "font-size": "40px", + "white-space": "nowrap", + "animation": [{ + "name": "keyframes", + "delay": 0, + "duration": 2.0, + "keyframes": [{ + "property": "position.x", + "keyframes": [ + { "time": 0.0, "value": 0 }, + { "time": 2.0, "value": -900 } + ], + "easing": "linear" + }] + }] + } + }] + }, + { + "duration": 1.0, + "transition": { "type": "fade", "duration": 0.5 }, + "children": [] + } + ] + }"##; + + #[test] + fn validate_geometry_animated_misses_the_v2_tail_continue_transition_overflow() { + let scenario = parse(V2_TAIL_CONTINUE_TRANSITION_JSON); + let violations = validate_geometry_animated(&scenario); + assert!( + violations.is_empty(), + "this is exactly the blind spot #334 names: `validate_geometry_animated` only \ + samples within [0, scene_duration], so it never sees this scene sliding further \ + left during the transition overlap its own `tail: \"continue\"` grants it. A \ + non-empty result here means the blind spot has already been closed some other \ + way and this demonstration needs a new repro: {:?}", + violations + ); + } + + #[test] + fn validate_geometry_transitions_catches_the_v2_tail_continue_transition_overflow() { + let scenario = parse(V2_TAIL_CONTINUE_TRANSITION_JSON); + let violations = validate_geometry_transitions(&scenario); + assert!( + !violations.is_empty(), + "expected the transition-frame sampler to catch the overflow \ + validate_geometry_animated misses" + ); + let v = &violations[0]; + assert_eq!(v.component, "text"); + assert_eq!(v.kind, ViolationKind::AnimatedTextOverflow); + assert!( + matches!(v.axis, Axis::X | Axis::Both), + "expected an X-axis (or both) overflow, got {:?}", + v.axis + ); + assert!( + v.bbox.x < -0.5, + "expected the box to have slid past the left edge, got x={}", + v.bbox.x + ); + assert_eq!(v.view_index, 0); + assert_eq!( + v.scene_index, 0, + "the overflowing side is scene 0 (the outgoing/`tail: continue` scene), not scene 1" + ); + assert!( + v.hint.contains("SlideTransition"), + "hint should name the transition frame this was sampled from, not read like an \ + ordinary in-scene sample: {}", + v.hint + ); + } + + /// A `CameraPan` `SlideTransition` genuinely translates each side's + /// foreground across the frame as part of compositing — up to the full + /// `world-position` delta between the two scenes, commonly close to a + /// full viewport width. Both shapes below are only ~270px from the + /// opposite edge of a 640px-wide frame — well inside the 500px pan this + /// transition declares — so a naive fold-then-bounds-check would flag + /// both of them as leaving the viewport, on every single `camera_pan` + /// transition, which is that mechanism working as designed, not a + /// defect. `validate_geometry_transitions` must report nothing at all + /// for a `CameraPan` side. + #[test] + fn camera_pan_slide_transition_sides_are_not_reported() { + let json = r##"{ + "video": { "width": 640, "height": 360 }, + "scenes": [ + { + "duration": 1.0, + "world-position": { "x": 0, "y": 0 }, + "children": [{ + "type": "shape", "shape": "rect", + "size": { "width": 100, "height": 80 }, + "x": 270, "y": 140, "fill": "#ff0000" + }] + }, + { + "duration": 1.0, + "world-position": { "x": 500, "y": 0 }, + "transition": { "type": "camera_pan", "duration": 0.5 }, + "children": [{ + "type": "shape", "shape": "rect", + "size": { "width": 100, "height": 80 }, + "x": 270, "y": 140, "fill": "#00ff00" + }] + } + ] + }"##; + let scenario = parse(json); + let violations = validate_geometry_transitions(&scenario); + assert!( + violations.is_empty(), + "a camera_pan transition's sides must be skipped outright, not bounds-checked: {:?}", + violations + ); + } + // ─── Round 4 audit, constat 4: a `world` scene without its own `layout` // must be validated against the SAME centred-column root layout // `render_world_frame_scaled` synthesizes, not the plain top-aligned @@ -2010,173 +2485,6 @@ mod tests { ); } - #[test] - fn auto_scroll_disabled_codeblock_overflows() { - // 20 lines × 14 px × 1.5 line-height + chrome + padding ≈ 487 px. - // Box height capped at 200 via style.height → AutoScrollDisabledOverflow. - // Note: "size" is a legacy field silently ignored by the schema; use - // style.height to actually constrain the box in the layout pass. - let json = r##"{ - "video": { "width": 1920, "height": 1080 }, - "scenes": [{ - "duration": 1.0, - "children": [{ - "type": "codeblock", - "code": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20", - "auto_scroll": false, - "style": { "width": "800px", "height": "200px" }, - "x": 100, "y": 100 - }] - }] - }"##; - let scenario = parse(json); - let violations = validate_geometry(&scenario); - let v = violations - .iter() - .find(|v| v.kind == ViolationKind::AutoScrollDisabledOverflow); - assert!( - v.is_some(), - "missing AutoScrollDisabledOverflow in {:?}", - violations - ); - assert_eq!(v.unwrap().component, "codeblock"); - } - - // ─── Round 4 audit, constat 6: check_auto_scroll must use the real - // painter's dimension formula (CodeblockIntrinsic/TerminalIntrinsic), - // not a hardcoded 16+16=32px padding assumption ────────────────────── - - #[test] - fn codeblock_auto_scroll_check_honours_explicit_padding_not_a_hardcoded_16px() { - // 10 lines, font-size defaults to 14px (line-height 1.3 -> 18.2px/line - // -> 182px of text), auto_scroll: false, box height fixed at 250px. - // style.padding is *explicitly* 60px on every side (120px vertical - // budget) — nothing close to the hardcoded "16 top + 16 bottom" the - // old formula assumed. Real natural height (chrome disabled): - // 120 (padding) + 182 (text) = 302px, ~52px past the 250px box — - // a genuine overflow. The hardcoded-32px formula computed - // 32 + 182 = 214px, comfortably under 250px, and stayed silent. - let code_lines: String = (1..=10) - .map(|i| i.to_string()) - .collect::>() - .join("\\n"); - let json = format!( - r##"{{ - "video": {{ "width": 1920, "height": 1080 }}, - "scenes": [{{ - "duration": 1.0, - "children": [{{ - "type": "codeblock", - "code": "{code_lines}", - "auto_scroll": false, - "style": {{ "width": "600px", "height": "250px", "padding": "60px" }} - }}] - }}] - }}"## - ); - let scenario = parse(&json); - let violations = validate_geometry(&scenario); - let v = violations - .iter() - .find(|v| v.kind == ViolationKind::AutoScrollDisabledOverflow); - assert!( - v.is_some(), - "expected AutoScrollDisabledOverflow for a 60px-padded codeblock the \ - hardcoded-16px formula wrongly cleared (real natural height ~302px > \ - 250px box): {:?}", - violations - ); - } - - #[test] - fn codeblock_auto_scroll_check_does_not_false_positive_on_tight_default_padding() { - // Complementary false-positive guard: 10 lines, DEFAULT padding - // (16px each side -> 32px vertical budget, matching - // CodeblockIntrinsic's own fallback for an all-zero/unset padding — - // see `CodeblockIntrinsic::from_codeblock`'s (16,16,16,16) default). - // Natural height: 32 + 182 = 214px. Box height 220px comfortably - // holds it — must NOT be flagged. - let code_lines: String = (1..=10) - .map(|i| i.to_string()) - .collect::>() - .join("\\n"); - let json = format!( - r##"{{ - "video": {{ "width": 1920, "height": 1080 }}, - "scenes": [{{ - "duration": 1.0, - "children": [{{ - "type": "codeblock", - "code": "{code_lines}", - "auto_scroll": false, - "style": {{ "width": "600px", "height": "220px" }} - }}] - }}] - }}"## - ); - let scenario = parse(&json); - let violations = validate_geometry(&scenario); - assert!( - violations - .iter() - .all(|v| v.kind != ViolationKind::AutoScrollDisabledOverflow), - "a codeblock that genuinely fits its box must not be flagged: {:?}", - violations - ); - } - - #[test] - fn terminal_auto_scroll_check_uses_the_painters_fixed_line_height_ratio() { - // Terminal (unlike codeblock) does NOT honour `style.line-height` at - // paint time — `terminal.rs`'s own `line_height()` method always - // computes `(font_size * 22.0 / 14.0).ceil()` (a fixed ratio baked - // into the component, `terminal::LINE_HEIGHT`/`FONT_SIZE`), ignoring - // any CSS `line-height` override entirely. The old hand-rolled check - // used `t.style.line_height_for(font_size)` (the CSS property, - // honouring `style.line-height`) instead — so a `line-height: 3` - // override (unitless -> 3 * 14px = 42px/line) inflated the OLD - // formula's estimate even though the real painter still renders - // 22px lines and ignores the override. - // - // 8 lines, chrome disabled, font-size defaults to 14: - // real (TerminalIntrinsic/painter): 2*16 (fixed padding) + - // 8 * 22 (fixed ratio, ignores the override) = 32 + 176 = 208px - // old hand-rolled (CSS line-height, AND its own wrong default - // font-size of 16px instead of the real 14px): - // 32 + 8 * line_height_for(16) = 32 + 8 * 48 = 32 + 384 = 416px - // (captured red-phase output: "terminal content needs ~416px") - // Box height fixed at 300px sits strictly between the two: the real - // content fits (208 < 300), but the old formula's inflated 416px - // wrongly reported an overflow — a false positive this fix removes. - let json = r##"{ - "video": { "width": 1920, "height": 1080 }, - "scenes": [{ - "duration": 1.0, - "children": [{ - "type": "terminal", - "lines": [ - { "text": "one" }, { "text": "two" }, { "text": "three" }, - { "text": "four" }, { "text": "five" }, { "text": "six" }, - { "text": "seven" }, { "text": "eight" } - ], - "show_chrome": false, - "auto_scroll": false, - "style": { "width": "600px", "height": "300px", "line-height": 3 } - }] - }] - }"##; - let scenario = parse(json); - let violations = validate_geometry(&scenario); - assert!( - violations - .iter() - .all(|v| v.kind != ViolationKind::AutoScrollDisabledOverflow), - "terminal ignores style.line-height at paint time — the check must too, \ - real content (208px) fits the 300px box: {:?}", - violations - ); - } - // ─── C1: remediation hints must never name the nonexistent `wrap` field ── #[test] @@ -2303,7 +2611,7 @@ mod tests { assert!( hidden_violations .iter() - .any(|v| v.component == "card" && v.kind == ViolationKind::ViewportOverflow), + .any(|v| v.component == "div" && v.kind == ViolationKind::ViewportOverflow), "the card itself must still be reported: {:?}", hidden_violations ); @@ -3443,8 +3751,8 @@ mod tests { // "Deliberately NOT in scope" note and `walk`'s retired call site for // the full reasoning. These fixtures are the same ones that used to // assert the (wrong) opposite — kept, with flipped assertions, as - // regression coverage across component types (text/codeblock/table/ - // nested card/bleed) now that the check is gone. ───────────────────── + // regression coverage across component types (text/table/nested + // card/bleed) now that the check is gone. ──────────────────────────── #[test] fn absolutely_positioned_text_spilling_past_a_visible_card_is_legal() { @@ -3523,112 +3831,6 @@ mod tests { ); } - #[test] - fn in_flow_codeblock_shrunk_by_its_card_is_caught_by_auto_scroll_check() { - // Sanity/regression guard establishing the baseline this workstream - // found empirically: an ordinary in-flow codeblock (single child, - // no explicit height) inside a card with an *explicit* fixed height - // gets its own box shrunk to that height by flex layout (same - // shrink-to-fit behaviour already established for `text`/`table`), - // so `check_auto_scroll`'s existing natural-vs-own-box comparison - // already catches it correctly here. The genuinely uncaught case — - // an *unclamped* codeblock whose own box already matches its own - // (natural) content but still spills past its card — is the next - // test, `absolutely_positioned_codeblock_spilling_past_its_card_is_flagged`. - let code_lines: String = (1..=30) - .map(|i| i.to_string()) - .collect::>() - .join("\\n"); - let json = format!( - r##"{{ - "video": {{ "width": 1920, "height": 1080 }}, - "scenes": [{{ - "duration": 1.0, - "children": [{{ - "type": "card", - "position": "absolute", - "x": 100, "y": 100, - "style": {{ "width": "600px", "height": "300px", "background": "#111111" }}, - "children": [{{ - "type": "codeblock", - "code": "{code_lines}", - "auto_scroll": false - }}] - }}] - }}] - }}"## - ); - let scenario = parse(&json); - let violations = validate_geometry(&scenario); - let v = violations - .iter() - .find(|v| { - v.kind == ViolationKind::AutoScrollDisabledOverflow && v.component == "codeblock" - }) - .unwrap_or_else(|| panic!("expected AutoScrollDisabledOverflow: {:?}", violations)); - assert_eq!(v.axis, Axis::Y); - } - - #[test] - fn absolutely_positioned_codeblock_spilling_past_a_visible_card_is_legal() { - // #128 item 1's first repro ("a codeblock painting 578px inside a - // 300px card"): taken out of flex flow (`position: absolute`, like - // the analogous text/table tests above) so its own box is NOT - // shrunk to fit the card — it stays at its natural, unscrolled - // content height regardless of `auto_scroll`. `auto_scroll: true` - // (the default) is used deliberately here so `check_auto_scroll` - // stays quiet too, isolating this from every other check: the card - // has default (`visible`) overflow, so per constat 7 this must - // validate clean. - let code_lines: String = (1..=30) - .map(|i| i.to_string()) - .collect::>() - .join("\\n"); - let json = format!( - r##"{{ - "video": {{ "width": 1920, "height": 1080 }}, - "scenes": [{{ - "duration": 1.0, - "children": [{{ - "type": "card", - "position": "absolute", - "x": 100, "y": 100, - "style": {{ "width": "600px", "height": "300px", "background": "#111111" }}, - "children": [{{ - "type": "codeblock", - "position": "absolute", - "x": 0, "y": 0, - "code": "{code_lines}" - }}] - }}] - }}] - }}"## - ); - let scenario = parse(&json); - let violations = validate_geometry(&scenario); - assert!( - violations - .iter() - .all(|v| v.kind != ViolationKind::AutoScrollDisabledOverflow), - "auto_scroll defaults to true — that check must stay quiet: {:?}", - violations - ); - assert!( - violations - .iter() - .all(|v| v.kind != ViolationKind::ViewportOverflow), - "fixture should stay inside the 1080px-tall frame by construction: {:?}", - violations - ); - assert!( - violations - .iter() - .all(|v| v.kind != ViolationKind::ContentOverflowsCard), - "codeblock sticking out of a visible-overflow card is a legal, documented pattern: {:?}", - violations - ); - } - #[test] fn in_flow_table_taller_than_its_card_is_flagged_via_content_overflows_box() { // #128 item 1's third repro: a table with enough rows that its @@ -3782,8 +3984,8 @@ mod tests { #[test] fn component_that_fits_its_card_is_not_flagged() { - // Passing-case guard: same shape as the codeblock repro, but the - // card is tall enough to hold it — must not fire. + // Passing-case guard: a component small enough for its card — must + // not fire. let json = r##"{ "video": { "width": 1920, "height": 1080 }, "scenes": [{ @@ -3794,9 +3996,8 @@ mod tests { "x": 100, "y": 100, "style": { "width": "600px", "height": "200px", "background": "#111111" }, "children": [{ - "type": "codeblock", - "code": "fn main() {\n println!(\"hi\");\n}", - "auto_scroll": false + "type": "text", + "content": "hi" }] }] }] @@ -3807,7 +4008,7 @@ mod tests { violations .iter() .all(|v| v.kind != ViolationKind::ContentOverflowsCard), - "a codeblock that fits its card must not be flagged: {:?}", + "a component that fits its card must not be flagged: {:?}", violations ); } @@ -4235,17 +4436,15 @@ mod legibility_tests { } #[test] - fn default_table_terminal_codeblock_on_1080p_do_not_warn() { - // 14px defaults must clear the floor so this check doesn't spam + fn default_table_on_1080p_does_not_warn() { + // 14px default must clear the floor so this check doesn't spam // every scenario that never touched style.font-size. let json = r##"{ "video": { "width": 1920, "height": 1080 }, "scenes": [{ "duration": 1.0, "children": [ - { "type": "table", "headers": ["a"], "rows": [["1"]] }, - { "type": "terminal", "lines": [{ "text": "$ ok", "type": "input" }] }, - { "type": "codeblock", "code": "fn main() {}" } + { "type": "table", "headers": ["a"], "rows": [["1"]] } ] }] }"##; @@ -4343,3 +4542,105 @@ mod legibility_tests { ); } } + +/// Issue #336: `off_grid_cut` — an advisory warning when `bpm` is set and a +/// resolved cut does not land on the beat grid. +#[cfg(test)] +mod off_grid_cut_tests { + use super::*; + use rustmotion::loader::load_scenario_from_source; + + fn parse(json: &str) -> rustmotion::schema::ResolvedScenario { + load_scenario_from_source(None, Some(json)).expect("scenario parses") + } + + #[test] + fn no_bpm_means_no_warnings_regardless_of_cut_placement() { + // 0.62s is deliberately off any plausible grid — but with no `bpm` + // declared, there is no grid to be off of. + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "scenes": [ + {"duration": 0.62, "children": []}, + {"duration": 1.0, "children": []} + ] + }"##; + assert!(check_off_grid_cuts(&parse(json)).is_empty()); + } + + #[test] + fn cut_exactly_on_the_beat_does_not_warn() { + // bpm=120 -> 0.5s/beat. Scene 0 is exactly 1.0s (two beats), so the + // cut into scene 1 lands exactly on beat 2. + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "bpm": 120, + "scenes": [ + {"duration": 1.0, "children": []}, + {"duration": 1.0, "children": []} + ] + }"##; + assert!( + check_off_grid_cuts(&parse(json)).is_empty(), + "a cut exactly on a beat must not warn" + ); + } + + #[test] + fn off_grid_cut_is_named_and_located() { + // bpm=120 -> 0.5s/beat. Scene 0 is 0.62s, so the cut into scene 1 + // lands at 0.62s — 0.12s off the nearest beat (0.5s). + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "bpm": 120, + "scenes": [ + {"duration": 0.62, "children": []}, + {"duration": 1.0, "children": []} + ] + }"##; + let warnings = check_off_grid_cuts(&parse(json)); + assert_eq!(warnings.len(), 1, "got: {warnings:?}"); + assert!(warnings[0].contains("off_grid_cut"), "got: {}", warnings[0]); + assert!( + warnings[0].contains("views[0].scenes[1]"), + "must name and locate the entering scene: {}", + warnings[0] + ); + } + + #[test] + fn snap_beat_on_an_explicit_at_silences_the_warning() { + // Same off-grid 0.62s target as the test above, but expressed as an + // explicit `at` under `timing: "v2"` with `snap: "beat"` — the + // scheduler itself rounds it onto the grid, so the *resolved* cut + // (what this check reads) is on-grid even though the declared value + // was not. + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "timing": "v2", + "bpm": 120, + "snap": "beat", + "scenes": [ + {"duration": 1.0, "children": []}, + {"duration": 1.0, "children": [], "at": "0.62s"} + ] + }"##; + assert!( + check_off_grid_cuts(&parse(json)).is_empty(), + "snap: beat must resolve the cut onto the grid before this check sees it" + ); + } + + #[test] + fn a_views_first_scene_never_warns() { + // No cut *into* the first scene of a view — nothing to check. + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "bpm": 120, + "scenes": [ + {"duration": 0.37, "children": []} + ] + }"##; + assert!(check_off_grid_cuts(&parse(json)).is_empty()); + } +} diff --git a/crates/rustmotion/src/cli/commands/info.rs b/crates/rustmotion/src/cli/commands/info.rs index d545402c..0066abde 100644 --- a/crates/rustmotion/src/cli/commands/info.rs +++ b/crates/rustmotion/src/cli/commands/info.rs @@ -177,13 +177,8 @@ fn collect_text_measurements_in_children( } _ => {} } - match &child.component { - Component::Card(c) => collect_text_measurements_in_children(&c.children, &p, out), - Component::Flex(c) => collect_text_measurements_in_children(&c.children, &p, out), - Component::Grid(c) => collect_text_measurements_in_children(&c.children, &p, out), - Component::Positioned(c) => collect_text_measurements_in_children(&c.children, &p, out), - Component::Container(c) => collect_text_measurements_in_children(&c.children, &p, out), - _ => {} + if let Component::Container(c) = &child.component { + collect_text_measurements_in_children(&c.children, &p, out) } } } @@ -267,13 +262,8 @@ fn collect_springs_in_children( } } } - match &child.component { - Component::Card(c) => collect_springs_in_children(&c.children, &p, out), - Component::Flex(c) => collect_springs_in_children(&c.children, &p, out), - Component::Grid(c) => collect_springs_in_children(&c.children, &p, out), - Component::Positioned(c) => collect_springs_in_children(&c.children, &p, out), - Component::Container(c) => collect_springs_in_children(&c.children, &p, out), - _ => {} + if let Component::Container(c) = &child.component { + collect_springs_in_children(&c.children, &p, out) } } } @@ -487,12 +477,18 @@ fn collect_media_assets_in_children( status: probe_local_video(&c.src), src: c.src.clone(), }), + // `Avatar`/`AvatarGroup` are two of issue #333's eleven + // frozen-composition components: deprecating the struct + // deprecates every field read on it, and asset probing reads + // `src`/`avatars` directly. + #[allow(deprecated)] Component::Avatar(c) => out.push(MediaAssetReport { label: p.clone(), kind: "avatar", status: probe_local_image(&c.src), src: c.src.clone(), }), + #[allow(deprecated)] Component::AvatarGroup(c) => { for (ai, avatar) in c.avatars.iter().enumerate() { out.push(MediaAssetReport { @@ -503,6 +499,7 @@ fn collect_media_assets_in_children( }); } } + #[allow(deprecated)] Component::Mockup(c) => out.push(MediaAssetReport { label: p.clone(), kind: "mockup", @@ -511,13 +508,8 @@ fn collect_media_assets_in_children( }), _ => {} } - match &child.component { - Component::Card(c) => collect_media_assets_in_children(&c.children, &p, out), - Component::Flex(c) => collect_media_assets_in_children(&c.children, &p, out), - Component::Grid(c) => collect_media_assets_in_children(&c.children, &p, out), - Component::Positioned(c) => collect_media_assets_in_children(&c.children, &p, out), - Component::Container(c) => collect_media_assets_in_children(&c.children, &p, out), - _ => {} + if let Component::Container(c) = &child.component { + collect_media_assets_in_children(&c.children, &p, out) } } } diff --git a/crates/rustmotion/src/cli/commands/migrate.rs b/crates/rustmotion/src/cli/commands/migrate.rs new file mode 100644 index 00000000..1920b157 --- /dev/null +++ b/crates/rustmotion/src/cli/commands/migrate.rs @@ -0,0 +1,428 @@ +//! `rustmotion migrate` — issue #335's crossing for issue #336's gate. +//! +//! `"timing": "v2"` (see [`rustmotion_core::schema::TimingMode`]) changes how +//! a slide view's total duration is computed: a `"v1"` scenario's transition +//! *carves its frames out of* the two scenes it sits between +//! (`sum(scene durations) - sum(transition durations)`), while a `"v2"` +//! scenario places every scene at an absolute `at` and lets a transition +//! *add* frames past the outgoing scene's own end +//! (`at_last + duration_last`, no subtraction). Flipping the flag alone on an +//! existing file is therefore a real behaviour break — a five-transition +//! reel gets 1.5s longer — which is exactly why the flag defaults to `"v1"` +//! and needs a deliberate opt-in. +//! +//! This command is that opt-in, done losslessly: it does not merely set the +//! flag, it *compensates* for the semantic difference so the migrated file +//! renders frame-for-frame identically to the one it replaces. For every +//! scene that has a transition entering the *next* one, this scene's own +//! `duration` is shortened by that transition's length (clamped to this +//! scene's own frame budget, exactly the way +//! `rustmotion::encode::video::tasks`'s `actual_outgoing_transition` already +//! clamps it for `"v1"` rendering) and its `tail` is set to `"continue"` — +//! reproducing `"v1"`'s own behaviour, where the outgoing scene keeps +//! animating (never freezes) through the overlap. Every scene's `at` is +//! written out explicitly, as the plain number of seconds where it would +//! have landed anyway under `"v2"`'s own default (auto) placement — a no-op +//! for a first `migrate` run, but what makes a *subsequent*, separate +//! `"snap": "beat"` opt-in able to move a cut at all: [`SceneStart::Auto`] +//! ignores `snap` entirely (only an explicit [`SceneStart::At`] is +//! snapped — see `rustmotion::encode::video::tasks::build_slide_view_tasks_v2`), +//! so a migrated file that left every `at` on `"auto"` would silently ignore +//! `snap: "beat"` layered on afterwards. +//! +//! Migration preserves; it does not improve. A scenario that already reads +//! `13.5s` under `"v1"` still reads `13.5s`, frame for frame, once migrated — +//! any subsequent change in on-screen timing (e.g. snapping cuts to a beat +//! grid) is a deliberate, separate, later edit, never something this command +//! does on its own. +//! +//! Refuses a templated scenario, or one using `include`/`for-each`/`use`, +//! for the identical reason `validate --fix` already does (see +//! `validate.rs`'s `FixRefusal` doc comment, reused here rather than +//! re-derived): the path a migrated `duration`/`at`/`tail` gets written at is +//! computed against the *expanded* tree, and would silently drift from the +//! source the moment `include`/`for-each`/`use` makes the two diverge. + +use rustmotion::error::{Result, RustmotionError}; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +use super::validate::{fixable_source, refuse_fix}; + +/// Frames a transition entering the scene *after* `scene_frames` consumes, +/// clamped to `scene_frames` itself — the same clamp +/// `rustmotion::encode::video::tasks::actual_outgoing_transition` applies +/// when `"v1"` actually renders this same overlap, reproduced here (rather +/// than called: that function is private to a file outside this +/// workstream's owned perimeter) so the compensation this command computes +/// matches, frame for frame, what the source file already rendered. +fn transition_frames_into_next(next_scene: &Value, scene_frames: u32, fps: u32) -> u32 { + let Some(duration) = next_scene + .get("transition") + .and_then(|t| t.get("duration")) + .and_then(Value::as_f64) + else { + return 0; + }; + let raw = (duration * fps as f64).round().max(0.0) as u32; + raw.min(scene_frames) +} + +/// `video.fps`, defaulting to 30 (the schema's own default — +/// `rustmotion_core::schema::video::default_fps`, private to that crate) when +/// absent or not a plain number. +fn scenario_fps(root: &Value) -> u32 { + root.get("video") + .and_then(|v| v.get("fps")) + .and_then(Value::as_u64) + .and_then(|f| u32::try_from(f).ok()) + .filter(|&f| f > 0) + .unwrap_or(30) +} + +/// Rewrites one view's `scenes` array in place: every scene's `duration` is +/// compensated for the transition entering the *next* scene, `tail` is set +/// to `"continue"` on a scene that has one, and every scene's `at` is +/// written as the absolute second it lands on either way — see the module +/// doc for why all three are necessary for a lossless migration. +fn migrate_scenes_array(scenes: &mut [Value], fps: u32, label: &str) -> Result<()> { + let scene_frames: Vec = scenes + .iter() + .map(|s| { + s.get("duration") + .and_then(Value::as_f64) + .map(|d| (d * fps as f64).round().max(0.0) as u32) + .ok_or_else(|| { + RustmotionError::Generic(format!( + "migrate: a scene in '{label}' has no numeric `duration` — refusing to \ + guess a compensated value for it" + )) + }) + }) + .collect::>()?; + + let n = scenes.len(); + let mut new_duration_frames = scene_frames.clone(); + let mut has_outgoing_transition = vec![false; n]; + for i in 0..n { + if i + 1 >= n { + continue; + } + let into_next = transition_frames_into_next(&scenes[i + 1], scene_frames[i], fps); + if into_next > 0 { + new_duration_frames[i] = scene_frames[i] - into_next; + has_outgoing_transition[i] = true; + } + } + + let mut at_frames = vec![0u32; n]; + for i in 1..n { + at_frames[i] = at_frames[i - 1] + new_duration_frames[i - 1]; + } + + for (i, scene) in scenes.iter_mut().enumerate() { + let obj = scene.as_object_mut().ok_or_else(|| { + RustmotionError::Generic(format!( + "migrate: a scene in '{label}' is not a JSON object" + )) + })?; + let new_duration = new_duration_frames[i] as f64 / fps as f64; + obj.insert("duration".into(), serde_json::json!(new_duration)); + let at_seconds = at_frames[i] as f64 / fps as f64; + obj.insert("at".into(), serde_json::json!(at_seconds)); + if has_outgoing_transition[i] { + obj.insert("tail".into(), Value::String("continue".into())); + } + } + + Ok(()) +} + +/// `rustmotion migrate -f x.json [-o out.json]` — see the module doc. +pub fn cmd_migrate(input: &PathBuf, output: Option<&Path>) -> Result<()> { + let raw_source = std::fs::read_to_string(input).map_err(|e| RustmotionError::FileRead { + path: input.display().to_string(), + source: e, + })?; + + if let Some(refusal) = refuse_fix(input, &raw_source) { + return Err(RustmotionError::Generic(refusal.explain_for_migrate(input))); + } + + let mut root = fixable_source(&raw_source)?; + + if matches!(root.get("timing").and_then(Value::as_str), Some("v2")) { + return Err(RustmotionError::Generic(format!( + "migrate: {} already declares `\"timing\": \"v2\"` — nothing to migrate", + input.display() + ))); + } + + let fps = scenario_fps(&root); + + if let Some(composition) = root.get_mut("composition").and_then(Value::as_array_mut) { + for (vi, view) in composition.iter_mut().enumerate() { + if let Some(scenes) = view.get_mut("scenes").and_then(Value::as_array_mut) { + migrate_scenes_array(scenes, fps, &format!("composition[{vi}].scenes"))?; + } + } + } else if let Some(scenes) = root.get_mut("scenes").and_then(Value::as_array_mut) { + migrate_scenes_array(scenes, fps, "scenes")?; + } + + let obj = root.as_object_mut().ok_or_else(|| { + RustmotionError::Generic("migrate: scenario root is not a JSON object".into()) + })?; + obj.insert("timing".into(), Value::String("v2".into())); + + let pretty = serde_json::to_string_pretty(&root).map_err(|e| { + RustmotionError::Generic(format!("migrate: serialize migrated scenario: {e}")) + })?; + + let dest = output.unwrap_or(input.as_path()); + std::fs::write(dest, &pretty).map_err(|e| RustmotionError::FileRead { + path: dest.display().to_string(), + source: e, + })?; + + // Prove the round trip rather than just asserting it: load both the + // pre-migration source and the freshly written file through the exact + // same frame scheduler `render` uses, and report their durations side by + // side. A mismatch here is this command's own bug, not the input's. + let before = rustmotion::loader::load_scenario_from_source(None, Some(&raw_source)) + .map(|s| rustmotion::encode::build_frame_tasks(&s).len()); + let after = rustmotion::loader::load_scenario_from_source(None, Some(&pretty)) + .map(|s| rustmotion::encode::build_frame_tasks(&s).len()); + + eprintln!("Migrated {} to `\"timing\": \"v2\"`", input.display()); + if dest != input.as_path() { + eprintln!(" Wrote {}", dest.display()); + } + match (before, after) { + (Ok(b), Ok(a)) => { + eprintln!( + " Duration: {:.3}s -> {:.3}s ({} -> {} frames @ {fps}fps){}", + b as f64 / fps as f64, + a as f64 / fps as f64, + b, + a, + if b == a { + " — frame-identical" + } else { + " — MISMATCH" + } + ); + if b != a { + return Err(RustmotionError::Generic(format!( + "migrate: {} rendered {b} frames before migration but {a} frames after — \ + refusing to leave a migrated file that changed the render. This is a bug in \ + `migrate`'s own compensation, not in the source file.", + input.display() + ))); + } + } + (Err(e), _) | (_, Err(e)) => { + eprintln!(" Warning: could not confirm frame parity ({e})"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_fixture(name: &str, content: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "rm_migrate_test_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + name + )); + std::fs::write(&path, content).expect("write fixture"); + path + } + + /// The exact six-scene reel issue #335 names: 2.2+2.6+2.6+3.12+2.08+2.4s + /// of scene duration with five transitions (0.3+0.3+0.3+0.25+0.35s) + /// entering scenes 1..5, rendering 13.5s under `"v1"`. Migrated, it must + /// still render 13.5s, frame for frame. + fn six_scene_reel_json() -> String { + serde_json::json!({ + "video": { "width": 640, "height": 360, "fps": 30 }, + "scenes": [ + { "duration": 2.2, "children": [] }, + { "duration": 2.6, "transition": { "type": "fade", "duration": 0.3 }, "children": [] }, + { "duration": 2.6, "transition": { "type": "fade", "duration": 0.3 }, "children": [] }, + { "duration": 3.12, "transition": { "type": "fade", "duration": 0.3 }, "children": [] }, + { "duration": 2.08, "transition": { "type": "fade", "duration": 0.25 }, "children": [] }, + { "duration": 2.4, "transition": { "type": "fade", "duration": 0.35 }, "children": [] } + ] + }) + .to_string() + } + + #[test] + fn migrates_the_six_scene_reel_frame_identically() { + let path = write_fixture("six_scene.json", &six_scene_reel_json()); + let result = cmd_migrate(&path, None); + let migrated = std::fs::read_to_string(&path).expect("read migrated file"); + std::fs::remove_file(&path).ok(); + result.expect("migration must succeed"); + + let value: Value = serde_json::from_str(&migrated).unwrap(); + assert_eq!(value["timing"], "v2"); + + let scenario = rustmotion::loader::load_scenario_from_source(None, Some(&migrated)) + .expect("migrated scenario loads"); + let frames = rustmotion::encode::build_frame_tasks(&scenario).len(); + let duration = frames as f64 / 30.0; + + // The real acceptance criterion: byte-for-byte the same frame count + // as the pre-migration ("v1") source — not a hand-computed constant, + // which would silently drift from whatever `actual_outgoing_transition` + // (the frame scheduler's own rounding) actually does. + let original = + rustmotion::loader::load_scenario_from_source(None, Some(&six_scene_reel_json())) + .expect("original scenario loads"); + assert_eq!( + frames, + rustmotion::encode::build_frame_tasks(&original).len(), + "migrated file must render the exact same frame count as the pre-migration source" + ); + // Sanity: near the ~13.5s issue #335 names for this reel (exact value + // depends on how `.round()` breaks the one exact half-frame tie in + // this fixture's numbers — see this workstream's report). + assert!( + (duration - 13.5).abs() < 0.1, + "expected roughly 13.5s, got {duration}s ({frames} frames)" + ); + } + + #[test] + fn migrated_scenes_have_explicit_at_and_continue_tail() { + let path = write_fixture("six_scene_fields.json", &six_scene_reel_json()); + cmd_migrate(&path, None).expect("migration succeeds"); + let migrated = std::fs::read_to_string(&path).expect("read migrated file"); + std::fs::remove_file(&path).ok(); + + let value: Value = serde_json::from_str(&migrated).unwrap(); + let scenes = value["scenes"].as_array().unwrap(); + assert_eq!(scenes.len(), 6); + // Scene 0 starts at 0; every scene but the last (no transition + // follows it) continues through its own tail. + assert_eq!(scenes[0]["at"], serde_json::json!(0.0)); + for s in &scenes[..5] { + assert_eq!(s["tail"], "continue"); + } + assert!( + scenes[5].get("tail").is_none(), + "last scene has no outgoing transition to continue through" + ); + } + + #[test] + fn snap_beat_on_top_of_the_migrated_file_moves_cuts_and_changes_duration() { + let path = write_fixture("six_scene_snap.json", &six_scene_reel_json()); + cmd_migrate(&path, None).expect("migration succeeds"); + let migrated = std::fs::read_to_string(&path).expect("read migrated file"); + std::fs::remove_file(&path).ok(); + + let mut value: Value = serde_json::from_str(&migrated).unwrap(); + // bpm=11 (a 60/11s beat, beat_offset=0): of this reel's five + // migrated `at` values, this is the grid where exactly one of them + // (scene 3's) has its nearest beat land *past* where the previous + // scene's own window already ends, so it actually moves (opening a + // hold) instead of being clamped back to its unsnapped position like + // every other scene's nearest beat is here. That single moved cut is + // what turns the migrated file's frame-identical duration into + // exactly 15.0s — found by simulating `build_slide_view_tasks_v2`'s + // exact rounding rather than guessed at (see this workstream's + // report). + value["bpm"] = serde_json::json!(11.0); + value["snap"] = serde_json::json!("beat"); + + let snapped = serde_json::to_string(&value).unwrap(); + let scenario = rustmotion::loader::load_scenario_from_source(None, Some(&snapped)) + .expect("snapped scenario loads"); + let frames = rustmotion::encode::build_frame_tasks(&scenario).len(); + let duration = frames as f64 / 30.0; + assert!( + (duration - 15.0).abs() < 1e-6, + "expected snap:\"beat\" on the migrated file to render exactly 15.0s, got {duration}s" + ); + } + + #[test] + fn refuses_a_templated_scenario_and_leaves_the_file_untouched() { + let original = r#"{ + "config": { "title": { "type": "string", "default": "hi" } }, + "video": { "width": 320, "height": 240, "fps": 30 }, + "scenes": [{ "duration": 1.0, "children": [] }] + }"#; + let path = write_fixture("templated.json", original); + let result = cmd_migrate(&path, None); + let after = std::fs::read_to_string(&path).unwrap(); + std::fs::remove_file(&path).ok(); + + assert!(result.is_err(), "a templated scenario must be refused"); + assert_eq!( + after, original, + "file must be byte-identical after a refused migrate" + ); + } + + #[test] + fn refuses_a_scenario_using_include() { + let original = r#"{ + "video": { "width": 320, "height": 240, "fps": 30 }, + "scenes": [{ "include": "part.json" }] + }"#; + let path = write_fixture("uses_include.json", original); + let result = cmd_migrate(&path, None); + std::fs::remove_file(&path).ok(); + assert!(result.is_err(), "a scenario using include must be refused"); + } + + #[test] + fn refuses_a_scenario_already_on_v2() { + let original = serde_json::json!({ + "video": { "width": 320, "height": 240, "fps": 30 }, + "timing": "v2", + "scenes": [{ "duration": 1.0, "children": [] }] + }) + .to_string(); + let path = write_fixture("already_v2.json", &original); + let result = cmd_migrate(&path, None); + let after = std::fs::read_to_string(&path).unwrap(); + std::fs::remove_file(&path).ok(); + + assert!(result.is_err(), "an already-v2 scenario must be refused"); + assert_eq!(after, original, "file must be untouched"); + } + + #[test] + fn a_scene_with_no_following_transition_keeps_its_duration() { + let original = serde_json::json!({ + "video": { "width": 320, "height": 240, "fps": 30 }, + "scenes": [ + { "duration": 1.0, "children": [] }, + { "duration": 2.0, "children": [] } + ] + }) + .to_string(); + let path = write_fixture("no_transition.json", &original); + cmd_migrate(&path, None).expect("migration succeeds"); + let migrated = std::fs::read_to_string(&path).unwrap(); + std::fs::remove_file(&path).ok(); + + let value: Value = serde_json::from_str(&migrated).unwrap(); + assert_eq!(value["scenes"][0]["duration"], serde_json::json!(1.0)); + assert_eq!(value["scenes"][1]["duration"], serde_json::json!(2.0)); + assert!(value["scenes"][0].get("tail").is_none()); + } +} diff --git a/crates/rustmotion/src/cli/commands/mod.rs b/crates/rustmotion/src/cli/commands/mod.rs index 3a4d5c23..6501720f 100644 --- a/crates/rustmotion/src/cli/commands/mod.rs +++ b/crates/rustmotion/src/cli/commands/mod.rs @@ -1,9 +1,12 @@ +mod audio_report; mod batch; mod captions; mod geometry; mod info; +mod migrate; mod render; mod schema; +mod sheet; mod still; mod validate; mod validate_attrs; @@ -13,7 +16,9 @@ pub mod validation; pub use batch::cmd_batch; pub use captions::cmd_captions; pub use info::cmd_info; +pub use migrate::cmd_migrate; pub use render::{cmd_render, cmd_watch}; pub use schema::cmd_schema; +pub use sheet::cmd_sheet; pub use still::cmd_still; pub use validate::cmd_validate; diff --git a/crates/rustmotion/src/cli/commands/schema.rs b/crates/rustmotion/src/cli/commands/schema.rs index 9318f464..e80d49c0 100644 --- a/crates/rustmotion/src/cli/commands/schema.rs +++ b/crates/rustmotion/src/cli/commands/schema.rs @@ -16,8 +16,9 @@ pub fn cmd_schema(output: Option<&std::path::Path>) -> Result<()> { Ok(()) } -/// Build the full scenario schema, with `Scene.children` typed against the -/// real `Component` variants instead of `serde_json::Value` ("items: true"). +/// Build the full scenario schema, with every `children` array typed +/// against the real `Component` variants (plus `for-each`/`use`) instead of +/// `serde_json::Value` ("items: true"). /// /// `Scene.children` is `Vec` in `rustmotion-core` (schema /// can't depend on `rustmotion-components`, which depends on it), so @@ -25,6 +26,15 @@ pub fn cmd_schema(output: Option<&std::path::Path>) -> Result<()> { /// all 57 component variants — is already built for internal use at /// `validate_attrs.rs:40`; here we merge it into the scenario schema so the /// exported schema actually documents the authoring surface. +/// +/// A *nested* container's own `children` field (`card`/`flex`/`div`/…, +/// typed `Vec` in `rustmotion-components`, not +/// `rustmotion-core`) is a second, independent occurrence of the exact same +/// problem: `ChildComponent` is schemars-derived and merged in below +/// alongside `Component`'s other auxiliary types, and needs the identical +/// `for-each`/`use` treatment — a `for-each` written inside a `div`'s own +/// `children` (as every `examples/composition-*.json` file actually does) +/// is invisible to a fix that only touches `Scene.children`/`Component`. fn build_schema() -> serde_json::Value { let mut scenario_schema = schema::generate_json_schema(); let component_schema = serde_json::to_value(schemars::schema_for!(Component)) @@ -48,8 +58,8 @@ fn build_schema() -> serde_json::Value { }; // Merge Component's auxiliary type definitions (CssStyle, AnimationEffect, - // etc. — the same underlying Rust types the scenario schema may already - // reference elsewhere). + // ChildComponent, etc. — the same underlying Rust types the scenario + // schema may already reference elsewhere). for (k, v) in component_defs { defs_obj.entry(k.clone()).or_insert_with(|| v.clone()); } @@ -63,8 +73,22 @@ fn build_schema() -> serde_json::Value { } defs_obj.insert("Component".to_string(), component_root); - // Point `Scene.children` at `Component` instead of the untyped - // `serde_json::Value` ("items: true"). + inject_for_each_use_definitions(defs_obj); + + // Widen both `Component` (what `Scene.children` points at) and + // `ChildComponent` (what every container's *own* `children` field + // points at, merged in above) to also accept a `for-each`/`use` + // directive in place of a concrete component. Both named definitions + // are wrapped in place — `wrap_with_directives` renames the original + // content to `Base` and re-points every existing `$ref` to + // `` (there are ten `ChildComponent` call sites alone) at the + // widened union instead, with no further find-and-replace needed. + wrap_with_directives(defs_obj, "Component"); + wrap_with_directives(defs_obj, "ChildComponent"); + + // `Scene.children` itself: was untyped `serde_json::Value` ("any + // JSON"); now the same widened `Component` union every nested + // container's own `children` already points at. if let Some(children) = scenario_schema.pointer_mut("/definitions/Scene/properties/children") { *children = serde_json::json!({ "type": "array", @@ -72,5 +96,122 @@ fn build_schema() -> serde_json::Value { }); } + // `components[name].template`: a single child entry, or an array of + // sibling entries (a fragment) — the same shape a `for-each`'s own + // `template` accepts, and by the same reasoning may itself nest another + // `for-each`/`use` (see `expand.rs`'s module doc on composing without + // special-casing). + if let Some(template) = + scenario_schema.pointer_mut("/definitions/ComponentTemplateDef/properties/template") + { + *template = serde_json::json!({ "$ref": "#/definitions/TemplateValue" }); + } + scenario_schema } + +/// Replaces `defs_obj[name]` with `anyOf(original content, ForEachDirective, +/// UseDirective)`, moving the original content to `Base` so every +/// existing `$ref: "#/definitions/"` elsewhere in the schema — already +/// written before this function runs, since `component_defs` was merged in +/// wholesale — resolves to the widened union without needing to be +/// rewritten individually. A no-op if `name` isn't present. +fn wrap_with_directives(defs_obj: &mut serde_json::Map, name: &str) { + let Some(original) = defs_obj.remove(name) else { + return; + }; + let base_name = format!("{name}Base"); + defs_obj.insert(base_name.clone(), original); + defs_obj.insert( + name.to_string(), + serde_json::json!({ + "description": "A concrete component, or a `for-each`/`use` directive that expands \ + into one or more components before rendering — see CLAUDE.md's \ + \"Factorisation\" section.", + "anyOf": [ + { "$ref": format!("#/definitions/{base_name}") }, + { "$ref": "#/definitions/ForEachDirective" }, + { "$ref": "#/definitions/UseDirective" } + ] + }), + ); +} + +/// Adds `TemplateValue`/`ForEachDirective`/`UseDirective` to `defs_obj` — +/// hand-written JSON Schema fragments, not derived from a Rust type: the +/// real structs they describe (`rustmotion_core::expand::{ForEachDirective, +/// UseDirective}`) are private to that module, by design (nothing outside +/// it should construct or see one — they are pre-processing wire types, +/// consumed and discarded before `Scenario` is ever deserialized; see +/// `Scenario::components`'s doc for the same story about the `components` +/// block itself). Mirrors their shape as documented in `CLAUDE.md`'s +/// "Factorisation" section and `expand.rs`'s own module doc, not by +/// importing them (a private type across a crate/module boundary can't +/// drive `schemars::schema_for!` anyway). +/// +/// `TemplateValue` — a single child entry, or an array of them (a +/// `template` written as a fragment of several sibling nodes) — is the +/// shape both a `for-each`'s own `template` and a `components[name].template` +/// share; referencing `Component` here (rather than being folded into +/// `wrap_with_directives`'s own output) keeps it correct regardless of +/// which caller resolves the reference after `Component` is widened. +fn inject_for_each_use_definitions(defs_obj: &mut serde_json::Map) { + defs_obj.insert( + "TemplateValue".to_string(), + serde_json::json!({ + "description": "A `template`'s value (`for-each`'s own, or a `components[name]` \ + entry's): a single child entry, or an array of sibling child entries spliced in \ + place. May itself nest another `for-each`/`use`.", + "anyOf": [ + { "$ref": "#/definitions/Component" }, + { + "type": "array", + "items": { "$ref": "#/definitions/Component" } + } + ] + }), + ); + defs_obj.insert( + "ForEachDirective".to_string(), + serde_json::json!({ + "type": "object", + "description": "Repeats `template` once per element of an array, binding each \ + element's own fields directly (plus `$index` and `$item`) into it.", + "properties": { + "for-each": { + "description": "The array to iterate: a literal JSON array, or a \ + `$variable` reference to a `config`-declared array.", + "anyOf": [ + { "type": "array" }, + { "type": "string" } + ] + }, + "template": { "$ref": "#/definitions/TemplateValue" } + }, + "required": ["for-each", "template"], + "additionalProperties": false + }), + ); + defs_obj.insert( + "UseDirective".to_string(), + serde_json::json!({ + "type": "object", + "description": "Instantiates a named template declared in the top-level \ + `components` block.", + "properties": { + "use": { + "type": "string", + "description": "Name of the `components` entry to instantiate." + }, + "props": { + "type": "object", + "description": "Overrides bound into the template's `$name` placeholders \ + — deliberately not named `config`, which is reserved for the \ + scenario-level declarations block (see `expand.rs`'s module doc)." + } + }, + "required": ["use"], + "additionalProperties": false + }), + ); +} diff --git a/crates/rustmotion/src/cli/commands/sheet.rs b/crates/rustmotion/src/cli/commands/sheet.rs new file mode 100644 index 00000000..f45a0baa --- /dev/null +++ b/crates/rustmotion/src/cli/commands/sheet.rs @@ -0,0 +1,532 @@ +//! `rustmotion sheet`: a contact sheet of timestamped stills. +//! +//! The motivating case (issue #334, batch 1 of #326) is an author — human or +//! an LLM generating a scenario — who cannot watch a rendered video move. +//! What they *can* do is look at several instants at once and compare them. +//! Before this command that meant running `rustmotion still` once per +//! instant and assembling the PNGs by hand; this reuses the exact same +//! single-frame render path (`encode::build_frame_tasks` + +//! `encode::render_frame_task_scaled`, as `still` does) and composes the +//! results into one PNG grid, each cell stamped with its timestamp so a +//! reader can tell which instant a defect belongs to. +//! +//! Deliberately out of scope here (later batches of #326): sampling +//! transition frames specifically and audio peak/RMS measurement. + +use crate::cli::OutputFormat; +use rustmotion::encode; +use rustmotion::engine; +use rustmotion::error::{Result, RustmotionError}; +use rustmotion::schema::ResolvedScenario; +use skia_safe::{ + images, surfaces, AlphaType, Canvas, Color4f, ColorType, Data, Font, FontStyle, ImageInfo, + Paint, PaintStyle, RRect, Rect, TextBlob, +}; +use std::path::{Path, PathBuf}; + +/// A scratch path in the same directory as `output`, carrying the same +/// extension — mirrors `still.rs::temp_sibling_path`. Encoding into this +/// scratch path first and renaming onto `output` only on success means a +/// failed encode never leaves a truncated file at `output`. +fn temp_sibling_path(output: &Path) -> PathBuf { + let ext = output.extension().and_then(|e| e.to_str()); + let stem = output + .file_stem() + .and_then(|e| e.to_str()) + .unwrap_or("sheet"); + let name = match ext { + Some(ext) => format!(".{stem}.rustmotion-tmp.{ext}"), + None => format!(".{stem}.rustmotion-tmp"), + }; + output.with_file_name(name) +} + +/// Parse `--at 3.2,4.8,19.9` into an ordered list of seconds. Preserves the +/// caller's order (and duplicates) rather than sorting/deduping — the grid +/// lays cells out in that same order, so a reordered `--at` reorders the +/// sheet. +fn parse_at_list(spec: &str) -> Result> { + let mut times = Vec::new(); + for (i, raw) in spec.split(',').enumerate() { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(RustmotionError::Generic(format!( + "sheet: --at token {} is empty — expected a comma-separated list of seconds, \ + e.g. \"3.2,4.8,19.9\"", + i + 1 + ))); + } + let t: f64 = trimmed.parse().map_err(|_| { + RustmotionError::Generic(format!( + "sheet: --at token {} ('{}') is not a valid number of seconds", + i + 1, + trimmed + )) + })?; + times.push(t); + } + Ok(times) +} + +/// Generate `0, step, 2*step, ...` up to and including `last_valid_time` +/// (the timestamp of the scenario's last rendered frame) — this is what +/// keeps `--every` from ever tripping the out-of-range check below: it only +/// ever samples inside the scenario it was asked to sample. +fn generate_every(step: f64, last_valid_time: f64) -> Result> { + if !step.is_finite() || step <= 0.0 { + return Err(RustmotionError::Generic(format!( + "sheet: --every must be a positive number of seconds, got {step}" + ))); + } + // Safety valve against a pathologically small step turning one command + // into an unbounded allocation / render loop. + const MAX_CELLS: usize = 10_000; + let mut times = Vec::new(); + let mut i: u64 = 0; + loop { + let t = i as f64 * step; + if t > last_valid_time + 1e-9 { + break; + } + times.push(t); + i += 1; + if times.len() > MAX_CELLS { + return Err(RustmotionError::Generic(format!( + "sheet: --every {step}s would generate more than {MAX_CELLS} cells for a \ + {last_valid_time:.2}s scenario — use a larger step" + ))); + } + } + if times.is_empty() { + // A zero-frame guard elsewhere already rejects `total_frames == 0`; + // this only guards a degenerate `last_valid_time < 0.0`, which + // should not occur, but an empty sheet is a worse failure mode than + // one cell at t=0. + times.push(0.0); + } + Ok(times) +} + +/// Pixel geometry of the grid: cell size (derived from `cell_width` and the +/// scenario's aspect ratio, so no letterboxing is needed inside a cell), +/// column/row count, and the full canvas size. +struct GridLayout { + columns: usize, + rows: usize, + cell_width: u32, + cell_height: u32, + gap: u32, + margin: u32, + total_width: u32, + total_height: u32, +} + +fn compute_layout( + count: usize, + columns: usize, + cell_width: u32, + video_width: u32, + video_height: u32, +) -> GridLayout { + let rows = count.div_ceil(columns); + let cell_height = ((cell_width as f64 * video_height as f64) / video_width as f64) + .round() + .max(1.0) as u32; + let gap: u32 = 10; + let margin: u32 = 16; + let total_width = margin * 2 + columns as u32 * cell_width + gap * (columns as u32 - 1); + let total_height = margin * 2 + rows as u32 * cell_height + gap * (rows as u32 - 1); + GridLayout { + columns, + rows, + cell_width, + cell_height, + gap, + margin, + total_width, + total_height, + } +} + +/// A bold sans font sized off the cell width, used for the timestamp stamp. +/// Falls back through the same Helvetica → Arial → OS chain every other +/// text painter in the engine uses (`typeface_with_fallback`). +fn label_font(cell_width: u32) -> Result { + let typeface = engine::typeface_with_fallback("", FontStyle::bold())?; + let size = (cell_width as f32 * 0.06).clamp(14.0, 26.0); + Ok(Font::from_typeface(typeface, size)) +} + +/// Burn `t`'s timestamp into the bottom-left corner of the cell at +/// `(cell_x, cell_y)` — a small rounded, semi-transparent badge so the label +/// stays legible over arbitrary frame content, light or dark. +fn draw_timestamp_stamp( + canvas: &Canvas, + font: &Font, + t: f64, + cell_x: f32, + cell_y: f32, + cell_h: f32, +) { + let label = format!("{t:.2}s"); + let (text_w, _) = font.measure_str(&label, None); + let (_, metrics) = font.metrics(); + let ascent = -metrics.ascent; + let descent = metrics.descent; + let text_h = ascent + descent; + + let pad_x = 8.0f32; + let pad_y = 5.0f32; + let badge_w = text_w + pad_x * 2.0; + let badge_h = text_h + pad_y * 2.0; + let badge_x = cell_x + 6.0; + let badge_y = cell_y + cell_h - badge_h - 6.0; + + let badge_rect = Rect::from_xywh(badge_x, badge_y, badge_w, badge_h); + let rrect = RRect::new_rect_xy(badge_rect, 4.0, 4.0); + let mut bg_paint = Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None); + bg_paint.set_anti_alias(true); + canvas.draw_rrect(rrect, &bg_paint); + + let mut text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 1.0), None); + text_paint.set_anti_alias(true); + let baseline_y = badge_y + pad_y + ascent; + if let Some(blob) = TextBlob::new(&label, font) { + canvas.draw_text_blob(&blob, (badge_x + pad_x, baseline_y), &text_paint); + } +} + +/// Composite every rendered cell into one RGBA buffer sized +/// `layout.total_width x layout.total_height`. +fn compose_grid(cells: &[(f64, image::RgbaImage)], layout: &GridLayout) -> Result> { + let info = ImageInfo::new( + (layout.total_width as i32, layout.total_height as i32), + ColorType::RGBA8888, + AlphaType::Unpremul, + None, + ); + let mut surface = + surfaces::raster(&info, None, None).ok_or(RustmotionError::SurfaceCreation)?; + let canvas = surface.canvas(); + canvas.clear(Color4f::new(0.08, 0.08, 0.08, 1.0)); + + let font = label_font(layout.cell_width)?; + let mut border_paint = Paint::default(); + border_paint.set_anti_alias(true); + border_paint.set_style(PaintStyle::Stroke); + border_paint.set_stroke_width(1.5); + border_paint.set_color4f(Color4f::new(1.0, 1.0, 1.0, 0.15), None); + + for (idx, (t, img)) in cells.iter().enumerate() { + let col = idx % layout.columns; + let row = idx / layout.columns; + let x = layout.margin as f32 + col as f32 * (layout.cell_width + layout.gap) as f32; + let y = layout.margin as f32 + row as f32 * (layout.cell_height + layout.gap) as f32; + + let src_info = ImageInfo::new( + (img.width() as i32, img.height() as i32), + ColorType::RGBA8888, + AlphaType::Unpremul, + None, + ); + let data = Data::new_copy(img.as_raw()); + let sk_img = images::raster_from_data(&src_info, data, img.width() as usize * 4) + .ok_or(RustmotionError::PixelImage)?; + + let dst = Rect::from_xywh(x, y, layout.cell_width as f32, layout.cell_height as f32); + canvas.draw_image_rect(&sk_img, None, dst, &Paint::default()); + canvas.draw_rect(dst, &border_paint); + + draw_timestamp_stamp(canvas, &font, *t, x, y, layout.cell_height as f32); + } + + let row_bytes = layout.total_width as usize * 4; + let mut pixels = vec![0u8; row_bytes * layout.total_height as usize]; + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + Ok(pixels) +} + +/// Render `at`/`every`-selected instants of `scenario` through the existing +/// single-frame path and compose them into one timestamped contact sheet. +/// +/// Exactly one of `at`/`every` must be `Some` — the CLI layer +/// (`conflicts_with` + `required_unless_present` on both flags) already +/// guarantees this before `cmd_sheet` is ever called. +/// +/// A requested instant beyond the scenario's last frame is a located, +/// named error (naming which instant, its value, and the valid range) — +/// unlike `still --time`, which tolerantly clamps. A contact sheet exists to +/// let an author or a generator *locate* a defect in time; silently +/// clamping an out-of-range instant to the last frame would show the same +/// frame twice under two different timestamps, hiding exactly the mistake +/// (e.g. a duration typo) this command is meant to catch. +#[allow(clippy::too_many_arguments)] +pub fn cmd_sheet( + scenario: ResolvedScenario, + output: &Path, + at: Option<&str>, + every: Option, + columns: usize, + cell_width: u32, + output_format: Option<&OutputFormat>, + quiet: bool, +) -> Result<()> { + if columns == 0 { + return Err(RustmotionError::Generic( + "sheet: --columns must be at least 1".to_string(), + )); + } + if cell_width == 0 { + return Err(RustmotionError::Generic( + "sheet: --cell-width must be at least 1".to_string(), + )); + } + + if !scenario.fonts.is_empty() { + engine::renderer::load_custom_fonts(&scenario.fonts); + } + + let start_time = std::time::Instant::now(); + let config = &scenario.video; + let fps = config.fps; + + let tasks = encode::build_frame_tasks(&scenario); + let total_frames = tasks.len() as u32; + if total_frames == 0 { + return Err(RustmotionError::NoFrames); + } + let last_valid_time = (total_frames - 1) as f64 / fps as f64; + + let times = match (at, every) { + (Some(spec), None) => parse_at_list(spec)?, + (None, Some(step)) => generate_every(step, last_valid_time)?, + (Some(_), Some(_)) | (None, None) => { + return Err(RustmotionError::Generic( + "sheet: exactly one of --at or --every is required".to_string(), + )); + } + }; + if times.is_empty() { + return Err(RustmotionError::Generic( + "sheet: no instants requested".to_string(), + )); + } + + // Resolve every requested time to a frame index up front — and refuse + // the whole sheet, before rendering a single cell, if any one of them + // falls outside the scenario. Located by instant index (1-based, as + // shown to a human) and value, not just "a time was out of range". + let mut frame_indices = Vec::with_capacity(times.len()); + for (i, &t) in times.iter().enumerate() { + if !t.is_finite() { + return Err(RustmotionError::Generic(format!( + "sheet: instant {} of {} is not a finite number of seconds", + i + 1, + times.len() + ))); + } + if t < 0.0 || t > last_valid_time + 1e-9 { + return Err(RustmotionError::Generic(format!( + "sheet: instant {} of {} ({:.3}s) is beyond the scenario — it runs from 0.00s \ + to {:.3}s ({total_frames} frame(s) at {fps} fps)", + i + 1, + times.len(), + t, + last_valid_time + ))); + } + let raw_index = (t * fps as f64).round() as i64; + let frame_index = raw_index.clamp(0, total_frames as i64 - 1) as u32; + frame_indices.push(frame_index); + } + + let mut cells: Vec<(f64, image::RgbaImage)> = Vec::with_capacity(times.len()); + for (&t, &frame_index) in times.iter().zip(frame_indices.iter()) { + let task = &tasks[frame_index as usize]; + let rgba = encode::render_frame_task_scaled(config, &scenario, task, 1.0)?; + let img = image::RgbaImage::from_raw(config.width, config.height, rgba) + .ok_or(RustmotionError::PixelImage)?; + cells.push((t, img)); + } + + let layout = compute_layout( + cells.len(), + columns, + cell_width, + config.width, + config.height, + ); + let pixels = compose_grid(&cells, &layout)?; + + if let Some(parent) = output.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + + let sheet_img = image::RgbaImage::from_raw(layout.total_width, layout.total_height, pixels) + .ok_or(RustmotionError::PixelImage)?; + + let tmp_path = temp_sibling_path(output); + match sheet_img.save(&tmp_path) { + Ok(()) => { + std::fs::rename(&tmp_path, output)?; + } + Err(e) => { + let _ = std::fs::remove_file(&tmp_path); + return Err(RustmotionError::from(e)); + } + } + + if !quiet { + eprintln!( + "Contact sheet ({} cell(s), {}x{} grid, {}x{}px) saved to {}", + cells.len(), + layout.columns, + layout.rows, + layout.total_width, + layout.total_height, + output.display() + ); + } + + let elapsed = start_time.elapsed(); + if let Some(OutputFormat::Json) = output_format { + let result = serde_json::json!({ + "status": "success", + "output": output.to_string_lossy(), + "cells": cells.len(), + "columns": layout.columns, + "rows": layout.rows, + "cell_width": layout.cell_width, + "cell_height": layout.cell_height, + "width": layout.total_width, + "height": layout.total_height, + "times": times, + "duration_ms": elapsed.as_millis(), + }); + println!("{}", serde_json::to_string(&result)?); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion::loader::load_scenario_from_source; + + fn scratch_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rm_sheet_test_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + name + )) + } + + /// 4s @ 10fps scenario, wide enough that a solid-color rect makes each + /// cell trivially distinguishable if something scaled it wrong. + fn colored_scenario(width: u32, height: u32, fps: u32, duration: f64) -> ResolvedScenario { + let json = format!( + r##"{{"video": {{"width": {width}, "height": {height}, "fps": {fps}}}, + "scenes": [{{"duration": {duration}, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#3366ff", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}}]}}"## + ); + load_scenario_from_source(None, Some(&json)).expect("load") + } + + #[test] + fn at_produces_a_grid_with_exactly_that_many_cells() { + let scenario = colored_scenario(64, 64, 10, 2.0); + let out = scratch_path("at_grid.png"); + let _ = std::fs::remove_file(&out); + + cmd_sheet(scenario, &out, Some("0.0,0.5,1.0"), None, 2, 64, None, true) + .expect("sheet must succeed"); + + let img = image::open(&out).expect("must decode as a valid image"); + // 3 cells, 2 columns -> 2 rows. cell 64x64, gap 10, margin 16. + let expected_w = 16 * 2 + 64 * 2 + 10; + let expected_h = 16 * 2 + 64 * 2 + 10; + assert_eq!(img.width(), expected_w as u32); + assert_eq!(img.height(), expected_h as u32); + + let _ = std::fs::remove_file(&out); + } + + #[test] + fn every_samples_from_zero_to_the_scenario_end() { + let scenario = colored_scenario(32, 32, 10, 1.0); + let out = scratch_path("every_grid.png"); + let _ = std::fs::remove_file(&out); + + cmd_sheet(scenario, &out, None, Some(0.5), 2, 32, None, true).expect("sheet must succeed"); + + let img = image::open(&out).expect("must decode as a valid image"); + // duration 1.0s @ 10fps -> last_valid_time = 9/10 = 0.9s. + // every 0.5s -> t = 0.0, 0.5 -> 2 cells; --columns 2 -> 1 row, both + // columns used, so the canvas is exactly as wide as the 2 cells. + let expected_w = 16 * 2 + 32 * 2 + 10; + let expected_h = 16 * 2 + 32; + assert_eq!(img.width(), expected_w as u32); + assert_eq!(img.height(), expected_h as u32); + + let _ = std::fs::remove_file(&out); + } + + #[test] + fn an_at_time_past_the_scenario_end_is_a_located_error_not_a_panic() { + let scenario = colored_scenario(32, 32, 10, 1.0); // last_valid_time = 0.9s + let out = scratch_path("oob.png"); + let _ = std::fs::remove_file(&out); + + let err = cmd_sheet(scenario, &out, Some("0.1,5.0"), None, 4, 32, None, true) + .expect_err("a time past the scenario's end must be a named error"); + + let msg = err.to_string(); + assert!( + msg.contains("instant 2 of 2"), + "error must locate which instant of the list failed: {msg}" + ); + assert!( + msg.contains("5.000"), + "error must name the offending value: {msg}" + ); + assert!( + !out.exists(), + "no partial sheet must be written on an out-of-range instant" + ); + } + + #[test] + fn parse_at_list_rejects_a_non_numeric_token() { + let err = parse_at_list("1.0,not-a-number,2.0").expect_err("must reject"); + assert!(err.to_string().contains("token 2")); + } + + #[test] + fn parse_at_list_preserves_order() { + let times = parse_at_list("3.2,4.8,19.9").expect("must parse"); + assert_eq!(times, vec![3.2, 4.8, 19.9]); + } + + #[test] + fn generate_every_rejects_a_non_positive_step() { + assert!(generate_every(0.0, 5.0).is_err()); + assert!(generate_every(-1.0, 5.0).is_err()); + } + + #[test] + fn compute_layout_lays_out_left_to_right_top_to_bottom() { + let layout = compute_layout(5, 2, 100, 100, 100); + assert_eq!(layout.columns, 2); + assert_eq!(layout.rows, 3); + } +} diff --git a/crates/rustmotion/src/cli/commands/validate.rs b/crates/rustmotion/src/cli/commands/validate.rs index 6c995d9b..d1146f3c 100644 --- a/crates/rustmotion/src/cli/commands/validate.rs +++ b/crates/rustmotion/src/cli/commands/validate.rs @@ -2,6 +2,7 @@ use rustmotion::error::{Result, RustmotionError}; use rustmotion::schema::ResolvedScenario; use std::path::{Path, PathBuf}; +use super::audio_report::analyze_scenario_audio_levels; use super::geometry::{GeometryViolation, ViolationKind}; use super::validation::{self, ValidationReport, ValidationSource, VarOverrides}; @@ -35,20 +36,30 @@ fn announced_duration(scenario: &ResolvedScenario) -> f64 { /// writing back is faithful. For anything templated they do not, and the write /// silently replaces the source with its own expansion: the `config` block and /// every `$var` disappear, includes get inlined into the parent, `for-each`/ -/// `use` get inlined into their repeated/instantiated output, and an HTML -/// input is replaced by JSON outright. +/// `use` get inlined into their repeated/instantiated output, a static +/// `= ...` expression is replaced by the one literal it folded to, and an +/// HTML input is replaced by JSON outright. /// -/// One rule covers all four: only write back a source `--fix` can reproduce. +/// One rule covers all five: only write back a source `--fix` can reproduce. +/// +/// `pub(crate)` rather than private: `rustmotion migrate` refuses a +/// templated/`include`/`for-each`/`use` source on exactly this ground (see +/// `migrate.rs`'s own doc comment) — a migrated file whose path indices no +/// longer match its source is worse than an unmigrated one, the same reason +/// `--fix` refuses. Reusing this type and `refuse_fix` below, rather than a +/// second copy, is what keeps the two commands from silently drifting apart +/// on what counts as "templated". #[derive(Debug, PartialEq, Eq)] -enum FixRefusal { +pub(crate) enum FixRefusal { HtmlSource, Templated, UsesInclude, UsesTemplateDirectives, + UsesExpression, } impl FixRefusal { - fn explain(&self, path: &Path) -> String { + pub(crate) fn explain(&self, path: &Path) -> String { let p = path.display(); match self { Self::HtmlSource => format!( @@ -73,12 +84,59 @@ impl FixRefusal { exactly like `include`. Fix the `components` definition or the `for-each` \ template directly." ), + Self::UsesExpression => format!( + "--fix cannot rewrite {p}: it uses an `= ...` expression (see \ + `rustmotion_core::expr`), and the fixer would write back the *evaluated* tree — \ + a static expression folds to its literal number at load, and writing that \ + number back would silently replace the formula with the one value it happened \ + to produce, making the expression unrecoverable. Fix the expression by hand." + ), + } + } + + /// Same refusal, `rustmotion migrate`'s own wording: the noun changes + /// ("the migrator" instead of "the fixer") but the reasoning — path + /// indices no longer matching the source — is identical, which is why + /// this shares [`refuse_fix`] rather than re-deriving its own detection. + pub(crate) fn explain_for_migrate(&self, path: &Path) -> String { + let p = path.display(); + match self { + Self::HtmlSource => format!( + "migrate cannot rewrite {p}: it is an HTML source, and the migrator only knows \ + how to emit JSON — applying it would replace your markup with the transpiled \ + scenario. Transpile to JSON first, then migrate that." + ), + Self::Templated => format!( + "migrate cannot rewrite {p}: it declares `config` or uses `$variables`, and the \ + migrator would write back the substituted scenario — dropping the template and \ + making `--var` a silent no-op. Migrate the template by hand." + ), + Self::UsesInclude => format!( + "migrate cannot rewrite {p}: it uses `include`, and the migrator would write back \ + the resolved tree — inlining the included files into the parent and patching by \ + a path that no longer means the same node. Migrate the included file directly." + ), + Self::UsesTemplateDirectives => format!( + "migrate cannot rewrite {p}: it uses `for-each`/`use` (or declares `components`), \ + and the migrator would write back the expanded tree — inlining every repeated \ + instance and patching by a path that no longer means the same source node, \ + exactly like `include`. Migrate the `components` definition or the `for-each` \ + template directly." + ), + Self::UsesExpression => format!( + "migrate cannot rewrite {p}: it uses an `= ...` expression (see \ + `rustmotion_core::expr`), and the migrator would write back the *evaluated* tree \ + — a static expression folds to its literal number at load, and writing that \ + number back would silently replace the formula with the one value it happened \ + to produce, making the expression unrecoverable. Migrate the expression by hand." + ), } } } -/// `None` when `--fix` may write over `input`. -fn refuse_fix(input: &Path, raw_source: &str) -> Option { +/// `None` when `--fix` (or `rustmotion migrate`, which reuses this same +/// check — see [`FixRefusal`]'s doc comment) may write over `input`. +pub(crate) fn refuse_fix(input: &Path, raw_source: &str) -> Option { if rustmotion::loader::is_html_path(input) { return Some(FixRefusal::HtmlSource); } @@ -105,6 +163,14 @@ fn refuse_fix(input: &Path, raw_source: &str) -> Option { { return Some(FixRefusal::UsesTemplateDirectives); } + // Every expression containing a `$name` (the common case — every + // example in the issue this exists for does) is already caught by the + // `raw_source.contains("$")` check above; this closes the narrower gap + // of a fully `$`-free static expression like `"= cos(PI/4) * 100"`, + // which would otherwise fold to a literal and be refused nowhere. + if rustmotion::loader::source_uses_expression(raw_source) { + return Some(FixRefusal::UsesExpression); + } None } @@ -122,9 +188,13 @@ fn refuse_fix(input: &Path, raw_source: &str) -> Option { /// exact bytes still on disk) fresh yields the identical tree /// `apply_fixes`/`navigate`'s path indices were computed against, minus the /// rebase. -fn fixable_source(raw_source: &str) -> Result { - serde_json::from_str(raw_source) - .map_err(|e| RustmotionError::Generic(format!("re-parse source for --fix: {}", e))) +/// +/// `pub(crate)`: `rustmotion migrate` re-parses the same on-disk bytes for +/// the same reason, once `refuse_fix` has cleared them. +pub(crate) fn fixable_source(raw_source: &str) -> Result { + serde_json::from_str(raw_source).map_err(|e| { + RustmotionError::Generic(format!("re-parse source for --fix/--migrate: {}", e)) + }) } pub fn cmd_validate( @@ -151,7 +221,7 @@ pub fn cmd_validate( } if let Some(report_path) = report { - write_report(report_path, &report_out)?; + write_report(report_path, &report_out, &loaded.scenario)?; eprintln!("Wrote report: {}", report_path.display()); } @@ -216,13 +286,20 @@ pub fn cmd_validate( Ok(()) } -fn write_report(path: &Path, report: &ValidationReport) -> Result<()> { +fn write_report(path: &Path, report: &ValidationReport, scenario: &ResolvedScenario) -> Result<()> { + // Issue #334's second blind spot, closed: a mixed soundtrack that clips + // used to be a discovery made after the fact with an external tool + // (`ffmpeg -af astats`) — see `audio_report`'s module doc. Measuring it + // here, unconditionally, means every `--report` carries it next to + // `geometry_violations` instead of requiring a second pass. + let audio = analyze_scenario_audio_levels(scenario); let json = serde_json::json!({ "schema_errors": report.schema_errors, "geometry_violations": report.geom_violations, "unresolved_vars": report.unresolved_vars, "warnings": report.warnings, "attr_warnings": report.attr_warnings, + "audio": audio, }); let pretty = serde_json::to_string_pretty(&json) .map_err(|e| RustmotionError::Generic(format!("serialize report: {}", e)))?; @@ -256,12 +333,6 @@ fn apply_fixes(root: &mut serde_json::Value, violations: &[GeometryViolation]) - } } } - ViolationKind::AutoScrollDisabledOverflow => { - if let Some(obj) = target.as_object_mut() { - obj.insert("auto_scroll".into(), serde_json::Value::Bool(true)); - applied += 1; - } - } ViolationKind::ContentOverflowsBox => { // Growing the box, shrinking the font and shortening the copy // are all legitimate answers with very different visual @@ -486,7 +557,7 @@ mod tests { let top_children = render::deserialize_children(&scenario.views[0].scenes[0]); assert_eq!(top_children.len(), 1, "card must survive the fix"); let text_survived = match &top_children[0].component { - Component::Card(c) => c.children.len() == 1, + Component::Container(c) => c.children.len() == 1, _ => false, }; assert!( @@ -702,6 +773,40 @@ mod tests { ); } + #[test] + fn a_scenario_using_a_dollar_free_static_expression_is_refused() { + // No `$` anywhere in this fixture on purpose, same reasoning as + // `a_scenario_using_for_each_is_refused` above: proves the + // detection does not piggyback on the pre-existing `$`-content + // check, which a fully static `= ...` expression can slip past. + let with_expression = r##"{"video":{"width":320,"height":240,"fps":30}, + "scenes":[{"duration":1.0,"children":[ + {"type":"text","content":"hi","x":"= cos(PI/4) * 100"} + ]}]}"##; + assert_eq!( + refuse_fix(Path::new("s.json"), with_expression), + Some(FixRefusal::UsesExpression) + ); + } + + #[test] + fn a_scenario_using_a_dollar_expression_is_refused_as_templated_not_expression() { + // `refuse_fix` checks the generic `$`-content rule before the + // expression-specific one, so an expression referencing a scope + // variable is refused as `Templated` — still refused, just + // attributed to the check that runs first. Documented here so a + // future reordering doesn't silently change this without a test + // noticing. + let with_var_expression = r##"{"video":{"width":320,"height":240,"fps":30}, + "scenes":[{"duration":1.0,"children":[ + {"type":"text","content":"hi","x":"= $W/2"} + ]}]}"##; + assert_eq!( + refuse_fix(Path::new("s.json"), with_var_expression), + Some(FixRefusal::Templated) + ); + } + #[test] fn every_refusal_names_the_file_and_says_what_to_do_instead() { let p = Path::new("scenes/hero.json"); @@ -710,6 +815,7 @@ mod tests { FixRefusal::Templated, FixRefusal::UsesInclude, FixRefusal::UsesTemplateDirectives, + FixRefusal::UsesExpression, ] { let msg = r.explain(p); assert!(msg.contains("scenes/hero.json"), "{msg}"); diff --git a/crates/rustmotion/src/cli/commands/validate_attrs.rs b/crates/rustmotion/src/cli/commands/validate_attrs.rs index cd8c30d8..ad293325 100644 --- a/crates/rustmotion/src/cli/commands/validate_attrs.rs +++ b/crates/rustmotion/src/cli/commands/validate_attrs.rs @@ -30,15 +30,23 @@ use rustmotion::schema::ResolvedScenario; /// Keys accepted on any component object but absent from the per-variant /// schema properties: -/// - `position`, `x`, `y`, `z-index`: `ChildComponent` wrapper fields -/// (flattened around the component itself). +/// - `position`, `x`, `y`, `z-index`, `id`: `ChildComponent` wrapper fields +/// (flattened around the component itself). `id` is what a sibling node's +/// expression addresses through `node("id", "prop")`. /// - `animation`: top-level `animation` is ignored by the engine and already /// reported by the dedicated misplaced-animation warning — flagging it here /// too would double-report. -const WRAPPER_KEYS: &[&str] = &["position", "x", "y", "z-index", "animation", "bleed"]; +const WRAPPER_KEYS: &[&str] = &["position", "x", "y", "z-index", "animation", "bleed", "id"]; /// Serde enum aliases that schemars does not know about: alias tag → schema tag. -const TAG_ALIASES: &[(&str, &str)] = &[("container", "div"), ("progress_bar", "progress")]; +const TAG_ALIASES: &[(&str, &str)] = &[ + ("container", "div"), + ("progress_bar", "progress"), + ("card", "div"), + ("flex", "div"), + ("grid", "div"), + ("positioned", "div"), +]; /// Lazily-built map: component tag ("counter") → set of known top-level /// property names, extracted from the schemars `oneOf` variants. Flattened diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index a42a28c1..fecfb1f8 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -7,7 +7,8 @@ use rustmotion::core::css::style::{ }; use rustmotion::engine::animator::{motion_path_length, MOTION_PATH_MIN_LENGTH}; use rustmotion::schema::{ - AnimationEffect, CharAnimationTiming, MotionPathConfig, ResolvedScenario, SpringConfig, + AnimationEffect, CharAnimationTiming, MotionPathConfig, ResolvedScenario, SceneStart, + SpringConfig, TimeError, }; pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec) { @@ -35,6 +36,24 @@ pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec 0", vi, si)); } + // Issue #336: a syntactically valid `at` (e.g. "@8b") can still + // be unresolvable if the scenario never declared `bpm` — that + // can only be known once the whole scenario is in scope, unlike + // grammar (rejected earlier, at deserialize time — see + // `SceneStart`'s custom `Deserialize` impl). `resolve_absolute` + // cannot return `Unparseable` here: grammar was already + // enforced, so `NoBpm` is the only reachable error. + if let SceneStart::At(ref time_point) = scene.at { + if let Err(e @ TimeError::NoBpm(_)) = + time_point.resolve_absolute(&scene.resolved_time_ctx) + { + errors.push(format!( + "views[{vi}].scenes[{si}].at: unresolved_beat_unit — {e} (declare \ + `bpm` at the scenario root, or use an `s`/`ms` unit instead)" + )); + } + } + let children = rustmotion::engine::render::deserialize_children(scene); validate_children( &children, @@ -237,11 +256,16 @@ fn validate_children( errors.push(format!("{}: QR code content must not be empty", p)); } } + #[allow(deprecated)] Component::Mockup(m) => { if !std::path::Path::new(&m.src).exists() { errors.push(format!("{}.src: file not found '{}'", p, m.src)); } } + // `Counter` is one of issue #333's eleven frozen-composition + // components: deprecating the struct deprecates every field read + // on it, and `counter_display_len` reads five of them directly. + #[allow(deprecated)] Component::Counter(c) => { let from_len = counter_display_len(c.from, c.decimals, &c.separator, &c.prefix, &c.suffix); @@ -255,27 +279,24 @@ fn validate_children( )); } } - Component::Card(card) => { - if matches!(card.style.display, Some(CssDisplay::Grid)) - && card.style.grid_template_columns.is_none() + Component::Container(container) => { + // `div`, `card`, `flex`, `grid` and `positioned` all + // deserialize into the same `ContainerComponent` now (see + // the alias list on `Component::Container` in `lib.rs`), so + // there is no way left to single out a node that was typed + // `grid` in the source JSON — only what its `style.display` + // actually says survives the merge. A bare `{"type": + // "grid"}` with no `display` and no + // `grid-template-columns` used to get its own dedicated + // error; it no longer can, since it is indistinguishable + // from a plain `div`. What still fires: explicit `display: + // grid` without `grid-template-columns`, previously the + // `card` half of this check. + if matches!(container.style.display, Some(CssDisplay::Grid)) + && container.style.grid_template_columns.is_none() { errors.push(format!("{}: grid display without grid-template-columns", p)); } - validate_children(&card.children, &p, scene_duration, errors, warnings); - } - Component::Flex(flex) => { - validate_children(&flex.children, &p, scene_duration, errors, warnings); - } - Component::Grid(grid) => { - if grid.style.grid_template_columns.is_none() { - errors.push(format!("{}: grid without grid-template-columns", p)); - } - validate_children(&grid.children, &p, scene_duration, errors, warnings); - } - Component::Positioned(pos) => { - validate_children(&pos.children, &p, scene_duration, errors, warnings); - } - Component::Container(container) => { validate_children(&container.children, &p, scene_duration, errors, warnings); } _ => {} @@ -751,11 +772,7 @@ fn check_motion_path_config( /// The `time_scale` declared on a container component, if any. fn container_time_scale(component: &Component) -> Option { match component { - Component::Card(c) => c.time_scale, - Component::Flex(c) => c.time_scale, - Component::Grid(c) => c.time_scale, Component::Container(c) => c.time_scale, - Component::Positioned(c) => c.time_scale, _ => None, } } @@ -1663,3 +1680,74 @@ mod motion_path_validation_tests { ); } } + +/// Issue #336: a grammatically valid `at` that still can't be *resolved* +/// (a beat unit with no `bpm` declared) must be a named, located, blocking +/// error — `unresolved_beat_unit` — not a silent fallback to automatic +/// placement. +#[cfg(test)] +mod unresolved_beat_unit_tests { + use super::*; + use rustmotion::loader::load_scenario_from_source; + + fn parse(json: &str) -> ResolvedScenario { + load_scenario_from_source(None, Some(json)).expect("scenario parses") + } + + #[test] + fn beat_unit_with_no_bpm_is_a_named_located_error() { + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "scenes": [ + {"duration": 1.0, "children": []}, + {"duration": 1.0, "children": [], "at": "@8b"} + ] + }"##; + let scenario = parse(json); + let (errors, _warnings) = validate_scenario(&scenario); + let hit = errors + .iter() + .find(|e| e.contains("unresolved_beat_unit")) + .unwrap_or_else(|| panic!("expected an unresolved_beat_unit error, got: {errors:?}")); + assert!( + hit.contains("views[0].scenes[1]"), + "must name and locate the offending scene: {hit}" + ); + assert!(hit.contains("@8b"), "must echo the offending spec: {hit}"); + } + + #[test] + fn beat_unit_with_bpm_declared_is_not_an_error() { + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "bpm": 120, + "scenes": [ + {"duration": 1.0, "children": []}, + {"duration": 1.0, "children": [], "at": "@8b"} + ] + }"##; + let scenario = parse(json); + let (errors, _warnings) = validate_scenario(&scenario); + assert!( + errors.iter().all(|e| !e.contains("unresolved_beat_unit")), + "bpm is declared — this must resolve, not error: {errors:?}" + ); + } + + #[test] + fn plain_seconds_at_never_needs_bpm() { + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "scenes": [ + {"duration": 1.0, "children": []}, + {"duration": 1.0, "children": [], "at": "2.5s"} + ] + }"##; + let scenario = parse(json); + let (errors, _warnings) = validate_scenario(&scenario); + assert!( + errors.iter().all(|e| !e.contains("unresolved_beat_unit")), + "an s/ms-unit `at` never needs bpm: {errors:?}" + ); + } +} diff --git a/crates/rustmotion/src/cli/commands/validation.rs b/crates/rustmotion/src/cli/commands/validation.rs index 96ff6b0f..416b6d25 100644 --- a/crates/rustmotion/src/cli/commands/validation.rs +++ b/crates/rustmotion/src/cli/commands/validation.rs @@ -8,19 +8,20 @@ //! 4. Deserialize into `Scenario` //! 5. Resolve includes → `ResolvedScenario` //! 6. Schema-level checks (file existence, dimensions, durations, etc.) -//! 7. Geometry checks (viewport overflow, wrap, auto_scroll) +//! 7. Geometry checks (viewport overflow, wrap) use rustmotion::engine; use rustmotion::error::{Result, RustmotionError}; use rustmotion::expand; -use rustmotion::include::{self, IncludeSource}; +use rustmotion::include::IncludeSource; use rustmotion::schema::{ResolvedScenario, Scenario}; use rustmotion::variables; use std::collections::HashMap; use std::path::{Path, PathBuf}; use super::geometry::{ - check_legibility, validate_geometry, validate_geometry_animated, GeometryViolation, + check_legibility, check_off_grid_cuts, validate_geometry, validate_geometry_animated, + validate_geometry_transitions, GeometryViolation, }; use super::validate_schema::validate_scenario; @@ -187,6 +188,20 @@ pub fn load_with_vars( // document would be validating something other than what actually // renders. expand::expand_directives(&mut json_value, &label)?; + // Same ordering rule, same reason, for `= ...` expressions: this is a + // second, independent load pipeline from `rustmotion::loader`'s (this + // crate's `validate`/`render` both go through *this* one, not that one — + // see `rustmotion_core::expr`'s module doc and `loader::fold_static_expressions`'s + // doc for why the fold must run right here, immediately after expansion + // and before `Scenario` deserialization: a `for-each`-authored template + // has its `$i`/`$index`/`$item`/`$count` already substituted to literal + // text by the expansion step just above, which is what lets a purely + // arithmetic expression like `cos($i / $count * TAU) * 600` fold to a + // plain number here rather than reach `Scenario` deserialization as a + // string where an `f32` is expected (which used to fail with a + // misleading "invalid type: string, expected f32" instead of the + // scenario simply working). + rustmotion::loader::fold_static_expressions(&mut json_value, &label)?; // Assets are relative to the scenario file, like `include` — and this must // happen before `raw` is captured, so the existence check below and the @@ -196,7 +211,14 @@ pub fn load_with_vars( } let scenario: Scenario = serde_json::from_value(json_value.clone())?; - let resolved = include::resolve_includes(scenario, &include_source)?; + // `resolve_includes_and_synthesize_audio` is `include::resolve_includes` + // plus, when this scenario's own `audio` declares a synthesised score + // (issue #331), rendering it and appending it to the resolved + // scenario's `audio` as an ordinary `AudioTrack` — both `validate` and + // `render` go through this one shared function, not a bespoke call to + // `include::resolve_includes` that would silently skip that step. + let resolved = + rustmotion::loader::resolve_includes_and_synthesize_audio(scenario, &include_source)?; Ok(LoadedScenario { raw: json_value, @@ -221,6 +243,12 @@ pub fn run_checks(loaded: &LoadedScenario, strict_anim: bool) -> ValidationRepor let mut geom_violations = validate_geometry(&loaded.scenario); if strict_anim { geom_violations.extend(validate_geometry_animated(&loaded.scenario)); + // Issue #334's second blind spot, closed: a `SlideTransition`/ + // `ViewTransition` frame is a real, on-screen frame like any other — + // sampling only `[0, scene_duration]` (above) never looked at it, so + // text that only leaves the viewport mid-transition passed clean. + // Same `--strict-anim` gate, same cost trade, same `ViolationKind`. + geom_violations.extend(validate_geometry_transitions(&loaded.scenario)); } let (mut schema_errors, mut warnings) = validate_scenario(&loaded.scenario); warnings.extend(warn_misplaced_animation(&loaded.raw)); @@ -228,6 +256,15 @@ pub fn run_checks(loaded: &LoadedScenario, strict_anim: bool) -> ValidationRepor // blocking (see `check_legibility`'s doc comment for the threshold // justification). warnings.extend(check_legibility(&loaded.scenario)); + // Issue #336: off-grid cuts — always advisory, never blocking (see + // `check_off_grid_cuts`'s doc comment). + warnings.extend(check_off_grid_cuts(&loaded.scenario)); + // Issue #328: a `node("id", "prop")` dependency graph error (a cycle, an + // unknown id, a duplicate id, or a reference crossing a scene boundary — + // `reference_cross_scene`) is a load-time structural mistake, the same + // category as `unresolved_beat_unit` above it — always blocking, + // unaffected by `--lenient` (see `check_node_references`'s doc comment). + schema_errors.extend(check_node_references(&loaded.scenario)); let (attr_errors, mut attr_warnings) = super::validate_attrs::check_component_attrs(&loaded.scenario); schema_errors.extend(attr_errors); @@ -251,6 +288,76 @@ pub fn run_checks(loaded: &LoadedScenario, strict_anim: bool) -> ValidationRepor } } +/// Issue #328's `node("id", "prop")` dependency graph, checked once per +/// scene at `validate` time — a cycle, an undeclared id, a duplicate id, or +/// a reference crossing a scene boundary (`reference_cross_scene`) are all +/// decided by [`rustmotion::engine::deps::DepGraph::build`], reusing the +/// exact `(String, Vec)` list +/// [`rustmotion::components::box_builder::collect_node_refs`] already builds +/// for `rustmotion::engine::render::resolve_node_references`'s per-frame, +/// single-scene call at render time. +/// +/// That render-time call always passes an *empty* `other_scene_ids` (see its +/// own doc comment: no caller crosses scene boundaries there), so +/// `DepsError::CrossScene` can never actually fire from it — a reference to +/// an id declared in a different scene is reported as a plain `UnknownId` +/// instead, and only at render, never at `validate`. This is the gap issue +/// #335 exists to close: here, every *other* scene's own declared ids are +/// collected first, so a cross-scene reference is named for what it is +/// (`reference_cross_scene`) before a single frame is ever rendered. +/// +/// Static and cheap — no layout, no per-frame evaluation, just the +/// `(id, refs)` list every declared id's own style expressions carry after +/// ordinary deserialization — so this runs unconditionally, not gated behind +/// `--strict-anim` the way the geometry sampler above is. +/// +/// One limitation, inherited rather than introduced here: `collect_node_refs` +/// only records an entry for a node that itself declares an `id` (see that +/// function's own doc) — a *referencing* node with no `id` of its own is +/// invisible to this check too, exactly as it already is to +/// `resolve_node_references` at render time. Closing that needs a broader +/// walk than this workstream's file scope reaches (see this workstream's +/// report). +fn check_node_references(scenario: &ResolvedScenario) -> Vec { + use rustmotion::components::box_builder::collect_node_refs; + use rustmotion::engine::deps::DepGraph; + use std::collections::HashSet; + + let per_scene: Vec<( + (usize, usize), + Vec<(String, Vec)>, + )> = scenario + .views + .iter() + .enumerate() + .flat_map(|(vi, view)| { + view.scenes + .iter() + .enumerate() + .map(move |(si, scene)| (vi, si, scene)) + }) + .map(|(vi, si, scene)| { + let children = engine::render::deserialize_children(scene); + ((vi, si), collect_node_refs(&children)) + }) + .collect(); + + let mut errors = Vec::new(); + for (i, (loc, nodes)) in per_scene.iter().enumerate() { + let other_scene_ids: HashSet = per_scene + .iter() + .enumerate() + .filter(|(j, _)| *j != i) + .flat_map(|(_, (_, n))| n.iter().map(|(id, _)| id.clone())) + .collect(); + if let Err(e) = DepGraph::build(nodes, &other_scene_ids) { + let (vi, si) = loc; + errors.push(format!("views[{vi}].scenes[{si}]: {e}")); + } + } + errors +} + /// Detect `animation` placed at a component's top level (a sibling of `style`). /// The engine only reads `style.animation`, so a top-level `animation` is /// silently ignored — a common, hard-to-spot mistake. Returns one warning per @@ -309,6 +416,17 @@ pub fn warn_on_silent_defaults(loaded: &LoadedScenario) { "Warning: top-level `scenes` is legacy. Migrate to `composition: [{{ type: \"slide\", scenes: [...] }}]` for clarity." ); } + // Issue #336: `timing` absent defaults to `v1` (today's semantics, which + // subtract every transition's duration from the total). Nudge authors + // who want beat-accurate cuts toward `v2` the same way the `scenes` + // check above nudges toward `composition`. + if loaded.raw.get("timing").is_none() { + eprintln!( + "Warning: `timing` not specified, using legacy v1 (transition durations are \ + subtracted from the total). Set `\"timing\": \"v2\"` for absolute scene \ + placement with a beat grid." + ); + } } /// Validate `--codec` against the list ffmpeg can drive. Defaults to OK if None. @@ -646,6 +764,208 @@ mod expanded_tree_is_what_gets_validated { } } +/// `validation::load_with_vars` is a second, independent load pipeline from +/// `rustmotion::loader`'s (see that module's `fold_static_expressions` doc) +/// — `validate` and `render` (which validates first) both go through *this* +/// one. Before the fold was wired in here too, a scenario using `= ...` +/// expressions passed neither: a static expression reached `Scenario` +/// deserialization as a bare string where a typed field (e.g. `x: f32`) was +/// expected, and even a `$`-free expression that *would* have deserialized +/// fine printed spurious "unresolved variable" warnings for `$W`/`$H`/etc +/// (see `crate::variables::find_unresolved`'s doc on why an expression +/// string is no longer scanned for `$name` content at all). +#[cfg(test)] +mod expr_fold_through_validation_pipeline { + use super::*; + + /// The acceptance scenario this whole workstream exists for: a + /// `for-each` over 8 items placing badges on a circle via + /// `$W`/`$i`/`$count`, going through the exact pipeline `validate`/ + /// `render` use — not `rustmotion::loader`'s. + #[test] + fn for_each_circle_of_expressions_validates_clean_through_this_pipeline() { + let json = serde_json::json!({ + "video": { "width": 1080, "height": 1920, "fps": 30 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "for-each": [1,2,3,4,5,6,7,8], + "template": { + "type": "text", + "content": "badge", + "position": "absolute", + "style": { "color": "#fff", "font-size": "40px", "white-space": "nowrap" }, + "x": "= $W/2 + cos($i / $count * TAU - PI/2) * 400 - 40", + "y": "= $H/2 + sin($i / $count * TAU - PI/2) * 400 - 20" + } + }] + }] + }) + .to_string(); + + let loaded = load(ValidationSource::Inline(&json)).expect("scenario loads and folds"); + let children = &loaded.raw["scenes"][0]["children"]; + let children = children.as_array().expect("8 expanded children"); + assert_eq!(children.len(), 8); + for (i, child) in children.iter().enumerate() { + assert!( + child["x"].is_number() && child["y"].is_number(), + "child {i}'s x/y must be folded to plain numbers, got {child}" + ); + } + + let report = run_checks(&loaded, false); + assert!( + report.schema_errors.is_empty(), + "an expression-driven component must deserialize, not be dropped: {:?}", + report.schema_errors + ); + assert!( + report.unresolved_vars.is_empty(), + "$W/$H/$i/$count must not be reported as unresolved variables: {:?}", + report.unresolved_vars + ); + assert!(!report.is_blocking(false)); + } + + /// A fully `$`-free static expression (no scope variable at all) used to + /// be the sharpest repro of the missing fold: nothing about it looks + /// like a `$variable`, so it reached `Scenario` deserialization as a + /// plain string exactly once, with no other symptom. + #[test] + fn dollar_free_static_expression_folds_and_deserializes() { + let json = serde_json::json!({ + "video": { "width": 200, "height": 200 }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "position": "absolute", + "x": "= 960 + cos(5.0 / 8.0 * TAU - PI/2) * 600 - 60" } + ] + }] + }) + .to_string(); + + let loaded = load(ValidationSource::Inline(&json)).expect("scenario loads and folds"); + assert!(loaded.raw["scenes"][0]["children"][0]["x"].is_number()); + let report = run_checks(&loaded, false); + assert!( + report.schema_errors.is_empty(), + "must not be silently dropped at render: {:?}", + report.schema_errors + ); + } +} + +/// Issue #328/#335: `reference_cross_scene` (and the rest of +/// `DepGraph::build`'s error surface) reachable from `validate`, not only +/// from `render`'s single-scene call site — see `check_node_references`'s +/// own doc comment for why the render-time call could never actually +/// produce `CrossScene` at all. +#[cfg(test)] +mod node_reference_checks { + use super::*; + + fn scene_with_id_and_transform_ref(id: &str, target_id: &str) -> serde_json::Value { + serde_json::json!({ + "duration": 1.0, + "children": [{ + "type": "shape", "shape": "circle", "id": id, + "position": "absolute", "x": 0, "y": 0, + "style": { + "width": "20px", "height": "20px", + "transform": [ + { "fn": "translate", "x": format!("= node(\"{target_id}\", \"tx\")"), "y": 0 } + ] + } + }] + }) + } + + #[test] + fn a_reference_to_an_id_in_another_scene_is_reported_as_reference_cross_scene() { + let json = serde_json::json!({ + "video": { "width": 320, "height": 240, "fps": 30 }, + "scenes": [ + { "duration": 1.0, "children": [ + { "type": "shape", "shape": "circle", "id": "anchor", + "position": "absolute", "x": 0, "y": 0, + "style": { "width": "20px", "height": "20px" } } + ] }, + scene_with_id_and_transform_ref("follower", "anchor") + ] + }) + .to_string(); + + let loaded = load(ValidationSource::Inline(&json)).expect("scenario loads"); + let report = run_checks(&loaded, false); + assert!( + report + .schema_errors + .iter() + .any(|e| e.contains("reference_cross_scene")), + "expected a reference_cross_scene schema error: {:?}", + report.schema_errors + ); + assert!(report.is_blocking(false), "must block, lenient or not"); + assert!( + report.is_blocking(true), + "a structural reference error is not a geometry violation — --lenient must not \ + downgrade it" + ); + } + + #[test] + fn a_reference_to_an_id_in_the_same_scene_validates_clean() { + let json = serde_json::json!({ + "video": { "width": 320, "height": 240, "fps": 30 }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "shape", "shape": "circle", "id": "anchor", + "position": "absolute", "x": 0, "y": 0, + "style": { "width": "20px", "height": "20px" } }, + scene_with_id_and_transform_ref("follower", "anchor")["children"][0].clone() + ] + }] + }) + .to_string(); + + let loaded = load(ValidationSource::Inline(&json)).expect("scenario loads"); + let report = run_checks(&loaded, false); + assert!( + report + .schema_errors + .iter() + .all(|e| !e.contains("reference_cross_scene") && !e.contains("node(")), + "a same-scene reference must not be flagged: {:?}", + report.schema_errors + ); + } + + #[test] + fn a_reference_to_a_genuinely_unknown_id_is_reported() { + let json = serde_json::json!({ + "video": { "width": 320, "height": 240, "fps": 30 }, + "scenes": [ + scene_with_id_and_transform_ref("follower", "does_not_exist_anywhere") + ] + }) + .to_string(); + + let loaded = load(ValidationSource::Inline(&json)).expect("scenario loads"); + let report = run_checks(&loaded, false); + assert!( + report + .schema_errors + .iter() + .any(|e| e.contains("unknown id")), + "expected an unknown-id schema error: {:?}", + report.schema_errors + ); + } +} + #[cfg(test)] mod check_crf_tests { use super::check_crf; diff --git a/crates/rustmotion/src/cli/mod.rs b/crates/rustmotion/src/cli/mod.rs index 0ca99269..4a0b6156 100644 --- a/crates/rustmotion/src/cli/mod.rs +++ b/crates/rustmotion/src/cli/mod.rs @@ -186,6 +186,55 @@ enum Commands { var: Vec, }, + /// Render a contact sheet: one PNG grid of timestamped stills, so a + /// scenario can be inspected at several instants at once without + /// watching it play. Reuses the same single-frame render path as + /// `still`, one call per requested instant. + Sheet { + /// Path to the JSON scenario file + #[arg(short, long)] + file: PathBuf, + + /// Comma-separated list of instants to capture, in seconds (e.g. + /// "3.2,4.8,19.9"). Cells are laid out in this order. Mutually + /// exclusive with --every; exactly one of the two is required. + #[arg( + long, + value_name = "T1,T2,...", + conflicts_with = "every", + required_unless_present = "every" + )] + at: Option, + + /// Sample every `SECONDS` from 0 up to the scenario's last frame. + /// Mutually exclusive with --at; exactly one of the two is required. + #[arg( + long, + value_name = "SECONDS", + conflicts_with = "at", + required_unless_present = "at" + )] + every: Option, + + /// Output file path + #[arg(short, long, default_value = "sheet.png")] + output: PathBuf, + + /// Number of cells per row. The grid lays out left to right, top to + /// bottom. + #[arg(long, default_value_t = 4)] + columns: usize, + + /// Width of each cell in pixels. Cell height follows the + /// scenario's own aspect ratio, so no letterboxing is needed. + #[arg(long, default_value_t = 320)] + cell_width: u32, + + /// Output format for machine consumption + #[arg(long, value_enum)] + output_format: Option, + }, + /// Generate word-level caption timings from audio (whisper.cpp) or subtitles #[command(after_help = CAPTIONS_EXAMPLES)] Captions { @@ -232,8 +281,8 @@ enum Commands { #[arg(long)] report: Option, - /// Auto-fix safe violations in place (clamp positions, set wrap=true, - /// enable auto_scroll). The original file is rewritten. + /// Auto-fix safe violations in place (clamp positions, set wrap=true). + /// The original file is rewritten. #[arg(long)] fix: bool, @@ -261,6 +310,21 @@ enum Commands { var: Vec, }, + /// Convert a scenario to `"timing": "v2"` (issue #336), compensating + /// every scene's duration and `at` so the migrated file renders + /// frame-for-frame identically to the one it replaces. Refuses a + /// templated scenario, or one using `include`/`for-each`/`use`, for the + /// same reason `validate --fix` already does. + Migrate { + /// Path to the JSON scenario file + #[arg(short, long)] + file: PathBuf, + + /// Write the migrated scenario here instead of overwriting `--file` + #[arg(short, long)] + output: Option, + }, + /// Render one video per line of a JSONL data file Batch { /// Path to the scenario template file (JSON or HTML dialect) @@ -796,6 +860,27 @@ pub fn run() -> Result<()> { let scenario = rustmotion::loader::load_input_with_vars(&file, overrides.as_ref())?; commands::cmd_still(scenario, &output, time, format, quality) } + Commands::Sheet { + file, + at, + every, + output, + columns, + cell_width, + output_format, + } => { + let scenario = rustmotion::loader::load_input_with_vars(&file, None)?; + commands::cmd_sheet( + scenario, + &output, + at.as_deref(), + every, + columns, + cell_width, + output_format.as_ref(), + cli.quiet, + ) + } Commands::Captions { audio, output, @@ -833,6 +918,7 @@ pub fn run() -> Result<()> { overrides.as_ref(), ) } + Commands::Migrate { file, output } => commands::cmd_migrate(&file, output.as_deref()), Commands::Batch { file, data, diff --git a/crates/rustmotion/src/encode/audio.rs b/crates/rustmotion/src/encode/audio.rs index 6f284a44..a221a5ad 100644 --- a/crates/rustmotion/src/encode/audio.rs +++ b/crates/rustmotion/src/encode/audio.rs @@ -499,6 +499,168 @@ fn resample_linear(samples: &[f32], src_rate: u32, dst_rate: u32) -> Vec { result } +// ─── Synthesised soundtrack (issue #331) ─────────────────────────────────────── +// +// `rustmotion-core::audio` renders a declarative score into an offline f32 +// buffer with no audio file involved. The bridge here writes that buffer to +// a cached WAV file and appends it to `ResolvedScenario::audio` as an +// ordinary `AudioTrack` — from that point on it is indistinguishable from a +// file a user actually supplied, and flows through `mix_audio_tracks_segment` +// above (resampling, `--frames a-b` segment windowing, the final mux) +// completely unmodified. This module joins that existing pipeline; it does +// not replace any part of it. + +/// Cache key for a synthesised score's rendered WAV: a hash of its JSON +/// representation plus the resolved `bpm`/`beat_offset`/duration it was +/// rendered against. Mirrors `video_audio.rs`'s own cache-by-hash +/// convention for its embedded-video-audio extraction. +/// +/// `AudioConfig`/`Score`/`Voice` hold `f32`/`f64` fields and so cannot +/// derive `Hash` directly; this goes through `serde_json::to_string` +/// instead, re-keying `voices` (a `HashMap`, whose field order — +/// and so its JSON string — would otherwise vary per process) through a +/// `BTreeMap` first, so the same score hashes to the same path both within +/// a run and across separate ones. Even if it didn't: a hash collision +/// here is only ever a cache *miss* (a harmless re-render) or, in +/// principle, a cache hit on the wrong content — the samples +/// `rustmotion_core::audio::render` produces never depend on this key at +/// all (see its doc), only on `cfg`/`ctx`/`duration_secs` themselves. +fn synth_cache_path( + cfg: &crate::schema::AudioConfig, + ctx: &rustmotion_core::schema::time::TimeCtx, + duration_secs: f64, +) -> std::path::PathBuf { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + // `Score::voices` is a `HashMap`, whose iteration order — and so + // `serde_json::to_string`'s field order — varies per process (std's + // default hasher is randomly seeded). Hashing that string directly + // would turn every fresh `rustmotion` invocation into a cache miss for + // the *identical* score. Re-keying through a `BTreeMap` first fixes the + // order deterministically, so the same score hashes to the same path + // both within a run and across separate ones. + let score = cfg.as_score(); + let sorted_voices: std::collections::BTreeMap<_, _> = score.voices.iter().collect(); + if let Ok(json) = serde_json::to_string(&sorted_voices) { + json.hash(&mut hasher); + } + if let Ok(json) = serde_json::to_string(&score.score) { + json.hash(&mut hasher); + } + if let Ok(json) = serde_json::to_string(&score.master) { + json.hash(&mut hasher); + } + ctx.bpm.map(f64::to_bits).hash(&mut hasher); + ctx.beat_offset.to_bits().hash(&mut hasher); + duration_secs.to_bits().hash(&mut hasher); + + std::env::temp_dir().join(format!("rustmotion_synth_{:016x}.wav", hasher.finish())) +} + +/// Writes a canonical PCM WAV (16-bit, little-endian, no extension chunks) — +/// the same header shape this module's own test fixtures already use, just +/// as production code instead of a test helper. `symphonia`'s WAV demuxer +/// (already exercised by [`decode_audio_file`]) reads this back byte-exact. +fn write_wav_pcm16( + path: &std::path::Path, + samples: &[i16], + sample_rate: u32, + channels: u16, +) -> std::io::Result<()> { + let bits_per_sample: u16 = 16; + let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8; + let block_align = channels * bits_per_sample / 8; + let data_size = (samples.len() * 2) as u32; + + let mut buf = Vec::with_capacity(44 + data_size as usize); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&(36 + data_size).to_le_bytes()); + buf.extend_from_slice(b"WAVE"); + buf.extend_from_slice(b"fmt "); + buf.extend_from_slice(&16u32.to_le_bytes()); + buf.extend_from_slice(&1u16.to_le_bytes()); // PCM + buf.extend_from_slice(&channels.to_le_bytes()); + buf.extend_from_slice(&sample_rate.to_le_bytes()); + buf.extend_from_slice(&byte_rate.to_le_bytes()); + buf.extend_from_slice(&block_align.to_le_bytes()); + buf.extend_from_slice(&bits_per_sample.to_le_bytes()); + buf.extend_from_slice(b"data"); + buf.extend_from_slice(&data_size.to_le_bytes()); + for &s in samples { + buf.extend_from_slice(&s.to_le_bytes()); + } + std::fs::write(path, &buf) +} + +/// Renders `cfg`'s synthesised score (issue #331's `voices`/`score`/ +/// `master`) and appends it to `resolved.audio` as a plain [`AudioTrack`], +/// so every downstream consumer — the resampler, `--frames a-b` segment +/// windowing, the final mux — treats it exactly like a user-supplied file. +/// A no-op when `cfg.has_synth()` is `false` (an object-form `audio` used +/// only to carry `tracks`) or the scenario has zero duration. +/// +/// `scenario_bpm`/`scenario_beat_offset` are the scenario's own grid, +/// captured by the caller (`crate::loader`) before `include::resolve_includes` +/// consumes the `Scenario` they came from; `cfg.bpm`/`cfg.beat_offset` +/// override them when set. Sharing the scenario's real grid by default — +/// not duplicating it — is deliverable #1's whole point: a score whose +/// `every`/`from`/`to` resolve against the *same* `bpm`/`beat_offset` as +/// `Scene::at` is what puts a kick on the same instant as a cut. +pub fn synthesize_score_into_track( + resolved: &mut crate::schema::ResolvedScenario, + cfg: &crate::schema::AudioConfig, + scenario_bpm: Option, + scenario_beat_offset: f64, +) -> Result<()> { + if !cfg.has_synth() { + return Ok(()); + } + + let ctx = rustmotion_core::schema::time::TimeCtx { + bpm: cfg.bpm.or(scenario_bpm), + beat_offset: cfg.beat_offset.unwrap_or(scenario_beat_offset), + scene_start: 0.0, + }; + let duration_secs = crate::encode::video_audio::resolved_scenario_duration(resolved); + if duration_secs <= 0.0 { + return Ok(()); + } + + let path = synth_cache_path(cfg, &ctx, duration_secs); + if !path.exists() { + let score = cfg.as_score(); + let stereo = rustmotion_core::audio::render(&score, ctx, duration_secs)?; + let samples_i16: Vec = stereo + .iter() + .map(|&s| (s.clamp(-1.0, 1.0) * 32767.0) as i16) + .collect(); + write_wav_pcm16( + &path, + &samples_i16, + rustmotion_core::audio::SYNTH_SAMPLE_RATE, + 2, + ) + .map_err(|e| RustmotionError::AudioSynthWrite { + path: path.display().to_string(), + reason: e.to_string(), + })?; + } + + resolved.audio.push(AudioTrack { + src: path.display().to_string(), + start: 0.0, + end: None, + volume: 1.0, + fade_in: None, + fade_out: None, + volume_keyframes: Vec::new(), + }); + + Ok(()) +} + // ─── Unit tests ─────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index 378174f5..97349959 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -2,8 +2,8 @@ use crate::engine::animator::ease; use crate::engine::transition::{apply_transition, camera_pan_transition, TransitionOptions}; use crate::error::{Result, RustmotionError}; use crate::schema::{ - EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionType, VideoConfig, - ViewType, + EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, SceneStart, SceneTail, SnapMode, + TimingMode, TransitionType, VideoConfig, ViewType, }; /// Description of what to render for a specific frame @@ -38,6 +38,19 @@ pub enum FrameTask { scene_b_idx: usize, frame_in_transition: u32, scene_a_frame_offset: u32, + /// Whether scene A's frame index keeps advancing through the + /// transition (`scene_a_frame_offset + frame_in_transition`, today's + /// only behaviour) or stays pinned at `scene_a_frame_offset` for + /// every frame of the transition. + /// + /// Every `v1`-built task sets this `true`, reproducing the original + /// formula exactly. `timing: "v2"` (issue #336) is the only builder + /// that ever sets it `false` — for a scene whose `tail` is + /// `"freeze"`, rendered past its own end: the outgoing scene must + /// hold its *last* frame for the whole overlap rather than replay + /// frames it already showed as plain `Normal` frames moments + /// earlier (v2 does not clip a scene's own tail the way v1 does). + scene_a_frame_advance: bool, scene_a_total_frames: u32, scene_b_total_frames: u32, transition_type: TransitionType, @@ -157,6 +170,7 @@ pub fn render_frame_task_scaled( scaled_h, &scene.effects, *frame_in_scene, + *frame_in_scene as f64 / config.fps as f64, ); Ok(pixels) } @@ -167,6 +181,7 @@ pub fn render_frame_task_scaled( scene_b_idx, frame_in_transition, scene_a_frame_offset, + scene_a_frame_advance, scene_a_total_frames, scene_b_total_frames, transition_type, @@ -180,7 +195,11 @@ pub fn render_frame_task_scaled( let scaled_h = (config.height as f32 * scale_factor) as u32; let fps = config.fps; let progress = transition_progress(*frame_in_transition, *transition_duration, fps); - let frame_a_idx = scene_a_frame_offset + frame_in_transition; + let frame_a_idx = if *scene_a_frame_advance { + scene_a_frame_offset + frame_in_transition + } else { + *scene_a_frame_offset + }; if matches!(transition_type, TransitionType::CameraPan) { let (ax, ay) = scenes[*scene_a_idx] @@ -253,6 +272,7 @@ pub fn render_frame_task_scaled( scaled_h, &scenes[*scene_b_idx].effects, *frame_in_transition, + *frame_in_transition as f64 / config.fps as f64, ); return Ok(composited); } @@ -313,6 +333,7 @@ pub fn render_frame_task_scaled( scaled_h, &scenes[*scene_b_idx].effects, *frame_in_transition, + *frame_in_transition as f64 / config.fps as f64, ); Ok(composited) } @@ -350,6 +371,7 @@ pub fn render_frame_task_scaled( scaled_h, &view.scenes[active_idx].effects, *frame_in_view, + *frame_in_view as f64 / config.fps as f64, ); } Ok(pixels) @@ -405,6 +427,7 @@ pub fn render_frame_task_scaled( scaled_h, &first_scene.effects, *frame_in_transition, + *frame_in_transition as f64 / config.fps as f64, ); } Ok(composited) @@ -584,7 +607,10 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec { } match view.view_type { - ViewType::Slide => build_slide_view_tasks(&mut tasks, view_idx, view, fps), + ViewType::Slide => match view_timing(view) { + TimingMode::V1 => build_slide_view_tasks(&mut tasks, view_idx, view, fps), + TimingMode::V2 => build_slide_view_tasks_v2(&mut tasks, view_idx, view, fps), + }, ViewType::World => build_world_view_tasks( &mut tasks, view_idx, @@ -701,6 +727,7 @@ fn build_slide_view_tasks( scene_b_idx: i + 1, frame_in_transition: f, scene_a_frame_offset: scene_frames - outgoing_transition_frames, + scene_a_frame_advance: true, scene_a_total_frames: scene_frames, scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), @@ -713,6 +740,235 @@ fn build_slide_view_tasks( } } +/// Which [`TimingMode`] a slide view's tasks should be built under. +/// +/// There is no view-level (or scenario-level) place to read this from — see +/// the doc on [`crate::schema::ResolvedScenario`] for why: `bpm`/ +/// `beat_offset`/`timing`/`snap` are stamped onto each individual +/// [`Scene`] instead (`Scene::resolved_timing`), once, when the source +/// `Scenario` is deserialized. A view's timing is therefore its first +/// scene's `resolved_timing` — the common case (no `include`, uniform +/// `timing` for the whole file) makes every scene in a view agree, so this +/// is exact there; a view built by mixing an `include`d file that declares +/// no `timing` of its own with a root scenario that does is a known, +/// undocumented-by-tests edge case this reduces to "the first scene wins". +fn view_timing(view: &ResolvedView) -> TimingMode { + view.scenes + .first() + .map(|s| s.resolved_timing) + .unwrap_or_default() +} + +/// Frames spent transitioning *into* `scenes[entering_idx]` — 0 if it has no +/// `transition` of its own (every scene's `transition` describes how it is +/// entered from the previous one). Clamped to the entering scene's own +/// frame budget: unlike v1 (which clamps against the *outgoing* scene, +/// because that side pays for the transition there), v2 never shortens the +/// outgoing scene, so the entering scene is the one whose own Normal frames +/// would go negative if the transition were allowed to ask for more than it +/// has. +fn v2_incoming_transition_frames(entering: &Scene, entering_frames: u32, fps: u32) -> u32 { + let Some(transition) = entering.transition.as_ref() else { + return 0; + }; + let raw = (transition.duration * fps as f64).round() as u32; + raw.min(entering_frames) +} + +/// Rounds `seconds` to the nearest point on the beat grid `beat_offset + n * +/// 60 / bpm` (issue #336, `snap: "beat"`) — the cheapest way to get a +/// rhythmic edit by default: an author sets `snap` once and never +/// hand-computes a single beat position for `at`. +fn snap_seconds_to_beat(seconds: f64, beat_offset: f64, bpm: f64) -> f64 { + let beat_len = 60.0 / bpm; + let n = ((seconds - beat_offset) / beat_len).round(); + beat_offset + n * beat_len +} + +/// Resolves `scene.at` to an absolute frame index, applying `snap: "beat"` +/// when the scene's scenario declared one. Never fails outright: an +/// unresolvable `TimePoint` (e.g. a beat unit with no `bpm`) is reported to +/// stderr and treated as [`SceneStart::Auto`] (`fallback`) — `build_frame_tasks` +/// has no `Result` to propagate through, and the rest of the render +/// pipeline (every caller of it) is built on that being infallible. +fn v2_resolve_at_frames(scene: &Scene, scene_idx: usize, fps: u32, fallback: u32) -> u32 { + let SceneStart::At(ref tp) = scene.at else { + return fallback; + }; + let seconds = match tp.resolve_absolute(&scene.resolved_time_ctx) { + Ok(s) => s, + Err(e) => { + eprintln!( + "warning: scene {scene_idx}'s `at` could not be resolved ({e}); falling back \ + to automatic placement" + ); + return fallback; + } + }; + let seconds = match scene.resolved_snap { + Some(SnapMode::Beat) => match scene.resolved_time_ctx.bpm { + Some(bpm) => snap_seconds_to_beat(seconds, scene.resolved_time_ctx.beat_offset, bpm), + None => seconds, + }, + None => seconds, + }; + (seconds * fps as f64).round().max(0.0) as u32 +} + +/// Pushes `count` repeats of `scenes[scene_idx]`'s frame `frame_in_scene` as +/// plain `Normal` tasks — holding that one frame in place. Used by +/// [`build_slide_view_tasks_v2`] to fill a *gap* an explicit `at` can open +/// (an absolute start later than where the previous scene naturally ends): +/// the previous scene (or, for a leading gap before the very first scene, +/// that scene's own opening frame) holds until the gap closes, so the dense, +/// index-addressed frame schedule this crate builds everywhere else never +/// grows a hole. +fn hold_scene_frame( + tasks: &mut Vec, + view_idx: usize, + scene_idx: usize, + frame_in_scene: u32, + scene_total_frames: u32, + count: u32, +) { + for _ in 0..count { + tasks.push(FrameTask::Normal { + global_frame: tasks.len() as u32, + view_idx, + scene_idx, + frame_in_scene, + scene_total_frames, + }); + } +} + +/// `build_slide_view_tasks`'s `timing: "v2"` counterpart (issue #336): scene +/// *i* occupies `[at_i, at_i + duration_i)` and a transition entering scene +/// *i+1* renders scene *i* an *additional* `transition_frames(i+1)` frames +/// past that window instead of stealing from inside it — see the module doc +/// this function's neighbours don't have room for: `at_i` defaults +/// (`SceneStart::Auto`) to exactly where scene `i-1`'s own window ends, so +/// the transition never shortens anything and the view's total frame count +/// is simply `sum(duration_frames)`, independent of how many transitions +/// there are or how long they last. +/// +/// An explicit `at` that lands *before* the natural next position is +/// clamped up to it (this workstream does not model an overlap beyond what +/// a declared `transition` already covers) and warns; one that lands +/// *after* it opens a gap, filled by holding the previous scene — see +/// [`hold_scene_frame`]. +fn build_slide_view_tasks_v2( + tasks: &mut Vec, + view_idx: usize, + view: &ResolvedView, + fps: u32, +) { + let scenes = &view.scenes; + if scenes.is_empty() { + return; + } + + let duration_frames: Vec = scenes + .iter() + .map(|s| (s.duration * fps as f64).round() as u32) + .collect(); + + // Frames spent transitioning into scenes[k] — 0 for k == 0. + let transition_frames: Vec = scenes + .iter() + .enumerate() + .map(|(k, s)| { + if k == 0 { + 0 + } else { + v2_incoming_transition_frames(s, duration_frames[k], fps) + } + }) + .collect(); + + let mut cursor: u32 = 0; + + for (i, scene) in scenes.iter().enumerate() { + let requested_start = match scene.at { + SceneStart::Auto(_) => cursor, + SceneStart::At(_) => v2_resolve_at_frames(scene, i, fps, cursor), + }; + let start = if requested_start < cursor { + eprintln!( + "warning: scene {i}'s `at` resolves before the previous scene's own window \ + ends ({:.3}s) — clamped to avoid an overlap this workstream does not model", + cursor as f64 / fps as f64 + ); + cursor + } else { + requested_start + }; + + if start > cursor { + let gap = start - cursor; + if i == 0 { + hold_scene_frame(tasks, view_idx, 0, 0, duration_frames[0], gap); + } else { + let prev = i - 1; + hold_scene_frame( + tasks, + view_idx, + prev, + duration_frames[prev].saturating_sub(1), + duration_frames[prev], + gap, + ); + } + } + + for f in transition_frames[i]..duration_frames[i] { + tasks.push(FrameTask::Normal { + global_frame: tasks.len() as u32, + view_idx, + scene_idx: i, + frame_in_scene: f, + scene_total_frames: duration_frames[i], + }); + } + cursor = start + duration_frames[i]; + + if let Some(next_scene) = scenes.get(i + 1) { + let d = transition_frames[i + 1]; + if d > 0 { + let transition = next_scene + .transition + .as_ref() + .expect("transition_frames[i+1] > 0 implies scenes[i+1].transition.is_some()"); + let advance = matches!(scene.tail, SceneTail::Continue); + let offset = if advance { + duration_frames[i] + } else { + duration_frames[i].saturating_sub(1) + }; + let scene_b_frames = duration_frames[i + 1]; + let easing = transition.easing.clone(); + for f in 0..d { + tasks.push(FrameTask::SlideTransition { + global_frame: tasks.len() as u32, + view_idx, + scene_a_idx: i, + scene_b_idx: i + 1, + frame_in_transition: f, + scene_a_frame_offset: offset, + scene_a_frame_advance: advance, + scene_a_total_frames: duration_frames[i], + scene_b_total_frames: scene_b_frames, + transition_type: transition.transition_type.clone(), + options: transition.into(), + transition_duration: d as f64 / fps as f64, + easing: easing.clone(), + }); + } + } + } + } +} + fn build_world_view_tasks( tasks: &mut Vec, view_idx: usize, @@ -963,6 +1219,7 @@ pub(super) fn build_scene_frame_tasks_in_view( scene_b_idx: scene_idx + 1, frame_in_transition: f, scene_a_frame_offset: scene_frames - outgoing_transition_frames, + scene_a_frame_advance: true, scene_a_total_frames: scene_frames, scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), @@ -1464,3 +1721,339 @@ mod segment_tests { assert!(all.iter().all(|d| *d)); } } + +// Issue #336: absolute scene placement + a beat grid. `build_frame_tasks` +// must render `timing: "v2"` without subtracting transition durations from +// the total, and must stay byte-identical (same 13.5s-from-15.0s +// subtraction) when `timing` is absent. +#[cfg(test)] +mod timing_v2_tests { + use super::*; + use crate::loader::load_scenario_from_source; + use crate::schema::ResolvedScenario; + + /// The issue's own reproduction case, verbatim: six scenes declaring + /// 2.2 + 2.6 + 2.6 + 3.12 + 2.08 + 2.4 = 15.0s, five transitions + /// 0.3 + 0.3 + 0.3 + 0.25 + 0.35 = 1.5s. fps=20 is chosen because at + /// that rate every one of those eleven durations lands on an exact + /// frame count under Rust's round-half-away-from-zero `f64::round()` + /// (unlike e.g. 30fps, where 0.35s rounds to 10.5 frames and drifts the + /// v1 total off 13.5s by a third of a frame) — so both the v1 and the + /// v2 target are exact, not "close to", 13.5s/15.0s. + fn six_scene_json(timing: Option<&str>) -> String { + let timing_field = timing + .map(|t| format!(r#""timing": "{t}","#)) + .unwrap_or_default(); + format!( + r##"{{ + "video": {{"width": 64, "height": 64, "fps": 20}}, + {timing_field} + "scenes": [ + {{"duration": 2.2, "children": []}}, + {{"duration": 2.6, "children": [], + "transition": {{"type": "fade", "duration": 0.3}}}}, + {{"duration": 2.6, "children": [], + "transition": {{"type": "fade", "duration": 0.3}}}}, + {{"duration": 3.12, "children": [], + "transition": {{"type": "fade", "duration": 0.3}}}}, + {{"duration": 2.08, "children": [], + "transition": {{"type": "fade", "duration": 0.25}}}}, + {{"duration": 2.4, "children": [], + "transition": {{"type": "fade", "duration": 0.35}}}} + ] + }}"## + ) + } + + fn load(json: &str) -> ResolvedScenario { + load_scenario_from_source(None, Some(json)).expect("load") + } + + #[test] + fn v2_timing_renders_the_full_declared_duration_with_no_subtraction() { + let scenario = load(&six_scene_json(Some("v2"))); + let tasks = build_frame_tasks(&scenario); + let seconds = tasks.len() as f64 / scenario.video.fps as f64; + assert_eq!( + seconds, + 15.0, + "expected exactly 15.0s under timing: v2 (at_last + duration_last, no \ + subtraction), got {seconds}s ({} frames)", + tasks.len() + ); + } + + #[test] + fn absent_timing_still_subtracts_transition_durations_like_today() { + // No `timing` field at all — the default (`v1`) must reproduce + // today's behaviour exactly: sum(durations) - sum(transitions). + let scenario = load(&six_scene_json(None)); + let tasks = build_frame_tasks(&scenario); + let seconds = tasks.len() as f64 / scenario.video.fps as f64; + assert_eq!( + seconds, 13.5, + "expected exactly 13.5s under the default (v1) subtracting semantics, got {seconds}s" + ); + } + + #[test] + fn explicit_timing_v1_matches_the_default() { + let default_tasks = build_frame_tasks(&load(&six_scene_json(None))); + let explicit_tasks = build_frame_tasks(&load(&six_scene_json(Some("v1")))); + assert_eq!(default_tasks.len(), explicit_tasks.len()); + } + + #[test] + fn frame_range_still_addresses_the_same_dense_index_space_under_v2() { + // Deliverable 7: `--frames a-b` (`build_frame_tasks_range`) must + // keep slicing the same flat, index-addressed schedule under v2. + let scenario = load(&six_scene_json(Some("v2"))); + let full = build_frame_tasks(&scenario); + let total = full.len() as u32; + assert_eq!(total, 300, "15.0s @ 20fps must be exactly 300 frames"); + + let (range_tasks, reported_total) = + build_frame_tasks_range(&scenario, 100, 199).expect("range must be in bounds"); + assert_eq!(reported_total, total); + assert_eq!(range_tasks.len(), 100); + for (offset, task) in range_tasks.iter().enumerate() { + let expected_global = 100 + offset as u32; + let actual_global = match task { + FrameTask::Normal { global_frame, .. } => *global_frame, + FrameTask::SlideTransition { global_frame, .. } => *global_frame, + FrameTask::WorldFrame { global_frame, .. } => *global_frame, + FrameTask::ViewTransition { global_frame, .. } => *global_frame, + }; + assert_eq!(actual_global, expected_global); + } + + // Out of range still errors exactly like it does under v1. + assert!(build_frame_tasks_range(&scenario, 0, total).is_err()); + } + + fn two_scene_json(tail: Option<&str>, transition_duration: f64) -> String { + let tail_field = tail + .map(|t| format!(r#""tail": "{t}", "#)) + .unwrap_or_default(); + format!( + r##"{{ + "video": {{"width": 64, "height": 64, "fps": 30}}, + "timing": "v2", + "scenes": [ + {{{tail_field}"duration": 1.0, "children": []}}, + {{"duration": 1.0, "children": [], + "transition": {{"type": "fade", "duration": {transition_duration}}}}} + ] + }}"## + ) + } + + #[test] + fn v2_default_tail_freezes_scene_a_through_the_overlap() { + let scenario = load(&two_scene_json(None, 0.2)); + let tasks = build_frame_tasks(&scenario); + let transitions: Vec<_> = tasks + .iter() + .filter_map(|t| match t { + FrameTask::SlideTransition { + scene_a_frame_offset, + scene_a_frame_advance, + scene_a_total_frames, + .. + } => Some(( + *scene_a_frame_offset, + *scene_a_frame_advance, + *scene_a_total_frames, + )), + _ => None, + }) + .collect(); + assert!(!transitions.is_empty(), "expected transition frames"); + for (offset, advance, total_frames) in transitions { + assert!(!advance, "default tail must not advance scene A's clock"); + assert_eq!( + offset, + total_frames - 1, + "frozen scene A must always render its own last real frame" + ); + } + } + + #[test] + fn v2_continue_tail_advances_scene_a_past_its_own_duration() { + let scenario = load(&two_scene_json(Some("continue"), 0.2)); + let tasks = build_frame_tasks(&scenario); + let transitions: Vec<_> = tasks + .iter() + .filter_map(|t| match t { + FrameTask::SlideTransition { + scene_a_frame_offset, + scene_a_frame_advance, + scene_a_total_frames, + .. + } => Some(( + *scene_a_frame_offset, + *scene_a_frame_advance, + *scene_a_total_frames, + )), + _ => None, + }) + .collect(); + assert!(!transitions.is_empty(), "expected transition frames"); + for (offset, advance, total_frames) in transitions { + assert!( + advance, + "\"continue\" tail must keep scene A's clock advancing" + ); + assert_eq!( + offset, total_frames, + "the overlap must pick up exactly where scene A's own Normal frames left off" + ); + } + } + + #[test] + fn v2_scene_a_own_normal_frames_are_never_clipped() { + // The core of the fix: under v1 the outgoing scene loses its last + // `transition_frames` frames from its own Normal range. Under v2 it + // must keep every one of them — this is what makes the total + // additive instead of subtractive. + let scenario = load(&two_scene_json(None, 0.5)); + let tasks = build_frame_tasks(&scenario); + let scene_a_normal_frames: Vec = tasks + .iter() + .filter_map(|t| match t { + FrameTask::Normal { + scene_idx: 0, + frame_in_scene, + .. + } => Some(*frame_in_scene), + _ => None, + }) + .collect(); + let fps = scenario.video.fps; + let expected_frames = (1.0 * fps as f64).round() as u32; + assert_eq!( + scene_a_normal_frames.len() as u32, + expected_frames, + "scene A's own 1.0s must render in full as Normal frames, unclipped: got {scene_a_normal_frames:?}" + ); + assert_eq!(*scene_a_normal_frames.last().unwrap(), expected_frames - 1); + } + + #[test] + fn snap_beat_rounds_an_explicit_at_onto_the_grid() { + // bpm=120 -> beat length 0.5s, beats at 0, 0.5, 1.0, 1.5, 2.0, ... + // Scene 0 (1.0s) ends at 1.0s, well before either candidate beat, so + // this only exercises snapping, not the overlap clamp. Scene 1 asks + // for the off-grid 1.8s, nearer to the 2.0s beat than to 1.5s; with + // `snap: "beat"` that must land exactly on 2.0s. + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "timing": "v2", + "bpm": 120, + "snap": "beat", + "scenes": [ + {"duration": 1.0, "children": []}, + {"duration": 1.0, "children": [], "at": "1.8s"} + ] + }"##; + let scenario = load(json); + let tasks = build_frame_tasks(&scenario); + let scene_1_start = tasks + .iter() + .find_map(|t| match t { + FrameTask::Normal { + scene_idx: 1, + global_frame, + frame_in_scene: 0, + .. + } => Some(*global_frame), + _ => None, + }) + .expect("scene 1 must have a frame_in_scene == 0 Normal task"); + let fps = scenario.video.fps as f64; + assert_eq!( + scene_1_start as f64 / fps, + 2.0, + "snap: beat must round the off-grid 1.8s onto the 2.0s beat" + ); + } + + #[test] + fn at_beat_unit_resolves_against_the_scenarios_bpm_and_beat_offset() { + // bpm=120 (0.5s/beat), beat_offset=2.2s (the reel's real anchor per + // issue #336) -> beat 1 lands at 2.2 + 0.5 = 2.7s. Scene 0 is only + // 2.0s, ending well before that, so this isolates beat resolution + // from the overlap clamp (see `snap_beat_rounds_an_explicit_at_onto_the_grid`'s doc). + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "timing": "v2", + "bpm": 120, + "beat_offset": 2.2, + "scenes": [ + {"duration": 2.0, "children": []}, + {"duration": 1.0, "children": [], "at": "1b"} + ] + }"##; + let scenario = load(json); + let tasks = build_frame_tasks(&scenario); + let scene_1_start = tasks + .iter() + .find_map(|t| match t { + FrameTask::Normal { + scene_idx: 1, + global_frame, + frame_in_scene: 0, + .. + } => Some(*global_frame), + _ => None, + }) + .expect("scene 1 must have a frame_in_scene == 0 Normal task"); + let fps = scenario.video.fps as f64; + assert!( + (scene_1_start as f64 / fps - 2.7).abs() < 1e-9, + "expected scene 1 to start at beat 1 = 2.7s, got {}s", + scene_1_start as f64 / fps + ); + } + + #[test] + fn v2_gap_from_an_explicit_at_holds_the_previous_scene() { + // Scene 1 explicitly starts a full second after scene 0's 1.0s + // window ends, opening a 1.0s gap that must be filled by holding + // scene 0 on its own last frame rather than leaving a hole in the + // dense, index-addressed schedule. + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 10}, + "timing": "v2", + "scenes": [ + {"duration": 1.0, "children": []}, + {"duration": 1.0, "children": [], "at": "2.0s"} + ] + }"##; + let scenario = load(json); + let tasks = build_frame_tasks(&scenario); + assert_eq!( + tasks.len(), + 30, + "1.0s scene0 + 1.0s gap + 1.0s scene1 @ 10fps" + ); + let held: Vec = tasks[10..20] + .iter() + .filter_map(|t| match t { + FrameTask::Normal { + scene_idx: 0, + frame_in_scene, + .. + } => Some(*frame_in_scene), + _ => None, + }) + .collect(); + assert_eq!( + held, + vec![9; 10], + "the 1.0s gap must hold scene 0's own last frame (index 9), got {held:?}" + ); + } +} diff --git a/crates/rustmotion/src/encode/video_audio.rs b/crates/rustmotion/src/encode/video_audio.rs index 01a1c033..e419aff8 100644 --- a/crates/rustmotion/src/encode/video_audio.rs +++ b/crates/rustmotion/src/encode/video_audio.rs @@ -106,6 +106,35 @@ pub fn scene_start_offsets(scenario: &ResolvedScenario) -> Vec> { result } +/// The rendered scenario's own total duration, in seconds — the last +/// view's last scene's own start offset plus its (frame-rounded) duration. +/// Reuses [`scene_start_offsets`] rather than re-deriving the same +/// transition-overlap arithmetic a second time. +/// +/// This is what sizes the synthesised-audio buffer (issue #331): a score +/// event with no explicit `to` plays until here, and the muxed track built +/// from it (`crate::encode::audio::synthesize_score_into_track`) is given +/// exactly this many seconds so a segment render (`--frames a-b`) can slice +/// it the same way it already slices any other [`crate::schema::AudioTrack`]. +pub fn resolved_scenario_duration(scenario: &ResolvedScenario) -> f64 { + let offsets = scene_start_offsets(scenario); + let fps = scenario.video.fps as f64; + let mut total = 0.0f64; + for (view_idx, view) in scenario.views.iter().enumerate() { + let Some(last_scene) = view.scenes.last() else { + continue; + }; + let last_offset = offsets + .get(view_idx) + .and_then(|o| o.last()) + .copied() + .unwrap_or(0.0); + let scene_frames = (last_scene.duration * fps).round() / fps; + total = total.max(last_offset + scene_frames); + } + total +} + // ─── Component walk ─────────────────────────────────────────────────────────── /// Collected metadata for a single video component found in the scene tree. @@ -137,26 +166,6 @@ fn collect_videos_in_child(child: &ChildComponent, out: &mut Vec { - for ch in &c.children { - collect_videos_in_child(ch, out); - } - } - Component::Flex(c) => { - for ch in &c.children { - collect_videos_in_child(ch, out); - } - } - Component::Grid(c) => { - for ch in &c.children { - collect_videos_in_child(ch, out); - } - } - Component::Positioned(c) => { - for ch in &c.children { - collect_videos_in_child(ch, out); - } - } Component::Container(c) => { for ch in &c.children { collect_videos_in_child(ch, out); diff --git a/crates/rustmotion/src/engine/preload.rs b/crates/rustmotion/src/engine/preload.rs index dc132c5b..951e3f3e 100644 --- a/crates/rustmotion/src/engine/preload.rs +++ b/crates/rustmotion/src/engine/preload.rs @@ -88,26 +88,6 @@ pub fn prefetch_icons(scenes: &[Scene]) { h, )); } - Component::Card(c) => { - for child in &c.children { - collect_from_component(child, seen); - } - } - Component::Flex(c) => { - for child in &c.children { - collect_from_component(child, seen); - } - } - Component::Grid(c) => { - for child in &c.children { - collect_from_component(child, seen); - } - } - Component::Positioned(c) => { - for child in &c.children { - collect_from_component(child, seen); - } - } Component::Container(c) => { for child in &c.children { collect_from_component(child, seen); @@ -400,10 +380,6 @@ pub fn preextract_video_frames(scenes: &[Scene], fps: u32) { // Recurse into containers if let Some(children) = match &child.component { - Component::Card(c) => Some(&c.children), - Component::Flex(c) => Some(&c.children), - Component::Grid(c) => Some(&c.children), - Component::Positioned(c) => Some(&c.children), Component::Container(c) => Some(&c.children), _ => None, } { diff --git a/crates/rustmotion/src/engine/render/mod.rs b/crates/rustmotion/src/engine/render/mod.rs index 7b97a763..e0d3782c 100644 --- a/crates/rustmotion/src/engine/render/mod.rs +++ b/crates/rustmotion/src/engine/render/mod.rs @@ -10,5 +10,5 @@ pub use scene::{ deserialize_children, prepare_scene, render_frame_v2, render_frame_v2_scaled, render_scene_bg_scaled, render_scene_fg_scaled, render_scene_frame, render_scene_frame_scaled, render_scene_frame_scaled_with_prev_bg, render_scene_hits, render_world_frame_scaled, - root_style, + resolve_node_references, root_style, }; diff --git a/crates/rustmotion/src/engine/render/post_effects.rs b/crates/rustmotion/src/engine/render/post_effects.rs index 0d34b496..653f2da5 100644 --- a/crates/rustmotion/src/engine/render/post_effects.rs +++ b/crates/rustmotion/src/engine/render/post_effects.rs @@ -12,16 +12,22 @@ //! when encoding offline; avoid on the studio preview hot path if performance matters. use rustmotion_core::schema::scenario::{BlurDirection, PostEffect}; +use rustmotion_core::schema::time::{TimeCtx, TimePoint}; /// Apply a sequence of post-processing effects in order to an RGBA frame buffer. /// /// `buf` must be exactly `w * h * 4` bytes (RGBA8888, row-major). +/// +/// `time` is the scene-local instant this buffer represents, in seconds. Only +/// `Flash` reads it; every other effect is time-invariant and depends on +/// `frame_index` alone. pub fn apply_post_effects( buf: &mut [u8], w: u32, h: u32, effects: &[PostEffect], frame_index: u32, + time: f64, ) { for effect in effects { match effect { @@ -45,10 +51,66 @@ pub fn apply_post_effects( } => { apply_progressive_blur(buf, w, h, direction, *start, *max_radius); } + PostEffect::Flash { + at, + color, + intensity, + duration, + } => { + apply_flash(buf, at, color, *intensity, *duration, time); + } } } } +/// Blend a full-frame colour over the buffer, at full `intensity` on the +/// flash's own instant and decaying linearly to nothing over `duration`. +/// +/// Outside `[at, at + duration)` this is a no-op, so an effect list carrying +/// several flashes costs one comparison each on every other frame. +pub fn apply_flash( + buf: &mut [u8], + at: &TimePoint, + color: &str, + intensity: f32, + duration: f32, + time: f64, +) { + if duration <= 0.0 || intensity <= 0.0 { + return; + } + let ctx = TimeCtx::default(); + let Ok(start) = at.resolve_relative(&ctx) else { + return; + }; + let dt = time - start; + if dt < 0.0 || dt >= duration as f64 { + return; + } + let alpha = intensity.clamp(0.0, 1.0) * (1.0 - (dt / duration as f64) as f32); + let (fr, fg, fb) = parse_hex_rgb(color); + for px in buf.as_chunks_mut::<4>().0 { + px[0] = blend(px[0], fr, alpha); + px[1] = blend(px[1], fg, alpha); + px[2] = blend(px[2], fb, alpha); + } +} + +fn blend(dst: u8, src: u8, a: f32) -> u8 { + (dst as f32 + (src as f32 - dst as f32) * a) + .round() + .clamp(0.0, 255.0) as u8 +} + +fn parse_hex_rgb(hex: &str) -> (u8, u8, u8) { + let h = hex.trim_start_matches('#'); + if h.len() < 6 { + return (255, 255, 255); + } + let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).unwrap_or(255); + (byte(0), byte(2), byte(4)) +} + // ─── Grain ─────────────────────────────────────────────────────────────────── /// Overlay film-grain noise on every pixel. @@ -625,6 +687,7 @@ mod tests { PostEffect::Pixelate { size: 4 }, ], 0, + 0.0, ); let mut b = base_buf.clone(); @@ -641,6 +704,7 @@ mod tests { }, ], 0, + 0.0, ); assert_ne!(a, b, "grain+pixelate must differ from pixelate+grain"); @@ -652,7 +716,27 @@ mod tests { fn no_effects_is_identity() { let orig = solid(4, 4, 200, 150, 100); let mut buf = orig.clone(); - apply_post_effects(&mut buf, 4, 4, &[], 0); + apply_post_effects(&mut buf, 4, 4, &[], 0, 0.0); assert_eq!(buf, orig); } + + #[test] + fn a_flash_peaks_on_its_own_instant_and_is_gone_after_its_duration() { + let flash = PostEffect::Flash { + at: TimePoint::Seconds(1.0), + color: "#FF0000".to_string(), + intensity: 1.0, + duration: 0.2, + }; + let sample = |t: f64| { + let mut buf = vec![0u8; 4 * 4]; + apply_post_effects(&mut buf, 2, 2, std::slice::from_ref(&flash), 0, t); + buf[0] + }; + assert_eq!(sample(0.5), 0, "before its instant, nothing"); + assert_eq!(sample(1.0), 255, "on its instant, full intensity"); + assert!(sample(1.1) > 0 && sample(1.1) < 255, "mid-decay"); + assert_eq!(sample(1.2), 0, "after its duration, nothing"); + assert_eq!(sample(5.0), 0, "long after, nothing"); + } } diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index d9fe1ed3..4d2a513a 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -13,8 +13,13 @@ use rustmotion_core::css::style::{ use rustmotion_core::css::taffy_bridge::ConversionContext; use rustmotion_core::css::units::LengthPercentage; use rustmotion_core::engine::animator::safe_div; +use rustmotion_core::engine::deps::ResolvedFrame; use rustmotion_core::engine::paint_pass::PlaneCamera; use rustmotion_core::engine::renderer::color4f_from_hex; +use rustmotion_core::engine::shake::{shake_offset, ShakeOffset}; +use rustmotion_core::expr::Scope; +use rustmotion_core::schema::time::TimeCtx; +use rustmotion_core::vars::{VarScope, VarSet, VarTable}; /// The single choke-point that turns "a frame of this scene" into the time /// value every render path in this file feeds into the background draw, the @@ -113,15 +118,82 @@ fn scene_uses_depth(children: &[ChildComponent]) -> bool { .any(|c| c.component.as_styled().style_config().depth.is_some()) } +/// Evaluate `scene.shake` (issue #330) at scene-local `time`, additive over +/// whatever the camera itself already resolves to — see +/// [`crate::schema::shake::SceneShake`]'s doc for why "additive" rather than +/// "instead of". `time` is the same scene-local clock every camera call +/// site in this file already threads through `interpolate_camera_property` +/// (`PaintCtx::time`'s own clock, already passed through the +/// `SceneTime`/`freeze_at` clamp by every caller). +/// +/// A scene with no `shake` returns [`ShakeOffset::default`] — all zero — so +/// adding it to a camera's `x`/`y`/`rotation` never changes a byte of a +/// scene that never opted in. On a [`rustmotion_core::schema::time::TimeError`] +/// (e.g. a beat-unit impact with no scenario `bpm`), also degrades to zero +/// rather than failing the render: the same best-effort posture +/// `apply_flash` (`post_effects.rs`) already takes on the identical +/// `TimePoint` resolution, and a scenario using beat-unit impacts with no +/// `bpm` is already caught earlier by `validate`. +fn scene_shake_offset(scene: &Scene, time: f32) -> ShakeOffset { + match &scene.shake { + Some(shake) => { + shake_offset(shake, &scene.resolved_time_ctx, time as f64).unwrap_or_default() + } + None => ShakeOffset::default(), + } +} + +/// The neutral camera (no pan, no zoom, no rotation, no origin override, no +/// keyframes) — `apply_camera_transform`/`resolve_plane_camera` applied +/// with this is the identity transform. Stands in for `scene.camera` when +/// a scene declares `shake` but no `camera` of its own: without this, the +/// whole camera-transform code path (every call site below is gated on +/// `scene.camera.is_some()`) would never run at all, and a shake-only +/// scene would render with no shake — see [`effective_camera`]. +static IDENTITY_CAMERA: Camera = Camera { + x: 0.0, + y: 0.0, + zoom: 1.0, + rotation: 0.0, + origin: None, + keyframes: Vec::new(), +}; + +/// The camera this scene's render path should treat as active: its own +/// declared `camera`, or [`IDENTITY_CAMERA`] when only `shake` is declared +/// — so a shake-only scene (the common case: a beat-synced hit with no +/// underlying pan) still enters the camera-transform code path and gets +/// its shake applied, rather than requiring an otherwise-pointless +/// `camera: {}` block just to opt in. `None` only when the scene declares +/// neither `camera` nor `shake`, in which case every call site below skips +/// the camera-transform code path exactly as it always has. +fn effective_camera(scene: &Scene) -> Option<&Camera> { + if let Some(camera) = scene.camera.as_ref() { + Some(camera) + } else if scene.shake.is_some() { + Some(&IDENTITY_CAMERA) + } else { + None + } +} + /// Resolve the scene camera at `time` into the flat state consumed by the -/// per-plane paint path. -fn resolve_plane_camera(camera: &Camera, time: f32, vw: f32, vh: f32) -> PlaneCamera { +/// per-plane paint path. `scene.shake` rides along additively on `pan_x`/ +/// `pan_y`/`rotation` — see [`scene_shake_offset`]. +fn resolve_plane_camera( + scene: &Scene, + camera: &Camera, + time: f32, + vw: f32, + vh: f32, +) -> PlaneCamera { let (origin_x, origin_y) = resolve_camera_origin(camera, time, vw, vh); + let shake = scene_shake_offset(scene, time); PlaneCamera { - pan_x: interpolate_camera_property(camera, "x", time), - pan_y: interpolate_camera_property(camera, "y", time), + pan_x: interpolate_camera_property(camera, "x", time) + shake.x as f32, + pan_y: interpolate_camera_property(camera, "y", time) + shake.y as f32, zoom: interpolate_camera_property(camera, "zoom", time), - rotation: interpolate_camera_property(camera, "rotation", time), + rotation: interpolate_camera_property(camera, "rotation", time) + shake.rotation as f32, origin_x, origin_y, } @@ -136,8 +208,10 @@ fn per_plane_camera( vw: f32, vh: f32, ) -> Option { - match &scene.camera { - Some(cam) if scene_uses_depth(children) => Some(resolve_plane_camera(cam, time, vw, vh)), + match effective_camera(scene) { + Some(cam) if scene_uses_depth(children) => { + Some(resolve_plane_camera(scene, cam, time, vw, vh)) + } _ => None, } } @@ -336,11 +410,12 @@ pub fn render_frame_v2_scaled( // Apply the global virtual camera transform (skipped in per-plane mode — // the paint pass applies it per top-level plane, scaled by depth). - let camera_guard = match &scene.camera { + let camera_guard = match effective_camera(scene) { Some(camera) if plane_cam.is_none() => { let g = super::CanvasGuard::new(canvas); apply_camera_transform( canvas, + scene, camera, time as f32, config.width as f32, @@ -368,6 +443,7 @@ pub fn render_frame_v2_scaled( config.height as f32, scene.layout.as_ref(), &ctx, + scene, ); drop(clip_guard); @@ -474,6 +550,45 @@ pub fn root_style(scene_layout: Option<&SceneLayout>, view_type: ViewType) -> Cs style } +/// The per-frame [`Scope`](rustmotion_core::expr::Scope) `render_with_new_pipeline_iter` +/// builds when a scene declares `vars` and/or at least one node `id` — +/// [`rustmotion_core::css::ComposedScope`]'s `outer`. Composes a +/// [`VarScope`] (declared scenario/scene variables, shadowed the way that +/// type documents) with a [`ResolvedFrame`] (`node(...)` cross-node +/// references, issue #328) exactly the way `rustmotion_core::vars::scope`'s +/// own module doc prescribes for two `Scope`s that both need answering: +/// held as fields, tried by delegating each trait method to whichever one +/// actually implements it, rather than one wrapping the other — a `Scope` +/// is only ever consumed behind `&dyn Scope`, and trait objects don't nest. +struct EngineScope<'a> { + vars: VarScope<'a>, + frame: &'a ResolvedFrame, +} + +impl Scope for EngineScope<'_> { + fn var(&self, name: &str) -> Option { + self.vars.resolve(name) + } + + fn node_prop(&self, id: &str, prop: &str) -> Option { + self.frame.node_prop(id, prop) + } +} + +/// Compile `vars` against `ctx`, falling back to an empty (always-`None`) +/// [`VarTable`] and a stderr warning if it fails to compile (e.g. a `"b"`-unit +/// keyframe with no `bpm` declared) — the same best-effort, named, never-fatal +/// contract `resolve_computed_style` (`rustmotion-components/src/box_builder.rs`) +/// already uses for a single bad expression, applied here to a whole +/// `VarSet` instead of one property. `rustmotion validate`'s schema pass is +/// where this should have been caught before it ever reaches a render. +fn compile_var_table_or_warn(vars: &VarSet, ctx: &TimeCtx, which: &str) -> VarTable { + VarTable::compile(vars, ctx).unwrap_or_else(|e| { + eprintln!("warning: {which} `vars` failed to compile ({e}) — treated as empty this frame"); + VarTable::compile(&VarSet::new(), ctx).expect("compiling an empty VarSet never fails") + }) +} + /// Render `root_children` through the CSS-engine pipeline: /// build a `BoxNode` tree, run taffy to lay it out, then paint via /// `paint_tree` with the `LegacyPaintDispatcher` bridging to component @@ -485,6 +600,7 @@ fn render_with_new_pipeline( viewport_h: f32, scene_layout: Option<&SceneLayout>, ctx: &RenderContext, + scene: &Scene, ) { render_with_new_pipeline_iter( canvas, @@ -493,11 +609,22 @@ fn render_with_new_pipeline( viewport_h, scene_layout, ctx, + scene, ); } /// Iterator-based variant for callers (like world rendering) that want to /// pass a filtered subset of children without cloning. +/// +/// `scene` supplies the two ingredients `box_builder.rs`'s own `FrameClock` +/// has no way to answer (see that file's "Per-frame expressions" doc +/// section): `scene.vars`/`scene.resolved_scenario_vars` for a declared +/// `$name`, and — indirectly, via a scan over the already-typed +/// `root_children` — every declared node `id` for a `node(...)` reference +/// (issue #328's join to #338/#329). A scene using neither takes exactly +/// today's single-build path, with no [`rustmotion_core::expr::Scope`] built +/// at all — see [`rustmotion_core::css::ComposedScope`]'s doc for why that +/// keeps such a scenario's render byte-identical. fn render_with_new_pipeline_iter<'a, I>( canvas: &Canvas, root_children: I, @@ -505,10 +632,14 @@ fn render_with_new_pipeline_iter<'a, I>( viewport_h: f32, scene_layout: Option<&SceneLayout>, ctx: &RenderContext, + scene: &Scene, ) where I: IntoIterator, { - use rustmotion_components::box_builder::{build_scene_from_refs, BuildAnimationCtx}; + use rustmotion_components::box_builder::{ + build_scene_from_refs_with_scope, build_scene_from_refs_with_scope_quiet, + collect_node_refs, scene_uses_node_refs, BuildAnimationCtx, + }; use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; use rustmotion_core::engine::layout_pass::run_layout; use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; @@ -530,12 +661,115 @@ fn render_with_new_pipeline_iter<'a, I>( scene_duration: ctx.scene_duration, fps: ctx.fps, }); - let built = build_scene_from_refs(root_children, (viewport_w, viewport_h), root_css, anim); - let layout = run_layout( - &built.root, - (viewport_w, viewport_h), - &ConversionContext::for_viewport(viewport_w, viewport_h), - ); + let viewport = (viewport_w, viewport_h); + let conversion = ConversionContext::for_viewport(viewport_w, viewport_h); + + // Materialized once: the reference scan below and, when a second build + // pass turns out to be needed, that second pass both walk these + // children again — `I` makes no `Clone` guarantee. + let children_vec: Vec<&'a ChildComponent> = root_children.into_iter().collect(); + + // Whether *any* node anywhere in this scene makes a `node(...)` call — + // the referencer need not have a declared `id` of its own (only the + // node it points at does), so this is a separate, broader scan than + // `collect_node_refs` below: see `scene_uses_node_refs`'s own doc for + // why deriving it from `collect_node_refs`'s output instead would have + // silently missed the common shape (an unlabelled node reading + // `node("otherId", ...)`). + let has_node_refs = scene_uses_node_refs(children_vec.iter().copied()); + let use_vars = !scene.resolved_scenario_vars.is_empty() || !scene.vars.is_empty(); + + let built = if !use_vars && !has_node_refs { + build_scene_from_refs_with_scope( + children_vec.iter().copied(), + viewport, + root_css, + anim, + None, + ) + } else { + let scenario_table = compile_var_table_or_warn( + &scene.resolved_scenario_vars, + &scene.resolved_time_ctx, + "scenario-level", + ); + let scene_table = + compile_var_table_or_warn(&scene.vars, &scene.resolved_time_ctx, "scene-level"); + + if !has_node_refs { + // `vars` only: a `VarTable` needs no layout to sample, so one + // build already sees the right values — no second pass. + let empty_frame = ResolvedFrame::new(); + let engine_scope = EngineScope { + vars: VarScope::new(&scenario_table, Some(&scene_table), ctx.scenario_time), + frame: &empty_frame, + }; + build_scene_from_refs_with_scope( + children_vec.iter().copied(), + viewport, + root_css, + anim, + Some(&engine_scope as &dyn Scope), + ) + } else { + // At least one `node(...)` reference. `resolve_node_references` + // (issue #328) needs a laid-out tree to snapshot each + // referenced node from — but a node's *own* expressions must + // resolve before its snapshot is taken (that function's own + // doc). A first, throwaway build supplies that layout: any + // `node(...)` expression in it fails best-effort against an + // empty `ResolvedFrame` (same as an undeclared name today) and + // is corrected in the real build below, but a node with no + // reference of its own — including every node something else's + // `node(...)` call actually needs to read — is already fully + // correct here, `vars` included. `_quiet(..., false)`: this + // build's own failures don't describe the frame that actually + // gets painted, so they stay off stderr (see that function's + // doc) — the real build a few lines down warns normally. + let empty_frame = ResolvedFrame::new(); + let base_engine_scope = EngineScope { + vars: VarScope::new(&scenario_table, Some(&scene_table), ctx.scenario_time), + frame: &empty_frame, + }; + let base_built = build_scene_from_refs_with_scope_quiet( + children_vec.iter().copied(), + viewport, + root_css.clone(), + anim, + Some(&base_engine_scope as &dyn Scope), + false, + ); + let base_layout = run_layout(&base_built.root, viewport, &conversion); + // Only declared ids need an entry here — see + // `collect_node_refs`'s own doc; a referencer with no `id` of + // its own (already accounted for by `has_node_refs` above) + // needs no entry of its own for `DepGraph::build` to place + // every id it can legally reach before it. + let refs_by_id = collect_node_refs(children_vec.iter().copied()); + let resolved_frame = + resolve_node_references(&base_built, &base_layout, viewport, &refs_by_id) + .unwrap_or_else(|e| { + eprintln!( + "warning: node(...) dependency graph: {e} — cross-node references unresolved this frame" + ); + ResolvedFrame::new() + }); + + let final_engine_scope = EngineScope { + vars: VarScope::new(&scenario_table, Some(&scene_table), ctx.scenario_time), + frame: &resolved_frame, + }; + build_scene_from_refs_with_scope( + children_vec.iter().copied(), + viewport, + root_css, + anim, + Some(&final_engine_scope as &dyn Scope), + ) + } + }; + + let layout = run_layout(&built.root, viewport, &conversion); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time: ctx.time.seconds(), @@ -645,6 +879,158 @@ pub fn prepare_scene(scene: &Scene, _config: &VideoConfig) -> Vec, + layout: &rustmotion_core::engine::layout_pass::LayoutResult, + viewport: (f32, f32), + refs_by_id: &[(String, Vec)], +) -> std::result::Result< + rustmotion_core::engine::deps::ResolvedFrame, + rustmotion_core::engine::deps::DepsError, +> { + use rustmotion_core::engine::box_tree::NodeId; + use rustmotion_core::engine::deps::{snapshot_node, DepGraph, ResolvedFrame}; + use std::collections::{HashMap, HashSet}; + + // Single-scene scope: `node(...)` cannot reach another scene's id (see + // `DepsError::CrossScene`), but distinguishing "genuinely unknown" from + // "declared in a scene we didn't pass in" needs that other scene's ids + // in hand. No caller of this function crosses scene boundaries today, + // so an empty set is always correct here — it only ever costs a + // slightly less specific error message (`UnknownId` instead of + // `CrossScene`) if that ever changes. + let other_scene_ids: HashSet = HashSet::new(); + let graph = DepGraph::build(refs_by_id, &other_scene_ids)?; + + // `built.components[id]` is `Some(&ChildComponent)` for every real node + // (component or ghost), keyed by the exact `NodeId` `BoxNode::id` and + // `LayoutResult::get` use — see `BuiltScene::components`'s own doc. + let id_index: HashMap<&str, NodeId> = built + .components + .iter() + .enumerate() + .filter_map(|(node_id, c)| { + let child = (*c)?; + let id = child.id.as_deref()?; + Some((id, node_id as NodeId)) + }) + .collect(); + + let text_provider = ResolvingTextMetrics { + components: &built.components, + }; + let mut frame = ResolvedFrame::new(); + for id in graph.order() { + let Some(&node_id) = id_index.get(id.as_str()) else { + continue; + }; + let Some(box_node) = built.root.find(node_id) else { + continue; + }; + if let Some(resolved) = snapshot_node(box_node, layout, viewport, &text_provider) { + frame.insert(id.clone(), resolved); + } + } + Ok(frame) +} + +/// Bridges [`rustmotion_core::engine::deps::TextMetricsProvider`] to the real +/// component behind a node — the fix issue #328's own agent flagged and left +/// for this join: [`rustmotion_components::box_builder`] stores a `BoxNode`'s +/// [`rustmotion_core::engine::box_tree::BoxKind::Component`] payload as +/// `Arc::new(node_id)` (a plain [`NodeId`]), not the component itself, because +/// [`rustmotion_components::legacy_dispatch::LegacyPaintDispatcher`] — the +/// *only* other reader of that payload, and the one every one of this +/// engine's 60 component types' painting already depends on — downcasts it +/// back to a `NodeId` and looks the real component up in +/// [`rustmotion_components::box_builder::BuiltScene::components`] by index. +/// Retyping the payload to carry a component directly would fix +/// [`rustmotion_components::intrinsic::ComponentTextMetrics`]'s `downcast_ref` +/// (which expects exactly that) but break dispatch for every other +/// component — out of this workstream's reach, and said so rather than +/// touched. +/// +/// This type does the same `NodeId`-then-lookup indirection +/// `LegacyPaintDispatcher` already does, then hands the *actual* `&Text` / +/// `&GradientText` off to [`rustmotion_components::intrinsic::ComponentTextMetrics`] +/// — which already downcasts correctly, and needed no change — closing the +/// gap entirely on this function's side, with no reshaping of +/// `rustmotion-components` or the frozen `engine::deps` trait. +struct ResolvingTextMetrics<'a> { + components: &'a [Option<&'a rustmotion_components::ChildComponent>], +} + +impl rustmotion_core::engine::deps::TextMetricsProvider for ResolvingTextMetrics<'_> { + fn text_metrics( + &self, + payload: &(dyn std::any::Any + Send + Sync), + content_box_width: f32, + ) -> Option { + use rustmotion_components::intrinsic::ComponentTextMetrics; + use rustmotion_components::Component; + use rustmotion_core::engine::box_tree::NodeId; + + let node_id = payload.downcast_ref::()?; + let child = (*self.components.get(*node_id as usize)?)?; + match &child.component { + Component::Text(t) => ComponentTextMetrics.text_metrics(t, content_box_width), + Component::GradientText(g) => ComponentTextMetrics.text_metrics(g, content_box_width), + _ => None, + } + } +} + /// Render a single frame using the v2 pipeline. /// This is the unified entry point for both single-frame and video encoding. pub fn render_scene_frame( @@ -749,10 +1135,10 @@ pub fn render_scene_hits( // camera per top-level plane; the canvas matrix at each node then feeds // `local_to_device` so hit rects follow their plane automatically. let plane_cam = per_plane_camera(scene, &children, time as f32, vw, vh); - let _camera_guard = match &scene.camera { + let _camera_guard = match effective_camera(scene) { Some(camera) if plane_cam.is_none() => { let g = super::CanvasGuard::new(canvas); - apply_camera_transform(canvas, camera, time as f32, vw, vh); + apply_camera_transform(canvas, scene, camera, time as f32, vw, vh); Some(g) } _ => None, @@ -1090,10 +1476,12 @@ pub fn render_world_frame_scaled( camera: None, }; - // Apply per-scene camera if present - let has_camera = scene.camera.is_some(); - if let Some(ref camera) = scene.camera { - apply_camera_transform(canvas, camera, anim_time as f32, vw, vh); + // Apply per-scene camera if present (or an identity one, if only + // `shake` is declared — see `effective_camera`'s doc). + let camera = effective_camera(scene); + let has_camera = camera.is_some(); + if let Some(camera) = camera { + apply_camera_transform(canvas, scene, camera, anim_time as f32, vw, vh); } // World scenes: force content children into centered flex flow. @@ -1121,6 +1509,7 @@ pub fn render_world_frame_scaled( vh, Some(scene_layout), &ctx, + scene, ); if has_camera { @@ -1323,10 +1712,11 @@ pub fn render_scene_fg_scaled( camera: plane_cam, }; - let has_camera = scene.camera.is_some() && plane_cam.is_none(); - if let (Some(camera), None) = (&scene.camera, plane_cam) { + let has_camera = effective_camera(scene).is_some() && plane_cam.is_none(); + if let (Some(camera), None) = (effective_camera(scene), plane_cam) { apply_camera_transform( canvas, + scene, camera, time as f32, config.width as f32, @@ -1348,6 +1738,7 @@ pub fn render_scene_fg_scaled( config.height as f32, scene.layout.as_ref(), &ctx, + scene, ); canvas.restore(); @@ -1455,18 +1846,22 @@ pub(super) fn resolve_camera_origin( } /// Apply camera transform to the canvas: translate, zoom, rotate around the -/// camera origin (default: scene centre). +/// camera origin (default: scene centre). `scene.shake` (issue #330) rides +/// additively on the pan (`x`/`y`) and `rotation` — see +/// [`scene_shake_offset`]'s doc. pub(super) fn apply_camera_transform( canvas: &Canvas, + scene: &Scene, camera: &Camera, time: f32, width: f32, height: f32, ) { - let x = interpolate_camera_property(camera, "x", time); - let y = interpolate_camera_property(camera, "y", time); + let shake = scene_shake_offset(scene, time); + let x = interpolate_camera_property(camera, "x", time) + shake.x as f32; + let y = interpolate_camera_property(camera, "y", time) + shake.y as f32; let zoom = interpolate_camera_property(camera, "zoom", time); - let rotation = interpolate_camera_property(camera, "rotation", time); + let rotation = interpolate_camera_property(camera, "rotation", time) + shake.rotation as f32; let (cx, cy) = resolve_camera_origin(camera, time, width, height); canvas.save(); diff --git a/crates/rustmotion/src/include.rs b/crates/rustmotion/src/include.rs index 78d474a2..b633b713 100644 --- a/crates/rustmotion/src/include.rs +++ b/crates/rustmotion/src/include.rs @@ -62,7 +62,13 @@ pub fn resolve_includes_with_policy( source: &IncludeSource, remote_policy: RemoteIncludePolicy, ) -> Result { - let mut audio = scenario.audio; + // `scenario.audio`'s object form (issue #331) can also carry a + // synthesised score alongside `tracks` — that part is captured by the + // loader (`crate::loader`) before this function is ever called, since + // it needs the scenario's own `bpm`/`beat_offset` and this function + // consumes `scenario` outright. Only the file-based tracks flow + // through the merge below, same as before this issue. + let mut audio = scenario.audio.into_tracks(); let mut included_paths = Vec::new(); let has_scenes = !scenario.scenes.is_empty(); let has_composition = scenario.composition.is_some(); @@ -234,12 +240,21 @@ fn fetch_and_resolve( // `rustmotion_core::expand`'s module doc for why that scoping was // chosen over a cross-file component registry. crate::expand::expand_directives(&mut json_value, &directive.include)?; + // Same reason, same ordering rule, as `loader.rs`/`validation.rs`: an + // included file is itself a full document that went through its own + // `apply_variables` + `expand_directives` pass just above, so a + // `= ...` expression inside *this* file's own scenes needs its own fold + // pass too — an included file's static expression is otherwise never + // folded (it isn't part of the parent document `loader.rs`/ + // `validation.rs` already fold), and reaches `Scenario` deserialization + // below as a bare string. + crate::loader::fold_static_expressions(&mut json_value, &directive.include)?; let child_scenario: Scenario = serde_json::from_value(json_value).map_err(RustmotionError::from)?; // Merge audio tracks from the included file - audio.extend(child_scenario.audio); + audio.extend(child_scenario.audio.into_tracks()); // Recursively resolve any nested includes let mut scenes = resolve_entries( diff --git a/crates/rustmotion/src/loader.rs b/crates/rustmotion/src/loader.rs index 724187d5..e6fc76f3 100644 --- a/crates/rustmotion/src/loader.rs +++ b/crates/rustmotion/src/loader.rs @@ -3,6 +3,51 @@ use crate::schema::{ResolvedScenario, Scenario}; use crate::{expand, include, variables}; use std::path::PathBuf; +/// [`include::resolve_includes`], plus (issue #331) rendering `scenario`'s +/// own synthesised score, if it has one, into a cached WAV and appending it +/// to the resolved scenario's `audio` — see +/// `crate::encode::audio::synthesize_score_into_track`'s doc for why that +/// join happens as a plain [`crate::schema::AudioTrack`] rather than a +/// second, parallel audio path. +/// +/// This has to sit here rather than inside `include::resolve_includes` +/// itself: that function takes `Scenario` by value and moves it away +/// (recursing into `include`d files), so `scenario`'s own `bpm`/ +/// `beat_offset` and its `audio`'s synth config (if any) are captured +/// *before* the call — copied out for the two `f64`/`Option` grid +/// values, cloned for the config, since `Scenario` itself derives no +/// `Clone`. `include.rs` does not otherwise change for this issue: a +/// synthesised score is a root-scenario-only feature, the same boundary +/// `bpm`/`beat_offset` propagation already draws (see +/// `Scenario::propagate_time_ctx`'s doc) — an included file's own `audio` +/// synth block, if it declared one, is not picked up here. +/// +/// `pub`: this crate's binary target (`cli::commands::validation`, the +/// shared pipeline behind both `validate` and `render`) calls straight into +/// `include::resolve_includes` today and needs this same audio-synthesis +/// step, not a second, independently-drifting copy of it. +pub fn resolve_includes_and_synthesize_audio( + scenario: Scenario, + source: &include::IncludeSource, +) -> Result { + let scenario_bpm = scenario.bpm; + let scenario_beat_offset = scenario.beat_offset; + let synth_config = scenario.audio.config().cloned(); + + let mut resolved = include::resolve_includes(scenario, source)?; + + if let Some(cfg) = synth_config { + crate::encode::audio::synthesize_score_into_track( + &mut resolved, + &cfg, + scenario_bpm, + scenario_beat_offset, + )?; + } + + Ok(resolved) +} + pub fn load_scenario(input: &PathBuf) -> Result { load_scenario_with_vars(input, None) } @@ -24,6 +69,7 @@ pub fn load_scenario_with_vars( let label = input.display().to_string(); variables::apply_variables(&mut json_value, overrides, &label)?; expand::expand_directives(&mut json_value, &label)?; + fold_static_expressions(&mut json_value, &label)?; // Asset paths are relative to the file that names them, like `include` — // not to wherever the process happens to run. if let Some(dir) = input.parent() { @@ -33,7 +79,303 @@ pub fn load_scenario_with_vars( } let scenario: Scenario = serde_json::from_value(json_value).map_err(RustmotionError::from)?; - include::resolve_includes(scenario, &include::IncludeSource::File(input.clone())) + resolve_includes_and_synthesize_audio(scenario, &include::IncludeSource::File(input.clone())) +} + +/// Static expression folding: the load-time half of the two-tier model +/// described in [`rustmotion_core::expr`]'s module doc. +/// +/// Walks the scenario's JSON tree looking for string values that begin with +/// `=` — an expression, per [`rustmotion_core::expr::Computed`]'s +/// convention — and replaces the ones whose [`rustmotion_core::expr::Expr::is_static`] +/// is true with the literal number they evaluate to. A `for-each` over +/// eight items with `"x": "= cos($i / $count * TAU) * 700"` folds to eight +/// different literals this way, one per expanded clone — see below for why +/// that is already true by the time this function runs. +/// +/// ## Pass ordering (load-bearing, same reasoning as `expand`'s own doc) +/// +/// This runs immediately *after* [`expand::expand_directives`] and *before* +/// `Scenario` is deserialized — one step later than `expand`'s own position +/// in this same pipeline, for a specific reason: `expand_for_each_directive` +/// binds `$i`/`$index`/`$item`/`$count` (plus each element's own fields) and +/// substitutes them *textually* into every string in the template — expression +/// strings included, since that substitution pass does not know expressions +/// exist, it just does what it always does to any `$name` occurrence it +/// finds. By the time this function sees a `for-each`-authored expression, +/// `"= cos($i / $count * TAU) * 700"` has therefore already become e.g. +/// `"= cos(3 / 8 * TAU) * 700"` in the fourth of eight expanded clones: pure +/// arithmetic, no scope lookup needed for `i`/`count` at all. This function +/// never re-implements `for-each` iteration itself — it only ever sees the +/// already-expanded, already-substituted tree, exactly the same tree +/// `Scenario` deserialization sees a moment later. +/// +/// What *is* resolved here, freshly, is `$W`/`$H`/`$fps` — the three +/// reserved names no upstream pass ever touches, read straight from this +/// same document's own `video` block by [`LoadScope`] — plus (issue #329) +/// any *scenario-level* `vars` entry that has no `animation`: a genuine +/// constant folds exactly the same way `$W`/`$H`/`$fps` do, at exactly the +/// same cost (zero, per frame). `$t`, `$T`, `$beat` and `$duration` are +/// deliberately never attempted here (see +/// `rustmotion_core::expr::Expr::is_static`'s doc on why `duration` +/// specifically joins the animation-clock names): an expression naming any +/// of them is left exactly as authored, a `=`-prefixed string, for a future +/// per-frame consumer to evaluate against the real per-frame context. +/// +/// ## `vars` this function cannot safely fold (issue #329) +/// +/// [`rustmotion_core::expr::Expr::is_static`] only special-cases the fixed +/// `t`/`T`/`beat`/`duration` names — it has no notion of a scenario's own +/// `vars` block, so an expression naming a declared, *animated* variable +/// (`"= $keyDraw * 360"`) reports `is_static() == true` just like one +/// naming a genuine constant does. Folding it anyway would evaluate it +/// against [`LoadScope`] and fail with a misleading "unknown identifier" +/// for a name that is not unknown at all — or, worse, once some field +/// eventually accepts a resolved literal in its place, silently freeze a +/// variable that was supposed to move for the rest of the render. +/// +/// [`LoadScope`] can only ever resolve a `vars` name unambiguously when it +/// is both a *constant* (no `animation`) and declared at the *scenario* +/// level (see [`LoadScope::constant_scenario_vars`]'s doc for why a +/// scene-level constant doesn't qualify). Before walking the tree, this +/// function collects every OTHER name any `vars` block in this document +/// declares — animated or constant, scenario-level or a scene's own, from a +/// `vars` object at any depth — and [`fold_value`] refuses to fold any +/// expression whose free variables intersect that set, leaving it exactly +/// as authored for the per-frame tier instead of erroring or guessing. This +/// is deliberately a single flat, document-wide set, not scoped per scene: +/// `fold_value` has no notion of "which scene is this expression in" to +/// begin with, and erring towards *not* folding an expression is always +/// safe — the worst case is a value that could have been folded but instead +/// survives to be evaluated fresh every frame, never a value that was +/// folded when it shouldn't have been, and never a spurious "unknown +/// identifier" for a name that is, in fact, declared. +/// +/// A parse failure, an unknown identifier, or a result with no JSON +/// representation (`NaN`/`Infinity` — division by zero, an out-of-domain +/// `sqrt`/`log`, …) is a hard load error naming the offending expression and +/// the file, the same way `variables::apply_variables`'s +/// `UndefinedVariable` and `expand`'s `ForEachDirectiveInvalid` already are. +pub(crate) fn fold_static_expressions(value: &mut serde_json::Value, label: &str) -> Result<()> { + let scope = LoadScope::from_document(value); + let unfoldable_vars = collect_unfoldable_var_names(value, &scope); + fold_value(value, &scope, &unfoldable_vars, label) +} + +/// Resolves the three reserved names a scenario's own `video` block makes +/// load-time-known, plus (issue #329) any *scenario-level* `vars` entry +/// that is itself a constant — see [`fold_static_expressions`]'s doc for +/// why nothing else is answered here. +struct LoadScope { + width: Option, + height: Option, + fps: Option, + /// The scenario's own top-level `vars`, filtered to the ones with no + /// `animation` (`VarDef::is_static`) and reduced to their `default`. + /// Deliberately scoped to the *document's top-level* `vars` object + /// only, never a scene's own: `LoadScope` is one flat scope shared by + /// every expression in the document regardless of which scene it sits + /// in, and two scenes are allowed to declare the same variable name + /// with different constant values (shadowing is per-scene by design — + /// see `rustmotion_core::vars::VarScope`). Folding a scene-level + /// constant through this single flat scope would silently apply + /// whichever scene's value this map happened to end up with to every + /// *other* scene's expression naming that same variable too. A + /// scenario-level constant carries no such ambiguity: it names exactly + /// one value for the whole document, the same guarantee `$W`/`$H`/ + /// `$fps` already rely on. A scene-level constant is instead left + /// unfolded by [`collect_unfoldable_var_names`] — deferred, not wrong. + constant_scenario_vars: std::collections::HashMap, +} + +impl LoadScope { + fn from_document(value: &serde_json::Value) -> Self { + let video = value.get("video"); + let constant_scenario_vars = value + .get("vars") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .map(|vars| { + vars.iter() + .filter(|(_, def)| def.is_static()) + .map(|(name, def)| (name.clone(), def.default)) + .collect() + }) + .unwrap_or_default(); + LoadScope { + width: video.and_then(|v| v.get("width")).and_then(|v| v.as_f64()), + height: video.and_then(|v| v.get("height")).and_then(|v| v.as_f64()), + fps: video.and_then(|v| v.get("fps")).and_then(|v| v.as_f64()), + constant_scenario_vars, + } + } +} + +impl rustmotion_core::expr::Scope for LoadScope { + fn var(&self, name: &str) -> Option { + match name { + "W" => self.width, + "H" => self.height, + "fps" => self.fps, + _ => self.constant_scenario_vars.get(name).copied(), + } + } +} + +/// Every name [`fold_value`] must not fold an expression through, because +/// [`LoadScope`] cannot resolve it unambiguously — see +/// [`fold_static_expressions`]'s doc, "`vars` this function cannot safely +/// fold". Starts from every name declared anywhere in `value` by a `vars` +/// object (any depth — the scenario's own, and every scene's), via +/// [`collect_declared_var_names`], then removes exactly the names +/// [`LoadScope::constant_scenario_vars`] can already answer, since those +/// fold correctly and should. +fn collect_unfoldable_var_names( + value: &serde_json::Value, + scope: &LoadScope, +) -> std::collections::HashSet { + let mut names = collect_declared_var_names(value); + for resolvable in scope.constant_scenario_vars.keys() { + names.remove(resolvable); + } + names +} + +/// Every name declared, anywhere in `value`, by a `vars` object — animated +/// or constant, scenario-level or a scene's own; the caller narrows this +/// down to the ones that actually need protecting from folding. Collected +/// leniently: a `vars` object that doesn't deserialize as +/// [`rustmotion_core::vars::VarSet`] is skipped here rather than reported — +/// the real, schema-validated error for a malformed `vars` block comes from +/// `Scenario`'s own deserialization a few lines after this function's +/// caller returns. This mirrors [`LoadScope::from_document`]'s own +/// best-effort reads of `video.width`/`height`/`fps`. +fn collect_declared_var_names(value: &serde_json::Value) -> std::collections::HashSet { + let mut names = std::collections::HashSet::new(); + collect_declared_var_names_into(value, &mut names); + names +} + +fn collect_declared_var_names_into( + value: &serde_json::Value, + names: &mut std::collections::HashSet, +) { + match value { + serde_json::Value::Object(map) => { + if let Some(vars) = map.get("vars") { + if let Ok(set) = + serde_json::from_value::(vars.clone()) + { + names.extend(set.keys().cloned()); + } + } + for (key, v) in map { + // Same skip `fold_value`/`variables::substitute`/ + // `expand::find_unresolved` already apply: `config` holds + // declarations, never references. + if key == "config" { + continue; + } + collect_declared_var_names_into(v, names); + } + } + serde_json::Value::Array(arr) => { + for item in arr { + collect_declared_var_names_into(item, names); + } + } + _ => {} + } +} + +fn fold_value( + value: &mut serde_json::Value, + scope: &LoadScope, + unfoldable_vars: &std::collections::HashSet, + label: &str, +) -> Result<()> { + match value { + serde_json::Value::String(s) => { + if let Some(src) = s.strip_prefix('=') { + let expr = rustmotion_core::expr::Expr::parse(src).map_err(|e| { + RustmotionError::Generic(format!("expression error in '{label}': {e}")) + })?; + let reads_an_unfoldable_var = expr + .free_vars() + .iter() + .any(|v| unfoldable_vars.contains(v.as_str())); + if expr.is_static() && !reads_an_unfoldable_var { + let n = expr.eval(scope).map_err(|e| { + RustmotionError::Generic(format!("expression error in '{label}': {e}")) + })?; + *value = expr_result_to_json(n, s, label)?; + } + // Non-static (or naming a `vars` variable this pass can't + // safely fold): left as the `=`-prefixed string for the + // per-frame tier — see `fold_static_expressions`'s doc. + } + } + serde_json::Value::Object(map) => { + for (key, v) in map.iter_mut() { + // `config` holds variable *declarations*, never references — + // same skip `variables::substitute`/`expand::find_unresolved` + // already apply, for the same reason. + if key == "config" { + continue; + } + fold_value(v, scope, unfoldable_vars, label)?; + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + fold_value(item, scope, unfoldable_vars, label)?; + } + } + _ => {} + } + Ok(()) +} + +fn expr_result_to_json(n: f64, src: &str, label: &str) -> Result { + serde_json::Number::from_f64(n) + .map(serde_json::Value::Number) + .ok_or_else(|| { + RustmotionError::Generic(format!( + "expression `{src}` in '{label}' evaluated to {n}, which has no JSON \ + representation (NaN/Infinity) — check for a division by zero or an \ + out-of-domain call" + )) + }) +} + +/// Whether `raw_source` — the exact bytes of a scenario file, before any +/// pass has touched it — contains a JSON string value using the `= ...` +/// expression prefix (see [`rustmotion_core::expr`]'s module doc). +/// +/// Conservative, raw-substring detection, deliberately the same style as +/// `crates/rustmotion/src/cli/commands/validate.rs`'s existing +/// `refuse_fix` checks for `"include"`/`"for-each"`/`"use"`: a `for-each` +/// that places eight expressions on a circle folds every one of them to a +/// literal by the time `LoadedScenario::raw` is captured (see +/// [`fold_static_expressions`]), so `--fix` must refuse to write that +/// folded tree back over a source that still names the expression — the +/// same reasoning `--fix` already applies to `for-each`/`use`/`include`, +/// whose expansions are equally unfaithful to write back verbatim. +/// +/// This crate's `cli/` is out of this workstream's scope (see the issue +/// this module's fold pass was added for), so this function is exposed for +/// `refuse_fix` to call rather than wired in directly — a `FixRefusal` +/// variant plus one added condition is the full remaining change. +/// +/// Note this is a *completion*, not the first line of defence: an +/// expression that names a `$variable` (the overwhelming majority in +/// practice — every example in the issue this exists for does) is already +/// caught today by `refuse_fix`'s existing `raw_source.contains("$")` +/// check, before this function would ever need to run. What this catches +/// is the narrower case that check misses: a fully `$`-free static +/// expression such as `"= cos(PI/4) * 100"`, which contains no `$` at all +/// but still must not be written back as its folded literal. +pub fn source_uses_expression(raw_source: &str) -> bool { + raw_source.contains("\"=") } pub fn load_scenario_from_source( @@ -57,9 +399,10 @@ pub fn load_scenario_from_source_with_vars( serde_json::from_str(json_str).map_err(RustmotionError::from)?; variables::apply_variables(&mut json_value, overrides, "")?; expand::expand_directives(&mut json_value, "")?; + fold_static_expressions(&mut json_value, "")?; let scenario: Scenario = serde_json::from_value(json_value).map_err(RustmotionError::from)?; - include::resolve_includes(scenario, &include::IncludeSource::Inline) + resolve_includes_and_synthesize_audio(scenario, &include::IncludeSource::Inline) } (None, None) => Err(RustmotionError::MissingInput), } @@ -107,12 +450,13 @@ pub fn load_scenario_from_html_with_vars( let label = input.display().to_string(); variables::apply_variables(&mut value, overrides, &label)?; expand::expand_directives(&mut value, &label)?; + fold_static_expressions(&mut value, &label)?; // Same rule as the JSON loader: assets are relative to the file naming them. if let Some(dir) = input.parent() { crate::assets::rebase_relative_paths(&mut value, dir); } let scenario: Scenario = serde_json::from_value(value).map_err(RustmotionError::from)?; - include::resolve_includes(scenario, &include::IncludeSource::File(input.clone())) + resolve_includes_and_synthesize_audio(scenario, &include::IncludeSource::File(input.clone())) } /// Read the annotations sidecar next to an HTML-dialect source: for @@ -405,3 +749,285 @@ mod vars_tests { let _ = std::fs::remove_file(&path); } } + +#[cfg(test)] +mod expr_fold_tests { + use super::*; + + fn load(json: &serde_json::Value) -> ResolvedScenario { + load_scenario_from_source(None, Some(&json.to_string())).expect("scenario loads") + } + + /// Acceptance criterion 1: a `for-each` over 8 items with + /// `"x": "= cos($i / $count * TAU) * 700"` places them on a circle, + /// folded to literals at load — each child's `x` is a plain JSON + /// number after loading, and matches the real cosine. + #[test] + fn for_each_over_eight_items_folds_to_literals_matching_real_cosines() { + let items: Vec = (0..8).map(|_| serde_json::json!({})).collect(); + let json = serde_json::json!({ + "video": { "width": 1080, "height": 1920, "fps": 30 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "for-each": items, + "template": { + "type": "text", + "content": "badge", + "x": "= $W/2 + cos($i / $count * TAU - PI/2) * 700" + } + }] + }] + }); + let resolved = load(&json); + let children = &resolved.views[0].scenes[0].children; + assert_eq!(children.len(), 8); + for (i, child) in children.iter().enumerate() { + let got = child["x"].as_f64().unwrap_or_else(|| { + panic!("child {i}'s x did not fold to a number: {:?}", child["x"]) + }); + let want = 1080.0 / 2.0 + + (i as f64 / 8.0 * std::f64::consts::TAU - std::f64::consts::PI / 2.0).cos() + * 700.0; + assert!( + (got - want).abs() < 1e-9, + "badge {i}: folded x = {got}, expected {want}" + ); + } + } + + /// `$W`/`$H`/`$fps` fold from the scenario's own `video` block, with no + /// `for-each` involved at all. + #[test] + fn plain_expression_folds_w_h_fps_from_video_block() { + let json = serde_json::json!({ + "video": { "width": 800, "height": 600, "fps": 24 }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "x": "= $W/2", "y": "= $H/2", "opacity": "= $fps / 24" } + ] + }] + }); + let resolved = load(&json); + let child = &resolved.views[0].scenes[0].children[0]; + assert_eq!(child["x"], serde_json::json!(400.0)); + assert_eq!(child["y"], serde_json::json!(300.0)); + assert_eq!(child["opacity"], serde_json::json!(1.0)); + } + + /// Acceptance criterion 2: a hostile (deeply nested) expression is + /// rejected at load — a clear error, not a hang. + #[test] + fn hostile_expression_is_rejected_at_load_not_hung() { + let hostile = format!("={}1{}", "(".repeat(500), ")".repeat(500)); + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "x": hostile } + ] + }] + }); + let err = load_scenario_from_source(None, Some(&json.to_string())) + .expect_err("a hostile expression must fail to load"); + assert!( + err.to_string().contains("nests deeper"), + "error should name the nesting problem, got: {err}" + ); + } + + /// An expression naming a genuinely runtime-only variable (`$t`) is + /// never attempted at load time — it survives folding as the original + /// `=`-prefixed string, for a future per-frame consumer to evaluate. + #[test] + fn expression_naming_scene_time_is_left_unfolded() { + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "x": "= $t * 10" } + ] + }] + }); + let resolved = load(&json); + let x = &resolved.views[0].scenes[0].children[0]["x"]; + assert_eq!(x, &serde_json::json!("= $t * 10")); + } + + /// An expression naming an identifier this loader genuinely can't + /// resolve (not a reserved name, not a declared `config` variable) is a + /// named, located hard error — not a silent pass-through. + #[test] + fn unresolvable_identifier_is_a_named_load_error() { + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "x": "= $totallyUndeclared + 1" } + ] + }] + }); + let err = load_scenario_from_source(None, Some(&json.to_string())) + .expect_err("an unresolvable identifier must fail to load"); + assert!( + err.to_string().contains("totallyUndeclared"), + "error should name the identifier, got: {err}" + ); + } + + /// `rand(seed)` is a pure function of its argument end-to-end through + /// the loader too: two independent loads of the same source fold to the + /// identical literal. + #[test] + fn rand_folds_deterministically_across_separate_loads() { + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "x": "= rand(42)" } + ] + }] + }); + let a = load(&json).views[0].scenes[0].children[0]["x"].clone(); + let b = load(&json).views[0].scenes[0].children[0]["x"].clone(); + assert_eq!(a, b); + assert!(a.is_f64()); + } + + /// `--fix`'s refusal helper: a `$`-free static expression must still be + /// detected in the raw source, since `refuse_fix`'s existing `"$"` + /// check alone would miss it. + #[test] + fn source_uses_expression_detects_dollar_free_expressions() { + assert!(source_uses_expression(r#"{"x": "= cos(PI/4) * 100"}"#)); + assert!(source_uses_expression(r#"{"x": "= $W/2"}"#)); + assert!(!source_uses_expression(r#"{"x": "plain literal"}"#)); + } + + /// Issue #329: a scenario-level `vars` entry with no `animation` is a + /// constant and folds exactly like `$W`/`$H`/`$fps` — zero per-frame + /// cost, same as any other literal. + #[test] + fn scenario_level_constant_var_folds_like_w_h_fps() { + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "vars": { "badgeCount": { "default": 8 } }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "opacity": "= $badgeCount / 8" } + ] + }] + }); + let resolved = load(&json); + let child = &resolved.views[0].scenes[0].children[0]; + assert_eq!(child["opacity"], serde_json::json!(1.0)); + } + + /// Issue #329, the bug this workstream exists to prevent: an expression + /// naming a declared, *animated* variable must not be folded (it would + /// either freeze a value that's supposed to move, or fail with a + /// misleading "unknown identifier" for a name that is not unknown) — + /// it must survive as the original `=`-prefixed string, exactly like + /// `$t` already does. + #[test] + fn scenario_level_animated_var_is_left_unfolded_not_treated_as_unknown() { + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "vars": { + "keyDraw": { + "default": 0, + "animation": [{ "at": "1s", "to": 1, "duration": "1s" }] + } + }, + "scenes": [{ + "duration": 2.0, + "children": [ + { "type": "text", "content": "c", "opacity": "= $keyDraw" } + ] + }] + }); + let resolved = load(&json); + let opacity = &resolved.views[0].scenes[0].children[0]["opacity"]; + assert_eq!(opacity, &serde_json::json!("= $keyDraw")); + } + + /// A scene's own `vars` entry — even an un-animated, otherwise-constant + /// one — is deliberately *not* folded by this document-wide pass: two + /// scenes may declare the same name with different values (shadowing), + /// and `LoadScope` has no per-scene context to resolve that + /// unambiguously. It must be left unfolded, not silently folded to the + /// wrong scene's value and not a hard "unknown identifier" error either + /// — deferred to the per-frame tier, same as an animated one. + #[test] + fn scene_level_var_is_left_unfolded_even_when_constant() { + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ + "duration": 1.0, + "vars": { "localOnly": { "default": 42 } }, + "children": [ + { "type": "text", "content": "c", "opacity": "= $localOnly" } + ] + }] + }); + let resolved = load(&json); + let opacity = &resolved.views[0].scenes[0].children[0]["opacity"]; + assert_eq!(opacity, &serde_json::json!("= $localOnly")); + } + + /// Regression: an identifier that is genuinely undeclared anywhere + /// still hard-errors, even in a document that also declares an + /// unrelated `vars` block — the new `vars`-aware guard must not + /// swallow a real "unknown identifier" error. + #[test] + fn unresolvable_identifier_still_errors_alongside_declared_vars() { + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "vars": { "keyDraw": { "default": 0 } }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "x": "= $totallyUndeclared + 1" } + ] + }] + }); + let err = load_scenario_from_source(None, Some(&json.to_string())) + .expect_err("an unresolvable identifier must still fail to load"); + assert!( + err.to_string().contains("totallyUndeclared"), + "error should name the identifier, got: {err}" + ); + } + + /// End-to-end through the real loader (not a hand-built `VarSet`): + /// scenario-level and scene-level `vars` both survive + /// `Scenario::deserialize`'s propagation into the resolved `Scene` + /// fields (`Scene::vars`, `Scene::resolved_scenario_vars`) — the same + /// mechanism `Scene::resolved_time_ctx` already uses for `bpm`/ + /// `beat_offset`, reused here for issue #329. + #[test] + fn scenario_and_scene_vars_survive_the_real_loader_into_resolved_scene_fields() { + let json = serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "vars": { "keyDraw": { "default": 0, + "animation": [{ "at": "1s", "to": 1, "duration": "1s" }] } }, + "scenes": [{ + "duration": 2.0, + "vars": { "localOnly": { "default": 42 } }, + "children": [] + }] + }); + let resolved = load(&json); + let scene = &resolved.views[0].scenes[0]; + assert_eq!(scene.vars.len(), 1); + assert!(scene.vars.contains_key("localOnly")); + assert_eq!(scene.resolved_scenario_vars.len(), 1); + assert!(scene.resolved_scenario_vars.contains_key("keyDraw")); + } +} diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 661687b4..728b7cd0 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -15,7 +15,6 @@ mod component_smoke { "cursor", r#"{"type":"cursor","cursor_style":"pointer","path_easing":"linear"}"#, ), - ("codeblock", r#"{"type":"codeblock","code":"fn main() {}"}"#), ("badge", r#"{"type":"badge","text":"New"}"#), ("callout", r#"{"type":"callout","text":"Hello"}"#), ( @@ -77,10 +76,6 @@ mod component_smoke { "tag_cloud", r#"{"type":"tag_cloud","tags":[{"text":"rust","weight":1.0}]}"#, ), - ( - "terminal", - r#"{"type":"terminal","lines":[{"text":"$ echo hello"}]}"#, - ), ( "timeline", r#"{"type":"timeline","steps":[{"label":"Start"}]}"#, @@ -139,7 +134,6 @@ mod component_smoke { "mockup", r#"{"type":"mockup","device":"browser","src":"a.png"}"#, ), - ("notification", r#"{"type":"notification","title":"Hi"}"#), ("pill_nav", r#"{"type":"pill_nav","items":["A","B"]}"#), ("tooltip", r#"{"type":"tooltip","text":"hi"}"#), // Audio reactive components @@ -166,8 +160,14 @@ mod component_smoke { } /// Input-only `type` aliases and the canonical tag they must serialize to. - const COMPONENT_ALIASES: &[(&str, &str)] = - &[("container", "div"), ("progress_bar", "progress")]; + const COMPONENT_ALIASES: &[(&str, &str)] = &[ + ("container", "div"), + ("card", "div"), + ("flex", "div"), + ("grid", "div"), + ("positioned", "div"), + ("progress_bar", "progress"), + ]; /// The exact `shape` spellings SKILL.md documents must parse. /// @@ -355,6 +355,7 @@ mod component_smoke { Err(_) => continue, }; let child = ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 10.0, y: 10.0 }), x: None, @@ -391,7 +392,7 @@ mod component_smoke { // where a component reports zero size and the card collapses. use crate::components::{ChildComponent, PositionMode}; use rustmotion_components::box_builder::build_scene; - use rustmotion_components::card::Card; + use rustmotion_components::container::ContainerComponent; use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; use rustmotion_core::css::style::{CssStyle, Edges, FlexDirection, Gap}; use rustmotion_core::css::taffy_bridge::ConversionContext; @@ -420,6 +421,7 @@ mod component_smoke { Err(_) => continue, }; let inner = ChildComponent { + id: None, component, position: None, x: None, @@ -434,7 +436,8 @@ mod component_smoke { ..Default::default() }; let card_child = ChildComponent { - component: Component::Card(Card { + id: None, + component: Component::Container(ContainerComponent { children: vec![inner], timing: Default::default(), style: card_style, @@ -558,6 +561,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize"); let child = crate::components::ChildComponent { + id: None, component, position: None, x: None, @@ -593,6 +597,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize"); let child = crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 60.0, y: 40.0 }), x: None, @@ -629,6 +634,7 @@ mod component_smoke { } let component: Component = serde_json::from_value(json).expect("deserialize"); vec![crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 60.0, y: 40.0 }), x: None, @@ -688,6 +694,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize"); let child = crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 60.0, y: 40.0 }), x: None, @@ -782,6 +789,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize"); let child = crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -870,6 +878,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize"); let child = crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 40.0, y: 40.0 }), x: None, @@ -972,6 +981,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize"); let child = crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 150.0, y: 110.0 }), x: None, @@ -1012,6 +1022,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize"); let child = crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 250.0, y: 120.0 }), x: None, @@ -1036,7 +1047,7 @@ mod component_smoke { } // ────────────────────────────────────────────────────────────────────────── - // Intrinsic-measure tests for Terminal, Table, Codeblock + // Intrinsic-measure tests for Table // // Each test wraps the component in a flex card with NO explicit width/height, // runs the full layout pass, and asserts that the component's BoxLayout has @@ -1051,13 +1062,14 @@ mod component_smoke { ) -> rustmotion_core::engine::layout_pass::BoxLayout { use crate::components::{ChildComponent, PositionMode}; use rustmotion_components::box_builder::build_scene; - use rustmotion_components::card::Card; + use rustmotion_components::container::ContainerComponent; use rustmotion_core::css::style::{CssStyle, FlexDirection}; use rustmotion_core::css::taffy_bridge::ConversionContext; use rustmotion_core::engine::layout_pass::run_layout; let component: Component = serde_json::from_value(component_json).expect("deserialize"); let inner = ChildComponent { + id: None, component, position: None, x: None, @@ -1071,7 +1083,8 @@ mod component_smoke { ..Default::default() }; let card_child = ChildComponent { - component: Component::Card(Card { + id: None, + component: Component::Container(ContainerComponent { children: vec![inner], timing: Default::default(), style: card_style, @@ -1109,42 +1122,6 @@ mod component_smoke { .expect("component must have a layout entry") } - #[test] - fn terminal_intrinsic_height_reflects_line_count() { - // Terminal with 3 lines, default font size (14px), line-height ratio - // 22/14 ≈ 1.57 → line_height = ceil(14 * 22/14) = 22. - // Expected minimum height: chrome (36) + padding_top (16) + 3 * 22 + padding_bottom (16) = 134. - // We assert ≥ 3 * line_height_min = 3 * 22 = 66 (conservative: chrome may be excluded - // in some configs, let the intrinsic beat 0). - let json = serde_json::json!({ - "type": "terminal", - "lines": [ - { "text": "echo hello", "line_type": "command" }, - { "text": "hello", "line_type": "output" }, - { "text": "echo world", "line_type": "command" } - ] - }); - let layout = layout_for_unsized_component_in_flex_card(json); - assert!( - layout.height > 0.0, - "terminal height should be > 0, got {}", - layout.height - ); - // With chrome (36) + 2*padding (32) + 3 lines * line_height (22) ≥ 134 - let min_expected = 3.0 * 22.0; // conservative: at least 3 line-heights - assert!( - layout.height >= min_expected, - "terminal height {} should be ≥ {} (3 × line_height)", - layout.height, - min_expected - ); - assert!( - layout.width > 0.0, - "terminal width should be > 0, got {}", - layout.width - ); - } - #[test] fn table_intrinsic_height_reflects_row_count() { // Table with 1 header row + 2 data rows. Default font_size = 14, row_height = 14 * 2.5 = 35. @@ -1178,35 +1155,6 @@ mod component_smoke { ); } - #[test] - fn codeblock_intrinsic_height_reflects_line_count() { - // Codeblock with 3 lines of code, default font_size = 14. - // Default padding = 16px each side. line_height = style.line_height_for(14) ≈ 14 * 1.5 = 21. - // Expected: 3 lines * line_height + pad_top + pad_bottom ≥ 3 * 14 = 42. - let json = serde_json::json!({ - "type": "codeblock", - "code": "fn main() {\n println!(\"hello\");\n}" - }); - let layout = layout_for_unsized_component_in_flex_card(json); - assert!( - layout.height > 0.0, - "codeblock height should be > 0, got {}", - layout.height - ); - let min_expected = 3.0 * 14.0; // very conservative: at least 3 × font_size - assert!( - layout.height >= min_expected, - "codeblock height {} should be ≥ {} (3 × font_size)", - layout.height, - min_expected - ); - assert!( - layout.width > 0.0, - "codeblock width should be > 0, got {}", - layout.width - ); - } - // ─── Time Remapping Tests ──────────────────────────────────────────────────── /// Build a scene with one flex container (time_scale, time_offset) wrapping @@ -1234,6 +1182,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize flex"); vec![crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -1336,6 +1285,7 @@ mod component_smoke { let make_scene = || { let component: Component = serde_json::from_value(json.clone()).expect("deserialize"); vec![crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -1390,6 +1340,7 @@ mod component_smoke { let make_scene = || { let component: Component = serde_json::from_value(json.clone()).expect("deserialize"); vec![crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -1445,6 +1396,7 @@ mod component_smoke { }); let component: Component = serde_json::from_value(json).expect("deserialize flex+line"); vec![crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -1510,6 +1462,7 @@ mod component_smoke { let make_scene = || { let component: Component = serde_json::from_value(json.clone()).expect("deserialize"); vec![crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -1611,6 +1564,7 @@ mod svg_draw_on_tests { } let component: Component = serde_json::from_value(json).expect("svg deserialize"); let child = ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -1920,6 +1874,7 @@ mod audio_tests { let component: crate::components::Component = serde_json::from_value(json).expect("component json"); crate::components::ChildComponent { + id: None, component, position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -2701,6 +2656,7 @@ mod motion_blur_trail { }); let component: Component = serde_json::from_value(json).expect("motion_blur json"); vec![ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), x: None, @@ -2727,6 +2683,7 @@ mod motion_blur_trail { }); let component: Component = serde_json::from_value(json).expect("trail json"); vec![ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), x: None, @@ -2784,6 +2741,7 @@ mod motion_blur_trail { }); let component: Component = serde_json::from_value(json).unwrap(); vec![ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), x: None, @@ -2824,6 +2782,7 @@ mod motion_blur_trail { }); let component: Component = serde_json::from_value(json).unwrap(); vec![ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), x: None, @@ -2847,6 +2806,7 @@ mod motion_blur_trail { }); let component: Component = serde_json::from_value(json).unwrap(); vec![ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), x: None, @@ -2899,6 +2859,7 @@ mod motion_blur_trail { }); let component: Component = serde_json::from_value(json).unwrap(); vec![ChildComponent { + id: None, component, position: Some(PositionMode::Absolute { x: 300.0, y: 120.0 }), x: None, @@ -3728,3 +3689,467 @@ mod world_view_regressions { ); } } + +#[cfg(test)] +mod camera_shake_tests { + //! Issue #330: declarative camera shake (`Scene.shake`), additive over + //! the ordinary camera pan — see `rustmotion_core::schema::shake::SceneShake`'s + //! doc for the formula and + //! `crate::engine::render::scene_shake_offset` (private to that module; + //! exercised here only indirectly, through rendered pixels) for how it + //! rides along `apply_camera_transform`'s `x`/`y`/`rotation`. + + use crate::engine::render::render_frame_v2; + use crate::schema::{Scene, VideoConfig}; + use rustmotion_core::schema::shake::{SceneShake, ShakeImpact}; + use rustmotion_core::schema::time::{TimeCtx, TimePoint}; + + fn config(w: u32, h: u32) -> VideoConfig { + serde_json::from_value(serde_json::json!({ "width": w, "height": h, "fps": 30 })) + .expect("config") + } + + fn render_scene_json(scene_json: serde_json::Value, w: u32, h: u32, frame: u32) -> Vec { + let scene: Scene = serde_json::from_value(scene_json).expect("scene json"); + let children = crate::engine::render::deserialize_children(&scene); + let t = frame as f64 / 30.0; + render_frame_v2(&config(w, h), &scene, frame, t, 120, &children).expect("render") + } + + /// Centroid (x, y) of pixels dominated by the given channel (0=r, 2=b) — + /// same helper shape as `camera_focal_tests::channel_centroid`, kept + /// local since that one is private to its own module. + fn channel_centroid(buf: &[u8], w: u32, h: u32, channel: usize) -> (f32, f32) { + let (mut sx, mut sy, mut n) = (0.0f64, 0.0f64, 0.0f64); + for y in 0..h { + for x in 0..w { + let i = ((y * w + x) * 4) as usize; + let v = buf[i + channel]; + let others: u16 = (0..3) + .filter(|c| *c != channel) + .map(|c| buf[i + c] as u16) + .sum(); + if v > 180 && others < 160 { + sx += x as f64; + sy += y as f64; + n += 1.0; + } + } + } + if n == 0.0 { + (-1.0, -1.0) + } else { + ((sx / n) as f32, (sy / n) as f32) + } + } + + fn red_rect_scene(extra: serde_json::Value) -> serde_json::Value { + let mut base = serde_json::json!({ + "duration": 4.0, + "children": [{ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "position": "absolute", + "x": 150, "y": 100, + "style": { "width": "100px", "height": "80px" } + }] + }); + if let (Some(base_obj), Some(extra_obj)) = (base.as_object_mut(), extra.as_object()) { + for (k, v) in extra_obj { + base_obj.insert(k.clone(), v.clone()); + } + } + base + } + + #[test] + fn a_scene_with_no_shake_field_renders_byte_identical_to_an_empty_impacts_shake() { + // `Scene.shake: Option` — absent (`None`) and an + // explicit empty-impacts `SceneShake` both evaluate to + // `ShakeOffset::default()` (all zero); this locks that equivalence + // in at the rendered-pixel level, not just the offset struct level. + let no_shake = render_scene_json(red_rect_scene(serde_json::json!({})), 400, 300, 5); + let empty_shake = render_scene_json( + red_rect_scene(serde_json::json!({ "shake": { "impacts": [] } })), + 400, + 300, + 5, + ); + assert_eq!( + no_shake, empty_shake, + "absent shake and an empty-impacts shake must both be a no-op on the rendered frame" + ); + } + + #[test] + fn a_scene_with_camera_but_no_shake_is_unaffected_by_the_shake_wiring() { + // Regression proof for the mission's "byte-identical to today" + // requirement: a scene using only the pre-existing `camera` field + // (no `shake` at all) must render exactly as it did before this + // workstream added `scene_shake_offset` into `apply_camera_transform`'s + // call sites — i.e. adding zero must be invisible. + let panned = render_scene_json( + red_rect_scene(serde_json::json!({ "camera": { "x": 30.0, "zoom": 1.0 } })), + 400, + 300, + 5, + ); + let panned_again = render_scene_json( + red_rect_scene(serde_json::json!({ "camera": { "x": 30.0, "zoom": 1.0 } })), + 400, + 300, + 5, + ); + assert_eq!( + panned, panned_again, + "same camera-only scene must render identically twice" + ); + } + + #[test] + fn a_landed_shake_impact_visibly_perturbs_the_frame() { + let no_shake = render_scene_json(red_rect_scene(serde_json::json!({})), 400, 300, 3); + let with_shake = render_scene_json( + red_rect_scene(serde_json::json!({ + "shake": { + "impacts": [ { "at": 0.0, "amplitude": 40.0 } ], + "decay": 4.0, + "frequency": 6.0 + } + })), + 400, + 300, + 3, + ); + assert_ne!( + no_shake, with_shake, + "a landed shake impact must move the rendered frame" + ); + } + + #[test] + fn shake_offset_moves_the_rendered_centroid_by_exactly_its_own_formula() { + // Quantitative integration check: the rendered rect's centroid + // shift must match `shake_offset`'s own prediction, not just + // "differ". `apply_camera_transform` (zoom=1, rotation=0) maps + // scene-space point p to screen p - (pan_x, pan_y); shake rides on + // pan_x/pan_y, so screen_x = original_x - shake.x. + let shake = SceneShake { + impacts: vec![ShakeImpact { + at: TimePoint::Seconds(0.0), + amplitude: 40.0, + }], + decay: 4.0, + frequency: 6.0, + rotation: 0.0, + }; + let ctx = TimeCtx { + bpm: None, + beat_offset: 0.0, + scene_start: 0.0, + }; + let frame = 3u32; + let t = frame as f64 / 30.0; + let expected = rustmotion_core::engine::shake::shake_offset(&shake, &ctx, t) + .expect("no bpm needed for a plain-seconds impact"); + + let base = render_scene_json(red_rect_scene(serde_json::json!({})), 400, 300, frame); + let shaken = render_scene_json( + red_rect_scene(serde_json::json!({ + "shake": { + "impacts": [ { "at": 0.0, "amplitude": 40.0 } ], + "decay": 4.0, + "frequency": 6.0 + } + })), + 400, + 300, + frame, + ); + + let (bx, by) = channel_centroid(&base, 400, 300, 0); + let (sx, sy) = channel_centroid(&shaken, 400, 300, 0); + assert!( + bx >= 0.0 && sx >= 0.0, + "red rect must be visible in both frames" + ); + + let dx = sx - bx; + let dy = sy - by; + assert!( + (dx - (-expected.x as f32)).abs() < 3.0, + "centroid x shift {dx} must match -shake.x ({})", + -expected.x + ); + assert!( + (dy - (-expected.y as f32)).abs() < 3.0, + "centroid y shift {dy} must match -shake.y ({})", + -expected.y + ); + } + + #[test] + fn shake_is_additive_over_an_existing_camera_pan_not_a_replacement() { + let shake_cfg = serde_json::json!({ + "impacts": [ { "at": 0.0, "amplitude": 25.0 } ], + "decay": 5.0, + "frequency": 4.0 + }); + let frame = 2u32; + + let pan_only = render_scene_json( + red_rect_scene(serde_json::json!({ "camera": { "x": 20.0, "zoom": 1.0 } })), + 400, + 300, + frame, + ); + let shake_only = render_scene_json( + red_rect_scene(serde_json::json!({ "shake": shake_cfg })), + 400, + 300, + frame, + ); + let both = render_scene_json( + red_rect_scene(serde_json::json!({ + "camera": { "x": 20.0, "zoom": 1.0 }, + "shake": shake_cfg + })), + 400, + 300, + frame, + ); + + assert_ne!(pan_only, both, "combined render must differ from pan alone"); + assert_ne!( + shake_only, both, + "combined render must differ from shake alone" + ); + assert_ne!( + pan_only, shake_only, + "pan alone must differ from shake alone" + ); + + let (px, _) = channel_centroid(&pan_only, 400, 300, 0); + let (bx0, _) = channel_centroid( + &render_scene_json(red_rect_scene(serde_json::json!({})), 400, 300, frame), + 400, + 300, + 0, + ); + let (cx, _) = channel_centroid(&both, 400, 300, 0); + + // Pan-only shift and combined shift, both relative to the + // unshaken/unpanned baseline: if shake were replacing the pan + // instead of adding to it, the combined shift would equal the + // shake-only shift and ignore the pan entirely. + let pan_shift = px - bx0; + let combined_shift = cx - bx0; + assert!( + (combined_shift - pan_shift).abs() > 1.0, + "combined shift ({combined_shift}) must differ from the pan-only shift ({pan_shift}) \ + — shake must contribute on top of the pan, not disappear under it" + ); + } +} + +#[cfg(test)] +mod node_reference_resolution { + //! Issue #328's per-frame half — + //! `crate::engine::render::resolve_node_references` — proven against + //! this crate's real box tree and layout, not in isolation. + //! + //! **What this does and does not prove.** No component field can yet + //! carry an expression string: `rustmotion_core::expr::Computed` is + //! defined but used by zero fields today (every numeric field, e.g. + //! `Line::x2`, is a plain `f32`), so there is no JSON scenario that can + //! write `"x2": "= node(\"badge\", \"tx\")"` and have it survive + //! deserialization — see `resolve_node_references`'s own doc for the + //! gap and the workstream now closing it. `badge` below is therefore + //! entirely real: an ordinary, already-supported JSON scene, parsed + //! with `deserialize_children` and built/laid out with + //! `build_scene_from_refs`/`run_layout` — the exact functions the real + //! per-frame render loop calls every frame. Only the *referencing* + //! side — what `line`'s own `x2` would read, were a field able to hold + //! that expression — is entered directly as `NodeRef`/`Expr` values + //! (`refs_by_id`, below), one level under the deserializer that doesn't + //! exist yet for it. + + use crate::engine::render::{deserialize_children, resolve_node_references, root_style}; + use crate::schema::{Scene, ViewType}; + use rustmotion_components::box_builder::{build_scene_from_refs, BuildAnimationCtx}; + use rustmotion_core::css::taffy_bridge::ConversionContext; + use rustmotion_core::engine::deps::{DepGraph, FrameScope, NodeRef}; + use rustmotion_core::engine::layout_pass::run_layout; + use rustmotion_core::engine::paint_pass::animated_transform; + use rustmotion_core::expr::Expr; + + const VW: f32 = 400.0; + const VH: f32 = 300.0; + + /// A scene with two id'd nodes: `badge` (a plain, literal CSS + /// `transform: translate(bx, by)` — real, deserializable JSON) and + /// `line` (an id'd node whose *would-be* `node("badge","tx")`-driven + /// endpoint is resolved by hand below, not read from its own `x2`). + fn badge_and_line_scene(bx: f32, by: f32) -> Scene { + let json = serde_json::json!({ + "duration": 4.0, + "children": [ + { + "type": "shape", + "shape": "circle", + "id": "badge", + "position": "absolute", "x": 0, "y": 0, + "style": { + "width": "20px", "height": "20px", + "transform": [ + { "fn": "translate", "x": format!("{bx}px"), "y": format!("{by}px") } + ] + } + }, + { + "type": "line", + "id": "line", + "x1": 0.0, "y1": 0.0, "x2": 1.0, "y2": 1.0 + } + ] + }); + serde_json::from_value(json).expect("scene json") + } + + /// `line`'s own reference to `badge`, declared *before* `badge` — the + /// same deliberately-reversed order + /// `engine::deps::graph_tests::a_dependent_resolves_after_its_dependency_even_when_declared_first` + /// uses, so this test cannot pass by accident of declaration order. + fn refs_by_id() -> Vec<(String, Vec)> { + vec![ + ( + "line".to_string(), + vec![NodeRef { + id: "badge".to_string(), + prop: "tx".to_string(), + }], + ), + ("badge".to_string(), vec![]), + ] + } + + /// Build the real box tree + layout for `scene`, resolve node + /// references through `resolve_node_references`, and return `(line`'s + /// resolved `node("badge","tx")` value, badge's own `tx` recomputed + /// *independently* from the very same tree/layout, bypassing + /// `ResolvedFrame` entirely)`. The two must always agree; if they + /// don't, the snapshot itself is stale, not just the reference. + fn resolve_at(scene: &Scene) -> (f64, f32) { + let children = deserialize_children(scene); + let root_css = root_style(scene.layout.as_ref(), ViewType::Slide); + let anim = Some(BuildAnimationCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: scene.duration, + fps: 30, + }); + let built = build_scene_from_refs(children.iter(), (VW, VH), root_css, anim); + let layout = run_layout( + &built.root, + (VW, VH), + &ConversionContext::for_viewport(VW, VH), + ); + + let frame = resolve_node_references(&built, &layout, (VW, VH), &refs_by_id()) + .expect("dependency graph must build: no cycle, no unknown id"); + + let scope = FrameScope(&frame); + let resolved_x2 = Expr::parse(r#"= node("badge", "tx")"#) + .unwrap() + .eval(&scope) + .expect("badge must already be resolved by the time line's expression evaluates"); + + let badge_node_id = built + .components + .iter() + .position(|c| c.and_then(|cc| cc.id.as_deref()) == Some("badge")) + .expect("badge must be in the built scene") as u32; + let badge_box = built.root.find(badge_node_id).expect("badge box node"); + let badge_layout = *layout.get(badge_box.id).expect("badge layout"); + let (badge_tx, ..) = animated_transform(&badge_box.css, &badge_layout, (VW, VH)); + + (resolved_x2, badge_tx) + } + + #[test] + fn dep_graph_topological_order_puts_badge_before_line_despite_declaration_order() { + let refs = refs_by_id(); + assert_eq!( + refs[0].0, "line", + "sanity: line is declared first in refs_by_id" + ); + let graph = DepGraph::build(&refs, &std::collections::HashSet::new()).unwrap(); + assert_eq!(graph.order(), &["badge", "line"]); + } + + #[test] + fn resolved_reference_tracks_the_current_frames_badge_with_no_one_frame_lag() { + // Several independently-built "frames" — different badge + // transforms each time. Each iteration builds its own tree, layout + // and `ResolvedFrame` from scratch, so nothing here can leak a + // value from a previous sample; that is what makes "no lag" + // structural rather than a discipline the test itself has to + // remember. + let samples: [(f32, f32); 4] = [(150.0, 80.0), (-40.0, 220.0), (0.0, 0.0), (77.5, -12.5)]; + + let mut resolved_values = Vec::new(); + for &(bx, by) in &samples { + let scene = badge_and_line_scene(bx, by); + let (resolved_x2, badge_tx_independent) = resolve_at(&scene); + + assert_eq!( + resolved_x2, badge_tx_independent as f64, + "line's node(\"badge\",\"tx\") must equal badge's own tx recomputed independently \ + from the same tree, badge placed at x={bx}" + ); + assert_eq!( + resolved_x2, bx as f64, + "badge's resolved tx must match the transform declared for this sample" + ); + resolved_values.push(resolved_x2); + } + + for pair in resolved_values.windows(2) { + assert_ne!( + pair[0], pair[1], + "resolved value did not change between differently-placed badges — \ + looks like a stale or cached ResolvedFrame" + ); + } + } + + #[test] + fn unknown_or_duplicate_ids_are_reported_not_silently_dropped() { + // `resolve_node_references` surfaces `DepsError` rather than + // swallowing it — a caller (once one exists) decides what + // best-effort behaviour means; this function itself never guesses. + let bad_refs = vec![( + "line".to_string(), + vec![NodeRef { + id: "does_not_exist".to_string(), + prop: "tx".to_string(), + }], + )]; + let scene = badge_and_line_scene(0.0, 0.0); + let children = deserialize_children(&scene); + let root_css = root_style(scene.layout.as_ref(), ViewType::Slide); + let built = build_scene_from_refs(children.iter(), (VW, VH), root_css, None); + let layout = run_layout( + &built.root, + (VW, VH), + &ConversionContext::for_viewport(VW, VH), + ); + + let err = resolve_node_references(&built, &layout, (VW, VH), &bad_refs).unwrap_err(); + assert!(matches!( + err, + rustmotion_core::engine::deps::DepsError::UnknownId { .. } + )); + } +} diff --git a/crates/rustmotion/tests/animated_box_size.rs b/crates/rustmotion/tests/animated_box_size.rs index 50d1bdb5..d54a23ea 100644 --- a/crates/rustmotion/tests/animated_box_size.rs +++ b/crates/rustmotion/tests/animated_box_size.rs @@ -75,7 +75,7 @@ fn card_rect(frame: u32) -> (f32, f32) { let hits = render_scene_hits(&config(), &resizing_card_scene(), frame); let card = hits .iter() - .find(|h| h.kind == "card") + .find(|h| h.kind == "div") .expect("card hit present in render_scene_hits output"); (card.rect.w, card.rect.h) } diff --git a/crates/rustmotion/tests/audit_ws_b.rs b/crates/rustmotion/tests/audit_ws_b.rs index cf773941..d75c1323 100644 --- a/crates/rustmotion/tests/audit_ws_b.rs +++ b/crates/rustmotion/tests/audit_ws_b.rs @@ -358,9 +358,9 @@ fn fix_leaves_relative_asset_paths_untouched() { // ─── RM-31: unwrappable_text_overflow must measure the CONTENT box ──────── /// A nowrap text's own painter draws inside its CONTENT box -/// (`LegacyPaintDispatcher` hands it `layout.content_box()`, not the raw -/// layout box, for every component except `codeblock`) — so the geometry -/// check must compare the natural line width against the content box too. +/// (`LegacyPaintDispatcher` hands every painter `layout.content_box()`, not +/// the raw layout box) — so the geometry check must compare the natural +/// line width against the content box too. /// Content box width here is 2000 - 1900 = 100px (950px of padding on each /// side); the border box is 2000px. Any real natural width for this /// string/font-size sits comfortably in between, so the violation fires if @@ -447,104 +447,6 @@ fn nowrap_text_taller_than_its_box_is_still_flagged() { assert_eq!(violation["axis"], "y", "{report_json}"); } -// ─── RM-33: auto_scroll_disabled_overflow must use the terminal's CONTENT box height ─── - -/// A terminal's own painter is NOT self-padding -/// (`LegacyPaintDispatcher::is_self_padding` matches only `Codeblock`) — it -/// paints inside its content box, so `auto_scroll: false` must compare -/// natural height against that, not the border box. 10 lines ≈ 288px -/// natural height (36px chrome + 32px internal terminal padding + 10×22px -/// lines at the default 14px font); border box height is 400px, content box -/// height is 400 - 300 (150px top+bottom CSS padding) = 100px. -#[test] -fn terminal_auto_scroll_disabled_overflow_is_measured_against_the_content_box() { - let scenario = ScratchFile::new("rm33-scenario"); - let report = ScratchFile::new("rm33-report"); - let lines: String = (1..=10) - .map(|i| format!(r##"{{ "text": "line {i}" }}"##)) - .collect::>() - .join(","); - let json = format!( - r##"{{ - "video": {{ "width": 1920, "height": 1080 }}, - "scenes": [{{ - "duration": 1.0, - "children": [{{ - "type": "terminal", - "lines": [{lines}], - "auto_scroll": false, - "position": "absolute", - "x": 50, "y": 50, - "style": {{ - "width": "800px", "height": "400px", - "padding": {{ "top": "150px", "bottom": "150px" }} - }} - }}] - }}] - }}"## - ); - std::fs::write(&scenario.0, json).expect("write scenario"); - - let output = run_validate(&scenario.0, Some(&report.0), false, false); - let report_json = read_report(&report.0); - assert!( - !output.status.success(), - "~288px of natural content is far past a 100px content box (400px border box minus \ - 300px of padding); report={report_json}" - ); - let violation = find_kind(&report_json, "auto_scroll_disabled_overflow") - .expect("expected an auto_scroll_disabled_overflow violation"); - let height = violation["bbox"]["h"].as_f64().expect("bbox.h is a number"); - assert!( - (height - 100.0).abs() < 1.0, - "violation bbox should be the 100px CONTENT box, not the 400px border box: {report_json}" - ); -} - -/// Negative control: a codeblock genuinely IS self-padding -/// (`LegacyPaintDispatcher::is_self_padding`), so its own natural-height -/// formula already bakes its padding in — the codeblock arm must keep -/// comparing against the BORDER box, unaffected by this fix. Same 60px -/// padding fixture as the pre-existing -/// `codeblock_auto_scroll_check_honours_explicit_padding_not_a_hardcoded_16px` -/// internal test, driven through the CLI instead. -#[test] -fn codeblock_auto_scroll_disabled_overflow_still_uses_the_border_box() { - let scenario = ScratchFile::new("rm33-codeblock-scenario"); - let report = ScratchFile::new("rm33-codeblock-report"); - let code_lines: String = (1..=10) - .map(|i| i.to_string()) - .collect::>() - .join("\\n"); - let json = format!( - r##"{{ - "video": {{ "width": 1920, "height": 1080 }}, - "scenes": [{{ - "duration": 1.0, - "children": [{{ - "type": "codeblock", - "code": "{code_lines}", - "auto_scroll": false, - "style": {{ "width": "600px", "height": "250px", "padding": "60px" }} - }}] - }}] - }}"## - ); - std::fs::write(&scenario.0, json).expect("write scenario"); - - let output = run_validate(&scenario.0, Some(&report.0), false, false); - let report_json = read_report(&report.0); - assert!(!output.status.success(), "report={report_json}"); - let violation = find_kind(&report_json, "auto_scroll_disabled_overflow") - .expect("expected an auto_scroll_disabled_overflow violation"); - let height = violation["bbox"]["h"].as_f64().expect("bbox.h is a number"); - assert!( - (height - 250.0).abs() < 1.0, - "codeblock is self-padding: the reported bbox must stay the 250px BORDER box, \ - not a content box: {report_json}" - ); -} - // ─── RM-40: geometry.rs must not duplicate box_builder's component_kind ─── /// `rustmotion_components::box_builder::component_kind` is already `pub` diff --git a/crates/rustmotion/tests/include_vars.rs b/crates/rustmotion/tests/include_vars.rs new file mode 100644 index 00000000..940f370c --- /dev/null +++ b/crates/rustmotion/tests/include_vars.rs @@ -0,0 +1,105 @@ +//! Issue #329's fold guard, exercised through the actual `include` pipeline +//! (not just `loader::fold_static_expressions` directly): an included +//! file's own `vars` block must protect its own expressions the same way a +//! top-level scenario's does, and must survive into the merged +//! `ResolvedScenario`'s `Scene` fields. +//! +//! `include.rs`'s own `resolve_entries` calls +//! `crate::loader::fold_static_expressions` on each included file's JSON +//! value independently (see that call site's doc comment) — this is a +//! *second*, separate fold pass from the parent document's own, so the +//! `vars`-aware guard has to hold there too, not just in the parent. + +use rustmotion::loader::load_input; + +fn write_temp(name: &str, contents: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "rm_include_vars_{name}_{}.json", + std::process::id() + )); + std::fs::write(&path, contents).unwrap(); + path +} + +/// An included file that declares its own animated `vars` and reads it +/// from an expression must load cleanly through the parent — the +/// expression must survive unfolded, not error with "unknown identifier" +/// and not silently freeze. +#[test] +fn included_file_s_animated_var_is_left_unfolded() { + let child_path = write_temp( + "child", + &serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "vars": { "keyDraw": { "default": 0, + "animation": [{ "at": "1s", "to": 1, "duration": "1s" }] } }, + "scenes": [{ + "duration": 2.0, + "children": [ + { "type": "text", "content": "c", "opacity": "= $keyDraw" } + ] + }] + }) + .to_string(), + ); + let parent_path = write_temp( + "parent", + &serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ "include": child_path.file_name().unwrap().to_str().unwrap() }] + }) + .to_string(), + ); + + let resolved = load_input(&parent_path).expect("parent with included vars-using file loads"); + let scene = &resolved.views[0].scenes[0]; + assert_eq!( + scene.children[0]["opacity"], + serde_json::json!("= $keyDraw"), + "the included file's own dynamic var reference must survive unfolded" + ); + // The included file's own `vars` propagate onto its own scene exactly + // like a top-level scenario's would (Scene::resolved_scenario_vars) — + // see `rustmotion_core::schema::scenario::Scenario::propagate_time_ctx`'s + // doc: an included file is deserialized as its own `Scenario`, so this + // is *that* file's own declared grid/vars, not the parent's. + assert!(scene.resolved_scenario_vars.contains_key("keyDraw")); + + let _ = std::fs::remove_file(&child_path); + let _ = std::fs::remove_file(&parent_path); +} + +/// An included file's own scenario-level *constant* `vars` entry folds to +/// a literal, exactly like a top-level scenario's would. +#[test] +fn included_file_s_constant_var_folds_to_a_literal() { + let child_path = write_temp( + "child_const", + &serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "vars": { "badgeCount": { "default": 8 } }, + "scenes": [{ + "duration": 1.0, + "children": [ + { "type": "text", "content": "c", "opacity": "= $badgeCount / 8" } + ] + }] + }) + .to_string(), + ); + let parent_path = write_temp( + "parent_const", + &serde_json::json!({ + "video": { "width": 100, "height": 100 }, + "scenes": [{ "include": child_path.file_name().unwrap().to_str().unwrap() }] + }) + .to_string(), + ); + + let resolved = load_input(&parent_path).expect("parent with included const-var file loads"); + let scene = &resolved.views[0].scenes[0]; + assert_eq!(scene.children[0]["opacity"], serde_json::json!(1.0)); + + let _ = std::fs::remove_file(&child_path); + let _ = std::fs::remove_file(&parent_path); +} diff --git a/crates/rustmotion/tests/node_effects_cost.rs b/crates/rustmotion/tests/node_effects_cost.rs index eaa97aaf..785cfed0 100644 --- a/crates/rustmotion/tests/node_effects_cost.rs +++ b/crates/rustmotion/tests/node_effects_cost.rs @@ -117,7 +117,7 @@ fn bench_pixel_effects_full_frame_vs_node_box() { let mut full_buf = vec![0u8; (full_w * full_h * 4) as usize]; let t0 = Instant::now(); for f in 0..FRAMES { - apply_post_effects(&mut full_buf, full_w, full_h, &effects, f); + apply_post_effects(&mut full_buf, full_w, full_h, &effects, f, 0.0); } let full_elapsed = t0.elapsed(); @@ -125,7 +125,7 @@ fn bench_pixel_effects_full_frame_vs_node_box() { let mut box_buf = vec![0u8; (box_w * box_h * 4) as usize]; let t0 = Instant::now(); for f in 0..FRAMES { - apply_post_effects(&mut box_buf, box_w, box_h, &effects, f); + apply_post_effects(&mut box_buf, box_w, box_h, &effects, f, 0.0); } let box_elapsed = t0.elapsed(); diff --git a/crates/rustmotion/tests/node_effects_leak_proof.rs b/crates/rustmotion/tests/node_effects_leak_proof.rs index 1026a3ea..855d649c 100644 --- a/crates/rustmotion/tests/node_effects_leak_proof.rs +++ b/crates/rustmotion/tests/node_effects_leak_proof.rs @@ -79,7 +79,7 @@ fn buffer_crop_strategy_leaks_effect_onto_overlapping_sibling() { let mut buf = rendered.clone(); let effects = vec![PostEffect::Pixelate { size: 32 }]; - apply_post_effects(&mut buf, config.width, config.height, &effects, 0); + apply_post_effects(&mut buf, config.width, config.height, &effects, 0, 0.0); // The foreground square sits entirely inside the background's hit rect, // so the crop-and-apply strategy touches its pixels too, even though it diff --git a/crates/rustmotion/tests/templates_iteration.rs b/crates/rustmotion/tests/templates_iteration.rs index 20ba4ff5..e06e6d5b 100644 --- a/crates/rustmotion/tests/templates_iteration.rs +++ b/crates/rustmotion/tests/templates_iteration.rs @@ -285,3 +285,60 @@ fn for_each_authored_scenario_resolves_identically_to_the_hand_written_equivalen resolved_hand_written.views[0].scenes[0].duration ); } + +/// An included file goes through its own `apply_variables` + +/// `expand_directives` pass (`include.rs`'s own doc explains why — +/// `components` is scoped per document) — and needs its own expression fold +/// for exactly the same reason: `= ...` expressions inside the *included* +/// file's own scenes were never folded by the parent document's loader +/// pipeline, only the parent's own top-level tree was. Proves the included +/// file's `for-each`-driven expression resolves to a literal, not a bare +/// string that would fail typed deserialization. +#[test] +fn an_included_files_own_expressions_are_folded_too() { + let dir = std::env::temp_dir().join(format!( + "rm_templates_iteration_include_expr_{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let child_path = dir.join("child_expr.json"); + let parent_path = dir.join("parent_expr.json"); + + let child = serde_json::json!({ + "video": { "width": 1080, "height": 1920 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "for-each": [1, 2, 3, 4], + "template": { + "type": "text", + "content": "badge", + "position": "absolute", + "x": "= $W/2 + cos($i / $count * TAU) * 200" + } + }] + }] + }); + let parent = serde_json::json!({ + "video": { "width": 1080, "height": 1920 }, + "scenes": [{ "include": "child_expr.json" }] + }); + std::fs::write(&child_path, child.to_string()).unwrap(); + std::fs::write(&parent_path, parent.to_string()).unwrap(); + + let resolved = rustmotion::loader::load_scenario_with_vars(&parent_path, None) + .expect("parent including child with expressions resolves"); + let children = &resolved.views[0].scenes[0].children; + assert_eq!(children.len(), 4); + for (i, child) in children.iter().enumerate() { + let x = child["x"] + .as_f64() + .unwrap_or_else(|| panic!("child {i}'s x must fold to a number, got {:?}", child["x"])); + let want = 1080.0 / 2.0 + (i as f64 / 4.0 * std::f64::consts::TAU).cos() * 200.0; + assert!((x - want).abs() < 1e-9, "child {i}: got {x}, want {want}"); + } + + let _ = std::fs::remove_file(&child_path); + let _ = std::fs::remove_file(&parent_path); + let _ = std::fs::remove_dir(&dir); +} diff --git a/examples/component-showcase.json b/examples/component-showcase.json index 7b9d6d8e..e77ce281 100644 --- a/examples/component-showcase.json +++ b/examples/component-showcase.json @@ -531,88 +531,1028 @@ }, "children": [ { - "type": "codeblock", - "code": "use skia_safe::Canvas;\n\npub trait Painter {\n fn paint_content(\n &self,\n canvas: &Canvas,\n layout: &BoxLayout,\n props: &AnimatedProperties,\n ctx: &PaintCtx,\n );\n}\n\nimpl Painter for Text {\n fn paint_content(\n &self,\n canvas: &Canvas,\n layout: &BoxLayout,\n _props: &AnimatedProperties,\n ctx: &PaintCtx,\n ) {\n draw_runs(canvas, &self.runs, layout, ctx);\n }\n}", - "language": "rust", - "theme": "tokyo-night", - "show_line_numbers": true, - "chrome": { - "enabled": true, - "title": "painter.rs" - }, - "reveal": { - "mode": "line_by_line", - "duration": 4.0 + "type": "div", + "style": { + "flex-direction": "column", + "background": "#1a1b26", + "overflow": "hidden", + "border-radius": 10, + "width": 900, + "height": 888, + "animation": [ + { + "name": "fade_in_up", + "delay": 0.3, + "duration": 0.5 + } + ] }, + "children": [ + { + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 8, + "padding": { + "top": 10, + "right": 14, + "bottom": 10, + "left": 14 + }, + "background": "#16161e" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#ff5f56", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#ffbd2e", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#27c93f", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "text", + "content": "painter.rs", + "style": { + "font-size": 13, + "color": "#8b949e", + "margin": { + "left": 8 + } + } + } + ] + }, + { + "type": "div", + "stagger": 0.17391304347826086, + "style": { + "flex-direction": "column", + "padding": 20, + "gap": 4 + }, + "children": [ + { + "type": "rich_text", + "spans": [ + { + "text": "use", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "skia_safe", + "color": "#7dcfff" + }, + { + "text": "::", + "color": "#c0caf5" + }, + { + "text": "Canvas", + "color": "#7dcfff" + }, + { + "text": ";", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "pub", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "trait", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "Painter", + "color": "#7dcfff" + }, + { + "text": " {", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "fn", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "paint_content", + "color": "#7aa2f7" + }, + { + "text": "(", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " &", + "color": "#c0caf5" + }, + { + "text": "self", + "color": "#bb9af7" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " canvas: &", + "color": "#c0caf5" + }, + { + "text": "Canvas", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " layout: &", + "color": "#c0caf5" + }, + { + "text": "BoxLayout", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " props: &", + "color": "#c0caf5" + }, + { + "text": "AnimatedProperties", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ctx: &", + "color": "#c0caf5" + }, + { + "text": "PaintCtx", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " );", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "}", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "impl", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "Painter", + "color": "#7dcfff" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "for", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "Text", + "color": "#7dcfff" + }, + { + "text": " {", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "fn", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "paint_content", + "color": "#7aa2f7" + }, + { + "text": "(", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " &", + "color": "#c0caf5" + }, + { + "text": "self", + "color": "#bb9af7" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " canvas: &", + "color": "#c0caf5" + }, + { + "text": "Canvas", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " layout: &", + "color": "#c0caf5" + }, + { + "text": "BoxLayout", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " _props: &", + "color": "#c0caf5" + }, + { + "text": "AnimatedProperties", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ctx: &", + "color": "#c0caf5" + }, + { + "text": "PaintCtx", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ) {", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "draw_runs", + "color": "#7aa2f7" + }, + { + "text": "(canvas, &", + "color": "#c0caf5" + }, + { + "text": "self", + "color": "#bb9af7" + }, + { + "text": ".runs, layout, ctx);", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " }", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "}", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 15, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.17391304347826086 + } + ] + } + } + ] + } + ] + }, + { + "type": "div", "style": { + "flex-direction": "column", + "background": "#1E1E1E", + "border-radius": 10, + "overflow": "hidden", + "width": 900, + "height": 888, "animation": [ { "name": "fade_in_up", - "delay": 0.3, + "delay": 0.5, "duration": 0.5 } - ], - "width": 900, - "height": 888 - } - }, - { - "type": "terminal", - "title": "rustmotion", - "show_chrome": true, - "lines": [ - { - "text": "$ rustmotion validate -f showcase.json" - }, - { - "text": "✓ schema: pass" - }, - { - "text": "✓ geometry: pass (0 violations)" - }, - { - "text": "" - }, - { - "text": "$ rustmotion render -f showcase.json -o out.mp4" - }, - { - "text": " Rendering 1920×1080 @ 30fps" - }, - { - "text": " [scene 1/4] ████████████ 100%" - }, - { - "text": " [scene 2/4] ████████████ 100%" - }, - { - "text": " [scene 3/4] ████████████ 100%" - }, - { - "text": " [scene 4/4] ████████████ 100%" - }, + ] + }, + "children": [ { - "text": " Encoding… ffmpeg H.264 10-bit" + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 8, + "padding": { + "top": 10, + "right": 14, + "bottom": 10, + "left": 14 + }, + "background": "#2D2D2D" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#ff5f56", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#ffbd2e", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#27c93f", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "text", + "content": "rustmotion", + "style": { + "font-size": 13, + "color": "#808080", + "margin": { + "left": 8 + } + } + } + ] }, { - "text": "✓ out.mp4 4.2 MB 19.0s" + "type": "div", + "stagger": 0.375, + "style": { + "flex-direction": "column", + "padding": 20, + "gap": 6 + }, + "children": [ + { + "type": "text", + "content": "$ rustmotion validate -f showcase.json", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": "✓ schema: pass", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": "✓ geometry: pass (0 violations)", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": " ", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": "$ rustmotion render -f showcase.json -o out.mp4", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": " Rendering 1920×1080 @ 30fps", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": " [scene 1/4] ████████████ 100%", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": " [scene 2/4] ████████████ 100%", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": " [scene 3/4] ████████████ 100%", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": " [scene 4/4] ████████████ 100%", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": " Encoding… ffmpeg H.264 10-bit", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + } + }, + { + "type": "text", + "content": "✓ out.mp4 4.2 MB 19.0s", + "style": { + "font-family": "JetBrains Mono", + "font-size": 16, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.375 + } + ] + }, + "caret": { + "shape": "block", + "hide_when_done": true + } + } + ] } - ], - "reveal": { - "mode": "typewriter", - "duration": 4.5 - }, - "style": { - "animation": [ - { - "name": "fade_in_up", - "delay": 0.5, - "duration": 0.5 - } - ], - "width": 900, - "height": 888 - } + ] } ] } @@ -877,14 +1817,75 @@ } }, { - "type": "notification", - "title": "Build succeeded", - "message": "All 98 tests passed in 1.86s", - "variant": "success", - "icon": "lucide:check-circle", - "width": 920, - "slide_in_at": 1.5, - "slide_out_at": 4.8 + "type": "div", + "start_at": 1.5, + "end_at": 4.8, + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 14, + "background": "#111827", + "border-radius": 14, + "padding": 18, + "width": 920, + "animation": [ + { + "name": "slide_in_left", + "duration": 0.4 + }, + { + "name": "fade_out", + "delay": 2.9, + "duration": 0.35 + } + ] + }, + "children": [ + { + "type": "div", + "style": { + "width": 4, + "align-self": "stretch", + "background": "#10b981", + "border-radius": 4 + } + }, + { + "type": "icon", + "icon": "lucide:check-circle", + "style": { + "width": 28, + "height": 28, + "color": "#10b981" + } + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "text", + "content": "Build succeeded", + "style": { + "font-size": 20, + "font-weight": "bold", + "color": "#f8fafc" + } + }, + { + "type": "text", + "content": "All 98 tests passed in 1.86s", + "style": { + "font-size": 15, + "color": "#94a3b8" + } + } + ] + } + ] } ] } diff --git a/examples/composition-kpi-row.json b/examples/composition-kpi-row.json new file mode 100644 index 00000000..67a8ee09 --- /dev/null +++ b/examples/composition-kpi-row.json @@ -0,0 +1,98 @@ +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30, "background": "#0B1120" }, + "components": { + "kpi_card": { + "params": { + "label": { "type": "string" }, + "value": { "type": "string" }, + "accent": { "type": "string", "default": "#6366F1" }, + "icon": { "type": "string", "default": "lucide:trending-up" }, + "delay": { "type": "number", "default": 0 } + }, + "template": { + "type": "card", + "style": { + "width": 360, + "height": 220, + "background": "#111827", + "border-radius": 20, + "padding": 28, + "flex-direction": "column", + "justify-content": "space-between", + "gap": 12, + "border": { "color": "#1F2937", "width": 1 }, + "box-shadow": [{ "color": "#00000060", "offset-x": 0, "offset-y": 12, "blur": 30 }], + "animation": [{ "name": "fade_in_up", "delay": "$delay", "duration": 0.6 }] + }, + "children": [ + { + "type": "div", + "style": { "flex-direction": "row", "align-items": "center", "gap": 12 }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "$accent", + "style": { "width": 48, "height": 48, "opacity": 0.18 } + }, + { + "type": "icon", + "icon": "$icon", + "position": { "x": 12, "y": 12 }, + "style": { "width": 24, "height": 24, "color": "$accent" } + } + ] + }, + { + "type": "text", + "content": "$value", + "style": { "font-size": 56, "color": "#FFFFFF", "font-weight": "bold" } + }, + { + "type": "text", + "content": "$label", + "style": { "font-size": 22, "color": "#94A3B8" } + } + ] + } + } + }, + "scenes": [ + { + "duration": 4.0, + "layout": { "direction": "column", "align_items": "center", "justify_content": "center", "gap": 48 }, + "children": [ + { + "type": "text", + "content": "Q3 Performance", + "style": { + "font-size": 64, + "color": "#FFFFFF", + "font-weight": "bold", + "text-align": "center", + "animation": [{ "name": "fade_in", "duration": 0.5 }] + } + }, + { + "type": "div", + "style": { "flex-direction": "row", "gap": 32 }, + "children": [ + { + "for-each": [ + { "label": "Active Users", "value": "45.2K", "accent": "#22C55E", "icon": "lucide:users", "delay": 0.1 }, + { "label": "Revenue (USD)", "value": "1.24M", "accent": "#3B82F6", "icon": "lucide:dollar-sign", "delay": 0.25 }, + { "label": "Churn", "value": "2.1%", "accent": "#F59E0B", "icon": "lucide:trending-down", "delay": 0.4 }, + { "label": "NPS Score", "value": "68", "accent": "#EC4899", "icon": "lucide:heart", "delay": 0.55 } + ], + "template": { + "use": "kpi_card", + "props": { "label": "$label", "value": "$value", "accent": "$accent", "icon": "$icon", "delay": "$delay" } + } + } + ] + } + ] + } + ] +} diff --git a/examples/composition-pill-row.json b/examples/composition-pill-row.json new file mode 100644 index 00000000..27db0a69 --- /dev/null +++ b/examples/composition-pill-row.json @@ -0,0 +1,62 @@ +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30, "background": "#0F0E2A" }, + "components": { + "feature_pill": { + "params": { + "text": { "type": "string" }, + "icon": { "type": "string", "default": "lucide:check" }, + "accent": { "type": "string", "default": "#6366F1" }, + "delay": { "type": "number", "default": 0 } + }, + "template": { + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 10, + "padding": { "top": 12, "bottom": 12, "left": 20, "right": 24 }, + "background": "$accent", + "border-radius": 999, + "animation": [{ "name": "scale_in", "delay": "$delay", "duration": 0.4 }] + }, + "children": [ + { "type": "icon", "icon": "$icon", "style": { "width": 22, "height": 22, "color": "#FFFFFF" } }, + { "type": "text", "content": "$text", "style": { "font-size": 26, "color": "#FFFFFF", "font-weight": "bold" } } + ] + } + } + }, + "scenes": [ + { + "duration": 3.5, + "layout": { "direction": "column", "align_items": "center", "justify_content": "center", "gap": 40 }, + "children": [ + { + "type": "text", + "content": "Everything you need", + "style": { "font-size": 60, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center", "animation": [{ "name": "fade_in_up", "duration": 0.5 }] } + }, + { + "type": "div", + "style": { "flex-direction": "row", "gap": 20, "flex-wrap": "wrap", "justify-content": "center" }, + "children": [ + { + "for-each": [ + { "text": "Unlimited projects", "icon": "lucide:infinity", "accent": "#6366F1", "delay": 0.2 }, + { "text": "Priority support", "icon": "lucide:headphones", "accent": "#22C55E", "delay": 0.35 }, + { "text": "Custom domains", "icon": "lucide:globe", "accent": "#EC4899", "delay": 0.5 }, + { "text": "Team seats", "icon": "lucide:users", "accent": "#F59E0B", "delay": 0.65 }, + { "text": "SSO & audit logs", "icon": "lucide:shield-check", "accent": "#3B82F6", "delay": 0.8 } + ], + "template": { + "use": "feature_pill", + "props": { "text": "$text", "icon": "$icon", "accent": "$accent", "delay": "$delay" } + } + } + ] + } + ] + } + ] +} diff --git a/examples/composition-progress-bars.json b/examples/composition-progress-bars.json new file mode 100644 index 00000000..4daf7167 --- /dev/null +++ b/examples/composition-progress-bars.json @@ -0,0 +1,91 @@ +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30, "background": "#0B1120" }, + "components": { + "progress_row": { + "params": { + "label": { "type": "string" }, + "percent_label": { "type": "string" }, + "target_width": { "type": "number" }, + "accent": { "type": "string", "default": "#3B82F6" }, + "delay": { "type": "number", "default": 0 } + }, + "template": { + "type": "div", + "style": { "flex-direction": "column", "gap": 10, "width": 760 }, + "children": [ + { + "type": "div", + "style": { "flex-direction": "row", "justify-content": "space-between" }, + "children": [ + { "type": "text", "content": "$label", "style": { "font-size": 26, "color": "#E2E8F0", "font-weight": "bold" } }, + { "type": "text", "content": "$percent_label", "style": { "font-size": 26, "color": "$accent", "font-weight": "bold" } } + ] + }, + { + "type": "card", + "style": { "width": 760, "height": 18, "background": "#1E293B", "border-radius": 9, "padding": 0 }, + "children": [ + { + "type": "shape", + "shape": "rounded_rect", + "fill": "$accent", + "style": { + "width": 6, + "height": 18, + "border-radius": 9, + "animation": [{ + "name": "keyframes", + "delay": "$delay", + "keyframes": [{ + "property": "width", + "easing": "ease_out_cubic", + "keyframes": [{ "time": 0, "value": 6 }, { "time": 1.2, "value": "$target_width" }] + }] + }] + } + } + ] + } + ] + } + } + }, + "scenes": [ + { + "duration": 4.0, + "layout": { "direction": "column", "align_items": "center", "justify_content": "center", "gap": 44 }, + "children": [ + { + "type": "text", + "content": "Migration Progress", + "style": { "font-size": 60, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center", "animation": [{ "name": "fade_in", "duration": 0.5 }] } + }, + { + "type": "div", + "style": { "flex-direction": "column", "gap": 28 }, + "children": [ + { + "for-each": [ + { "label": "Database schema", "percent_label": "100%", "target_width": 760, "accent": "#22C55E", "delay": 0.2 }, + { "label": "API endpoints", "percent_label": "82%", "target_width": 623, "accent": "#3B82F6", "delay": 0.35 }, + { "label": "Background jobs", "percent_label": "54%", "target_width": 410, "accent": "#F59E0B", "delay": 0.5 }, + { "label": "Legacy cleanup", "percent_label": "12%", "target_width": 91, "accent": "#EF4444", "delay": 0.65 } + ], + "template": { + "use": "progress_row", + "props": { + "label": "$label", + "percent_label": "$percent_label", + "target_width": "$target_width", + "accent": "$accent", + "delay": "$delay" + } + } + } + ] + } + ] + } + ] +} diff --git a/examples/composition-step-flow.json b/examples/composition-step-flow.json new file mode 100644 index 00000000..0ee479e8 --- /dev/null +++ b/examples/composition-step-flow.json @@ -0,0 +1,74 @@ +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30, "background": "#0F172A" }, + "components": { + "flow_step": { + "params": { + "number": { "type": "string" }, + "label": { "type": "string" }, + "accent": { "type": "string", "default": "#3B82F6" }, + "connector_color": { "type": "string", "default": "#334155" }, + "delay": { "type": "number", "default": 0 } + }, + "template": [ + { + "type": "div", + "style": { "flex-direction": "column", "align-items": "center", "gap": 16, "animation": [{ "name": "fade_in_up", "delay": "$delay", "duration": 0.5 }] }, + "children": [ + { + "type": "card", + "style": { "width": 88, "height": 88, "background": "$accent", "border-radius": 44, "align-items": "center", "justify-content": "center" }, + "children": [ + { "type": "text", "content": "$number", "style": { "font-size": 36, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center" } } + ] + }, + { "type": "text", "content": "$label", "style": { "font-size": 24, "color": "#E2E8F0", "text-align": "center" } } + ] + }, + { + "type": "shape", + "shape": "rect", + "fill": "$connector_color", + "style": { "width": 140, "height": 4, "margin": { "top": 42 } } + } + ] + } + }, + "scenes": [ + { + "duration": 4.0, + "layout": { "direction": "column", "align_items": "center", "justify_content": "center", "gap": 44 }, + "children": [ + { + "type": "text", + "content": "Getting Started", + "style": { "font-size": 60, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center", "animation": [{ "name": "fade_in", "duration": 0.5 }] } + }, + { + "type": "div", + "style": { "flex-direction": "row", "align-items": "flex-start", "gap": 0 }, + "children": [ + { + "for-each": [ + { "number": "1", "label": "Sign up", "accent": "#3B82F6", "connector_color": "#334155", "delay": 0.2 }, + { "number": "2", "label": "Connect data", "accent": "#3B82F6", "connector_color": "#334155", "delay": 0.4 }, + { "number": "3", "label": "Configure", "accent": "#3B82F6", "connector_color": "#334155", "delay": 0.6 }, + { "number": "4", "label": "Go live", "accent": "#22C55E", "connector_color": "#00000000", "delay": 0.8 } + ], + "template": { + "use": "flow_step", + "props": { + "number": "$number", + "label": "$label", + "accent": "$accent", + "connector_color": "$connector_color", + "delay": "$delay" + } + } + } + ] + } + ] + } + ] +} diff --git a/examples/depth-3d-showcase.json b/examples/depth-3d-showcase.json new file mode 100644 index 00000000..d380dec3 --- /dev/null +++ b/examples/depth-3d-showcase.json @@ -0,0 +1,886 @@ +{ + "version": "1.0", + "video": { + "width": 1920, + "height": 1080, + "fps": 30, + "background": "#0f172a" + }, + "backgrounds": { + "rings": { + "preset": "concentric_circles", + "colors": [ + "#0f172a", + "#1b1447", + "#0f172a" + ], + "speed": 12, + "element_size": 1.5, + "count": 5 + } + }, + "composition": [ + { + "type": "slide", + "scenes": [ + { + "duration": 6.0, + "background": { + "$ref": "rings" + }, + "layout": { + "align_items": "center", + "justify_content": "center", + "direction": "column", + "gap": 40 + }, + "camera": { + "origin": { + "x": 960, + "y": 540 + }, + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.18 + }, + { + "time": 5.0, + "value": 1.0 + } + ], + "easing": "ease_out" + }, + { + "property": "x", + "values": [ + { + "time": 0, + "value": -90 + }, + { + "time": 5.0, + "value": 0 + } + ], + "easing": "ease_out" + } + ] + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "position": "absolute", + "x": 120, + "y": 140, + "fill": { + "type": "radial", + "colors": [ + "#6366F180", + "#6366F100" + ] + }, + "style": { + "width": 760, + "height": 760, + "depth": 0.25, + "animation": [ + { + "name": "fade_in", + "duration": 1.0 + }, + { + "name": "wiggle", + "property": "translate_y", + "amplitude": 5, + "frequency": 0.35, + "seed": 7 + } + ] + } + }, + { + "type": "shape", + "shape": "circle", + "position": "absolute", + "x": 1180, + "y": 360, + "fill": { + "type": "radial", + "colors": [ + "#A855F780", + "#A855F700" + ] + }, + "style": { + "width": 680, + "height": 680, + "depth": 0.4, + "animation": [ + { + "name": "fade_in", + "delay": 0.2, + "duration": 1.0 + }, + { + "name": "wiggle", + "property": "translate_x", + "amplitude": 6, + "frequency": 0.45, + "seed": 91 + } + ] + } + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "align-items": "center", + "gap": 28, + "depth": 1.0 + }, + "children": [ + { + "type": "text", + "content": "Depth is a rendering decision", + "max_width": 1400, + "style": { + "font-size": 104, + "color": "#F8FAFC", + "font-weight": "bold", + "text-align": "center", + "line-height": 1.05, + "letter-spacing": -2, + "animation": [ + { + "name": "char_fade_in", + "granularity": "word", + "stagger": 0.14, + "duration": 0.6, + "delay": 0.6 + } + ] + } + }, + { + "type": "text", + "content": "Four independent mechanisms, one camera.", + "max_width": 1100, + "style": { + "font-size": 40, + "color": "#94A3B8", + "text-align": "center", + "line-height": 1.4, + "animation": [ + { + "name": "fade_in_up", + "delay": 1.7, + "duration": 0.7 + } + ] + } + } + ] + }, + { + "type": "badge", + "text": "style.depth", + "icon": "lucide:layers", + "variant": "solid", + "position": "absolute", + "x": 150, + "y": 880, + "style": { + "background": "#4338CA", + "font-size": 30, + "depth": 1.9, + "animation": [ + { + "name": "fade_in_up", + "delay": 2.4, + "duration": 0.6 + }, + { + "name": "wiggle", + "property": "translate_y", + "amplitude": 9, + "frequency": 1.2, + "seed": 33 + } + ] + } + } + ] + }, + { + "duration": 6.5, + "transition": { + "type": "iris", + "duration": 0.7 + }, + "background": { + "$ref": "rings", + "colors": [ + "#0f172a", + "#10283f", + "#0f172a" + ], + "transition": { + "duration": 1.0, + "easing": "ease_in_out" + } + }, + "layout": { + "align_items": "center", + "justify_content": "center", + "direction": "column", + "gap": 48 + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "position": "absolute", + "x": 700, + "y": 240, + "fill": { + "type": "radial", + "colors": [ + "#0EA5E980", + "#0EA5E900" + ] + }, + "style": { + "width": 820, + "height": 820, + "animation": [ + { + "name": "fade_in", + "duration": 1.0 + }, + { + "name": "wiggle", + "property": "translate_y", + "amplitude": 4, + "frequency": 0.3, + "seed": 12 + } + ] + } + }, + { + "type": "text", + "content": "A tilt the shadow agrees with", + "style": { + "font-size": 64, + "color": "#F8FAFC", + "font-weight": "bold", + "text-align": "center", + "letter-spacing": -1, + "animation": [ + { + "name": "fade_in_down", + "duration": 0.7 + } + ] + } + }, + { + "type": "card", + "style": { + "width": 1180, + "height": 440, + "background": "#1e293b", + "border-radius": 28, + "padding": 56, + "gap": 32, + "flex-direction": "column", + "justify-content": "center", + "border": { + "color": "#FFFFFF1A", + "width": 1 + }, + "box-shadow": [ + { + "color": "#00000090", + "offset-x": 0, + "offset-y": 36, + "blur": 90 + } + ], + "animation": [ + { + "name": "keyframes", + "keyframes": [ + { + "property": "rotate_x", + "keyframes": [ + { + "time": 0, + "value": 14 + }, + { + "time": 2.6, + "value": 4 + } + ], + "easing": "ease_out" + }, + { + "property": "rotate_y", + "keyframes": [ + { + "time": 0, + "value": -13 + }, + { + "time": 2.6, + "value": -4 + } + ], + "easing": "ease_out" + }, + { + "property": "perspective", + "keyframes": [ + { + "time": 0, + "value": 900 + }, + { + "time": 2.6, + "value": 900 + } + ], + "easing": "linear" + }, + { + "property": "opacity", + "keyframes": [ + { + "time": 0, + "value": 0 + }, + { + "time": 0.6, + "value": 1 + } + ], + "easing": "ease_out" + } + ] + }, + { + "name": "float_3d", + "loop": true + } + ] + }, + "children": [ + { + "type": "rich_text", + "spans": [ + { + "text": "rotate_x", + "color": "#38BDF8", + "font-weight": "bold" + }, + { + "text": " and " + }, + { + "text": "rotate_y", + "color": "#38BDF8", + "font-weight": "bold" + }, + { + "text": " drive a Skia M44 matrix." + } + ], + "max_width": 1020, + "style": { + "font-size": 44, + "color": "#E2E8F0", + "line-height": 1.35 + } + }, + { + "type": "text", + "content": "The box-shadow is not drawn by hand. It reads the tilt and shifts against it, so the card keeps a ground plane.", + "max_width": 1020, + "style": { + "font-size": 32, + "color": "#94A3B8", + "line-height": 1.5, + "animation": [ + { + "name": "fade_in", + "delay": 1.2, + "duration": 0.8 + } + ] + } + } + ] + } + ] + }, + { + "duration": 7.0, + "transition": { + "type": "corner_reveal", + "duration": 0.6, + "corner": "bottom_left" + }, + "background": { + "$ref": "rings", + "colors": [ + "#0f172a", + "#2a1240", + "#0f172a" + ], + "transition": { + "duration": 1.0, + "easing": "ease_in_out" + } + }, + "layout": { + "align_items": "center", + "justify_content": "center", + "direction": "column", + "gap": 36 + }, + "camera": { + "origin": { + "x": 960, + "y": 540 + }, + "keyframes": [ + { + "property": "x", + "values": [ + { + "time": 0.4, + "value": 0 + }, + { + "time": 6.2, + "value": 150 + } + ], + "easing": "ease_in_out" + } + ] + }, + "children": [ + { + "type": "shape", + "shape": "rounded_rect", + "position": "absolute", + "x": 0, + "y": 560, + "fill": { + "type": "linear", + "colors": [ + "#1E1B4B00", + "#312E8166" + ], + "angle": 90 + }, + "style": { + "width": 1920, + "height": 520, + "border-radius": 0, + "depth": 0.2, + "animation": [ + { + "name": "fade_in", + "duration": 0.8 + } + ] + } + }, + { + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 40, + "depth": 0.55 + }, + "children": [ + { + "type": "card", + "style": { + "width": 380, + "height": 300, + "background": "#16213a", + "border-radius": 22, + "padding": 32, + "gap": 14, + "flex-direction": "column", + "justify-content": "center", + "opacity": 0.72, + "box-shadow": [ + { + "color": "#00000060", + "offset-x": 0, + "offset-y": 16, + "blur": 40 + } + ], + "animation": [ + { + "name": "fade_in_up", + "delay": 0.3, + "duration": 0.7 + }, + { + "name": "wiggle", + "property": "translate_y", + "amplitude": 4, + "frequency": 0.4, + "seed": 51 + } + ] + }, + "children": [ + { + "type": "text", + "content": "depth 0.2", + "style": { + "font-size": 34, + "color": "#64748B", + "font-weight": "bold" + } + }, + { + "type": "text", + "content": "Barely moves. Reads as far.", + "max_width": 300, + "style": { + "font-size": 26, + "color": "#475569", + "line-height": 1.4, + "height": 80 + } + } + ] + }, + { + "type": "card", + "style": { + "width": 380, + "height": 300, + "background": "#1e293b", + "border-radius": 22, + "padding": 32, + "gap": 14, + "flex-direction": "column", + "justify-content": "center", + "box-shadow": [ + { + "color": "#00000070", + "offset-x": 0, + "offset-y": 22, + "blur": 55 + } + ], + "animation": [ + { + "name": "fade_in_up", + "delay": 0.5, + "duration": 0.7 + }, + { + "name": "wiggle", + "property": "translate_y", + "amplitude": 6, + "frequency": 0.7, + "seed": 42 + } + ] + }, + "children": [ + { + "type": "text", + "content": "depth 1.0", + "style": { + "font-size": 34, + "color": "#E2E8F0", + "font-weight": "bold" + } + }, + { + "type": "text", + "content": "Tracks the camera exactly.", + "max_width": 300, + "style": { + "font-size": 26, + "color": "#94A3B8", + "line-height": 1.4, + "height": 80 + } + } + ] + } + ] + }, + { + "type": "card", + "style": { + "width": 400, + "height": 300, + "background": "#312E81", + "border-radius": 24, + "padding": 36, + "gap": 16, + "flex-direction": "column", + "justify-content": "center", + "depth": 2.1, + "border": { + "color": "#818CF880", + "width": 2 + }, + "box-shadow": [ + { + "color": "#000000A0", + "offset-x": 0, + "offset-y": 34, + "blur": 80 + } + ], + "animation": [ + { + "name": "fade_in_up", + "delay": 0.8, + "duration": 0.7 + }, + { + "name": "wiggle", + "property": "translate_y", + "amplitude": 10, + "frequency": 1.1, + "seed": 88 + } + ] + }, + "children": [ + { + "type": "text", + "content": "depth 2.1", + "style": { + "font-size": 38, + "color": "#FFFFFF", + "font-weight": "bold" + } + }, + { + "type": "text", + "content": "Overtakes the camera. Reads as close enough to touch.", + "max_width": 340, + "style": { + "font-size": 27, + "color": "#C7D2FE", + "line-height": 1.4, + "height": 120 + } + } + ], + "position": "absolute", + "x": 1210, + "y": 600 + } + ] + }, + { + "duration": 5.5, + "transition": { + "type": "pixel_dissolve", + "duration": 0.8, + "cell": 40, + "seed": 11 + }, + "background": { + "$ref": "rings" + }, + "layout": { + "align_items": "center", + "justify_content": "center", + "direction": "column", + "gap": 44 + }, + "camera": { + "origin": { + "x": 960, + "y": 540 + }, + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.0 + }, + { + "time": 4.6, + "value": 1.12 + } + ], + "easing": "ease_in_out" + } + ] + }, + "children": [ + { + "type": "shape", + "shape": { + "polygon": { + "sides": 6 + } + }, + "position": "absolute", + "x": 170, + "y": 120, + "fill": { + "type": "linear", + "colors": [ + "#6366F1", + "#A855F7" + ], + "angle": 135 + }, + "style": { + "width": 330, + "height": 330, + "depth": 0.35, + "animation": [ + { + "name": "fade_in", + "duration": 0.8 + }, + { + "name": "orbit", + "radius_x": 26, + "radius_y": 16, + "speed": 0.16, + "depth": 0.22, + "tilt": 18 + } + ], + "opacity": 0.32 + } + }, + { + "type": "shape", + "shape": { + "polygon": { + "sides": 6 + } + }, + "position": "absolute", + "x": 1450, + "y": 620, + "fill": { + "type": "linear", + "colors": [ + "#38BDF8", + "#6366F1" + ], + "angle": 135 + }, + "style": { + "width": 260, + "height": 260, + "depth": 0.18, + "opacity": 0.22, + "animation": [ + { + "name": "fade_in", + "delay": 0.3, + "duration": 0.9 + }, + { + "name": "orbit", + "radius_x": 18, + "radius_y": 12, + "speed": 0.11, + "depth": 0.18, + "tilt": -14 + } + ] + } + }, + { + "type": "icon", + "icon": "lucide:box", + "style": { + "width": 128, + "height": 128, + "color": "#818CF8", + "animation": [ + { + "name": "tilt_in", + "duration": 1.0 + }, + { + "name": "float_3d", + "loop": true + } + ] + } + }, + { + "type": "gradient_text", + "content": "rustmotion", + "colors": [ + "#818CF8", + "#C084FC", + "#38BDF8" + ], + "angle": 90, + "animate_angle": true, + "speed": 0.2, + "style": { + "font-size": 132, + "font-weight": "bold", + "letter-spacing": -3, + "animation": [ + { + "name": "scale_in", + "delay": 0.5, + "duration": 0.9 + } + ] + } + }, + { + "type": "text", + "content": "No browser. No Node. One binary that knows where things are in space.", + "max_width": 1200, + "style": { + "font-size": 34, + "color": "#94A3B8", + "text-align": "center", + "line-height": 1.5, + "animation": [ + { + "name": "fade_in_up", + "delay": 1.5, + "duration": 0.8 + } + ] + } + } + ] + } + ] + } + ] +} diff --git a/examples/depth-3d-showcase.mp4 b/examples/depth-3d-showcase.mp4 new file mode 100644 index 00000000..c44056ab Binary files /dev/null and b/examples/depth-3d-showcase.mp4 differ diff --git a/examples/ferriskey-launch-60s.json b/examples/ferriskey-launch-60s.json index ebd2cd02..e206ea15 100644 --- a/examples/ferriskey-launch-60s.json +++ b/examples/ferriskey-launch-60s.json @@ -6637,47 +6637,14 @@ ] }, { - "type": "terminal", - "position": "absolute", - "x": 300, - "y": 320, - "title": "ferriskey", - "theme": "dark", - "reveal": { - "mode": "typewriter", - "start": 0.25, - "duration": 2.6 - }, - "lines": [ - { - "text": "cargo run --release", - "line_type": "command" - }, - { - "text": " Compiling ferriskey v0.7.0", - "line_type": "output", - "color": "#a1a1aa" - }, - { - "text": " Finished release [optimized] in 4.82s", - "line_type": "output", - "color": "#a1a1aa" - }, - { - "text": " Running target/release/ferriskey", - "line_type": "output", - "color": "#a1a1aa" - }, - { - "text": " 0 unsafe blocks · 0 known CVEs", - "line_type": "output", - "color": "#22C55E" - } - ], + "type": "div", "style": { + "flex-direction": "column", + "background": "#1E1E1E", + "border-radius": 10, + "overflow": "hidden", "width": 1000, "height": 320, - "font-size": 28, "animation": [ { "name": "keyframes", @@ -6729,7 +6696,162 @@ ] } ] - } + }, + "children": [ + { + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 8, + "padding": { + "top": 10, + "right": 14, + "bottom": 10, + "left": 14 + }, + "background": "#2D2D2D" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#ff5f56", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#ffbd2e", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#27c93f", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "text", + "content": "ferriskey", + "style": { + "font-size": 13, + "color": "#808080", + "margin": { + "left": 8 + } + } + } + ] + }, + { + "type": "div", + "stagger": 0.52, + "style": { + "flex-direction": "column", + "padding": 20, + "gap": 6 + }, + "children": [ + { + "type": "text", + "content": "cargo run --release", + "style": { + "font-family": "JetBrains Mono", + "font-size": 28, + "color": "#FFFFFF", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + } + }, + { + "type": "text", + "content": " Compiling ferriskey v0.7.0", + "style": { + "font-family": "JetBrains Mono", + "font-size": 28, + "color": "#a1a1aa", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + } + }, + { + "type": "text", + "content": " Finished release [optimized] in 4.82s", + "style": { + "font-family": "JetBrains Mono", + "font-size": 28, + "color": "#a1a1aa", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + } + }, + { + "type": "text", + "content": " Running target/release/ferriskey", + "style": { + "font-family": "JetBrains Mono", + "font-size": 28, + "color": "#a1a1aa", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + } + }, + { + "type": "text", + "content": " 0 unsafe blocks · 0 known CVEs", + "style": { + "font-family": "JetBrains Mono", + "font-size": 28, + "color": "#22C55E", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + }, + "caret": { + "shape": "block", + "hide_when_done": true + } + } + ] + } + ], + "position": "absolute", + "x": 300, + "y": 320 }, { "type": "success_check", diff --git a/examples/mega-showcase.json b/examples/mega-showcase.json index 88e3ec98..2e631278 100644 --- a/examples/mega-showcase.json +++ b/examples/mega-showcase.json @@ -1355,14 +1355,75 @@ ] }, { - "type": "notification", - "title": "Settings saved", - "message": "Your preferences were updated", - "variant": "success", - "icon": "lucide:check-circle", - "width": 420, - "slide_in_at": 2.2, - "slide_out_at": 5.2, + "type": "div", + "start_at": 2.2, + "end_at": 5.2, + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 14, + "background": "#111827", + "border-radius": 14, + "padding": 18, + "width": 420, + "animation": [ + { + "name": "slide_in_left", + "duration": 0.4 + }, + { + "name": "fade_out", + "delay": 2.6, + "duration": 0.35 + } + ] + }, + "children": [ + { + "type": "div", + "style": { + "width": 4, + "align-self": "stretch", + "background": "#10b981", + "border-radius": 4 + } + }, + { + "type": "icon", + "icon": "lucide:check-circle", + "style": { + "width": 28, + "height": 28, + "color": "#10b981" + } + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "gap": 4 + }, + "children": [ + { + "type": "text", + "content": "Settings saved", + "style": { + "font-size": 20, + "font-weight": "bold", + "color": "#f8fafc" + } + }, + { + "type": "text", + "content": "Your preferences were updated", + "style": { + "font-size": 15, + "color": "#94a3b8" + } + } + ] + } + ], "position": "absolute", "x": 1450, "y": 60 @@ -1714,26 +1775,14 @@ }, "children": [ { - "type": "codeblock", - "code": "use skia_safe::Canvas;\n\npub trait Painter {\n fn paint_content(\n &self,\n canvas: &Canvas,\n layout: &BoxLayout,\n props: &AnimatedProperties,\n ctx: &PaintCtx,\n );\n}\n\nimpl Painter for Text {\n fn paint_content(&self, c: &Canvas, l: &BoxLayout,\n _p: &AnimatedProperties, ctx: &PaintCtx) {\n draw_runs(c, &self.runs, l, ctx);\n }\n}", - "language": "rust", - "theme": "tokyo-night", - "show_line_numbers": true, - "chrome": { - "enabled": true, - "title": "painter.rs" - }, - "reveal": { - "mode": "line_by_line", - "start": 0.3, - "duration": 4.0 - }, - "auto_scroll": true, + "type": "div", "style": { + "flex-direction": "column", + "background": "#1a1b26", + "overflow": "hidden", + "border-radius": 14, "width": 858, "height": 760, - "font-size": 22, - "border-radius": 14, "animation": [ { "name": "fade_in_left", @@ -1741,78 +1790,897 @@ "duration": 0.5 } ] - } - }, - { - "type": "terminal", - "title": "rustmotion", - "show_chrome": true, - "theme": "dark", - "auto_scroll": true, - "lines": [ - { - "text": "$ rustmotion validate -f mega-showcase.json", - "line_type": "prompt" - }, - { - "text": " schema: pass", - "line_type": "output" - }, - { - "text": " geometry: pass (0 violations)", - "line_type": "output" - }, - { - "text": "", - "line_type": "output" - }, - { - "text": "$ rustmotion render -f mega-showcase.json -o out.mp4", - "line_type": "prompt" - }, - { - "text": " Rendering 1920x1080 @ 30fps", - "line_type": "output" - }, - { - "text": " [scene 1/8] done", - "line_type": "output" - }, - { - "text": " [scene 4/8] done", - "line_type": "output" - }, - { - "text": " [scene 8/8] done", - "line_type": "output" - }, + }, + "children": [ { - "text": " Encoding ffmpeg H.264 10-bit", - "line_type": "output" + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 8, + "padding": { + "top": 10, + "right": 14, + "bottom": 10, + "left": 14 + }, + "background": "#16161e" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#ff5f56", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#ffbd2e", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#27c93f", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "text", + "content": "painter.rs", + "style": { + "font-size": 13, + "color": "#8b949e", + "margin": { + "left": 8 + } + } + } + ] }, { - "text": " out.mp4 6.1 MB 37.0s", - "line_type": "output" - } - ], - "reveal": { - "mode": "typewriter", - "start": 0.5, - "duration": 4.5 - }, - "style": { - "width": 858, - "height": 760, - "font-size": 22, - "border-radius": 14, - "animation": [ - { - "name": "fade_in_right", - "delay": 0.5, - "duration": 0.5 - } - ] - } + "type": "div", + "stagger": 0.2222222222222222, + "style": { + "flex-direction": "column", + "padding": 20, + "gap": 4 + }, + "children": [ + { + "type": "rich_text", + "spans": [ + { + "text": "use", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "skia_safe", + "color": "#7dcfff" + }, + { + "text": "::", + "color": "#c0caf5" + }, + { + "text": "Canvas", + "color": "#7dcfff" + }, + { + "text": ";", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "pub", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "trait", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "Painter", + "color": "#7dcfff" + }, + { + "text": " {", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "fn", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "paint_content", + "color": "#7aa2f7" + }, + { + "text": "(", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " &", + "color": "#c0caf5" + }, + { + "text": "self", + "color": "#bb9af7" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " canvas: &", + "color": "#c0caf5" + }, + { + "text": "Canvas", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " layout: &", + "color": "#c0caf5" + }, + { + "text": "BoxLayout", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " props: &", + "color": "#c0caf5" + }, + { + "text": "AnimatedProperties", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ctx: &", + "color": "#c0caf5" + }, + { + "text": "PaintCtx", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " );", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "}", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "impl", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "Painter", + "color": "#7dcfff" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "for", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "Text", + "color": "#7dcfff" + }, + { + "text": " {", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "fn", + "color": "#bb9af7" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "paint_content", + "color": "#7aa2f7" + }, + { + "text": "(&", + "color": "#c0caf5" + }, + { + "text": "self", + "color": "#bb9af7" + }, + { + "text": ", c: &", + "color": "#c0caf5" + }, + { + "text": "Canvas", + "color": "#7dcfff" + }, + { + "text": ", l: &", + "color": "#c0caf5" + }, + { + "text": "BoxLayout", + "color": "#7dcfff" + }, + { + "text": ",", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " _p: &", + "color": "#c0caf5" + }, + { + "text": "AnimatedProperties", + "color": "#7dcfff" + }, + { + "text": ", ctx: &", + "color": "#c0caf5" + }, + { + "text": "PaintCtx", + "color": "#7dcfff" + }, + { + "text": ") {", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "draw_runs", + "color": "#7aa2f7" + }, + { + "text": "(c, &", + "color": "#c0caf5" + }, + { + "text": "self", + "color": "#bb9af7" + }, + { + "text": ".runs, l, ctx);", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " }", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "}", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.2222222222222222 + } + ] + } + } + ] + } + ] + }, + { + "type": "div", + "style": { + "flex-direction": "column", + "background": "#1E1E1E", + "border-radius": 14, + "overflow": "hidden", + "width": 858, + "height": 760, + "animation": [ + { + "name": "fade_in_right", + "delay": 0.5, + "duration": 0.5 + } + ] + }, + "children": [ + { + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 8, + "padding": { + "top": 10, + "right": 14, + "bottom": 10, + "left": 14 + }, + "background": "#2D2D2D" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#ff5f56", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#ffbd2e", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#27c93f", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "text", + "content": "rustmotion", + "style": { + "font-size": 13, + "color": "#808080", + "margin": { + "left": 8 + } + } + } + ] + }, + { + "type": "div", + "stagger": 0.4090909090909091, + "style": { + "flex-direction": "column", + "padding": 20, + "gap": 6 + }, + "children": [ + { + "type": "text", + "content": "$ rustmotion validate -f mega-showcase.json", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#22C55E", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " schema: pass", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " geometry: pass (0 violations)", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " ", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": "$ rustmotion render -f mega-showcase.json -o out.mp4", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#22C55E", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " Rendering 1920x1080 @ 30fps", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " [scene 1/8] done", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " [scene 4/8] done", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " [scene 8/8] done", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " Encoding ffmpeg H.264 10-bit", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + } + }, + { + "type": "text", + "content": " out.mp4 6.1 MB 37.0s", + "style": { + "font-family": "JetBrains Mono", + "font-size": 22, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4090909090909091 + } + ] + }, + "caret": { + "shape": "block", + "hide_when_done": true + } + } + ] + } + ] } ] } @@ -2272,4 +3140,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/examples/rustmotion-promo.json b/examples/rustmotion-promo.json index 728efce6..50943729 100644 --- a/examples/rustmotion-promo.json +++ b/examples/rustmotion-promo.json @@ -183,27 +183,423 @@ } }, { - "type": "codeblock", - "language": "html", - "theme": "tokyo-night", - "show_line_numbers": true, - "chrome": { - "enabled": true, - "title": "promo.html" - }, - "reveal": { - "mode": "typewriter", - "start": 0.6, - "duration": 3.4 - }, - "code": "\n \n

Ship Faster

\n

Built in Rust. No browser.

\n \n
\n
", + "type": "div", "style": { + "flex-direction": "column", + "background": "#1a1b26", + "overflow": "hidden", + "border-radius": 16, "width": 1280, - "height": 420, - "font-size": 26, - "padding": 28, - "border-radius": 16 - } + "height": 420 + }, + "children": [ + { + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 8, + "padding": { + "top": 10, + "right": 14, + "bottom": 10, + "left": 14 + }, + "background": "#16161e" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#ff5f56", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#ffbd2e", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#27c93f", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "text", + "content": "promo.html", + "style": { + "font-size": 13, + "color": "#8b949e", + "margin": { + "left": 8 + } + } + } + ] + }, + { + "type": "div", + "stagger": 0.4857142857142857, + "style": { + "flex-direction": "column", + "padding": 28, + "gap": 4 + }, + "children": [ + { + "type": "rich_text", + "spans": [ + { + "text": "<", + "color": "#c0caf5" + }, + { + "text": "rustmotion", + "color": "#f7768e" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "width=", + "color": "#e0af68" + }, + { + "text": "\"1920\"", + "color": "#9ece6a" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "height=", + "color": "#e0af68" + }, + { + "text": "\"1080\"", + "color": "#9ece6a" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "fps=", + "color": "#e0af68" + }, + { + "text": "\"30\"", + "color": "#9ece6a" + }, + { + "text": ">", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 26, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4857142857142857 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " <", + "color": "#c0caf5" + }, + { + "text": "scene", + "color": "#f7768e" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "duration=", + "color": "#e0af68" + }, + { + "text": "\"4\"", + "color": "#9ece6a" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "background=", + "color": "#e0af68" + }, + { + "text": "\"#0f172a\"", + "color": "#9ece6a" + }, + { + "text": ">", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 26, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4857142857142857 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " <", + "color": "#c0caf5" + }, + { + "text": "h1", + "color": "#f7768e" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "anim=", + "color": "#e0af68" + }, + { + "text": "\"fade-in-up\"", + "color": "#9ece6a" + }, + { + "text": ">Ship Faster", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 26, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4857142857142857 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " <", + "color": "#c0caf5" + }, + { + "text": "p", + "color": "#f7768e" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "anim=", + "color": "#e0af68" + }, + { + "text": "\"fade-in-up delay:0.3\"", + "color": "#9ece6a" + }, + { + "text": ">Built in Rust. No browser.", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 26, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4857142857142857 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " <", + "color": "#c0caf5" + }, + { + "text": "rm-counter", + "color": "#f7768e" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "from=", + "color": "#e0af68" + }, + { + "text": "\"0\"", + "color": "#9ece6a" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "to=", + "color": "#e0af68" + }, + { + "text": "\"1250\"", + "color": "#9ece6a" + }, + { + "text": " ", + "color": "#c0caf5" + }, + { + "text": "suffix=", + "color": "#e0af68" + }, + { + "text": "\"+\"", + "color": "#9ece6a" + }, + { + "text": ">", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 26, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4857142857142857 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": " ", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 26, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4857142857142857 + } + ] + } + }, + { + "type": "rich_text", + "spans": [ + { + "text": "", + "color": "#c0caf5" + } + ], + "style": { + "font-family": "JetBrains Mono", + "font-size": 26, + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.4857142857142857 + } + ] + } + } + ] + } + ] }, { "type": "text", @@ -629,41 +1025,167 @@ } }, { - "type": "terminal", - "title": "rustmotion", - "theme": "dark", - "reveal": { - "mode": "line_by_line", - "start": 0.6, - "duration": 2.6 + "type": "div", + "style": { + "flex-direction": "column", + "background": "#1E1E1E", + "border-radius": 10, + "overflow": "hidden", + "width": 1160, + "height": 360 }, - "lines": [ - { - "text": "rustmotion validate -f promo.json", - "line_type": "prompt" - }, - { - "text": "Warning: unknown attribute 'sufix' on 'counter'", - "line_type": "output" - }, - { - "text": " hint: did you mean 'suffix'?", - "line_type": "output" - }, + "children": [ { - "text": "", - "line_type": "output" + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 8, + "padding": { + "top": 10, + "right": 14, + "bottom": 10, + "left": 14 + }, + "background": "#2D2D2D" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#ff5f56", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#ffbd2e", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#27c93f", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "text", + "content": "rustmotion", + "style": { + "font-size": 13, + "color": "#808080", + "margin": { + "left": 8 + } + } + } + ] }, { - "text": "Valid scenario: 6 scene(s) · schema + geometry safe", - "line_type": "command" + "type": "div", + "stagger": 0.52, + "style": { + "flex-direction": "column", + "padding": 20, + "gap": 6 + }, + "children": [ + { + "type": "text", + "content": "$ rustmotion validate -f promo.json", + "style": { + "font-family": "JetBrains Mono", + "font-size": 24, + "color": "#22C55E", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + } + }, + { + "type": "text", + "content": "Warning: unknown attribute 'sufix' on 'counter'", + "style": { + "font-family": "JetBrains Mono", + "font-size": 24, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + } + }, + { + "type": "text", + "content": " hint: did you mean 'suffix'?", + "style": { + "font-family": "JetBrains Mono", + "font-size": 24, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + } + }, + { + "type": "text", + "content": " ", + "style": { + "font-family": "JetBrains Mono", + "font-size": 24, + "color": "#A0A0A0", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + } + }, + { + "type": "text", + "content": "Valid scenario: 6 scene(s) · schema + geometry safe", + "style": { + "font-family": "JetBrains Mono", + "font-size": 24, + "color": "#FFFFFF", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 0.52 + } + ] + }, + "caret": { + "shape": "block", + "hide_when_done": true + } + } + ] } - ], - "style": { - "width": 1160, - "height": 360, - "font-size": 24 - } + ] }, { "type": "text", @@ -741,25 +1263,103 @@ } }, { - "type": "terminal", - "title": "sh", - "theme": "dark", - "reveal": { - "mode": "typewriter", - "start": 0.5, - "duration": 1.4 + "type": "div", + "style": { + "flex-direction": "column", + "background": "#1E1E1E", + "border-radius": 10, + "overflow": "hidden", + "width": 720, + "height": 130 }, - "lines": [ + "children": [ { - "text": "cargo install rustmotion", - "line_type": "prompt" + "type": "div", + "style": { + "flex-direction": "row", + "align-items": "center", + "gap": 8, + "padding": { + "top": 10, + "right": 14, + "bottom": 10, + "left": 14 + }, + "background": "#2D2D2D" + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#ff5f56", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#ffbd2e", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#27c93f", + "style": { + "width": 12, + "height": 12 + } + }, + { + "type": "text", + "content": "sh", + "style": { + "font-size": 13, + "color": "#808080", + "margin": { + "left": 8 + } + } + } + ] + }, + { + "type": "div", + "stagger": 1.4, + "style": { + "flex-direction": "column", + "padding": 20, + "gap": 6 + }, + "children": [ + { + "type": "text", + "content": "$ cargo install rustmotion", + "style": { + "font-family": "JetBrains Mono", + "font-size": 28, + "color": "#22C55E", + "white-space": "pre", + "animation": [ + { + "name": "typewriter", + "duration": 1.4 + } + ] + }, + "caret": { + "shape": "block", + "hide_when_done": true + } + } + ] } - ], - "style": { - "width": 720, - "height": 130, - "font-size": 28 - } + ] }, { "type": "badge", @@ -782,4 +1382,4 @@ ] } ] -} \ No newline at end of file +}