1use std::path::PathBuf;
18
19use rff::transcode::{InputSpec, MapSelector, MapSpec, OutputSpec, StreamCodec, TranscodeSpec};
20use rff_core::{CodecId, Dictionary, MediaType};
21
22fn 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
35pub enum Action {
38 Version,
39 Help,
40 ListCodecs,
41 ListFormats,
42 Transcode(TranscodeSpec),
43}
44
45pub struct Cli {
47 pub hide_banner: bool,
48 pub loglevel: Option<String>,
49 pub warnings: Vec<String>,
51 pub action: Action,
52}
53
54pub 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 let mut action_override: Option<Action> = None;
65
66 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 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 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 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 let Some(opt) = arg.strip_prefix('-').filter(|_| arg != "-") else {
108 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 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" => {
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 "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 "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 "vf" => video_filters = Some(take_value(args, &mut i, arg)?),
217 "af" => audio_filters = Some(take_value(args, &mut i, arg)?),
218 "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 "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" | "framerate" => frame_rate = Some(parse_rate(&take_value(args, &mut i, arg)?)?),
238
239 "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" => {
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_time" => {
274 let value = take_value(args, &mut i, arg)?;
275 format_options.set("hls_time", value);
276 }
277 "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" => {
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 "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 "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 "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" | "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 "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 _ => {
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 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 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 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 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 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
490fn 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
498fn 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
516fn 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#[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 return;
564 }
565 let Some(id) = CodecId::from_name(name) else {
566 warnings.push(format!("unknown codec `{name}` (ignored)"));
567 return;
568 };
569 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 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}