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            // -profile (h264: baseline|main|high), -g (keyframe interval).
309            // `-frames:v N` / `-frames N` — stop after N video frames. Needed for
310            // any rate-vs-quality measurement over a clip prefix: without it a
311            // harness silently encodes the whole input while scoring a prefix.
312            "frames" | "vframes" => {
313                let value = take_value(args, &mut i, arg)?;
314                match value.parse::<u64>() {
315                    Ok(n) => max_video_frames = Some(n),
316                    Err(_) => warnings.push(format!("ignoring non-numeric -{arg} {value}")),
317                }
318            }
319
320            // JPEG/MJPEG private options default to video (an image codec has no
321            // audio side): -jpeg_quality 1..100, -sampling 444|440|422|420|411,
322            // -progressive, -optimize_huffman, -restart_interval, -trellis.
323            // `-pred` is PNG's filter/prediction knob (none/sub/up/avg/paeth/
324            // mixed), spelled as FFmpeg's PNG encoder spells it. It is video-only
325            // by nature — there is no audio codec that takes it.
326            "crf" | "qp" | "preset" | "profile" | "g" | "pass" | "q" | "qscale" | "cpu-used"
327            | "speed" | "lag" | "lag-in-frames" | "arnr-strength" | "dispatch-budget"
328            | "jpeg_quality" | "sampling" | "jpeg_sampling" | "progressive"
329            | "optimize_huffman" | "restart_interval" | "trellis" | "pred" | "png_auto_type"
330            | "png_auto_config" => {
331                let value = take_value(args, &mut i, arg)?;
332                if base == "pass" && value != "1" {
333                    warnings
334                        .push("two-pass (-pass 2) is parsed but runs single-pass for now".into());
335                }
336                match spec {
337                    Some(s) if s.starts_with('a') => audio_opts.set(base, value),
338                    _ => video_opts.set(base, value),
339                }
340            }
341
342            // `-compression_level` is not audio-only: FFmpeg treats it as a
343            // generic codec option, and PNG uses it (0..9) exactly as Opus does
344            // (0..10). Unscoped it therefore has to reach BOTH dictionaries, or
345            // `rff -i in.png -c:v png -compression_level 9 out.png` silently
346            // encodes at the default — which is what it used to do. `:v` / `:a`
347            // still target one side.
348            "compression_level" | "threads" => {
349                let value = take_value(args, &mut i, arg)?;
350                match spec {
351                    Some(s) if s.starts_with('v') => video_opts.set(base, value),
352                    Some(s) if s.starts_with('a') => audio_opts.set(base, value),
353                    _ => shared_opts.set(base, value),
354                }
355            }
356
357            // Codec private / tuning options forwarded to the encoder's
358            // `configure` Dictionary (audio by default; `:v` targets video).
359            // Includes Opus: -application, -vbr, and the R1 frame-parallel
360            // controls -opus_parallel / -opus_warmup / -threads.
361            "application" | "vbr" | "opus_parallel" | "opus_warmup" | "frame_duration" => {
362                let value = take_value(args, &mut i, arg)?;
363                match spec {
364                    Some(s) if s.starts_with('v') => video_opts.set(base, value),
365                    _ => audio_opts.set(base, value),
366                }
367            }
368
369            // Anything else: accept gracefully. Best-effort consume a trailing
370            // value so we don't mistake it for the output path.
371            _ => {
372                warnings.push(format!("unrecognized option `-{opt}` (ignored)"));
373                if let Some(next) = args.get(i + 1) {
374                    if !next.starts_with('-') {
375                        i += 1;
376                    }
377                }
378            }
379        }
380
381        i += 1;
382    }
383
384    if let Some(action) = action_override {
385        return Ok(Cli {
386            hide_banner,
387            loglevel,
388            warnings,
389            action,
390        });
391    }
392
393    // Attach the unscoped shared options to whichever codecs are actually
394    // present. An explicit `:v`/`:a` value already set above wins over these.
395    for (key, value) in shared_opts.iter() {
396        if video_codec.is_some() && video_opts.get(key).is_none() {
397            video_opts.set(key, value);
398        }
399        if audio_codec.is_some() && audio_opts.get(key).is_none() {
400            audio_opts.set(key, value);
401        }
402    }
403
404    // Warn about codec options that have no codec to attach to.
405    if !video_opts.is_empty() && video_codec.is_none() {
406        warnings.push("video options given without -c:v; ignored".into());
407    }
408    if !audio_opts.is_empty() && audio_codec.is_none() {
409        warnings.push("audio options given without -c:a; ignored".into());
410    }
411    // Re-encode-only options on a stream-copy: say so instead of silence.
412    if audio_codec.is_none()
413        && (audio_filters.is_some() || audio_rate.is_some() || audio_channels.is_some())
414    {
415        warnings.push("-af/-ar/-ac need a re-encode; pass -c:a (ignored on stream copy)".into());
416    }
417
418    // `-vf fps=N` is the same CFR stage as `-r`: lift it out of the chain.
419    if let Some(vf) = &video_filters {
420        let mut kept: Vec<&str> = Vec::new();
421        for token in vf.split(',') {
422            match token.trim().strip_prefix("fps=") {
423                Some(rate) => match parse_rate(rate) {
424                    Ok(r) => frame_rate = Some(r),
425                    Err(e) => warnings.push(e),
426                },
427                None => kept.push(token),
428            }
429        }
430        if kept.len() != vf.split(',').count() {
431            let kept = kept.join(",");
432            video_filters = (!kept.trim().is_empty()).then_some(kept);
433        }
434    }
435    if frame_rate.is_some() && video_codec.is_none() {
436        warnings.push("-r/-vf fps= need a re-encode; pass -c:v (ignored on stream copy)".into());
437    }
438
439    // -ss/-t/-to resolve to an absolute [start, end) window; -to wins over -t
440    // only when both name the same bound (FFmpeg takes the earlier end).
441    let trim_end = match (trim_to, trim_duration) {
442        (Some(to), Some(t)) => Some(to.min(trim_start.unwrap_or(0.0) + t)),
443        (Some(to), None) => Some(to),
444        (None, Some(t)) => Some(trim_start.unwrap_or(0.0) + t),
445        (None, None) => None,
446    };
447
448    let output = output_path.map(|path| OutputSpec {
449        path,
450        format: out_format,
451        video_codec: video_codec.map(|codec| StreamCodec {
452            codec,
453            options: video_opts,
454            sample_format: None,
455        }),
456        audio_codec: audio_codec.map(|codec| StreamCodec {
457            codec,
458            options: audio_opts,
459            sample_format: audio_sample_format,
460        }),
461        subtitle_codec,
462        video_filters,
463        filter_complex,
464        max_video_frames,
465        maps,
466        overwrite,
467        trim_start,
468        trim_end,
469        frame_rate,
470        audio_rate,
471        audio_channels,
472        audio_filters,
473        metadata,
474        format_options,
475    });
476
477    let spec = TranscodeSpec {
478        inputs,
479        outputs: output.into_iter().collect(),
480    };
481
482    Ok(Cli {
483        hide_banner,
484        loglevel,
485        warnings,
486        action: Action::Transcode(spec),
487    })
488}
489
490/// Consume and return the value following an option, advancing the cursor.
491fn take_value(args: &[String], i: &mut usize, opt: &str) -> Result<String, String> {
492    *i += 1;
493    args.get(*i)
494        .cloned()
495        .ok_or_else(|| format!("option `{opt}` requires an argument"))
496}
497
498/// Parse an FFmpeg time value: plain seconds (`12.5`) or `[HH:]MM:SS[.mmm]`.
499fn parse_time(value: &str) -> Result<f64, String> {
500    let value = value.trim();
501    let bad = || format!("bad time `{value}` (want seconds or [HH:]MM:SS[.mmm])");
502    if !value.contains(':') {
503        return value.parse::<f64>().map_err(|_| bad());
504    }
505    let parts: Vec<&str> = value.split(':').collect();
506    if parts.len() > 3 || parts.is_empty() {
507        return Err(bad());
508    }
509    let mut seconds = 0.0;
510    for p in &parts {
511        seconds = seconds * 60.0 + p.parse::<f64>().map_err(|_| bad())?;
512    }
513    Ok(seconds)
514}
515
516/// Parse a frame rate: integer (`25`), decimal (`29.97`), or fraction
517/// (`30000/1001`). Decimals become exact fractions over a power of ten.
518fn parse_rate(value: &str) -> Result<(u32, u32), String> {
519    let value = value.trim();
520    let bad = || format!("bad frame rate `{value}`");
521    if let Some((n, d)) = value.split_once('/') {
522        let (n, d) = (
523            n.trim().parse::<u32>().map_err(|_| bad())?,
524            d.trim().parse::<u32>().map_err(|_| bad())?,
525        );
526        if n == 0 || d == 0 {
527            return Err(bad());
528        }
529        return Ok((n, d));
530    }
531    if let Some((int, frac)) = value.split_once('.') {
532        let digits = frac.len().min(3) as u32;
533        let den = 10u32.pow(digits);
534        let int: u32 = int.parse().map_err(|_| bad())?;
535        let frac: u32 = frac[..digits as usize].parse().map_err(|_| bad())?;
536        let num = int * den + frac;
537        if num == 0 {
538            return Err(bad());
539        }
540        return Ok((num, den));
541    }
542    let n: u32 = value.parse().map_err(|_| bad())?;
543    if n == 0 {
544        return Err(bad());
545    }
546    Ok((n, 1))
547}
548
549/// Resolve a codec name to an id (treating `copy` as "no re-encode" → `None`)
550/// and assign it to the slot selected by the stream specifier.
551#[allow(clippy::too_many_arguments)]
552fn apply_codec(
553    spec: Option<&str>,
554    name: &str,
555    video: &mut Option<CodecId>,
556    audio: &mut Option<CodecId>,
557    subtitle: &mut Option<CodecId>,
558    audio_format: &mut Option<rff_core::SampleFormat>,
559    warnings: &mut Vec<String>,
560) {
561    if name == "copy" {
562        // Stream copy: leave the slot unset; the pipeline will passthrough.
563        return;
564    }
565    let Some(id) = CodecId::from_name(name) else {
566        warnings.push(format!("unknown codec `{name}` (ignored)"));
567        return;
568    };
569    // `pcm_s16le` vs `pcm_f32le` differ only in the name; keep what it pins.
570    let pinned = CodecId::sample_format_from_name(name);
571    match spec {
572        Some(s) if s.starts_with('v') => *video = Some(id),
573        Some(s) if s.starts_with('a') => {
574            *audio = Some(id);
575            if pinned.is_some() {
576                *audio_format = pinned;
577            }
578        }
579        Some(s) if s.starts_with('s') => match id.media_type() {
580            rff_core::MediaType::Subtitle => *subtitle = Some(id),
581            _ => warnings.push(format!("-c:s: `{name}` is not a subtitle codec (ignored)")),
582        },
583        // No specifier: apply to whichever media type this codec is.
584        None => match id.media_type() {
585            rff_core::MediaType::Video => *video = Some(id),
586            rff_core::MediaType::Audio => {
587                *audio = Some(id);
588                if pinned.is_some() {
589                    *audio_format = pinned;
590                }
591            }
592            rff_core::MediaType::Subtitle => *subtitle = Some(id),
593            _ => {}
594        },
595        _ => {}
596    }
597}