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
mod overlays;
mod root;
pub mod state;
mod window;

use std::path::PathBuf;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};

use rustmotion::engine;
use rustmotion::error::Result;
use rustmotion::schema::ResolvedScenario;

use crate::studio::library::{LibraryState, SharedLibrary, WatchMsg};
use crate::studio::scenario::{empty_scenario, Shared, StudioModel, View};

fn default_workspace() -> PathBuf {
    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}

pub fn run_preview(
    scenario: ResolvedScenario,
    input_path: Option<PathBuf>,
    watch: bool,
) -> Result<()> {
    let workspace = workspace_for(&input_path);
    run_preview_root(scenario, None, input_path, workspace, true, watch)
}

pub fn run_preview_with_error(
    initial_error: String,
    input_path: Option<PathBuf>,
    watch: bool,
) -> Result<()> {
    let workspace = workspace_for(&input_path);
    run_preview_root(
        empty_scenario(),
        Some(initial_error),
        input_path,
        workspace,
        true,
        watch,
    )
}

fn workspace_for(input_path: &Option<PathBuf>) -> PathBuf {
    input_path
        .as_ref()
        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
        .filter(|d| !d.as_os_str().is_empty())
        .unwrap_or_else(default_workspace)
}

pub fn run_preview_root(
    scenario: ResolvedScenario,
    initial_error: Option<String>,
    input_path: Option<PathBuf>,
    workspace: PathBuf,
    start_in_editor: bool,
    watch: bool,
) -> Result<()> {
    if !scenario.fonts.is_empty() {
        engine::renderer::load_custom_fonts(&scenario.fonts);
    }

    let shared: Shared = Arc::new(Mutex::new(StudioModel::new(
        scenario,
        initial_error,
        input_path.clone(),
    )));
    let library: SharedLibrary =
        Arc::new(Mutex::new(LibraryState::new(workspace, start_in_editor)));

    spawn_scenario_warmup(shared.clone());

    if watch {
        let tx = spawn_watcher(shared.clone());
        if let Some(p) = input_path.clone() {
            let _ = tx.send(WatchMsg::Retarget(p));
        }
        library.lock().unwrap_or_else(|e| e.into_inner()).watch_tx = Some(tx);
    }

    let view = if start_in_editor {
        View::Editor
    } else {
        View::Library
    };
    let theme_pref = crate::studio::theme::persist::load_theme_pref();

    gpui_kit::application()
        .with_assets(gpui_kit::assets::AllAssets::new(""))
        .run(move |cx| {
            gpui_kit::init(cx);
            cx.spawn(async move |cx| {
                window::open(shared, library, view, theme_pref, cx);
            })
            .detach();
        });

    Ok(())
}

pub fn spawn_scenario_warmup(shared: Shared) {
    std::thread::spawn(move || {
        let (scenario, fps) = {
            let m = shared.lock().unwrap_or_else(|e| e.into_inner());
            (m.scenario.clone(), m.scenario.video.fps)
        };
        for view in &scenario.views {
            if let Err(icons_unresolved) = engine::prefetch_icons(&view.scenes) {
                eprintln!("Warning: {icons_unresolved}");
            }
            engine::preextract_video_frames(&view.scenes, fps);
        }
        let failures = rustmotion::encode::audio_analysis::analyze_scenario_audio(&scenario);
        let audio_error = (!failures.is_empty()).then(|| {
            failures
                .iter()
                .map(|f| f.to_string())
                .collect::<Vec<_>>()
                .join(" \u{b7} ")
        });

        let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
        if Arc::ptr_eq(&m.scenario, &scenario) {
            m.audio_error = audio_error;
            m.generation = m.generation.wrapping_add(1);
        }
    });
}

fn spawn_watcher(shared: Shared) -> Sender<WatchMsg> {
    use notify::{RecursiveMode, Watcher};
    let (tx, rx) = std::sync::mpsc::channel::<WatchMsg>();
    let notify_tx = tx.clone();
    std::thread::spawn(move || {
        let mut watcher = match notify::recommended_watcher(
            move |res: std::result::Result<notify::Event, notify::Error>| {
                if let Ok(event) = res {
                    if event.kind.is_modify() || event.kind.is_create() {
                        let _ = notify_tx.send(WatchMsg::Changed);
                    }
                }
            },
        ) {
            Ok(w) => w,
            Err(_) => return,
        };
        let mut current: Option<PathBuf> = None;
        while let Ok(msg) = rx.recv() {
            match msg {
                WatchMsg::Retarget(p) => {
                    if let Some(old) = current.take() {
                        let _ = watcher.unwatch(old.as_ref());
                    }
                    let _ = watcher.watch(p.as_ref(), RecursiveMode::NonRecursive);
                    current = Some(p);
                }
                WatchMsg::Changed => {
                    if let Some(p) = current.clone() {
                        if let Ok(content) = std::fs::read_to_string(&p) {
                            if crate::studio::scenario::is_self_write(
                                &crate::studio::scenario::self_write_slot(),
                                &p,
                                &content,
                            ) {
                                continue;
                            }
                        }
                        let (scenario, error) = match rustmotion::loader::load_input(&p) {
                            Ok(s) => (s, None),
                            Err(e) => (
                                crate::studio::scenario::empty_scenario(),
                                Some(e.to_string()),
                            ),
                        };
                        let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
                        let g = m.generation.wrapping_add(1);
                        *m = StudioModel::new(scenario, error, Some(p.clone()));
                        m.generation = g;
                        drop(m);
                        spawn_scenario_warmup(shared.clone());
                    }
                }
            }
        }
    });
    tx
}