rustmotion 0.8.1

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
Documentation
use rustmotion::encode::{build_frame_tasks, render_frame_task_scaled};
use rustmotion::schema::ResolvedScenario;

const WIDTH: u32 = 320;
const HEIGHT: u32 = 160;
const SOURCE_SIDE: u32 = 640;
const BACKGROUND: [u8; 3] = [255, 0, 0];

fn write_checkerboard() -> std::path::PathBuf {
    let path = std::env::temp_dir().join(format!(
        "rm_image_sampling_checker_{}.png",
        std::process::id()
    ));
    let mut buf = image::ImageBuffer::<image::Rgb<u8>, Vec<u8>>::new(SOURCE_SIDE, SOURCE_SIDE);
    for (x, y, pixel) in buf.enumerate_pixels_mut() {
        let v = if (x + y) % 2 == 0 { 255 } else { 0 };
        *pixel = image::Rgb([v, v, v]);
    }
    buf.save(&path).expect("write the checkerboard source");
    path
}

fn scenario(src: &std::path::Path) -> ResolvedScenario {
    let src = src.display();
    let json = format!(
        r##"{{
          "version": "1.0",
          "video": {{ "width": {WIDTH}, "height": {HEIGHT}, "fps": 30, "background": "#FF0000" }},
          "scenes": [{{
            "duration": 1.0,
            "layout": {{ "direction": "row", "align_items": "center", "justify_content": "center", "gap": 40 }},
            "children": [
              {{ "type": "image", "src": "{src}", "fit": "cover", "style": {{ "width": 64, "height": 64 }} }},
              {{ "type": "image", "src": "{src}", "fit": "cover", "style": {{ "width": 120, "height": 120 }} }}
            ]
          }}]
        }}"##
    );
    rustmotion::loader::load_scenario_from_source(None, Some(&json)).expect("scenario loads")
}

fn first_frame(src: &std::path::Path) -> Vec<u8> {
    let scenario = scenario(src);
    rustmotion::engine::preload::preload_scenario_assets(&scenario).expect("preload");
    let tasks = build_frame_tasks(&scenario);
    render_frame_task_scaled(&scenario.video, &scenario, &tasks[0], 1.0).expect("render")
}

fn is_background(rgba: &[u8], x: u32, y: u32) -> bool {
    let i = ((y * WIDTH + x) * 4) as usize;
    rgba[i] == BACKGROUND[0] && rgba[i + 1] == BACKGROUND[1] && rgba[i + 2] == BACKGROUND[2]
}

fn image_columns(rgba: &[u8]) -> Vec<(u32, u32)> {
    let mut spans = Vec::new();
    let mut start = None;
    for x in 0..WIDTH {
        let painted = (0..HEIGHT).any(|y| !is_background(rgba, x, y));
        match (start, painted) {
            (None, true) => start = Some(x),
            (Some(s), false) => {
                spans.push((s, x));
                start = None;
            }
            _ => {}
        }
    }
    if let Some(s) = start {
        spans.push((s, WIDTH));
    }
    spans
}

fn rows_of(rgba: &[u8], (from_x, to_x): (u32, u32)) -> (u32, u32) {
    let painted: Vec<u32> = (0..HEIGHT)
        .filter(|y| (from_x..to_x).any(|x| !is_background(rgba, x, *y)))
        .collect();
    (
        *painted.first().expect("a painted row"),
        painted.last().expect("a painted row") + 1,
    )
}

struct Luma {
    mean: f64,
    min: u8,
    max: u8,
}

fn luma(rgba: &[u8], (x0, x1): (u32, u32), (y0, y1): (u32, u32), inset: u32) -> Luma {
    let mut values = Vec::new();
    for y in y0 + inset..y1 - inset {
        for x in x0 + inset..x1 - inset {
            let i = ((y * WIDTH + x) * 4) as usize;
            let v =
                0.299 * rgba[i] as f64 + 0.587 * rgba[i + 1] as f64 + 0.114 * rgba[i + 2] as f64;
            values.push(v.round() as u8);
        }
    }
    Luma {
        mean: values.iter().map(|v| *v as f64).sum::<f64>() / values.len() as f64,
        min: *values.iter().min().expect("a non-empty region"),
        max: *values.iter().max().expect("a non-empty region"),
    }
}

#[test]
fn a_photo_shrunk_by_the_image_component_is_averaged_not_point_sampled() {
    let src = write_checkerboard();
    let rgba = first_frame(&src);
    let _ = std::fs::remove_file(&src);

    let spans = image_columns(&rgba);
    assert_eq!(
        spans.len(),
        2,
        "expected the 64px and the 120px image, got {spans:?}"
    );

    for (span, drawn) in spans.iter().zip([64u32, 120]) {
        let rows = rows_of(&rgba, *span);
        assert_eq!(
            (span.1 - span.0, rows.1 - rows.0),
            (drawn, drawn),
            "the {drawn}px image is not laid out at its declared size"
        );

        let measured = luma(&rgba, *span, rows, 4);
        assert!(
            (measured.mean - 127.0).abs() < 2.0 && measured.min > 118 && measured.max < 137,
            "a 1px checkerboard drawn at {drawn}px must average to grey, got mean {:.1} \
             min {} max {}; nearest-neighbour sampling reads 255/255/255 at 64px and \
             mean 124 with min 0 max 255 at 120px",
            measured.mean,
            measured.min,
            measured.max
        );
    }
}