enginerenderer 0.0.1

A zero-dependency offline rendering engine in pure Rust — CPU path tracing, BVH acceleration, 16-band spectral rendering, PBR materials, animation & video export.
Documentation
use std::{error::Error, fmt, path::Path, process::Command};

use super::sequence::SequenceResult;

/// Error returned when ffmpeg is not available on PATH.
#[derive(Debug)]
pub struct FfmpegNotFound;

impl fmt::Display for FfmpegNotFound {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("ffmpeg not found in PATH — install ffmpeg to encode video sequences")
    }
}

impl Error for FfmpegNotFound {}

/// Utility for encoding rendered frame sequences into video files.
pub struct VideoExporter;

impl VideoExporter {
    /// Encodes an image sequence into an H.264 MP4 file using ffmpeg.
    pub fn encode_h264<P: AsRef<Path>, Q: AsRef<Path>>(
        frame_dir: P,
        frame_prefix: &str,
        fps: f64,
        output_path: Q,
    ) -> Result<(), Box<dyn Error>> {
        if !ffmpeg_available() {
            return Err(Box::new(FfmpegNotFound));
        }

        let frame_dir = frame_dir.as_ref();
        let output_path = output_path.as_ref();

        if let Some(parent) = output_path.parent()
            && !parent.as_os_str().is_empty()
        {
            std::fs::create_dir_all(parent)?;
        }

        let input_pattern = frame_dir.join(format!("{prefix}_%05d.png", prefix = frame_prefix));

        let status = Command::new("ffmpeg")
            .args([
                "-y",
                "-r",
                &format!("{fps:.6}"),
                "-i",
                input_pattern.to_str().unwrap_or(""),
                "-vcodec",
                "libx264",
                "-crf",
                "18",
                "-pix_fmt",
                "yuv420p",
                "-movflags",
                "+faststart",
                output_path.to_str().unwrap_or(""),
            ])
            .status()?;

        if status.success() {
            Ok(())
        } else {
            Err(format!("ffmpeg exited with status {status}").into())
        }
    }

    /// Encodes a sequence result into an H.264 video file.
    pub fn encode_from_result<P: AsRef<Path>>(
        result: &SequenceResult,
        output_path: P,
    ) -> Result<(), Box<dyn Error>> {
        if result.frames.is_empty() {
            return Err("no frames to encode".into());
        }
        let first = &result.frames[0].output_path;
        let ext = first.extension().and_then(|e| e.to_str()).unwrap_or("png");
        let stem = first
            .file_name()
            .and_then(|n| n.to_str())
            .and_then(|n| n.rfind('_').map(|i| &n[..i]))
            .unwrap_or("frame");

        if ext != "png" {
            return Err(
                "video encoding requires PNG frames — rerun sequence with .png prefix".into(),
            );
        }

        Self::encode_h264(&result.output_dir, stem, result.fps, output_path)
    }
}

fn ffmpeg_available() -> bool {
    Command::new("ffmpeg")
        .arg("-version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}