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;

use serde_json::Value;

const PATH_KEYS: &[&str] = &["src", "track"];

fn is_remote(s: &str) -> bool {
    s.starts_with("http://") || s.starts_with("https://") || s.starts_with("data:")
}

pub fn rebase_relative_paths(value: &mut Value, base_dir: &Path) {
    match value {
        Value::Object(map) => {
            for (key, child) in map.iter_mut() {
                if PATH_KEYS.contains(&key.as_str()) {
                    if let Value::String(s) = child {
                        if let Some(abs) = rebased(s, base_dir) {
                            *s = abs;
                            continue;
                        }
                    }
                }
                rebase_relative_paths(child, base_dir);
            }
        }
        Value::Array(items) => {
            for item in items {
                rebase_relative_paths(item, base_dir);
            }
        }
        _ => {}
    }
}

fn rebased(src: &str, base_dir: &Path) -> Option<String> {
    if src.is_empty() || is_remote(src) {
        return None;
    }
    let path = Path::new(src);
    if path.is_absolute() {
        return None;
    }
    let candidate = base_dir.join(path);
    if !candidate.is_file() {
        return None;
    }
    let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate);
    Some(resolved.to_str()?.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn scratch() -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "rustmotion_assets_{}_{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        std::fs::create_dir_all(dir.join("assets")).expect("scratch dir");
        dir
    }

    #[test]
    fn a_relative_src_next_to_the_scenario_becomes_absolute() {
        let dir = scratch();
        std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture");

        let mut v =
            json!({"scenes": [{"children": [{"type": "image", "src": "assets/logo.png"}]}]});
        rebase_relative_paths(&mut v, &dir);

        let got = v["scenes"][0]["children"][0]["src"].as_str().expect("src");
        assert!(Path::new(got).is_absolute(), "not rewritten: {got}");
        assert!(Path::new(got).is_file(), "rewritten to a non-file: {got}");
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn the_result_does_not_depend_on_the_working_directory() {
        let dir = scratch();
        std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture");

        let mut a = json!({"src": "assets/logo.png"});
        let mut b = json!({"src": "assets/logo.png"});
        rebase_relative_paths(&mut a, &dir);
        rebase_relative_paths(&mut b, &dir);
        assert_eq!(a, b);
        assert_ne!(a["src"], json!("assets/logo.png"));
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn a_missing_file_is_left_untouched() {
        let dir = scratch();
        let mut v = json!({"src": "assets/absent.png"});
        rebase_relative_paths(&mut v, &dir);
        assert_eq!(v["src"], json!("assets/absent.png"));
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn absolute_and_remote_sources_are_left_untouched() {
        let dir = scratch();
        let mut v = json!({
            "a": {"src": "/etc/hosts"},
            "b": {"src": "https://example.com/x.png"},
            "c": {"src": "data:image/png;base64,AAAA"}
        });
        let before = v.clone();
        rebase_relative_paths(&mut v, &dir);
        assert_eq!(v, before);
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn track_is_rebased_like_src_so_the_analysis_lookup_still_matches() {
        let dir = scratch();
        std::fs::write(dir.join("assets/t.wav"), b"x").expect("fixture");

        let mut v = json!({
            "audio": [{"src": "assets/t.wav"}],
            "scenes": [{"children": [{"type": "waveform", "track": "assets/t.wav"}]}]
        });
        rebase_relative_paths(&mut v, &dir);

        assert_eq!(
            v["audio"][0]["src"], v["scenes"][0]["children"][0]["track"],
            "src and track must resolve to the same string"
        );
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn unrelated_keys_are_not_rewritten() {
        let dir = scratch();
        std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture");
        let mut v = json!({"content": "assets/logo.png", "title": "assets/logo.png"});
        let before = v.clone();
        rebase_relative_paths(&mut v, &dir);
        assert_eq!(v, before);
        std::fs::remove_dir_all(&dir).ok();
    }
}