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
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
32 changes: 27 additions & 5 deletions crates/rustmotion/skills/rules/card-flex-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading