Skip to main content

ez_ffmpeg/core/context/output/
stream_map.rs

1//! Per-map stream mapping parameters: the [`StreamMap`] parameter object
2//! accepted by [`Output::add_stream_map`] and
3//! [`Output::add_stream_map_with_copy`].
4//!
5//! A plain specifier string keeps working (`add_stream_map("0:v")`); the
6//! parameter object adds the FFmpeg per-output-stream encoder selection
7//! (`-c:v:0 libx264 -c:v:1 libx265`) and per-stream encoder options
8//! (`-b:v:0 4M`, `-preset:v:0 slow`) that a per-media-type `Output` setter
9//! cannot express.
10//!
11//! [`Output::add_stream_map`]: super::Output::add_stream_map
12//! [`Output::add_stream_map_with_copy`]: super::Output::add_stream_map_with_copy
13
14use crate::error::OpenOutputError;
15use std::collections::HashMap;
16use std::ffi::CString;
17
18/// Parameters for one stream mapping of an [`Output`](super::Output):
19/// an FFmpeg-style specifier plus optional per-map encoder selection.
20///
21/// This is the builder-API equivalent of the FFmpeg CLI's indexed
22/// per-stream options. One `-map` plus its matching `-c`/`-b`/... options
23/// become one `StreamMap`:
24///
25/// ```text
26/// -map "[v0]" -c:v:0 libx264 -b:v:0 4M -preset:v:0 slow
27/// -map "[v1]" -c:v:1 libx265 -crf:v:1 22
28/// -map 0:a:0  -c:a:0 aac     -b:a:0 128k
29/// -map 0:a:1  -c:a:1 libopus -b:a:1 96k
30/// ```
31///
32/// ```rust,ignore
33/// use ez_ffmpeg::{Output, StreamMap};
34///
35/// let output = Output::from("out.mkv")
36///     .add_stream_map(StreamMap::new("[v0]").codec("libx264")
37///         .codec_opt("b", "4M").codec_opt("preset", "slow"))
38///     .add_stream_map(StreamMap::new("[v1]").codec("libx265")
39///         .codec_opt("crf", "22"))
40///     .add_stream_map(StreamMap::new("0:a:0").codec("aac").codec_opt("b", "128k"))
41///     .add_stream_map(StreamMap::new("0:a:1").codec("libopus").codec_opt("b", "96k"));
42/// ```
43///
44/// # Precedence
45///
46/// - Encoder: per-map [`codec`](Self::codec) > per-type
47///   [`set_video_codec`](super::Output::set_video_codec) /
48///   [`set_audio_codec`](super::Output::set_audio_codec) /
49///   [`set_subtitle_codec`](super::Output::set_subtitle_codec) > the
50///   container format's default encoder.
51/// - Options: per-map [`codec_opt`](Self::codec_opt) entries are merged
52///   **key by key** over the per-type
53///   [`set_video_codec_opt`](super::Output::set_video_codec_opt) /
54///   [`set_audio_codec_opt`](super::Output::set_audio_codec_opt) tables —
55///   the FFmpeg semantics, where every option matches its stream specifier
56///   independently (`-b:v 4M -preset:v:0 slow` leaves stream `v:0` with
57///   *both* the bitrate and the preset). A per-map key overrides the
58///   same-named per-type key; other per-type keys still apply.
59///
60/// This "more specific wins, per key" chain is the builder equivalent of
61/// the CLI's "last matching option is applied" rule.
62///
63/// # Map granularity is binding granularity
64///
65/// A map that expands to several streams applies its codec to **all** of
66/// them: `StreamMap::new("0:a").codec("aac")` re-encodes every audio
67/// stream of input 0 with AAC (like `-c:a aac`). To give two streams
68/// different encoders, write two single-stream maps (`"0:a:0"`,
69/// `"0:a:1"`), exactly like splitting one coarse `-map 0` into indexed
70/// maps on the CLI.
71///
72/// # Copy
73///
74/// `codec("copy")` selects stream copy for the map, equivalent to
75/// [`Output::add_stream_map_with_copy`](super::Output::add_stream_map_with_copy)
76/// (FFmpeg `-c:<spec> copy`). Combining copy with a *different* per-map
77/// codec, or with [`codec_opt`](Self::codec_opt) entries, is rejected at
78/// build time with
79/// [`OpenOutputError::StreamMapCopyConflict`](crate::error::OpenOutputError::StreamMapCopyConflict):
80/// copied packets never pass through an encoder, so those settings could
81/// never take effect.
82///
83/// A negative (disabling) map such as `"-0:v"` only disables previously
84/// matched streams; attaching a codec or codec options to one is rejected
85/// at build time as well.
86//
87// NOTE (coherence): `StreamMap` must NEVER implement `Into<String>` /
88// `From<StreamMap> for String`. The blanket `impl<T: Into<String>> From<T>
89// for StreamMap` below only coexists with std's reflexive `impl<T> From<T>
90// for T` because `StreamMap: Into<String>` does not hold; adding it would
91// make the two impls overlap for `T = StreamMap` and stop compiling.
92#[derive(Debug, Clone)]
93pub struct StreamMap {
94    /// Stream specifier string: `"0:v"`, `"1:a:0"`, `"0:v?"`, `"[label]"`, ...
95    pub(crate) linklabel: String,
96    /// Stream copy flag (FFmpeg `-c copy` for this map).
97    pub(crate) copy: bool,
98    /// Per-map encoder request (FFmpeg `-c:<spec>` for this map's streams).
99    pub(crate) codec: Option<String>,
100    /// Per-map encoder options in insertion order (FFmpeg `-b:<spec>`,
101    /// `-preset:<spec>`, ...). Later entries win on duplicate keys.
102    pub(crate) codec_opts: Vec<(String, String)>,
103}
104
105impl StreamMap {
106    /// Creates a mapping for `spec`, an FFmpeg-style stream specifier
107    /// (`"0:v"`, `"1:a:0"`, `"0:s?"`) or a filter output label
108    /// (`"[v0]"`).
109    ///
110    /// Without further calls this is exactly
111    /// `Output::add_stream_map(spec)`.
112    pub fn new(spec: impl Into<String>) -> Self {
113        Self {
114            linklabel: spec.into(),
115            copy: false,
116            codec: None,
117            codec_opts: Vec::new(),
118        }
119    }
120
121    /// Selects the encoder for every stream this map matches — the
122    /// builder form of FFmpeg's indexed `-c:v:0 libx264`.
123    ///
124    /// `"copy"` selects stream copy (see the type-level docs). Calling
125    /// `codec` again replaces the previous value.
126    pub fn codec(mut self, name: impl Into<String>) -> Self {
127        self.codec = Some(name.into());
128        self
129    }
130
131    /// Adds one encoder option for every stream this map matches — the
132    /// builder form of FFmpeg's indexed `-b:v:0 4M` / `-preset:v:0 slow`.
133    ///
134    /// Per-map entries override same-named per-type options key by key
135    /// (see the type-level docs). Repeating a key keeps the last value.
136    pub fn codec_opt(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
137        self.codec_opts.push((key.into(), value.into()));
138        self
139    }
140
141    /// Resolves the user-facing fields into bind-time form: normalizes
142    /// `codec("copy")` to the copy flag, rejects copy × re-encoding
143    /// conflicts, and converts the option list into the `CString` table
144    /// the encoder layer consumes (later duplicates win, FFmpeg
145    /// last-match style). Called once per map at build time, before any
146    /// expansion, so every error is a typed `build()` failure.
147    pub(crate) fn resolve_for_bind(&self) -> crate::error::Result<ResolvedStreamMap> {
148        let (copy, codec) = match self.codec.as_deref() {
149            // FFmpeg parity: `-c:<spec> copy` means stream copy, not an
150            // encoder named "copy".
151            Some("copy") => (true, None),
152            Some(name) => {
153                if self.copy {
154                    return Err(OpenOutputError::StreamMapCopyConflict {
155                        spec: self.linklabel.clone(),
156                        what: "a per-map codec",
157                    }
158                    .into());
159                }
160                (false, Some(name.to_string()))
161            }
162            None => (self.copy, None),
163        };
164
165        let codec_opts = if self.codec_opts.is_empty() {
166            None
167        } else if copy {
168            // Copied packets never reach an encoder; silently dropping the
169            // options would misrepresent the delivered configuration.
170            return Err(OpenOutputError::StreamMapCopyConflict {
171                spec: self.linklabel.clone(),
172                what: "per-map codec options",
173            }
174            .into());
175        } else {
176            let mut map = HashMap::with_capacity(self.codec_opts.len());
177            for (key, value) in &self.codec_opts {
178                map.insert(CString::new(key.as_str())?, CString::new(value.as_str())?);
179            }
180            Some(map)
181        };
182
183        Ok(ResolvedStreamMap {
184            copy,
185            codec,
186            codec_opts,
187        })
188    }
189}
190
191/// A plain specifier string is a map with no per-map overrides, so every
192/// pre-existing `add_stream_map("0:v")` call keeps compiling unchanged.
193impl<T: Into<String>> From<T> for StreamMap {
194    fn from(linklabel: T) -> Self {
195        Self::new(linklabel)
196    }
197}
198
199/// Bind-time form of one [`StreamMap`]: conflicts rejected, `"copy"`
200/// normalized into the flag, options converted for the encoder layer.
201#[derive(Debug)]
202pub(crate) struct ResolvedStreamMap {
203    pub(crate) copy: bool,
204    pub(crate) codec: Option<String>,
205    pub(crate) codec_opts: Option<HashMap<CString, CString>>,
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::error::Error;
212
213    fn cstr(s: &str) -> CString {
214        CString::new(s).unwrap()
215    }
216
217    #[test]
218    fn new_stores_spec_without_overrides() {
219        let map = StreamMap::new("0:v");
220        assert_eq!(map.linklabel, "0:v");
221        assert!(!map.copy);
222        assert_eq!(map.codec, None);
223        assert!(map.codec_opts.is_empty());
224    }
225
226    #[test]
227    fn blanket_from_matches_new() {
228        // The source-compat path: &str and String become bare maps.
229        let from_str: StreamMap = "0:a?".into();
230        let from_string: StreamMap = String::from("0:a?").into();
231        for map in [from_str, from_string] {
232            assert_eq!(map.linklabel, "0:a?");
233            assert!(!map.copy && map.codec.is_none() && map.codec_opts.is_empty());
234        }
235    }
236
237    #[test]
238    fn codec_copy_normalizes_to_the_copy_flag() {
239        let resolved = StreamMap::new("0:a").codec("copy").resolve_for_bind().unwrap();
240        assert!(resolved.copy);
241        assert_eq!(resolved.codec, None);
242        assert!(resolved.codec_opts.is_none());
243    }
244
245    #[test]
246    fn copy_flag_with_codec_is_a_typed_conflict() {
247        let mut map = StreamMap::new("0:v").codec("libx264");
248        map.copy = true; // what add_stream_map_with_copy forces
249        match map.resolve_for_bind() {
250            Err(Error::OpenOutput(OpenOutputError::StreamMapCopyConflict { spec, what })) => {
251                assert_eq!(spec, "0:v");
252                assert_eq!(what, "a per-map codec");
253            }
254            other => panic!("expected StreamMapCopyConflict, got {other:?}"),
255        }
256    }
257
258    #[test]
259    fn copy_with_codec_opts_is_a_typed_conflict() {
260        let mut map = StreamMap::new("0:a").codec_opt("b", "320k");
261        map.copy = true;
262        match map.resolve_for_bind() {
263            Err(Error::OpenOutput(OpenOutputError::StreamMapCopyConflict { spec, what })) => {
264                assert_eq!(spec, "0:a");
265                assert_eq!(what, "per-map codec options");
266            }
267            other => panic!("expected StreamMapCopyConflict, got {other:?}"),
268        }
269    }
270
271    #[test]
272    fn codec_copy_with_codec_opts_is_a_typed_conflict() {
273        // `-c:a:0 copy -b:a:0 320k`: the options could never take effect.
274        let map = StreamMap::new("0:a").codec("copy").codec_opt("b", "320k");
275        assert!(matches!(
276            map.resolve_for_bind(),
277            Err(Error::OpenOutput(
278                OpenOutputError::StreamMapCopyConflict { .. }
279            ))
280        ));
281    }
282
283    #[test]
284    fn later_codec_call_replaces_the_earlier_one() {
285        let resolved = StreamMap::new("0:v")
286            .codec("copy")
287            .codec("libx264")
288            .resolve_for_bind()
289            .unwrap();
290        assert!(!resolved.copy);
291        assert_eq!(resolved.codec.as_deref(), Some("libx264"));
292    }
293
294    #[test]
295    fn duplicate_option_keys_keep_the_last_value() {
296        let resolved = StreamMap::new("0:v")
297            .codec("mpeg4")
298            .codec_opt("b", "1M")
299            .codec_opt("b", "4M")
300            .resolve_for_bind()
301            .unwrap();
302        let opts = resolved.codec_opts.unwrap();
303        assert_eq!(opts.len(), 1);
304        assert_eq!(opts.get(&cstr("b")), Some(&cstr("4M")));
305    }
306
307    #[test]
308    fn empty_option_list_resolves_to_none() {
309        let resolved = StreamMap::new("0:v").codec("mpeg4").resolve_for_bind().unwrap();
310        assert!(resolved.codec_opts.is_none());
311    }
312
313    #[test]
314    fn interior_nul_in_option_is_a_typed_error() {
315        let map = StreamMap::new("0:v").codec_opt("b\0ad", "1M");
316        assert!(map.resolve_for_bind().is_err());
317    }
318
319    #[test]
320    fn add_stream_map_with_copy_forces_the_copy_flag() {
321        use crate::core::context::output::Output;
322        let output = Output::from("out.mkv").add_stream_map_with_copy(StreamMap::new("0:a"));
323        assert!(output.stream_map_specs[0].copy);
324
325        // The string form keeps its historical behavior too.
326        let output = Output::from("out.mkv").add_stream_map_with_copy("0:a?");
327        assert!(output.stream_map_specs[0].copy);
328        assert_eq!(output.stream_map_specs[0].linklabel, "0:a?");
329    }
330
331    #[test]
332    fn add_stream_map_accepts_str_string_and_stream_map() {
333        use crate::core::context::output::Output;
334        // Source-compat pin for the generic signature: every historical
335        // argument shape still compiles and lands in stream_map_specs.
336        let output = Output::from("out.mkv")
337            .add_stream_map("0:v")
338            .add_stream_map(String::from("0:a:0"))
339            .add_stream_map(StreamMap::new("0:a:1").codec("flac"));
340        assert_eq!(output.stream_map_specs.len(), 3);
341        assert_eq!(output.stream_map_specs[2].codec.as_deref(), Some("flac"));
342    }
343}