use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
};
#[derive(Debug)]
pub enum EncodeOutcome {
Mp4(PathBuf),
Gif {
path: PathBuf,
mp4_failure_reason: Option<String>,
},
FramesOnly {
readme: PathBuf,
},
}
#[must_use]
pub fn ffmpeg_available() -> bool {
Command::new("ffmpeg")
.arg("-version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
#[must_use]
pub fn ffmpeg_command_line(digits: usize, fps: u32, out_name: &str) -> String {
format!(
"ffmpeg -y -framerate {fps} -i \"frame_%0{digits}d.png\" -c:v libx264 -pix_fmt yuv420p -crf 18 \"{out_name}.mp4\""
)
}
pub fn mux_mp4(frame_dir: &Path, digits: usize, fps: u32, out_path: &Path) -> Result<(), String> {
let partial = out_path.with_extension("mp4.partial");
let pattern = format!("frame_%0{digits}d.png");
let output = Command::new("ffmpeg")
.current_dir(frame_dir)
.arg("-y")
.arg("-framerate")
.arg(fps.to_string())
.arg("-i")
.arg(&pattern)
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18"])
.arg(&partial)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("Failed to launch ffmpeg: {e}"))?;
if !output.status.success() {
let _ = std::fs::remove_file(&partial);
return Err(format!(
"ffmpeg exited with status {}{}",
output.status,
last_stderr_line(&output.stderr)
));
}
std::fs::rename(&partial, out_path)
.map_err(|e| format!("Failed to finalize {}: {e}", out_path.display()))
}
fn last_stderr_line(stderr: &[u8]) -> String {
String::from_utf8_lossy(stderr)
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.map_or_else(String::new, |line| format!(" -- {}", line.trim()))
}
pub fn mux_gif(frame_paths: &[PathBuf], fps: u32, out_path: &Path) -> Result<(), String> {
use image::{Delay, Frame, codecs::gif::GifEncoder};
let file = std::fs::File::create(out_path).map_err(|e| e.to_string())?;
let mut encoder = GifEncoder::new_with_speed(file, 10);
let delay_ms = u64::from(1000 / fps.max(1));
for path in frame_paths {
let decoded = image::open(path)
.map_err(|e| format!("{}: {e}", path.display()))?
.to_rgba8();
let frame = Frame::from_parts(
decoded,
0,
0,
Delay::from_saturating_duration(std::time::Duration::from_millis(delay_ms)),
);
encoder.encode_frame(frame).map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn write_readme(
frame_dir: &Path,
digits: usize,
fps: u32,
out_name: &str,
) -> Result<PathBuf, String> {
let path = frame_dir.join("README.txt");
let body = format!(
"This folder holds the exported tilt-performance frame sequence.\n\n\
No video was produced automatically (ffmpeg was not found on PATH, and the \
GIF fallback did not succeed either). To build an MP4 yourself, install \
ffmpeg (https://ffmpeg.org/) and run this from inside this folder:\n\n{}\n",
ffmpeg_command_line(digits, fps, out_name)
);
std::fs::write(&path, body).map_err(|e| e.to_string())?;
Ok(path)
}
#[must_use]
pub fn encode(
frame_dir: &Path,
frame_paths: &[PathBuf],
digits: usize,
fps: u32,
out_name: &str,
) -> EncodeOutcome {
let mp4_path = frame_dir.join(format!("{out_name}.mp4"));
let mp4_failure_reason = if ffmpeg_available() {
match mux_mp4(frame_dir, digits, fps, &mp4_path) {
Ok(()) => return EncodeOutcome::Mp4(mp4_path),
Err(e) => {
tracing::warn!("Tilt video: ffmpeg mux failed, falling back to GIF: {e}");
Some(e)
}
}
} else {
None
};
let gif_path = frame_dir.join(format!("{out_name}.gif"));
match mux_gif(frame_paths, fps, &gif_path) {
Ok(()) => {
return EncodeOutcome::Gif {
path: gif_path,
mp4_failure_reason,
};
}
Err(e) => {
tracing::warn!("Tilt video: GIF fallback failed, leaving the frame sequence: {e}");
}
}
let readme = write_readme(frame_dir, digits, fps, out_name)
.unwrap_or_else(|_| frame_dir.join("README.txt"));
EncodeOutcome::FramesOnly { readme }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ffmpeg_command_line_names_every_required_flag() {
let cmd = ffmpeg_command_line(5, 30, "clip");
assert!(cmd.contains("-framerate 30"));
assert!(cmd.contains("frame_%05d.png"));
assert!(cmd.contains("-pix_fmt yuv420p"));
assert!(cmd.contains("-crf 18"));
assert!(cmd.contains("clip.mp4"));
}
#[test]
fn mux_mp4_leaves_no_output_when_ffmpeg_cannot_run() {
let dir = std::env::temp_dir().join(format!(
"tilt_video_encode_test_{}_{}",
std::process::id(),
line!()
));
let out = dir.join("clip.mp4");
let result = mux_mp4(&dir, 3, 30, &out);
assert!(result.is_err());
assert!(!out.exists());
assert!(!out.with_extension("mp4.partial").exists());
}
#[test]
fn last_stderr_line_picks_the_last_non_empty_line() {
assert_eq!(
last_stderr_line(b"frame= 1 fps=0.0\nUnknown encoder 'libx264'\n\n"),
" -- Unknown encoder 'libx264'"
);
assert_eq!(last_stderr_line(b""), "");
assert_eq!(last_stderr_line(b"\n\n \n"), "");
}
#[test]
fn write_readme_embeds_the_exact_ffmpeg_command() {
let dir =
std::env::temp_dir().join(format!("tilt_video_readme_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = write_readme(&dir, 3, 24, "clip").unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains(&ffmpeg_command_line(3, 24, "clip")));
let _ = std::fs::remove_dir_all(&dir);
}
}