diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 31e0719..f3b43df 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -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 @@ -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. diff --git a/crates/rustmotion/skills/rules/chromatic-aberration.md b/crates/rustmotion/skills/rules/chromatic-aberration.md index 391a477..24b4857 100644 --- a/crates/rustmotion/skills/rules/chromatic-aberration.md +++ b/crates/rustmotion/skills/rules/chromatic-aberration.md @@ -7,7 +7,7 @@ ```json { "type": "icon", - "name": "zap", + "icon": "lucide:zap", "style": { "animation": [{ "name": "chromatic_aberration", diff --git a/crates/rustmotion/skills/rules/dynamic-depth.md b/crates/rustmotion/skills/rules/dynamic-depth.md index eeca037..af087fa 100644 --- a/crates/rustmotion/skills/rules/dynamic-depth.md +++ b/crates/rustmotion/skills/rules/dynamic-depth.md @@ -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 } ] } diff --git a/crates/rustmotion/skills/rules/geometry-safety.md b/crates/rustmotion/skills/rules/geometry-safety.md index f28c4a2..c559592 100644 --- a/crates/rustmotion/skills/rules/geometry-safety.md +++ b/crates/rustmotion/skills/rules/geometry-safety.md @@ -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 @@ -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 ``` diff --git a/crates/rustmotion/skills/rules/gradient-quality.md b/crates/rustmotion/skills/rules/gradient-quality.md index 6f970ea..21aa3a8 100644 --- a/crates/rustmotion/skills/rules/gradient-quality.md +++ b/crates/rustmotion/skills/rules/gradient-quality.md @@ -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" } } diff --git a/crates/rustmotion/skills/rules/motion-path.md b/crates/rustmotion/skills/rules/motion-path.md index 37f6ca5..4358bdf 100644 --- a/crates/rustmotion/skills/rules/motion-path.md +++ b/crates/rustmotion/skills/rules/motion-path.md @@ -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 | diff --git a/crates/rustmotion/skills/rules/shape-draw-start-and-path-morph.md b/crates/rustmotion/skills/rules/shape-draw-start-and-path-morph.md index e35e281..662b9ab 100644 --- a/crates/rustmotion/skills/rules/shape-draw-start-and-path-morph.md +++ b/crates/rustmotion/skills/rules/shape-draw-start-and-path-morph.md @@ -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": { @@ -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` @@ -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": [ diff --git a/crates/rustmotion/skills/rules/text-component-parity.md b/crates/rustmotion/skills/rules/text-component-parity.md index 0267b92..51e9f0f 100644 --- a/crates/rustmotion/skills/rules/text-component-parity.md +++ b/crates/rustmotion/skills/rules/text-component-parity.md @@ -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, diff --git a/crates/rustmotion/skills/rules/whip-transition.md b/crates/rustmotion/skills/rules/whip-transition.md index 4894d67..e30f58a 100644 --- a/crates/rustmotion/skills/rules/whip-transition.md +++ b/crates/rustmotion/skills/rules/whip-transition.md @@ -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" } } ``` diff --git a/crates/rustmotion/src/cli/commands/mod.rs b/crates/rustmotion/src/cli/commands/mod.rs index 6501720..6fd7b9e 100644 --- a/crates/rustmotion/src/cli/commands/mod.rs +++ b/crates/rustmotion/src/cli/commands/mod.rs @@ -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; diff --git a/crates/rustmotion/src/cli/mod.rs b/crates/rustmotion/src/cli/mod.rs index 408ca2c..fcee6c6 100644 --- a/crates/rustmotion/src/cli/mod.rs +++ b/crates/rustmotion/src/cli/mod.rs @@ -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; diff --git a/crates/rustmotion/tests/skill_examples_validate.rs b/crates/rustmotion/tests/skill_examples_validate.rs new file mode 100644 index 0000000..3987dea --- /dev/null +++ b/crates/rustmotion/tests/skill_examples_validate.rs @@ -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 /crates/rustmotion") + .to_path_buf() +} + +fn skill_documents() -> Vec { + let skills = workspace_root().join("crates/rustmotion/skills"); + let mut files = vec![skills.join("SKILL.md")]; + let mut rules: Vec = 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 { + 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 { + 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 = 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::(&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 ") + ); +}