use std::path::PathBuf;
use rff::transcode::{InputSpec, MapSelector, MapSpec, OutputSpec, StreamCodec, TranscodeSpec};
use rff_core::{CodecId, Dictionary, MediaType};
fn parse_map(spec: &str) -> Option<MapSpec> {
let mut parts = spec.split(':');
let input: usize = parts.next()?.parse().ok()?;
let selector = match parts.next() {
None => MapSelector::All,
Some("v") | Some("V") => MapSelector::Kind(MediaType::Video),
Some("a") => MapSelector::Kind(MediaType::Audio),
Some(idx) => MapSelector::Index(idx.parse().ok()?),
};
Some(MapSpec { input, selector })
}
pub enum Action {
Version,
Help,
ListCodecs,
ListFormats,
Transcode(TranscodeSpec),
}
pub struct Cli {
pub hide_banner: bool,
pub loglevel: Option<String>,
pub warnings: Vec<String>,
pub action: Action,
}
pub fn parse(args: &[String]) -> Result<Cli, String> {
let mut hide_banner = false;
let mut loglevel = None;
let mut warnings = Vec::new();
let mut action_override: Option<Action> = None;
let mut inputs: Vec<InputSpec> = Vec::new();
let mut pending_input_format: Option<String> = None;
let mut out_format: Option<String> = None;
let mut video_codec: Option<CodecId> = None;
let mut audio_codec: Option<CodecId> = None;
let mut subtitle_codec: Option<CodecId> = None;
let mut audio_sample_format: Option<rff_core::SampleFormat> = None;
let mut video_opts = Dictionary::new();
let mut audio_opts = Dictionary::new();
let mut shared_opts = Dictionary::new();
let mut max_video_frames: Option<u64> = None;
let mut video_filters: Option<String> = None;
let mut audio_filters: Option<String> = None;
let mut filter_complex: Option<String> = None;
let mut maps: Vec<MapSpec> = Vec::new();
let mut overwrite = false;
let mut output_path: Option<PathBuf> = None;
let mut trim_start: Option<f64> = None;
let mut trim_duration: Option<f64> = None;
let mut trim_to: Option<f64> = None;
let mut frame_rate: Option<(u32, u32)> = None;
let mut audio_rate: Option<u32> = None;
let mut audio_channels: Option<u16> = None;
let mut metadata = Dictionary::new();
let mut format_options = Dictionary::new();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
let Some(opt) = arg.strip_prefix('-').filter(|_| arg != "-") else {
if output_path.is_some() {
warnings.push(format!(
"multiple output files given; using the last (`{arg}`)"
));
}
output_path = Some(PathBuf::from(arg));
i += 1;
continue;
};
let (base, spec) = match opt.split_once(':') {
Some((b, s)) => (b, Some(s)),
None => (opt, None),
};
match base {
"version" => action_override = Some(Action::Version),
"h" | "help" | "?" => action_override = Some(Action::Help),
"codecs" | "encoders" | "decoders" => action_override = Some(Action::ListCodecs),
"formats" | "muxers" | "demuxers" => action_override = Some(Action::ListFormats),
"hide_banner" => hide_banner = true,
"y" => overwrite = true,
"n" => overwrite = false,
"loglevel" | "v" => loglevel = Some(take_value(args, &mut i, arg)?),
"i" => {
let path = take_value(args, &mut i, arg)?;
inputs.push(InputSpec {
path: PathBuf::from(path),
format: pending_input_format.take(),
});
}
"f" => {
let fmt = take_value(args, &mut i, arg)?;
if inputs.is_empty() {
pending_input_format = Some(fmt);
} else {
out_format = Some(fmt);
}
}
"c" | "codec" => {
let name = take_value(args, &mut i, arg)?;
apply_codec(
spec,
&name,
&mut video_codec,
&mut audio_codec,
&mut subtitle_codec,
&mut audio_sample_format,
&mut warnings,
);
}
"vcodec" => {
let name = take_value(args, &mut i, arg)?;
apply_codec(
Some("v"),
&name,
&mut video_codec,
&mut audio_codec,
&mut subtitle_codec,
&mut audio_sample_format,
&mut warnings,
);
}
"acodec" => {
let name = take_value(args, &mut i, arg)?;
apply_codec(
Some("a"),
&name,
&mut video_codec,
&mut audio_codec,
&mut subtitle_codec,
&mut audio_sample_format,
&mut warnings,
);
}
"scodec" => {
let name = take_value(args, &mut i, arg)?;
apply_codec(
Some("s"),
&name,
&mut video_codec,
&mut audio_codec,
&mut subtitle_codec,
&mut audio_sample_format,
&mut warnings,
);
}
"map" => {
let value = take_value(args, &mut i, arg)?;
match parse_map(&value) {
Some(m) => maps.push(m),
None => warnings.push(format!("ignoring invalid -map `{value}`")),
}
}
"vf" => video_filters = Some(take_value(args, &mut i, arg)?),
"af" => audio_filters = Some(take_value(args, &mut i, arg)?),
"filter_complex" | "lavfi" => filter_complex = Some(take_value(args, &mut i, arg)?),
"filter" => {
let value = take_value(args, &mut i, arg)?;
match spec {
Some(s) if s.starts_with('v') => video_filters = Some(value),
Some(s) if s.starts_with('a') => audio_filters = Some(value),
_ => warnings.push(format!("ignoring filter spec `{value}`")),
}
}
"ss" => trim_start = Some(parse_time(&take_value(args, &mut i, arg)?)?),
"t" => trim_duration = Some(parse_time(&take_value(args, &mut i, arg)?)?),
"to" => trim_to = Some(parse_time(&take_value(args, &mut i, arg)?)?),
"r" | "framerate" => frame_rate = Some(parse_rate(&take_value(args, &mut i, arg)?)?),
"s" | "video_size" => {
let value = take_value(args, &mut i, arg)?;
let (w, h) = value
.split_once(['x', 'X'])
.and_then(|(w, h)| Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?)))
.ok_or_else(|| format!("-s: expected WxH, got `{value}`"))?;
let scale = format!("scale={w}:{h}");
video_filters = Some(match video_filters.take() {
Some(chain) => format!("{chain},{scale}"),
None => scale,
});
}
"ar" => {
let value = take_value(args, &mut i, arg)?;
audio_rate = Some(
value
.parse()
.map_err(|_| format!("-ar: bad sample rate `{value}`"))?,
);
}
"ac" => {
let value = take_value(args, &mut i, arg)?;
audio_channels = Some(
value
.parse()
.map_err(|_| format!("-ac: bad channel count `{value}`"))?,
);
}
"hls_time" => {
let value = take_value(args, &mut i, arg)?;
format_options.set("hls_time", value);
}
"seg_duration" => {
let value = take_value(args, &mut i, arg)?;
format_options.set("seg_duration", value);
}
"hls_list_size" => {
let _ = take_value(args, &mut i, arg)?;
warnings.push("-hls_list_size: HLS output is a VOD playlist; ignored".into());
}
"metadata" => {
let value = take_value(args, &mut i, arg)?;
match value.split_once('=') {
Some((k, v)) => metadata.set(k.trim(), v),
None => warnings.push(format!("-metadata: expected key=value, got `{value}`")),
}
}
"b" => {
let value = take_value(args, &mut i, arg)?;
match spec {
Some(s) if s.starts_with('a') => audio_opts.set("b", value),
_ => video_opts.set("b", value),
}
}
"frames" | "vframes" => {
let value = take_value(args, &mut i, arg)?;
match value.parse::<u64>() {
Ok(n) => max_video_frames = Some(n),
Err(_) => warnings.push(format!("ignoring non-numeric -{arg} {value}")),
}
}
"crf" | "qp" | "preset" | "profile" | "g" | "pass" | "q" | "qscale" | "cpu-used"
| "speed" | "lag" | "lag-in-frames" | "arnr-strength" | "dispatch-budget"
| "jpeg_quality" | "sampling" | "jpeg_sampling" | "progressive"
| "optimize_huffman" | "restart_interval" | "trellis" | "pred" | "png_auto_type"
| "png_auto_config" => {
let value = take_value(args, &mut i, arg)?;
if base == "pass" && value != "1" {
warnings
.push("two-pass (-pass 2) is parsed but runs single-pass for now".into());
}
match spec {
Some(s) if s.starts_with('a') => audio_opts.set(base, value),
_ => video_opts.set(base, value),
}
}
"compression_level" | "threads" => {
let value = take_value(args, &mut i, arg)?;
match spec {
Some(s) if s.starts_with('v') => video_opts.set(base, value),
Some(s) if s.starts_with('a') => audio_opts.set(base, value),
_ => shared_opts.set(base, value),
}
}
"application" | "vbr" | "opus_parallel" | "opus_warmup" | "frame_duration" => {
let value = take_value(args, &mut i, arg)?;
match spec {
Some(s) if s.starts_with('v') => video_opts.set(base, value),
_ => audio_opts.set(base, value),
}
}
_ => {
warnings.push(format!("unrecognized option `-{opt}` (ignored)"));
if let Some(next) = args.get(i + 1) {
if !next.starts_with('-') {
i += 1;
}
}
}
}
i += 1;
}
if let Some(action) = action_override {
return Ok(Cli {
hide_banner,
loglevel,
warnings,
action,
});
}
for (key, value) in shared_opts.iter() {
if video_codec.is_some() && video_opts.get(key).is_none() {
video_opts.set(key, value);
}
if audio_codec.is_some() && audio_opts.get(key).is_none() {
audio_opts.set(key, value);
}
}
if !video_opts.is_empty() && video_codec.is_none() {
warnings.push("video options given without -c:v; ignored".into());
}
if !audio_opts.is_empty() && audio_codec.is_none() {
warnings.push("audio options given without -c:a; ignored".into());
}
if audio_codec.is_none()
&& (audio_filters.is_some() || audio_rate.is_some() || audio_channels.is_some())
{
warnings.push("-af/-ar/-ac need a re-encode; pass -c:a (ignored on stream copy)".into());
}
if let Some(vf) = &video_filters {
let mut kept: Vec<&str> = Vec::new();
for token in vf.split(',') {
match token.trim().strip_prefix("fps=") {
Some(rate) => match parse_rate(rate) {
Ok(r) => frame_rate = Some(r),
Err(e) => warnings.push(e),
},
None => kept.push(token),
}
}
if kept.len() != vf.split(',').count() {
let kept = kept.join(",");
video_filters = (!kept.trim().is_empty()).then_some(kept);
}
}
if frame_rate.is_some() && video_codec.is_none() {
warnings.push("-r/-vf fps= need a re-encode; pass -c:v (ignored on stream copy)".into());
}
let trim_end = match (trim_to, trim_duration) {
(Some(to), Some(t)) => Some(to.min(trim_start.unwrap_or(0.0) + t)),
(Some(to), None) => Some(to),
(None, Some(t)) => Some(trim_start.unwrap_or(0.0) + t),
(None, None) => None,
};
let output = output_path.map(|path| OutputSpec {
path,
format: out_format,
video_codec: video_codec.map(|codec| StreamCodec {
codec,
options: video_opts,
sample_format: None,
}),
audio_codec: audio_codec.map(|codec| StreamCodec {
codec,
options: audio_opts,
sample_format: audio_sample_format,
}),
subtitle_codec,
video_filters,
filter_complex,
max_video_frames,
maps,
overwrite,
trim_start,
trim_end,
frame_rate,
audio_rate,
audio_channels,
audio_filters,
metadata,
format_options,
});
let spec = TranscodeSpec {
inputs,
outputs: output.into_iter().collect(),
};
Ok(Cli {
hide_banner,
loglevel,
warnings,
action: Action::Transcode(spec),
})
}
fn take_value(args: &[String], i: &mut usize, opt: &str) -> Result<String, String> {
*i += 1;
args.get(*i)
.cloned()
.ok_or_else(|| format!("option `{opt}` requires an argument"))
}
fn parse_time(value: &str) -> Result<f64, String> {
let value = value.trim();
let bad = || format!("bad time `{value}` (want seconds or [HH:]MM:SS[.mmm])");
if !value.contains(':') {
return value.parse::<f64>().map_err(|_| bad());
}
let parts: Vec<&str> = value.split(':').collect();
if parts.len() > 3 || parts.is_empty() {
return Err(bad());
}
let mut seconds = 0.0;
for p in &parts {
seconds = seconds * 60.0 + p.parse::<f64>().map_err(|_| bad())?;
}
Ok(seconds)
}
fn parse_rate(value: &str) -> Result<(u32, u32), String> {
let value = value.trim();
let bad = || format!("bad frame rate `{value}`");
if let Some((n, d)) = value.split_once('/') {
let (n, d) = (
n.trim().parse::<u32>().map_err(|_| bad())?,
d.trim().parse::<u32>().map_err(|_| bad())?,
);
if n == 0 || d == 0 {
return Err(bad());
}
return Ok((n, d));
}
if let Some((int, frac)) = value.split_once('.') {
let digits = frac.len().min(3) as u32;
let den = 10u32.pow(digits);
let int: u32 = int.parse().map_err(|_| bad())?;
let frac: u32 = frac[..digits as usize].parse().map_err(|_| bad())?;
let num = int * den + frac;
if num == 0 {
return Err(bad());
}
return Ok((num, den));
}
let n: u32 = value.parse().map_err(|_| bad())?;
if n == 0 {
return Err(bad());
}
Ok((n, 1))
}
#[allow(clippy::too_many_arguments)]
fn apply_codec(
spec: Option<&str>,
name: &str,
video: &mut Option<CodecId>,
audio: &mut Option<CodecId>,
subtitle: &mut Option<CodecId>,
audio_format: &mut Option<rff_core::SampleFormat>,
warnings: &mut Vec<String>,
) {
if name == "copy" {
return;
}
let Some(id) = CodecId::from_name(name) else {
warnings.push(format!("unknown codec `{name}` (ignored)"));
return;
};
let pinned = CodecId::sample_format_from_name(name);
match spec {
Some(s) if s.starts_with('v') => *video = Some(id),
Some(s) if s.starts_with('a') => {
*audio = Some(id);
if pinned.is_some() {
*audio_format = pinned;
}
}
Some(s) if s.starts_with('s') => match id.media_type() {
rff_core::MediaType::Subtitle => *subtitle = Some(id),
_ => warnings.push(format!("-c:s: `{name}` is not a subtitle codec (ignored)")),
},
None => match id.media_type() {
rff_core::MediaType::Video => *video = Some(id),
rff_core::MediaType::Audio => {
*audio = Some(id);
if pinned.is_some() {
*audio_format = pinned;
}
}
rff_core::MediaType::Subtitle => *subtitle = Some(id),
_ => {}
},
_ => {}
}
}