Skip to content
Merged
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
17 changes: 15 additions & 2 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ For each scene in the validated plan:

1. Assemble the complete JSON with all scenes
2. Run final `rustmotion validate`
3. Render with `rustmotion render -o output.mp4 --quiet`
3. Render with `rustmotion render -f scenario.json -o output.mp4 --quiet`
4. Suggest `--codec prores` for videos with dark gradients

### Design guidelines
Expand Down Expand Up @@ -1523,10 +1523,23 @@ Text with animated gradient fill.
}
```

**Root fields:** `content` (required), `colors` (array of hex, default ["#3B82F6", "#8B5CF6"]), `angle` (90 — gradient angle in degrees), `animate_angle` (false — rotate gradient over time), `speed` (0.5 — rotations/sec when animate_angle), `size`
**Root fields:** `content` (required), `colors` (array of hex, default ["#3B82F6", "#8B5CF6"]), `stops`, `angle` (90), `animate_angle` (false — rotate gradient over time), `speed` (0.5 — rotations/sec when animate_angle), `size`

Style: `font-size`, `font-weight`, `font-family`

`angle` follows the CSS convention: `0` points up, `90` points right, growing clockwise.

`stops` places each colour explicitly instead of spreading `colors` evenly along the gradient line. The position key is **`position`**, not the `offset` a background's stops use — the two vocabularies differ, and the validator refuses the wrong one:

```json
{ "type": "gradient_text", "content": "Rustmotion", "angle": 90,
"stops": [
{ "color": "#7C3AED", "position": 0 },
{ "color": "#EC4899", "position": 0.7 },
{ "color": "#F59E0B", "position": 1 }
] }
```

### `treemap`

Space-filling rectangles proportional to values.
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion/skills/rules/chromatic-aberration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
```json
{
"type": "icon",
"name": "zap",
"icon": "lucide:zap",
"style": {
"animation": [{
"name": "chromatic_aberration",
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion/skills/rules/dynamic-depth.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Each element gets a different `seed`. Because seeds produce different noise curv
"border-radius": 28,
"box-shadow": [{ "color": "#00000060", "offset-y": 40, "blur": 80 }],
"animation": [
{ "name": "scale_in", "duration": 0.7, "easing": "ease_out" },
{ "name": "scale_in", "duration": 0.7 },
{ "name": "float_3d", "loop": true }
]
}
Expand Down
10 changes: 5 additions & 5 deletions crates/rustmotion/skills/rules/geometry-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at t

## What the validator catches

`rustmotion validate scenario.json` reports four geometry violation kinds:
`rustmotion validate -f 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
Expand All @@ -54,10 +54,10 @@ CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at t
## CLI usage

```bash
rustmotion validate scenario.json # human-readable
rustmotion validate scenario.json --report report.json # JSON report
rustmotion validate scenario.json --fix # safe auto-fixes
rustmotion validate scenario.json --strict-anim # per-frame check, adds animated_text_overflow
rustmotion validate -f scenario.json # human-readable
rustmotion validate -f scenario.json --report report.json # JSON report
rustmotion validate -f scenario.json --fix # safe auto-fixes
rustmotion validate -f scenario.json --strict-anim # per-frame check, adds animated_text_overflow
rustmotion validate scenario.json --lenient # warnings only
```

Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion/skills/rules/gradient-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Or use a named template with `$ref` for reuse across scenes:
```json
{
"backgrounds": {
"dark_radial": { "colors": ["#0f172a", "#1e1b4b", "#0f172a"], "speed": 20, "gradient_type": "radial" }
"dark_radial": { "preset": "gradient_shift", "colors": ["#0f172a", "#1e1b4b", "#0f172a"], "speed": 20, "gradient_type": "radial" }
},
"scenes": [
{ "duration": 5, "background": { "$ref": "dark_radial" } }
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion/skills/rules/motion-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Pour faire suivre une trajectoire à un composant — une courbe, un arc, un tra

| Champ | Rôle |
|---|---|
| `path` | Données de chemin SVG (`M`/`L`/`H`/`V`/`C`/`S`/`Q`/`T`/`A`/`Z`) — **la même syntaxe** que `shape: { "type": "path", "data": ... }` |
| `path` | Données de chemin SVG (`M`/`L`/`H`/`V`/`C`/`S`/`Q`/`T`/`A`/`Z`) — **la même syntaxe** que `shape: { "path": { "data": … } }` |
| `delay`, `duration` | Fenêtre temporelle, comme tout autre effet |
| `loop` | Reprend au début à la fin du parcours |
| `orient` | Oriente le composant selon la tangente |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Deux champs propres au composant `shape`, pas des propriétés `style.animation`
```json
{
"type": "shape",
"shape": { "type": "path", "data": "M0 80 C 20 40, 40 40, 50 10 C 60 40, 80 40, 90 80" },
"shape": { "path": { "data": "M0 80 C 20 40, 40 40, 50 10 C 60 40, 80 40, 90 80" } },
"stroke": { "color": "#F68F2B", "width": 6 },
"draw_start": 0.5,
"style": {
Expand All @@ -23,10 +23,10 @@ Deux champs propres au composant `shape`, pas des propriétés `style.animation`
}
```

`draw_start` accepte un nombre littéral ou une expression `"= …"` (même grammaire que `stroke.dash_offset`), réévaluée chaque frame contre `t`/`t_abs`/`duration`/`width`/`height`/`fps`. Pour un segment qui « marche » le long du trait :
`draw_start` accepte un nombre littéral ou une expression `"= …"` (même grammaire que `stroke.dash_offset`), réévaluée chaque frame contre `$t`/`$t_abs`/`$duration`/`$width`/`$height`/`$fps`. Pour un segment qui « marche » le long du trait :

```json
"draw_start": "= max(0, (t - 0.9) / 0.5)"
"draw_start": "= max(0, ($t - 0.9) / 0.5)"
```

### Ce n'est pas une propriété `keyframes` comme `draw_progress`
Expand All @@ -51,7 +51,7 @@ Un `draw_start >= draw_progress` (fenêtre vide ou inversée) ne peint rien —
```json
{
"type": "shape",
"shape": { "type": "path", "data": "M10 80 C 20 40, 40 40, 50 10 C 60 40, 80 40, 90 80 Z" },
"shape": { "path": { "data": "M10 80 C 20 40, 40 40, 50 10 C 60 40, 80 40, 90 80 Z" } },
"fill": "#8B5CF6",
"path_morph": {
"keyframes": [
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion/skills/rules/text-component-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ La ligne du dégradé est aussi recalculée : c'est la projection CSS de la boî

## gradient_text : stops explicites

Nouveau champ optionnel `stops`, au même format que les stops de `view.background` en `linear-gradient` :
Champ optionnel `stops`. Il ne partage pas la clé des stops d'un fond : `gradient_text` nomme la position `position`, un `background` la nomme `offset`. Les deux sont une fraction `0..1` de la ligne du dégradé — seul le nom diffère, et le validateur refuse l'autre.

```json
{ "type": "gradient_text", "content": "Rustmotion", "angle": 90,
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion/skills/rules/whip-transition.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ L'effet : un `slide` classique le long d'un axe, dont le déplacement porte un f
"direction": "left",
"strength": 1.5,
"duration": 0.35,
"easing": "ease_in_out_cubic"
"easing": "ease_in_out"
}
}
```
Expand Down
4 changes: 2 additions & 2 deletions crates/rustmotion/src/cli/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ mod schema;
mod sheet;
mod still;
mod validate;
mod validate_attrs;
mod validate_schema;
pub mod validate_attrs;
pub mod validate_schema;
pub mod validation;

pub use batch::cmd_batch;
Expand Down
3 changes: 3 additions & 0 deletions crates/rustmotion/src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
mod claude_md;
mod commands;

pub use commands::validate_attrs::check_component_attrs;
pub use commands::validate_schema::validate_scenario;
mod skills;
mod tui;

Expand Down
164 changes: 164 additions & 0 deletions crates/rustmotion/tests/skill_examples_validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
use std::path::{Path, PathBuf};

fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.expect("rustmotion is expected at <workspace>/crates/rustmotion")
.to_path_buf()
}

fn skill_documents() -> Vec<PathBuf> {
let skills = workspace_root().join("crates/rustmotion/skills");
let mut files = vec![skills.join("SKILL.md")];
let mut rules: Vec<PathBuf> = std::fs::read_dir(skills.join("rules"))
.expect("rules directory")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "md"))
.collect();
rules.sort();
files.extend(rules);
files
}

struct Example {
file: String,
line: usize,
body: String,
marked_bad: bool,
}

fn json_examples(path: &Path) -> Vec<Example> {
let text = std::fs::read_to_string(path).expect("read skill document");
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
let mut out = Vec::new();
let mut cursor = 0usize;
while let Some(open) = text[cursor..].find("```json\n") {
let start = cursor + open + "```json\n".len();
let Some(close) = text[start..].find("```") else {
break;
};
let end = start + close;
let preceding = &text[..cursor + open];
out.push(Example {
file: name.clone(),
line: preceding.matches('\n').count() + 1,
body: text[start..end].to_string(),
marked_bad: preceding
.rsplit("```")
.next()
.is_some_and(|tail| tail.contains("**BAD")),
});
cursor = end + 3;
}
out
}

const TRANSITION_TYPES: &[&str] = &[
"fade",
"dissolve",
"slide",
"wipe",
"wipe_up",
"wipe_down",
"wipe_left",
"wipe_right",
"corner_reveal",
"pixel_dissolve",
"chromatic_wipe",
"iris",
"zoom",
"flip",
"zoom_blur",
"whip",
"mask",
"blob",
"camera_pan",
];

const ERRORS_THE_WRAPPER_CAUSES: &[&str] =
&["file not found", "but scene_duration is", "Failed to read"];

fn as_scenario(value: &serde_json::Value) -> Option<serde_json::Value> {
let object = value.as_object()?;
if object.contains_key("scenes") || object.contains_key("composition") {
let mut doc = value.clone();
if !object.contains_key("video") {
doc.as_object_mut()?.insert(
"video".into(),
serde_json::json!({ "width": 640, "height": 360, "fps": 30 }),
);
}
return Some(doc);
}
let kind = object.get("type")?.as_str()?;
if TRANSITION_TYPES.contains(&kind) {
return None;
}
Some(serde_json::json!({
"video": { "width": 640, "height": 360, "fps": 30 },
"scenes": [{ "duration": 6.0, "children": [value] }]
}))
}

#[test]
fn every_json_example_in_the_skill_documents_still_validates() {
let mut checked = 0usize;
let mut broken: Vec<String> = Vec::new();

for path in skill_documents() {
for example in json_examples(&path) {
if example.marked_bad
|| example.body.contains('…')
|| example.body.contains("...")
|| example.body.contains("//")
{
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(&example.body) else {
continue;
};
let Some(scenario) = as_scenario(&value) else {
continue;
};
checked += 1;

let source = scenario.to_string();
let loaded = match rustmotion::loader::load_scenario_from_source(None, Some(&source)) {
Ok(s) => s,
Err(e) => {
broken.push(format!("{}:{} — {e}", example.file, example.line));
continue;
}
};
let (schema_errors, _) = rustmotion::cli::validate_scenario(&loaded);
let (attr_errors, _) = rustmotion::cli::check_component_attrs(&loaded);
for error in schema_errors.into_iter().chain(attr_errors) {
if ERRORS_THE_WRAPPER_CAUSES
.iter()
.any(|ignored| error.contains(ignored))
{
continue;
}
broken.push(format!("{}:{} — {error}", example.file, example.line));
}
}
}

assert!(
checked > 80,
"only {checked} examples were reachable, so this test is not reading the documents"
);
assert!(
broken.is_empty(),
"a generating model reads these documents as the source of truth, and {} example(s) \
no longer validate:\n {}",
broken.len(),
broken.join("\n ")
);
}
Loading