use crate::animate::{
precompute_orb_params, render_frame_with_params, AnimateOptions, MotionDirection, MotionSpeed,
};
use crate::cluster::Cluster;
use crate::orb::OrbShape;
use crate::output_mode::OutputMode;
use std::io;
use std::path::Path;
use std::process::{Command, ExitStatus};
pub const VIDEO_WIDTH: u32 = 1080;
pub const VIDEO_HEIGHT: u32 = 1920;
pub const VIDEO_FPS: u32 = 30;
pub const MAX_DURATION_MS: u64 = 600_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoCodec {
H264,
Vp9,
}
impl VideoCodec {
pub fn from_output_mode(mode: OutputMode) -> Option<Self> {
match mode {
OutputMode::Mp4 => Some(VideoCodec::H264),
OutputMode::Webm => Some(VideoCodec::Vp9),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct VideoOptions {
pub orb_size: f32,
pub blur: f32,
pub saturation: f32,
pub direction: MotionDirection,
pub speed: MotionSpeed,
pub seed: u64,
pub count: Option<usize>,
pub background: [u8; 4],
pub shape: OrbShape,
}
impl Default for VideoOptions {
fn default() -> Self {
let a = AnimateOptions::default();
Self {
orb_size: a.orb_size,
blur: a.blur,
saturation: a.saturation,
direction: a.direction,
speed: a.speed,
seed: a.seed,
count: a.count,
background: a.background,
shape: a.shape,
}
}
}
#[derive(Debug)]
pub enum VideoError {
FfmpegNotFound,
FfmpegFailed { status: ExitStatus, stderr: String },
Io(io::Error),
FrameSave(image::ImageError),
InvalidDuration,
}
impl std::fmt::Display for VideoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FfmpegNotFound => write!(
f,
"ffmpeg not found in PATH; install ffmpeg (e.g. apt install ffmpeg / brew install ffmpeg) and retry"
),
Self::FfmpegFailed { status, stderr } => {
write!(f, "ffmpeg failed with {status}: {stderr}")
}
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::FrameSave(e) => write!(f, "failed to encode frame: {e}"),
Self::InvalidDuration => write!(
f,
"duration_ms must be in 1000..={MAX_DURATION_MS} (1s..=10min)"
),
}
}
}
impl std::error::Error for VideoError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
Self::FrameSave(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for VideoError {
fn from(e: io::Error) -> Self {
VideoError::Io(e)
}
}
pub fn calc_frame_count(duration_ms: u64) -> Result<usize, VideoError> {
if !(1000..=MAX_DURATION_MS).contains(&duration_ms) {
return Err(VideoError::InvalidDuration);
}
let n = duration_ms
.checked_mul(VIDEO_FPS as u64)
.ok_or(VideoError::InvalidDuration)?
/ 1000;
Ok(n as usize)
}
pub fn render_video(
clusters: &[Cluster],
opts: &VideoOptions,
output: &Path,
duration_ms: u64,
codec: VideoCodec,
) -> Result<(), VideoError> {
let total = calc_frame_count(duration_ms)?;
eprintln!("orber: rendering {total} frames at {VIDEO_FPS}fps...");
let frame_opts = AnimateOptions {
width: VIDEO_WIDTH,
height: VIDEO_HEIGHT,
orb_size: opts.orb_size,
blur: opts.blur,
saturation: opts.saturation,
direction: opts.direction,
speed: opts.speed,
seed: opts.seed,
count: opts.count,
background: opts.background,
shape: opts.shape,
};
let temp_dir = tempfile::TempDir::new()?;
let cache = precompute_orb_params(&frame_opts, clusters);
let progress_step = (total / 10).max(1);
for i in 0..total {
let t = i as f32 / total as f32;
let frame = render_frame_with_params(clusters, &frame_opts, &cache, t);
let path = temp_dir.path().join(format!("frame_{i:05}.png"));
frame.save(&path).map_err(VideoError::FrameSave)?;
if i > 0 && i % progress_step == 0 {
let pct = (i * 100) / total;
eprintln!("orber: {pct}% ({i}/{total} frames)");
}
}
eprintln!("orber: invoking ffmpeg ({codec:?})...");
let pattern = temp_dir.path().join("frame_%05d.png");
let fps_str = VIDEO_FPS.to_string();
let mut cmd = Command::new("ffmpeg");
cmd.arg("-y")
.arg("-loglevel")
.arg("error")
.arg("-framerate")
.arg(&fps_str)
.arg("-i")
.arg(&pattern);
match codec {
VideoCodec::H264 => {
cmd.args([
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
]);
}
VideoCodec::Vp9 => {
cmd.args([
"-c:v",
"libvpx-vp9",
"-pix_fmt",
"yuv420p",
"-b:v",
"0",
"-crf",
"32",
]);
}
}
cmd.arg(output);
let result = cmd.output();
let out = match result {
Ok(o) => o,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(VideoError::FfmpegNotFound);
}
Err(e) => return Err(VideoError::Io(e)),
};
if !out.status.success() {
return Err(VideoError::FfmpegFailed {
status: out.status,
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_count_math() {
assert_eq!(calc_frame_count(5000).unwrap(), 150);
assert_eq!(calc_frame_count(1000).unwrap(), 30);
match calc_frame_count(999) {
Err(VideoError::InvalidDuration) => {}
other => panic!("expected InvalidDuration, got {other:?}"),
}
match calc_frame_count(33) {
Err(VideoError::InvalidDuration) => {}
other => panic!("expected InvalidDuration, got {other:?}"),
}
match calc_frame_count(0) {
Err(VideoError::InvalidDuration) => {}
other => panic!("expected InvalidDuration, got {other:?}"),
}
}
#[test]
fn frame_count_max_duration() {
assert_eq!(calc_frame_count(MAX_DURATION_MS).unwrap(), 18_000);
match calc_frame_count(MAX_DURATION_MS + 1) {
Err(VideoError::InvalidDuration) => {}
other => panic!("expected InvalidDuration for over-cap, got {other:?}"),
}
}
#[test]
fn frame_count_overflow_safe() {
match calc_frame_count(u64::MAX) {
Err(VideoError::InvalidDuration) => {}
other => panic!("expected InvalidDuration for u64::MAX, got {other:?}"),
}
}
#[test]
fn codec_from_output_mode() {
assert_eq!(
VideoCodec::from_output_mode(OutputMode::Mp4),
Some(VideoCodec::H264)
);
assert_eq!(
VideoCodec::from_output_mode(OutputMode::Webm),
Some(VideoCodec::Vp9)
);
assert_eq!(VideoCodec::from_output_mode(OutputMode::Png), None);
assert_eq!(VideoCodec::from_output_mode(OutputMode::Webp), None);
assert_eq!(VideoCodec::from_output_mode(OutputMode::Svg), None);
assert_eq!(VideoCodec::from_output_mode(OutputMode::Css), None);
}
#[test]
fn video_error_display() {
let msg = format!("{}", VideoError::FfmpegNotFound);
assert!(
msg.contains("ffmpeg"),
"FfmpegNotFound display should mention ffmpeg: {msg}"
);
assert!(
msg.contains("install"),
"FfmpegNotFound display should mention install: {msg}"
);
}
#[test]
fn video_options_default_matches_animate() {
let v = VideoOptions::default();
let a = AnimateOptions::default();
assert_eq!(v.orb_size, a.orb_size);
assert_eq!(v.blur, a.blur);
assert_eq!(v.saturation, a.saturation);
assert_eq!(v.direction, a.direction);
assert_eq!(v.speed, a.speed);
assert_eq!(v.seed, a.seed);
}
}