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::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{channel, RecvTimeoutError, Sender};
use std::sync::{Arc, OnceLock};
use std::time::Duration;

use rustmotion::schema::{AudioTrack, ResolvedScenario};

enum Cmd {
    Load(Arc<Vec<f32>>, u32),
    Clear,
    PlayFrom(f64),
    Stop,
    SetMuted(bool),
}

static POSITION_MS: AtomicU64 = AtomicU64::new(0);
static PLAYING: AtomicBool = AtomicBool::new(false);
static HAS_AUDIO: AtomicBool = AtomicBool::new(false);
static MUTED: AtomicBool = AtomicBool::new(false);
static MIX_TOKEN: AtomicUsize = AtomicUsize::new(0);

static TX: OnceLock<Option<Sender<Cmd>>> = OnceLock::new();

fn tx() -> Option<&'static Sender<Cmd>> {
    TX.get_or_init(spawn_audio_thread).as_ref()
}

fn spawn_audio_thread() -> Option<Sender<Cmd>> {
    let (tx, rx) = channel::<Cmd>();
    std::thread::Builder::new()
        .name("rm-preview-audio".into())
        .spawn(move || {
            let stream = match rodio::stream::DeviceSinkBuilder::from_default_device()
                .and_then(|b| b.open_stream())
            {
                Ok(s) => s,
                Err(e) => {
                    eprintln!("rustmotion-studio: no audio output device ({e}); preview is silent");
                    while rx.recv().is_ok() {}
                    return;
                }
            };

            let mut mix: Option<(Arc<Vec<f32>>, u32)> = None;
            let mut sink: Option<rodio::Player> = None;
            let mut start_offset = 0.0f64;

            loop {
                match rx.recv_timeout(Duration::from_millis(10)) {
                    Ok(Cmd::Load(pcm, rate)) => {
                        mix = Some((pcm, rate));
                        HAS_AUDIO.store(true, Ordering::Relaxed);
                    }
                    Ok(Cmd::Clear) => {
                        mix = None;
                        sink = None;
                        HAS_AUDIO.store(false, Ordering::Relaxed);
                        PLAYING.store(false, Ordering::Relaxed);
                    }
                    Ok(Cmd::PlayFrom(seconds)) => {
                        sink = None;
                        let Some((pcm, rate)) = mix.as_ref() else {
                            continue;
                        };
                        let frame = (seconds.max(0.0) * *rate as f64) as usize;
                        let offset = (frame * CHANNELS as usize).min(pcm.len());
                        let s = rodio::Player::connect_new(stream.mixer());
                        s.set_volume(if MUTED.load(Ordering::Relaxed) {
                            0.0
                        } else {
                            1.0
                        });
                        s.append(rodio::buffer::SamplesBuffer::new(
                            channel_count(),
                            sample_rate(*rate),
                            pcm[offset..].to_vec(),
                        ));
                        s.play();
                        start_offset = seconds.max(0.0);
                        sink = Some(s);
                        POSITION_MS.store((start_offset * 1000.0) as u64, Ordering::Relaxed);
                        PLAYING.store(true, Ordering::Relaxed);
                    }
                    Ok(Cmd::Stop) => {
                        sink = None;
                        PLAYING.store(false, Ordering::Relaxed);
                    }
                    Ok(Cmd::SetMuted(m)) => {
                        if let Some(s) = sink.as_ref() {
                            s.set_volume(if m { 0.0 } else { 1.0 });
                        }
                    }
                    Err(RecvTimeoutError::Timeout) => {}
                    Err(RecvTimeoutError::Disconnected) => return,
                }

                if let Some(s) = sink.as_ref() {
                    if s.empty() {
                        sink = None;
                        PLAYING.store(false, Ordering::Relaxed);
                    } else {
                        let pos = start_offset + s.get_pos().as_secs_f64();
                        POSITION_MS.store((pos * 1000.0) as u64, Ordering::Relaxed);
                    }
                }
            }
        })
        .ok()?;
    Some(tx)
}

const CHANNELS: u16 = 2;

fn channel_count() -> rodio::ChannelCount {
    rodio::ChannelCount::new(CHANNELS).expect("stereo is non-zero")
}

fn sample_rate(rate: u32) -> rodio::SampleRate {
    rodio::SampleRate::new(rate).unwrap_or(rodio::SampleRate::new(44_100).unwrap())
}

pub fn audio_fingerprint(audio: &[AudioTrack], total_duration: f64) -> u64 {
    let mut hasher = DefaultHasher::new();
    serde_json::to_string(audio)
        .unwrap_or_default()
        .hash(&mut hasher);
    total_duration.to_bits().hash(&mut hasher);
    hasher.finish()
}

pub fn prepare(scenario: Arc<ResolvedScenario>, total_duration: f64) {
    let Some(tx) = tx() else { return };
    if scenario.audio.is_empty() {
        let _ = tx.send(Cmd::Clear);
        return;
    }
    let token = MIX_TOKEN.fetch_add(1, Ordering::SeqCst) + 1;
    let tx = tx.clone();
    std::thread::spawn(move || {
        let mixed = rustmotion::encode::audio::mix_audio_tracks(&scenario.audio, total_duration);
        if MIX_TOKEN.load(Ordering::SeqCst) != token {
            return;
        }
        match mixed {
            Ok(Some(pcm_bytes)) => {
                let pcm: Vec<f32> = pcm_bytes
                    .as_chunks::<2>()
                    .0
                    .iter()
                    .map(|b| i16::from_le_bytes([b[0], b[1]]) as f32 / 32768.0)
                    .collect();
                let _ = tx.send(Cmd::Load(Arc::new(pcm), SAMPLE_RATE));
            }
            Ok(None) => {
                let _ = tx.send(Cmd::Clear);
            }
            Err(e) => {
                eprintln!("rustmotion-studio: preview audio unavailable: {e}");
                let _ = tx.send(Cmd::Clear);
            }
        }
    });
}

const SAMPLE_RATE: u32 = rustmotion::encode::audio::OUTPUT_SAMPLE_RATE;

pub fn play_from_frame(frame: u32, fps: u32) {
    let seconds = frame as f64 / fps.max(1) as f64;
    POSITION_MS.store((seconds * 1000.0) as u64, Ordering::Relaxed);
    PLAYING.store(true, Ordering::Relaxed);
    if let Some(tx) = tx() {
        let _ = tx.send(Cmd::PlayFrom(seconds));
    }
}

pub fn stop() {
    if let Some(tx) = tx() {
        let _ = tx.send(Cmd::Stop);
    }
}

pub fn set_muted(muted: bool) {
    MUTED.store(muted, Ordering::Relaxed);
    if let Some(tx) = tx() {
        let _ = tx.send(Cmd::SetMuted(muted));
    }
}

pub fn has_audio() -> bool {
    HAS_AUDIO.load(Ordering::Relaxed)
}

pub fn position_frame(fps: u32) -> Option<u32> {
    if !PLAYING.load(Ordering::Relaxed) {
        return None;
    }
    let secs = POSITION_MS.load(Ordering::Relaxed) as f64 / 1000.0;
    Some((secs * fps.max(1) as f64).round() as u32)
}

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

    #[test]
    fn position_is_none_while_stopped() {
        PLAYING.store(false, Ordering::Relaxed);
        assert_eq!(position_frame(30), None);
    }

    #[test]
    fn position_converts_milliseconds_to_frames() {
        PLAYING.store(true, Ordering::Relaxed);
        POSITION_MS.store(2_000, Ordering::Relaxed);
        assert_eq!(position_frame(30), Some(60));
        POSITION_MS.store(0, Ordering::Relaxed);
        assert_eq!(position_frame(30), Some(0));
        PLAYING.store(false, Ordering::Relaxed);
    }

    #[test]
    fn zero_fps_is_clamped() {
        PLAYING.store(true, Ordering::Relaxed);
        POSITION_MS.store(1_000, Ordering::Relaxed);
        assert_eq!(position_frame(0), Some(1));
        PLAYING.store(false, Ordering::Relaxed);
    }
}