Skip to main content

rff_targets/
lib.rs

1//! `rff-targets` — "what can this file be converted into?"
2//!
3//! Given the streams of an input, this crate enumerates every container the
4//! engine can actually *write* for it, and says for each one what happens to
5//! each stream: a byte-exact stream copy, a re-encode (and whether that
6//! re-encode is lossy), or a drop the container forces.
7//!
8//! The answer is derived from the engine's own registries — [`MuxCaps`] on each
9//! registered [`Format`](rff_format::Format), and the encoder/decoder factories
10//! on each registered [`Codec`](rff_codec::Codec). Nothing here is a hand-kept
11//! list of file extensions, so a build with fewer codecs compiled in reports
12//! fewer targets, automatically and correctly.
13//!
14//! ```no_run
15//! # use rff_codec::CodecRegistry;
16//! # use rff_format::FormatRegistry;
17//! # let (codecs, formats) = (CodecRegistry::new(), FormatRegistry::new());
18//! # let streams: Vec<rff_format::Stream> = Vec::new();
19//! let source: Vec<rff_targets::SourceStream> = streams.iter().map(Into::into).collect();
20//! for target in rff_targets::plan(&codecs, &formats, &source).targets {
21//!     println!("{:<10} {}", target.extension, target.summary());
22//! }
23//! ```
24//!
25//! The result is plain data plus a [`to_json`](Plan::to_json) rendering, so a
26//! CLI, an HTTP handler and a browser UI all consume the same answer instead of
27//! each keeping its own conversion table.
28
29use 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// ---------------------------------------------------------------------------
42// Input
43// ---------------------------------------------------------------------------
44
45/// One stream of the input, reduced to what target selection depends on.
46///
47/// Deliberately smaller than [`rff_format::Stream`] so callers can build it
48/// from a probe result, a database row, or a UI form — anything that knows a
49/// media type and a codec.
50#[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// ---------------------------------------------------------------------------
78// Output
79// ---------------------------------------------------------------------------
80
81/// What the engine can produce from one input: every writable container, best
82/// first.
83#[derive(Debug, Clone)]
84pub struct Plan {
85    /// The input streams the plan was computed for.
86    pub source: Vec<SourceStream>,
87    /// Short name of the input container, when the caller knew it.
88    pub source_format: Option<String>,
89    /// Every container that can be written, ordered by [`plan`]'s ranking.
90    pub targets: Vec<Target>,
91}
92
93impl Plan {
94    /// The targets that need no re-encoding at all.
95    pub fn stream_copies(&self) -> impl Iterator<Item = &Target> {
96        self.targets.iter().filter(|t| t.fidelity == Fidelity::Copy)
97    }
98
99    /// Targets of one kind (all the image outputs, all the audio outputs, ...).
100    pub fn of_kind(&self, kind: TargetKind) -> impl Iterator<Item = &Target> + '_ {
101        self.targets.iter().filter(move |t| t.kind == kind)
102    }
103
104    /// Look one target up by its format name (`"webm"`).
105    pub fn target(&self, format: &str) -> Option<&Target> {
106        self.targets.iter().find(|t| t.format == format)
107    }
108}
109
110/// One output container the engine can write for this input.
111#[derive(Debug, Clone)]
112pub struct Target {
113    /// Format name as `-f` takes it (`mp4`, `matroska`, `yuv4mpegpipe`).
114    pub format: &'static str,
115    pub long_name: &'static str,
116    /// The extension to offer by default (the format's first).
117    pub extension: &'static str,
118    /// Every extension that selects this format.
119    pub extensions: &'static [&'static str],
120    pub kind: TargetKind,
121    /// What happens to each input stream, in input order.
122    pub streams: Vec<StreamPlan>,
123    pub fidelity: Fidelity,
124    /// Anything a caller should say out loud before writing this: dropped
125    /// streams, still-image truncation, generation loss, multi-file output.
126    pub notes: Vec<String>,
127    /// A ready-to-run argument list, minus the input and output paths:
128    /// `["-c:v", "copy", "-c:a", "opus"]`. Goes between `-i <input>` and the
129    /// output path.
130    pub args: Vec<String>,
131}
132
133impl Target {
134    /// Streams that survive into the output.
135    pub fn kept(&self) -> impl Iterator<Item = &StreamPlan> {
136        self.streams.iter().filter(|s| !s.action.is_drop())
137    }
138
139    /// Streams the container forces us to leave behind.
140    pub fn dropped(&self) -> impl Iterator<Item = &StreamPlan> {
141        self.streams.iter().filter(|s| s.action.is_drop())
142    }
143
144    /// What happens to the streams: `"h264 copy + aac->opus"`.
145    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    /// [`stream_summary`](Target::stream_summary) plus the fidelity verdict:
159    /// `"h264 copy + aac->opus (lossy)"`.
160    pub fn summary(&self) -> String {
161        format!("{} ({})", self.stream_summary(), self.fidelity)
162    }
163
164    /// The full command a user would run, given concrete paths.
165    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/// The broad category an output belongs to — what a UI groups it under.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
177pub enum TargetKind {
178    /// Moving pictures, with or without sound.
179    Video,
180    /// Sound only.
181    Audio,
182    /// A single picture.
183    Image,
184    /// Timed text.
185    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/// How much of the source survives the conversion.
206///
207/// Ordered worst-last, so a target's fidelity is the `max` over its streams.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
209pub enum Fidelity {
210    /// Every kept stream is copied through: the payload bytes are unchanged.
211    Copy,
212    /// Something is re-encoded, but every re-encode is mathematically lossless.
213    Lossless,
214    /// At least one stream is re-encoded through a lossy codec.
215    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/// What one input stream becomes in one target.
235#[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    /// `"h264 copy"`, `"aac->opus"`, `"h264 dropped (...)"`.
245    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/// The fate of one stream.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum Action {
257    /// Muxed straight through — no decode, no encode, bytes preserved.
258    Copy,
259    /// Decoded and re-encoded to `to`.
260    Transcode { to: CodecId, lossy: bool },
261    /// Left out of the output.
262    Drop(DropReason),
263}
264
265impl Action {
266    pub fn is_drop(&self) -> bool {
267        matches!(self, Action::Drop(_))
268    }
269}
270
271/// Why a stream cannot make it into a given container.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum DropReason {
274    /// The container carries nothing of this media type at all.
275    UnsupportedMedia,
276    /// The container's codecs for this media type all lack an encoder here.
277    NoEncoder,
278    /// The source codec has no decoder here, so it cannot be re-encoded.
279    NoDecoder,
280    /// The container holds fewer streams than the input has.
281    ContainerFull,
282}
283
284impl DropReason {
285    /// Stable machine-readable slug (used in the JSON rendering).
286    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
311// ---------------------------------------------------------------------------
312// Codec facts
313// ---------------------------------------------------------------------------
314
315/// Is re-encoding *into* `id` mathematically lossless, as this engine writes it?
316///
317/// WebP counts as lossless because our encoder emits VP8L (the lossy VP8 encode
318/// path is a decode-only gap here); GIF does **not**, because writing one
319/// quantizes to a 256-colour palette even though the LZW step itself is exact.
320pub 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
333/// Codecs whose encoder only produces a single picture, so they cannot supply
334/// the video track of a moving-picture container. Our AVIF encoder drives
335/// rav1e in `still_picture` mode; video-mode AV1 encode is a known gap.
336fn encodes_stills_only(id: CodecId) -> bool {
337    matches!(id, CodecId::Avif)
338}
339
340/// Text-cue codecs, which convert between each other by relabelling the cue
341/// packets rather than through the codec registry.
342fn is_text_subtitle(id: CodecId) -> bool {
343    matches!(id, CodecId::Subrip | CodecId::WebVtt)
344}
345
346/// Preference order when a container accepts several codecs for one media type.
347/// The first entry the container accepts *and* we can encode wins.
348fn 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
375/// Per-container overrides where the globally-preferred codec is legal but not
376/// what the file is expected to carry. MP4 accepts Opus, but an `.mp4` should
377/// default to AAC.
378fn 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
385/// The order a person expects containers offered in, **per kind**: a `.flac`
386/// input should lead with `.mp3`/`.wav`, not with the `.mkv` that also happens
387/// to accept its audio. One global list cannot do that — Matroska is the second
388/// video container and the sixth audio one. Unlisted formats sort last.
389const 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
408/// Where `name` sits in its kind's running order.
409fn 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
419/// Media types in the order a container spends its stream budget.
420const 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
426// ---------------------------------------------------------------------------
427// The planner
428// ---------------------------------------------------------------------------
429
430/// Enumerate every container the engine can write for `source`, best first.
431///
432/// "Best" is: the kind that matches the input first (a video in leads with
433/// video containers), then stream-copy before lossless re-encode before lossy,
434/// then a fixed popularity order. A container where every stream would be
435/// dropped is not offered at all.
436pub 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                // The extension alone selects the muxer unless it resolves to a
449                // different format (asking the registry keeps this correct as
450                // formats are added, where a hand-kept list would drift).
451                |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
474/// What the input mostly *is*, which decides which targets lead the list.
475fn 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    // True when the suggested extension does not resolve back to this format,
494    // so the command must say `-f <format>` explicitly.
495    needs_explicit_f: impl Fn(&str) -> bool,
496    source: &[SourceStream],
497) -> Option<Target> {
498    // 1. Decide each stream's fate on codec grounds alone.
499    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    // 2. Apply the container's stream budget: keep the highest-priority
510    //    survivors, drop the overflow.
511    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    // 3. A target is only as faithful as its worst kept stream.
526    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
566/// The extension to suggest for this target.
567///
568/// A container with a codec-specific extension should use it when that is the
569/// codec we are writing — Ogg is `.opus` when it carries Opus and `.ogg` when it
570/// carries Vorbis. Otherwise the format's first extension wins (`.jpg`, not
571/// `.jpeg`).
572fn 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
590/// Copy, transcode, or drop — for one stream against one container.
591fn 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    // A stream copy touches no codec at all, so it needs neither encoder nor
596    // decoder — only a muxer that accepts the codec.
597    if caps.accepts(s.codec_id) {
598        return Action::Copy;
599    }
600    // Text cues convert by relabelling, not through the codec registry.
601    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    // Re-encoding means decoding the source first.
611    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
623/// The codec to re-encode into for `media` in this container: the override if
624/// there is one and we can encode it, else the first preference the container
625/// accepts and we can encode.
626fn pick_encoder(
627    codecs: &CodecRegistry,
628    format: &str,
629    caps: &MuxCaps,
630    media: MediaType,
631) -> Option<CodecId> {
632    let usable = |id: CodecId| {
633        // A still-only encoder cannot supply a moving-picture container.
634        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
642/// Everything a caller should be told before writing this target.
643fn 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    // Generation loss: already-lossy content pushed through another lossy codec.
664    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
692/// The `-c:*` arguments that pin this plan, so a caller does not have to trust
693/// that the engine's defaults match what we just reported.
694fn args(format: &str, needs_f: bool, streams: &[StreamPlan]) -> Vec<String> {
695    let mut args = Vec::new();
696    let mut seen = [false; 3]; // video, audio, subtitle
697    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// ---------------------------------------------------------------------------
724// Static matrix (no input file needed)
725// ---------------------------------------------------------------------------
726
727/// One row of the "what can this build read and write?" matrix, independent of
728/// any input. A UI populates its format picker from this.
729#[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    /// Codecs the muxer accepts, split by media type.
737    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
744/// The full container matrix for this build, container-first then alphabetical.
745pub 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
765/// Every extension this build can *read*, deduplicated and sorted — the accept
766/// list for a file picker.
767pub 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}