Skip to main content

mediadecode_ffmpeg/
channel_layout.rs

1//! Conversions from FFmpeg's [`ffmpeg_next::ChannelLayout`] /
2//! [`ffmpeg_next::ffi::AVChannelOrder`] to the channel-layout vocabulary
3//! [`mediaframe`] owns ([`ChannelLayout`], [`ChannelOrder`],
4//! [`ChannelSpec`], [`ChannelLayoutDescription`]).
5//!
6//! These live as **free functions** (not `From` trait impls) because of
7//! Rust's orphan rule: this crate owns neither `From` nor
8//! `mediaframe::audio::*`, so we can't write the `impl` here. Calling
9//! `mediadecode_ffmpeg::channel_layout_description_from_ffmpeg(layout)`
10//! is the ergonomic boundary instead.
11//!
12//! FFmpeg's own type is imported as [`AvChannelLayout`] so the name
13//! [`ChannelLayout`] can stay with the vocabulary these functions
14//! produce.
15
16use core::{ffi::c_char, slice, str::FromStr};
17
18use ffmpeg_next::{ChannelLayout as AvChannelLayout, ffi};
19use mediaframe::audio::{ChannelLayout, ChannelLayoutDescription, ChannelOrder, ChannelSpec};
20use smol_str::SmolStr;
21use std::vec::Vec;
22
23/// Maps an FFmpeg [`AvChannelLayout`] to the named
24/// [`ChannelLayout`] vocabulary.
25///
26/// Two rungs, in order:
27///
28/// 1. the **constant-arm table** — exactly `ffmpeg_next`'s
29///    `ChannelLayout` constant set, compared through
30///    `av_channel_layout_compare`;
31/// 2. the **describe rung** — for a layout that falls off the table,
32///    FFmpeg names it via `av_channel_layout_describe` and that name
33///    goes through [`ChannelLayout`]'s own total door (`FromStr`).
34///
35/// The second rung is what makes `binaural` / `5.1.2` / `9.1.6`
36/// reachable: [`ChannelLayout`] names all three, `ffmpeg_next` 9.0.0
37/// mints no constant for any of them, so the table alone can never
38/// produce them. It is also why a layout a *later* FFmpeg adds is
39/// reachable with no edit here, as long as the vocabulary already
40/// names it — FFmpeg speaks the name, the vocabulary reads the word,
41/// one source.
42///
43/// Returns [`ChannelLayout::default`] — the `Other("")` absent sentinel
44/// — when neither rung names the layout. The rendering itself is not
45/// smuggled into `Other`: an unrecognised layout stays *absent*, and
46/// [`ChannelLayoutDescription::text`] is where its FFmpeg rendering
47/// lives.
48pub fn channel_layout_from_ffmpeg(value: &AvChannelLayout) -> ChannelLayout {
49  mapped_constant(value).unwrap_or_else(|| channel_layout_from_describe(&describe_layout(value)))
50}
51
52/// The constant-arm table — the first and authoritative rung of
53/// [`channel_layout_from_ffmpeg`]. `None` means the layout fell off the
54/// table and the caller should try the describe rung.
55///
56/// The arm list is exactly `ffmpeg_next`'s `ChannelLayout` constant set:
57/// its `_7POINT1_TOP_BACK` is a `#define` alias of
58/// `AV_CH_LAYOUT_5POINT1POINT2_BACK` and so has no arm of its own, and
59/// its `BINAURAL` / `_5POINT1POINT2` / `_9POINT1POINT6` siblings — which
60/// [`ChannelLayout`] does name — have no constant to match against.
61fn mapped_constant(value: &AvChannelLayout) -> Option<ChannelLayout> {
62  let named = match () {
63    () if value.eq(&AvChannelLayout::MONO) => ChannelLayout::Mono,
64    () if value.eq(&AvChannelLayout::STEREO) => ChannelLayout::Stereo,
65    () if value.eq(&AvChannelLayout::STEREO_DOWNMIX) => ChannelLayout::StereoDownmix,
66    () if value.eq(&AvChannelLayout::SURROUND) => ChannelLayout::Ch3_0,
67    () if value.eq(&AvChannelLayout::QUAD) => ChannelLayout::Quad,
68    () if value.eq(&AvChannelLayout::HEXAGONAL) => ChannelLayout::Hexagonal,
69    () if value.eq(&AvChannelLayout::OCTAGONAL) => ChannelLayout::Octagonal,
70    () if value.eq(&AvChannelLayout::HEXADECAGONAL) => ChannelLayout::Hexadecagonal,
71    () if value.eq(&AvChannelLayout::CUBE) => ChannelLayout::Cube,
72    () if value.eq(&AvChannelLayout::_2POINT1) => ChannelLayout::Ch2_1,
73    () if value.eq(&AvChannelLayout::_2_1) => ChannelLayout::Ch3_0Back,
74    () if value.eq(&AvChannelLayout::_2_2) => ChannelLayout::QuadSide,
75    () if value.eq(&AvChannelLayout::_3POINT1) => ChannelLayout::Ch3_1,
76    () if value.eq(&AvChannelLayout::_3POINT1POINT2) => ChannelLayout::Ch3_1_2,
77    () if value.eq(&AvChannelLayout::_4POINT0) => ChannelLayout::Ch4_0,
78    () if value.eq(&AvChannelLayout::_4POINT1) => ChannelLayout::Ch4_1,
79    () if value.eq(&AvChannelLayout::_5POINT0) => ChannelLayout::Ch5_0,
80    () if value.eq(&AvChannelLayout::_5POINT0_BACK) => ChannelLayout::Ch5_0Back,
81    () if value.eq(&AvChannelLayout::_5POINT1) => ChannelLayout::Ch5_1,
82    () if value.eq(&AvChannelLayout::_5POINT1_BACK) => ChannelLayout::Ch5_1Back,
83    () if value.eq(&AvChannelLayout::_5POINT1POINT2_BACK) => ChannelLayout::Ch5_1_2Back,
84    () if value.eq(&AvChannelLayout::_5POINT1POINT4_BACK) => ChannelLayout::Ch5_1_4Back,
85    () if value.eq(&AvChannelLayout::_6POINT0) => ChannelLayout::Ch6_0,
86    () if value.eq(&AvChannelLayout::_6POINT0_FRONT) => ChannelLayout::Ch6_0Front,
87    () if value.eq(&AvChannelLayout::_6POINT1) => ChannelLayout::Ch6_1,
88    () if value.eq(&AvChannelLayout::_6POINT1_BACK) => ChannelLayout::Ch6_1Back,
89    () if value.eq(&AvChannelLayout::_6POINT1_FRONT) => ChannelLayout::Ch6_1Front,
90    () if value.eq(&AvChannelLayout::_7POINT0) => ChannelLayout::Ch7_0,
91    () if value.eq(&AvChannelLayout::_7POINT0_FRONT) => ChannelLayout::Ch7_0Front,
92    () if value.eq(&AvChannelLayout::_7POINT1) => ChannelLayout::Ch7_1,
93    () if value.eq(&AvChannelLayout::_7POINT1_WIDE) => ChannelLayout::Ch7_1Wide,
94    () if value.eq(&AvChannelLayout::_7POINT1_WIDE_BACK) => ChannelLayout::Ch7_1WideBack,
95    () if value.eq(&AvChannelLayout::_7POINT1POINT2) => ChannelLayout::Ch7_1_2,
96    () if value.eq(&AvChannelLayout::_7POINT1POINT4_BACK) => ChannelLayout::Ch7_1_4Back,
97    () if value.eq(&AvChannelLayout::_7POINT2POINT3) => ChannelLayout::Ch7_2_3,
98    () if value.eq(&AvChannelLayout::_9POINT1POINT4_BACK) => ChannelLayout::Ch9_1_4Back,
99    () if value.eq(&AvChannelLayout::_22POINT2) => ChannelLayout::Ch22_2,
100    () => return None,
101  };
102  Some(named)
103}
104
105/// The describe rung: read FFmpeg's own rendering of a layout
106/// (`av_channel_layout_describe`, e.g. `"binaural"`, `"5.1(side)"`)
107/// through [`ChannelLayout`]'s total `FromStr` door.
108///
109/// A **named** variant wins. Anything the vocabulary does not name —
110/// `FromStr`'s `Other` escape, which is where `"3 channels (FL+FR+TFL)"`
111/// and every custom-order rendering land — collapses to
112/// [`ChannelLayout::default`], the absent sentinel. That collapse is
113/// deliberate: `known_kind` answers *which named layout is this*, and
114/// "none of them" is `Other("")`; the rendering is already carried
115/// verbatim by [`ChannelLayoutDescription::text`], so letting it ride
116/// `Other` too would put a second, differently-shaped copy of the same
117/// string in the same struct.
118fn channel_layout_from_describe(rendered: &str) -> ChannelLayout {
119  ChannelLayout::from_str(rendered)
120    .ok()
121    .filter(|layout| !matches!(layout, ChannelLayout::Other(_)))
122    .unwrap_or_default()
123}
124
125/// Maps FFmpeg's [`AVChannelOrder`](ffi::AVChannelOrder) to the
126/// [`ChannelOrder`] tag.
127pub fn channel_order_from_ffmpeg(value: ffi::AVChannelOrder) -> ChannelOrder {
128  // Compare via integer rather than enum-matching: the caller often
129  // sources `value` from raw FFmpeg memory (`AVChannelLayout.order`),
130  // and an unknown variant would already be UB before reaching this
131  // function. Going through `as i32` here is sound because the caller
132  // is responsible for the up-conversion path; for the raw-pointer
133  // path use [`channel_order_from_raw`].
134  channel_order_from_raw(value as i32)
135}
136
137/// Variant of [`channel_order_from_ffmpeg`] that takes the raw integer
138/// directly. Use this when the caller has just read
139/// `AVChannelLayout.order` from FFmpeg memory and doesn't want to
140/// risk constructing an invalid bindgen enum value first.
141pub fn channel_order_from_raw(raw: i32) -> ChannelOrder {
142  match raw {
143    x if x == ffi::AVChannelOrder::AV_CHANNEL_ORDER_NATIVE as i32 => ChannelOrder::Native,
144    x if x == ffi::AVChannelOrder::AV_CHANNEL_ORDER_CUSTOM as i32 => ChannelOrder::Custom,
145    x if x == ffi::AVChannelOrder::AV_CHANNEL_ORDER_AMBISONIC as i32 => ChannelOrder::Ambisonic,
146    _ => ChannelOrder::Unspecified,
147  }
148}
149
150/// Builds a fully-populated [`ChannelLayoutDescription`] from an FFmpeg
151/// [`AvChannelLayout`].
152///
153/// - Native / Ambisonic layouts populate `native_mask` from
154///   [`AvChannelLayout::bits`] (clearing it to `None` if zero).
155/// - Custom layouts populate `custom_channels` from FFmpeg's per-channel
156///   list (`AVChannelLayout.u.map`), with each label drawn from
157///   `AVChannelCustom.name`.
158/// - `text` carries the result of `av_channel_layout_describe`
159///   (FFmpeg's human-readable rendering — e.g. `"5.1(side)"`).
160/// - `known_kind` runs [`channel_layout_from_ffmpeg`]'s two rungs
161///   against that same single rendering: constant table first, then
162///   the describe rung.
163pub fn channel_layout_description_from_ffmpeg(value: &AvChannelLayout) -> ChannelLayoutDescription {
164  // SAFETY: `value` is a live reference; the inner `AVChannelLayout`
165  // stays valid for the duration of this call. We hand the raw
166  // address into the pointer-based variant which is the canonical
167  // implementation (avoids forming `&AVChannelLayout` over a
168  // potentially-invalid `order` discriminant).
169  unsafe { channel_layout_description_from_raw_ptr(&value.0 as *const ffi::AVChannelLayout) }
170}
171
172/// Pointer variant of [`channel_layout_description_from_ffmpeg`].
173/// Safe-API callers that already hold a `&AvChannelLayout` should prefer
174/// that function; the pointer form exists so the convert path
175/// (which never forms `&AVFrame`) can pass `addr_of!((*av_frame).ch_layout)`
176/// straight through without materializing a typed reference.
177///
178/// # Safety
179/// `ptr` must be a live `*const AVChannelLayout` for the duration of
180/// this call. The function reads `order` raw, then `nb_channels`,
181/// then either `u.mask` (NATIVE / AMBISONIC) or `u.map`
182/// (CUSTOM) — only after the order discriminant has been validated.
183/// It never forms a `&AVChannelLayout` reference.
184pub unsafe fn channel_layout_description_from_raw_ptr(
185  ptr: *const ffi::AVChannelLayout,
186) -> ChannelLayoutDescription {
187  use core::ptr::{addr_of, read_unaligned};
188  // Read `order` as a raw integer first — never let Rust assume
189  // the field is a valid `AVChannelOrder`.
190  // SAFETY: `ptr` is a valid `*const AVChannelLayout`; `addr_of!`
191  // computes the field address without forming a reference; reading
192  // as `i32` matches the bindgen enum's `c_int` storage.
193  let order_raw = unsafe { read_unaligned(addr_of!((*ptr).order) as *const i32) };
194  let order = channel_order_from_raw(order_raw);
195  let nb_channels = unsafe { (*ptr).nb_channels };
196
197  // Native / Ambisonic carry the bitmask in the union. Only read
198  // `u.mask` after the order is validated so we don't trip on an
199  // unknown order writing into a future variant of the union.
200  let native_mask = match order {
201    ChannelOrder::Native | ChannelOrder::Ambisonic => {
202      // SAFETY: `u.mask` is the union variant for NATIVE/AMBISONIC.
203      let mask = unsafe { (*ptr).u.mask };
204      if mask != 0 { Some(mask) } else { None }
205    }
206    _ => None,
207  };
208
209  // Build name / rendering through ffmpeg-next helpers. They take
210  // `&AvChannelLayout` (which is `repr(transparent)` over
211  // `AVChannelLayout`), but at this point we've already validated
212  // `order`, so forming the reference is sound: the only enum-typed
213  // field in `AVChannelLayout` is `order`, and it now holds a value
214  // that came back from `channel_order_from_raw` with the
215  // unknown bucket folded into a known variant — but the *underlying
216  // struct* still has the original raw bytes. We can't form `&AVChannelLayout`
217  // over an unknown order without UB, so for those helpers we
218  // explicitly only call them when order is one of the known variants.
219  let (known_kind, text) = if matches!(order, ChannelOrder::Unspecified) {
220    (ChannelLayout::default(), SmolStr::default())
221  } else {
222    // SAFETY: `order` is one of {Native, Custom, Ambisonic} — all of
223    // which are valid `AVChannelOrder` discriminants present in our
224    // bindgen output, so `&*ptr` is sound to form here.
225    let layout_ref = unsafe { &*(ptr as *const AvChannelLayout) };
226    let text = describe_layout(layout_ref);
227    // Constant-arm table first, exactly as in
228    // `channel_layout_from_ffmpeg`; the rendering is consulted only
229    // when the layout falls off it. Describing once and feeding both
230    // fields from that one string keeps `known_kind` and `text`
231    // answering from the same FFmpeg call.
232    let known_kind =
233      mapped_constant(layout_ref).unwrap_or_else(|| channel_layout_from_describe(&text));
234    (known_kind, text)
235  };
236  let custom_channels_vec = unsafe { custom_channels_raw(ptr, order) };
237
238  ChannelLayoutDescription::new(nb_channels.max(0) as u32)
239    .with_order(order)
240    .with_known_kind(known_kind)
241    .with_native_mask(native_mask)
242    .with_custom_channels(custom_channels_vec)
243    .with_text(text)
244}
245
246/// Pointer-form of `custom_channels`. `order` must be the result of
247/// reading `(*ptr).order` as `i32` and folding through
248/// [`channel_order_from_raw`]; this skips re-reading it.
249///
250/// # Safety
251/// `ptr` must be a live `*const AVChannelLayout`. Reads only fields
252/// (`u.map`, `nb_channels`, and the per-channel array) — no `&AVChannelLayout`
253/// reference is ever formed.
254unsafe fn custom_channels_raw(
255  ptr: *const ffi::AVChannelLayout,
256  order: ChannelOrder,
257) -> Vec<ChannelSpec> {
258  use core::ptr::{addr_of, read_unaligned};
259  if !matches!(order, ChannelOrder::Custom) {
260    return Vec::new();
261  }
262  let count = unsafe { (*ptr).nb_channels }.max(0) as usize;
263  if count == 0 {
264    return Vec::new();
265  }
266  // SAFETY: The `u` field is a union; reading `.map` is sound when
267  // `order == CUSTOM` per FFmpeg's documented contract. Guard
268  // explicitly for null.
269  let map_ptr = unsafe { (*ptr).u.map };
270  if map_ptr.is_null() {
271    return Vec::new();
272  }
273  // Iterate the AVChannelCustom array via raw pointers — never form
274  // `&[AVChannelCustom]` or `&AVChannelCustom`, because each entry
275  // contains `id: AVChannel`, a bindgen enum. If FFmpeg writes an
276  // unknown channel id (version skew / hostile decoder), the
277  // reference itself would be UB before the raw `id` read could
278  // sanitize it.
279  let mut out = Vec::with_capacity(count);
280  for index in 0..count {
281    // SAFETY: `map_ptr` points to `count == nb_channels` valid
282    // `AVChannelCustom` entries per FFmpeg's contract; `index < count`,
283    // so `entry_ptr` lies inside the allocation.
284    let entry_ptr: *const ffi::AVChannelCustom = unsafe { map_ptr.add(index) };
285    // SAFETY: `entry_ptr` is a valid pointer; `addr_of!((*p).field)`
286    // computes the field address without forming a reference.
287    let raw_id = unsafe { read_unaligned(addr_of!((*entry_ptr).id) as *const i32) };
288    let label = unsafe { custom_channel_label_raw(entry_ptr) };
289    out.push(ChannelSpec::new(index as u32, raw_id as u32).with_label(label));
290  }
291  out
292}
293
294/// Pointer-form of `custom_channel_label` — never forms
295/// `&AVChannelCustom`, since the struct contains an enum-typed `id`.
296///
297/// # Safety
298/// `entry_ptr` must be a live `*const AVChannelCustom`.
299unsafe fn custom_channel_label_raw(entry_ptr: *const ffi::AVChannelCustom) -> SmolStr {
300  use core::ptr::addr_of;
301  // SAFETY: `name: [c_char; 16]` is an inline byte array — no
302  // validity invariant beyond initialization (FFmpeg guarantees that).
303  // `addr_of!` computes the address; we then re-interpret as `*const u8`
304  // for UTF-8 lossy decoding.
305  let name_ptr = unsafe { addr_of!((*entry_ptr).name) } as *const u8;
306  // SAFETY: `name` is exactly 16 bytes wide.
307  let bytes = unsafe { slice::from_raw_parts(name_ptr, 16) };
308  let end = bytes
309    .iter()
310    .position(|byte| *byte == 0)
311    .unwrap_or(bytes.len());
312  if end == 0 {
313    return SmolStr::default();
314  }
315  SmolStr::new(std::string::String::from_utf8_lossy(&bytes[..end]))
316}
317
318#[allow(dead_code)]
319fn custom_channels(layout: &AvChannelLayout) -> Vec<ChannelSpec> {
320  // Same raw-integer check as in `channel_layout_description_from_ffmpeg`:
321  // never let Rust form an `AVChannelOrder` value from runtime data
322  // before we've validated its discriminant.
323  use core::ptr::{addr_of, read_unaligned};
324  // SAFETY: `layout.0` is the inner `AVChannelLayout`; reading the
325  // `order` field as `i32` matches the bindgen enum's storage.
326  let order_raw = unsafe { read_unaligned(addr_of!(layout.0.order) as *const i32) };
327  if order_raw != ffi::AVChannelOrder::AV_CHANNEL_ORDER_CUSTOM as i32 {
328    return Vec::new();
329  }
330  let count = layout.0.nb_channels.max(0) as usize;
331  if count == 0 {
332    return Vec::new();
333  }
334  // SAFETY: The `u` field is a union; reading `.map` is sound when
335  // `order == CUSTOM` per FFmpeg's documented contract. The pointer
336  // may still be null on a malformed layout — guard explicitly.
337  let ptr = unsafe { layout.0.u.map };
338  if ptr.is_null() {
339    return Vec::new();
340  }
341  // SAFETY: AVChannelLayout's contract says `.u.map` points to
342  // `nb_channels` valid `AVChannelCustom` entries when order == CUSTOM.
343  let slice_ref = unsafe { slice::from_raw_parts(ptr, count) };
344  slice_ref
345    .iter()
346    .enumerate()
347    .map(|(index, channel)| {
348      // Read `channel.id` as raw `i32` to avoid constructing an
349      // invalid `AVChannel` enum from a value we don't recognize.
350      // SAFETY: `channel` is a valid `&AVChannelCustom`; `id` has the
351      // bindgen enum layout (c_int).
352      let raw_id = unsafe { read_unaligned(addr_of!(channel.id) as *const i32) };
353      ChannelSpec::new(index as u32, raw_id as u32).with_label(custom_channel_label(channel))
354    })
355    .collect()
356}
357
358fn custom_channel_label(channel: &ffi::AVChannelCustom) -> SmolStr {
359  // SAFETY: AVChannelCustom.name is a fixed-size [c_char; 16] inline
360  // buffer. Re-interpreting as bytes for UTF-8 lossy decoding is sound.
361  let bytes =
362    unsafe { slice::from_raw_parts(channel.name.as_ptr() as *const u8, channel.name.len()) };
363  let end = bytes
364    .iter()
365    .position(|byte| *byte == 0)
366    .unwrap_or(bytes.len());
367  if end == 0 {
368    return SmolStr::default();
369  }
370  SmolStr::new(std::string::String::from_utf8_lossy(&bytes[..end]))
371}
372
373/// Renders a layout the way FFmpeg names it (`av_channel_layout_describe`).
374fn describe_layout(layout: &AvChannelLayout) -> SmolStr {
375  // `av_channel_layout_describe` returns the number of bytes needed
376  // (excluding the NUL terminator). Start with a 128-byte buffer —
377  // comfortably bigger than every named layout — and grow once if it
378  // wasn't enough. Use `c_char` for portability (signed on
379  // x86/aarch64-Apple, unsigned on aarch64-Linux).
380  let mut buf = std::vec![0 as c_char; 128];
381  let mut needed =
382    unsafe { ffi::av_channel_layout_describe(&layout.0 as *const _, buf.as_mut_ptr(), buf.len()) };
383  if needed < 0 {
384    return SmolStr::default();
385  }
386  if needed as usize >= buf.len() {
387    buf.resize(needed as usize + 1, 0 as c_char);
388    needed = unsafe {
389      ffi::av_channel_layout_describe(&layout.0 as *const _, buf.as_mut_ptr(), buf.len())
390    };
391    if needed < 0 {
392      return SmolStr::default();
393    }
394  }
395  // SAFETY: buf is heap-allocated, NUL-terminated by FFmpeg's contract.
396  let bytes = unsafe { slice::from_raw_parts(buf.as_ptr() as *const u8, buf.len()) };
397  let end = bytes
398    .iter()
399    .position(|byte| *byte == 0)
400    .unwrap_or(needed as usize);
401  if end == 0 {
402    return SmolStr::default();
403  }
404  SmolStr::new(std::string::String::from_utf8_lossy(&bytes[..end]))
405}
406
407#[cfg(test)]
408mod tests {
409  use super::*;
410
411  /// Builds a NATIVE-order layout from a channel mask, the way a decoder
412  /// hands one over. This is how a layout `ffmpeg_next` mints no constant
413  /// for can be reached at all: `av_channel_layout_from_mask` fills in the
414  /// order and the channel count, so nothing about the value is
415  /// hand-forged.
416  fn native(mask: u64) -> AvChannelLayout {
417    // SAFETY: an all-zero `AVChannelLayout` is `AV_CHANNEL_ORDER_UNSPEC`
418    // with no channels — a valid value, and the same starting point
419    // `ffmpeg_next`'s own `ChannelLayout::default` uses. The constructor
420    // then overwrites every field.
421    let mut raw: ffi::AVChannelLayout = unsafe { core::mem::zeroed() };
422    // SAFETY: `raw` is a live, writable `AVChannelLayout`.
423    let rc = unsafe { ffi::av_channel_layout_from_mask(&mut raw, mask) };
424    assert_eq!(rc, 0, "av_channel_layout_from_mask({mask:#x}) failed");
425    AvChannelLayout(raw)
426  }
427
428  /// `AV_CH_LAYOUT_BINAURAL`, spelled the way the FFmpeg header spells it
429  /// (`1ULL << AV_CHAN_BINAURAL_*`). The composed `AV_CH_BINAURAL_LEFT` /
430  /// `_RIGHT` macros do not survive bindgen's macro evaluation, but the
431  /// `AVChannel` enum they shift by does, so the mask is still derived
432  /// from FFmpeg's own numbers rather than typed out.
433  fn binaural_mask() -> u64 {
434    (1u64 << ffi::AVChannel::AV_CHAN_BINAURAL_LEFT as u64)
435      | (1u64 << ffi::AVChannel::AV_CHAN_BINAURAL_RIGHT as u64)
436  }
437
438  /// The three layouts [`ChannelLayout`] names but `ffmpeg_next` 9.0.0
439  /// mints no constant for. The constant table cannot reach them by
440  /// construction; the describe rung does, because FFmpeg's own layout
441  /// map names all three and the vocabulary reads that word.
442  #[test]
443  fn orphan_layouts_are_named_through_the_describe_rung() {
444    let cases = [
445      (binaural_mask(), "binaural", ChannelLayout::Binaural),
446      (
447        ffi::AV_CH_LAYOUT_5POINT1 | ffi::AV_CH_TOP_FRONT_LEFT | ffi::AV_CH_TOP_FRONT_RIGHT,
448        "5.1.2",
449        ChannelLayout::Ch5_1_2,
450      ),
451      (
452        ffi::AV_CH_LAYOUT_9POINT1POINT4_BACK | ffi::AV_CH_TOP_SIDE_LEFT | ffi::AV_CH_TOP_SIDE_RIGHT,
453        "9.1.6",
454        ChannelLayout::Ch9_1_6,
455      ),
456    ];
457    for (mask, slug, expected) in cases {
458      let layout = native(mask);
459      assert_eq!(
460        mapped_constant(&layout),
461        None,
462        "{slug} must fall off the constant table — that is what makes it an orphan"
463      );
464      assert_eq!(
465        describe_layout(&layout).as_str(),
466        slug,
467        "FFmpeg must name {slug} for the rung to have a word to read"
468      );
469      assert_eq!(
470        channel_layout_from_ffmpeg(&layout),
471        expected,
472        "{slug} must reach its named variant through the rung"
473      );
474
475      let described = channel_layout_description_from_ffmpeg(&layout);
476      assert_eq!(
477        described.known_kind(),
478        &expected,
479        "{slug} must be named on the description path too"
480      );
481      assert_eq!(described.text(), slug, "{slug} rendering rides `text`");
482    }
483  }
484
485  /// FFmpeg 9's actual `5.1.4`: the *side*-surround mask
486  /// (`FL+FR+FC+LFE+SL+SR` plus the four heights), which its layout map
487  /// names and no constant here reaches.
488  ///
489  /// `ffmpeg_sys_next` 9.0.0 bundles a `channel_layout_fixed.h` that
490  /// `#undef`s FFmpeg's layout macros and re-declares them as C
491  /// constants, and its `AV_CH_LAYOUT_5POINT1POINT4_BACK` still carries
492  /// FFmpeg 8's *back*-surround formula. So `ffmpeg_next`'s
493  /// `_5POINT1POINT4_BACK` constant — the one the table compares against
494  /// — is a mask FFmpeg 9 no longer names, and the mask FFmpeg 9 *does*
495  /// name has no constant at all. This is the ruling's "a layout the
496  /// vocabulary already names is reachable with zero adapter edits",
497  /// arriving earlier than expected.
498  ///
499  /// Asserted through the public entry point alone, deliberately: if the
500  /// upstream shim is ever refreshed the constant table will start
501  /// answering this mask itself, and `5.1.4` must come out named either
502  /// way.
503  #[test]
504  fn ffmpeg_nines_own_5_1_4_is_named() {
505    let layout = native(
506      ffi::AV_CH_LAYOUT_5POINT1
507        | ffi::AV_CH_TOP_FRONT_LEFT
508        | ffi::AV_CH_TOP_FRONT_RIGHT
509        | ffi::AV_CH_TOP_BACK_LEFT
510        | ffi::AV_CH_TOP_BACK_RIGHT,
511    );
512    assert_eq!(describe_layout(&layout).as_str(), "5.1.4");
513    assert_eq!(
514      channel_layout_from_ffmpeg(&layout),
515      ChannelLayout::Ch5_1_4Back
516    );
517  }
518
519  /// The constant table is the first rung and answers alone.
520  ///
521  /// `Some` here *is* the bypass proof: [`channel_layout_from_ffmpeg`] is
522  /// `mapped_constant(..).unwrap_or_else(<describe rung>)`, and
523  /// `unwrap_or_else` does not evaluate its closure on `Some` — so a
524  /// mapped constant never renders, never parses, and cannot be
525  /// re-answered by a word.
526  ///
527  /// The sample is the crossed-slug family (where FFmpeg qualifies the
528  /// *side* layout in one place and the *back* one in another, so a
529  /// name-based answer is the one that could plausibly differ), plus the
530  /// `_7POINT1_TOP_BACK` alias that shares `_5POINT1POINT2_BACK`'s mask
531  /// and therefore has no arm of its own.
532  #[test]
533  fn mapped_constants_are_answered_by_the_table_alone() {
534    let table = [
535      ("MONO", AvChannelLayout::MONO, ChannelLayout::Mono),
536      ("STEREO", AvChannelLayout::STEREO, ChannelLayout::Stereo),
537      (
538        "STEREO_DOWNMIX",
539        AvChannelLayout::STEREO_DOWNMIX,
540        ChannelLayout::StereoDownmix,
541      ),
542      ("SURROUND", AvChannelLayout::SURROUND, ChannelLayout::Ch3_0),
543      ("_5POINT0", AvChannelLayout::_5POINT0, ChannelLayout::Ch5_0),
544      (
545        "_5POINT0_BACK",
546        AvChannelLayout::_5POINT0_BACK,
547        ChannelLayout::Ch5_0Back,
548      ),
549      ("_5POINT1", AvChannelLayout::_5POINT1, ChannelLayout::Ch5_1),
550      (
551        "_5POINT1_BACK",
552        AvChannelLayout::_5POINT1_BACK,
553        ChannelLayout::Ch5_1Back,
554      ),
555      (
556        "_5POINT1POINT2_BACK",
557        AvChannelLayout::_5POINT1POINT2_BACK,
558        ChannelLayout::Ch5_1_2Back,
559      ),
560      (
561        "_7POINT1_TOP_BACK",
562        AvChannelLayout::_7POINT1_TOP_BACK,
563        ChannelLayout::Ch5_1_2Back,
564      ),
565      (
566        "_7POINT1_WIDE",
567        AvChannelLayout::_7POINT1_WIDE,
568        ChannelLayout::Ch7_1Wide,
569      ),
570      (
571        "_7POINT1_WIDE_BACK",
572        AvChannelLayout::_7POINT1_WIDE_BACK,
573        ChannelLayout::Ch7_1WideBack,
574      ),
575      (
576        "_22POINT2",
577        AvChannelLayout::_22POINT2,
578        ChannelLayout::Ch22_2,
579      ),
580    ];
581    for (name, layout, expected) in table {
582      assert_eq!(
583        mapped_constant(&layout),
584        Some(expected.clone()),
585        "{name} must be answered by the constant table, not by a rendering"
586      );
587      assert_eq!(channel_layout_from_ffmpeg(&layout), expected, "{name}");
588    }
589  }
590
591  /// A layout nobody names stays *absent*. The rung upgrades the sentinel
592  /// to a named variant or leaves it alone; it never smuggles FFmpeg's
593  /// rendering into `known_kind`'s escape, because `text` already carries
594  /// that rendering verbatim.
595  #[test]
596  fn an_unnamed_layout_stays_absent_with_its_rendering_in_text() {
597    // FL+FR+TFL: a native mask FFmpeg's layout map does not carry, so
598    // `av_channel_layout_describe` falls back to listing the channels.
599    let layout = native(ffi::AV_CH_FRONT_LEFT | ffi::AV_CH_FRONT_RIGHT | ffi::AV_CH_TOP_FRONT_LEFT);
600    assert_eq!(mapped_constant(&layout), None);
601
602    let rendering = describe_layout(&layout);
603    assert!(
604      rendering.contains("TFL"),
605      "FFmpeg should list the channels it cannot name: {rendering:?}"
606    );
607    assert_eq!(
608      channel_layout_from_ffmpeg(&layout),
609      ChannelLayout::default(),
610      "an unnamed layout must land on the absent sentinel"
611    );
612
613    let described = channel_layout_description_from_ffmpeg(&layout);
614    assert_eq!(described.known_kind(), &ChannelLayout::default());
615    assert_eq!(
616      described.text(),
617      rendering.as_str(),
618      "the rendering is what `text` carries"
619    );
620  }
621
622  /// The rung itself, on describe-shaped strings — the half of the door
623  /// that needs no `AVChannelLayout` to exercise.
624  #[test]
625  fn the_describe_rung_reads_names_and_refuses_everything_else() {
626    // The three orphans, as words.
627    assert_eq!(
628      channel_layout_from_describe("binaural"),
629      ChannelLayout::Binaural
630    );
631    assert_eq!(
632      channel_layout_from_describe("5.1.2"),
633      ChannelLayout::Ch5_1_2
634    );
635    assert_eq!(
636      channel_layout_from_describe("9.1.6"),
637      ChannelLayout::Ch9_1_6
638    );
639    // The crossed slugs: unqualified `5.1` is the *back* layout and the
640    // side one is qualified, so reading the word is the only way to tell
641    // these two apart.
642    assert_eq!(
643      channel_layout_from_describe("5.1"),
644      ChannelLayout::Ch5_1Back
645    );
646    assert_eq!(
647      channel_layout_from_describe("5.1(side)"),
648      ChannelLayout::Ch5_1
649    );
650    // Case folding is the vocabulary's, not ours.
651    assert_eq!(
652      channel_layout_from_describe("BINAURAL"),
653      ChannelLayout::Binaural
654    );
655
656    // Everything else is absent — never `Other(<the rendering>)`.
657    for unnamed in [
658      "",
659      "3 channels",
660      "3 channels (FL+FR+TFL)",
661      "FL@Left+FR@Right",
662      "ambisonic 2",
663      "not-a-layout",
664    ] {
665      assert_eq!(
666        channel_layout_from_describe(unnamed),
667        ChannelLayout::default(),
668        "{unnamed:?} must stay absent"
669      );
670    }
671  }
672}