Skip to main content

rff_cli/
args.rs

1//! FFmpeg-compatible argument parsing.
2//!
3//! FFmpeg's CLI is not a flat flag list — it's a small grammar:
4//!
5//! ```text
6//!   ffmpeg [global opts] {[input opts] -i INPUT}... {[output opts] OUTPUT}...
7//! ```
8//!
9//! Options can carry a *stream specifier* after a colon — `-c:v libx264` means
10//! "codec, video stream". This module parses that grammar into a neutral
11//! [`Cli`] and, for transcode invocations, builds an [`rff::transcode::TranscodeSpec`].
12//!
13//! This is a deliberately pragmatic subset: the common options people actually
14//! type. Unknown options are skipped with a warning rather than aborting, and
15//! the recognised set is easy to extend — add an arm to the match in [`parse`].
16
17use std::path::PathBuf;
18
19use rff::transcode::{InputSpec, MapSelector, MapSpec, OutputSpec, StreamCodec, TranscodeSpec};
20use rff_core::{CodecId, Dictionary, MediaType};
21
22/// Parse a `-map` value like `0`, `0:v`, `0:a`, or `0:2`.
23fn parse_map(spec: &str) -> Option<MapSpec> {
24    let mut parts = spec.split(':');
25    let input: usize = parts.next()?.parse().ok()?;
26    let selector = match parts.next() {
27        None => MapSelector::All,
28        Some("v") | Some("V") => MapSelector::Kind(MediaType::Video),
29        Some("a") => MapSelector::Kind(MediaType::Audio),
30        Some(idx) => MapSelector::Index(idx.parse().ok()?),
31    };
32    Some(MapSpec { input, selector })
33}
34
35/// What the user actually asked `ffmpeg` to do. Informational sub-commands
36/// (`-version`, `-codecs`, ...) short-circuit a transcode.
37pub enum Action {
38    Version,
39    Help,
40    ListCodecs,
41    ListFormats,
42    Transcode(TranscodeSpec),
43}
44
45/// A fully parsed command line.
46pub struct Cli {
47    pub hide_banner: bool,
48    pub loglevel: Option<String>,
49    /// Non-fatal parse notes (unknown options, ignored values) to print to stderr.
50    pub warnings: Vec<String>,
51    pub action: Action,
52}
53
54/// Parse `args` (everything after the program name) into a [`Cli`].
55///
56/// Returns `Err` only for hard syntax errors (e.g. an option missing its
57/// required value); soft problems become [`Cli::warnings`].
58pub fn parse(args: &[String]) -> Result<Cli, String> {
59    let mut hide_banner = false;
60    let mut loglevel = None;
61    let mut warnings = Vec::new();
62
63    // Informational sub-commands win over a transcode if present.
64    let mut action_override: Option<Action> = None;
65
66    // --- transcode accumulators ---
67    let mut inputs: Vec<InputSpec> = Vec::new();
68    let mut pending_input_format: Option<String> = None;
69    let mut out_format: Option<String> = None;
70    let mut video_codec: Option<CodecId> = None;
71    let mut audio_codec: Option<CodecId> = None;
72    let mut subtitle_codec: Option<CodecId> = None;
73    // Sample format pinned by the codec NAME (`-c:a pcm_s16le`) or by an
74    // explicit `-sample_fmt`. Kept separate from the codec id, which is
75    // format-agnostic by design.
76    let mut audio_sample_format: Option<rff_core::SampleFormat> = None;
77    let mut video_opts = Dictionary::new();
78    let mut audio_opts = Dictionary::new();
79    // Options given WITHOUT a `:v`/`:a` stream specifier that more than one
80    // codec kind understands (FFmpeg treats `-compression_level` this way).
81    // Held aside and attached after parsing, once we know which codecs actually
82    // exist — attaching eagerly to both made every `-compression_level` on an
83    // image encode emit a spurious "audio options given without -c:a" warning.
84    let mut shared_opts = Dictionary::new();
85    let mut max_video_frames: Option<u64> = None;
86    let mut video_filters: Option<String> = None;
87    let mut audio_filters: Option<String> = None;
88    let mut filter_complex: Option<String> = None;
89    let mut maps: Vec<MapSpec> = Vec::new();
90    let mut overwrite = false;
91    let mut output_path: Option<PathBuf> = None;
92    // Trim window: -ss (start), -t (duration), -to (absolute end).
93    let mut trim_start: Option<f64> = None;
94    let mut trim_duration: Option<f64> = None;
95    let mut trim_to: Option<f64> = None;
96    let mut frame_rate: Option<(u32, u32)> = None;
97    let mut audio_rate: Option<u32> = None;
98    let mut audio_channels: Option<u16> = None;
99    let mut metadata = Dictionary::new();
100    let mut format_options = Dictionary::new();
101
102    let mut i = 0;
103    while i < args.len() {
104        let arg = &args[i];
105
106        // A bare `-` is stdout (FFmpeg's pipe spelling), not an option.
107        let Some(opt) = arg.strip_prefix('-').filter(|_| arg != "-") else {
108            // A bare token is a positional argument: the output file.
109            if output_path.is_some() {
110                warnings.push(format!(
111                    "multiple output files given; using the last (`{arg}`)"
112                ));
113            }
114            output_path = Some(PathBuf::from(arg));
115            i += 1;
116            continue;
117        };
118
119        // Split an option into its base name and optional stream specifier:
120        // `c:v:0` -> ("c", Some("v:0")).
121        let (base, spec) = match opt.split_once(':') {
122            Some((b, s)) => (b, Some(s)),
123            None => (opt, None),
124        };
125
126        match base {
127            "version" => action_override = Some(Action::Version),
128            "h" | "help" | "?" => action_override = Some(Action::Help),
129            "codecs" | "encoders" | "decoders" => action_override = Some(Action::ListCodecs),
130            "formats" | "muxers" | "demuxers" => action_override = Some(Action::ListFormats),
131            "hide_banner" => hide_banner = true,
132            "y" => overwrite = true,
133            "n" => overwrite = false,
134            "loglevel" | "v" => loglevel = Some(take_value(args, &mut i, arg)?),
135
136            "i" => {
137                let path = take_value(args, &mut i, arg)?;
138                inputs.push(InputSpec {
139                    path: PathBuf::from(path),
140                    format: pending_input_format.take(),
141                });
142            }
143
144            // `-f` applies to the next input if we haven't reached outputs yet,
145            // otherwise to the output.
146            "f" => {
147                let fmt = take_value(args, &mut i, arg)?;
148                if inputs.is_empty() {
149                    pending_input_format = Some(fmt);
150                } else {
151                    out_format = Some(fmt);
152                }
153            }
154
155            // Codec selection: -c / -codec (optionally :v / :a), and the legacy
156            // -vcodec / -acodec aliases.
157            "c" | "codec" => {
158                let name = take_value(args, &mut i, arg)?;
159                apply_codec(
160                    spec,
161                    &name,
162                    &mut video_codec,
163                    &mut audio_codec,
164                    &mut subtitle_codec,
165                    &mut audio_sample_format,
166                    &mut warnings,
167                );
168            }
169            "vcodec" => {
170                let name = take_value(args, &mut i, arg)?;
171                apply_codec(
172                    Some("v"),
173                    &name,
174                    &mut video_codec,
175                    &mut audio_codec,
176                    &mut subtitle_codec,
177                    &mut audio_sample_format,
178                    &mut warnings,
179                );
180            }
181            "acodec" => {
182                let name = take_value(args, &mut i, arg)?;
183                apply_codec(
184                    Some("a"),
185                    &name,
186                    &mut video_codec,
187                    &mut audio_codec,
188                    &mut subtitle_codec,
189                    &mut audio_sample_format,
190                    &mut warnings,
191                );
192            }
193            "scodec" => {
194                let name = take_value(args, &mut i, arg)?;
195                apply_codec(
196                    Some("s"),
197                    &name,
198                    &mut video_codec,
199                    &mut audio_codec,
200                    &mut subtitle_codec,
201                    &mut audio_sample_format,
202                    &mut warnings,
203                );
204            }
205
206            // Stream selection: -map INPUT[:v|:a|:N] (repeatable).
207            "map" => {
208                let value = take_value(args, &mut i, arg)?;
209                match parse_map(&value) {
210                    Some(m) => maps.push(m),
211                    None => warnings.push(format!("ignoring invalid -map `{value}`")),
212                }
213            }
214
215            // Video filter graph: -vf / -filter:v. Audio: -af / -filter:a.
216            "vf" => video_filters = Some(take_value(args, &mut i, arg)?),
217            "af" => audio_filters = Some(take_value(args, &mut i, arg)?),
218            // Multi-input filter graph: -filter_complex / -lavfi.
219            "filter_complex" | "lavfi" => filter_complex = Some(take_value(args, &mut i, arg)?),
220            "filter" => {
221                let value = take_value(args, &mut i, arg)?;
222                match spec {
223                    Some(s) if s.starts_with('v') => video_filters = Some(value),
224                    Some(s) if s.starts_with('a') => audio_filters = Some(value),
225                    _ => warnings.push(format!("ignoring filter spec `{value}`")),
226                }
227            }
228
229            // Trim window: -ss START, -t DURATION, -to END (`HH:MM:SS.mmm` or
230            // seconds). Applied to the output: decode-and-discard, so it is
231            // frame-accurate for transcodes and keyframe-cut for -c copy.
232            "ss" => trim_start = Some(parse_time(&take_value(args, &mut i, arg)?)?),
233            "t" => trim_duration = Some(parse_time(&take_value(args, &mut i, arg)?)?),
234            "to" => trim_to = Some(parse_time(&take_value(args, &mut i, arg)?)?),
235
236            // -r RATE: constant output frame rate (25, 29.97, 30000/1001).
237            "r" | "framerate" => frame_rate = Some(parse_rate(&take_value(args, &mut i, arg)?)?),
238
239            // -s WxH: shorthand for a trailing scale filter.
240            "s" | "video_size" => {
241                let value = take_value(args, &mut i, arg)?;
242                let (w, h) = value
243                    .split_once(['x', 'X'])
244                    .and_then(|(w, h)| Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?)))
245                    .ok_or_else(|| format!("-s: expected WxH, got `{value}`"))?;
246                let scale = format!("scale={w}:{h}");
247                video_filters = Some(match video_filters.take() {
248                    Some(chain) => format!("{chain},{scale}"),
249                    None => scale,
250                });
251            }
252
253            // -ar RATE / -ac CHANNELS: audio output rate and channel count.
254            "ar" => {
255                let value = take_value(args, &mut i, arg)?;
256                audio_rate = Some(
257                    value
258                        .parse()
259                        .map_err(|_| format!("-ar: bad sample rate `{value}`"))?,
260                );
261            }
262            "ac" => {
263                let value = take_value(args, &mut i, arg)?;
264                audio_channels = Some(
265                    value
266                        .parse()
267                        .map_err(|_| format!("-ac: bad channel count `{value}`"))?,
268                );
269            }
270
271            // HLS segmenting: -hls_time SECONDS. (-hls_list_size shapes LIVE
272            // sliding-window playlists; our HLS output is VOD, so say so.)
273            "hls_time" => {
274                let value = take_value(args, &mut i, arg)?;
275                format_options.set("hls_time", value);
276            }
277            // DASH segmenting: -seg_duration SECONDS (FFmpeg's dash option).
278            "seg_duration" => {
279                let value = take_value(args, &mut i, arg)?;
280                format_options.set("seg_duration", value);
281            }
282            "hls_list_size" => {
283                let _ = take_value(args, &mut i, arg)?;
284                warnings.push("-hls_list_size: HLS output is a VOD playlist; ignored".into());
285            }
286
287            // -metadata key=value (repeatable).
288            "metadata" => {
289                let value = take_value(args, &mut i, arg)?;
290                match value.split_once('=') {
291                    Some((k, v)) => metadata.set(k.trim(), v),
292                    None => warnings.push(format!("-metadata: expected key=value, got `{value}`")),
293                }
294            }
295
296            // Bitrate: -b:v / -b:a (bare -b defaults to video).
297            "b" => {
298                let value = take_value(args, &mut i, arg)?;
299                match spec {
300                    Some(s) if s.starts_with('a') => audio_opts.set("b", value),
301                    _ => video_opts.set("b", value),
302                }
303            }
304
305            // Rate control / tuning (video by default; `:a` targets audio):
306            // -crf / -qp / -q / -qscale (quality), -preset (speed<->quality), -pass (1|2),
307            // -cpu-used / -speed (VP9 speed preset 0 best..4 fastest).
308            // `-frames:v N` / `-frames N` — stop after N video frames. Needed for
309            // any rate-vs-quality measurement over a clip prefix: without it a
310            // harness silently encodes the whole input while scoring a prefix.
311            "frames" | "vframes" => {
312                let value = take_value(args, &mut i, arg)?;
313                match value.parse::<u64>() {
314                    Ok(n) => max_video_frames = Some(n),
315                    Err(_) => warnings.push(format!("ignoring non-numeric -{arg} {value}")),
316                }
317            }
318
319            // JPEG/MJPEG private options default to video (an image codec has no
320            // audio side): -jpeg_quality 1..100, -sampling 444|440|422|420|411,
321            // -progressive, -optimize_huffman, -restart_interval, -trellis.
322            // `-pred` is PNG's filter/prediction knob (none/sub/up/avg/paeth/
323            // mixed), spelled as FFmpeg's PNG encoder spells it. It is video-only
324            // by nature — there is no audio codec that takes it.
325            "crf" | "qp" | "preset" | "pass" | "q" | "qscale" | "cpu-used" | "speed" | "lag"
326            | "lag-in-frames" | "arnr-strength" | "dispatch-budget" | "jpeg_quality"
327            | "sampling" | "jpeg_sampling" | "progressive" | "optimize_huffman"
328            | "restart_interval" | "trellis" | "pred" | "png_auto_type" | "png_auto_config" => {
329                let value = take_value(args, &mut i, arg)?;
330                if base == "pass" && value != "1" {
331                    warnings
332                        .push("two-pass (-pass 2) is parsed but runs single-pass for now".into());
333                }
334                match spec {
335                    Some(s) if s.starts_with('a') => audio_opts.set(base, value),
336                    _ => video_opts.set(base, value),
337                }
338            }
339
340            // `-compression_level` is not audio-only: FFmpeg treats it as a
341            // generic codec option, and PNG uses it (0..9) exactly as Opus does
342            // (0..10). Unscoped it therefore has to reach BOTH dictionaries, or
343            // `rff -i in.png -c:v png -compression_level 9 out.png` silently
344            // encodes at the default — which is what it used to do. `:v` / `:a`
345            // still target one side.
346            "compression_level" | "threads" => {
347                let value = take_value(args, &mut i, arg)?;
348                match spec {
349                    Some(s) if s.starts_with('v') => video_opts.set(base, value),
350                    Some(s) if s.starts_with('a') => audio_opts.set(base, value),
351                    _ => shared_opts.set(base, value),
352                }
353            }
354
355            // Codec private / tuning options forwarded to the encoder's
356            // `configure` Dictionary (audio by default; `:v` targets video).
357            // Includes Opus: -application, -vbr, and the R1 frame-parallel
358            // controls -opus_parallel / -opus_warmup / -threads.
359            "application" | "vbr" | "opus_parallel" | "opus_warmup" | "frame_duration" => {
360                let value = take_value(args, &mut i, arg)?;
361                match spec {
362                    Some(s) if s.starts_with('v') => video_opts.set(base, value),
363                    _ => audio_opts.set(base, value),
364                }
365            }
366
367            // Anything else: accept gracefully. Best-effort consume a trailing
368            // value so we don't mistake it for the output path.
369            _ => {
370                warnings.push(format!("unrecognized option `-{opt}` (ignored)"));
371                if let Some(next) = args.get(i + 1) {
372                    if !next.starts_with('-') {
373                        i += 1;
374                    }
375                }
376            }
377        }
378
379        i += 1;
380    }
381
382    if let Some(action) = action_override {
383        return Ok(Cli {
384            hide_banner,
385            loglevel,
386            warnings,
387            action,
388        });
389    }
390
391    // Attach the unscoped shared options to whichever codecs are actually
392    // present. An explicit `:v`/`:a` value already set above wins over these.
393    for (key, value) in shared_opts.iter() {
394        if video_codec.is_some() && video_opts.get(key).is_none() {
395            video_opts.set(key, value);
396        }
397        if audio_codec.is_some() && audio_opts.get(key).is_none() {
398            audio_opts.set(key, value);
399        }
400    }
401
402    // Warn about codec options that have no codec to attach to.
403    if !video_opts.is_empty() && video_codec.is_none() {
404        warnings.push("video options given without -c:v; ignored".into());
405    }
406    if !audio_opts.is_empty() && audio_codec.is_none() {
407        warnings.push("audio options given without -c:a; ignored".into());
408    }
409    // Re-encode-only options on a stream-copy: say so instead of silence.
410    if audio_codec.is_none()
411        && (audio_filters.is_some() || audio_rate.is_some() || audio_channels.is_some())
412    {
413        warnings.push("-af/-ar/-ac need a re-encode; pass -c:a (ignored on stream copy)".into());
414    }
415
416    // `-vf fps=N` is the same CFR stage as `-r`: lift it out of the chain.
417    if let Some(vf) = &video_filters {
418        let mut kept: Vec<&str> = Vec::new();
419        for token in vf.split(',') {
420            match token.trim().strip_prefix("fps=") {
421                Some(rate) => match parse_rate(rate) {
422                    Ok(r) => frame_rate = Some(r),
423                    Err(e) => warnings.push(e),
424                },
425                None => kept.push(token),
426            }
427        }
428        if kept.len() != vf.split(',').count() {
429            let kept = kept.join(",");
430            video_filters = (!kept.trim().is_empty()).then_some(kept);
431        }
432    }
433    if frame_rate.is_some() && video_codec.is_none() {
434        warnings.push("-r/-vf fps= need a re-encode; pass -c:v (ignored on stream copy)".into());
435    }
436
437    // -ss/-t/-to resolve to an absolute [start, end) window; -to wins over -t
438    // only when both name the same bound (FFmpeg takes the earlier end).
439    let trim_end = match (trim_to, trim_duration) {
440        (Some(to), Some(t)) => Some(to.min(trim_start.unwrap_or(0.0) + t)),
441        (Some(to), None) => Some(to),
442        (None, Some(t)) => Some(trim_start.unwrap_or(0.0) + t),
443        (None, None) => None,
444    };
445
446    let output = output_path.map(|path| OutputSpec {
447        path,
448        format: out_format,
449        video_codec: video_codec.map(|codec| StreamCodec {
450            codec,
451            options: video_opts,
452            sample_format: None,
453        }),
454        audio_codec: audio_codec.map(|codec| StreamCodec {
455            codec,
456            options: audio_opts,
457            sample_format: audio_sample_format,
458        }),
459        subtitle_codec,
460        video_filters,
461        filter_complex,
462        max_video_frames,
463        maps,
464        overwrite,
465        trim_start,
466        trim_end,
467        frame_rate,
468        audio_rate,
469        audio_channels,
470        audio_filters,
471        metadata,
472        format_options,
473    });
474
475    let spec = TranscodeSpec {
476        inputs,
477        outputs: output.into_iter().collect(),
478    };
479
480    Ok(Cli {
481        hide_banner,
482        loglevel,
483        warnings,
484        action: Action::Transcode(spec),
485    })
486}
487
488/// Consume and return the value following an option, advancing the cursor.
489fn take_value(args: &[String], i: &mut usize, opt: &str) -> Result<String, String> {
490    *i += 1;
491    args.get(*i)
492        .cloned()
493        .ok_or_else(|| format!("option `{opt}` requires an argument"))
494}
495
496/// Parse an FFmpeg time value: plain seconds (`12.5`) or `[HH:]MM:SS[.mmm]`.
497fn parse_time(value: &str) -> Result<f64, String> {
498    let value = value.trim();
499    let bad = || format!("bad time `{value}` (want seconds or [HH:]MM:SS[.mmm])");
500    if !value.contains(':') {
501        return value.parse::<f64>().map_err(|_| bad());
502    }
503    let parts: Vec<&str> = value.split(':').collect();
504    if parts.len() > 3 || parts.is_empty() {
505        return Err(bad());
506    }
507    let mut seconds = 0.0;
508    for p in &parts {
509        seconds = seconds * 60.0 + p.parse::<f64>().map_err(|_| bad())?;
510    }
511    Ok(seconds)
512}
513
514/// Parse a frame rate: integer (`25`), decimal (`29.97`), or fraction
515/// (`30000/1001`). Decimals become exact fractions over a power of ten.
516fn parse_rate(value: &str) -> Result<(u32, u32), String> {
517    let value = value.trim();
518    let bad = || format!("bad frame rate `{value}`");
519    if let Some((n, d)) = value.split_once('/') {
520        let (n, d) = (
521            n.trim().parse::<u32>().map_err(|_| bad())?,
522            d.trim().parse::<u32>().map_err(|_| bad())?,
523        );
524        if n == 0 || d == 0 {
525            return Err(bad());
526        }
527        return Ok((n, d));
528    }
529    if let Some((int, frac)) = value.split_once('.') {
530        let digits = frac.len().min(3) as u32;
531        let den = 10u32.pow(digits);
532        let int: u32 = int.parse().map_err(|_| bad())?;
533        let frac: u32 = frac[..digits as usize].parse().map_err(|_| bad())?;
534        let num = int * den + frac;
535        if num == 0 {
536            return Err(bad());
537        }
538        return Ok((num, den));
539    }
540    let n: u32 = value.parse().map_err(|_| bad())?;
541    if n == 0 {
542        return Err(bad());
543    }
544    Ok((n, 1))
545}
546
547/// Resolve a codec name to an id (treating `copy` as "no re-encode" → `None`)
548/// and assign it to the slot selected by the stream specifier.
549#[allow(clippy::too_many_arguments)]
550fn apply_codec(
551    spec: Option<&str>,
552    name: &str,
553    video: &mut Option<CodecId>,
554    audio: &mut Option<CodecId>,
555    subtitle: &mut Option<CodecId>,
556    audio_format: &mut Option<rff_core::SampleFormat>,
557    warnings: &mut Vec<String>,
558) {
559    if name == "copy" {
560        // Stream copy: leave the slot unset; the pipeline will passthrough.
561        return;
562    }
563    let Some(id) = CodecId::from_name(name) else {
564        warnings.push(format!("unknown codec `{name}` (ignored)"));
565        return;
566    };
567    // `pcm_s16le` vs `pcm_f32le` differ only in the name; keep what it pins.
568    let pinned = CodecId::sample_format_from_name(name);
569    match spec {
570        Some(s) if s.starts_with('v') => *video = Some(id),
571        Some(s) if s.starts_with('a') => {
572            *audio = Some(id);
573            if pinned.is_some() {
574                *audio_format = pinned;
575            }
576        }
577        Some(s) if s.starts_with('s') => match id.media_type() {
578            rff_core::MediaType::Subtitle => *subtitle = Some(id),
579            _ => warnings.push(format!("-c:s: `{name}` is not a subtitle codec (ignored)")),
580        },
581        // No specifier: apply to whichever media type this codec is.
582        None => match id.media_type() {
583            rff_core::MediaType::Video => *video = Some(id),
584            rff_core::MediaType::Audio => {
585                *audio = Some(id);
586                if pinned.is_some() {
587                    *audio_format = pinned;
588                }
589            }
590            rff_core::MediaType::Subtitle => *subtitle = Some(id),
591            _ => {}
592        },
593        _ => {}
594    }
595}