diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 07e10293..c584463d 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -12,8 +12,8 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22
with:
components: rustfmt
- name: Check formatting
@@ -22,27 +22,74 @@ jobs:
clippy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Install system dependencies
# webkit/gtk/xdo: required to compile rustmotion-studio (dioxus desktop)
# asound: required by cpal, which rodio pulls in for preview audio
run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libwebkit2gtk-4.1-dev libgtk-3-dev libxdo-dev libasound2-dev
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22
with:
components: clippy
- - uses: Swatinem/rust-cache@v2
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Install system dependencies
# webkit/gtk/xdo: required to compile rustmotion-studio (dioxus desktop)
# asound: required by cpal, which rodio pulls in for preview audio
run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libwebkit2gtk-4.1-dev libgtk-3-dev libxdo-dev libasound2-dev
- - uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
+ - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Run tests
run: cargo test --workspace
+
+ audit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ - name: Install cargo-audit
+ run: cargo install cargo-audit --locked
+ # Blocking on any advisory not listed below — a newly introduced
+ # vulnerability fails this job. Every `--ignore` is a pre-existing
+ # transitive-dependency advisory tolerated today because the fix is a
+ # dependency version bump, and version bumps for the published
+ # `rustmotion` crate are being handled separately from this workstream
+ # (crates/rustmotion/Cargo.toml, orchestrator-owned). Unmaintained/
+ # unsound/yanked advisories (17 as of 2026-09-22) print but do not fail
+ # the job — that's `cargo audit`'s own default, left unchanged here.
+ #
+ # Review by 2026-12-22, or sooner once the dependency bumps land:
+ # RUSTSEC-2025-0008 — openh264-sys2 0.6.6, heap overflow in decoding.
+ # Direct dependency of the published `rustmotion` crate. Fix: openh264 >=0.8.0.
+ # RUSTSEC-2026-0204 — crossbeam-epoch 0.9.18, invalid pointer deref in `fmt::Pointer`.
+ # Via rayon-core <- exr <- image, reaches rustmotion-core/-components. Fix: >=0.9.20.
+ # RUSTSEC-2026-0195, RUSTSEC-2026-0194 — quick-xml 0.38.4 / 0.39.4, DoS + quadratic runtime.
+ # 0.38.4 via syntect reaches the published crates; 0.39.4 via dioxus-desktop/rfd is
+ # rustmotion-studio-only (Linux/Wayland file dialogs). Fix: >=0.41.0.
+ # RUSTSEC-2026-0285 — rustls 0.23.37, TLS 1.3 handshake level-boundary bug.
+ # Via ureq, used by rustmotion/rustmotion-core for Google Fonts + Iconify fetches. Fix: >=0.23.45.
+ # RUSTSEC-2026-0104, RUSTSEC-2026-0098, RUSTSEC-2026-0099, RUSTSEC-2026-0049 — rustls-webpki
+ # 0.103.9, four CRL/name-constraint parsing bugs. Same ureq path as rustls above.
+ # Fix: >=0.103.13,<0.104.0-alpha.1 (or the matching 0.104 alpha per advisory).
+ # RUSTSEC-2026-0257 — webbrowser 1.2.1, BROWSER env argument injection on Unix.
+ # Via dioxus-desktop, rustmotion-studio only (`publish = false`, never reaches a published
+ # crate). Fix: >=1.2.2.
+ - name: Audit dependencies
+ run: >
+ cargo audit
+ --ignore RUSTSEC-2025-0008
+ --ignore RUSTSEC-2026-0204
+ --ignore RUSTSEC-2026-0195
+ --ignore RUSTSEC-2026-0194
+ --ignore RUSTSEC-2026-0285
+ --ignore RUSTSEC-2026-0104
+ --ignore RUSTSEC-2026-0098
+ --ignore RUSTSEC-2026-0099
+ --ignore RUSTSEC-2026-0049
+ --ignore RUSTSEC-2026-0257
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index 8a70df11..c30152cd 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -9,7 +9,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Install system dependencies
# Doit rester identique à ci.yaml : l'étape « Run tests » ci-dessous lance
@@ -20,7 +20,7 @@ jobs:
# asound: required by cpal, which rodio pulls in for preview audio
run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libwebkit2gtk-4.1-dev libgtk-3-dev libxdo-dev libasound2-dev
- - uses: dtolnay/rust-toolchain@stable
+ - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable channel, 2026-09-22
# La version se lit via `cargo metadata`, pas en grepant un manifeste : elle
# est déclarée dans `[workspace.package]` et héritée, donc un `grep
diff --git a/README.md b/README.md
index f9b34b6a..c119f1d9 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@ A CLI tool that renders motion design videos from JSON scenarios. No browser, no
[](https://docs.rs/rustmotion)
[](LICENSE)
+MIT-licensed: no licence key, no telemetry, no per-render billing. See [Non-goals](docs/non-goals.md) for this and everything else rustmotion deliberately doesn't do (no embeddable Player/browser/React, no vendor-cloud deploy target, ...).
+
## Install
```bash
@@ -87,19 +89,117 @@ Once installed, Claude Code automatically loads the skills when you work in that
## CLI Reference
+### `rustmotion validate`
+
+Schema + geometry checks, with no render. This is the gate every generated scenario is expected to pass before use.
+
+| Flag | Description | Default |
+|---|---|---|
+| `-f, --file ` | Path to the JSON scenario file | (required) |
+| `--report ` | Write a machine-readable JSON report of all violations | |
+| `--fix` | Auto-fix safe violations in place (`auto_scroll: true`, drop `white-space` back to wrap, `text-autofit: true`) — refuses templated scenarios (`include`/`for-each`/`use`) | `false` |
+| `--strict-anim` | Sample animated frames and reapply renderer transforms to detect per-frame viewport overflow (slower) | `false` |
+| `--lenient` | Treat geometry violations as warnings instead of errors | `false` |
+| `--props ` | Load variable overrides from a JSON object file | |
+| `--var ` | Set a single variable override (repeatable); `--var` wins over `--props` | |
+
### `rustmotion render`
| Flag | Description | Default |
|---|---|---|
-| `input` | Path to the JSON scenario file | (required) |
+| `-f, --file ` | Path to the JSON scenario file (or `--json ` for inline input) | (required) |
| `-o, --output` | Output file path | `output.mp4` |
| `--frame ` | Render a single frame to PNG (0-indexed) | |
+| `--frames ` | Render only frames `START..=END` as a standalone segment with its own windowed audio slice, for joining later with `rustmotion concat`. Mutually exclusive with `--frame`/`--watch`; only mp4/webm/mov are implemented for a range | |
| `--codec ` | Video codec: `h264`, `h265`, `vp9`, `prores` | `h264` |
| `--crf <0-51>` | Constant Rate Factor (lower = better quality) | `23` |
| `--format ` | Output format: `mp4`, `webm`, `mov`, `gif`, `png-seq` | auto from extension |
| `--transparent` | Transparent background (PNG sequence, WebM, ProRes 4444) | `false` |
+| `--hardware-acceleration` | Probe `ffmpeg -encoders` and use this machine's hardware encoder (VideoToolbox/NVENC/QSV/AMF) when available; explicit message and software fallback otherwise | `false` |
+| `-w, --watch` | Watch the input file and re-render on change (not compatible with `--props`/`--var`) | `false` |
+| `--no-validate` | Skip the implicit validate pass (schema + geometry + variables) before rendering | `false` |
+| `--lenient` | Treat geometry violations as warnings during the implicit validate pass | `false` |
+| `--strict-anim` | Sample animated frames for per-frame viewport overflow during the implicit validate pass | `false` |
+| `--props ` | Load variable overrides from a JSON object file | |
+| `--var ` | Set a single variable override (repeatable); `--var` wins over `--props` | |
| `--output-format json` | Machine-readable JSON output for CI pipelines | |
| `-q, --quiet` | Suppress all output except errors | |
+| `--threads ` | Number of parallel rendering threads (global flag) | all cores |
+
+### `rustmotion concat`
+
+Joins segment files — e.g. several `render --frames a-b` outputs from the same scenario — via ffmpeg's concat demuxer (`-c copy`, no re-encoding). Requires ffmpeg on PATH.
+
+```bash
+rustmotion concat seg1.mp4 seg2.mp4 -o out.mp4
+```
+
+### `rustmotion still`
+
+Exports a single frame as a still image (PNG/JPEG/WebP).
+
+| Flag | Description | Default |
+|---|---|---|
+| `-f, --file ` | Path to the JSON scenario file | (required) |
+| `-o, --output` | Output file path | `still.png` |
+| `--time ` | Time to capture | `0.0` |
+| `--format ` | Image format: `png`, `jpeg`, `webp` | from extension |
+| `--quality <1-100>` | JPEG quality | `90` |
+| `--props` / `--var` | Variable overrides, same as `render` | |
+
+### `rustmotion captions`
+
+Generates word-level caption timings from audio (via a local `whisper.cpp` binary) or by importing subtitles.
+
+```bash
+rustmotion captions voice.mp3 -o words.json
+rustmotion captions --from-srt subs.srt -o words.json
+```
+
+| Flag | Description | Default |
+|---|---|---|
+| `audio` | Audio file to transcribe (mutually exclusive with `--from-srt`/`--from-vtt`) | |
+| `-o, --output` | Output JSON file (stdout if omitted) | |
+| `--model` | Whisper model name (`tiny`, `base`, `small`, `medium`, `large-v3`) or a path to a `.bin` | `base` |
+| `--lang` | Spoken language code (auto-detected if omitted) | |
+| `--from-srt` / `--from-vtt` | Import cues from a subtitle file instead of transcribing | |
+
+### `rustmotion batch`
+
+Renders one video per line of a JSONL data file, substituting each line's fields as variable overrides.
+
+| Flag | Description | Default |
+|---|---|---|
+| `-f, --file ` | Path to the scenario template (JSON or HTML dialect) | (required) |
+| `--data ` | JSONL file, one object of variable overrides per line | (required) |
+| `--output-dir ` | Directory to write output files into | (required) |
+| `--name-template` | Output filename template (`{field}`, `{index}`) | `"{index}.mp4"` |
+| `--codec` / `--crf` / `--format` / `--transparent` | Same as `render` | |
+| `--jobs ` | Videos to render in parallel (the render itself already uses all cores via rayon) | `1` |
+
+### `rustmotion schema`
+
+Prints the JSON Schema for scenario files (editor autocompletion, LLM prompts).
+
+```bash
+rustmotion schema -o schema.json
+```
+
+### `rustmotion info`
+
+Shows information about a scenario (duration, scene count, dimensions, ...).
+
+```bash
+rustmotion info scenario.json
+```
+
+### `rustmotion skills`
+
+Manages the built-in Claude Code skills — `install [--global]`, `uninstall [--global]`, `list`, `show `. See [Claude Code Skills](#claude-code-skills).
+
+### `rustmotion completions`
+
+Generates or installs shell completions — `install`, `uninstall`, `generate `. See [Shell Completions](#shell-completions).
---
@@ -123,7 +223,6 @@ Once installed, Claude Code automatically loads the skills when you work in that
"height": 1920,
"fps": 30,
"background": "#0f172a",
- "codec": "h264",
"crf": 23
}
}
@@ -135,7 +234,7 @@ Once installed, Claude Code automatically loads the skills when you work in that
| `height` | `u32` | (required) | Video height in pixels (must be even) |
| `fps` | `u32` | `30` | Frames per second |
| `background` | `string` | `"#000000"` | Default background color (hex) |
-| `codec` | `string` | `"h264"` | Video codec: `h264`, `h265`, `vp9`, `prores` |
+| `codec` | `string` | | Accepted by the schema (`h264`, `h265`, `vp9`, `prores`) but not yet read by the encoder — set the codec with `render --codec`/`batch --codec` instead |
| `crf` | `u8` | `23` | Constant Rate Factor (0-51, lower = better quality) |
### Audio Tracks
@@ -2025,42 +2124,42 @@ Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), an
- **JSON Schema:** schemars (auto-generated from Rust types)
- **Parallelism:** rayon (multi-threaded frame rendering)
-## Architecture
+rustmotion ships 60 components, each implementing the `Painter` trait, through a CSS-inspired **box_tree → layout_pass → paint_pass** pipeline:
-rustmotion uses a Flutter-inspired **measure → layout → paint** pipeline built on Skia:
+1. **box_tree** — builds a tree of `BoxNode { css: CssStyle, children, intrinsic }` from the resolved JSON components
+2. **layout_pass** — runs [taffy](https://github.com/DioxusLabs/taffy) to compute each node's `BoxLayout { x, y, width, height }`; leaves that carry an `IntrinsicMeasure` (text, images, codeblocks, ...) are measured through a `measure_fn`
+3. **paint_pass** — walks the tree top-down, applies transform/opacity, paints decorations (background, border, shadow), and delegates content painting to the component's `Painter` implementation
-```
-src/
-├── components/ # 51 components (each implements Widget trait)
-│ ├── chart/ # Chart sub-modules (bar, line, pie, radar, etc.)
-│ └── *.rs # One file per component
-├── engine/
-│ ├── render/ # Render pipeline (component, scene, background, transforms)
-│ ├── codeblock/ # Codeblock rendering (highlight, chrome, reveal, diff)
-│ ├── animator.rs # Animation resolver, easing, spring solver
-│ └── renderer.rs # Skia drawing primitives
-├── schema/ # Data models
-│ ├── scenario.rs # Scenario, View, Scene, VideoConfig
-│ ├── style.rs # LayerStyle, FontWeight, layout types
-│ ├── background.rs # Animated backgrounds
-│ ├── animation.rs # EasingType, presets
-│ └── video.rs # AnimationEffect, shapes, fills
-├── layout/ # Flex/grid layout engines
-├── traits/ # Widget, Styled, Animatable, Timed, Container
-└── macros.rs # impl_traits! macro
+```rust
+pub trait Painter {
+ fn paint_content(&self, canvas: &Canvas, layout: &BoxLayout, props: &AnimatedProperties, ctx: &PaintCtx);
+ fn intrinsic_size(&self, available: AvailableSize, ctx: &MeasureCtx) -> Option<(f32, f32)> { None }
+}
```
-Every component implements the `Widget` trait:
+`PaintCtx` carries `time`, `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`.
-```rust
-trait Widget {
- fn paint(&self, canvas: &Canvas, ctx: &PaintContext) -> Result<()>;
- fn measure(&self, constraints: &Constraints) -> (f32, f32);
- fn layout(&self, constraints: &Constraints) -> LayoutNode;
-}
+### Workspace layout
+
+```
+crates/
+├── rustmotion-core/src/
+│ ├── css/ # CssStyle, units, cascade, taffy bridge, animation resolution
+│ ├── engine/ # box_tree, layout_pass, paint_pass, animator, transitions, Skia primitives
+│ ├── schema/ # Scenario, Scene, VideoConfig, style, background, animation, codeblock models
+│ └── traits/ # Painter, Animatable, Timed, Styled
+├── rustmotion-components/src/
+│ ├── lib.rs # `Component` enum (60 variants) + dispatch (as_painter, as_animatable, ...)
+│ ├── box_builder.rs # JSON components → BuiltScene (box tree + stagger delays)
+│ ├── chart/ # bar/line/pie/radar/scatter/radial/funnel/waterfall sub-modules
+│ └── *.rs # one file per component (Painter implementation)
+└── rustmotion/src/
+ ├── cli/ # the `rustmotion` binary (clap subcommands)
+ ├── encode/ # video/audio encoders and muxing
+ └── loader.rs # JSON/HTML → ResolvedScenario
```
-`PaintContext` provides timing, layout dimensions, parent info, and resolved animated properties in a single struct.
+The `rustmotion` crate is where the binary lives — a crate with only a `[lib]` target installs nothing executable via `cargo install`.
## License
diff --git a/crates/rustmotion-components/Cargo.toml b/crates/rustmotion-components/Cargo.toml
index adf8f0e1..db478ee6 100644
--- a/crates/rustmotion-components/Cargo.toml
+++ b/crates/rustmotion-components/Cargo.toml
@@ -2,7 +2,7 @@
name = "rustmotion-components"
version.workspace = true
edition = "2021"
-description = "Component library for rustmotion (51 components)"
+description = "Component library for rustmotion (60 components)"
license = "MIT"
repository = "https://github.com/LeadcodeDev/rustmotion"
readme = "../../README.md"
diff --git a/crates/rustmotion-components/src/badge.rs b/crates/rustmotion-components/src/badge.rs
index 5e19ea9f..f4f2cdbb 100644
--- a/crates/rustmotion-components/src/badge.rs
+++ b/crates/rustmotion-components/src/badge.rs
@@ -441,7 +441,9 @@ mod tests {
// Solid variant text is always white — probe for white ink
// specifically, since the pill background paints regardless.
let text_ink = buf
- .chunks_exact(4)
+ .as_chunks::<4>()
+ .0
+ .iter()
.filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200)
.count();
assert!(
diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs
index 21d151ff..f5ab158b 100644
--- a/crates/rustmotion-components/src/box_builder.rs
+++ b/crates/rustmotion-components/src/box_builder.rs
@@ -636,7 +636,7 @@ fn build_child<'a>(
time_remap,
&css,
);
- let intrinsic = component_intrinsic(&child.component);
+ let intrinsic = component_intrinsic(&child.component, &css);
let principal = BoxNode {
id,
@@ -1131,9 +1131,22 @@ fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::Ani
/// Build an [`IntrinsicMeasure`] for components whose box size depends on
/// their content (text, codeblock, terminal, etc.). Returns `None` for
/// components with explicit dimensions or pure containers.
+///
+/// `cascaded_css` is this node's own `CssStyle` after `cascade::inherit_from`
+/// has already merged it against the parent, plus every subsequent overlay
+/// (timeline states, animation) — the exact same value `LegacyPaintDispatcher`
+/// receives at paint time. `Component::with_cascaded_style` folds it into
+/// whichever component variants read inherited typography off their own
+/// style before this function's match ever sees them, so the reserved box
+/// always matches what those components' painters (which fold the same
+/// cascade in at paint time) actually draw.
fn component_intrinsic(
component: &Component,
+ cascaded_css: &CssStyle,
) -> Option> {
+ let cascaded_component = component.with_cascaded_style(cascaded_css);
+ let component = cascaded_component.as_ref().unwrap_or(component);
+
use Component::*;
match component {
Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))),
@@ -1495,7 +1508,9 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
css.width = Some(CSize::Length(CLP::Px(c.width)));
}
if css.height.is_none() {
- let font_size = c.style.font_size_px_or(16.0);
+ let font_size = c
+ .style
+ .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0);
let line_height = font_size * 1.3;
let n = c.items.len() as f32;
let h = n * line_height + (n - 1.0).max(0.0) * c.gap;
@@ -1640,7 +1655,9 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// box is fit exactly to the unwrapped text width, the painter's
// own `wrap_text(text, font, Some(text_area_w))` never has a
// reason to wrap, so painted output matches this box exactly.
- let font_size = t.style.font_size_px_or(16.0);
+ let font_size = t
+ .style
+ .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0);
let family = t.style.font_family_or("Inter");
let text_w = measure_text_line_width(&t.text, font_size, family, false);
let h_pad = 12.0; // callout.rs's own `let padding = 12.0;`
@@ -1660,7 +1677,10 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// Same shape as Callout above; padding value borrowed from
// callout.rs since tooltip.rs's own paint() centers text in the
// body with no defined constant of its own.
- let font_size = t.style.font_size_px_or(t.font_size);
+ let font_size = t.style.font_size_px_ctx(
+ &crate::intrinsic::measure_time_font_size_ctx(0.0),
+ t.font_size,
+ );
let family = t.style.font_family_or("Inter");
let text_w = measure_text_line_width(&t.text, font_size, family, false);
let h_pad = 12.0;
@@ -1685,7 +1705,9 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// formula (h_pad = font_size*1.2 per side, `gap` before/after/
// between every pill) using the same public fields and the same
// `measure_text_with_fallback` call it makes internally.
- let font_size = p.style.font_size_px_or(14.0);
+ let font_size = p
+ .style
+ .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 14.0);
let family = p.style.font_family_or("Inter");
let h_pad = font_size * 1.2;
let n = p.items.len() as f32;
@@ -1713,7 +1735,10 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) {
// height ratio both of those same real usages share:
// `font_size: 24` paired with `style.height: 48`, i.e.
// `2 × font_size`.
- let font_size = m.style.font_size_px_or(m.font_size);
+ let font_size = m.style.font_size_px_ctx(
+ &crate::intrinsic::measure_time_font_size_ctx(0.0),
+ m.font_size,
+ );
apply_default_size(css, 800.0, font_size * 2.0);
}
Stepper(s) => {
@@ -2125,7 +2150,7 @@ pub fn component_kind(c: &Component) -> &'static str {
Particle(_) => "particle",
PillNav(_) => "pill_nav",
Progress(_) => "progress",
- QrCode(_) => "qrcode",
+ QrCode(_) => "qr_code",
NumberWheel(_) => "number_wheel",
SuccessCheck(_) => "success_check",
Pointer(_) => "pointer",
@@ -2147,7 +2172,11 @@ pub fn component_kind(c: &Component) -> &'static str {
Flex(_) => "flex",
Grid(_) => "grid",
Card(_) => "card",
- Container(_) => "container",
+ // 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(_) => "div",
AudioSpectrum(_) => "audio_spectrum",
Waveform(_) => "waveform",
}
diff --git a/crates/rustmotion-components/src/callout.rs b/crates/rustmotion-components/src/callout.rs
index 22e8bf97..8092d4aa 100644
--- a/crates/rustmotion-components/src/callout.rs
+++ b/crates/rustmotion-components/src/callout.rs
@@ -267,7 +267,9 @@ mod tests {
// for near-white ink specifically, since the bubble background
// paints regardless of font-size.
let text_ink = buf
- .chunks_exact(4)
+ .as_chunks::<4>()
+ .0
+ .iter()
.filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200)
.count();
assert!(
diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs
index ebe1108e..e67af735 100644
--- a/crates/rustmotion-components/src/counter.rs
+++ b/crates/rustmotion-components/src/counter.rs
@@ -403,7 +403,7 @@ mod tests {
skia_safe::image::CachingHint::Disallow,
);
assert!(ok, "pixel read should succeed");
- let lit = buf.chunks_exact(4).filter(|p| p[3] > 0).count();
+ let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count();
assert!(
lit > 20,
"counter at font-size: 2rem must paint visible ink, got {lit} lit pixels"
diff --git a/crates/rustmotion-components/src/gif.rs b/crates/rustmotion-components/src/gif.rs
index e1738e04..5a0d918d 100644
--- a/crates/rustmotion-components/src/gif.rs
+++ b/crates/rustmotion-components/src/gif.rs
@@ -86,8 +86,42 @@ fn clear_rect(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Fr
/// `gif_cache` stores.
type DecodedGif = (Vec<(Vec, u32, u32)>, Vec, f64);
+/// Artificial per-decode stall, settable only from this file's own tests
+/// (`DECODE_STALL_MS`) to open a deterministic race window around the
+/// cache-miss branch without timing-dependent sleeps sprinkled through the
+/// test itself. Zero by default, and compiled out entirely in a non-test
+/// build — no production cost.
+#[cfg(test)]
+static DECODE_STALL_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+
+#[cfg(test)]
+fn stall_decode_for_tests() {
+ let stall_ms = DECODE_STALL_MS.load(std::sync::atomic::Ordering::SeqCst);
+ if stall_ms > 0 {
+ std::thread::sleep(std::time::Duration::from_millis(stall_ms));
+ }
+}
+
+/// Hard ceiling on one composed GIF canvas frame's byte size
+/// (`width × height × 4`), independent of the render's own video dimensions.
+/// A GIF's logical-screen descriptor is two `u16` fields straight from the
+/// file header — up to 65535×65535, a 17.2 GiB single allocation — and
+/// nothing validated them before this cap existed.
+const MAX_GIF_CANVAS_BYTES: u64 = 128 * 1024 * 1024;
+
+/// Hard ceiling on the number of animation frames one GIF decodes into.
+/// Real GIFs rarely exceed a few hundred; this keeps a maliciously (or just
+/// accidentally) long frame count from growing the decoded frame list
+/// without bound even when each individual frame is well under the canvas
+/// budget above.
+const MAX_GIF_FRAMES: usize = 600;
+
/// Decode a GIF into full-canvas RGBA frames, their cumulative end times, and
-/// the total duration.
+/// the total duration. `max_canvas_w`/`max_canvas_h` are the render's own
+/// video dimensions: a GIF canvas larger than that can never be usefully
+/// drawn (it only ever gets scaled into the component's layout box, which is
+/// at most the video frame), so it is rejected the same way an
+/// over-budget canvas is.
///
/// Every frame after the first is usually a *sub-rectangle* holding only the
/// pixels that changed, so frames must be composed onto a persistent canvas
@@ -97,7 +131,7 @@ type DecodedGif = (Vec<(Vec, u32, u32)>, Vec, f64);
/// why only the first frame ever appeared (issue #185).
///
/// `None` means nothing can be drawn, and the reason has already been reported.
-fn decode_composed_frames(src: &str) -> Option {
+fn decode_composed_frames(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option {
let file = match std::fs::File::open(src) {
Ok(f) => f,
Err(e) => {
@@ -123,12 +157,37 @@ fn decode_composed_frames(src: &str) -> Option {
let canvas_w = decoder.width() as u32;
let canvas_h = decoder.height() as u32;
+ let canvas_bytes = canvas_w as u64 * canvas_h as u64 * 4;
+ if canvas_bytes > MAX_GIF_CANVAS_BYTES || canvas_w > max_canvas_w || canvas_h > max_canvas_h {
+ if crate::warn_once_for(&format!("gif-oversized:{src}")) {
+ eprintln!(
+ "rustmotion: gif '{src}' declares a {canvas_w}x{canvas_h} canvas ({canvas_bytes} \
+ bytes/frame), over the {MAX_GIF_CANVAS_BYTES}-byte budget or larger than this \
+ render's own {max_canvas_w}x{max_canvas_h} video — refusing to decode it."
+ );
+ }
+ return None;
+ }
+
+ #[cfg(test)]
+ stall_decode_for_tests();
+
let mut frames: Vec<(Vec, u32, u32)> = Vec::new();
let mut cumulative_times: Vec = Vec::new();
let mut accumulated = 0.0;
let mut composed = vec![0u8; canvas_w as usize * canvas_h as usize * 4];
while let Ok(Some(frame)) = decoder.read_next_frame() {
+ if frames.len() >= MAX_GIF_FRAMES {
+ if crate::warn_once_for(&format!("gif-frame-cap:{src}")) {
+ eprintln!(
+ "rustmotion: gif '{src}' has more than {MAX_GIF_FRAMES} frames — truncating \
+ the decoded animation at the cap."
+ );
+ }
+ break;
+ }
+
// `Previous` disposal restores what was there before this frame, so it
// has to be captured before compositing.
let restore = (frame.dispose == gif::DisposalMethod::Previous).then(|| composed.clone());
@@ -163,6 +222,25 @@ fn decode_composed_frames(src: &str) -> Option {
Some((frames, cumulative_times, accumulated))
}
+/// Cache lookup with the actual decode folded in, single-flighted through
+/// `DashMap::entry`: a vacant entry holds its shard's write lock for as long
+/// as the closure runs, so a second caller racing the same cache-cold `src`
+/// blocks on that lock instead of starting its own redundant decode. Decode
+/// failure (`decode_composed_frames` returning `None`) leaves the entry
+/// vacant — `or_try_insert_with` never calls `insert` on its `Err` path —
+/// so a broken source is retried rather than permanently cached as absent.
+fn cached_decode(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option> {
+ gif_cache()
+ .entry(src.to_string())
+ .or_try_insert_with(|| {
+ decode_composed_frames(src, max_canvas_w, max_canvas_h)
+ .map(Arc::new)
+ .ok_or(())
+ })
+ .ok()
+ .map(|entry| entry.clone())
+}
+
impl Painter for Gif {
fn paint_content(
&self,
@@ -171,17 +249,8 @@ impl Painter for Gif {
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
- let gcache = gif_cache();
-
- let cached = if let Some(cached) = gcache.get(&self.src) {
- cached.clone()
- } else {
- let Some(decoded) = decode_composed_frames(&self.src) else {
- return;
- };
- let cached = Arc::new(decoded);
- gcache.insert(self.src.clone(), cached.clone());
- cached
+ let Some(cached) = cached_decode(&self.src, ctx.video_width, ctx.video_height) else {
+ return;
};
let (ref frames, ref cumulative_times, total_duration) = *cached;
@@ -257,7 +326,8 @@ mod tests {
write_two_frame_gif(&path);
let (frames, times, total) =
- decode_composed_frames(path.to_str().expect("utf-8 path")).expect("gif must decode");
+ decode_composed_frames(path.to_str().expect("utf-8 path"), 1920, 1080)
+ .expect("gif must decode");
std::fs::remove_file(&path).ok();
assert_eq!(frames.len(), 2, "both frames must be drawable");
@@ -287,7 +357,7 @@ mod tests {
#[test]
fn a_missing_file_reports_instead_of_returning_nothing() {
let missing = std::env::temp_dir().join("rustmotion_gif_absent_xyz.gif");
- assert!(decode_composed_frames(missing.to_str().expect("utf-8")).is_none());
+ assert!(decode_composed_frames(missing.to_str().expect("utf-8"), 1920, 1080).is_none());
// The warn-once slot must have been claimed — silence is the bug.
assert!(
!crate::warn_once_for(&format!("gif-open:{}", missing.to_str().expect("utf-8"))),
@@ -302,4 +372,182 @@ mod tests {
frame.top = 3;
assert_eq!(frame_rect(4, 4, &frame), (3, 3, 1, 1));
}
+
+ /// Releases `DECODE_STALL_MS` back to zero even if the test body
+ /// panics mid-assertion, so a failing run never leaks a stall into
+ /// whatever other test in this binary decodes a GIF next.
+ struct StallGuard;
+
+ impl Drop for StallGuard {
+ fn drop(&mut self) {
+ DECODE_STALL_MS.store(0, std::sync::atomic::Ordering::SeqCst);
+ }
+ }
+
+ /// N callers racing a cache-cold `src` must decode it once, not N
+ /// times. A 120ms stall (`DECODE_STALL_MS`) right after the header is
+ /// read opens a race window wide enough that every thread reaches the
+ /// cache-miss branch before any of them can finish decoding and insert —
+ /// pre-fix, that means eight independent decodes, each producing its own
+ /// `Arc` allocation. Comparing pointers (not content — a deterministic
+ /// decode produces byte-identical content either way) is what proves
+ /// only one of the eight actually ran.
+ #[test]
+ fn concurrent_paints_of_the_same_uncached_gif_decode_exactly_once() {
+ let _guard = StallGuard;
+ DECODE_STALL_MS.store(120, std::sync::atomic::Ordering::SeqCst);
+
+ let path = std::env::temp_dir().join(format!(
+ "rustmotion_gif_stampede_{}_{}.gif",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos(),
+ ));
+ write_two_frame_gif(&path);
+ let src = path.to_str().expect("utf-8 path").to_string();
+
+ const THREADS: usize = 8;
+ let barrier = std::sync::Arc::new(std::sync::Barrier::new(THREADS));
+ let handles: Vec<_> = (0..THREADS)
+ .map(|_| {
+ let barrier = barrier.clone();
+ let src = src.clone();
+ std::thread::spawn(move || {
+ barrier.wait();
+ cached_decode(&src, 4, 2)
+ })
+ })
+ .collect();
+
+ let results: Vec
"##;
+ let v = html_to_scenario_value(html).expect("