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::sync::Arc;

use image::{Frame, ImageBuffer, Rgba};
use smallvec::smallvec;

use gpui_kit::{
    div, img, AnyElement, App, ImageSource, IntoElement, ObjectFit, ParentElement, RenderImage,
    Styled, StyledImage, Window,
};

use rustmotion::encode::video::FrameTask;
use rustmotion::schema::ResolvedScenario;

use crate::studio::app::state::EditorState;
use crate::studio::scenario::Shared;

use super::diff_panel::DiffSide;
use super::frames::render_frame_rgba_deep;
use super::prefetch::{ensure_prefetcher, frame_cache, publish_target, FrameKey, PrefetchTarget};

pub fn frame_from_rgba(width: u32, height: u32, mut rgba: Vec<u8>) -> Arc<RenderImage> {
    let (pixels, _) = rgba.as_chunks_mut::<4>();
    for pixel in pixels {
        pixel.swap(0, 2);
        pixel[3] = 255;
    }
    let buffer: ImageBuffer<Rgba<u8>, Vec<u8>> =
        ImageBuffer::from_raw(width, height, rgba).expect("rgba byte length matches dimensions");
    Arc::new(RenderImage::new(smallvec![Frame::new(buffer)]))
}

#[allow(clippy::result_unit_err)]
pub fn render_current_frame(
    scenario: &ResolvedScenario,
    tasks: &[FrameTask],
    frame: u32,
    scale: f32,
) -> Result<Arc<RenderImage>, ()> {
    let (width, height, rgba) = render_frame_rgba_deep(scenario, tasks, frame, scale)?;
    if width == 0 || height == 0 {
        return Err(());
    }
    Ok(frame_from_rgba(width, height, rgba))
}

pub fn swap_frame(
    editor: &mut EditorState,
    image: Arc<RenderImage>,
    window: &mut Window,
    cx: &mut App,
) {
    if let Some(old) = editor.frame.replace(image) {
        cx.drop_image(old, Some(window));
    }
}

pub fn request_next_frame_if_playing(playing: bool, window: &Window) {
    if playing {
        window.request_animation_frame();
    }
}

pub fn frame_surface(frame: Option<Arc<RenderImage>>) -> AnyElement {
    match frame {
        Some(image) => img(ImageSource::Render(image))
            .size_full()
            .object_fit(ObjectFit::Contain)
            .into_any_element(),
        None => div()
            .size_full()
            .flex()
            .items_center()
            .justify_center()
            .child(gpui_component::spinner::Spinner::new())
            .into_any_element(),
    }
}

pub fn frame_from_jpeg(bytes: &[u8]) -> Option<Arc<RenderImage>> {
    let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Jpeg).ok()?;
    let rgba = decoded.to_rgba8();
    let (width, height) = rgba.dimensions();
    Some(frame_from_rgba(width, height, rgba.into_raw()))
}

pub fn prefetched_frame(key: &FrameKey) -> Option<Arc<RenderImage>> {
    let bytes = frame_cache()
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .get(key)?;
    frame_from_jpeg(&bytes)
}

fn prefetch_target_for(shared: &Shared, editor: &EditorState) -> PrefetchTarget {
    let side = if editor.diff_active {
        editor.diff_side
    } else {
        DiffSide::B
    };
    let (scenario, tasks, generation, path) = {
        let model = shared.lock().unwrap_or_else(|e| e.into_inner());
        (
            Some(model.scenario.clone()),
            Some(model.tasks.clone()),
            model.generation,
            model.path.clone(),
        )
    };
    PrefetchTarget {
        current: editor.current,
        playing: editor.playing,
        generation,
        side,
        scenario,
        tasks,
        path,
    }
}

pub fn publish_prefetch_target(shared: &Shared, editor: &EditorState) {
    ensure_prefetcher();
    publish_target(prefetch_target_for(shared, editor));
}

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

    const SCENARIO: &str = r##"{ "video": { "width": 8, "height": 4, "background": "#101418" }, "scenes": [ { "duration": 1.0 } ] }"##;

    #[test]
    fn frame_from_rgba_swaps_red_and_blue_and_forces_opaque() {
        let rgba = vec![10u8, 20, 30, 0, 200, 210, 220, 128];
        let image = frame_from_rgba(2, 1, rgba);
        let bytes = image.as_bytes(0).expect("first frame present");
        assert_eq!(bytes, &[30, 20, 10, 255, 220, 210, 200, 255]);
    }

    #[test]
    fn frame_from_rgba_reports_the_given_dimensions() {
        let rgba = vec![0u8; (4 * 3 * 4) as usize];
        let image = frame_from_rgba(4, 3, rgba);
        let size = image.size(0);
        assert_eq!((size.width.0, size.height.0), (4, 3));
    }

    #[test]
    fn render_current_frame_matches_the_scenario_dimensions() {
        let scenario = rustmotion::loader::load_scenario_from_source(None, Some(SCENARIO)).unwrap();
        let tasks = rustmotion::encode::build_frame_tasks(&scenario);
        let image = render_current_frame(&scenario, &tasks, 0, 1.0).unwrap();
        let size = image.size(0);
        assert_eq!((size.width.0, size.height.0), (8, 4));
    }

    #[test]
    fn render_current_frame_rejects_an_empty_task_list() {
        let scenario = rustmotion::loader::load_scenario_from_source(None, Some(SCENARIO)).unwrap();
        assert!(render_current_frame(&scenario, &[], 0, 1.0).is_err());
    }

    fn editor_state(
        current: u32,
        playing: bool,
        diff_active: bool,
        diff_side: DiffSide,
    ) -> EditorState {
        EditorState {
            current,
            playing,
            muted: false,
            rev: 0,
            selected: None,
            show_annotations: false,
            show_hits: true,
            diff_active,
            diff_side,
            preview_scale: 100,
            frame: None,
        }
    }

    fn shared_model() -> Shared {
        use crate::studio::scenario::StudioModel;
        use std::sync::{Arc as StdArc, Mutex};
        let scenario = rustmotion::loader::load_scenario_from_source(None, Some(SCENARIO)).unwrap();
        StdArc::new(Mutex::new(StudioModel::new(scenario, None, None)))
    }

    #[test]
    fn prefetch_target_carries_editor_state_and_model_snapshot() {
        let shared = shared_model();
        let target = prefetch_target_for(&shared, &editor_state(3, true, true, DiffSide::A));

        assert_eq!(target.current, 3);
        assert!(target.playing);
        assert_eq!(target.side, DiffSide::A);
        assert!(
            target.scenario.is_some() && target.tasks.is_some(),
            "workers render from these snapshots and must never take the model lock"
        );
    }

    #[test]
    fn prefetch_target_forces_side_b_when_diff_is_off() {
        let shared = shared_model();
        let target = prefetch_target_for(&shared, &editor_state(0, false, false, DiffSide::A));

        assert_eq!(
            target.side,
            DiffSide::B,
            "a stale diff_side must not survive leaving diff mode"
        );
    }
}