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}