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::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use gpui_component::notification::Notification;
use gpui_component::WindowExt as _;
use gpui_kit::{div, Context, IntoElement, Render, Window};

use crate::studio::scenario::Shared;

#[derive(Debug, Clone, PartialEq)]
pub enum ExportStatus {
    Idle,
    Running {
        phase: &'static str,
        done: u32,
        total: u32,
    },
    Done(PathBuf),
    Failed(String),
}

impl ExportStatus {
    pub fn is_running(&self) -> bool {
        matches!(self, ExportStatus::Running { .. })
    }
}

pub type SharedExport = Arc<Mutex<ExportStatus>>;

pub fn export_slot() -> SharedExport {
    static SLOT: std::sync::OnceLock<SharedExport> = std::sync::OnceLock::new();
    SLOT.get_or_init(|| Arc::new(Mutex::new(ExportStatus::Idle)))
        .clone()
}

pub fn start_export(shared: &Shared, status: &SharedExport) {
    {
        let mut st = status.lock().unwrap_or_else(|e| e.into_inner());
        if st.is_running() {
            return;
        }
        *st = ExportStatus::Running {
            phase: "Rendering",
            done: 0,
            total: 0,
        };
    }

    let (path, raw) = {
        let m = shared.lock().unwrap_or_else(|e| e.into_inner());
        (m.path.clone(), m.raw.clone())
    };

    let status = status.clone();
    std::thread::spawn(move || {
        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            run_export(path, raw, &status)
        }));
        let outcome = match result {
            Ok(Ok(out)) => ExportStatus::Done(out),
            Ok(Err(e)) => ExportStatus::Failed(e),
            Err(_) => ExportStatus::Failed("renderer panicked".to_string()),
        };
        *status.lock().unwrap_or_else(|e| e.into_inner()) = outcome;
    });
}

fn run_export(
    path: Option<PathBuf>,
    raw: serde_json::Value,
    status: &SharedExport,
) -> Result<PathBuf, String> {
    let scenario = match &path {
        Some(p) => rustmotion::loader::load_input(p).map_err(|e| e.to_string())?,
        None => {
            if raw.is_null() {
                return Err("no scenario to export".to_string());
            }
            let json = serde_json::to_string(&raw).map_err(|e| e.to_string())?;
            rustmotion::loader::load_scenario_from_source(None, Some(&json))
                .map_err(|e| e.to_string())?
        }
    };

    if !scenario.fonts.is_empty() {
        rustmotion::engine::renderer::load_custom_fonts(&scenario.fonts);
    }

    let output = output_path(&path);
    let output_str = output
        .to_str()
        .ok_or_else(|| "non-UTF-8 output path".to_string())?;

    let mut cb = |p: rustmotion::encode::EncodeProgress| {
        *status.lock().unwrap_or_else(|e| e.into_inner()) = status_for_progress(&p);
    };

    if ffmpeg_available() {
        rustmotion::encode::encode_with_ffmpeg(
            &scenario,
            output_str,
            true,
            "h264",
            scenario.video.crf,
            false,
            Some(&mut cb),
        )
        .map_err(|e| e.to_string())?;
    } else {
        rustmotion::encode::encode_video(&scenario, output_str, true, Some(&mut cb))
            .map_err(|e| e.to_string())?;
    }
    Ok(output)
}

fn status_for_progress(p: &rustmotion::encode::EncodeProgress) -> ExportStatus {
    use rustmotion::encode::EncodeProgress;
    match p {
        EncodeProgress::Rendering(c, t) => ExportStatus::Running {
            phase: "Rendering",
            done: *c,
            total: *t,
        },
        EncodeProgress::Encoding(c, t) => ExportStatus::Running {
            phase: "Encoding",
            done: *c,
            total: *t,
        },
        EncodeProgress::Muxing => ExportStatus::Running {
            phase: "Muxing",
            done: 0,
            total: 0,
        },
    }
}

fn output_path(source: &Option<PathBuf>) -> PathBuf {
    match source {
        Some(p) => p.with_extension("mp4"),
        None => std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join("export.mp4"),
    }
}

pub fn export_label(status: &ExportStatus) -> String {
    match status {
        ExportStatus::Running { phase, done, total } if *total > 0 => {
            let pct = (*done as f64 / *total as f64 * 100.0).round() as u32;
            format!("{phase}… {pct}%")
        }
        ExportStatus::Running { phase, .. } => format!("{phase}…"),
        _ => "Export".to_string(),
    }
}

fn ffmpeg_available() -> bool {
    std::process::Command::new("ffmpeg")
        .arg("-version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

const EXPORT_POLL_INTERVAL: Duration = Duration::from_millis(150);

struct ExportNotificationKind;

pub struct ExportWatcher {
    status: SharedExport,
    last: ExportStatus,
}

impl ExportWatcher {
    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let this = Self {
            status: export_slot(),
            last: ExportStatus::Idle,
        };
        this.start_polling(window, cx);
        this
    }

    fn start_polling(&self, window: &mut Window, cx: &mut Context<Self>) {
        cx.spawn_in(window, async move |this, cx| loop {
            cx.background_executor().timer(EXPORT_POLL_INTERVAL).await;
            let alive = this
                .update_in(cx, |this, window, cx| this.tick(window, cx))
                .is_ok();
            if !alive {
                break;
            }
        })
        .detach();
    }

    fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let current = self
            .status
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .clone();
        if current == self.last {
            return;
        }
        self.last = current.clone();

        match current {
            ExportStatus::Idle => {
                window.remove_notification::<ExportNotificationKind>(cx);
            }
            ExportStatus::Running { .. } => {
                window.push_notification(
                    Notification::info(export_label(&current))
                        .title("Exporting")
                        .id::<ExportNotificationKind>()
                        .autohide(false),
                    cx,
                );
            }
            ExportStatus::Done(ref path) => {
                window.push_notification(
                    Notification::success(format!("Saved to {}", path.display()))
                        .title("Export complete")
                        .id::<ExportNotificationKind>()
                        .autohide(true),
                    cx,
                );
            }
            ExportStatus::Failed(ref message) => {
                window.push_notification(
                    Notification::error(message.clone())
                        .title("Export failed")
                        .id::<ExportNotificationKind>()
                        .autohide(false),
                    cx,
                );
            }
        }
    }
}

impl Render for ExportWatcher {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        div()
    }
}

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

    #[test]
    fn output_path_sits_next_to_the_source() {
        let src = Some(PathBuf::from("/work/demo/scenario.json"));
        assert_eq!(output_path(&src), PathBuf::from("/work/demo/scenario.mp4"));
        let html = Some(PathBuf::from("/work/demo/hello.html"));
        assert_eq!(output_path(&html), PathBuf::from("/work/demo/hello.mp4"));
    }

    #[test]
    fn output_path_defaults_to_export_mp4() {
        let out = output_path(&None);
        assert_eq!(out.file_name().and_then(|s| s.to_str()), Some("export.mp4"));
    }

    #[test]
    fn progress_maps_to_running_phases() {
        assert_eq!(
            status_for_progress(&EncodeProgress::Rendering(3, 10)),
            ExportStatus::Running {
                phase: "Rendering",
                done: 3,
                total: 10
            }
        );
        assert_eq!(
            status_for_progress(&EncodeProgress::Encoding(5, 10)),
            ExportStatus::Running {
                phase: "Encoding",
                done: 5,
                total: 10
            }
        );
        assert!(status_for_progress(&EncodeProgress::Muxing).is_running());
    }

    #[test]
    fn labels_show_percentage_when_total_known() {
        assert_eq!(
            export_label(&ExportStatus::Running {
                phase: "Rendering",
                done: 42,
                total: 100
            }),
            "Rendering… 42%"
        );
        assert_eq!(
            export_label(&ExportStatus::Running {
                phase: "Muxing",
                done: 0,
                total: 0
            }),
            "Muxing…"
        );
        assert_eq!(export_label(&ExportStatus::Idle), "Export");
        assert_eq!(
            export_label(&ExportStatus::Done(PathBuf::from("/x.mp4"))),
            "Export"
        );
    }
}