motion-canvas-rs 0.1.3

A high-performance vector animation engine inspired by Motion Canvas, built on Vello and Typst.
Documentation
use crate::engine::animation::Animation;
use std::time::Duration;

#[derive(Clone, Debug)]
pub struct AudioNode {
    pub path: String,
    pub volume: f32,
    pub start_crop: Duration,
    pub end_crop: Duration,
}

impl AudioNode {
    pub fn new(path: &str) -> Self {
        Self {
            path: path.to_string(),
            volume: 1.0,
            start_crop: Duration::ZERO,
            end_crop: Duration::ZERO,
        }
    }

    pub fn with_volume(mut self, volume: f32) -> Self {
        self.volume = volume;
        self
    }

    pub fn with_start(mut self, offset: Duration) -> Self {
        self.start_crop = offset;
        self
    }

    pub fn with_end(mut self, offset: Duration) -> Self {
        self.end_crop = offset;
        self
    }
}

pub struct AudioManager;

impl AudioManager {
    pub fn get_duration(path: &str) -> Option<Duration> {
        let file = File::open(path).ok()?;
        let decoder = Decoder::new(BufReader::new(file)).ok()?;
        decoder.total_duration()
    }
}

use lazy_static::lazy_static;
use rodio::{Decoder, OutputStream, OutputStreamHandle, Source};
use std::fs::File;
use std::io::BufReader;

lazy_static! {
    static ref AUDIO_HANDLE: OutputStreamHandle = {
        let (stream, handle) =
            OutputStream::try_default().expect("Failed to initialize audio output stream");
        std::mem::forget(stream); // Keep the stream alive forever
        handle
    };
}

/// A wrapper for AudioNode that implements Animation for the audio timeline.
pub struct AudioAnimation {
    pub node: AudioNode,
    pub elapsed: Duration,
    pub started: bool,
    pub total_duration: Duration,
    pub recorded: bool,
}

impl AudioAnimation {
    pub fn new(node: AudioNode) -> Self {
        let total_duration = AudioManager::get_duration(&node.path)
            .unwrap_or(Duration::from_secs(1)); // Fallback if duration is unknown

        Self {
            node,
            elapsed: Duration::ZERO,
            started: false,
            total_duration,
            recorded: false,
        }
    }
}

impl Animation for AudioAnimation {
    fn update(&mut self, dt: Duration) -> (bool, Duration) {
        if !self.started {
            self.started = true;
            let path = self.node.path.clone();
            let volume = self.node.volume;
            let start_crop = self.node.start_crop;
            let end_crop = self.node.end_crop;
            let total_dur = self.total_duration;

            std::thread::spawn(move || {
                let file = match File::open(&path) {
                    Ok(f) => f,
                    Err(e) => {
                        eprintln!("Failed to open audio file {}: {}", path, e);
                        return;
                    }
                };

                let source = match Decoder::new(BufReader::new(file)) {
                    Ok(s) => s,
                    Err(e) => {
                        eprintln!("Failed to decode audio file {}: {}", path, e);
                        return;
                    }
                };

                let play_duration = total_dur
                    .checked_sub(start_crop)
                    .and_then(|d| d.checked_sub(end_crop))
                    .unwrap_or(Duration::ZERO);

                if play_duration > Duration::ZERO {
                    let source = source
                        .skip_duration(start_crop)
                        .take_duration(play_duration)
                        .amplify(volume);

                    let _ = AUDIO_HANDLE.play_raw(source.convert_samples());
                    // Keep the thread alive while the sound plays if needed, or rodio handles it?
                    // rodio's play_raw handles it as long as the sink/source is managed.
                    // Actually, play_raw detaches it to the stream handle.
                    std::thread::sleep(play_duration);
                }
            });
        }

        self.elapsed += dt;

        let play_duration = self
            .total_duration
            .checked_sub(self.node.start_crop)
            .and_then(|d| d.checked_sub(self.node.end_crop))
            .unwrap_or(Duration::ZERO);

        let finished = self.elapsed >= play_duration;
        let leftover = self.elapsed.saturating_sub(play_duration);
        (finished, leftover)
    }

    fn duration(&self) -> Duration {
        self.total_duration
            .checked_sub(self.node.start_crop)
            .and_then(|d| d.checked_sub(self.node.end_crop))
            .unwrap_or(Duration::ZERO)
    }

    fn collect_audio_events(&mut self, current_time: Duration, events: &mut Vec<crate::engine::animation::base::AudioEvent>) {
        if self.started && !self.recorded {
            self.recorded = true;
            events.push(crate::engine::animation::base::AudioEvent {
                path: self.node.path.clone(),
                volume: self.node.volume,
                start_crop: self.node.start_crop,
                end_crop: self.node.end_crop,
                start_time: if current_time > self.elapsed {
                    current_time - self.elapsed
                } else {
                    Duration::ZERO
                },
            });
        }
    }
}

impl From<AudioNode> for Box<dyn Animation> {
    fn from(node: AudioNode) -> Self {
        Box::new(AudioAnimation::new(node))
    }
}