From 76284513352251696cf93aa2cea0a144d8a67c9d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 22 Sep 2026 11:09:44 +0200 Subject: [PATCH] ci(workflow): gate the build on cargo audit Refs #220 --- .github/workflows/ci.yaml | 47 ++++++ README.md | 158 ++++++++++++++---- crates/rustmotion-components/Cargo.toml | 2 +- crates/rustmotion-core/tests/audit_ws_k.rs | 90 ++++++++++ .../skills/rules/card-flex-layout.md | 32 +++- 5 files changed, 293 insertions(+), 36 deletions(-) create mode 100644 crates/rustmotion-core/tests/audit_ws_k.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07e10293..e7db3a5f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,3 +46,50 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run tests run: cargo test --workspace + + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - 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/README.md b/README.md index f9b34b6a..15f3dd20 100644 --- a/README.md +++ b/README.md @@ -87,19 +87,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). --- @@ -2025,42 +2123,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-core/tests/audit_ws_k.rs b/crates/rustmotion-core/tests/audit_ws_k.rs new file mode 100644 index 00000000..3a5d801f --- /dev/null +++ b/crates/rustmotion-core/tests/audit_ws_k.rs @@ -0,0 +1,90 @@ +//! Regression tests for the workstream K (docs, schema, CI) audit findings: +//! documentation drift and inert schema fields. + +/// Text immediately following the component count in the README's +/// Architecture section (see `README.md`'s "rustmotion ships N components, +/// each implementing the `Painter` trait" sentence). +const README_COUNT_MARKER: &str = " components, each implementing the `Painter` trait"; + +/// Text immediately following the component count in +/// `crates/rustmotion-components/Cargo.toml`'s `description` — the string +/// crates.io displays for the published crate. +const CARGO_TOML_COUNT_MARKER: &str = " components)\""; + +/// Read the integer that appears immediately before `marker` in `haystack`, +/// skipping trailing whitespace. Panics with the marker text on failure so a +/// reworded sentence names exactly what moved instead of a bare parse error. +fn number_before(haystack: &str, marker: &str, haystack_name: &str) -> u32 { + let idx = haystack.find(marker).unwrap_or_else(|| { + panic!( + "marker {marker:?} not found in {haystack_name} — did the component count sentence \ + move or get reworded? Update this test's marker to match." + ) + }); + let digits: String = haystack[..idx] + .chars() + .rev() + .skip_while(|c| c.is_whitespace()) + .take_while(|c| c.is_ascii_digit()) + .collect(); + let digits: String = digits.chars().rev().collect(); + digits.parse().unwrap_or_else(|_| { + panic!("no number found immediately before marker {marker:?} in {haystack_name}") + }) +} + +/// Count the variants of `pub enum Component` in +/// `crates/rustmotion-components/src/lib.rs`, by counting non-empty, +/// non-attribute lines between its opening `{` and closing `}`. Every +/// variant in that enum is declared on its own line (`Name(Type),`); the +/// only other lines in the block are `#[serde(...)]` attributes. +fn count_component_variants(lib_rs: &str) -> usize { + let start_marker = "pub enum Component {"; + let start = lib_rs.find(start_marker).unwrap_or_else(|| { + panic!("{start_marker:?} not found in rustmotion-components/src/lib.rs") + }) + start_marker.len(); + let rest = &lib_rs[start..]; + let end = rest + .find("\n}") + .expect("no closing '}' found for `pub enum Component` block"); + let body = &rest[..end]; + body.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .count() +} + +/// README.md and `rustmotion-components/Cargo.toml` (the text +/// crates.io shows for the published crate) both claimed "51 components" +/// while `Component` actually had 60 variants — and nothing kept the two in +/// sync. Locks the documented counts to the real one so a future component +/// addition/removal that forgets to update the docs fails CI instead of +/// drifting silently again. +#[test] +fn documented_component_count_matches_enum_variant_count() { + let lib_rs = include_str!("../../rustmotion-components/src/lib.rs"); + let actual = count_component_variants(lib_rs); + assert!( + actual > 0, + "found zero variants in `pub enum Component` — count parsing is broken" + ); + + let readme = include_str!("../../../README.md"); + let readme_count = number_before(readme, README_COUNT_MARKER, "README.md"); + assert_eq!( + readme_count as usize, actual, + "README.md claims {readme_count} components but `Component` has {actual} variants" + ); + + let cargo_toml = include_str!("../../rustmotion-components/Cargo.toml"); + let cargo_toml_count = number_before( + cargo_toml, + CARGO_TOML_COUNT_MARKER, + "rustmotion-components/Cargo.toml", + ); + assert_eq!( + cargo_toml_count as usize, actual, + "rustmotion-components/Cargo.toml's description claims {cargo_toml_count} components but \ + `Component` has {actual} variants" + ); +} diff --git a/crates/rustmotion/skills/rules/card-flex-layout.md b/crates/rustmotion/skills/rules/card-flex-layout.md index e22eb345..c2908579 100644 --- a/crates/rustmotion/skills/rules/card-flex-layout.md +++ b/crates/rustmotion/skills/rules/card-flex-layout.md @@ -37,15 +37,37 @@ Children flow in the flexbox. Use `positioned` container for absolute positionin **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). -## 23 component types have no intrinsic size — they need explicit `width`/`height` +## 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 neither** (verified against `crates/rustmotion-components/src/box_builder.rs`'s `component_intrinsic` and `apply_intrinsic_overrides` — both are exhaustive `match`es and these fall through to their `_ => None` / `_ => {}` arms, and no component overrides `Painter::intrinsic_size` either): +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: -`shape`, `image`, `icon`, `svg`, `video`, `gif`, `callout`, `chart`, `comparison`, `dot_map`, `gauge`, `heatmap`, `lottie`, `marquee`, `mockup`, `pill_nav`, `skeleton`, `sparkline`, `stat`, `stepper`, `tag_cloud`, `tooltip`, `treemap` +| Component | Default size | Where it comes from | +|---|---|---| +| `sparkline` | 120×40 | fixed convention | +| `stat` | 280×180 | fixed convention | +| `gauge` | square, `2·(88 + track_width/2 + 4)` | derived from its own `track_width` field | +| `dot_map` | 640×320 (2:1) | fixed (equirectangular aspect) | +| `comparison` | 520×280 | fixed convention | +| `treemap` | 416×368 | fixed convention | +| `chart` | 320×320 (pie/donut/radar/radial_bar) or 400×300 (other 8 types) | fixed, by chart shape | +| `mockup` | per `device` (e.g. 320×690 for iphone/android, 640×400 laptop, 640×360 browser) | fixed, by device aspect | +| `icon` | 64×64 | fixed convention | +| `svg` | 200×200 | fixed convention | +| `shape` | 80×80 | fixed convention | +| `image` | 400×300 (4:3) | fixed convention | +| `video`, `gif` | 400×225 (16:9) | fixed convention | +| `lottie` | 300×300 | fixed convention | +| `skeleton` | 400×200 (rectangle) / 64×64 (circle) / `240×(lines·line_height + gaps)` (text) | fixed, or derived from its own `lines`/`line_height`/`line_gap` for the `text` variant | +| `marquee` | 800×`2·font_size` | fixed width, height derived from its own `font_size` | +| `heatmap` | derived from `data` rows/cols and `cell_size`/`cell_gap` | fully content-derived | +| `callout`, `tooltip` | derived from the measured text width + padding + arrow | fully content-derived | +| `pill_nav` | derived from each item's measured label width + padding + gap | fully content-derived | +| `stepper` | derived from `node_size` and each step's label/description width | fully content-derived | +| `tag_cloud` | derived from each tag's weighted font size, wrapped at a conventional content width | fully content-derived | -As a flex/grid child with no explicit `style.width`/`style.height`, any of these lays out at **0×0 and renders nothing** — not a smaller-than-expected box, no pixels at all. Confirmed by rendering: three `stat`s in a flex-row card with no explicit size produce a blank frame. `rustmotion validate` does not flag this (a 0×0 box doesn't overflow anything). +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. -Always give these components explicit `style.width`/`style.height` (or a fixed size via their own dedicated `size` field where one exists, e.g. `qr_code`'s `size`) wherever they're a flow child of a `card`/`flex`/`grid` — not just when the parent has `height: "auto"`. If a component silently doesn't render, check this list before assuming a schema-field-placement bug (see [rules/component-field-placement.md](rules/component-field-placement.md) for that other, more common cause of invisible components). +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. **GOOD** (icon + text row): ```json