Skip to main content

oxideav_rtmp/
flv.rs

1//! FLV-tag payload shape for RTMP audio / video messages.
2//!
3//! Real RTMP always carries H.264 + AAC (plus MP3 / Speex / Nellymoser
4//! for audio on legacy flows; we treat those as opaque). The payload
5//! layout inside type-8 / type-9 messages matches what an `.flv` file
6//! stores in its audio / video tags, so the parsing code is identical
7//! to FLV's.
8//!
9//! Callers of this module work in terms of:
10//!
11//! * [`VideoTag`] — frame type + codec + AVC packet type + NALU-ish
12//!   body. For H.264, the first video message of a stream is an
13//!   "AVC sequence header" (= `AVCDecoderConfigurationRecord`, aka
14//!   avcC). Every subsequent keyframe / interframe is
15//!   `AVCPacketType = 1` with length-prefixed NALUs.
16//!
17//! * [`AudioTag`] — format + rate/size/channels + AAC packet type +
18//!   raw payload. For AAC, the first audio message is the
19//!   `AudioSpecificConfig` (2-byte ASC for LC-AAC 44.1k stereo);
20//!   subsequent messages carry raw AAC frames.
21//!
22//! These shapes are stable across every commodity RTMP implementation
23//! we have interoperated with.
24
25use crate::amf::{self, Amf0Value};
26use crate::error::{Error, Result};
27
28// §E.4.3 "Video tag body" (FLV 10.1 spec annex E).
29// frame type (high nibble of byte 0):
30pub const VIDEO_FRAME_KEYFRAME: u8 = 1; // "seekable frame" aka IDR
31pub const VIDEO_FRAME_INTER: u8 = 2;
32pub const VIDEO_FRAME_DISPOSABLE: u8 = 3; // H.263 only
33pub const VIDEO_FRAME_GENERATED_KEY: u8 = 4;
34pub const VIDEO_FRAME_INFO: u8 = 5;
35
36// codec id (low nibble of byte 0):
37pub const VIDEO_CODEC_H263: u8 = 2;
38pub const VIDEO_CODEC_SCREEN: u8 = 3;
39pub const VIDEO_CODEC_VP6: u8 = 4;
40pub const VIDEO_CODEC_VP6A: u8 = 5;
41pub const VIDEO_CODEC_SCREEN_V2: u8 = 6;
42pub const VIDEO_CODEC_AVC: u8 = 7; // H.264 — the one anyone uses in 2026
43
44pub const AVC_PACKET_TYPE_SEQUENCE_HEADER: u8 = 0;
45pub const AVC_PACKET_TYPE_NALU: u8 = 1;
46pub const AVC_PACKET_TYPE_END_OF_SEQUENCE: u8 = 2;
47
48// Enhanced RTMP v1, Table 4 "Extended VideoTagHeader" (Veovera
49// Software Organization, 2023-2025). When the high bit of byte 0
50// (the IsExHeader flag, value 0x80) is set, the low nibble is a
51// `PacketType` rather than a legacy `CodecID`, and the four bytes
52// that follow are a FourCC video-codec tag rather than the
53// legacy AVC packet-type + composition-time bytes.
54//
55// IsExHeader sits at bit 7 of the first byte. Pre-2023 FLV
56// `FrameType` values never reached 8, so the bit was always zero
57// for legacy publishers — Enhanced RTMP repurposes it without
58// breaking those clients (per the spec's backwards-compatibility
59// note).
60pub const VIDEO_IS_EX_HEADER: u8 = 0x80;
61
62// Enhanced RTMP §"Defining Additional Video Codecs", Table 4 row
63// `PacketType (i.e. not CodecId) — IF IsExHeader == 1, UB[4]`.
64pub const EX_PACKET_TYPE_SEQUENCE_START: u8 = 0;
65pub const EX_PACKET_TYPE_CODED_FRAMES: u8 = 1;
66pub const EX_PACKET_TYPE_SEQUENCE_END: u8 = 2;
67/// `CodedFramesX` — like `CodedFrames` but the SI24
68/// `CompositionTime` is implied to be zero and therefore omitted
69/// from the wire to save three bytes.
70pub const EX_PACKET_TYPE_CODED_FRAMES_X: u8 = 3;
71/// `Metadata` — the VideoTagBody carries an AMF-encoded `[name,
72/// value]` metadata pair instead of coded video. The only
73/// `name` Enhanced RTMP v1 defines is `"colorInfo"` (HDR
74/// signalling). When this PacketType is present the `FrameType`
75/// flags at the top of the header are required (per spec) to be
76/// ignored.
77pub const EX_PACKET_TYPE_METADATA: u8 = 4;
78/// `MPEG2TSSequenceStart` — sequence-start variant whose body is
79/// the codec's MPEG-2-TS-format descriptor (used by AV1's
80/// `AV1VideoDescriptor`, mutually exclusive with
81/// `PacketTypeSequenceStart` per the 2023-06-07 revision note).
82pub const EX_PACKET_TYPE_MPEG2TS_SEQUENCE_START: u8 = 5;
83/// `Multitrack` — turns on video multitrack mode. After this
84/// PacketType nibble the next byte packs `multitrackType (UB[4]) |
85/// realPacketType (UB[4])`, optionally followed by a shared FourCC
86/// (when `multitrackType != ManyTracksManyCodecs`), then a sequence
87/// of tracks each carrying `(FourCC if ManyTracksManyCodecs) |
88/// trackId(UI8) | (sizeOfVideoTrack(UI24) if not OneTrack) | body`.
89/// Decoded by [`Multitrack`] / [`MultitrackTrack`] via
90/// [`VideoTag::multitrack`].
91pub const EX_PACKET_TYPE_MULTITRACK: u8 = 6;
92/// `ModEx` — modifier/extension marker that introduces a chain of
93/// size-prefixed ModEx packets before the *real* VideoPacketType is
94/// read (`enhanced-rtmp-v2.pdf` §"ExVideoTagHeader" — the
95/// `while (videoPacketType == VideoPacketType.ModEx)` loop). One of
96/// these chains can carry high-precision timestamps
97/// (`TimestampOffsetNano`) or other future per-message modifiers.
98pub const EX_PACKET_TYPE_MOD_EX: u8 = 7;
99
100/// `enum VideoPacketModExType` / `enum AudioPacketModExType`
101/// (`enhanced-rtmp-v2.pdf` §"ExVideoTagHeader" / §"ExAudioTagHeader").
102/// `TimestampOffsetNano = 0` is the only subtype defined today: the
103/// ModEx data carries a `bytesToUI24` nanosecond offset (0..=999_999
104/// ns) added to the current media message's presentation time
105/// without altering the core RTMP millisecond timestamp.
106pub const MOD_EX_TYPE_TIMESTAMP_OFFSET_NANO: u8 = 0;
107
108/// One entry in the Enhanced RTMP v2 ModEx prelude chain
109/// (`enhanced-rtmp-v2.pdf` §"ExVideoTagHeader" / §"ExAudioTagHeader").
110///
111/// On the wire each entry is `modExDataSize` (1-byte `UI8 + 1`, or a
112/// 16-bit `UI16 + 1` escape when the 8-bit form would be 256),
113/// followed by `modExDataSize` bytes of `modExData`, then a single
114/// byte whose high nibble is the [`mod_ex_type`][ModEx::mod_ex_type]
115/// (`UB[4]`) and whose low nibble is the *next* PacketType (`UB[4]`).
116/// The decoded struct keeps only the per-entry payload; the trailing
117/// nibble byte is reconstructed from the chain order + the tag's real
118/// packet type when re-encoding.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ModEx {
121    /// `AudioPacketModExType` / `VideoPacketModExType` — the high
122    /// nibble of the byte that follows the data. One of
123    /// `MOD_EX_TYPE_*` (only `TimestampOffsetNano = 0` defined today).
124    pub mod_ex_type: u8,
125    /// Raw `modExData` bytes (1..=65536 bytes). For
126    /// `TimestampOffsetNano` this is at least 3 bytes whose first
127    /// three big-endian bytes are the UI24 nanosecond offset.
128    pub data: Vec<u8>,
129}
130
131impl ModEx {
132    /// Decode the `TimestampOffsetNano` value (`bytesToUI24` of the
133    /// first three `data` bytes) when this entry is that subtype.
134    /// Returns `None` for any other `mod_ex_type` or if `data` is
135    /// shorter than the spec-mandated three bytes.
136    pub fn timestamp_offset_nano(&self) -> Option<u32> {
137        if self.mod_ex_type != MOD_EX_TYPE_TIMESTAMP_OFFSET_NANO || self.data.len() < 3 {
138            return None;
139        }
140        Some(((self.data[0] as u32) << 16) | ((self.data[1] as u32) << 8) | (self.data[2] as u32))
141    }
142
143    /// Build a `TimestampOffsetNano` ModEx entry from a nanosecond
144    /// offset (0..=999_999 ns per spec) encoded as a `bytesToUI24`
145    /// 3-byte big-endian payload.
146    pub fn timestamp_offset_nano_entry(nano: u32) -> ModEx {
147        ModEx {
148            mod_ex_type: MOD_EX_TYPE_TIMESTAMP_OFFSET_NANO,
149            data: vec![(nano >> 16) as u8, (nano >> 8) as u8, nano as u8],
150        }
151    }
152}
153
154/// Parse a ModEx prelude chain starting at `payload[start]` (the
155/// `payload[start - 1]` low nibble was already decoded as
156/// `PacketType.ModEx`). Returns the decoded entries, the *real*
157/// PacketType nibble that terminates the chain, and the offset of
158/// the first byte after the chain.
159///
160/// Per `enhanced-rtmp-v2.pdf` the loop is identical for audio and
161/// video: read `modExDataSize` (`UI8 + 1`, escaping to `UI16 + 1`
162/// when the 8-bit form would be 256), read that many data bytes,
163/// then read one nibble byte (`modExType:UB[4] | packetType:UB[4]`)
164/// — repeating while the new packetType is again `ModEx`.
165fn parse_mod_ex_chain(
166    payload: &[u8],
167    start: usize,
168    mod_ex_value: u8,
169    what: &str,
170) -> Result<(Vec<ModEx>, u8, usize)> {
171    let mut pos = start;
172    let mut chain = Vec::new();
173    loop {
174        // modExDataSize = UI8 + 1
175        if pos >= payload.len() {
176            return Err(Error::Other(format!(
177                "Enhanced RTMP {what} ModEx: truncated reading modExDataSize"
178            )));
179        }
180        let mut size = payload[pos] as usize + 1;
181        pos += 1;
182        // If the 8-bit form maxes out (== 256), a UI16 + 1 follows.
183        if size == 256 {
184            if pos + 2 > payload.len() {
185                return Err(Error::Other(format!(
186                    "Enhanced RTMP {what} ModEx: truncated reading 16-bit modExDataSize"
187                )));
188            }
189            size = (((payload[pos] as usize) << 8) | (payload[pos + 1] as usize)) + 1;
190            pos += 2;
191        }
192        // modExData = UI8[modExDataSize]
193        if pos + size > payload.len() {
194            return Err(Error::Other(format!(
195                "Enhanced RTMP {what} ModEx: truncated reading {size}-byte modExData"
196            )));
197        }
198        let data = payload[pos..pos + size].to_vec();
199        pos += size;
200        // nibble byte: modExType (UB[4], high) | packetType (UB[4], low)
201        if pos >= payload.len() {
202            return Err(Error::Other(format!(
203                "Enhanced RTMP {what} ModEx: truncated reading modExType/packetType nibble"
204            )));
205        }
206        let nibble = payload[pos];
207        pos += 1;
208        let mod_ex_type = (nibble >> 4) & 0x0F;
209        let next_packet_type = nibble & 0x0F;
210        chain.push(ModEx { mod_ex_type, data });
211        if next_packet_type != mod_ex_value {
212            return Ok((chain, next_packet_type, pos));
213        }
214        // Another ModEx entry follows.
215    }
216}
217
218/// Append a ModEx prelude chain to `out`. Each entry writes the
219/// `modExDataSize` (`UI8 + 1`, or the `0xFF` + `UI16 + 1` escape
220/// when the data is 257..=65536 bytes), the data bytes, and a nibble
221/// byte whose high nibble is the entry's `mod_ex_type` and whose low
222/// nibble is the PacketType to read *next* — `ModEx` for every entry
223/// except the last, whose low nibble is the real `packet_type`.
224fn build_mod_ex_chain(out: &mut Vec<u8>, chain: &[ModEx], mod_ex_value: u8, real_packet_type: u8) {
225    for (i, entry) in chain.iter().enumerate() {
226        let len = entry.data.len();
227        // UI8 form covers 1..=255 bytes (stored as len - 1, 0..=254).
228        // A stored UI8 of 255 means modExDataSize == 256, which the
229        // parser reads as the "switch to UI16" escape — so 256..=65536
230        // bytes always take the escape form (UI16 = len - 1).
231        if (1..=255).contains(&len) {
232            out.push((len - 1) as u8);
233        } else {
234            // UI16 escape: emit 0xFF (the 8-bit 256 sentinel), then
235            // (len - 1) as UI16. len is clamped to the 16-bit range.
236            out.push(0xFF);
237            let v16 = (len.saturating_sub(1)).min(0xFFFF) as u16;
238            out.push((v16 >> 8) as u8);
239            out.push(v16 as u8);
240        }
241        out.extend_from_slice(&entry.data);
242        // The terminating nibble byte points at the *next* packet
243        // type: ModEx while more entries follow, the real type last.
244        let next = if i + 1 < chain.len() {
245            mod_ex_value
246        } else {
247            real_packet_type
248        };
249        out.push(((entry.mod_ex_type & 0x0F) << 4) | (next & 0x0F));
250    }
251}
252
253// Enhanced RTMP §"Defining Additional Video Codecs", Table 4
254// "Video FourCC" row. FourCCs are read as four ASCII bytes in
255// reading order (i.e. `'a','v','0','1'`), interpreted as a UI32
256// big-endian for comparison (`0x6176_3031`).
257//
258// `av01` / `vp09` / `hvc1` were added in Enhanced RTMP v1
259// (Veovera 2023). `vp08` (VP8), `avc1` (FourCC-mode AVC/H.264),
260// and `vvc1` (VVC/H.266) were added in Enhanced RTMP v2
261// (Veovera 2026) — see enhanced-rtmp-v2.pdf §"Enhanced Video"
262// `enum VideoFourCc { Vp8, Vp9, Av1, Avc, Hevc, Vvc }`.
263pub const FOURCC_AV1: [u8; 4] = *b"av01";
264pub const FOURCC_VP9: [u8; 4] = *b"vp09";
265pub const FOURCC_HEVC: [u8; 4] = *b"hvc1";
266/// Enhanced RTMP v2 — VP8 FourCC. SequenceStart body is a
267/// `VPCodecConfigurationRecord` (same shape as VP9). CodedFrames
268/// body is one or more full VP8 frames. CTS not on the wire (no
269/// B-frames).
270pub const FOURCC_VP8: [u8; 4] = *b"vp08";
271/// Enhanced RTMP v2 — AVC/H.264 in FourCC mode. SequenceStart body
272/// is the `AVCDecoderConfigurationRecord`; CodedFrames body is
273/// one or more length-prefixed NALUs. Per
274/// enhanced-rtmp-v2.pdf §"ExVideoTagBody" the SI24
275/// `compositionTimeOffset` is on the wire for AVC + CodedFrames
276/// (parallel to HEVC's row), and implied zero for
277/// CodedFramesX.
278pub const FOURCC_AVC: [u8; 4] = *b"avc1";
279/// Enhanced RTMP v2 — VVC/H.266 FourCC. SequenceStart body is the
280/// `VVCDecoderConfigurationRecord` (per ISO/IEC 14496-15:2024
281/// §11.2.4.2). CodedFrames body is one or more length-prefixed
282/// NALUs. Per §"ExVideoTagBody" the SI24
283/// `compositionTimeOffset` is on the wire for VVC + CodedFrames
284/// (mirrors AVC + HEVC) and implied zero for CodedFramesX.
285pub const FOURCC_VVC: [u8; 4] = *b"vvc1";
286
287// §E.4.2 "Audio tag body".
288// sound format (high nibble of byte 0):
289pub const AUDIO_FORMAT_PCM_LE: u8 = 0;
290pub const AUDIO_FORMAT_ADPCM: u8 = 1;
291pub const AUDIO_FORMAT_MP3: u8 = 2;
292pub const AUDIO_FORMAT_PCM_LE_8BIT: u8 = 3;
293pub const AUDIO_FORMAT_NELLYMOSER_16K_MONO: u8 = 4;
294pub const AUDIO_FORMAT_NELLYMOSER_8K_MONO: u8 = 5;
295pub const AUDIO_FORMAT_NELLYMOSER: u8 = 6;
296pub const AUDIO_FORMAT_G711_ALAW: u8 = 7;
297pub const AUDIO_FORMAT_G711_MULAW: u8 = 8;
298pub const AUDIO_FORMAT_AAC: u8 = 10;
299pub const AUDIO_FORMAT_SPEEX: u8 = 11;
300
301// Enhanced RTMP v2, "Extended AudioTagHeader" (Veovera Software
302// Organization, 2026-01-31). When the high nibble of the FLV
303// AudioTagHeader byte (SoundFormat) equals `ExHeader = 9`, the
304// low UB[4] is reinterpreted as an `AudioPacketType` rather than
305// the legacy SoundRate(UB[2]) | SoundSize(UB[1]) | SoundType(UB[1])
306// bit field, and the four bytes that follow are an `AudioFourCc`
307// rather than the AAC packet-type marker.
308//
309// Spec: enhanced-rtmp-v2.pdf §"Enhanced Audio", `Extended
310// AudioTagHeader` table (`soundFormat = UB[4] as SoundFormat`,
311// `if soundFormat == SoundFormat.ExHeader { audioPacketType =
312// UB[4] as AudioPacketType }`). Legacy publishers leave the
313// high nibble in `0..=8 / 10..=11 / 14..=15` and the parser /
314// builder retain the pre-2023 single-byte format unchanged.
315pub const AUDIO_FORMAT_EX_HEADER: u8 = 9;
316
317// AudioPacketType enum from the same Extended AudioTagHeader
318// table. The values that carry semantics today:
319pub const AUDIO_PACKET_TYPE_SEQUENCE_START: u8 = 0;
320pub const AUDIO_PACKET_TYPE_CODED_FRAMES: u8 = 1;
321/// `SequenceEnd` — signals end of the audio sequence for the
322/// current track. Spec: "AudioPacketType.SequenceEnd is to have no
323/// less than the same meaning as a silence message".
324pub const AUDIO_PACKET_TYPE_SEQUENCE_END: u8 = 2;
325/// `MultichannelConfig` — body specifies AudioChannelOrder +
326/// channel count + (optionally) per-channel speaker mapping or a
327/// 32-bit AudioChannelFlags mask. See §"ExAudioTagBody" pseudocode
328/// for the layout. The body shape is decoded by
329/// [`MultichannelConfig`]; see [`AudioTag::multichannel_config`] for
330/// the lift / round-trip helpers.
331pub const AUDIO_PACKET_TYPE_MULTICHANNEL_CONFIG: u8 = 4;
332/// `Multitrack` — turns on audio multitrack mode. After this
333/// PacketType nibble the next byte packs `multitrackType (UB[4]) |
334/// realPacketType (UB[4])`, optionally followed by a shared FourCC
335/// (when `multitrackType != ManyTracksManyCodecs`), then a sequence
336/// of tracks each carrying `(FourCC if ManyTracksManyCodecs) |
337/// trackId(UI8) | (sizeOfAudioTrack(UI24) if not OneTrack) | body`.
338/// Decoded by [`Multitrack`] / [`MultitrackTrack`] via
339/// [`AudioTag::multitrack`].
340pub const AUDIO_PACKET_TYPE_MULTITRACK: u8 = 5;
341/// `ModEx` — modifier/extension marker that introduces a chain
342/// of size-prefixed ModEx packets before the real AudioPacketType
343/// is read. The only ModEx subtype defined today is
344/// `TimestampOffsetNano = 0`. Deferred to a follow-up round.
345pub const AUDIO_PACKET_TYPE_MOD_EX: u8 = 7;
346
347// Enhanced RTMP v2 §"Enhanced Audio", `enum AudioFourCc` block.
348// FourCCs are read as four ASCII bytes in reading order
349// (e.g. `'O','p','u','s'`), interpreted as a big-endian UI32 for
350// comparison.
351pub const FOURCC_AC3: [u8; 4] = *b"ac-3";
352pub const FOURCC_EAC3: [u8; 4] = *b"ec-3";
353pub const FOURCC_OPUS: [u8; 4] = *b"Opus";
354pub const FOURCC_MP3: [u8; 4] = *b".mp3";
355pub const FOURCC_FLAC: [u8; 4] = *b"fLaC";
356pub const FOURCC_AAC: [u8; 4] = *b"mp4a";
357
358pub const AAC_PACKET_TYPE_SEQUENCE_HEADER: u8 = 0;
359pub const AAC_PACKET_TYPE_RAW: u8 = 1;
360
361// ---------------------------------------------------------------------------
362// MultichannelConfig — Enhanced RTMP v2 §"ExAudioTagBody"
363// ---------------------------------------------------------------------------
364//
365// When AudioPacketType == MultichannelConfig (= 4) the per-packet body
366// has the layout:
367//
368//   audioChannelOrder = UI8 as AudioChannelOrder
369//   channelCount      = UI8
370//   if (audioChannelOrder == Custom)  audioChannelMapping = UI8[channelCount]
371//   if (audioChannelOrder == Native)  audioChannelFlags   = UI32
372//   if (audioChannelOrder == Unspecified) nothing further
373//
374// This block is sent on a separate `MultichannelConfig` audio message and
375// applies to the surrounding sequence; it does NOT itself carry codec
376// bitstream bytes.
377
378/// AudioChannelOrder discriminator (UI8) per enhanced-rtmp-v2.pdf
379/// §"ExAudioTagBody" `enum AudioChannelOrder`: only the channel count
380/// is specified, channel order is left to the codec / app.
381pub const AUDIO_CHANNEL_ORDER_UNSPECIFIED: u8 = 0;
382/// AudioChannelOrder.Native: the channels are in the order defined by
383/// the AudioChannel enum; an `AudioChannelFlags` UI32 mask follows the
384/// channel count, with bits indexing into [`audio_channel_mask`].
385pub const AUDIO_CHANNEL_ORDER_NATIVE: u8 = 1;
386/// AudioChannelOrder.Custom: each channel's speaker assignment is
387/// spelled out by `audioChannelMapping = UI8[channelCount]`, where each
388/// UI8 is an `AudioChannel` value (see [`audio_channel`]).
389pub const AUDIO_CHANNEL_ORDER_CUSTOM: u8 = 2;
390
391/// `AudioChannel` enum values (UI8) per enhanced-rtmp-v2.pdf
392/// §"ExAudioTagBody" — speaker positions used for
393/// `AudioChannelOrder.Custom` mappings. The numeric values match the
394/// spec table 1:1 and align with the bit indices in
395/// [`audio_channel_mask`].
396pub mod audio_channel {
397    pub const FRONT_LEFT: u8 = 0;
398    pub const FRONT_RIGHT: u8 = 1;
399    pub const FRONT_CENTER: u8 = 2;
400    pub const LOW_FREQUENCY1: u8 = 3;
401    pub const BACK_LEFT: u8 = 4;
402    pub const BACK_RIGHT: u8 = 5;
403    pub const FRONT_LEFT_CENTER: u8 = 6;
404    pub const FRONT_RIGHT_CENTER: u8 = 7;
405    pub const BACK_CENTER: u8 = 8;
406    pub const SIDE_LEFT: u8 = 9;
407    pub const SIDE_RIGHT: u8 = 10;
408    pub const TOP_CENTER: u8 = 11;
409    pub const TOP_FRONT_LEFT: u8 = 12;
410    pub const TOP_FRONT_CENTER: u8 = 13;
411    pub const TOP_FRONT_RIGHT: u8 = 14;
412    pub const TOP_BACK_LEFT: u8 = 15;
413    pub const TOP_BACK_CENTER: u8 = 16;
414    pub const TOP_BACK_RIGHT: u8 = 17;
415    // mappings completing 22.2 multichannel audio (SMPTE ST 2036-2-2008)
416    pub const LOW_FREQUENCY2: u8 = 18;
417    pub const TOP_SIDE_LEFT: u8 = 19;
418    pub const TOP_SIDE_RIGHT: u8 = 20;
419    pub const BOTTOM_FRONT_CENTER: u8 = 21;
420    pub const BOTTOM_FRONT_LEFT: u8 = 22;
421    pub const BOTTOM_FRONT_RIGHT: u8 = 23;
422    /// Channel is empty / can be safely skipped.
423    pub const UNUSED: u8 = 0xfe;
424    /// Channel contains data, but its speaker configuration is unknown.
425    pub const UNKNOWN: u8 = 0xff;
426}
427
428/// `AudioChannelMask` bitmask values (UI32) per enhanced-rtmp-v2.pdf
429/// §"ExAudioTagBody" — used with `AudioChannelOrder.Native` to indicate
430/// which channels of the standard layout are present.
431pub mod audio_channel_mask {
432    pub const FRONT_LEFT: u32 = 0x000001;
433    pub const FRONT_RIGHT: u32 = 0x000002;
434    pub const FRONT_CENTER: u32 = 0x000004;
435    pub const LOW_FREQUENCY1: u32 = 0x000008;
436    pub const BACK_LEFT: u32 = 0x000010;
437    pub const BACK_RIGHT: u32 = 0x000020;
438    pub const FRONT_LEFT_CENTER: u32 = 0x000040;
439    pub const FRONT_RIGHT_CENTER: u32 = 0x000080;
440    pub const BACK_CENTER: u32 = 0x000100;
441    pub const SIDE_LEFT: u32 = 0x000200;
442    pub const SIDE_RIGHT: u32 = 0x000400;
443    pub const TOP_CENTER: u32 = 0x000800;
444    pub const TOP_FRONT_LEFT: u32 = 0x001000;
445    pub const TOP_FRONT_CENTER: u32 = 0x002000;
446    pub const TOP_FRONT_RIGHT: u32 = 0x004000;
447    pub const TOP_BACK_LEFT: u32 = 0x008000;
448    pub const TOP_BACK_CENTER: u32 = 0x010000;
449    pub const TOP_BACK_RIGHT: u32 = 0x020000;
450    // 22.2 surround additions
451    pub const LOW_FREQUENCY2: u32 = 0x040000;
452    pub const TOP_SIDE_LEFT: u32 = 0x080000;
453    pub const TOP_SIDE_RIGHT: u32 = 0x100000;
454    pub const BOTTOM_FRONT_CENTER: u32 = 0x200000;
455    pub const BOTTOM_FRONT_LEFT: u32 = 0x400000;
456    pub const BOTTOM_FRONT_RIGHT: u32 = 0x800000;
457}
458
459/// Decoded body of an Enhanced RTMP v2
460/// `AudioPacketType.MultichannelConfig` message
461/// (enhanced-rtmp-v2.pdf §"ExAudioTagBody"). The body sits in
462/// [`AudioTag::body`] verbatim on parse; callers can lift it into this
463/// strongly-typed view via [`MultichannelConfig::parse`] and round-trip
464/// back through [`MultichannelConfig::encode`] / [`AudioTag::with_multichannel_config`].
465///
466/// Per spec the body length depends on `audio_channel_order`:
467///   - `Unspecified` (`0`): 2 bytes (`order`, `channel_count`).
468///   - `Native` (`1`): 6 bytes (`order`, `channel_count`, UI32 flags).
469///   - `Custom` (`2`): `2 + channel_count` bytes (mapping is a UI8 per
470///     channel).
471///
472/// Any UI8 `audio_channel_order` value that is not one of those three
473/// surfaces as [`MultichannelConfigOrder::Reserved`] — the parser does
474/// not invent a layout, and the build path will encode just the
475/// `(order, channel_count)` prefix, leaving any trailing bytes to the
476/// caller via [`MultichannelConfig::extra`].
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct MultichannelConfig {
479    /// The full discriminator union from the spec table. See
480    /// [`MultichannelConfigOrder`] for the shape per variant.
481    pub order: MultichannelConfigOrder,
482    /// Number of channels in the multichannel stream. UI8 on the wire,
483    /// so values 0..=255 are representable.
484    pub channel_count: u8,
485    /// Trailing bytes preserved verbatim when [`order`] is
486    /// [`MultichannelConfigOrder::Reserved`] (forward-compat with
487    /// future spec additions). Empty for the three recognised orders.
488    pub extra: Vec<u8>,
489}
490
491/// Discriminated union of the per-`audioChannelOrder` body shape from
492/// `ExAudioTagBody`.
493#[derive(Debug, Clone, PartialEq, Eq)]
494pub enum MultichannelConfigOrder {
495    /// `AudioChannelOrder.Unspecified` — only the channel count is
496    /// specified, no trailing per-channel data.
497    Unspecified,
498    /// `AudioChannelOrder.Native` — channels appear in the order
499    /// defined by the `AudioChannel` enum. The 32-bit
500    /// `audioChannelFlags` mask reports which of the standard channels
501    /// are present; bit positions match [`audio_channel_mask`].
502    Native { flags: u32 },
503    /// `AudioChannelOrder.Custom` — `audioChannelMapping[channelCount]`
504    /// names the speaker (an `AudioChannel` value) for each channel,
505    /// in stream order. Length equals
506    /// [`MultichannelConfig::channel_count`].
507    Custom { mapping: Vec<u8> },
508    /// A reserved / forward-compat `audioChannelOrder` value the parser
509    /// did not recognise. The raw discriminator byte is preserved here
510    /// so callers can pass the message through unchanged; trailing
511    /// body bytes (if any) sit in [`MultichannelConfig::extra`].
512    Reserved(u8),
513}
514
515impl MultichannelConfigOrder {
516    /// UI8 discriminator value as it appears on the wire.
517    pub fn as_u8(&self) -> u8 {
518        match self {
519            MultichannelConfigOrder::Unspecified => AUDIO_CHANNEL_ORDER_UNSPECIFIED,
520            MultichannelConfigOrder::Native { .. } => AUDIO_CHANNEL_ORDER_NATIVE,
521            MultichannelConfigOrder::Custom { .. } => AUDIO_CHANNEL_ORDER_CUSTOM,
522            MultichannelConfigOrder::Reserved(v) => *v,
523        }
524    }
525}
526
527impl MultichannelConfig {
528    /// Parse the body bytes of an `AudioPacketType.MultichannelConfig`
529    /// audio message (the bytes that sit in [`AudioTag::body`] after a
530    /// successful [`parse_audio`] call). Returns `Err(Error::Other)` on
531    /// truncation; an unrecognised `audioChannelOrder` does NOT trigger
532    /// an error — it is preserved as [`MultichannelConfigOrder::Reserved`]
533    /// and any trailing bytes flow through [`MultichannelConfig::extra`].
534    pub fn parse(body: &[u8]) -> Result<MultichannelConfig> {
535        if body.len() < 2 {
536            return Err(Error::Other(
537                "MultichannelConfig: need 2 bytes (order + channelCount)".into(),
538            ));
539        }
540        let order_byte = body[0];
541        let channel_count = body[1];
542        match order_byte {
543            AUDIO_CHANNEL_ORDER_UNSPECIFIED => {
544                if body.len() != 2 {
545                    return Err(Error::Other(
546                        "MultichannelConfig.Unspecified: trailing bytes after channelCount".into(),
547                    ));
548                }
549                Ok(MultichannelConfig {
550                    order: MultichannelConfigOrder::Unspecified,
551                    channel_count,
552                    extra: Vec::new(),
553                })
554            }
555            AUDIO_CHANNEL_ORDER_NATIVE => {
556                if body.len() != 6 {
557                    return Err(Error::Other(
558                        "MultichannelConfig.Native: need 6 bytes (order + count + UI32 flags)"
559                            .into(),
560                    ));
561                }
562                let flags = u32::from_be_bytes([body[2], body[3], body[4], body[5]]);
563                Ok(MultichannelConfig {
564                    order: MultichannelConfigOrder::Native { flags },
565                    channel_count,
566                    extra: Vec::new(),
567                })
568            }
569            AUDIO_CHANNEL_ORDER_CUSTOM => {
570                let need = 2 + channel_count as usize;
571                if body.len() != need {
572                    return Err(Error::Other(format!(
573                        "MultichannelConfig.Custom: need {need} bytes for channelCount={channel_count}, got {}",
574                        body.len()
575                    )));
576                }
577                Ok(MultichannelConfig {
578                    order: MultichannelConfigOrder::Custom {
579                        mapping: body[2..need].to_vec(),
580                    },
581                    channel_count,
582                    extra: Vec::new(),
583                })
584            }
585            other => Ok(MultichannelConfig {
586                order: MultichannelConfigOrder::Reserved(other),
587                channel_count,
588                extra: body[2..].to_vec(),
589            }),
590        }
591    }
592
593    /// Serialise to the byte layout `parse` consumes. The output is
594    /// what [`AudioTag::body`] needs to hold when constructing an
595    /// outgoing `MultichannelConfig` message.
596    pub fn encode(&self) -> Vec<u8> {
597        let mut out = Vec::with_capacity(8);
598        out.push(self.order.as_u8());
599        out.push(self.channel_count);
600        match &self.order {
601            MultichannelConfigOrder::Unspecified => {}
602            MultichannelConfigOrder::Native { flags } => {
603                out.extend_from_slice(&flags.to_be_bytes());
604            }
605            MultichannelConfigOrder::Custom { mapping } => {
606                out.extend_from_slice(mapping);
607            }
608            MultichannelConfigOrder::Reserved(_) => {
609                out.extend_from_slice(&self.extra);
610            }
611        }
612        out
613    }
614}
615
616// ---------------------------------------------------------------------------
617// ColorInfo — Enhanced RTMP §"Metadata Frame" (VideoPacketType.Metadata)
618// ---------------------------------------------------------------------------
619//
620// A `VideoPacketType.Metadata` (= 4) video message carries an AMF-encoded
621// sequence of `[name, value]` pairs. The only `name` Enhanced RTMP defines is
622// `"colorInfo"`, whose `value` is an Object with three optional sub-objects
623// describing HDR metadata for a BT.2020 (Rec. 2020) source:
624//
625//   type ColorInfo = {
626//     colorConfig: {
627//       bitDepth:                number,  // 8 | 10 | 12
628//       colorPrimaries:          number,  // H.273 enumeration [0-255]
629//       transferCharacteristics: number,  // H.273 enumeration [0-255]
630//       matrixCoefficients:      number,  // H.273 enumeration [0-255]
631//     },
632//     hdrCll: { maxFall: number, maxCLL: number },          // cd/m2
633//     hdrMdcv: {                                             // ST 2086:2018
634//       redX, redY, greenX, greenY, blueX, blueY,
635//       whitePointX, whitePointY,
636//       maxLuminance, minLuminance,                          // cd/m2 (nits)
637//     },
638//   }
639//
640// Every property is OPTIONAL on the wire (the spec marks the sub-objects
641// SHOULD/RECOMMENDED, never MUST), so a partial colorInfo — e.g. only
642// colorConfig — must round-trip. We therefore model each field as
643// `Option<f64>` (AMF's native numeric type is the IEEE-754 double, so storing
644// f64 keeps the round-trip byte-exact instead of coercing to an integer that
645// would lose a fractional luminance value). A missing sub-object is `None`;
646// an empty `{}` object is `Some(default)` (all fields `None`), preserving the
647// spec's "reset to original color state via an empty object" signal distinct
648// from "sub-object absent".
649
650/// `colorConfig` sub-object of [`ColorInfo`]. Bit depth plus the three
651/// ITU-T H.273 / ISO-IEC 23091-4 enumeration indices (colour primaries,
652/// transfer characteristics, matrix coefficients). Stored as `Option<f64>`
653/// to round-trip a partial object byte-for-byte; use [`ColorConfig::is_empty`]
654/// to detect the all-absent case.
655#[derive(Debug, Clone, Copy, Default, PartialEq)]
656pub struct ColorConfig {
657    /// Bits per colour channel. SHOULD be 8, 10 or 12.
658    pub bit_depth: Option<f64>,
659    /// Colour primaries — H.273 "Colour primaries" table index [0-255].
660    pub color_primaries: Option<f64>,
661    /// Transfer characteristics — H.273 table index [0-255] (e.g. PQ, HLG).
662    pub transfer_characteristics: Option<f64>,
663    /// Matrix coefficients — H.273 table index [0-255].
664    pub matrix_coefficients: Option<f64>,
665}
666
667impl ColorConfig {
668    /// True when no field is set (an absent or `{}` `colorConfig`).
669    pub fn is_empty(&self) -> bool {
670        self.bit_depth.is_none()
671            && self.color_primaries.is_none()
672            && self.transfer_characteristics.is_none()
673            && self.matrix_coefficients.is_none()
674    }
675}
676
677/// `hdrCll` sub-object of [`ColorInfo`] — content light level. Both values
678/// are in cd/m2 (nits), spec range `[0.0001, 10000]`.
679#[derive(Debug, Clone, Copy, Default, PartialEq)]
680pub struct HdrCll {
681    /// Maximum frame-average light level over the playback sequence.
682    pub max_fall: Option<f64>,
683    /// Maximum light level of any single pixel over the playback sequence.
684    pub max_cll: Option<f64>,
685}
686
687impl HdrCll {
688    /// True when neither value is set.
689    pub fn is_empty(&self) -> bool {
690        self.max_fall.is_none() && self.max_cll.is_none()
691    }
692}
693
694/// `hdrMdcv` sub-object of [`ColorInfo`] — mastering display colour volume
695/// per SMPTE ST 2086:2018. Chromaticity coordinates are CIE-1931 xy; the
696/// luminance pair is in cd/m2 (nits).
697#[derive(Debug, Clone, Copy, Default, PartialEq)]
698pub struct HdrMdcv {
699    pub red_x: Option<f64>,
700    pub red_y: Option<f64>,
701    pub green_x: Option<f64>,
702    pub green_y: Option<f64>,
703    pub blue_x: Option<f64>,
704    pub blue_y: Option<f64>,
705    pub white_point_x: Option<f64>,
706    pub white_point_y: Option<f64>,
707    /// Max display luminance of the mastering display, range `[5, 10000]`.
708    pub max_luminance: Option<f64>,
709    /// Min display luminance of the mastering display, range `[0.0001, 5]`.
710    pub min_luminance: Option<f64>,
711}
712
713impl HdrMdcv {
714    /// True when no coordinate or luminance field is set.
715    pub fn is_empty(&self) -> bool {
716        self.red_x.is_none()
717            && self.red_y.is_none()
718            && self.green_x.is_none()
719            && self.green_y.is_none()
720            && self.blue_x.is_none()
721            && self.blue_y.is_none()
722            && self.white_point_x.is_none()
723            && self.white_point_y.is_none()
724            && self.max_luminance.is_none()
725            && self.min_luminance.is_none()
726    }
727}
728
729/// Strongly-typed view of the `"colorInfo"` HDR metadata object carried in a
730/// `VideoPacketType.Metadata` video message (Enhanced RTMP §"Metadata Frame").
731///
732/// Each of the three sub-objects (`colorConfig`, `hdrCll`, `hdrMdcv`) is
733/// `Option`: `None` means the property was absent from the wire object,
734/// `Some(..)` (possibly all-`None` inside) means it was present. This
735/// distinction matters because the spec's "reset to the original color state"
736/// signal is an empty object `{}` — distinct from omitting `colorInfo`
737/// altogether (which sends `Undefined`, surfaced as [`ColorInfo::is_reset`]).
738///
739/// Lift it from a parsed metadata [`VideoTag`] with
740/// [`VideoTag::color_info`], and rebuild an outgoing tag with
741/// [`VideoTag::color_info_tag`].
742#[derive(Debug, Clone, Copy, Default, PartialEq)]
743pub struct ColorInfo {
744    pub color_config: Option<ColorConfig>,
745    pub hdr_cll: Option<HdrCll>,
746    pub hdr_mdcv: Option<HdrMdcv>,
747}
748
749impl ColorInfo {
750    /// Decode a `colorInfo` value (the AMF `value` half of the
751    /// `["colorInfo", value]` pair) from an already-decoded [`Amf0Value`].
752    ///
753    /// * An [`Amf0Value::Object`] / [`Amf0Value::EcmaArray`] is walked for
754    ///   the three sub-objects.
755    /// * [`Amf0Value::Undefined`] (the spec's RECOMMENDED reset signal) or
756    ///   an empty object decode to the all-`None` reset state.
757    /// * Any other AMF type is rejected with [`Error::Other`].
758    pub fn from_amf0(value: &Amf0Value) -> Result<ColorInfo> {
759        match value {
760            Amf0Value::Undefined | Amf0Value::Null => Ok(ColorInfo::default()),
761            Amf0Value::Object(_) | Amf0Value::EcmaArray(_) => Ok(ColorInfo {
762                color_config: value.get("colorConfig").map(parse_color_config),
763                hdr_cll: value.get("hdrCll").map(parse_hdr_cll),
764                hdr_mdcv: value.get("hdrMdcv").map(parse_hdr_mdcv),
765            }),
766            _ => Err(Error::Other(
767                "colorInfo: value must be an Object/ECMA array or Undefined".into(),
768            )),
769        }
770    }
771
772    /// True when this is the reset signal — no sub-object present. Encodes as
773    /// `Undefined` per the spec's RECOMMENDED reset approach (see
774    /// [`ColorInfo::to_amf0`]).
775    pub fn is_reset(&self) -> bool {
776        self.color_config.is_none() && self.hdr_cll.is_none() && self.hdr_mdcv.is_none()
777    }
778
779    /// Encode to the AMF `value` half of the `["colorInfo", value]` pair.
780    ///
781    /// The reset state ([`ColorInfo::is_reset`]) encodes as
782    /// [`Amf0Value::Undefined`] — the spec's RECOMMENDED reset form. A
783    /// present-but-empty sub-object encodes as an empty `{}` object so the
784    /// presence bit round-trips.
785    pub fn to_amf0(&self) -> Amf0Value {
786        if self.is_reset() {
787            return Amf0Value::Undefined;
788        }
789        let mut obj: Vec<(String, Amf0Value)> = Vec::new();
790        if let Some(cc) = &self.color_config {
791            obj.push(("colorConfig".into(), color_config_to_amf0(cc)));
792        }
793        if let Some(cll) = &self.hdr_cll {
794            obj.push(("hdrCll".into(), hdr_cll_to_amf0(cll)));
795        }
796        if let Some(mdcv) = &self.hdr_mdcv {
797            obj.push(("hdrMdcv".into(), hdr_mdcv_to_amf0(mdcv)));
798        }
799        Amf0Value::Object(obj)
800    }
801}
802
803fn opt_num(v: &Amf0Value, key: &str) -> Option<f64> {
804    v.get(key).and_then(Amf0Value::as_f64)
805}
806
807fn parse_color_config(v: &Amf0Value) -> ColorConfig {
808    ColorConfig {
809        bit_depth: opt_num(v, "bitDepth"),
810        color_primaries: opt_num(v, "colorPrimaries"),
811        transfer_characteristics: opt_num(v, "transferCharacteristics"),
812        matrix_coefficients: opt_num(v, "matrixCoefficients"),
813    }
814}
815
816fn parse_hdr_cll(v: &Amf0Value) -> HdrCll {
817    HdrCll {
818        max_fall: opt_num(v, "maxFall"),
819        max_cll: opt_num(v, "maxCLL"),
820    }
821}
822
823fn parse_hdr_mdcv(v: &Amf0Value) -> HdrMdcv {
824    HdrMdcv {
825        red_x: opt_num(v, "redX"),
826        red_y: opt_num(v, "redY"),
827        green_x: opt_num(v, "greenX"),
828        green_y: opt_num(v, "greenY"),
829        blue_x: opt_num(v, "blueX"),
830        blue_y: opt_num(v, "blueY"),
831        white_point_x: opt_num(v, "whitePointX"),
832        white_point_y: opt_num(v, "whitePointY"),
833        max_luminance: opt_num(v, "maxLuminance"),
834        min_luminance: opt_num(v, "minLuminance"),
835    }
836}
837
838fn push_num(obj: &mut Vec<(String, Amf0Value)>, key: &str, val: Option<f64>) {
839    if let Some(n) = val {
840        obj.push((key.to_string(), Amf0Value::Number(n)));
841    }
842}
843
844fn color_config_to_amf0(cc: &ColorConfig) -> Amf0Value {
845    let mut obj = Vec::new();
846    push_num(&mut obj, "bitDepth", cc.bit_depth);
847    push_num(&mut obj, "colorPrimaries", cc.color_primaries);
848    push_num(
849        &mut obj,
850        "transferCharacteristics",
851        cc.transfer_characteristics,
852    );
853    push_num(&mut obj, "matrixCoefficients", cc.matrix_coefficients);
854    Amf0Value::Object(obj)
855}
856
857fn hdr_cll_to_amf0(cll: &HdrCll) -> Amf0Value {
858    let mut obj = Vec::new();
859    push_num(&mut obj, "maxFall", cll.max_fall);
860    push_num(&mut obj, "maxCLL", cll.max_cll);
861    Amf0Value::Object(obj)
862}
863
864fn hdr_mdcv_to_amf0(mdcv: &HdrMdcv) -> Amf0Value {
865    let mut obj = Vec::new();
866    push_num(&mut obj, "redX", mdcv.red_x);
867    push_num(&mut obj, "redY", mdcv.red_y);
868    push_num(&mut obj, "greenX", mdcv.green_x);
869    push_num(&mut obj, "greenY", mdcv.green_y);
870    push_num(&mut obj, "blueX", mdcv.blue_x);
871    push_num(&mut obj, "blueY", mdcv.blue_y);
872    push_num(&mut obj, "whitePointX", mdcv.white_point_x);
873    push_num(&mut obj, "whitePointY", mdcv.white_point_y);
874    push_num(&mut obj, "maxLuminance", mdcv.max_luminance);
875    push_num(&mut obj, "minLuminance", mdcv.min_luminance);
876    Amf0Value::Object(obj)
877}
878
879// ---------------------------------------------------------------------------
880// Multitrack — Enhanced RTMP v2 §"ExVideoTagBody" / §"ExAudioTagBody"
881// ---------------------------------------------------------------------------
882//
883// When VideoPacketType == Multitrack (= 6) or AudioPacketType == Multitrack
884// (= 5), the body holds one or more tracks rather than a single track's
885// payload. The per-packet body has the layout (audio mirrors video):
886//
887//   multitrackType   = UB[4] as AvMultitrackType    // high nibble of next byte
888//   realPacketType   = UB[4] as VideoPacketType     // low nibble (the *real*
889//                                                   // PacketType the tracks
890//                                                   // carry; MUST NOT be
891//                                                   // Multitrack)
892//   if (multitrackType != ManyTracksManyCodecs) {
893//     sharedFourCc = FOURCC                         // codec shared by all tracks
894//   }
895//   while (more) {
896//     if (multitrackType == ManyTracksManyCodecs) {
897//       trackFourCc = FOURCC                        // per-track codec
898//     }
899//     trackId      = UI8
900//     if (multitrackType != OneTrack) {
901//       sizeOfTrack = UI24                          // bytes of the body that follows
902//     }
903//     body         = UI8[sizeOfTrack | rest-of-message]
904//   }
905//
906// OneTrack mode carries exactly one track and no size field; the body runs
907// to the end of the message. ManyTracks shares a single FourCC across all
908// tracks. ManyTracksManyCodecs carries a per-track FourCC.
909
910/// AvMultitrackType discriminator (UI8 in the spec's `enum AvMultitrackType`,
911/// stored on the wire as the high nibble of the byte immediately after the
912/// Multitrack PacketType nibble). See enhanced-rtmp-v2.pdf §"ExVideoTagBody" /
913/// §"ExAudioTagBody".
914pub const AV_MULTITRACK_TYPE_ONE_TRACK: u8 = 0;
915/// All tracks share the same codec (`sharedFourCc` read once before the
916/// track loop, `sizeOfTrack` UI24 present on every track).
917pub const AV_MULTITRACK_TYPE_MANY_TRACKS: u8 = 1;
918/// Each track carries its own codec (`trackFourCc` read inside the loop for
919/// every track, no shared FourCC in the header).
920pub const AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS: u8 = 2;
921
922/// Decoded `Multitrack` body of an Enhanced RTMP v2 video or audio message
923/// (enhanced-rtmp-v2.pdf §"ExVideoTagBody" / §"ExAudioTagBody"). The decoded
924/// view sits in [`VideoTag::multitrack`] / [`AudioTag::multitrack`]; when
925/// present, the tag's [`VideoTag::ex_packet_type`] /
926/// [`AudioTag::ex_packet_type`] holds the *real* (inner) PacketType the
927/// tracks carry (e.g. `CodedFrames`, `SequenceStart`), and the tag's
928/// [`VideoTag::fourcc`] / [`AudioTag::audio_fourcc`] holds the shared FourCC
929/// when [`multitrack_type`][Multitrack::multitrack_type] is `OneTrack` or
930/// `ManyTracks`. For `ManyTracksManyCodecs` the outer FourCC is `None`
931/// (each track carries its own).
932///
933/// The [`VideoTag::body`] / [`AudioTag::body`] field is unused for
934/// multitrack tags — track payloads live inside
935/// [`MultitrackTrack::body`].
936#[derive(Debug, Clone, PartialEq, Eq)]
937pub struct Multitrack {
938    /// `AvMultitrackType` discriminator (one of `AV_MULTITRACK_TYPE_*`).
939    /// Reserved values (3..=15) round-trip verbatim — the parser does not
940    /// reject them, so a forwarding ingest preserves unknown future modes.
941    pub multitrack_type: u8,
942    /// Decoded per-track entries in stream order. Always at least 1 entry
943    /// after a successful parse.
944    pub tracks: Vec<MultitrackTrack>,
945}
946
947/// One track inside a [`Multitrack`] body.
948#[derive(Debug, Clone, PartialEq, Eq)]
949pub struct MultitrackTrack {
950    /// Per-track codec FourCC. `Some(..)` only when the surrounding
951    /// [`Multitrack::multitrack_type`] is `ManyTracksManyCodecs` — the
952    /// `OneTrack` / `ManyTracks` modes carry a shared FourCC on the outer
953    /// tag (see [`VideoTag::fourcc`] / [`AudioTag::audio_fourcc`]) and this
954    /// field is `None`. Set to `Some(..)` on build to opt into the
955    /// many-codecs layout for this track.
956    pub fourcc: Option<[u8; 4]>,
957    /// `trackId = UI8`. Per spec, trackId 0 is the default track described
958    /// by the top-level onMetaData; additional tracks use positive ids
959    /// (1, 2, 3, …). Values are identifiers only and do not imply ordering.
960    pub track_id: u8,
961    /// Codec payload for this track (the shape the real PacketType + FourCC
962    /// would produce as a single-track Enhanced-RTMP body). Empty for
963    /// SequenceEnd tracks per spec.
964    pub body: Vec<u8>,
965}
966
967impl Multitrack {
968    /// Parse the multitrack track-list bytes (everything in
969    /// [`VideoTag::body`] / [`AudioTag::body`] after [`parse_video`] /
970    /// [`parse_audio`] stripped the per-tag header) given the outer
971    /// `multitrack_type`. Returns `Err(Error::Other)` on truncation or on
972    /// a track whose `sizeOfTrack` UI24 overruns the buffer.
973    ///
974    /// `OneTrack` mode produces exactly one track whose body runs to the
975    /// end of the buffer. `ManyTracks` and `ManyTracksManyCodecs` modes
976    /// loop while bytes remain, consuming a UI24 `sizeOfTrack` per track.
977    pub fn parse(body: &[u8], multitrack_type: u8) -> Result<Multitrack> {
978        let many_codecs = multitrack_type == AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS;
979        let one_track = multitrack_type == AV_MULTITRACK_TYPE_ONE_TRACK;
980        let mut pos = 0usize;
981        let mut tracks = Vec::new();
982        loop {
983            if pos >= body.len() {
984                if tracks.is_empty() {
985                    return Err(Error::Other(
986                        "Multitrack: empty track list (need at least one track)".into(),
987                    ));
988                }
989                break;
990            }
991            let track_fourcc = if many_codecs {
992                if pos + 4 > body.len() {
993                    return Err(Error::Other(
994                        "Multitrack: truncated reading per-track FourCC".into(),
995                    ));
996                }
997                let mut fcc = [0u8; 4];
998                fcc.copy_from_slice(&body[pos..pos + 4]);
999                pos += 4;
1000                Some(fcc)
1001            } else {
1002                None
1003            };
1004            if pos >= body.len() {
1005                return Err(Error::Other("Multitrack: truncated reading trackId".into()));
1006            }
1007            let track_id = body[pos];
1008            pos += 1;
1009            let track_body = if one_track {
1010                // OneTrack: no size field, body runs to end of buffer.
1011                let rest = body[pos..].to_vec();
1012                pos = body.len();
1013                rest
1014            } else {
1015                if pos + 3 > body.len() {
1016                    return Err(Error::Other(
1017                        "Multitrack: truncated reading sizeOfTrack UI24".into(),
1018                    ));
1019                }
1020                let size = ((body[pos] as usize) << 16)
1021                    | ((body[pos + 1] as usize) << 8)
1022                    | (body[pos + 2] as usize);
1023                pos += 3;
1024                if pos + size > body.len() {
1025                    return Err(Error::Other(format!(
1026                        "Multitrack: sizeOfTrack={size} overruns remaining {} bytes",
1027                        body.len() - pos
1028                    )));
1029                }
1030                let slice = body[pos..pos + size].to_vec();
1031                pos += size;
1032                slice
1033            };
1034            tracks.push(MultitrackTrack {
1035                fourcc: track_fourcc,
1036                track_id,
1037                body: track_body,
1038            });
1039            if one_track {
1040                break;
1041            }
1042        }
1043        Ok(Multitrack {
1044            multitrack_type,
1045            tracks,
1046        })
1047    }
1048
1049    /// Serialise to the byte layout `parse` consumes. Output goes into the
1050    /// tag's [`VideoTag::body`] / [`AudioTag::body`] slot when building an
1051    /// outgoing multitrack message.
1052    ///
1053    /// For `OneTrack` mode only the first track's `track_id` + `body` are
1054    /// emitted (the second-and-beyond tracks are silently ignored — the
1055    /// caller is responsible for using `ManyTracks` if it has more than
1056    /// one). For `ManyTracksManyCodecs` each track's `fourcc` MUST be
1057    /// `Some(..)`; a `None` is encoded as four zero bytes to keep the
1058    /// output decodable but the caller should treat that as a bug.
1059    pub fn encode(&self) -> Vec<u8> {
1060        let many_codecs = self.multitrack_type == AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS;
1061        let one_track = self.multitrack_type == AV_MULTITRACK_TYPE_ONE_TRACK;
1062        let mut out = Vec::new();
1063        for (i, track) in self.tracks.iter().enumerate() {
1064            if one_track && i > 0 {
1065                break;
1066            }
1067            if many_codecs {
1068                let fcc = track.fourcc.unwrap_or([0; 4]);
1069                out.extend_from_slice(&fcc);
1070            }
1071            out.push(track.track_id);
1072            if !one_track {
1073                let size = track.body.len() & 0x00FF_FFFF;
1074                out.extend_from_slice(&[(size >> 16) as u8, (size >> 8) as u8, size as u8]);
1075            }
1076            out.extend_from_slice(&track.body);
1077        }
1078        out
1079    }
1080}
1081
1082/// Decoded FLV video-tag header + payload. For H.264 the
1083/// `composition_time` is the signed CTS offset (ms) between the
1084/// decoder timestamp the RTMP chunk carries and the presentation
1085/// timestamp — callers add this to the chunk ts to get PTS.
1086///
1087/// **Legacy-vs-Enhanced-RTMP discriminator.** `fourcc` is the
1088/// signal: `None` = legacy single-byte `codec_id` framing
1089/// (`avcC` / H.263 / VP6 / FlashSV); `Some([..])` = Enhanced RTMP
1090/// (Veovera 2023) where `codec_id` is reserved-zero on the wire,
1091/// `ex_packet_type` is the `PacketType` low nibble, and `body`
1092/// follows the per-FourCC shape laid out in
1093/// `enhanced-rtmp-v1.pdf` §"Defining Additional Video Codecs"
1094/// (HEVCDecoderConfigurationRecord / `AV1CodecConfigurationRecord`
1095/// / `VPCodecConfigurationRecord` for `SequenceStart`, NALUs / OBUs /
1096/// frames for `CodedFrames(X)`, AMF metadata for `Metadata`).
1097///
1098/// `composition_time` carries the SI24 CTS in both modes — it is
1099/// only emitted on the wire for legacy AVC (`codec_id == 7`),
1100/// and for the three NALU-based Enhanced-RTMP FourCCs paired
1101/// with PacketType = `CodedFrames`: `hvc1` (HEVC, v1), `avc1`
1102/// (AVC, v2), `vvc1` (VVC, v2). For `CodedFramesX` and the
1103/// non-NALU FourCCs (`av01`, `vp09`, `vp08`) the field is zero
1104/// and not encoded on the wire.
1105#[derive(Debug, Clone, PartialEq, Eq)]
1106pub struct VideoTag {
1107    pub frame_type: u8,
1108    pub codec_id: u8,
1109    /// `AvcSequenceHeader` / `AvcNalu` / `AvcEndOfSequence`. `None`
1110    /// for non-AVC codecs where the first AVC-specific byte doesn't
1111    /// exist. Stays `None` for Enhanced RTMP tags too — use
1112    /// [`VideoTag::ex_packet_type`] instead.
1113    pub avc_packet_type: Option<u8>,
1114    pub composition_time: i32,
1115    /// Body: `AVCDecoderConfigurationRecord` for AVC sequence
1116    /// headers; a sequence of `[u32 length-BE][NALU bytes]` pairs
1117    /// for AVC / HEVC NALU packets; AV1 OBUs for `av01`; full VP9
1118    /// frames for `vp09`; AMF-encoded `[name, value]` pairs for
1119    /// Enhanced RTMP `PacketTypeMetadata`.
1120    pub body: Vec<u8>,
1121    /// Enhanced RTMP v1 `PacketType` nibble (the four bits that
1122    /// replace `CodecID` when the `IsExHeader` flag is set). One
1123    /// of `EX_PACKET_TYPE_*`. `None` for legacy tags.
1124    pub ex_packet_type: Option<u8>,
1125    /// Enhanced RTMP FourCC video codec tag — the four ASCII
1126    /// bytes following the header byte when `IsExHeader == 1`.
1127    /// `None` for legacy tags. Values defined by Veovera so far:
1128    /// `b"av01"` (AV1, v1), `b"vp09"` (VP9, v1), `b"hvc1"`
1129    /// (HEVC, v1), `b"vp08"` (VP8, v2), `b"avc1"` (AVC/H.264 in
1130    /// FourCC mode, v2), `b"vvc1"` (VVC/H.266, v2).
1131    pub fourcc: Option<[u8; 4]>,
1132    /// Enhanced RTMP v2 ModEx prelude chain
1133    /// (`enhanced-rtmp-v2.pdf` §"ExVideoTagHeader"). Empty for
1134    /// legacy tags and for Enhanced tags that carry no modifier.
1135    /// Each entry was a `PacketType.ModEx` step before the real
1136    /// [`ex_packet_type`][VideoTag::ex_packet_type] was decoded;
1137    /// the chain is re-emitted verbatim ahead of the real packet
1138    /// type on build. The only subtype defined today is
1139    /// `TimestampOffsetNano` (high-precision sub-millisecond
1140    /// presentation offset).
1141    pub mod_ex: Vec<ModEx>,
1142    /// Enhanced RTMP v2 `Multitrack` body (per-track FourCC + trackId +
1143    /// sizeOfVideoTrack chain — see [`Multitrack`]). `Some(..)` only when
1144    /// the wire PacketType nibble was `Multitrack = 6`; in that case
1145    /// [`ex_packet_type`][VideoTag::ex_packet_type] holds the *real* inner
1146    /// PacketType (e.g. `CodedFrames`, `SequenceStart`),
1147    /// [`fourcc`][VideoTag::fourcc] holds the shared codec FourCC when the
1148    /// multitrack mode is `OneTrack` / `ManyTracks` (and `None` for
1149    /// `ManyTracksManyCodecs`), and the tag's [`body`][VideoTag::body] is
1150    /// empty (track payloads sit in each [`MultitrackTrack::body`]).
1151    pub multitrack: Option<Multitrack>,
1152}
1153
1154impl VideoTag {
1155    pub fn is_keyframe(&self) -> bool {
1156        self.frame_type == VIDEO_FRAME_KEYFRAME || self.frame_type == VIDEO_FRAME_GENERATED_KEY
1157    }
1158    pub fn is_avc_sequence_header(&self) -> bool {
1159        self.codec_id == VIDEO_CODEC_AVC
1160            && self.avc_packet_type == Some(AVC_PACKET_TYPE_SEQUENCE_HEADER)
1161    }
1162    /// True when this tag is the FourCC-mode `PacketTypeSequenceStart`
1163    /// for an Enhanced-RTMP codec (`body` is the codec's
1164    /// configuration record — `HEVCDecoderConfigurationRecord` for
1165    /// `hvc1`, `AV1CodecConfigurationRecord` for `av01`,
1166    /// `VPCodecConfigurationRecord` for `vp09` / `vp08`,
1167    /// `AVCDecoderConfigurationRecord` for `avc1`,
1168    /// `VVCDecoderConfigurationRecord` for `vvc1`).
1169    pub fn is_ex_sequence_header(&self) -> bool {
1170        self.fourcc.is_some() && self.ex_packet_type == Some(EX_PACKET_TYPE_SEQUENCE_START)
1171    }
1172    /// True when this tag carries an Enhanced-RTMP
1173    /// `PacketTypeMetadata` body (HDR `colorInfo` and the like).
1174    /// Per Enhanced RTMP v1 the `FrameType` flags above the
1175    /// PacketType nibble are required to be ignored when this is
1176    /// set, so callers that classify keyframe vs interframe must
1177    /// short-circuit on this predicate first.
1178    pub fn is_ex_metadata(&self) -> bool {
1179        self.fourcc.is_some() && self.ex_packet_type == Some(EX_PACKET_TYPE_METADATA)
1180    }
1181
1182    /// Lift the `"colorInfo"` HDR metadata out of a
1183    /// `VideoPacketType.Metadata` tag into the strongly-typed [`ColorInfo`]
1184    /// view (Enhanced RTMP §"Metadata Frame").
1185    ///
1186    /// The metadata [`body`][VideoTag::body] is an AMF-encoded sequence of
1187    /// `[name, value]` pairs; this scans for the `"colorInfo"` name and
1188    /// decodes the following value. Returns:
1189    ///
1190    /// * `Ok(None)` when this is not a metadata tag, or no `"colorInfo"`
1191    ///   pair is present (the spec leaves room for other future names).
1192    /// * `Ok(Some(ColorInfo))` for a decoded `colorInfo` value, including
1193    ///   the reset signal (`Undefined`/`{}` → [`ColorInfo::is_reset`]).
1194    /// * `Err(..)` when the AMF body is malformed or the `colorInfo` value
1195    ///   is the wrong AMF type.
1196    pub fn color_info(&self) -> Result<Option<ColorInfo>> {
1197        if !self.is_ex_metadata() {
1198            return Ok(None);
1199        }
1200        let values = amf::decode_all(&self.body)?;
1201        // The body is a flat `name, value, name, value, …` stream. Find the
1202        // "colorInfo" name string and decode the value that follows it.
1203        let mut i = 0;
1204        while i + 1 < values.len() {
1205            if values[i].as_str() == Some("colorInfo") {
1206                return ColorInfo::from_amf0(&values[i + 1]).map(Some);
1207            }
1208            i += 2;
1209        }
1210        Ok(None)
1211    }
1212
1213    /// Build a `VideoPacketType.Metadata` tag carrying a single
1214    /// `["colorInfo", value]` pair for the given codec FourCC (Enhanced RTMP
1215    /// §"Metadata Frame"). The `FrameType` flags are ignored by spec for a
1216    /// metadata packet, so this stamps the conventional `Info` (5) value.
1217    ///
1218    /// The inverse of [`VideoTag::color_info`]: the produced tag's
1219    /// [`body`][VideoTag::body] is `encode("colorInfo") ++ encode(value)`,
1220    /// where a [reset][ColorInfo::is_reset] `ColorInfo` encodes the value as
1221    /// `Undefined`.
1222    pub fn color_info_tag(fourcc: [u8; 4], color_info: &ColorInfo) -> VideoTag {
1223        let mut body = Vec::new();
1224        amf::encode(&mut body, &Amf0Value::String("colorInfo".into()));
1225        amf::encode(&mut body, &color_info.to_amf0());
1226        VideoTag {
1227            frame_type: VIDEO_FRAME_INFO,
1228            codec_id: 0,
1229            avc_packet_type: None,
1230            composition_time: 0,
1231            body,
1232            ex_packet_type: Some(EX_PACKET_TYPE_METADATA),
1233            fourcc: Some(fourcc),
1234            mod_ex: Vec::new(),
1235            multitrack: None,
1236        }
1237    }
1238
1239    /// Sum of the `TimestampOffsetNano` ModEx entries on this tag, in
1240    /// nanoseconds. Per `enhanced-rtmp-v2.pdf` the offset is added to
1241    /// the current media message's presentation time without altering
1242    /// the core RTMP millisecond timestamp. Returns `0` when no such
1243    /// entry is present.
1244    pub fn timestamp_offset_nano(&self) -> u32 {
1245        self.mod_ex
1246            .iter()
1247            .filter_map(ModEx::timestamp_offset_nano)
1248            .fold(0u32, |acc, n| acc.saturating_add(n))
1249    }
1250
1251    /// True when this tag is an Enhanced-RTMP v2 video `Multitrack`
1252    /// message (the wire PacketType nibble was `Multitrack = 6` and
1253    /// [`Self::multitrack`] decoded the per-track body).
1254    pub fn is_multitrack(&self) -> bool {
1255        self.multitrack.is_some()
1256    }
1257
1258    /// Build an Enhanced-RTMP v2 video `Multitrack` tag with the given
1259    /// FrameType, real inner PacketType, shared FourCC (when the multitrack
1260    /// mode is `OneTrack` / `ManyTracks`; pass `None` for
1261    /// `ManyTracksManyCodecs`), and per-track body. The returned tag has
1262    /// `ex_packet_type = real_packet_type`, `fourcc = shared_fourcc`,
1263    /// `multitrack = Some(mt)`, and `body` empty. ModEx prelude is empty.
1264    pub fn multitrack_tag(
1265        frame_type: u8,
1266        real_packet_type: u8,
1267        shared_fourcc: Option<[u8; 4]>,
1268        mt: Multitrack,
1269    ) -> VideoTag {
1270        VideoTag {
1271            frame_type,
1272            codec_id: 0,
1273            avc_packet_type: None,
1274            composition_time: 0,
1275            body: Vec::new(),
1276            ex_packet_type: Some(real_packet_type),
1277            fourcc: shared_fourcc,
1278            mod_ex: Vec::new(),
1279            multitrack: Some(mt),
1280        }
1281    }
1282}
1283
1284// 24-bit signed → i32 sign-extend. The wire format ("FLV
1285// Composition Time", FLV §E.4.3.1, also Enhanced RTMP HEVC
1286// CodedFrames row) packs SI24 in three big-endian bytes.
1287fn sign_extend_si24(raw: i32) -> i32 {
1288    if raw & 0x0080_0000 != 0 {
1289        raw | -0x0100_0000i32
1290    } else {
1291        raw
1292    }
1293}
1294
1295/// Decode the FLV video-tag header from an RTMP video message payload.
1296///
1297/// Recognises both pre-2023 legacy framing (1-byte
1298/// `frame_type|codec_id` header, optional AVC packet-type +
1299/// SI24 CTS) and Enhanced RTMP v1 framing (`IsExHeader` flag in
1300/// bit 7 → 1-byte `is_ex|frame_type|packet_type` header, 4-byte
1301/// FourCC, optional SI24 CTS for HEVC `CodedFrames`).
1302///
1303/// Returns `Err(Error::Other)` on truncation. Per Enhanced RTMP
1304/// v1 the spec says: "During parsing, logic must gracefully
1305/// fail if at any point important signaling/flags (ex.
1306/// FrameType, IsExHeader, ExHeaderInfo) are not understood." —
1307/// we surface an unknown `ex_packet_type` by returning the raw
1308/// nibble in the struct (callers decide whether to ignore the
1309/// tag or fail).
1310pub fn parse_video(payload: &[u8]) -> Result<VideoTag> {
1311    if payload.is_empty() {
1312        return Err(Error::Other("FLV video tag: empty".into()));
1313    }
1314    let b0 = payload[0];
1315    if (b0 & VIDEO_IS_EX_HEADER) != 0 {
1316        // --- Enhanced RTMP v1/v2 framing ---
1317        //
1318        //   byte 0      = IsExHeader(1) | FrameType(3) | PacketType(4)
1319        //   [ModEx prelude chain — present only when PacketType == ModEx]
1320        //   byte ..=+3  = FourCC (4 ASCII bytes)
1321        //   byte ..     = body, with shape depending on FourCC × PacketType
1322        //
1323        // Per spec, when PacketType == Metadata the FrameType
1324        // flags above the nibble are required to be ignored;
1325        // we still preserve the raw bits in `frame_type` so
1326        // callers that diff fixtures can see them.
1327        let frame_type = (b0 >> 4) & 0b0111;
1328        let mut packet_type = b0 & 0x0F;
1329        let mut pos = 1;
1330
1331        // ModEx prelude (enhanced-rtmp-v2.pdf §"ExVideoTagHeader"):
1332        // while the freshly-read PacketType nibble is ModEx, consume
1333        // a size-prefixed modExData entry + the trailing
1334        // modExType/packetType nibble byte, looping until a non-ModEx
1335        // PacketType terminates the chain. The chain sits between the
1336        // header byte and the FourCC.
1337        let mut mod_ex = Vec::new();
1338        if packet_type == EX_PACKET_TYPE_MOD_EX {
1339            let (chain, real_pt, next) =
1340                parse_mod_ex_chain(payload, pos, EX_PACKET_TYPE_MOD_EX, "video")?;
1341            mod_ex = chain;
1342            packet_type = real_pt;
1343            pos = next;
1344        }
1345
1346        // Multitrack prelude (enhanced-rtmp-v2.pdf §"ExVideoTagHeader"):
1347        // a Multitrack PacketType pulls in a `multitrackType (UB[4]) |
1348        // realPacketType (UB[4])` byte and, when the multitrack mode is
1349        // not ManyTracksManyCodecs, a shared FourCC. The body (the
1350        // per-track list) is decoded later via `Multitrack::parse`.
1351        let mut multitrack_type: Option<u8> = None;
1352        if packet_type == EX_PACKET_TYPE_MULTITRACK {
1353            if pos >= payload.len() {
1354                return Err(Error::Other(
1355                    "Enhanced RTMP video Multitrack: truncated reading multitrackType nibble"
1356                        .into(),
1357                ));
1358            }
1359            let nibble = payload[pos];
1360            pos += 1;
1361            let mt_type = (nibble >> 4) & 0x0F;
1362            let inner_pt = nibble & 0x0F;
1363            // Spec: "This fetch MUST not result in a VideoPacketType.Multitrack"
1364            if inner_pt == EX_PACKET_TYPE_MULTITRACK {
1365                return Err(Error::Other(
1366                    "Enhanced RTMP video Multitrack: inner PacketType MUST NOT be Multitrack"
1367                        .into(),
1368                ));
1369            }
1370            multitrack_type = Some(mt_type);
1371            packet_type = inner_pt;
1372        }
1373
1374        // For Multitrack ManyTracksManyCodecs there is no shared FourCC
1375        // before the per-track loop; for OneTrack / ManyTracks the shared
1376        // FourCC sits here (per spec). For non-Multitrack tags the FourCC
1377        // always sits here.
1378        let need_shared_fourcc = match multitrack_type {
1379            Some(t) => t != AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS,
1380            None => true,
1381        };
1382        let fcc_opt = if need_shared_fourcc {
1383            if pos + 4 > payload.len() {
1384                return Err(Error::Other(
1385                    "Enhanced RTMP video tag: need 4 bytes for FourCC after header/ModEx".into(),
1386                ));
1387            }
1388            let mut fcc = [0u8; 4];
1389            fcc.copy_from_slice(&payload[pos..pos + 4]);
1390            pos += 4;
1391            Some(fcc)
1392        } else {
1393            None
1394        };
1395        // Keep a non-Option fcc for the non-Multitrack branches below
1396        // (preserves the pre-change shape of the rest of the function).
1397        let fcc = fcc_opt.unwrap_or([0; 4]);
1398
1399        // Multitrack tags: SI24 CompositionTime lives inside each
1400        // per-track body (a track is itself an Enhanced-RTMP video
1401        // body), so the outer parser only consumes the track list.
1402        if let Some(mt_type) = multitrack_type {
1403            let mt = Multitrack::parse(&payload[pos..], mt_type)?;
1404            return Ok(VideoTag {
1405                frame_type,
1406                codec_id: 0,
1407                avc_packet_type: None,
1408                composition_time: 0,
1409                body: Vec::new(),
1410                ex_packet_type: Some(packet_type),
1411                fourcc: fcc_opt,
1412                mod_ex,
1413                multitrack: Some(mt),
1414            });
1415        }
1416
1417        // SI24 CompositionTime is on the wire only for the
1418        // three NALU-based FourCCs paired with
1419        // PacketTypeCodedFrames (Enhanced RTMP v1 added HEVC;
1420        // Enhanced RTMP v2 §"ExVideoTagBody" adds AVC and VVC
1421        // with the same `compositionTimeOffset = SI24` row in
1422        // the pseudocode). For CodedFramesX the spec says:
1423        // "compositionTimeOffset is implied to equal zero. This
1424        // is an optimization to save putting SI24 value on the
1425        // wire." All other FourCCs (av01, vp09, vp08) and all
1426        // other PacketTypes have no CTS field — the body
1427        // follows the FourCC directly.
1428        let needs_cts = packet_type == EX_PACKET_TYPE_CODED_FRAMES
1429            && (fcc == FOURCC_HEVC || fcc == FOURCC_AVC || fcc == FOURCC_VVC);
1430        let (cts, body_start) = if needs_cts {
1431            if pos + 3 > payload.len() {
1432                return Err(Error::Other(
1433                    "Enhanced RTMP / HEVC CodedFrames: need 3 bytes for SI24 CTS".into(),
1434                ));
1435            }
1436            let raw = ((payload[pos] as i32) << 16)
1437                | ((payload[pos + 1] as i32) << 8)
1438                | (payload[pos + 2] as i32);
1439            (sign_extend_si24(raw), pos + 3)
1440        } else {
1441            (0, pos)
1442        };
1443
1444        Ok(VideoTag {
1445            frame_type,
1446            codec_id: 0, // reserved in extended mode; legacy nibble unused.
1447            avc_packet_type: None,
1448            composition_time: cts,
1449            body: payload[body_start..].to_vec(),
1450            ex_packet_type: Some(packet_type),
1451            fourcc: Some(fcc),
1452            mod_ex,
1453            multitrack: None,
1454        })
1455    } else {
1456        // --- Legacy pre-2023 framing ---
1457        let frame_type = b0 >> 4;
1458        let codec_id = b0 & 0x0F;
1459        if codec_id == VIDEO_CODEC_AVC {
1460            if payload.len() < 5 {
1461                return Err(Error::Other("FLV/AVC tag: need 5+ bytes".into()));
1462            }
1463            let apt = payload[1];
1464            let cts_raw =
1465                ((payload[2] as i32) << 16) | ((payload[3] as i32) << 8) | (payload[4] as i32);
1466            Ok(VideoTag {
1467                frame_type,
1468                codec_id,
1469                avc_packet_type: Some(apt),
1470                composition_time: sign_extend_si24(cts_raw),
1471                body: payload[5..].to_vec(),
1472                ex_packet_type: None,
1473                fourcc: None,
1474                mod_ex: Vec::new(),
1475                multitrack: None,
1476            })
1477        } else {
1478            Ok(VideoTag {
1479                frame_type,
1480                codec_id,
1481                avc_packet_type: None,
1482                composition_time: 0,
1483                body: payload[1..].to_vec(),
1484                ex_packet_type: None,
1485                fourcc: None,
1486                mod_ex: Vec::new(),
1487                multitrack: None,
1488            })
1489        }
1490    }
1491}
1492
1493/// Build an RTMP video-tag payload.
1494///
1495/// Legacy mode (`tag.fourcc.is_none()` and `tag.multitrack.is_none()`):
1496/// writes the 1-byte frame/codec header + optional AVC packet type +
1497/// 3-byte composition time, then `body`.
1498///
1499/// Enhanced RTMP mode (`tag.fourcc = Some([..])` *or*
1500/// `tag.multitrack = Some(..)` for ManyTracksManyCodecs): writes the
1501/// `IsExHeader | frame_type | packet_type` byte, optionally a
1502/// `multitrackType | realPacketType` byte for Multitrack tags, the
1503/// 4-byte FourCC (omitted for Multitrack ManyTracksManyCodecs), the
1504/// SI24 CTS *only* when FourCC ∈ {HEVC, AVC, VVC} and
1505/// PacketType == CodedFrames on a non-Multitrack tag, then `body`
1506/// (or the encoded track list for Multitrack tags).
1507pub fn build_video(tag: &VideoTag) -> Vec<u8> {
1508    if tag.fourcc.is_some() || tag.multitrack.is_some() {
1509        let real_packet_type = tag.ex_packet_type.unwrap_or(EX_PACKET_TYPE_CODED_FRAMES);
1510        let multitrack_outer_pt = if tag.multitrack.is_some() {
1511            Some(EX_PACKET_TYPE_MULTITRACK)
1512        } else {
1513            None
1514        };
1515        // The packet type that sits in the byte *after* the ModEx chain
1516        // (or the header byte itself when no ModEx is present): Multitrack
1517        // for a multitrack tag, the real packet type otherwise.
1518        let post_mod_ex_pt = multitrack_outer_pt.unwrap_or(real_packet_type);
1519        // When a ModEx prelude is present the header byte's PacketType
1520        // nibble is `ModEx`; the next packet type is carried by the
1521        // terminating nibble of the chain
1522        // (enhanced-rtmp-v2.pdf §"ExVideoTagHeader"). Otherwise the
1523        // header nibble is `post_mod_ex_pt` directly.
1524        let header_pt = if tag.mod_ex.is_empty() {
1525            post_mod_ex_pt
1526        } else {
1527            EX_PACKET_TYPE_MOD_EX
1528        };
1529        // Per Enhanced RTMP §"Defining Additional Video Codecs"
1530        // FrameType is UB[3] (i.e. lives in bits 4..=6 — bit 7
1531        // is IsExHeader). Mask to 3 bits before packing.
1532        let head = VIDEO_IS_EX_HEADER | ((tag.frame_type & 0x07) << 4) | (header_pt & 0x0F);
1533        let mut out = Vec::with_capacity(tag.body.len() + 8);
1534        out.push(head);
1535        build_mod_ex_chain(&mut out, &tag.mod_ex, EX_PACKET_TYPE_MOD_EX, post_mod_ex_pt);
1536        if let Some(mt) = &tag.multitrack {
1537            // Multitrack nibble byte: `multitrackType (UB[4]) |
1538            // realPacketType (UB[4])`.
1539            out.push(((mt.multitrack_type & 0x0F) << 4) | (real_packet_type & 0x0F));
1540            // Shared FourCC sits here unless the mode is ManyTracksManyCodecs.
1541            if mt.multitrack_type != AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS {
1542                let fcc = tag.fourcc.unwrap_or([0; 4]);
1543                out.extend_from_slice(&fcc);
1544            }
1545            out.extend_from_slice(&mt.encode());
1546            return out;
1547        }
1548        // Non-multitrack: FourCC always sits here. `tag.fourcc` is Some
1549        // by the outer `if` (the multitrack branch above already returned).
1550        let fcc = tag
1551            .fourcc
1552            .expect("Enhanced-RTMP non-Multitrack tag requires fourcc");
1553        out.extend_from_slice(&fcc);
1554        // Mirrors the parse-side `needs_cts` rule: HEVC / AVC /
1555        // VVC + CodedFrames emit the SI24 composition-time;
1556        // everything else (CodedFramesX, SequenceStart,
1557        // SequenceEnd, Metadata, and the non-NALU FourCCs)
1558        // omits it per Enhanced RTMP v1/v2 §"ExVideoTagBody".
1559        let cts_on_wire = real_packet_type == EX_PACKET_TYPE_CODED_FRAMES
1560            && (fcc == FOURCC_HEVC || fcc == FOURCC_AVC || fcc == FOURCC_VVC);
1561        if cts_on_wire {
1562            let cts = tag.composition_time & 0x00FF_FFFF;
1563            out.extend_from_slice(&[(cts >> 16) as u8, (cts >> 8) as u8, cts as u8]);
1564        }
1565        out.extend_from_slice(&tag.body);
1566        out
1567    } else {
1568        let head = (tag.frame_type << 4) | (tag.codec_id & 0x0F);
1569        let mut out = Vec::with_capacity(tag.body.len() + 5);
1570        out.push(head);
1571        if tag.codec_id == VIDEO_CODEC_AVC {
1572            out.push(tag.avc_packet_type.unwrap_or(AVC_PACKET_TYPE_NALU));
1573            let cts = tag.composition_time & 0x00FF_FFFF;
1574            out.extend_from_slice(&[(cts >> 16) as u8, (cts >> 8) as u8, cts as u8]);
1575        }
1576        out.extend_from_slice(&tag.body);
1577        out
1578    }
1579}
1580
1581/// Decoded FLV audio-tag header + payload.
1582///
1583/// **Legacy-vs-Enhanced-RTMP discriminator.** `audio_fourcc` is
1584/// the signal: `None` = legacy pre-2023 single-byte framing
1585/// (`SoundFormat | SoundRate | SoundSize | SoundType`, optional
1586/// AAC packet-type marker); `Some([..])` = Enhanced RTMP v2
1587/// (Veovera 2026) where `sound_format` is reserved-9 (`ExHeader`)
1588/// on the wire, `ex_packet_type` is the `AudioPacketType` low
1589/// nibble, `audio_fourcc` is the four ASCII bytes that follow,
1590/// and `body` is the per-FourCC × per-PacketType payload defined
1591/// in `enhanced-rtmp-v2.pdf` §"Enhanced Audio" (the
1592/// `ExAudioTagBody` table).
1593///
1594/// The legacy bit-field fields `sound_rate`, `sound_size_16bit`
1595/// and `stereo` are not interpreted in Enhanced mode — the spec
1596/// says: "if (soundFormat == SoundFormat.ExHeader) we switch into
1597/// FOURCC audio mode as defined below. This means that soundRate,
1598/// soundSize and soundType bits are not interpreted, instead the
1599/// UB[4] bits are interpreted as an AudioPacketType". We zero
1600/// them on parse for tags that arrive in Enhanced mode so callers
1601/// don't accidentally read them as audio configuration.
1602#[derive(Debug, Clone, PartialEq, Eq)]
1603pub struct AudioTag {
1604    pub sound_format: u8,
1605    /// 0 = 5.5k / 1 = 11k / 2 = 22k / 3 = 44k. Encoded in the FLV
1606    /// header but overridden for AAC (always 3 by spec). Ignored
1607    /// and forced to zero in Enhanced mode (`audio_fourcc.is_some()`).
1608    pub sound_rate: u8,
1609    pub sound_size_16bit: bool,
1610    pub stereo: bool,
1611    /// `AacSequenceHeader` / `AacRaw`. `None` for non-AAC codecs
1612    /// and for all Enhanced-mode tags (use [`AudioTag::ex_packet_type`]
1613    /// instead).
1614    pub aac_packet_type: Option<u8>,
1615    /// Enhanced RTMP v2 `AudioPacketType` nibble (the four bits
1616    /// that replace SoundRate|SoundSize|SoundType when
1617    /// `sound_format == AUDIO_FORMAT_EX_HEADER`). One of
1618    /// `AUDIO_PACKET_TYPE_*`. `None` for legacy tags.
1619    pub ex_packet_type: Option<u8>,
1620    /// Enhanced RTMP v2 FourCC audio codec tag — the four ASCII
1621    /// bytes following the header byte when `sound_format ==
1622    /// AUDIO_FORMAT_EX_HEADER`. `None` for legacy tags. Values
1623    /// defined by Veovera so far: `b"Opus"`, `b"fLaC"`, `b"ac-3"`,
1624    /// `b"ec-3"`, `b".mp3"`, `b"mp4a"` (AAC, added FOURCC
1625    /// signalling).
1626    pub audio_fourcc: Option<[u8; 4]>,
1627    /// Body: per-FourCC `…SequenceHeader` for
1628    /// `PacketTypeSequenceStart` (`OpusSequenceHeader` /
1629    /// `FlacSequenceHeader` / `AacSequenceHeader`); per-FourCC
1630    /// `…CodedData` for `PacketTypeCodedFrames` (`Ac3CodedData`,
1631    /// `OpusCodedData`, `Mp3CodedData`, `AacCodedData`,
1632    /// `FlacCodedData`); empty for `SequenceEnd`.
1633    pub body: Vec<u8>,
1634    /// Enhanced RTMP v2 ModEx prelude chain
1635    /// (`enhanced-rtmp-v2.pdf` §"ExAudioTagHeader"). Empty for
1636    /// legacy tags and for Enhanced tags that carry no modifier.
1637    /// Each entry was an `AudioPacketType.ModEx` step before the
1638    /// real [`ex_packet_type`][AudioTag::ex_packet_type] was
1639    /// decoded; the chain is re-emitted verbatim ahead of the real
1640    /// packet type on build. The only subtype defined today is
1641    /// `TimestampOffsetNano`.
1642    pub mod_ex: Vec<ModEx>,
1643    /// Enhanced RTMP v2 `Multitrack` body (per-track FourCC + trackId +
1644    /// sizeOfAudioTrack chain — see [`Multitrack`]). `Some(..)` only when
1645    /// the wire AudioPacketType nibble was `Multitrack = 5`; in that case
1646    /// [`ex_packet_type`][AudioTag::ex_packet_type] holds the *real* inner
1647    /// AudioPacketType (e.g. `CodedFrames`, `SequenceStart`),
1648    /// [`audio_fourcc`][AudioTag::audio_fourcc] holds the shared codec
1649    /// FourCC when the multitrack mode is `OneTrack` / `ManyTracks` (and
1650    /// `None` for `ManyTracksManyCodecs`), and the tag's
1651    /// [`body`][AudioTag::body] is empty (track payloads sit in each
1652    /// [`MultitrackTrack::body`]).
1653    pub multitrack: Option<Multitrack>,
1654}
1655
1656impl AudioTag {
1657    /// True when this tag is an Enhanced-RTMP v2 tag (the
1658    /// SoundFormat nibble was `ExHeader = 9` on the wire and the
1659    /// four-byte FourCC + AudioPacketType were decoded into
1660    /// [`audio_fourcc`][AudioTag::audio_fourcc] /
1661    /// [`ex_packet_type`][AudioTag::ex_packet_type]).
1662    pub fn is_enhanced(&self) -> bool {
1663        self.audio_fourcc.is_some()
1664    }
1665    /// True when this tag is a legacy AAC sequence-header
1666    /// (`AudioSpecificConfig` payload) — `sound_format = 10`,
1667    /// `aac_packet_type = 0`.
1668    pub fn is_aac_sequence_header(&self) -> bool {
1669        self.sound_format == AUDIO_FORMAT_AAC
1670            && self.aac_packet_type == Some(AAC_PACKET_TYPE_SEQUENCE_HEADER)
1671    }
1672    /// True when this tag is the Enhanced-RTMP v2
1673    /// `PacketTypeSequenceStart` for a FourCC audio codec — body
1674    /// is the codec's sequence header per `ExAudioTagBody`
1675    /// (`OpusSequenceHeader` / `FlacSequenceHeader` /
1676    /// `AacSequenceHeader` ASC; AC-3 / E-AC-3 / MP3 have no
1677    /// SequenceStart shape defined in v2).
1678    pub fn is_ex_sequence_header(&self) -> bool {
1679        self.audio_fourcc.is_some() && self.ex_packet_type == Some(AUDIO_PACKET_TYPE_SEQUENCE_START)
1680    }
1681
1682    /// Sum of the `TimestampOffsetNano` ModEx entries on this tag, in
1683    /// nanoseconds (added to the message presentation time without
1684    /// altering the RTMP millisecond timestamp). `0` when absent.
1685    pub fn timestamp_offset_nano(&self) -> u32 {
1686        self.mod_ex
1687            .iter()
1688            .filter_map(ModEx::timestamp_offset_nano)
1689            .fold(0u32, |acc, n| acc.saturating_add(n))
1690    }
1691
1692    /// True when this tag is an Enhanced-RTMP v2
1693    /// `AudioPacketType.MultichannelConfig` message (per
1694    /// enhanced-rtmp-v2.pdf §"ExAudioTagBody"). The body holds the
1695    /// `audioChannelOrder + channelCount + (mapping | flags)` layout;
1696    /// callers lift it via [`AudioTag::multichannel_config`].
1697    pub fn is_multichannel_config(&self) -> bool {
1698        self.audio_fourcc.is_some()
1699            && self.ex_packet_type == Some(AUDIO_PACKET_TYPE_MULTICHANNEL_CONFIG)
1700    }
1701
1702    /// Decode the `MultichannelConfig` body of this tag. Returns
1703    /// `Ok(None)` when the tag is not a MultichannelConfig message.
1704    /// Errors flow through from [`MultichannelConfig::parse`] on
1705    /// truncated bodies.
1706    pub fn multichannel_config(&self) -> Result<Option<MultichannelConfig>> {
1707        if self.is_multichannel_config() {
1708            Ok(Some(MultichannelConfig::parse(&self.body)?))
1709        } else {
1710            Ok(None)
1711        }
1712    }
1713
1714    /// Build an Enhanced-RTMP v2 `MultichannelConfig` audio tag with
1715    /// the given codec FourCC and decoded body. The returned tag has
1716    /// `ex_packet_type = MultichannelConfig`, `audio_fourcc = fourcc`,
1717    /// and `body` set to `cfg.encode()`. ModEx prelude is empty.
1718    pub fn multichannel_config_tag(fourcc: [u8; 4], cfg: &MultichannelConfig) -> AudioTag {
1719        AudioTag {
1720            sound_format: AUDIO_FORMAT_EX_HEADER,
1721            sound_rate: 0,
1722            sound_size_16bit: false,
1723            stereo: false,
1724            aac_packet_type: None,
1725            ex_packet_type: Some(AUDIO_PACKET_TYPE_MULTICHANNEL_CONFIG),
1726            audio_fourcc: Some(fourcc),
1727            body: cfg.encode(),
1728            mod_ex: Vec::new(),
1729            multitrack: None,
1730        }
1731    }
1732
1733    /// True when this tag is an Enhanced-RTMP v2 audio `Multitrack`
1734    /// message (the wire AudioPacketType nibble was `Multitrack = 5`
1735    /// and [`Self::multitrack`] decoded the per-track body).
1736    pub fn is_multitrack(&self) -> bool {
1737        self.multitrack.is_some()
1738    }
1739
1740    /// Build an Enhanced-RTMP v2 audio `Multitrack` tag with the given
1741    /// real inner AudioPacketType, shared FourCC (`None` for
1742    /// `ManyTracksManyCodecs`), and per-track body. The returned tag has
1743    /// `ex_packet_type = real_packet_type`, `audio_fourcc = shared_fourcc`,
1744    /// `multitrack = Some(mt)`, and `body` empty. ModEx prelude is empty.
1745    pub fn multitrack_tag(
1746        real_packet_type: u8,
1747        shared_fourcc: Option<[u8; 4]>,
1748        mt: Multitrack,
1749    ) -> AudioTag {
1750        AudioTag {
1751            sound_format: AUDIO_FORMAT_EX_HEADER,
1752            sound_rate: 0,
1753            sound_size_16bit: false,
1754            stereo: false,
1755            aac_packet_type: None,
1756            ex_packet_type: Some(real_packet_type),
1757            audio_fourcc: shared_fourcc,
1758            body: Vec::new(),
1759            mod_ex: Vec::new(),
1760            multitrack: Some(mt),
1761        }
1762    }
1763}
1764
1765/// Decode the FLV audio-tag header from an RTMP audio message
1766/// payload.
1767///
1768/// Recognises both legacy pre-2023 framing (1-byte
1769/// `SoundFormat|SoundRate|SoundSize|SoundType` header, optional
1770/// AAC packet-type marker) and Enhanced RTMP v2 framing
1771/// (`SoundFormat == ExHeader = 9` → 1-byte
1772/// `ExHeader|AudioPacketType` header, 4-byte FourCC, per-FourCC
1773/// body).
1774///
1775/// Returns `Err(Error::Other)` on truncation. Per Enhanced RTMP
1776/// v2: "During the parsing process, the logic MUST handle
1777/// unexpected or unknown elements gracefully. Specifically, if
1778/// any critical signaling or flags (e.g., AudioPacketType and
1779/// AudioFourCc) are not recognized, the system MUST fail in a
1780/// controlled and predictable manner." We surface an unknown
1781/// `ex_packet_type` / FourCC by returning the raw bytes in the
1782/// struct (callers decide whether to ignore the tag or fail).
1783///
1784/// The `ModEx` AudioPacketType prelude (a chain of
1785/// `modExDataSize + modExData + modExType/packetType` entries before
1786/// the real packet type) is now decoded into [`AudioTag::mod_ex`].
1787/// The `MultichannelConfig` AudioPacketType is also recognised — the
1788/// body bytes (`audioChannelOrder + channelCount + flags|mapping`)
1789/// sit in [`AudioTag::body`] verbatim and lift to the strongly-typed
1790/// [`MultichannelConfig`] view through
1791/// [`AudioTag::multichannel_config`]. The `Multitrack` AudioPacketType
1792/// is also recognised — the `multitrackType (UB[4]) | realPacketType
1793/// (UB[4])` byte plus the optional shared FourCC are consumed inline
1794/// here, and the per-track list (`(trackFourCc if ManyTracksManyCodecs)
1795/// | trackId(UI8) | (sizeOfAudioTrack(UI24) if not OneTrack) | body`)
1796/// is decoded into [`AudioTag::multitrack`].
1797pub fn parse_audio(payload: &[u8]) -> Result<AudioTag> {
1798    if payload.is_empty() {
1799        return Err(Error::Other("FLV audio tag: empty".into()));
1800    }
1801    let b0 = payload[0];
1802    let sound_format = b0 >> 4;
1803    if sound_format == AUDIO_FORMAT_EX_HEADER {
1804        // --- Enhanced RTMP v2 framing ---
1805        //
1806        //   byte 0     = SoundFormat=9(4) | AudioPacketType(4)
1807        //   [ModEx prelude chain — present only when packetType == ModEx]
1808        //   byte ..=+3 = AudioFourCc (4 ASCII bytes)
1809        //   byte ..    = body, per (FourCc, PacketType) per
1810        //                §"ExAudioTagBody"
1811        //
1812        // Per spec the legacy bit-field SoundRate/SoundSize/
1813        // SoundType are NOT interpreted in this mode — zero them
1814        // on the parsed struct so a downstream consumer that
1815        // (incorrectly) keys off them gets a clearly-zero answer
1816        // instead of an arbitrary alias of the AudioPacketType
1817        // nibble.
1818        let mut packet_type = b0 & 0x0F;
1819        let mut pos = 1;
1820
1821        // ModEx prelude (enhanced-rtmp-v2.pdf §"ExAudioTagHeader"):
1822        // identical loop to the video path — consume size-prefixed
1823        // modExData + the trailing modExType/packetType nibble while
1824        // the PacketType nibble is ModEx. The chain sits between the
1825        // header byte and the FourCC.
1826        let mut mod_ex = Vec::new();
1827        if packet_type == AUDIO_PACKET_TYPE_MOD_EX {
1828            let (chain, real_pt, next) =
1829                parse_mod_ex_chain(payload, pos, AUDIO_PACKET_TYPE_MOD_EX, "audio")?;
1830            mod_ex = chain;
1831            packet_type = real_pt;
1832            pos = next;
1833        }
1834
1835        // Multitrack prelude (enhanced-rtmp-v2.pdf §"ExAudioTagHeader"):
1836        // a Multitrack AudioPacketType pulls in a `multitrackType
1837        // (UB[4]) | realPacketType (UB[4])` byte and, when the
1838        // multitrack mode is not ManyTracksManyCodecs, a shared FourCC.
1839        // The body (per-track list) is decoded later via
1840        // `Multitrack::parse`.
1841        let mut multitrack_type: Option<u8> = None;
1842        if packet_type == AUDIO_PACKET_TYPE_MULTITRACK {
1843            if pos >= payload.len() {
1844                return Err(Error::Other(
1845                    "Enhanced RTMP audio Multitrack: truncated reading multitrackType nibble"
1846                        .into(),
1847                ));
1848            }
1849            let nibble = payload[pos];
1850            pos += 1;
1851            let mt_type = (nibble >> 4) & 0x0F;
1852            let inner_pt = nibble & 0x0F;
1853            // Spec: "This fetch MUST not result in a AudioPacketType.Multitrack"
1854            if inner_pt == AUDIO_PACKET_TYPE_MULTITRACK {
1855                return Err(Error::Other(
1856                    "Enhanced RTMP audio Multitrack: inner PacketType MUST NOT be Multitrack"
1857                        .into(),
1858                ));
1859            }
1860            multitrack_type = Some(mt_type);
1861            packet_type = inner_pt;
1862        }
1863
1864        let need_shared_fourcc = match multitrack_type {
1865            Some(t) => t != AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS,
1866            None => true,
1867        };
1868        let fcc_opt = if need_shared_fourcc {
1869            if pos + 4 > payload.len() {
1870                return Err(Error::Other(
1871                    "Enhanced RTMP audio tag: need 4 bytes for FourCC after header/ModEx".into(),
1872                ));
1873            }
1874            let mut fcc = [0u8; 4];
1875            fcc.copy_from_slice(&payload[pos..pos + 4]);
1876            pos += 4;
1877            Some(fcc)
1878        } else {
1879            None
1880        };
1881
1882        if let Some(mt_type) = multitrack_type {
1883            let mt = Multitrack::parse(&payload[pos..], mt_type)?;
1884            return Ok(AudioTag {
1885                sound_format,
1886                sound_rate: 0,
1887                sound_size_16bit: false,
1888                stereo: false,
1889                aac_packet_type: None,
1890                ex_packet_type: Some(packet_type),
1891                audio_fourcc: fcc_opt,
1892                body: Vec::new(),
1893                mod_ex,
1894                multitrack: Some(mt),
1895            });
1896        }
1897
1898        let fcc = fcc_opt.expect("non-Multitrack audio tag requires shared FourCC slot");
1899        Ok(AudioTag {
1900            sound_format,
1901            sound_rate: 0,
1902            sound_size_16bit: false,
1903            stereo: false,
1904            aac_packet_type: None,
1905            ex_packet_type: Some(packet_type),
1906            audio_fourcc: Some(fcc),
1907            body: payload[pos..].to_vec(),
1908            mod_ex,
1909            multitrack: None,
1910        })
1911    } else {
1912        // --- Legacy pre-2023 framing ---
1913        let sound_rate = (b0 >> 2) & 0x03;
1914        let sound_size_16bit = (b0 & 0x02) != 0;
1915        let stereo = (b0 & 0x01) != 0;
1916        if sound_format == AUDIO_FORMAT_AAC {
1917            if payload.len() < 2 {
1918                return Err(Error::Other("FLV/AAC tag: need 2+ bytes".into()));
1919            }
1920            Ok(AudioTag {
1921                sound_format,
1922                sound_rate,
1923                sound_size_16bit,
1924                stereo,
1925                aac_packet_type: Some(payload[1]),
1926                ex_packet_type: None,
1927                audio_fourcc: None,
1928                body: payload[2..].to_vec(),
1929                mod_ex: Vec::new(),
1930                multitrack: None,
1931            })
1932        } else {
1933            Ok(AudioTag {
1934                sound_format,
1935                sound_rate,
1936                sound_size_16bit,
1937                stereo,
1938                aac_packet_type: None,
1939                ex_packet_type: None,
1940                audio_fourcc: None,
1941                body: payload[1..].to_vec(),
1942                mod_ex: Vec::new(),
1943                multitrack: None,
1944            })
1945        }
1946    }
1947}
1948
1949/// Build an RTMP audio-tag payload.
1950///
1951/// Legacy mode (`tag.audio_fourcc.is_none()`): writes the 1-byte
1952/// `SoundFormat|SoundRate|SoundSize|SoundType` header + optional
1953/// 1-byte AAC packet type, then `body`.
1954///
1955/// Enhanced RTMP v2 mode (`tag.audio_fourcc = Some([..])`):
1956/// writes a 1-byte `ExHeader(9) | AudioPacketType` header
1957/// (regardless of the value sitting in `tag.sound_format` — the
1958/// spec mandates SoundFormat == 9 for this layout), the 4-byte
1959/// FourCC, then `body`. The legacy SoundRate / SoundSize /
1960/// SoundType bits are dropped per spec.
1961pub fn build_audio(tag: &AudioTag) -> Vec<u8> {
1962    if tag.audio_fourcc.is_some() || tag.multitrack.is_some() {
1963        let real_packet_type = tag.ex_packet_type.unwrap_or(AUDIO_PACKET_TYPE_CODED_FRAMES);
1964        let multitrack_outer_pt = if tag.multitrack.is_some() {
1965            Some(AUDIO_PACKET_TYPE_MULTITRACK)
1966        } else {
1967            None
1968        };
1969        let post_mod_ex_pt = multitrack_outer_pt.unwrap_or(real_packet_type);
1970        // When a ModEx prelude is present the header byte's
1971        // AudioPacketType nibble is `ModEx`; the next packet type is
1972        // carried by the terminating nibble of the chain
1973        // (enhanced-rtmp-v2.pdf §"ExAudioTagHeader"). For a multitrack
1974        // tag the next packet type is `Multitrack`, not the real inner.
1975        let header_pt = if tag.mod_ex.is_empty() {
1976            post_mod_ex_pt
1977        } else {
1978            AUDIO_PACKET_TYPE_MOD_EX
1979        };
1980        let head = (AUDIO_FORMAT_EX_HEADER << 4) | (header_pt & 0x0F);
1981        let mut out = Vec::with_capacity(tag.body.len() + 5);
1982        out.push(head);
1983        build_mod_ex_chain(
1984            &mut out,
1985            &tag.mod_ex,
1986            AUDIO_PACKET_TYPE_MOD_EX,
1987            post_mod_ex_pt,
1988        );
1989        if let Some(mt) = &tag.multitrack {
1990            // Multitrack nibble: `multitrackType (UB[4]) | realPacketType
1991            // (UB[4])`.
1992            out.push(((mt.multitrack_type & 0x0F) << 4) | (real_packet_type & 0x0F));
1993            if mt.multitrack_type != AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS {
1994                let fcc = tag.audio_fourcc.unwrap_or([0; 4]);
1995                out.extend_from_slice(&fcc);
1996            }
1997            out.extend_from_slice(&mt.encode());
1998            return out;
1999        }
2000        let fcc = tag
2001            .audio_fourcc
2002            .expect("Enhanced-RTMP non-Multitrack audio tag requires audio_fourcc");
2003        out.extend_from_slice(&fcc);
2004        out.extend_from_slice(&tag.body);
2005        out
2006    } else {
2007        let b0 = (tag.sound_format << 4)
2008            | ((tag.sound_rate & 0x03) << 2)
2009            | (if tag.sound_size_16bit { 0x02 } else { 0 })
2010            | (if tag.stereo { 0x01 } else { 0 });
2011        let mut out = Vec::with_capacity(tag.body.len() + 2);
2012        out.push(b0);
2013        if tag.sound_format == AUDIO_FORMAT_AAC {
2014            out.push(tag.aac_packet_type.unwrap_or(AAC_PACKET_TYPE_RAW));
2015        }
2016        out.extend_from_slice(&tag.body);
2017        out
2018    }
2019}
2020
2021#[cfg(test)]
2022mod tests {
2023    use super::*;
2024
2025    #[test]
2026    fn video_tag_avc_nalu_roundtrip() {
2027        let tag = VideoTag {
2028            mod_ex: Vec::new(),
2029            frame_type: VIDEO_FRAME_KEYFRAME,
2030            codec_id: VIDEO_CODEC_AVC,
2031            avc_packet_type: Some(AVC_PACKET_TYPE_NALU),
2032            composition_time: 42,
2033            body: b"\x00\x00\x00\x05hello".to_vec(),
2034            ex_packet_type: None,
2035            fourcc: None,
2036
2037            multitrack: None,
2038        };
2039        let payload = build_video(&tag);
2040        assert_eq!(payload[0], 0x17); // keyframe + AVC
2041        let back = parse_video(&payload).unwrap();
2042        assert_eq!(back, tag);
2043    }
2044
2045    #[test]
2046    fn video_tag_negative_cts_sign_extends() {
2047        let tag = VideoTag {
2048            mod_ex: Vec::new(),
2049            frame_type: VIDEO_FRAME_INTER,
2050            codec_id: VIDEO_CODEC_AVC,
2051            avc_packet_type: Some(AVC_PACKET_TYPE_NALU),
2052            composition_time: -5,
2053            body: vec![0x01],
2054            ex_packet_type: None,
2055            fourcc: None,
2056
2057            multitrack: None,
2058        };
2059        let payload = build_video(&tag);
2060        let back = parse_video(&payload).unwrap();
2061        assert_eq!(back.composition_time, -5);
2062    }
2063
2064    // ------- Enhanced RTMP v1 (Veovera 2023) round-trips -------
2065
2066    #[test]
2067    fn ex_video_tag_hevc_sequence_start_roundtrip() {
2068        // SequenceStart: HEVCDecoderConfigurationRecord in body,
2069        // no SI24 CTS on the wire.
2070        let tag = VideoTag {
2071            mod_ex: Vec::new(),
2072            frame_type: VIDEO_FRAME_KEYFRAME,
2073            codec_id: 0,
2074            avc_packet_type: None,
2075            composition_time: 0,
2076            body: b"\x01dummy-hvcc".to_vec(),
2077            ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_START),
2078            fourcc: Some(FOURCC_HEVC),
2079
2080            multitrack: None,
2081        };
2082        let payload = build_video(&tag);
2083        // Header byte: IsExHeader(1) | FrameType(001) | PacketType(0000)
2084        // = 0b1001_0000 = 0x90.
2085        assert_eq!(payload[0], 0x90);
2086        assert_eq!(&payload[1..5], b"hvc1");
2087        // No SI24 between FourCC and body for SequenceStart.
2088        assert_eq!(&payload[5..], b"\x01dummy-hvcc");
2089
2090        let back = parse_video(&payload).unwrap();
2091        assert_eq!(back, tag);
2092        assert!(back.is_ex_sequence_header());
2093        assert!(back.is_keyframe());
2094    }
2095
2096    #[test]
2097    fn ex_video_tag_hevc_coded_frames_carries_cts() {
2098        // CodedFrames is the only Enhanced RTMP shape that
2099        // keeps the SI24 CTS on the wire (per Table 4's HEVC
2100        // pseudocode).
2101        let tag = VideoTag {
2102            mod_ex: Vec::new(),
2103            frame_type: VIDEO_FRAME_INTER,
2104            codec_id: 0,
2105            avc_packet_type: None,
2106            composition_time: -33,
2107            body: b"\x00\x00\x00\x04NALU".to_vec(),
2108            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES),
2109            fourcc: Some(FOURCC_HEVC),
2110
2111            multitrack: None,
2112        };
2113        let payload = build_video(&tag);
2114        // IsExHeader=1 | FrameType=2 | PacketType=1 = 0b1010_0001 = 0xA1.
2115        assert_eq!(payload[0], 0xA1);
2116        assert_eq!(&payload[1..5], b"hvc1");
2117        // SI24(-33) two's complement = 0xFFFFDF; truncated to
2118        // 24 bits = 0xFFFFDF — three bytes 0xFF 0xFF 0xDF.
2119        assert_eq!(&payload[5..8], &[0xFF, 0xFF, 0xDF]);
2120        assert_eq!(&payload[8..], b"\x00\x00\x00\x04NALU");
2121
2122        let back = parse_video(&payload).unwrap();
2123        assert_eq!(back, tag);
2124        assert_eq!(back.composition_time, -33);
2125    }
2126
2127    #[test]
2128    fn ex_video_tag_hevc_coded_frames_x_omits_cts() {
2129        // CodedFramesX is the SI24=0 optimisation — three
2130        // bytes off the wire vs CodedFrames.
2131        let tag = VideoTag {
2132            mod_ex: Vec::new(),
2133            frame_type: VIDEO_FRAME_INTER,
2134            codec_id: 0,
2135            avc_packet_type: None,
2136            composition_time: 0,
2137            body: b"\x00\x00\x00\x04NALU".to_vec(),
2138            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES_X),
2139            fourcc: Some(FOURCC_HEVC),
2140
2141            multitrack: None,
2142        };
2143        let payload = build_video(&tag);
2144        // IsExHeader=1 | FrameType=2 | PacketType=3 = 0xA3.
2145        assert_eq!(payload[0], 0xA3);
2146        assert_eq!(&payload[1..5], b"hvc1");
2147        // Body follows the FourCC directly — no SI24 bytes.
2148        assert_eq!(&payload[5..], b"\x00\x00\x00\x04NALU");
2149        // Total length saved is exactly 3 bytes vs the
2150        // CodedFrames form (1-byte header + 4-byte FourCC +
2151        // 8-byte body, no SI24).
2152        assert_eq!(payload.len(), 1 + 4 + 8);
2153
2154        let back = parse_video(&payload).unwrap();
2155        assert_eq!(back, tag);
2156    }
2157
2158    #[test]
2159    fn ex_video_tag_av1_sequence_start_no_cts() {
2160        // AV1 SequenceStart body is the
2161        // AV1CodecConfigurationRecord (per spec). No CTS.
2162        let tag = VideoTag {
2163            mod_ex: Vec::new(),
2164            frame_type: VIDEO_FRAME_KEYFRAME,
2165            codec_id: 0,
2166            avc_packet_type: None,
2167            composition_time: 0,
2168            body: b"\x81\x05\x0c\x00".to_vec(),
2169            ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_START),
2170            fourcc: Some(FOURCC_AV1),
2171
2172            multitrack: None,
2173        };
2174        let payload = build_video(&tag);
2175        assert_eq!(payload[0], 0x90);
2176        assert_eq!(&payload[1..5], b"av01");
2177        assert_eq!(&payload[5..], b"\x81\x05\x0c\x00");
2178
2179        let back = parse_video(&payload).unwrap();
2180        assert_eq!(back, tag);
2181        assert!(back.is_ex_sequence_header());
2182    }
2183
2184    #[test]
2185    fn ex_video_tag_av1_coded_frames_obus() {
2186        // AV1 CodedFrames body is "one or more OBUs which MUST
2187        // represent a single temporal unit" (Enhanced RTMP v1
2188        // §"If FourCC == AV1"). Still no CTS — only HEVC keeps
2189        // composition-time on the wire.
2190        let tag = VideoTag {
2191            mod_ex: Vec::new(),
2192            frame_type: VIDEO_FRAME_KEYFRAME,
2193            codec_id: 0,
2194            avc_packet_type: None,
2195            composition_time: 0,
2196            body: b"\x0a\x0b\x0cobu-stub".to_vec(),
2197            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES),
2198            fourcc: Some(FOURCC_AV1),
2199
2200            multitrack: None,
2201        };
2202        let payload = build_video(&tag);
2203        // IsExHeader=1 | FrameType=1 | PacketType=1 = 0x91.
2204        assert_eq!(payload[0], 0x91);
2205        assert_eq!(&payload[1..5], b"av01");
2206        // Body immediately follows FourCC (no SI24 for AV1).
2207        assert_eq!(&payload[5..], b"\x0a\x0b\x0cobu-stub");
2208
2209        let back = parse_video(&payload).unwrap();
2210        assert_eq!(back, tag);
2211    }
2212
2213    #[test]
2214    fn ex_video_tag_vp9_coded_frames_full_frame() {
2215        // VP9 CodedFrames body "MUST contain full frames"
2216        // (Enhanced RTMP v1 §"If FourCC == VP9").
2217        let tag = VideoTag {
2218            mod_ex: Vec::new(),
2219            frame_type: VIDEO_FRAME_KEYFRAME,
2220            codec_id: 0,
2221            avc_packet_type: None,
2222            composition_time: 0,
2223            body: b"vp9-frame-bytes".to_vec(),
2224            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES),
2225            fourcc: Some(FOURCC_VP9),
2226
2227            multitrack: None,
2228        };
2229        let payload = build_video(&tag);
2230        assert_eq!(payload[0], 0x91);
2231        assert_eq!(&payload[1..5], b"vp09");
2232        assert_eq!(&payload[5..], b"vp9-frame-bytes");
2233
2234        let back = parse_video(&payload).unwrap();
2235        assert_eq!(back, tag);
2236    }
2237
2238    #[test]
2239    fn ex_video_tag_sequence_end_empty_body() {
2240        // SequenceEnd carries no codec data — body is empty.
2241        let tag = VideoTag {
2242            mod_ex: Vec::new(),
2243            frame_type: VIDEO_FRAME_KEYFRAME,
2244            codec_id: 0,
2245            avc_packet_type: None,
2246            composition_time: 0,
2247            body: vec![],
2248            ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_END),
2249            fourcc: Some(FOURCC_HEVC),
2250
2251            multitrack: None,
2252        };
2253        let payload = build_video(&tag);
2254        // IsExHeader=1 | FrameType=1 | PacketType=2 = 0x92.
2255        assert_eq!(payload[0], 0x92);
2256        assert_eq!(&payload[1..5], b"hvc1");
2257        assert_eq!(payload.len(), 5);
2258
2259        let back = parse_video(&payload).unwrap();
2260        assert_eq!(back, tag);
2261    }
2262
2263    #[test]
2264    fn ex_video_tag_metadata_carries_amf_body() {
2265        // PacketTypeMetadata: body is an AMF-encoded `[name,
2266        // value]` pair (only `"colorInfo"` is defined in v1).
2267        // Spec says: "presence of PacketTypeMetadata means
2268        // that FrameType flags at the top of this table should
2269        // be ignored." We still preserve the bits — caller
2270        // policy decides.
2271        let tag = VideoTag {
2272            mod_ex: Vec::new(),
2273            frame_type: VIDEO_FRAME_INFO, // would be "ignored" per spec
2274            codec_id: 0,
2275            avc_packet_type: None,
2276            composition_time: 0,
2277            body: b"amf-stub".to_vec(),
2278            ex_packet_type: Some(EX_PACKET_TYPE_METADATA),
2279            fourcc: Some(FOURCC_HEVC),
2280
2281            multitrack: None,
2282        };
2283        let payload = build_video(&tag);
2284        // IsExHeader=1 | FrameType=5 | PacketType=4 = 0xD4.
2285        assert_eq!(payload[0], 0xD4);
2286        let back = parse_video(&payload).unwrap();
2287        assert_eq!(back, tag);
2288        assert!(back.is_ex_metadata());
2289    }
2290
2291    #[test]
2292    fn color_info_round_trips_full_hdr10() {
2293        // Realistic HDR10 colorInfo: 10-bit, BT.2020 primaries (9),
2294        // PQ transfer (16), BT.2020 NCL matrix (9), with hdrCll + hdrMdcv.
2295        let ci = ColorInfo {
2296            color_config: Some(ColorConfig {
2297                bit_depth: Some(10.0),
2298                color_primaries: Some(9.0),
2299                transfer_characteristics: Some(16.0),
2300                matrix_coefficients: Some(9.0),
2301            }),
2302            hdr_cll: Some(HdrCll {
2303                max_fall: Some(400.0),
2304                max_cll: Some(1000.0),
2305            }),
2306            hdr_mdcv: Some(HdrMdcv {
2307                red_x: Some(0.708),
2308                red_y: Some(0.292),
2309                green_x: Some(0.170),
2310                green_y: Some(0.797),
2311                blue_x: Some(0.131),
2312                blue_y: Some(0.046),
2313                white_point_x: Some(0.3127),
2314                white_point_y: Some(0.3290),
2315                max_luminance: Some(1000.0),
2316                min_luminance: Some(0.0001),
2317            }),
2318        };
2319        let tag = VideoTag::color_info_tag(FOURCC_HEVC, &ci);
2320        assert!(tag.is_ex_metadata());
2321        let payload = build_video(&tag);
2322        let back = parse_video(&payload).unwrap();
2323        assert_eq!(back, tag);
2324        let decoded = back.color_info().unwrap().unwrap();
2325        assert_eq!(decoded, ci);
2326        assert!(!decoded.is_reset());
2327    }
2328
2329    #[test]
2330    fn color_info_partial_only_color_config_round_trips() {
2331        // Only colorConfig present — hdrCll/hdrMdcv absent must stay absent.
2332        let ci = ColorInfo {
2333            color_config: Some(ColorConfig {
2334                bit_depth: Some(8.0),
2335                color_primaries: Some(1.0),
2336                transfer_characteristics: Some(1.0),
2337                matrix_coefficients: Some(1.0),
2338            }),
2339            hdr_cll: None,
2340            hdr_mdcv: None,
2341        };
2342        let tag = VideoTag::color_info_tag(FOURCC_AV1, &ci);
2343        let decoded = VideoTag::color_info(&parse_video(&build_video(&tag)).unwrap())
2344            .unwrap()
2345            .unwrap();
2346        assert_eq!(decoded, ci);
2347        assert!(decoded.hdr_cll.is_none());
2348        assert!(decoded.hdr_mdcv.is_none());
2349    }
2350
2351    #[test]
2352    fn color_info_reset_encodes_undefined() {
2353        // The spec's RECOMMENDED reset signal: colorInfo = Undefined.
2354        let ci = ColorInfo::default();
2355        assert!(ci.is_reset());
2356        let tag = VideoTag::color_info_tag(FOURCC_HEVC, &ci);
2357        let back = parse_video(&build_video(&tag)).unwrap();
2358        let decoded = back.color_info().unwrap().unwrap();
2359        assert!(decoded.is_reset());
2360        assert_eq!(decoded, ColorInfo::default());
2361        // Body must be the "colorInfo" string followed by an AMF Undefined
2362        // marker (0x06), not an Object.
2363        let values = crate::amf::decode_all(&tag.body).unwrap();
2364        assert_eq!(values[0].as_str(), Some("colorInfo"));
2365        assert_eq!(values[1], Amf0Value::Undefined);
2366    }
2367
2368    #[test]
2369    fn color_info_empty_object_is_present_but_empty() {
2370        // An empty `{}` colorInfo (alternative reset form) decodes to a
2371        // present-but-all-None ColorInfo, distinct from a missing pair.
2372        let mut body = Vec::new();
2373        crate::amf::encode(&mut body, &Amf0Value::String("colorInfo".into()));
2374        crate::amf::encode(&mut body, &Amf0Value::Object(Vec::new()));
2375        let tag = VideoTag {
2376            frame_type: VIDEO_FRAME_INFO,
2377            codec_id: 0,
2378            avc_packet_type: None,
2379            composition_time: 0,
2380            body,
2381            ex_packet_type: Some(EX_PACKET_TYPE_METADATA),
2382            fourcc: Some(FOURCC_HEVC),
2383            mod_ex: Vec::new(),
2384            multitrack: None,
2385        };
2386        let decoded = tag.color_info().unwrap().unwrap();
2387        // Empty object → all sub-objects absent → reset.
2388        assert!(decoded.is_reset());
2389    }
2390
2391    #[test]
2392    fn color_info_none_for_non_metadata_tag() {
2393        let tag = VideoTag {
2394            frame_type: VIDEO_FRAME_KEYFRAME,
2395            codec_id: VIDEO_CODEC_AVC,
2396            avc_packet_type: Some(AVC_PACKET_TYPE_NALU),
2397            composition_time: 0,
2398            body: vec![0, 0, 0, 1, 0x65],
2399            ex_packet_type: None,
2400            fourcc: None,
2401            mod_ex: Vec::new(),
2402            multitrack: None,
2403        };
2404        assert_eq!(tag.color_info().unwrap(), None);
2405    }
2406
2407    #[test]
2408    fn color_info_none_when_pair_name_is_not_colorinfo() {
2409        // A metadata tag carrying some other (future) name yields None,
2410        // not an error.
2411        let mut body = Vec::new();
2412        crate::amf::encode(&mut body, &Amf0Value::String("somethingElse".into()));
2413        crate::amf::encode(&mut body, &Amf0Value::Number(1.0));
2414        let tag = VideoTag::color_info_tag(FOURCC_HEVC, &ColorInfo::default());
2415        let tag = VideoTag { body, ..tag };
2416        assert_eq!(tag.color_info().unwrap(), None);
2417    }
2418
2419    #[test]
2420    fn color_info_rejects_wrong_amf_type() {
2421        // colorInfo value of a scalar AMF type is a malformed metadata body.
2422        let mut body = Vec::new();
2423        crate::amf::encode(&mut body, &Amf0Value::String("colorInfo".into()));
2424        crate::amf::encode(&mut body, &Amf0Value::Number(42.0));
2425        let tag = VideoTag::color_info_tag(FOURCC_HEVC, &ColorInfo::default());
2426        let tag = VideoTag { body, ..tag };
2427        assert!(tag.color_info().is_err());
2428    }
2429
2430    #[test]
2431    fn legacy_avc_high_frame_type_bit_was_always_zero() {
2432        // Sanity-check the Enhanced RTMP backwards-compat
2433        // claim: pre-2023 FrameType values 1..=5 all leave bit
2434        // 7 of the header byte clear, so a parser that branches
2435        // on IsExHeader == 1 never mis-detects legacy traffic
2436        // as Enhanced.
2437        for ft in [
2438            VIDEO_FRAME_KEYFRAME,
2439            VIDEO_FRAME_INTER,
2440            VIDEO_FRAME_DISPOSABLE,
2441            VIDEO_FRAME_GENERATED_KEY,
2442            VIDEO_FRAME_INFO,
2443        ] {
2444            let tag = VideoTag {
2445                mod_ex: Vec::new(),
2446                frame_type: ft,
2447                codec_id: VIDEO_CODEC_AVC,
2448                avc_packet_type: Some(AVC_PACKET_TYPE_NALU),
2449                composition_time: 0,
2450                body: vec![0x00],
2451                ex_packet_type: None,
2452                fourcc: None,
2453
2454                multitrack: None,
2455            };
2456            let payload = build_video(&tag);
2457            assert_eq!(payload[0] & VIDEO_IS_EX_HEADER, 0, "ft={ft}");
2458        }
2459    }
2460
2461    // ------- Enhanced RTMP v2 (Veovera 2026) new video FourCCs -------
2462
2463    #[test]
2464    fn ex_video_tag_vp8_sequence_start_carries_vp_config_record() {
2465        // VP8 SequenceStart body is a `VPCodecConfigurationRecord`
2466        // (same shape as VP9 — per enhanced-rtmp-v2.pdf §"Enhanced
2467        // Video" the pseudocode is `vp8Header =
2468        // [VPCodecConfigurationRecord]`). No CTS — VP8 has no
2469        // B-frames.
2470        let tag = VideoTag {
2471            mod_ex: Vec::new(),
2472            frame_type: VIDEO_FRAME_KEYFRAME,
2473            codec_id: 0,
2474            avc_packet_type: None,
2475            composition_time: 0,
2476            body: vec![
2477                0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
2478            ],
2479            ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_START),
2480            fourcc: Some(FOURCC_VP8),
2481
2482            multitrack: None,
2483        };
2484        let payload = build_video(&tag);
2485        // IsExHeader=1 | FrameType=1 (key) | PacketType=0 = 0x90.
2486        assert_eq!(payload[0], 0x90);
2487        assert_eq!(&payload[1..5], b"vp08");
2488        assert_eq!(&payload[5..], &tag.body[..]);
2489
2490        let back = parse_video(&payload).unwrap();
2491        assert_eq!(back, tag);
2492        assert!(back.is_ex_sequence_header());
2493    }
2494
2495    #[test]
2496    fn ex_video_tag_vp8_coded_frames_no_cts() {
2497        // VP8 CodedFrames body is one or more full frames; no CTS
2498        // on the wire (no B-frame ordering).
2499        let tag = VideoTag {
2500            mod_ex: Vec::new(),
2501            frame_type: VIDEO_FRAME_INTER,
2502            codec_id: 0,
2503            avc_packet_type: None,
2504            composition_time: 0,
2505            body: b"vp8-frame-bytes".to_vec(),
2506            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES),
2507            fourcc: Some(FOURCC_VP8),
2508
2509            multitrack: None,
2510        };
2511        let payload = build_video(&tag);
2512        // IsExHeader=1 | FrameType=2 | PacketType=1 = 0xA1.
2513        assert_eq!(payload[0], 0xA1);
2514        assert_eq!(&payload[1..5], b"vp08");
2515        // Body immediately follows FourCC — no SI24 phantom.
2516        assert_eq!(&payload[5..], b"vp8-frame-bytes");
2517        let back = parse_video(&payload).unwrap();
2518        assert_eq!(back, tag);
2519    }
2520
2521    #[test]
2522    fn ex_video_tag_avc_fourcc_sequence_start_carries_avcc() {
2523        // FourCC-mode AVC SequenceStart body is the
2524        // `AVCDecoderConfigurationRecord` (per ISO/IEC 14496-15
2525        // §5.3.4.1, cited verbatim by enhanced-rtmp-v2.pdf
2526        // §"Enhanced Video"). No CTS on SequenceStart for any
2527        // FourCC, AVC included.
2528        let tag = VideoTag {
2529            mod_ex: Vec::new(),
2530            frame_type: VIDEO_FRAME_KEYFRAME,
2531            codec_id: 0,
2532            avc_packet_type: None,
2533            composition_time: 0,
2534            body: b"\x01\x42\xc0\x1edummy-avcc".to_vec(),
2535            ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_START),
2536            fourcc: Some(FOURCC_AVC),
2537
2538            multitrack: None,
2539        };
2540        let payload = build_video(&tag);
2541        // IsExHeader=1 | FrameType=1 | PacketType=0 = 0x90.
2542        assert_eq!(payload[0], 0x90);
2543        assert_eq!(&payload[1..5], b"avc1");
2544        // No SI24 — body follows FourCC directly.
2545        assert_eq!(&payload[5..], b"\x01\x42\xc0\x1edummy-avcc");
2546        let back = parse_video(&payload).unwrap();
2547        assert_eq!(back, tag);
2548        assert!(back.is_ex_sequence_header());
2549    }
2550
2551    #[test]
2552    fn ex_video_tag_avc_fourcc_coded_frames_carries_si24_cts() {
2553        // FourCC-mode AVC CodedFrames carries SI24
2554        // `compositionTimeOffset` exactly like HEVC. Tested with a
2555        // negative offset (-100) to also exercise the sign-extend
2556        // path through both build and parse.
2557        let tag = VideoTag {
2558            mod_ex: Vec::new(),
2559            frame_type: VIDEO_FRAME_INTER,
2560            codec_id: 0,
2561            avc_packet_type: None,
2562            composition_time: -100,
2563            body: b"\x00\x00\x00\x05nalu1".to_vec(),
2564            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES),
2565            fourcc: Some(FOURCC_AVC),
2566
2567            multitrack: None,
2568        };
2569        let payload = build_video(&tag);
2570        // IsExHeader=1 | FrameType=2 | PacketType=1 = 0xA1.
2571        assert_eq!(payload[0], 0xA1);
2572        assert_eq!(&payload[1..5], b"avc1");
2573        // SI24(-100) = 0xFFFF9C two's complement.
2574        assert_eq!(&payload[5..8], &[0xFF, 0xFF, 0x9C]);
2575        assert_eq!(&payload[8..], b"\x00\x00\x00\x05nalu1");
2576        let back = parse_video(&payload).unwrap();
2577        assert_eq!(back, tag);
2578        assert_eq!(back.composition_time, -100);
2579    }
2580
2581    #[test]
2582    fn ex_video_tag_avc_fourcc_coded_frames_x_omits_cts() {
2583        // CodedFramesX optimisation — same as HEVC: no SI24 on the
2584        // wire, three bytes saved.
2585        let tag = VideoTag {
2586            mod_ex: Vec::new(),
2587            frame_type: VIDEO_FRAME_INTER,
2588            codec_id: 0,
2589            avc_packet_type: None,
2590            composition_time: 0,
2591            body: b"\x00\x00\x00\x05nalu2".to_vec(),
2592            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES_X),
2593            fourcc: Some(FOURCC_AVC),
2594
2595            multitrack: None,
2596        };
2597        let payload = build_video(&tag);
2598        // IsExHeader=1 | FrameType=2 | PacketType=3 = 0xA3.
2599        assert_eq!(payload[0], 0xA3);
2600        assert_eq!(&payload[1..5], b"avc1");
2601        // Body follows immediately — no SI24.
2602        assert_eq!(&payload[5..], b"\x00\x00\x00\x05nalu2");
2603        assert_eq!(payload.len(), 1 + 4 + 9);
2604        let back = parse_video(&payload).unwrap();
2605        assert_eq!(back, tag);
2606    }
2607
2608    #[test]
2609    fn ex_video_tag_vvc_sequence_start_carries_vvcc() {
2610        // VVC SequenceStart body is `VVCDecoderConfigurationRecord`
2611        // (per ISO/IEC 14496-15:2024 §11.2.4.2). No CTS on
2612        // SequenceStart.
2613        let tag = VideoTag {
2614            mod_ex: Vec::new(),
2615            frame_type: VIDEO_FRAME_KEYFRAME,
2616            codec_id: 0,
2617            avc_packet_type: None,
2618            composition_time: 0,
2619            body: b"\xff\xfcdummy-vvcc".to_vec(),
2620            ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_START),
2621            fourcc: Some(FOURCC_VVC),
2622
2623            multitrack: None,
2624        };
2625        let payload = build_video(&tag);
2626        assert_eq!(payload[0], 0x90);
2627        assert_eq!(&payload[1..5], b"vvc1");
2628        assert_eq!(&payload[5..], b"\xff\xfcdummy-vvcc");
2629        let back = parse_video(&payload).unwrap();
2630        assert_eq!(back, tag);
2631        assert!(back.is_ex_sequence_header());
2632    }
2633
2634    #[test]
2635    fn ex_video_tag_vvc_coded_frames_carries_si24_cts() {
2636        // VVC CodedFrames carries SI24 like HEVC and AVC — covers
2637        // the §"ExVideoTagBody" pseudocode `if (videoFourCc ==
2638        // VideoFourCc.Vvc) { compositionTimeOffset = SI24 }`.
2639        let tag = VideoTag {
2640            mod_ex: Vec::new(),
2641            frame_type: VIDEO_FRAME_KEYFRAME,
2642            codec_id: 0,
2643            avc_packet_type: None,
2644            composition_time: 17,
2645            body: b"\x00\x00\x00\x06h266ku".to_vec(),
2646            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES),
2647            fourcc: Some(FOURCC_VVC),
2648
2649            multitrack: None,
2650        };
2651        let payload = build_video(&tag);
2652        // IsExHeader=1 | FrameType=1 | PacketType=1 = 0x91.
2653        assert_eq!(payload[0], 0x91);
2654        assert_eq!(&payload[1..5], b"vvc1");
2655        // SI24(17) = 0x000011.
2656        assert_eq!(&payload[5..8], &[0x00, 0x00, 0x11]);
2657        assert_eq!(&payload[8..], b"\x00\x00\x00\x06h266ku");
2658        let back = parse_video(&payload).unwrap();
2659        assert_eq!(back, tag);
2660        assert_eq!(back.composition_time, 17);
2661    }
2662
2663    #[test]
2664    fn ex_video_tag_vvc_coded_frames_x_omits_cts() {
2665        let tag = VideoTag {
2666            mod_ex: Vec::new(),
2667            frame_type: VIDEO_FRAME_INTER,
2668            codec_id: 0,
2669            avc_packet_type: None,
2670            composition_time: 0,
2671            body: b"\x00\x00\x00\x03vvc".to_vec(),
2672            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES_X),
2673            fourcc: Some(FOURCC_VVC),
2674
2675            multitrack: None,
2676        };
2677        let payload = build_video(&tag);
2678        // IsExHeader=1 | FrameType=2 | PacketType=3 = 0xA3.
2679        assert_eq!(payload[0], 0xA3);
2680        assert_eq!(&payload[1..5], b"vvc1");
2681        assert_eq!(&payload[5..], b"\x00\x00\x00\x03vvc");
2682        let back = parse_video(&payload).unwrap();
2683        assert_eq!(back, tag);
2684    }
2685
2686    #[test]
2687    fn ex_video_tag_avc_fourcc_coded_frames_truncated_si24_errors() {
2688        // §"ExVideoTagBody" guarantees the SI24 follows the
2689        // FourCC for AVC + CodedFrames. A wire stream missing
2690        // those three bytes must fail in a controlled manner
2691        // per "the system MUST fail in a controlled and
2692        // predictable manner".
2693        let truncated = [
2694            0xA1, // IsExHeader=1 | FrameType=2 | PacketType=1
2695            b'a', b'v', b'c', b'1', // FourCC
2696            0xFF, 0xFF, // only two of three SI24 bytes
2697        ];
2698        assert!(parse_video(&truncated).is_err());
2699    }
2700
2701    #[test]
2702    fn ex_video_tag_v2_fourccs_are_distinct_from_v1_set() {
2703        // Wire-byte distinctness check: each v2 FourCC must
2704        // round-trip independently of the v1 set so a multiplexer
2705        // can't accidentally alias one to another.
2706        for &fcc in &[FOURCC_VP8, FOURCC_AVC, FOURCC_VVC] {
2707            let tag = VideoTag {
2708                mod_ex: Vec::new(),
2709                frame_type: VIDEO_FRAME_KEYFRAME,
2710                codec_id: 0,
2711                avc_packet_type: None,
2712                composition_time: 0,
2713                body: vec![0xDE, 0xAD, 0xBE, 0xEF],
2714                ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_END),
2715                fourcc: Some(fcc),
2716
2717                multitrack: None,
2718            };
2719            let payload = build_video(&tag);
2720            // SequenceEnd: ExHeader byte + FourCC, no body
2721            // expected, but we ship a stub for the round-trip
2722            // check.
2723            assert_eq!(&payload[1..5], &fcc[..]);
2724            let back = parse_video(&payload).unwrap();
2725            assert_eq!(back, tag);
2726            assert!(!matches!(fcc, FOURCC_AV1 | FOURCC_VP9 | FOURCC_HEVC));
2727        }
2728    }
2729
2730    #[test]
2731    fn audio_tag_aac_sequence_header_roundtrip() {
2732        let tag = AudioTag {
2733            mod_ex: Vec::new(),
2734            sound_format: AUDIO_FORMAT_AAC,
2735            sound_rate: 3,
2736            sound_size_16bit: true,
2737            stereo: true,
2738            aac_packet_type: Some(AAC_PACKET_TYPE_SEQUENCE_HEADER),
2739            body: vec![0x12, 0x10], // LC-AAC 44.1k stereo AudioSpecificConfig
2740            ex_packet_type: None,
2741            audio_fourcc: None,
2742
2743            multitrack: None,
2744        };
2745        let payload = build_audio(&tag);
2746        assert_eq!(payload[0], 0xAF); // AAC + rate 3 + 16-bit + stereo
2747        assert_eq!(payload[1], 0); // seq header
2748        let back = parse_audio(&payload).unwrap();
2749        assert_eq!(back, tag);
2750        assert!(back.is_aac_sequence_header());
2751        assert!(!back.is_enhanced());
2752    }
2753
2754    // ------- Enhanced RTMP v2 (Veovera 2026) round-trips -------
2755
2756    #[test]
2757    fn ex_audio_tag_opus_sequence_start_roundtrip() {
2758        // SequenceStart for Opus: body is the Opus ID header (a
2759        // valid one starts with the 8-byte "OpusHead" magic per
2760        // RFC 7845 §5.1; we use a tiny stub here since the
2761        // framing layer doesn't validate codec-payload internals).
2762        let tag = AudioTag {
2763            mod_ex: Vec::new(),
2764            sound_format: AUDIO_FORMAT_EX_HEADER,
2765            sound_rate: 0,
2766            sound_size_16bit: false,
2767            stereo: false,
2768            aac_packet_type: None,
2769            ex_packet_type: Some(AUDIO_PACKET_TYPE_SEQUENCE_START),
2770            audio_fourcc: Some(FOURCC_OPUS),
2771            body: b"OpusHead\x01\x02".to_vec(),
2772
2773            multitrack: None,
2774        };
2775        let payload = build_audio(&tag);
2776        // Header byte: ExHeader(9) << 4 | PacketType(0) = 0x90.
2777        assert_eq!(payload[0], 0x90);
2778        assert_eq!(&payload[1..5], b"Opus");
2779        assert_eq!(&payload[5..], b"OpusHead\x01\x02");
2780
2781        let back = parse_audio(&payload).unwrap();
2782        assert_eq!(back, tag);
2783        assert!(back.is_ex_sequence_header());
2784        assert!(back.is_enhanced());
2785        // Legacy bit-field is suppressed in Enhanced mode.
2786        assert_eq!(back.sound_rate, 0);
2787        assert!(!back.sound_size_16bit);
2788        assert!(!back.stereo);
2789    }
2790
2791    #[test]
2792    fn ex_audio_tag_opus_coded_frames_carries_self_delimited_packets() {
2793        // Enhanced RTMP v2: "Body contains Opus packets [...] The
2794        // first (N - 1) Opus packets, if any, are packed one after
2795        // another using the self-delimiting framing from Appendix
2796        // B of [RFC6716]. The remaining Opus packet is packed at
2797        // the end of the Ogg packet using the regular,
2798        // undelimited framing from Section 3 of [RFC6716]." The
2799        // framing layer treats the body as opaque bytes.
2800        let tag = AudioTag {
2801            mod_ex: Vec::new(),
2802            sound_format: AUDIO_FORMAT_EX_HEADER,
2803            sound_rate: 0,
2804            sound_size_16bit: false,
2805            stereo: false,
2806            aac_packet_type: None,
2807            ex_packet_type: Some(AUDIO_PACKET_TYPE_CODED_FRAMES),
2808            audio_fourcc: Some(FOURCC_OPUS),
2809            body: b"opus-frame-bytes".to_vec(),
2810
2811            multitrack: None,
2812        };
2813        let payload = build_audio(&tag);
2814        // ExHeader=9 | CodedFrames=1 = 0x91.
2815        assert_eq!(payload[0], 0x91);
2816        assert_eq!(&payload[1..5], b"Opus");
2817        assert_eq!(&payload[5..], b"opus-frame-bytes");
2818
2819        let back = parse_audio(&payload).unwrap();
2820        assert_eq!(back, tag);
2821    }
2822
2823    #[test]
2824    fn ex_audio_tag_flac_sequence_start_roundtrip() {
2825        // FLAC SequenceStart body: "The bytes 0x66 0x4C 0x61 0x43
2826        // ('fLaC' in ASCII) signature // Followed by a metadata
2827        // block (called the STREAMINFO block) as described in
2828        // section 7 of the FLAC specification." The framing layer
2829        // treats this as opaque.
2830        let tag = AudioTag {
2831            mod_ex: Vec::new(),
2832            sound_format: AUDIO_FORMAT_EX_HEADER,
2833            sound_rate: 0,
2834            sound_size_16bit: false,
2835            stereo: false,
2836            aac_packet_type: None,
2837            ex_packet_type: Some(AUDIO_PACKET_TYPE_SEQUENCE_START),
2838            audio_fourcc: Some(FOURCC_FLAC),
2839            body: b"fLaC\x80\x00\x00\x22streaminfo".to_vec(),
2840
2841            multitrack: None,
2842        };
2843        let payload = build_audio(&tag);
2844        assert_eq!(payload[0], 0x90);
2845        assert_eq!(&payload[1..5], b"fLaC");
2846        assert_eq!(&payload[5..], b"fLaC\x80\x00\x00\x22streaminfo");
2847
2848        let back = parse_audio(&payload).unwrap();
2849        assert_eq!(back, tag);
2850        assert!(back.is_ex_sequence_header());
2851    }
2852
2853    #[test]
2854    fn ex_audio_tag_ac3_coded_frames_roundtrip() {
2855        // AC-3: "Body contains audio data as defined by the
2856        // bitstream syntax in the ATSC standard for Digital Audio
2857        // Compression (AC-3, E-AC-3)." No SequenceStart shape is
2858        // defined for AC-3 in v2 — only CodedFrames carries data.
2859        let tag = AudioTag {
2860            mod_ex: Vec::new(),
2861            sound_format: AUDIO_FORMAT_EX_HEADER,
2862            sound_rate: 0,
2863            sound_size_16bit: false,
2864            stereo: false,
2865            aac_packet_type: None,
2866            ex_packet_type: Some(AUDIO_PACKET_TYPE_CODED_FRAMES),
2867            audio_fourcc: Some(FOURCC_AC3),
2868            body: vec![0x0B, 0x77, 0x12, 0x34, 0x56, 0x78], // AC-3 sync + stub
2869
2870            multitrack: None,
2871        };
2872        let payload = build_audio(&tag);
2873        assert_eq!(payload[0], 0x91);
2874        assert_eq!(&payload[1..5], b"ac-3");
2875        assert_eq!(&payload[5..], &[0x0B, 0x77, 0x12, 0x34, 0x56, 0x78]);
2876
2877        let back = parse_audio(&payload).unwrap();
2878        assert_eq!(back, tag);
2879    }
2880
2881    #[test]
2882    fn ex_audio_tag_eac3_coded_frames_roundtrip() {
2883        let tag = AudioTag {
2884            mod_ex: Vec::new(),
2885            sound_format: AUDIO_FORMAT_EX_HEADER,
2886            sound_rate: 0,
2887            sound_size_16bit: false,
2888            stereo: false,
2889            aac_packet_type: None,
2890            ex_packet_type: Some(AUDIO_PACKET_TYPE_CODED_FRAMES),
2891            audio_fourcc: Some(FOURCC_EAC3),
2892            body: vec![0x0B, 0x77, 0xAB, 0xCD],
2893
2894            multitrack: None,
2895        };
2896        let payload = build_audio(&tag);
2897        assert_eq!(payload[0], 0x91);
2898        assert_eq!(&payload[1..5], b"ec-3");
2899        let back = parse_audio(&payload).unwrap();
2900        assert_eq!(back, tag);
2901    }
2902
2903    #[test]
2904    fn ex_audio_tag_mp3_coded_frames_roundtrip() {
2905        // MP3 (added FOURCC signalling): "An Mp3 audio stream is
2906        // built up from a succession of smaller parts called
2907        // frames. Each frame is a data block with its own header
2908        // and audio information."
2909        let tag = AudioTag {
2910            mod_ex: Vec::new(),
2911            sound_format: AUDIO_FORMAT_EX_HEADER,
2912            sound_rate: 0,
2913            sound_size_16bit: false,
2914            stereo: false,
2915            aac_packet_type: None,
2916            ex_packet_type: Some(AUDIO_PACKET_TYPE_CODED_FRAMES),
2917            audio_fourcc: Some(FOURCC_MP3),
2918            body: vec![0xFF, 0xFB, 0x90, 0x00], // MP3 sync header stub
2919
2920            multitrack: None,
2921        };
2922        let payload = build_audio(&tag);
2923        assert_eq!(payload[0], 0x91);
2924        assert_eq!(&payload[1..5], b".mp3");
2925        let back = parse_audio(&payload).unwrap();
2926        assert_eq!(back, tag);
2927    }
2928
2929    #[test]
2930    fn ex_audio_tag_aac_fourcc_sequence_start() {
2931        // AAC with FourCC signalling is the v2 way to carry AAC
2932        // alongside the other FourCC codecs. Body for
2933        // SequenceStart is AudioSpecificConfig per ISO/IEC
2934        // 14496-3 — same shape as the legacy AacSequenceHeader,
2935        // but reached via FourCC instead of the legacy
2936        // SoundFormat=10 / AACPacketType=0 path.
2937        let tag = AudioTag {
2938            mod_ex: Vec::new(),
2939            sound_format: AUDIO_FORMAT_EX_HEADER,
2940            sound_rate: 0,
2941            sound_size_16bit: false,
2942            stereo: false,
2943            aac_packet_type: None,
2944            ex_packet_type: Some(AUDIO_PACKET_TYPE_SEQUENCE_START),
2945            audio_fourcc: Some(FOURCC_AAC),
2946            body: vec![0x12, 0x10], // LC-AAC 44.1k stereo ASC
2947
2948            multitrack: None,
2949        };
2950        let payload = build_audio(&tag);
2951        assert_eq!(payload[0], 0x90);
2952        assert_eq!(&payload[1..5], b"mp4a");
2953        assert_eq!(&payload[5..], &[0x12, 0x10]);
2954
2955        let back = parse_audio(&payload).unwrap();
2956        assert_eq!(back, tag);
2957        assert!(back.is_ex_sequence_header());
2958        // The legacy `is_aac_sequence_header` predicate stays
2959        // false because the legacy SoundFormat/AacPacketType
2960        // discriminator isn't on the wire.
2961        assert!(!back.is_aac_sequence_header());
2962    }
2963
2964    #[test]
2965    fn ex_audio_tag_sequence_end_empty_body() {
2966        let tag = AudioTag {
2967            mod_ex: Vec::new(),
2968            sound_format: AUDIO_FORMAT_EX_HEADER,
2969            sound_rate: 0,
2970            sound_size_16bit: false,
2971            stereo: false,
2972            aac_packet_type: None,
2973            ex_packet_type: Some(AUDIO_PACKET_TYPE_SEQUENCE_END),
2974            audio_fourcc: Some(FOURCC_OPUS),
2975            body: vec![],
2976
2977            multitrack: None,
2978        };
2979        let payload = build_audio(&tag);
2980        // ExHeader=9 | SequenceEnd=2 = 0x92.
2981        assert_eq!(payload[0], 0x92);
2982        assert_eq!(&payload[1..5], b"Opus");
2983        assert_eq!(payload.len(), 5);
2984
2985        let back = parse_audio(&payload).unwrap();
2986        assert_eq!(back, tag);
2987    }
2988
2989    #[test]
2990    fn ex_audio_tag_truncated_fourcc_errors() {
2991        // ExHeader byte alone is not enough — the FourCC follows.
2992        // Per spec, the parser MUST fail in a controlled manner.
2993        let truncated = [0x90, b'O', b'p', b'u']; // missing one byte of FourCC
2994        assert!(parse_audio(&truncated).is_err());
2995        let just_header = [0x90];
2996        assert!(parse_audio(&just_header).is_err());
2997    }
2998
2999    #[test]
3000    fn legacy_audio_high_nibble_never_collides_with_ex_header() {
3001        // Sanity-check the v2 backwards-compatibility claim:
3002        // every legacy SoundFormat value lies outside
3003        // {9 = ExHeader}, so a parser branching on
3004        // `sound_format == ExHeader` never mis-detects a legacy
3005        // tag as Enhanced.
3006        for sf in [
3007            AUDIO_FORMAT_PCM_LE,
3008            AUDIO_FORMAT_ADPCM,
3009            AUDIO_FORMAT_MP3,
3010            AUDIO_FORMAT_PCM_LE_8BIT,
3011            AUDIO_FORMAT_NELLYMOSER_16K_MONO,
3012            AUDIO_FORMAT_NELLYMOSER_8K_MONO,
3013            AUDIO_FORMAT_NELLYMOSER,
3014            AUDIO_FORMAT_G711_ALAW,
3015            AUDIO_FORMAT_G711_MULAW,
3016            AUDIO_FORMAT_AAC,
3017            AUDIO_FORMAT_SPEEX,
3018        ] {
3019            assert_ne!(sf, AUDIO_FORMAT_EX_HEADER, "sf={sf}");
3020        }
3021    }
3022
3023    // ------- Enhanced RTMP v2 ModEx prelude (Veovera 2026) -------
3024
3025    #[test]
3026    fn ex_video_mod_ex_timestamp_offset_nano_roundtrip() {
3027        // A single TimestampOffsetNano ModEx entry preceding a VVC
3028        // CodedFrames packet. Header byte low nibble = ModEx(7);
3029        // chain carries the real CodedFrames(1) packet type in its
3030        // terminating nibble; SI24 CTS then follows the FourCC.
3031        let nano = 999_999u32; // spec max sub-millisecond offset.
3032        let tag = VideoTag {
3033            frame_type: VIDEO_FRAME_INTER,
3034            codec_id: 0,
3035            avc_packet_type: None,
3036            composition_time: 7,
3037            body: b"\x00\x00\x00\x05nalu!".to_vec(),
3038            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES),
3039            fourcc: Some(FOURCC_VVC),
3040            mod_ex: vec![ModEx::timestamp_offset_nano_entry(nano)],
3041
3042            multitrack: None,
3043        };
3044        let payload = build_video(&tag);
3045        // byte 0 = IsExHeader|FrameType(2)|ModEx(7) = 0b1010_0111 = 0xA7.
3046        assert_eq!(payload[0], 0xA7);
3047        // modExDataSize = UI8 + 1 → data is 3 bytes, so UI8 = 2.
3048        assert_eq!(payload[1], 2);
3049        // modExData = bytesToUI24(999_999) = 0x0F_423F.
3050        assert_eq!(&payload[2..5], &[0x0F, 0x42, 0x3F]);
3051        // nibble byte: modExType(0, high) | packetType CodedFrames(1, low).
3052        assert_eq!(payload[5], 0x01);
3053        // FourCC then SI24 CTS then body.
3054        assert_eq!(&payload[6..10], b"vvc1");
3055        assert_eq!(&payload[10..13], &[0x00, 0x00, 0x07]);
3056        assert_eq!(&payload[13..], b"\x00\x00\x00\x05nalu!");
3057
3058        let back = parse_video(&payload).unwrap();
3059        assert_eq!(back, tag);
3060        assert_eq!(back.timestamp_offset_nano(), nano);
3061        assert_eq!(back.mod_ex[0].timestamp_offset_nano(), Some(nano));
3062    }
3063
3064    #[test]
3065    fn ex_video_mod_ex_chain_multiple_entries_roundtrip() {
3066        // Two chained ModEx entries before an AV1 SequenceStart.
3067        // The first entry's terminating nibble is ModEx again; the
3068        // second's is the real SequenceStart(0).
3069        let tag = VideoTag {
3070            frame_type: VIDEO_FRAME_KEYFRAME,
3071            codec_id: 0,
3072            avc_packet_type: None,
3073            composition_time: 0,
3074            body: b"av1cfg".to_vec(),
3075            ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_START),
3076            fourcc: Some(FOURCC_AV1),
3077            mod_ex: vec![
3078                ModEx::timestamp_offset_nano_entry(500_000),
3079                ModEx {
3080                    mod_ex_type: 3, // a future/unknown subtype: preserved verbatim
3081                    data: vec![0xAA, 0xBB],
3082                },
3083            ],
3084
3085            multitrack: None,
3086        };
3087        let payload = build_video(&tag);
3088        // First entry: size byte (2 → 3-byte data), data, nibble
3089        // (ModExType 0 | ModEx 7) = 0x07.
3090        assert_eq!(payload[1], 2);
3091        assert_eq!(&payload[2..5], &[0x07, 0xA1, 0x20]); // bytesToUI24(500_000)
3092        assert_eq!(payload[5], 0x07);
3093        // Second entry: size byte (1 → 2-byte data), data, nibble
3094        // (ModExType 3 | SequenceStart 0) = 0x30.
3095        assert_eq!(payload[6], 1);
3096        assert_eq!(&payload[7..9], &[0xAA, 0xBB]);
3097        assert_eq!(payload[9], 0x30);
3098        assert_eq!(&payload[10..14], b"av01");
3099        assert_eq!(&payload[14..], b"av1cfg");
3100
3101        let back = parse_video(&payload).unwrap();
3102        assert_eq!(back, tag);
3103        // Only the TimestampOffsetNano entry contributes to the sum.
3104        assert_eq!(back.timestamp_offset_nano(), 500_000);
3105    }
3106
3107    #[test]
3108    fn ex_video_mod_ex_ui16_size_escape_roundtrip() {
3109        // modExData longer than 255 bytes uses the UI16 escape:
3110        // the 8-bit size byte is 0xFF (== 256 sentinel) followed by
3111        // a UI16 of (len - 1).
3112        let big = vec![0x5A; 300];
3113        let tag = VideoTag {
3114            frame_type: VIDEO_FRAME_INTER,
3115            codec_id: 0,
3116            avc_packet_type: None,
3117            composition_time: 0,
3118            body: b"hevc-frame".to_vec(),
3119            ex_packet_type: Some(EX_PACKET_TYPE_CODED_FRAMES_X),
3120            fourcc: Some(FOURCC_HEVC),
3121            mod_ex: vec![ModEx {
3122                mod_ex_type: MOD_EX_TYPE_TIMESTAMP_OFFSET_NANO,
3123                data: big.clone(),
3124            }],
3125
3126            multitrack: None,
3127        };
3128        let payload = build_video(&tag);
3129        // size: 0xFF sentinel + UI16(len-1 = 299 = 0x012B).
3130        assert_eq!(payload[1], 0xFF);
3131        assert_eq!(&payload[2..4], &[0x01, 0x2B]);
3132        assert_eq!(&payload[4..4 + 300], &big[..]);
3133        // nibble after data: ModExType 0 | CodedFramesX(3).
3134        assert_eq!(payload[4 + 300], 0x03);
3135
3136        let back = parse_video(&payload).unwrap();
3137        assert_eq!(back, tag);
3138        assert_eq!(back.mod_ex[0].data.len(), 300);
3139    }
3140
3141    #[test]
3142    fn ex_audio_mod_ex_timestamp_offset_nano_roundtrip() {
3143        // ModEx prelude on an Opus CodedFrames audio tag.
3144        let nano = 250_000u32;
3145        let tag = AudioTag {
3146            sound_format: AUDIO_FORMAT_EX_HEADER,
3147            sound_rate: 0,
3148            sound_size_16bit: false,
3149            stereo: false,
3150            aac_packet_type: None,
3151            ex_packet_type: Some(AUDIO_PACKET_TYPE_CODED_FRAMES),
3152            audio_fourcc: Some(FOURCC_OPUS),
3153            body: b"opus-pkt".to_vec(),
3154            mod_ex: vec![ModEx::timestamp_offset_nano_entry(nano)],
3155
3156            multitrack: None,
3157        };
3158        let payload = build_audio(&tag);
3159        // byte 0 = ExHeader(9) << 4 | ModEx(7) = 0x97.
3160        assert_eq!(payload[0], 0x97);
3161        assert_eq!(payload[1], 2); // 3-byte data → UI8 = 2.
3162        assert_eq!(&payload[2..5], &[0x03, 0xD0, 0x90]); // bytesToUI24(250_000)
3163                                                         // nibble: ModExType 0 | CodedFrames(1).
3164        assert_eq!(payload[5], 0x01);
3165        assert_eq!(&payload[6..10], b"Opus");
3166        assert_eq!(&payload[10..], b"opus-pkt");
3167
3168        let back = parse_audio(&payload).unwrap();
3169        assert_eq!(back, tag);
3170        assert_eq!(back.timestamp_offset_nano(), nano);
3171    }
3172
3173    #[test]
3174    fn mod_ex_accessor_rejects_wrong_type_and_short_data() {
3175        // timestamp_offset_nano() only resolves for the
3176        // TimestampOffsetNano subtype with >= 3 data bytes.
3177        let wrong_type = ModEx {
3178            mod_ex_type: 1,
3179            data: vec![0, 0, 0],
3180        };
3181        assert_eq!(wrong_type.timestamp_offset_nano(), None);
3182        let too_short = ModEx {
3183            mod_ex_type: MOD_EX_TYPE_TIMESTAMP_OFFSET_NANO,
3184            data: vec![0x00, 0x01],
3185        };
3186        assert_eq!(too_short.timestamp_offset_nano(), None);
3187    }
3188
3189    #[test]
3190    fn ex_video_mod_ex_truncated_chain_fails_controlled() {
3191        // Header announces ModEx but the chain is cut short — the
3192        // parser must surface a controlled error, not panic / index
3193        // out of bounds.
3194        // byte0 = IsExHeader|FrameType1|ModEx7 = 0x97 then a size
3195        // byte claiming 3 data bytes but no data following.
3196        let truncated = [0x97u8, 0x02];
3197        assert!(parse_video(&truncated).is_err());
3198        // Size + data present but missing the modExType/packetType nibble.
3199        let no_nibble = [0x97u8, 0x02, 0x00, 0x00, 0x00];
3200        assert!(parse_video(&no_nibble).is_err());
3201        // Chain terminates with a real packet type but no FourCC.
3202        let no_fourcc = [0x97u8, 0x02, 0x00, 0x00, 0x00, 0x01];
3203        assert!(parse_video(&no_fourcc).is_err());
3204    }
3205
3206    #[test]
3207    fn ex_audio_mod_ex_truncated_chain_fails_controlled() {
3208        let truncated = [0x97u8, 0x02];
3209        assert!(parse_audio(&truncated).is_err());
3210        let no_fourcc = [0x97u8, 0x02, 0x00, 0x00, 0x00, 0x01];
3211        assert!(parse_audio(&no_fourcc).is_err());
3212    }
3213
3214    #[test]
3215    fn ex_video_without_mod_ex_emits_no_prelude() {
3216        // Empty mod_ex must produce byte-identical output to the
3217        // pre-ModEx encoding (no spurious prelude bytes).
3218        let tag = VideoTag {
3219            frame_type: VIDEO_FRAME_KEYFRAME,
3220            codec_id: 0,
3221            avc_packet_type: None,
3222            composition_time: 0,
3223            body: b"\x01cfg".to_vec(),
3224            ex_packet_type: Some(EX_PACKET_TYPE_SEQUENCE_START),
3225            fourcc: Some(FOURCC_HEVC),
3226            mod_ex: Vec::new(),
3227
3228            multitrack: None,
3229        };
3230        let payload = build_video(&tag);
3231        // Header low nibble is the real packet type, not ModEx.
3232        assert_eq!(payload[0] & 0x0F, EX_PACKET_TYPE_SEQUENCE_START);
3233        assert_eq!(&payload[1..5], b"hvc1");
3234        assert_eq!(&payload[5..], b"\x01cfg");
3235        assert_eq!(parse_video(&payload).unwrap(), tag);
3236    }
3237
3238    // ------- Enhanced RTMP v2 MultichannelConfig (Veovera 2026) -------
3239
3240    #[test]
3241    fn multichannel_config_unspecified_roundtrip() {
3242        // 2-byte body: order=Unspecified(0), channelCount=2.
3243        let cfg = MultichannelConfig {
3244            order: MultichannelConfigOrder::Unspecified,
3245            channel_count: 2,
3246            extra: Vec::new(),
3247        };
3248        let bytes = cfg.encode();
3249        assert_eq!(bytes, [0x00, 0x02]);
3250        let back = MultichannelConfig::parse(&bytes).unwrap();
3251        assert_eq!(back, cfg);
3252    }
3253
3254    #[test]
3255    fn multichannel_config_native_5_1_layout() {
3256        // 5.1 surround = FL + FR + FC + LFE1 + BL + BR
3257        // = 0x01 | 0x02 | 0x04 | 0x08 | 0x10 | 0x20 = 0x3F.
3258        let mask = audio_channel_mask::FRONT_LEFT
3259            | audio_channel_mask::FRONT_RIGHT
3260            | audio_channel_mask::FRONT_CENTER
3261            | audio_channel_mask::LOW_FREQUENCY1
3262            | audio_channel_mask::BACK_LEFT
3263            | audio_channel_mask::BACK_RIGHT;
3264        assert_eq!(mask, 0x0000_003F);
3265        let cfg = MultichannelConfig {
3266            order: MultichannelConfigOrder::Native { flags: mask },
3267            channel_count: 6,
3268            extra: Vec::new(),
3269        };
3270        let bytes = cfg.encode();
3271        // order(1) | channelCount(6) | UI32-BE mask
3272        assert_eq!(bytes, [0x01, 0x06, 0x00, 0x00, 0x00, 0x3F]);
3273        let back = MultichannelConfig::parse(&bytes).unwrap();
3274        assert_eq!(back, cfg);
3275        if let MultichannelConfigOrder::Native { flags } = back.order {
3276            assert_eq!(flags & audio_channel_mask::LOW_FREQUENCY1, 0x08);
3277            assert_eq!(flags & audio_channel_mask::TOP_CENTER, 0); // not present
3278        } else {
3279            panic!("expected Native order");
3280        }
3281    }
3282
3283    #[test]
3284    fn multichannel_config_custom_mapping_roundtrip() {
3285        // Stereo with explicit speaker map: ch0=FL, ch1=FR.
3286        let cfg = MultichannelConfig {
3287            order: MultichannelConfigOrder::Custom {
3288                mapping: vec![audio_channel::FRONT_LEFT, audio_channel::FRONT_RIGHT],
3289            },
3290            channel_count: 2,
3291            extra: Vec::new(),
3292        };
3293        let bytes = cfg.encode();
3294        // order(2) | channelCount(2) | mapping[2]
3295        assert_eq!(bytes, [0x02, 0x02, 0x00, 0x01]);
3296        let back = MultichannelConfig::parse(&bytes).unwrap();
3297        assert_eq!(back, cfg);
3298    }
3299
3300    #[test]
3301    fn multichannel_config_custom_22_2_layout() {
3302        // 22.2 surround needs all 24 spec-defined channel positions
3303        // including the SMPTE ST 2036-2 extras. Exercises every
3304        // `audio_channel::*` constant on the wire.
3305        let mapping: Vec<u8> = (0..24).collect();
3306        let cfg = MultichannelConfig {
3307            order: MultichannelConfigOrder::Custom {
3308                mapping: mapping.clone(),
3309            },
3310            channel_count: 24,
3311            extra: Vec::new(),
3312        };
3313        let bytes = cfg.encode();
3314        assert_eq!(bytes.len(), 2 + 24);
3315        assert_eq!(bytes[0], AUDIO_CHANNEL_ORDER_CUSTOM);
3316        assert_eq!(bytes[1], 24);
3317        assert_eq!(&bytes[2..], mapping.as_slice());
3318        let back = MultichannelConfig::parse(&bytes).unwrap();
3319        assert_eq!(back, cfg);
3320    }
3321
3322    #[test]
3323    fn multichannel_config_custom_with_unused_unknown_sentinels() {
3324        // The spec carves out 0xFE / 0xFF for empty / unknown channels;
3325        // round-trip those as well so callers can encode "skip this
3326        // channel" / "unknown speaker" without losing them.
3327        let cfg = MultichannelConfig {
3328            order: MultichannelConfigOrder::Custom {
3329                mapping: vec![
3330                    audio_channel::FRONT_LEFT,
3331                    audio_channel::FRONT_RIGHT,
3332                    audio_channel::UNUSED,
3333                    audio_channel::UNKNOWN,
3334                ],
3335            },
3336            channel_count: 4,
3337            extra: Vec::new(),
3338        };
3339        let bytes = cfg.encode();
3340        assert_eq!(bytes, [0x02, 0x04, 0x00, 0x01, 0xFE, 0xFF]);
3341        assert_eq!(MultichannelConfig::parse(&bytes).unwrap(), cfg);
3342    }
3343
3344    #[test]
3345    fn multichannel_config_truncated_errors() {
3346        // Empty body: needs at least order + channelCount.
3347        assert!(MultichannelConfig::parse(&[]).is_err());
3348        assert!(MultichannelConfig::parse(&[0x00]).is_err());
3349        // Native missing the UI32 flags.
3350        assert!(MultichannelConfig::parse(&[0x01, 0x06, 0x00]).is_err());
3351        // Custom missing one mapping byte.
3352        assert!(MultichannelConfig::parse(&[0x02, 0x03, 0x00, 0x01]).is_err());
3353        // Unspecified with stray trailing bytes — caller likely
3354        // misframed it; refuse to silently swallow them.
3355        assert!(MultichannelConfig::parse(&[0x00, 0x02, 0xff]).is_err());
3356    }
3357
3358    #[test]
3359    fn multichannel_config_reserved_order_preserves_extra_bytes() {
3360        // A reserved order value (anything outside 0..=2 for now) is
3361        // preserved verbatim so the surrounding tag can be forwarded
3362        // unchanged. The trailing bytes flow through `extra`.
3363        let body = vec![0x05, 0x04, 0xAA, 0xBB, 0xCC];
3364        let cfg = MultichannelConfig::parse(&body).unwrap();
3365        assert_eq!(cfg.order, MultichannelConfigOrder::Reserved(0x05));
3366        assert_eq!(cfg.channel_count, 4);
3367        assert_eq!(cfg.extra, vec![0xAA, 0xBB, 0xCC]);
3368        // Round-trip preserves the bytes.
3369        assert_eq!(cfg.encode(), body);
3370    }
3371
3372    #[test]
3373    fn audio_tag_multichannel_config_full_roundtrip() {
3374        // End-to-end: build an Enhanced-RTMP audio tag carrying a
3375        // MultichannelConfig body for the Opus FourCC, drive it
3376        // through build_audio + parse_audio, then re-lift to the
3377        // strongly-typed view.
3378        let cfg = MultichannelConfig {
3379            order: MultichannelConfigOrder::Native {
3380                flags: audio_channel_mask::FRONT_LEFT
3381                    | audio_channel_mask::FRONT_RIGHT
3382                    | audio_channel_mask::FRONT_CENTER,
3383            },
3384            channel_count: 3,
3385            extra: Vec::new(),
3386        };
3387        let tag = AudioTag::multichannel_config_tag(FOURCC_OPUS, &cfg);
3388        assert!(tag.is_multichannel_config());
3389        assert_eq!(tag.audio_fourcc, Some(FOURCC_OPUS));
3390        assert_eq!(
3391            tag.ex_packet_type,
3392            Some(AUDIO_PACKET_TYPE_MULTICHANNEL_CONFIG)
3393        );
3394        // Wire shape: header byte (ExHeader nibble + MultichannelConfig
3395        // nibble) + 4-byte FourCC + 6-byte MultichannelConfig body.
3396        let wire = build_audio(&tag);
3397        assert_eq!(wire[0], (AUDIO_FORMAT_EX_HEADER << 4) | 0x04);
3398        assert_eq!(&wire[1..5], b"Opus");
3399        assert_eq!(wire.len(), 1 + 4 + 6);
3400        // Round-trip back.
3401        let back = parse_audio(&wire).unwrap();
3402        assert_eq!(back, tag);
3403        let cfg_back = back.multichannel_config().unwrap().unwrap();
3404        assert_eq!(cfg_back, cfg);
3405    }
3406
3407    #[test]
3408    fn audio_tag_multichannel_config_accessor_returns_none_for_other_packet_types() {
3409        // A SequenceStart tag is not a MultichannelConfig — the helper
3410        // returns None rather than mis-parsing the sequence header
3411        // bytes as a channel layout.
3412        let tag = AudioTag {
3413            sound_format: AUDIO_FORMAT_EX_HEADER,
3414            sound_rate: 0,
3415            sound_size_16bit: false,
3416            stereo: false,
3417            aac_packet_type: None,
3418            ex_packet_type: Some(AUDIO_PACKET_TYPE_SEQUENCE_START),
3419            audio_fourcc: Some(FOURCC_OPUS),
3420            body: vec![b'O', b'p', b'u', b's', b'H', b'e', b'a', b'd'],
3421            mod_ex: Vec::new(),
3422
3423            multitrack: None,
3424        };
3425        assert!(!tag.is_multichannel_config());
3426        assert!(tag.multichannel_config().unwrap().is_none());
3427    }
3428
3429    #[test]
3430    fn multichannel_config_disjoint_from_legacy_audio() {
3431        // A legacy (non-Enhanced) audio tag never lifts as a
3432        // MultichannelConfig — the accessor returns None even if the
3433        // legacy body happens to start with a 0/1/2 byte the
3434        // MultichannelConfig parser would otherwise accept.
3435        let tag = AudioTag {
3436            sound_format: AUDIO_FORMAT_AAC,
3437            sound_rate: 3,
3438            sound_size_16bit: true,
3439            stereo: true,
3440            aac_packet_type: Some(AAC_PACKET_TYPE_RAW),
3441            ex_packet_type: None,
3442            audio_fourcc: None,
3443            body: vec![0x01, 0x06, 0x00, 0x00, 0x00, 0x3F],
3444            mod_ex: Vec::new(),
3445
3446            multitrack: None,
3447        };
3448        assert!(!tag.is_multichannel_config());
3449        assert!(tag.multichannel_config().unwrap().is_none());
3450    }
3451
3452    #[test]
3453    fn audio_channel_mask_22_2_bit_assignments() {
3454        // The 24 bit positions in audio_channel_mask must line up
3455        // 1:1 with the AudioChannel UI8 indices when bits are read
3456        // as `1 << channel_index`. Spec table cross-check.
3457        let pairs = [
3458            (audio_channel::FRONT_LEFT, audio_channel_mask::FRONT_LEFT),
3459            (audio_channel::FRONT_RIGHT, audio_channel_mask::FRONT_RIGHT),
3460            (
3461                audio_channel::FRONT_CENTER,
3462                audio_channel_mask::FRONT_CENTER,
3463            ),
3464            (
3465                audio_channel::LOW_FREQUENCY1,
3466                audio_channel_mask::LOW_FREQUENCY1,
3467            ),
3468            (audio_channel::BACK_LEFT, audio_channel_mask::BACK_LEFT),
3469            (audio_channel::BACK_RIGHT, audio_channel_mask::BACK_RIGHT),
3470            (
3471                audio_channel::FRONT_LEFT_CENTER,
3472                audio_channel_mask::FRONT_LEFT_CENTER,
3473            ),
3474            (
3475                audio_channel::FRONT_RIGHT_CENTER,
3476                audio_channel_mask::FRONT_RIGHT_CENTER,
3477            ),
3478            (audio_channel::BACK_CENTER, audio_channel_mask::BACK_CENTER),
3479            (audio_channel::SIDE_LEFT, audio_channel_mask::SIDE_LEFT),
3480            (audio_channel::SIDE_RIGHT, audio_channel_mask::SIDE_RIGHT),
3481            (audio_channel::TOP_CENTER, audio_channel_mask::TOP_CENTER),
3482            (
3483                audio_channel::TOP_FRONT_LEFT,
3484                audio_channel_mask::TOP_FRONT_LEFT,
3485            ),
3486            (
3487                audio_channel::TOP_FRONT_CENTER,
3488                audio_channel_mask::TOP_FRONT_CENTER,
3489            ),
3490            (
3491                audio_channel::TOP_FRONT_RIGHT,
3492                audio_channel_mask::TOP_FRONT_RIGHT,
3493            ),
3494            (
3495                audio_channel::TOP_BACK_LEFT,
3496                audio_channel_mask::TOP_BACK_LEFT,
3497            ),
3498            (
3499                audio_channel::TOP_BACK_CENTER,
3500                audio_channel_mask::TOP_BACK_CENTER,
3501            ),
3502            (
3503                audio_channel::TOP_BACK_RIGHT,
3504                audio_channel_mask::TOP_BACK_RIGHT,
3505            ),
3506            (
3507                audio_channel::LOW_FREQUENCY2,
3508                audio_channel_mask::LOW_FREQUENCY2,
3509            ),
3510            (
3511                audio_channel::TOP_SIDE_LEFT,
3512                audio_channel_mask::TOP_SIDE_LEFT,
3513            ),
3514            (
3515                audio_channel::TOP_SIDE_RIGHT,
3516                audio_channel_mask::TOP_SIDE_RIGHT,
3517            ),
3518            (
3519                audio_channel::BOTTOM_FRONT_CENTER,
3520                audio_channel_mask::BOTTOM_FRONT_CENTER,
3521            ),
3522            (
3523                audio_channel::BOTTOM_FRONT_LEFT,
3524                audio_channel_mask::BOTTOM_FRONT_LEFT,
3525            ),
3526            (
3527                audio_channel::BOTTOM_FRONT_RIGHT,
3528                audio_channel_mask::BOTTOM_FRONT_RIGHT,
3529            ),
3530        ];
3531        for (ch, mask) in pairs {
3532            assert_eq!(
3533                1u32 << ch as u32,
3534                mask,
3535                "channel {ch} should map to mask bit (1 << {ch})"
3536            );
3537        }
3538    }
3539
3540    // ----------------------------------------------------------------
3541    // Multitrack — Enhanced RTMP v2 §"ExVideoTagBody" / §"ExAudioTagBody"
3542    // ----------------------------------------------------------------
3543
3544    #[test]
3545    fn multitrack_one_track_video_roundtrip() {
3546        // OneTrack mode: no per-track FourCC, no UI24 size. The track
3547        // body runs from after the UI8 trackId to end-of-buffer.
3548        let mt = Multitrack {
3549            multitrack_type: AV_MULTITRACK_TYPE_ONE_TRACK,
3550            tracks: vec![MultitrackTrack {
3551                fourcc: None,
3552                track_id: 0,
3553                body: b"\x00\x00\x00\x05hello".to_vec(),
3554            }],
3555        };
3556        let tag = VideoTag::multitrack_tag(
3557            VIDEO_FRAME_KEYFRAME,
3558            EX_PACKET_TYPE_CODED_FRAMES,
3559            Some(FOURCC_AVC),
3560            mt.clone(),
3561        );
3562        assert!(tag.is_multitrack());
3563        let wire = build_video(&tag);
3564        // Header byte: IsExHeader(1) | FrameType(001) | PacketType(Multitrack=0110)
3565        // = 0b1001_0110 = 0x96.
3566        assert_eq!(wire[0], 0x96);
3567        // Multitrack nibble byte: (OneTrack=0 << 4) | (CodedFrames=1) = 0x01.
3568        assert_eq!(wire[1], 0x01);
3569        // Shared FourCC sits next (OneTrack uses a shared codec).
3570        assert_eq!(&wire[2..6], b"avc1");
3571        // Then trackId, then body bytes (NO UI24 size).
3572        assert_eq!(wire[6], 0x00);
3573        assert_eq!(&wire[7..], b"\x00\x00\x00\x05hello");
3574        let back = parse_video(&wire).unwrap();
3575        assert_eq!(back, tag);
3576        assert_eq!(back.multitrack.as_ref().unwrap(), &mt);
3577        assert_eq!(back.ex_packet_type, Some(EX_PACKET_TYPE_CODED_FRAMES));
3578        assert_eq!(back.fourcc, Some(FOURCC_AVC));
3579    }
3580
3581    #[test]
3582    fn multitrack_many_tracks_video_roundtrip() {
3583        // ManyTracks mode: shared FourCC, per-track UI24 sizeOfTrack.
3584        // Two HEVC tracks of different sizes.
3585        let mt = Multitrack {
3586            multitrack_type: AV_MULTITRACK_TYPE_MANY_TRACKS,
3587            tracks: vec![
3588                MultitrackTrack {
3589                    fourcc: None,
3590                    track_id: 0,
3591                    body: b"hevc-track-0".to_vec(),
3592                },
3593                MultitrackTrack {
3594                    fourcc: None,
3595                    track_id: 1,
3596                    body: b"hevc-track-1-longer".to_vec(),
3597                },
3598            ],
3599        };
3600        let tag = VideoTag::multitrack_tag(
3601            VIDEO_FRAME_INTER,
3602            EX_PACKET_TYPE_CODED_FRAMES,
3603            Some(FOURCC_HEVC),
3604            mt.clone(),
3605        );
3606        let wire = build_video(&tag);
3607        // Header byte: IsExHeader(1) | FrameType(010) | PacketType(0110)
3608        // = 0b1010_0110 = 0xA6.
3609        assert_eq!(wire[0], 0xA6);
3610        // Multitrack nibble: (ManyTracks=1 << 4) | (CodedFrames=1) = 0x11.
3611        assert_eq!(wire[1], 0x11);
3612        assert_eq!(&wire[2..6], b"hvc1");
3613        // track 0: trackId(0) + UI24 size(12) + 12 body bytes
3614        assert_eq!(wire[6], 0x00);
3615        assert_eq!(&wire[7..10], &[0, 0, 12]);
3616        assert_eq!(&wire[10..22], b"hevc-track-0");
3617        // track 1: trackId(1) + UI24 size(19) + 19 body bytes
3618        assert_eq!(wire[22], 0x01);
3619        assert_eq!(&wire[23..26], &[0, 0, 19]);
3620        assert_eq!(&wire[26..45], b"hevc-track-1-longer");
3621        let back = parse_video(&wire).unwrap();
3622        assert_eq!(back, tag);
3623    }
3624
3625    #[test]
3626    fn multitrack_many_tracks_many_codecs_video_roundtrip() {
3627        // ManyTracksManyCodecs: no shared FourCC, each track carries its
3628        // own FourCC, each track has a UI24 size.
3629        let mt = Multitrack {
3630            multitrack_type: AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS,
3631            tracks: vec![
3632                MultitrackTrack {
3633                    fourcc: Some(FOURCC_HEVC),
3634                    track_id: 0,
3635                    body: b"hevc-data".to_vec(),
3636                },
3637                MultitrackTrack {
3638                    fourcc: Some(FOURCC_AV1),
3639                    track_id: 1,
3640                    body: b"av1-obu-bytes".to_vec(),
3641                },
3642            ],
3643        };
3644        // For ManyTracksManyCodecs the shared outer FourCC is None.
3645        let tag = VideoTag::multitrack_tag(
3646            VIDEO_FRAME_KEYFRAME,
3647            EX_PACKET_TYPE_CODED_FRAMES,
3648            None,
3649            mt.clone(),
3650        );
3651        let wire = build_video(&tag);
3652        // Header byte: 0x96 (same as OneTrack — IsExHeader | KF | Multitrack).
3653        assert_eq!(wire[0], 0x96);
3654        // Multitrack nibble: (MTMC=2 << 4) | (CodedFrames=1) = 0x21.
3655        assert_eq!(wire[1], 0x21);
3656        // No shared FourCC follows — track 0 starts at offset 2.
3657        assert_eq!(&wire[2..6], b"hvc1");
3658        assert_eq!(wire[6], 0x00);
3659        assert_eq!(&wire[7..10], &[0, 0, 9]);
3660        assert_eq!(&wire[10..19], b"hevc-data");
3661        // Track 1.
3662        assert_eq!(&wire[19..23], b"av01");
3663        assert_eq!(wire[23], 0x01);
3664        assert_eq!(&wire[24..27], &[0, 0, 13]);
3665        assert_eq!(&wire[27..40], b"av1-obu-bytes");
3666        let back = parse_video(&wire).unwrap();
3667        assert_eq!(back, tag);
3668        assert_eq!(back.fourcc, None);
3669    }
3670
3671    #[test]
3672    fn multitrack_audio_one_track_roundtrip() {
3673        // OneTrack audio Multitrack carrying an Opus CodedFrames body.
3674        let mt = Multitrack {
3675            multitrack_type: AV_MULTITRACK_TYPE_ONE_TRACK,
3676            tracks: vec![MultitrackTrack {
3677                fourcc: None,
3678                track_id: 0,
3679                body: b"opus-packet-bytes".to_vec(),
3680            }],
3681        };
3682        let tag = AudioTag::multitrack_tag(
3683            AUDIO_PACKET_TYPE_CODED_FRAMES,
3684            Some(FOURCC_OPUS),
3685            mt.clone(),
3686        );
3687        assert!(tag.is_multitrack());
3688        let wire = build_audio(&tag);
3689        // Header byte: ExHeader(9) | AudioPacketType(Multitrack=5) = 0x95.
3690        assert_eq!(wire[0], 0x95);
3691        // Multitrack nibble: (OneTrack=0 << 4) | (CodedFrames=1) = 0x01.
3692        assert_eq!(wire[1], 0x01);
3693        assert_eq!(&wire[2..6], b"Opus");
3694        assert_eq!(wire[6], 0x00); // trackId 0
3695        assert_eq!(&wire[7..], b"opus-packet-bytes");
3696        let back = parse_audio(&wire).unwrap();
3697        assert_eq!(back, tag);
3698        assert_eq!(back.ex_packet_type, Some(AUDIO_PACKET_TYPE_CODED_FRAMES));
3699        assert_eq!(back.audio_fourcc, Some(FOURCC_OPUS));
3700    }
3701
3702    #[test]
3703    fn multitrack_audio_many_tracks_many_codecs_roundtrip() {
3704        // Mixed Opus + AAC audio multitrack — ManyTracksManyCodecs.
3705        let mt = Multitrack {
3706            multitrack_type: AV_MULTITRACK_TYPE_MANY_TRACKS_MANY_CODECS,
3707            tracks: vec![
3708                MultitrackTrack {
3709                    fourcc: Some(FOURCC_OPUS),
3710                    track_id: 0,
3711                    body: b"opus-bytes".to_vec(),
3712                },
3713                MultitrackTrack {
3714                    fourcc: Some(FOURCC_AAC),
3715                    track_id: 7,
3716                    body: b"aac-raw-frame".to_vec(),
3717                },
3718            ],
3719        };
3720        let tag = AudioTag::multitrack_tag(AUDIO_PACKET_TYPE_CODED_FRAMES, None, mt.clone());
3721        let wire = build_audio(&tag);
3722        assert_eq!(wire[0], 0x95);
3723        assert_eq!(wire[1], 0x21);
3724        // Track 0
3725        assert_eq!(&wire[2..6], b"Opus");
3726        assert_eq!(wire[6], 0x00);
3727        assert_eq!(&wire[7..10], &[0, 0, 10]);
3728        assert_eq!(&wire[10..20], b"opus-bytes");
3729        // Track 1
3730        assert_eq!(&wire[20..24], b"mp4a");
3731        assert_eq!(wire[24], 0x07);
3732        assert_eq!(&wire[25..28], &[0, 0, 13]);
3733        assert_eq!(&wire[28..41], b"aac-raw-frame");
3734        let back = parse_audio(&wire).unwrap();
3735        assert_eq!(back, tag);
3736        assert_eq!(back.audio_fourcc, None);
3737    }
3738
3739    #[test]
3740    fn multitrack_video_inner_packet_type_must_not_be_multitrack() {
3741        // Spec: "This fetch MUST not result in a VideoPacketType.Multitrack"
3742        // Header 0x96 (Ex/KF/Multitrack), then nibble byte 0x06 (OneTrack |
3743        // inner=Multitrack=6). The parser must reject without recursing.
3744        let wire = [0x96u8, 0x06, b'a', b'v', b'c', b'1', 0x00];
3745        let err = parse_video(&wire).unwrap_err();
3746        let msg = format!("{err:?}");
3747        assert!(msg.contains("MUST NOT"), "got: {msg}");
3748    }
3749
3750    #[test]
3751    fn multitrack_audio_inner_packet_type_must_not_be_multitrack() {
3752        // Same constraint for audio (AudioPacketType.Multitrack = 5).
3753        let wire = [0x95u8, 0x05, b'O', b'p', b'u', b's', 0x00];
3754        let err = parse_audio(&wire).unwrap_err();
3755        let msg = format!("{err:?}");
3756        assert!(msg.contains("MUST NOT"), "got: {msg}");
3757    }
3758
3759    #[test]
3760    fn multitrack_video_truncated_size_overruns_error() {
3761        // ManyTracks: trackId(0) + UI24 size=100 + only 5 bytes follow.
3762        // The parser must surface a clean error, not panic.
3763        // Layout: header(0x96) + mt-nibble(0x11) + shared FourCC(avc1) +
3764        // track0 trackId(0) + size UI24 = 100 + only 5 body bytes.
3765        let mut wire = vec![0x96u8, 0x11];
3766        wire.extend_from_slice(b"avc1");
3767        wire.push(0x00); // trackId
3768        wire.extend_from_slice(&[0x00, 0x00, 100]); // size = 100
3769        wire.extend_from_slice(b"short"); // only 5 bytes
3770        let err = parse_video(&wire).unwrap_err();
3771        let msg = format!("{err:?}");
3772        assert!(
3773            msg.contains("overruns"),
3774            "expected size-overrun error, got: {msg}"
3775        );
3776    }
3777
3778    #[test]
3779    fn multitrack_truncation_paths_audio_video() {
3780        // Truncated reading the multitrack nibble byte (header byte
3781        // says Multitrack but no payload follows).
3782        assert!(parse_video(&[0x96]).is_err());
3783        assert!(parse_audio(&[0x95]).is_err());
3784        // Multitrack nibble present, but missing shared FourCC (OneTrack +
3785        // CodedFrames inner — needs 4 bytes for the shared FourCC).
3786        assert!(parse_video(&[0x96, 0x01]).is_err());
3787        assert!(parse_audio(&[0x95, 0x01]).is_err());
3788        // Multitrack with shared FourCC but no trackId.
3789        // For OneTrack with no trackId after FourCC the loop reports
3790        // empty-track-list.
3791        assert!(parse_video(&[0x96, 0x01, b'a', b'v', b'c', b'1']).is_err());
3792        // ManyTracksManyCodecs needs no shared FourCC but a per-track
3793        // FourCC; a buffer with only the multitrack nibble fails the
3794        // empty-list path.
3795        assert!(parse_video(&[0x96, 0x21]).is_err());
3796    }
3797
3798    #[test]
3799    fn multitrack_video_roundtrips_through_mod_ex_prelude() {
3800        // The ModEx prelude and the Multitrack prelude compose: the
3801        // header byte's PacketType nibble is ModEx; the chain's
3802        // terminating nibble is Multitrack; the nibble byte that
3803        // follows the FourCC-less position carries `multitrackType |
3804        // realPacketType`. This test confirms the parse / build path
3805        // round-trips that compound prelude.
3806        let mt = Multitrack {
3807            multitrack_type: AV_MULTITRACK_TYPE_ONE_TRACK,
3808            tracks: vec![MultitrackTrack {
3809                fourcc: None,
3810                track_id: 0,
3811                body: b"hevc-nalus".to_vec(),
3812            }],
3813        };
3814        let mut tag = VideoTag::multitrack_tag(
3815            VIDEO_FRAME_KEYFRAME,
3816            EX_PACKET_TYPE_CODED_FRAMES,
3817            Some(FOURCC_HEVC),
3818            mt.clone(),
3819        );
3820        tag.mod_ex = vec![ModEx::timestamp_offset_nano_entry(123_456)];
3821        let wire = build_video(&tag);
3822        let back = parse_video(&wire).unwrap();
3823        assert_eq!(back, tag);
3824        assert_eq!(back.timestamp_offset_nano(), 123_456);
3825        assert!(back.is_multitrack());
3826    }
3827
3828    #[test]
3829    fn multitrack_helpers_round_trip_through_body_encode_parse() {
3830        // Direct Multitrack::encode + Multitrack::parse symmetry, with
3831        // a reserved multitrack_type (4) preserved verbatim (it's not
3832        // OneTrack so a UI24 size IS emitted; tracks are still decodable).
3833        let mt = Multitrack {
3834            multitrack_type: 4,
3835            tracks: vec![
3836                MultitrackTrack {
3837                    fourcc: None,
3838                    track_id: 2,
3839                    body: vec![0xDE, 0xAD],
3840                },
3841                MultitrackTrack {
3842                    fourcc: None,
3843                    track_id: 3,
3844                    body: vec![0xBE, 0xEF, 0xCA, 0xFE],
3845                },
3846            ],
3847        };
3848        let bytes = mt.encode();
3849        let back = Multitrack::parse(&bytes, 4).unwrap();
3850        assert_eq!(back, mt);
3851    }
3852}