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::PathBuf;
use std::process::{Command, Output};

struct ScratchFile(PathBuf);

impl ScratchFile {
    fn new(label: &str) -> Self {
        let unique = format!(
            "rustmotion-motion-path-test-{label}-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("system clock before UNIX epoch")
                .as_nanos()
        );
        Self(std::env::temp_dir().join(unique))
    }
}

impl Drop for ScratchFile {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.0);
    }
}

fn scenario_json(path: &str, duration: f64) -> String {
    format!(
        r##"{{
            "video": {{ "width": 1920, "height": 1080 }},
            "scenes": [{{
                "duration": {duration},
                "children": [{{
                    "type": "shape",
                    "shape": "rect",
                    "position": "absolute",
                    "x": 200, "y": 490,
                    "style": {{
                        "width": "100px", "height": "100px",
                        "animation": [
                            {{ "name": "motion_path", "path": "{path}", "duration": {duration} }}
                        ]
                    }},
                    "fill": "#ff0000"
                }}]
            }}]
        }}"##
    )
}

fn write_scenario(scratch: &ScratchFile, json: &str) {
    std::fs::write(&scratch.0, json).expect("write scenario fixture");
}

fn run_validate(scenario_path: &PathBuf, report_path: &PathBuf, strict_anim: bool) -> Output {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_rustmotion"));
    cmd.arg("validate")
        .arg("--file")
        .arg(scenario_path)
        .arg("--report")
        .arg(report_path);
    if strict_anim {
        cmd.arg("--strict-anim");
    }
    cmd.output().expect("failed to spawn `rustmotion validate`")
}

fn animated_text_overflow_count(report_json: &serde_json::Value) -> usize {
    report_json["geometry_violations"]
        .as_array()
        .map(|v| {
            v.iter()
                .filter(|violation| violation["kind"] == "animated_text_overflow")
                .count()
        })
        .unwrap_or(0)
}

#[test]
fn strict_anim_detects_a_motion_path_that_leaves_the_viewport() {
    let scenario = ScratchFile::new("overflow-scenario");
    let report = ScratchFile::new("overflow-report");
    write_scenario(&scenario, &scenario_json("M0,0 L3000,0", 2.0));

    let output = run_validate(&scenario.0, &report.0, true);
    assert!(
        !output.status.success(),
        "expected `validate --strict-anim` to fail (block) on an out-of-frame motion_path; \
         stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let report_text = std::fs::read_to_string(&report.0).expect("read report");
    let report_json: serde_json::Value =
        serde_json::from_str(&report_text).expect("report is valid JSON");
    let count = animated_text_overflow_count(&report_json);
    assert!(
        count >= 1,
        "expected at least one animated_text_overflow violation, got report: {report_text}"
    );
}

#[test]
fn without_strict_anim_the_same_out_of_frame_motion_path_is_not_caught() {
    let scenario = ScratchFile::new("overflow-scenario-no-strict");
    let report = ScratchFile::new("overflow-report-no-strict");
    write_scenario(&scenario, &scenario_json("M0,0 L3000,0", 2.0));

    let output = run_validate(&scenario.0, &report.0, false);
    assert!(
        output.status.success(),
        "the resting layout alone must validate clean (the overflow is animation-only); \
         stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let report_text = std::fs::read_to_string(&report.0).expect("read report");
    let report_json: serde_json::Value =
        serde_json::from_str(&report_text).expect("report is valid JSON");
    assert_eq!(
        animated_text_overflow_count(&report_json),
        0,
        "must not report animated_text_overflow without --strict-anim: {report_text}"
    );
}

#[test]
fn strict_anim_does_not_flag_a_motion_path_that_stays_on_screen() {
    let scenario = ScratchFile::new("safe-scenario");
    let report = ScratchFile::new("safe-report");
    write_scenario(&scenario, &scenario_json("M0,0 L50,0", 2.0));

    let output = run_validate(&scenario.0, &report.0, true);
    assert!(
        output.status.success(),
        "a motion_path that stays on-screen must validate clean under --strict-anim; \
         stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let report_text = std::fs::read_to_string(&report.0).expect("read report");
    let report_json: serde_json::Value =
        serde_json::from_str(&report_text).expect("report is valid JSON");
    assert_eq!(
        animated_text_overflow_count(&report_json),
        0,
        "on-screen travel must not be flagged: {report_text}"
    );
}