#[cfg(feature = "encode-ffmpeg")]
use std::fmt::Write as _;
#[cfg(feature = "encode-gif")]
use std::io::BufWriter;
use std::{fs, path::Path, time::Duration};
#[cfg(feature = "encode-ffmpeg")]
use tokio::process::Command;
use super::Frame;
#[cfg(feature = "encode-gif")]
use super::FrameFormat;
#[cfg(feature = "encode-ffmpeg")]
use super::{Encoding, RecordedRegion, RecordingOptions};
use crate::error::{Result, VoidCrawlError};
fn frame_delays(frames: &[Frame]) -> Vec<Duration> {
let mut delays = Vec::with_capacity(frames.len());
for pair in frames.windows(2) {
let gap = match (pair.first(), pair.get(1)) {
(Some(a), Some(b)) => b.offset.saturating_sub(a.offset),
_ => Duration::from_millis(100),
};
delays.push(gap);
}
let last = delays.last().copied().unwrap_or(Duration::from_millis(100));
delays.push(last);
delays
}
#[cfg(feature = "encode-gif")]
pub(super) fn gif(frames: &[Frame], format: FrameFormat, path: &Path) -> Result<()> {
use image::{
Delay, Frame as AnimationFrame,
codecs::gif::{GifEncoder, Repeat},
load_from_memory_with_format,
};
if frames.is_empty() {
return Err(VoidCrawlError::RecordingEncodeError(
"no frames were captured; nothing to encode".into(),
));
}
let file = fs::File::create(path).map_err(|e| {
VoidCrawlError::RecordingEncodeError(format!("create {}: {e}", path.display()))
})?;
let mut encoder = GifEncoder::new(BufWriter::new(file));
encoder
.set_repeat(Repeat::Infinite)
.map_err(|e| VoidCrawlError::RecordingEncodeError(format!("gif repeat: {e}")))?;
let delays = frame_delays(frames);
for (frame, delay) in frames.iter().zip(delays) {
let img = load_from_memory_with_format(&frame.data, format.as_image())
.map_err(|e| VoidCrawlError::RecordingEncodeError(format!("decode frame: {e}")))?;
let buffer = img.to_rgba8();
let animation_frame =
AnimationFrame::from_parts(buffer, 0, 0, Delay::from_saturating_duration(delay));
encoder
.encode_frame(animation_frame)
.map_err(|e| VoidCrawlError::RecordingEncodeError(format!("gif frame: {e}")))?;
}
Ok(())
}
#[cfg(feature = "encode-ffmpeg")]
pub(super) async fn ffmpeg(
region: &RecordedRegion,
encoding: Encoding,
path: &Path,
opts: &RecordingOptions,
) -> Result<()> {
if region.frames.is_empty() {
return Err(VoidCrawlError::RecordingEncodeError(
"no frames were captured; nothing to encode".into(),
));
}
let staging = tempfile::tempdir()
.map_err(|e| VoidCrawlError::RecordingEncodeError(format!("staging dir: {e}")))?;
let ext = opts.format.extension();
let delays = frame_delays(®ion.frames);
let mut concat = String::new();
for (frame, delay) in region.frames.iter().zip(delays) {
let name = format!("{:05}.{ext}", frame.index);
let file = staging.path().join(&name);
fs::write(&file, &frame.data).map_err(|e| {
VoidCrawlError::RecordingEncodeError(format!("write {}: {e}", file.display()))
})?;
let _ = writeln!(concat, "file '{name}'\nduration {:.4}", delay.as_secs_f64());
}
if let Some(last) = region.frames.last() {
let _ = writeln!(concat, "file '{:05}.{ext}'", last.index);
}
let list = staging.path().join("frames.txt");
fs::write(&list, concat).map_err(|e| {
VoidCrawlError::RecordingEncodeError(format!("write {}: {e}", list.display()))
})?;
let codec: &[&str] = match encoding {
Encoding::Mp4 => &["-c:v", "libx264", "-pix_fmt", "yuv420p"],
Encoding::WebM => &["-c:v", "libvpx-vp9", "-pix_fmt", "yuv420p"],
Encoding::Gif => &[],
};
let output = Command::new("ffmpeg")
.arg("-y")
.args(["-f", "concat", "-safe", "0", "-i"])
.arg(&list)
.args(["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"])
.args(codec)
.arg(path)
.output()
.await
.map_err(|e| {
VoidCrawlError::RecordingEncodeError(format!(
"running ffmpeg: {e} (is it installed and on PATH?)"
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let tail: String = stderr.lines().rev().take(8).collect::<Vec<_>>().join("\n");
return Err(VoidCrawlError::RecordingEncodeError(format!(
"ffmpeg exited with {}: {tail}",
output.status
)));
}
Ok(())
}