1use std::fmt;
30
31use rff_codec::CodecRegistry;
32use rff_core::{CodecId, MediaType};
33use rff_format::{FormatRegistry, MuxCaps};
34
35mod json;
36pub use json::matrix_to_json;
37
38#[cfg(test)]
39mod tests;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct SourceStream {
52 pub index: usize,
53 pub media_type: MediaType,
54 pub codec_id: CodecId,
55}
56
57impl SourceStream {
58 pub fn new(index: usize, media_type: MediaType, codec_id: CodecId) -> SourceStream {
59 SourceStream {
60 index,
61 media_type,
62 codec_id,
63 }
64 }
65}
66
67impl From<&rff_format::Stream> for SourceStream {
68 fn from(s: &rff_format::Stream) -> SourceStream {
69 SourceStream {
70 index: s.index,
71 media_type: s.media_type,
72 codec_id: s.codec_id,
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
84pub struct Plan {
85 pub source: Vec<SourceStream>,
87 pub source_format: Option<String>,
89 pub targets: Vec<Target>,
91}
92
93impl Plan {
94 pub fn stream_copies(&self) -> impl Iterator<Item = &Target> {
96 self.targets.iter().filter(|t| t.fidelity == Fidelity::Copy)
97 }
98
99 pub fn of_kind(&self, kind: TargetKind) -> impl Iterator<Item = &Target> + '_ {
101 self.targets.iter().filter(move |t| t.kind == kind)
102 }
103
104 pub fn target(&self, format: &str) -> Option<&Target> {
106 self.targets.iter().find(|t| t.format == format)
107 }
108}
109
110#[derive(Debug, Clone)]
112pub struct Target {
113 pub format: &'static str,
115 pub long_name: &'static str,
116 pub extension: &'static str,
118 pub extensions: &'static [&'static str],
120 pub kind: TargetKind,
121 pub streams: Vec<StreamPlan>,
123 pub fidelity: Fidelity,
124 pub notes: Vec<String>,
127 pub args: Vec<String>,
131}
132
133impl Target {
134 pub fn kept(&self) -> impl Iterator<Item = &StreamPlan> {
136 self.streams.iter().filter(|s| !s.action.is_drop())
137 }
138
139 pub fn dropped(&self) -> impl Iterator<Item = &StreamPlan> {
141 self.streams.iter().filter(|s| s.action.is_drop())
142 }
143
144 pub fn stream_summary(&self) -> String {
146 let mut parts: Vec<String> = self.kept().map(|s| s.summary()).collect();
147 if parts.is_empty() {
148 parts.push("nothing".into());
149 }
150 let mut s = parts.join(" + ");
151 let dropped = self.dropped().count();
152 if dropped > 0 {
153 s.push_str(&format!(", {dropped} dropped"));
154 }
155 s
156 }
157
158 pub fn summary(&self) -> String {
161 format!("{} ({})", self.stream_summary(), self.fidelity)
162 }
163
164 pub fn command(&self, input: &str, output_stem: &str) -> String {
166 let mut s = format!("rff -i {input}");
167 for a in &self.args {
168 s.push(' ');
169 s.push_str(a);
170 }
171 format!("{s} {output_stem}.{}", self.extension)
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
177pub enum TargetKind {
178 Video,
180 Audio,
182 Image,
184 Subtitle,
186}
187
188impl TargetKind {
189 pub fn name(self) -> &'static str {
190 match self {
191 TargetKind::Video => "video",
192 TargetKind::Audio => "audio",
193 TargetKind::Image => "image",
194 TargetKind::Subtitle => "subtitle",
195 }
196 }
197}
198
199impl fmt::Display for TargetKind {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 f.pad(self.name())
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
209pub enum Fidelity {
210 Copy,
212 Lossless,
214 Lossy,
216}
217
218impl Fidelity {
219 pub fn name(self) -> &'static str {
220 match self {
221 Fidelity::Copy => "copy",
222 Fidelity::Lossless => "lossless",
223 Fidelity::Lossy => "lossy",
224 }
225 }
226}
227
228impl fmt::Display for Fidelity {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 f.pad(self.name())
231 }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub struct StreamPlan {
237 pub input_index: usize,
238 pub media_type: MediaType,
239 pub from: CodecId,
240 pub action: Action,
241}
242
243impl StreamPlan {
244 pub fn summary(&self) -> String {
246 match self.action {
247 Action::Copy => format!("{} copy", self.from),
248 Action::Transcode { to, .. } => format!("{}->{}", self.from, to),
249 Action::Drop(reason) => format!("{} dropped ({reason})", self.from),
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum Action {
257 Copy,
259 Transcode { to: CodecId, lossy: bool },
261 Drop(DropReason),
263}
264
265impl Action {
266 pub fn is_drop(&self) -> bool {
267 matches!(self, Action::Drop(_))
268 }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum DropReason {
274 UnsupportedMedia,
276 NoEncoder,
278 NoDecoder,
280 ContainerFull,
282}
283
284impl DropReason {
285 pub fn name(self) -> &'static str {
287 match self {
288 DropReason::UnsupportedMedia => "unsupported-media",
289 DropReason::NoEncoder => "no-encoder",
290 DropReason::NoDecoder => "no-decoder",
291 DropReason::ContainerFull => "container-full",
292 }
293 }
294
295 fn explain(self) -> &'static str {
296 match self {
297 DropReason::UnsupportedMedia => "container carries no stream of this type",
298 DropReason::NoEncoder => "no encoder for any codec this container accepts",
299 DropReason::NoDecoder => "no decoder for the source codec",
300 DropReason::ContainerFull => "container is full",
301 }
302 }
303}
304
305impl fmt::Display for DropReason {
306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307 f.pad(self.explain())
308 }
309}
310
311pub fn is_lossless(id: CodecId) -> bool {
321 matches!(
322 id,
323 CodecId::Png
324 | CodecId::Flac
325 | CodecId::Pcm
326 | CodecId::RawVideo
327 | CodecId::Webp
328 | CodecId::Subrip
329 | CodecId::WebVtt
330 )
331}
332
333fn encodes_stills_only(id: CodecId) -> bool {
337 matches!(id, CodecId::Avif)
338}
339
340fn is_text_subtitle(id: CodecId) -> bool {
343 matches!(id, CodecId::Subrip | CodecId::WebVtt)
344}
345
346fn preference(media: MediaType) -> &'static [CodecId] {
349 match media {
350 MediaType::Video => &[
351 CodecId::H264,
352 CodecId::Vp9,
353 CodecId::Av2,
354 CodecId::Avif,
355 CodecId::Jpeg,
356 CodecId::Png,
357 CodecId::Webp,
358 CodecId::Gif,
359 CodecId::Jxl,
360 CodecId::RawVideo,
361 ],
362 MediaType::Audio => &[
363 CodecId::Opus,
364 CodecId::Aac,
365 CodecId::Flac,
366 CodecId::Mp3,
367 CodecId::Vorbis,
368 CodecId::Pcm,
369 ],
370 MediaType::Subtitle => &[CodecId::Subrip, CodecId::WebVtt],
371 _ => &[],
372 }
373}
374
375fn preferred_override(format: &str, media: MediaType) -> Option<CodecId> {
379 match (format, media) {
380 ("mp4" | "dash", MediaType::Audio) => Some(CodecId::Aac),
381 _ => None,
382 }
383}
384
385const VIDEO_ORDER: &[&str] = &[
390 "mp4",
391 "matroska",
392 "webm",
393 "mpegts",
394 "flv",
395 "avi",
396 "hls",
397 "dash",
398 "ivf",
399 "av2f",
400 "yuv4mpegpipe",
401];
402const AUDIO_ORDER: &[&str] = &[
403 "mp3", "flac", "wav", "ogg", "mp4", "matroska", "webm", "mpegts", "flv", "avi", "hls", "dash",
404];
405const IMAGE_ORDER: &[&str] = &["jpeg", "png", "webp", "avif", "gif", "jpegxl", "av2f"];
406const SUBTITLE_ORDER: &[&str] = &["srt", "webvtt", "matroska", "mp4"];
407
408fn rank(kind: TargetKind, name: &str) -> usize {
410 let order = match kind {
411 TargetKind::Video => VIDEO_ORDER,
412 TargetKind::Audio => AUDIO_ORDER,
413 TargetKind::Image => IMAGE_ORDER,
414 TargetKind::Subtitle => SUBTITLE_ORDER,
415 };
416 order.iter().position(|n| *n == name).unwrap_or(order.len())
417}
418
419const MEDIA_PRIORITY: [MediaType; 3] = [MediaType::Video, MediaType::Audio, MediaType::Subtitle];
421
422fn media_rank(m: MediaType) -> usize {
423 MEDIA_PRIORITY.iter().position(|p| *p == m).unwrap_or(9)
424}
425
426pub fn plan(codecs: &CodecRegistry, formats: &FormatRegistry, source: &[SourceStream]) -> Plan {
437 let mut targets: Vec<Target> = formats
438 .iter()
439 .filter(|f| f.can_mux())
440 .filter_map(|f| {
441 build_target(
442 codecs,
443 f.name,
444 f.long_name,
445 f.extensions,
446 &f.mux_caps,
447 f.muxer_path.is_some(),
448 |ext| formats.by_extension(ext).map(|r| r.name) != Some(f.name),
452 source,
453 )
454 })
455 .collect();
456
457 let source_kind = source_kind(source);
458 targets.sort_by_key(|t| {
459 (
460 (t.kind != source_kind) as u8,
461 t.fidelity,
462 rank(t.kind, t.format),
463 t.format,
464 )
465 });
466
467 Plan {
468 source: source.to_vec(),
469 source_format: None,
470 targets,
471 }
472}
473
474fn source_kind(source: &[SourceStream]) -> TargetKind {
476 if source.iter().any(|s| s.media_type == MediaType::Video) {
477 TargetKind::Video
478 } else if source.iter().any(|s| s.media_type == MediaType::Audio) {
479 TargetKind::Audio
480 } else {
481 TargetKind::Subtitle
482 }
483}
484
485#[allow(clippy::too_many_arguments)]
486fn build_target(
487 codecs: &CodecRegistry,
488 format: &'static str,
489 long_name: &'static str,
490 extensions: &'static [&'static str],
491 caps: &MuxCaps,
492 multi_file: bool,
493 needs_explicit_f: impl Fn(&str) -> bool,
496 source: &[SourceStream],
497) -> Option<Target> {
498 let mut streams: Vec<StreamPlan> = source
500 .iter()
501 .map(|s| StreamPlan {
502 input_index: s.index,
503 media_type: s.media_type,
504 from: s.codec_id,
505 action: decide(codecs, format, caps, s),
506 })
507 .collect();
508
509 if let Some(max) = caps.max_streams {
512 let mut order: Vec<usize> = (0..streams.len())
513 .filter(|&i| !streams[i].action.is_drop())
514 .collect();
515 order.sort_by_key(|&i| (media_rank(streams[i].media_type), streams[i].input_index));
516 for &i in order.iter().skip(max) {
517 streams[i].action = Action::Drop(DropReason::ContainerFull);
518 }
519 }
520
521 if streams.iter().all(|s| s.action.is_drop()) {
522 return None;
523 }
524
525 let fidelity = streams
527 .iter()
528 .filter_map(|s| match s.action {
529 Action::Copy => Some(Fidelity::Copy),
530 Action::Transcode { lossy: false, .. } => Some(Fidelity::Lossless),
531 Action::Transcode { lossy: true, .. } => Some(Fidelity::Lossy),
532 Action::Drop(_) => None,
533 })
534 .max()
535 .unwrap_or(Fidelity::Copy);
536
537 let kind = if caps.still_image {
538 TargetKind::Image
539 } else {
540 match streams
541 .iter()
542 .filter(|s| !s.action.is_drop())
543 .map(|s| s.media_type)
544 .min_by_key(|m| media_rank(*m))
545 {
546 Some(MediaType::Video) => TargetKind::Video,
547 Some(MediaType::Audio) => TargetKind::Audio,
548 _ => TargetKind::Subtitle,
549 }
550 };
551
552 let extension = primary_extension(extensions, &streams, format);
553 Some(Target {
554 format,
555 long_name,
556 extension,
557 extensions,
558 kind,
559 notes: notes(format, caps, multi_file, &streams, kind),
560 args: args(format, needs_explicit_f(extension), &streams),
561 streams,
562 fidelity,
563 })
564}
565
566fn primary_extension(
573 extensions: &'static [&'static str],
574 streams: &[StreamPlan],
575 format: &'static str,
576) -> &'static str {
577 for s in streams {
578 let codec = match s.action {
579 Action::Copy => s.from,
580 Action::Transcode { to, .. } => to,
581 Action::Drop(_) => continue,
582 };
583 if let Some(ext) = extensions.iter().find(|e| **e == codec.name()) {
584 return ext;
585 }
586 }
587 extensions.first().copied().unwrap_or(format)
588}
589
590fn decide(codecs: &CodecRegistry, format: &str, caps: &MuxCaps, s: &SourceStream) -> Action {
592 if !caps.accepts_media(s.media_type) {
593 return Action::Drop(DropReason::UnsupportedMedia);
594 }
595 if caps.accepts(s.codec_id) {
598 return Action::Copy;
599 }
600 if s.media_type == MediaType::Subtitle && is_text_subtitle(s.codec_id) {
602 return match caps
603 .codecs_for(MediaType::Subtitle)
604 .find(|id| is_text_subtitle(*id))
605 {
606 Some(to) => Action::Transcode { to, lossy: false },
607 None => Action::Drop(DropReason::NoEncoder),
608 };
609 }
610 if !codecs.by_id(s.codec_id).is_some_and(|c| c.can_decode()) {
612 return Action::Drop(DropReason::NoDecoder);
613 }
614 match pick_encoder(codecs, format, caps, s.media_type) {
615 Some(to) => Action::Transcode {
616 to,
617 lossy: !is_lossless(to),
618 },
619 None => Action::Drop(DropReason::NoEncoder),
620 }
621}
622
623fn pick_encoder(
627 codecs: &CodecRegistry,
628 format: &str,
629 caps: &MuxCaps,
630 media: MediaType,
631) -> Option<CodecId> {
632 let usable = |id: CodecId| {
633 let shape_fits = caps.still_image || !encodes_stills_only(id);
635 shape_fits && caps.accepts(id) && codecs.by_id(id).is_some_and(|c| c.can_encode())
636 };
637 preferred_override(format, media)
638 .filter(|id| usable(*id))
639 .or_else(|| preference(media).iter().copied().find(|id| usable(*id)))
640}
641
642fn notes(
644 format: &str,
645 caps: &MuxCaps,
646 multi_file: bool,
647 streams: &[StreamPlan],
648 kind: TargetKind,
649) -> Vec<String> {
650 let mut notes = Vec::new();
651
652 if caps.still_image
653 && kind == TargetKind::Image
654 && streams
655 .iter()
656 .any(|s| !s.action.is_drop() && s.media_type == MediaType::Video)
657 {
658 notes.push("holds one picture — only the first frame is written".into());
659 }
660 if multi_file {
661 notes.push("writes a playlist plus segment files, not a single file".into());
662 }
663 for s in streams {
665 if let Action::Transcode { to, lossy: true } = s.action {
666 if !is_lossless(s.from) {
667 notes.push(format!(
668 "{} -> {to} re-encodes already-lossy data (generation loss)",
669 s.from
670 ));
671 }
672 }
673 }
674 for s in streams {
675 if let Action::Drop(reason) = s.action {
676 notes.push(format!(
677 "stream {} ({} {}) is dropped: {reason}",
678 s.input_index, s.media_type, s.from
679 ));
680 }
681 }
682 if format == "av2f" {
683 notes.push(
684 "experimental: the four-character codes are ours, so these files read back \
685 here and nowhere else"
686 .into(),
687 );
688 }
689 notes
690}
691
692fn args(format: &str, needs_f: bool, streams: &[StreamPlan]) -> Vec<String> {
695 let mut args = Vec::new();
696 let mut seen = [false; 3]; for s in streams {
698 let (slot, flag) = match s.media_type {
699 MediaType::Video => (0usize, "-c:v"),
700 MediaType::Audio => (1, "-c:a"),
701 MediaType::Subtitle => (2, "-c:s"),
702 _ => continue,
703 };
704 if seen[slot] {
705 continue;
706 }
707 let codec = match s.action {
708 Action::Copy => "copy".to_string(),
709 Action::Transcode { to, .. } => to.name().to_string(),
710 Action::Drop(_) => continue,
711 };
712 seen[slot] = true;
713 args.push(flag.to_string());
714 args.push(codec);
715 }
716 if needs_f {
717 args.push("-f".into());
718 args.push(format.to_string());
719 }
720 args
721}
722
723#[derive(Debug, Clone)]
730pub struct FormatSupport {
731 pub format: &'static str,
732 pub long_name: &'static str,
733 pub extensions: &'static [&'static str],
734 pub can_read: bool,
735 pub can_write: bool,
736 pub video: Vec<CodecId>,
738 pub audio: Vec<CodecId>,
739 pub subtitle: Vec<CodecId>,
740 pub still_image: bool,
741 pub max_streams: Option<usize>,
742}
743
744pub fn format_matrix(formats: &FormatRegistry) -> Vec<FormatSupport> {
746 let mut rows: Vec<FormatSupport> = formats
747 .iter()
748 .map(|f| FormatSupport {
749 format: f.name,
750 long_name: f.long_name,
751 extensions: f.extensions,
752 can_read: f.can_demux(),
753 can_write: f.can_mux(),
754 video: f.mux_caps.codecs_for(MediaType::Video).collect(),
755 audio: f.mux_caps.codecs_for(MediaType::Audio).collect(),
756 subtitle: f.mux_caps.codecs_for(MediaType::Subtitle).collect(),
757 still_image: f.mux_caps.still_image,
758 max_streams: f.mux_caps.max_streams,
759 })
760 .collect();
761 rows.sort_by_key(|r| (rank(TargetKind::Video, r.format), r.format));
762 rows
763}
764
765pub fn readable_extensions(formats: &FormatRegistry) -> Vec<&'static str> {
768 let mut v: Vec<&'static str> = formats
769 .iter()
770 .filter(|f| f.can_demux())
771 .flat_map(|f| f.extensions.iter().copied())
772 .collect();
773 v.sort_unstable();
774 v.dedup();
775 v
776}