use std::io::Write;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::OnceLock;
use crate::error::{FerriError, Result};
fn f64_to_i64(v: f64) -> i64 {
if !v.is_finite() {
return 0;
}
format!("{v:.0}").parse::<i64>().unwrap_or(0)
}
fn detect_encoder() -> &'static str {
static ENCODER: OnceLock<&'static str> = OnceLock::new();
ENCODER.get_or_init(|| {
let has_vpx = Command::new("ffmpeg")
.args(["-hide_banner", "-encoders"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("libvpx"));
if has_vpx { "vpx" } else { "h264" }
})
}
#[must_use]
pub fn video_extension() -> &'static str {
if detect_encoder() == "vpx" { "webm" } else { "mp4" }
}
#[must_use]
pub fn video_content_type() -> &'static str {
if detect_encoder() == "vpx" {
"video/webm"
} else {
"video/mp4"
}
}
fn find_ffmpeg() -> Result<&'static str> {
static FFMPEG: OnceLock<Result<&'static str>> = OnceLock::new();
FFMPEG
.get_or_init(|| {
match Command::new("ffmpeg")
.arg("-version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
{
Ok(s) if s.success() => Ok("ffmpeg"),
_ => Err(FerriError::Unsupported(
"ffmpeg not found. Install ffmpeg to enable video recording:\n \
macOS: brew install ffmpeg\n \
Linux: apt install ffmpeg\n \
Windows: winget install ffmpeg"
.into(),
)),
}
})
.clone()
}
fn spawn_ffmpeg(output_path: &Path, width: u32, height: u32, fps: u32) -> Result<Child> {
let ffmpeg = find_ffmpeg()?;
let w = width & !1;
let h = height & !1;
let mut args: Vec<String> = vec![
"-loglevel".into(),
"error".into(),
"-f".into(),
"image2pipe".into(),
"-avioflags".into(),
"direct".into(),
"-fpsprobesize".into(),
"0".into(),
"-probesize".into(),
"32".into(),
"-analyzeduration".into(),
"0".into(),
"-c:v".into(),
"mjpeg".into(),
"-i".into(),
"pipe:0".into(),
"-y".into(),
"-an".into(),
"-r".into(),
fps.to_string(),
];
if detect_encoder() == "vpx" {
args.extend(
[
"-c:v",
"vp8",
"-qmin",
"0",
"-qmax",
"50",
"-crf",
"8",
"-deadline",
"realtime",
"-speed",
"8",
"-b:v",
"1M",
"-threads",
"1",
]
.map(String::from),
);
} else {
args.extend(
[
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-tune",
"fastdecode",
"-threads",
"1",
]
.map(String::from),
);
}
args.extend(["-vf".into(), format!("pad={w}:{h}:0:0:gray,crop={w}:{h}:0:0")]);
args.push(output_path.to_string_lossy().into_owned());
Command::new(ffmpeg)
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| FerriError::backend(format!("failed to spawn ffmpeg: {e}")))
}
fn encode_blank(output_path: &Path, width: u32, height: u32, fps: u32) -> Result<()> {
let ffmpeg = find_ffmpeg()?;
let w = width & !1;
let h = height & !1;
let mut args: Vec<String> = vec![
"-loglevel".into(),
"error".into(),
"-y".into(),
"-f".into(),
"lavfi".into(),
"-i".into(),
format!("color=c=white:s={w}x{h}:d=1:r={fps}"),
"-an".into(),
];
if detect_encoder() == "vpx" {
args.extend(
[
"-c:v",
"vp8",
"-qmin",
"0",
"-qmax",
"50",
"-crf",
"8",
"-deadline",
"realtime",
"-speed",
"8",
"-b:v",
"1M",
"-threads",
"1",
]
.map(String::from),
);
} else {
args.extend(["-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-threads", "1"].map(String::from));
}
args.push(output_path.to_string_lossy().into_owned());
let output = Command::new(ffmpeg)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| FerriError::backend(format!("failed to spawn ffmpeg (blank): {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(FerriError::backend(format!(
"ffmpeg blank-encode exited with {}: {stderr}",
output.status
)));
}
Ok(())
}
pub fn encode_stream(
mut rx: tokio::sync::mpsc::Receiver<(Vec<u8>, f64)>,
output_path: &Path,
width: u32,
height: u32,
fps: u32,
) -> Result<()> {
let Some(first_frame) = rx.blocking_recv() else {
return encode_blank(output_path, width, height, fps);
};
let mut child = spawn_ffmpeg(output_path, width, height, fps)?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| FerriError::backend("failed to open ffmpeg stdin"))?;
let mut first_ts: Option<f64> = None;
let mut last_frame: Option<Vec<u8>> = None;
let mut last_frame_number: i64 = -1;
let mut next = Some(first_frame);
while let Some((jpeg_bytes, ts)) = next.take().or_else(|| rx.blocking_recv()) {
let first = *first_ts.get_or_insert(ts);
let frame_number = f64_to_i64(((ts - first) * f64::from(fps)).floor());
if let Some(ref prev) = last_frame {
for _ in (last_frame_number + 1)..frame_number {
if stdin.write_all(prev).is_err() {
break;
}
}
}
if stdin.write_all(&jpeg_bytes).is_err() {
break;
}
last_frame = Some(jpeg_bytes);
last_frame_number = frame_number;
}
if let Some(ref frame) = last_frame {
for _ in 0..fps {
if stdin.write_all(frame).is_err() {
break;
}
}
}
drop(stdin);
let output = child
.wait_with_output()
.map_err(|e| FerriError::backend(format!("ffmpeg wait: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(FerriError::backend(format!(
"ffmpeg exited with {}: {stderr}",
output.status
)));
}
Ok(())
}
pub fn encode_frames(frames: &[(Vec<u8>, f64)], output_path: &Path, width: u32, height: u32, fps: u32) -> Result<()> {
let mut child = spawn_ffmpeg(output_path, width, height, fps)?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| FerriError::backend("failed to open ffmpeg stdin"))?;
let mut first_ts: Option<f64> = None;
let mut last_frame: Option<&[u8]> = None;
let mut last_frame_number: i64 = -1;
for (jpeg_bytes, ts) in frames {
let first = *first_ts.get_or_insert(*ts);
let frame_number = f64_to_i64(((ts - first) * f64::from(fps)).floor());
if let Some(prev) = last_frame {
for _ in (last_frame_number + 1)..frame_number {
if stdin.write_all(prev).is_err() {
break;
}
}
}
if stdin.write_all(jpeg_bytes).is_err() {
break;
}
last_frame = Some(jpeg_bytes);
last_frame_number = frame_number;
}
if let Some(frame) = last_frame {
for _ in 0..fps {
if stdin.write_all(frame).is_err() {
break;
}
}
}
drop(stdin);
let output = child
.wait_with_output()
.map_err(|e| FerriError::backend(format!("ffmpeg wait: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(FerriError::backend(format!(
"ffmpeg exited with {}: {stderr}",
output.status
)));
}
Ok(())
}