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}