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 serde_json::Value;

#[allow(dead_code)]
pub fn read_style(raw: &Value, pointer: &str, prop: &str) -> Option<String> {
    let el = raw.pointer(pointer)?;
    let v = el.get("style")?.get(prop)?;
    Some(match v {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    })
}

pub fn read_style_object(raw: &Value, pointer: &str) -> Value {
    raw.pointer(pointer)
        .and_then(|el| el.get("style"))
        .cloned()
        .unwrap_or(Value::Null)
}

pub fn set_style(mut raw: Value, pointer: &str, prop: &str, value: &str) -> Option<Value> {
    let el = raw.pointer_mut(pointer)?;
    let obj = el.as_object_mut()?;
    let style = obj
        .entry("style")
        .or_insert_with(|| Value::Object(Default::default()));
    let style_obj = style.as_object_mut()?;
    style_obj.insert(prop.to_string(), Value::String(value.to_string()));
    Some(raw)
}

pub fn read_field(raw: &Value, pointer: &str, field: &str) -> Option<String> {
    let v = raw.pointer(pointer)?.get(field)?;
    Some(match v {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    })
}

pub fn set_field(mut raw: Value, pointer: &str, field: &str, value: &str) -> Option<Value> {
    let el = raw.pointer_mut(pointer)?;
    let obj = el.as_object_mut()?;
    obj.insert(field.to_string(), Value::String(value.to_string()));
    Some(raw)
}

pub fn set_field_value(mut raw: Value, pointer: &str, field: &str, value: Value) -> Option<Value> {
    let el = raw.pointer_mut(pointer)?;
    let obj = el.as_object_mut()?;
    if value.is_null() {
        obj.remove(field);
    } else {
        obj.insert(field.to_string(), value);
    }
    Some(raw)
}

pub fn set_style_value(mut raw: Value, pointer: &str, prop: &str, value: Value) -> Option<Value> {
    let el = raw.pointer_mut(pointer)?;
    let obj = el.as_object_mut()?;
    if value.is_null() {
        if let Some(style) = obj.get_mut("style").and_then(|s| s.as_object_mut()) {
            style.remove(prop);
        }
        return Some(raw);
    }
    let style = obj
        .entry("style")
        .or_insert_with(|| Value::Object(Default::default()));
    let style_obj = style.as_object_mut()?;
    style_obj.insert(prop.to_string(), value);
    Some(raw)
}

pub fn scene_duration_for_pointer(raw: &Value, pointer: &str) -> Option<f64> {
    let segs: Vec<&str> = pointer.split('/').collect();
    let scene_ptr = match segs.as_slice() {
        ["", "scenes", n, ..] => format!("/scenes/{n}"),
        ["", "composition", v, "scenes", n, ..] => format!("/composition/{v}/scenes/{n}"),
        _ => return None,
    };
    raw.pointer(&scene_ptr)?.get("duration")?.as_f64()
}

pub fn append_annotation(mut raw: Value, annotation: Value) -> Value {
    if let Some(obj) = raw.as_object_mut() {
        let arr = obj
            .entry("annotations")
            .or_insert_with(|| Value::Array(vec![]));
        if let Value::Array(a) = arr {
            a.push(annotation);
        }
    }
    raw
}

pub fn remove_annotation(mut raw: Value, id: &str) -> Value {
    if let Some(Value::Array(a)) = raw.get_mut("annotations") {
        a.retain(|x| x.get("id").and_then(|v| v.as_str()) != Some(id));
    }
    raw
}

pub fn list_annotations(raw: &Value) -> Vec<(String, String, u64, String)> {
    raw.get("annotations")
        .and_then(|v| v.as_array())
        .map(|a| {
            a.iter()
                .map(|x| {
                    let id = x
                        .get("id")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    let note = x
                        .get("note")
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    let frame = x.get("frame").and_then(|v| v.as_u64()).unwrap_or(0);
                    let kind = x
                        .get("target")
                        .and_then(|t| t.get("kind"))
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();
                    (id, note, frame, kind)
                })
                .collect()
        })
        .unwrap_or_default()
}

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

    fn raw() -> Value {
        json!({ "video": { "width": 1, "height": 1 },
            "scenes": [ { "duration": 1.0, "children": [
                { "type": "text", "content": "Hi", "style": { "color": "#fff" } }
            ] } ] })
    }

    #[test]
    fn reads_existing_style_prop() {
        assert_eq!(
            read_style(&raw(), "/scenes/0/children/0", "color").as_deref(),
            Some("#fff")
        );
    }

    #[test]
    fn sets_and_reads_back_a_prop() {
        let updated = set_style(raw(), "/scenes/0/children/0", "color", "#ff0000").unwrap();
        assert_eq!(
            read_style(&updated, "/scenes/0/children/0", "color").as_deref(),
            Some("#ff0000")
        );
    }

    #[test]
    fn creates_style_object_when_absent() {
        let mut r = raw();
        r["scenes"][0]["children"][0]
            .as_object_mut()
            .unwrap()
            .remove("style");
        let updated = set_style(r, "/scenes/0/children/0", "font-size", "48").unwrap();
        assert_eq!(
            read_style(&updated, "/scenes/0/children/0", "font-size").as_deref(),
            Some("48")
        );
    }

    #[test]
    fn scene_duration_for_pointer_reads_the_containing_scene() {
        let r = json!({"scenes": [ {"duration": 3.0}, {"duration": 5.5, "children": [ {"type": "text"} ]} ]});
        assert_eq!(
            scene_duration_for_pointer(&r, "/scenes/1/children/0"),
            Some(5.5)
        );
        assert_eq!(scene_duration_for_pointer(&r, "/scenes/0"), Some(3.0));
        let c = json!({"composition": [ {"scenes": [ {"duration": 2.0} ]} ]});
        assert_eq!(
            scene_duration_for_pointer(&c, "/composition/0/scenes/0/children/2"),
            Some(2.0)
        );
        assert_eq!(scene_duration_for_pointer(&r, "/video"), None);
    }

    #[test]
    fn set_field_value_keeps_json_types_round_trip() {
        let updated = set_field_value(raw(), "/scenes/0/children/0", "from", json!(250)).unwrap();
        let text = serde_json::to_string(&updated).unwrap();
        let back: Value = serde_json::from_str(&text).unwrap();
        assert_eq!(back["scenes"][0]["children"][0]["from"], json!(250));
        assert!(
            back["scenes"][0]["children"][0]["from"].is_number(),
            "a number must stay a number, not a string"
        );
        let updated =
            set_field_value(updated, "/scenes/0/children/0", "wrap", json!(false)).unwrap();
        assert_eq!(updated["scenes"][0]["children"][0]["wrap"], json!(false));
    }

    #[test]
    fn set_field_value_null_removes_the_field() {
        let updated =
            set_field_value(raw(), "/scenes/0/children/0", "content", Value::Null).unwrap();
        assert!(
            updated["scenes"][0]["children"][0].get("content").is_none(),
            "null must remove the key"
        );
    }

    #[test]
    fn set_style_value_null_removes_the_property() {
        let updated = set_style_value(raw(), "/scenes/0/children/0", "color", Value::Null).unwrap();
        assert!(updated["scenes"][0]["children"][0]["style"]
            .get("color")
            .is_none());
        let updated =
            set_style_value(updated, "/scenes/0/children/0", "font-size", json!(64)).unwrap();
        assert_eq!(
            updated["scenes"][0]["children"][0]["style"]["font-size"],
            json!(64)
        );
    }

    #[test]
    fn reads_and_sets_a_top_level_field() {
        assert_eq!(
            read_field(&raw(), "/scenes/0/children/0", "content").as_deref(),
            Some("Hi")
        );
        let updated = set_field(raw(), "/scenes/0/children/0", "content", "Hello").unwrap();
        assert_eq!(
            read_field(&updated, "/scenes/0/children/0", "content").as_deref(),
            Some("Hello")
        );
    }

    #[test]
    fn append_then_list_then_remove() {
        let raw = json!({ "video": { "width": 1, "height": 1 }, "scenes": [] });
        let ann = json!({ "id": "an_1", "note": "smaller", "status": "open", "frame": 5,
            "target": { "pointer": "/scenes/0/children/0", "kind": "text" } });
        let raw = append_annotation(raw, ann);
        let list = list_annotations(&raw);
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].0, "an_1");
        assert_eq!(list[0].1, "smaller");
        assert_eq!(list[0].2, 5);
        assert_eq!(list[0].3, "text");
        let raw = remove_annotation(raw, "an_1");
        assert!(list_annotations(&raw).is_empty());
    }
}