use std::collections::{BTreeSet, VecDeque};
use std::path::Path;
use ffmpeg_sidecar::child::FfmpegChild;
use ffmpeg_sidecar::command::FfmpegCommand;
use ffmpeg_sidecar::event::{FfmpegEvent, LogLevel};
use ffmpeg_sidecar::iter::FfmpegIterator;
use super::FFmpegVersion;
use super::ffmpeg::{Error, check_ffmpeg_version};
use crate::{HwAccel, Mp4TranscodeOptions, VideoCodec};
const STDERR_TAIL_LINES: usize = 40;
pub struct TranscodedMp4 {
child: FfmpegChild,
events: FfmpegIterator,
stderr_tail: VecDeque<String>,
debug_name: String,
done: bool,
}
impl Iterator for TranscodedMp4 {
type Item = Result<Vec<u8>, Error>;
fn next(&mut self) -> Option<Self::Item> {
if self.done {
return None;
}
loop {
match self.events.next() {
Some(FfmpegEvent::OutputChunk(chunk)) => return Some(Ok(chunk)),
Some(FfmpegEvent::Log(LogLevel::Error | LogLevel::Fatal, line)) => {
self.push_tail(line);
}
Some(FfmpegEvent::Error(err)) => {
self.done = true;
return Some(Err(Error::FfmpegSidecar(err)));
}
Some(FfmpegEvent::Done) | None => {
self.done = true;
return self.finish().err().map(Err);
}
Some(_) => {}
}
}
}
}
impl TranscodedMp4 {
fn push_tail(&mut self, line: String) {
if self.stderr_tail.len() >= STDERR_TAIL_LINES {
self.stderr_tail.pop_front();
}
self.stderr_tail.push_back(line);
}
fn finish(&mut self) -> Result<(), Error> {
let status = self
.child
.wait()
.map_err(|err| Error::FfmpegSidecar(format!("failed to await ffmpeg: {err}")))?;
if status.success() {
return Ok(());
}
let tail = Vec::from(std::mem::take(&mut self.stderr_tail)).join("\n");
Err(Error::Ffmpeg(format!(
"ffmpeg exited with {status} while transcoding {debug_name}:\n{tail}",
debug_name = self.debug_name,
)))
}
}
impl Drop for TranscodedMp4 {
fn drop(&mut self) {
if !self.done {
self.child.kill().ok();
self.child.wait().ok();
}
}
}
pub fn transcode_mp4(
input_path: &Path,
source_codec: VideoCodec,
options: &Mp4TranscodeOptions,
debug_name: &str,
) -> Result<TranscodedMp4, Error> {
re_tracing::profile_function!();
let ffmpeg_path = options.ffmpeg_override.as_deref();
check_ffmpeg_version(FFmpegVersion::for_executable_blocking(ffmpeg_path))?;
let target = options.output_codec.clone().unwrap_or(source_codec);
let available = available_encoders(ffmpeg_path);
let spec = resolve_encoder(&target, options.hardware_acceleration, &available)?;
let mut command = ffmpeg_command(ffmpeg_path);
command.input(input_path.to_string_lossy().as_ref());
command.args(["-c:v", spec.name]);
command.args(spec.rate_control.iter().map(String::as_str));
if spec.needs_bf0 {
command.args(["-bf", "0"]);
}
if let Some(gop) = options.gop_size {
command.args(["-g", &gop.to_string()]);
command.args(["-force_key_frames", &format!("expr:gte(n,n_forced*{gop})")]);
}
command.args(spec.extra_output_args.iter().map(String::as_str));
let mut child = command
.fps_mode("passthrough") .args(["-an"]) .args(["-movflags", "frag_keyframe+empty_moov+default_base_moof"])
.args(["-f", "mp4"])
.output("pipe:1")
.spawn()
.map_err(Error::FailedToStartFfmpeg)?;
let events = child
.iter()
.map_err(|err| Error::NoIterator(err.to_string()))?;
Ok(TranscodedMp4 {
child,
events,
stderr_tail: VecDeque::with_capacity(STDERR_TAIL_LINES),
debug_name: debug_name.to_owned(),
done: false,
})
}
struct EncoderSpec {
name: &'static str,
rate_control: Vec<String>,
extra_output_args: Vec<String>,
needs_bf0: bool,
}
impl EncoderSpec {
fn new(name: &'static str, rate_control: &[&str], needs_bf0: bool) -> Self {
Self {
name,
rate_control: rate_control.iter().map(|s| (*s).to_owned()).collect(),
extra_output_args: Vec::new(),
needs_bf0,
}
}
fn with_extra(mut self, extra: &[&str]) -> Self {
self.extra_output_args = extra.iter().map(|s| (*s).to_owned()).collect();
self
}
}
fn resolve_encoder(
target: &VideoCodec,
hw: HwAccel,
available: &BTreeSet<String>,
) -> Result<EncoderSpec, Error> {
let (gpu, sw): (Vec<EncoderSpec>, Vec<EncoderSpec>) = match target {
VideoCodec::H264 => (
vec![
EncoderSpec::new("h264_nvenc", &["-rc", "vbr", "-cq", "23"], true),
EncoderSpec::new("h264_videotoolbox", &["-q:v", "55"], true),
],
vec![EncoderSpec::new("libx264", &["-crf", "18"], true)],
),
VideoCodec::H265 => (
vec![
EncoderSpec::new("hevc_nvenc", &["-rc", "vbr", "-cq", "23"], true),
EncoderSpec::new("hevc_videotoolbox", &["-q:v", "55"], true),
],
vec![EncoderSpec::new("libx265", &["-crf", "20"], true)],
),
VideoCodec::AV1 => (
vec![EncoderSpec::new(
"av1_nvenc",
&["-rc", "vbr", "-cq", "30"],
false,
)],
vec![
EncoderSpec::new("libsvtav1", &["-crf", "30"], false),
EncoderSpec::new("libaom-av1", &["-crf", "30", "-b:v", "0"], false),
],
),
VideoCodec::VP9 => (
Vec::new(),
vec![EncoderSpec::new(
"libvpx-vp9",
&["-crf", "31", "-b:v", "0"],
false,
)],
),
VideoCodec::VP8 => (
Vec::new(),
vec![
EncoderSpec::new("libvpx", &["-crf", "10", "-b:v", "2M"], false)
.with_extra(&["-strict", "experimental"]),
],
),
VideoCodec::ImageSequence(_) => {
return Err(Error::BadVideoData(format!(
"Cannot transcode to a non-video (image-sequence) codec {target:?}"
)));
}
};
if hw == HwAccel::Auto {
if let Some(spec) = gpu.into_iter().find(|c| available.contains(c.name)) {
return Ok(spec);
}
re_log::warn_once!(
"No hardware encoder available in this FFmpeg build for {target:?}; using a software encoder"
);
}
if let Some(spec) = sw.into_iter().find(|c| available.contains(c.name)) {
return Ok(spec);
}
Err(Error::NoEncoderForCodec {
codec: target.clone(),
})
}
fn ffmpeg_command(ffmpeg_path: Option<&Path>) -> FfmpegCommand {
match ffmpeg_path {
Some(path) => FfmpegCommand::new_with_path(path),
None => FfmpegCommand::new(),
}
}
fn available_encoders(ffmpeg_path: Option<&Path>) -> BTreeSet<String> {
let output = match ffmpeg_command(ffmpeg_path)
.as_inner_mut()
.args(["-hide_banner", "-encoders"])
.stderr(std::process::Stdio::null())
.output()
{
Ok(output) => output,
Err(err) => {
re_log::warn_once!("Failed to probe FFmpeg encoders: {err}");
return BTreeSet::new();
}
};
parse_encoder_names(&String::from_utf8_lossy(&output.stdout))
}
fn parse_encoder_names(stdout: &str) -> BTreeSet<String> {
let body = stdout.split_once("------").map_or(stdout, |(_, rest)| rest);
body.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let flags = parts.next()?;
(flags.len() == 6 && flags.starts_with('V'))
.then(|| parts.next())
.flatten()
.map(str::to_owned)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{parse_encoder_names, resolve_encoder};
use crate::{HwAccel, VideoCodec};
fn available_set(names: &[&str]) -> std::collections::BTreeSet<String> {
names.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn software_specs_per_codec() {
let all = available_set(&["libx264", "libx265", "libsvtav1", "libvpx-vp9", "libvpx"]);
let cases = [
(VideoCodec::H264, "libx264", vec!["-crf", "18"], true),
(VideoCodec::H265, "libx265", vec!["-crf", "20"], true),
(VideoCodec::AV1, "libsvtav1", vec!["-crf", "30"], false),
(
VideoCodec::VP9,
"libvpx-vp9",
vec!["-crf", "31", "-b:v", "0"],
false,
),
(
VideoCodec::VP8,
"libvpx",
vec!["-crf", "10", "-b:v", "2M"],
false,
),
];
for (codec, name, rc, needs_bf0) in cases {
let spec = resolve_encoder(&codec, HwAccel::Off, &all).expect("encoder available");
assert_eq!(spec.name, name, "{codec:?}");
assert_eq!(spec.rate_control, rc, "{codec:?}");
assert_eq!(spec.needs_bf0, needs_bf0, "{codec:?}");
}
}
#[test]
fn vp8_carries_strict_experimental() {
let spec = resolve_encoder(&VideoCodec::VP8, HwAccel::Off, &available_set(&["libvpx"]))
.expect("libvpx available");
assert_eq!(spec.extra_output_args, vec!["-strict", "experimental"]);
}
#[test]
fn auto_prefers_hardware_then_falls_back_to_software() {
let with_gpu = available_set(&["h264_nvenc", "libx264"]);
let spec = resolve_encoder(&VideoCodec::H264, HwAccel::Auto, &with_gpu).unwrap();
assert_eq!(spec.name, "h264_nvenc");
let sw_only = available_set(&["libx264"]);
let spec = resolve_encoder(&VideoCodec::H264, HwAccel::Auto, &sw_only).unwrap();
assert_eq!(spec.name, "libx264");
}
#[test]
fn auto_for_codec_without_gpu_encoder_uses_software() {
let spec = resolve_encoder(
&VideoCodec::VP9,
HwAccel::Auto,
&available_set(&["libvpx-vp9"]),
)
.unwrap();
assert_eq!(spec.name, "libvpx-vp9");
}
#[test]
fn av1_falls_back_to_libaom_when_svtav1_missing() {
let spec = resolve_encoder(
&VideoCodec::AV1,
HwAccel::Off,
&available_set(&["libaom-av1"]),
)
.unwrap();
assert_eq!(spec.name, "libaom-av1");
assert_eq!(spec.rate_control, vec!["-crf", "30", "-b:v", "0"]);
}
#[test]
fn no_encoder_available_is_an_error() {
let err = resolve_encoder(&VideoCodec::VP9, HwAccel::Off, &available_set(&[]));
assert!(matches!(
err,
Err(super::Error::NoEncoderForCodec {
codec: VideoCodec::VP9
})
));
}
#[test]
fn parse_encoder_names_extracts_video_encoders() {
let sample = "\
Encoders:
V..... = Video
A..... = Audio
------
V....D libx264 libx264 H.264 / AVC
V....D h264_nvenc NVIDIA NVENC H.264 encoder
A....D aac AAC (Advanced Audio Coding)
V....D libvpx-vp9 libvpx VP9
";
let got = parse_encoder_names(sample);
assert!(got.contains("libx264"));
assert!(got.contains("h264_nvenc"));
assert!(got.contains("libvpx-vp9"));
assert!(!got.contains("aac"));
}
}