Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
158 changes: 128 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FILE>` | Path to the JSON scenario file | (required) |
| `--report <FILE>` | 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 <FILE>` | Load variable overrides from a JSON object file | |
| `--var <KEY=VALUE>` | 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 <FILE>` | Path to the JSON scenario file (or `--json <STRING>` for inline input) | (required) |
| `-o, --output` | Output file path | `output.mp4` |
| `--frame <N>` | Render a single frame to PNG (0-indexed) | |
| `--frames <START-END>` | 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 <CODEC>` | Video codec: `h264`, `h265`, `vp9`, `prores` | `h264` |
| `--crf <0-51>` | Constant Rate Factor (lower = better quality) | `23` |
| `--format <FMT>` | 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 <FILE>` | Load variable overrides from a JSON object file | |
| `--var <KEY=VALUE>` | 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 <N>` | 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 <FILE>` | Path to the JSON scenario file | (required) |
| `-o, --output` | Output file path | `still.png` |
| `--time <SECONDS>` | Time to capture | `0.0` |
| `--format <FMT>` | 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 <FILE>` | Path to the scenario template (JSON or HTML dialect) | (required) |
| `--data <FILE>` | JSONL file, one object of variable overrides per line | (required) |
| `--output-dir <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 <N>` | 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 <name>`. See [Claude Code Skills](#claude-code-skills).

### `rustmotion completions`

Generates or installs shell completions — `install`, `uninstall`, `generate <shell>`. See [Shell Completions](#shell-completions).

---

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion-components/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
90 changes: 90 additions & 0 deletions crates/rustmotion-core/tests/audit_ws_k.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
Loading
Loading