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" => {
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 "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" | "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 "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 _ => {
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 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 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 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 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 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
488fn 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
496fn 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
514fn 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#[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 return;
562 }
563 let Some(id) = CodecId::from_name(name) else {
564 warnings.push(format!("unknown codec `{name}` (ignored)"));
565 return;
566 };
567 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 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}