rustmotion 0.8.0

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
Documentation
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  ")
    );
}