@blockcast/mmt-base 0.1.0-main.56d9d13deed5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mmtp.ts ADDED
@@ -0,0 +1,898 @@
1
+ import {
2
+ MMTP_EXT_TYPE_ALTA_AUTH as _LEAF_MMTP_EXT_TYPE_ALTA_AUTH,
3
+ MMTP_EXT_TRAILER_HEADER_SIZE as _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE,
4
+ extractAltaTrailer as _leafExtractAltaTrailer,
5
+ type AltaTrailer as _LeafAltaTrailer,
6
+ } from '@blockcast/mmt-alta-parse'
7
+
8
+ /**
9
+ * @mmt/container - MMTP Parser
10
+ *
11
+ * MMTP (MMT Protocol) packet parser based on ISO/IEC 23008-1:2023 Section 9.2
12
+ *
13
+ * MMTP header format (12 bytes):
14
+ * - Byte 0: V(2) | C(1) | FEC_type(2) | r(1) | X(1) | R(1)
15
+ * - Byte 1: reserved(2) | packet_type(6)
16
+ * - Bytes 2-3: packet_id (big-endian)
17
+ * - Bytes 4-7: timestamp (big-endian, 32-bit)
18
+ * - Bytes 8-11: sequence_number (big-endian)
19
+ *
20
+ * FEC_type values:
21
+ * - 0: No FEC / source packet
22
+ * - 1: FEC mode 0 (source with FEC payload ID)
23
+ * - 2: FEC repair packet mode 0
24
+ * - 3: FEC repair packet mode 1
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+
29
+ export interface MMTPHeader {
30
+ version: number
31
+ /** Packet type: 0x00=MPU, 0x02=Signaling, 0x03=Repair */
32
+ packetType: number
33
+ packetId: number
34
+ timestamp: bigint
35
+ sequenceNumber: number
36
+ payloadLength: number
37
+ fecType: number
38
+ /** C flag: header carries a 32-bit packet_counter. */
39
+ packetCounterFlag: boolean
40
+ packetCounter?: number
41
+ /** X flag: header carries an extension header and value. */
42
+ extensionFlag: boolean
43
+ extension?: { type: number; data: Uint8Array }
44
+ /** Byte offset at which the MMTP payload begins. */
45
+ headerLength: number
46
+ /** Random Access Point flag - indicates keyframe (ISO 23008-1, byte 0 bit 0) */
47
+ rapFlag: boolean
48
+ }
49
+
50
+ export interface MPUHeader {
51
+ mpuSequenceNumber: number
52
+ fragmentationIndicator: number // FI: 0=complete, 1=first, 2=middle, 3=last
53
+ /** 8-bit MPU fragment_counter from byte 3. */
54
+ fragmentCounter: number
55
+ /** T flag: 1=timed media (14-byte MFU DU header), 0=non-timed (4-byte header) */
56
+ timedFlag: number
57
+ /** A flag: aggregated MPU payload. This strict profile requires A=0. */
58
+ aggregationFlag: boolean
59
+ /** MPU payload_length (bytes 0-1): bytes after the length field, including
60
+ * the remaining six MPU-header bytes and all data-unit bytes. */
61
+ payloadLength: number
62
+ }
63
+
64
+ export interface MFUDuHeader {
65
+ movieFragmentSequenceNumber: number
66
+ sampleNumber: number
67
+ offset: number
68
+ priority: number
69
+ dependencyCounter: number
70
+ }
71
+
72
+ export interface MMTPPacket {
73
+ header: MMTPHeader
74
+ payload: Uint8Array
75
+ }
76
+
77
+ export const MMTP_HEADER_SIZE = 12
78
+ export const MPU_HEADER_SIZE = 8
79
+ export const MFU_DU_HEADER_SIZE_TIMED = 14 // T=1: timed media (video/audio samples)
80
+ export const MFU_DU_HEADER_SIZE_NONTIMED = 4 // T=0: non-timed media (item_id only)
81
+
82
+ const MPU_LENGTH_FIELD_SIZE = 2
83
+ const MPU_HEADER_REMAINDER_SIZE = MPU_HEADER_SIZE - MPU_LENGTH_FIELD_SIZE
84
+ const SOURCE_FEC_PAYLOAD_ID_SIZE = 4
85
+
86
+ // MMTP Packet Types (ISO/IEC 23008-1:2023 Section 9.2.2)
87
+ export const PACKET_TYPE_MPU = 0x00
88
+ export const PACKET_TYPE_SIGNALING = 0x02
89
+ export const PACKET_TYPE_REPAIR = 0x03
90
+
91
+ // AL-FEC Signaling Message ID (ISO/IEC 23008-1:2023 Amendment 1:2025)
92
+ export const ALFEC_MESSAGE_ID = 0x0203
93
+
94
+ // FEC Code IDs
95
+ export const FEC_CODE_XOR = 1
96
+ export const FEC_CODE_RAPTORQ = 2
97
+
98
+ /**
99
+ * Parse MMTP header from buffer
100
+ */
101
+ export function parseMMTPHeader(buffer: Uint8Array): MMTPHeader {
102
+ if (buffer.length < MMTP_HEADER_SIZE) {
103
+ throw new Error(`MMTP packet too small: ${buffer.length} bytes`)
104
+ }
105
+
106
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
107
+
108
+ const version = (buffer[0]! >> 6) & 0x03
109
+ if (version !== 0) {
110
+ throw new Error(`Unsupported MMTP version: ${version}`)
111
+ }
112
+
113
+ // Extract FEC type from byte 0, bits 3-4
114
+ const fecType = (buffer[0]! >> 3) & 0x03
115
+ const packetCounterFlag = (buffer[0]! & 0x20) !== 0
116
+ const extensionFlag = (buffer[0]! & 0x02) !== 0
117
+
118
+ // Extract RAP flag from byte 0, bit 0 (R bit - Random Access Point)
119
+ const rapFlag = (buffer[0]! & 0x01) === 1
120
+
121
+ // ISO/IEC 23008-1:2023 Figure 8 (V=0) places reserved(2) before
122
+ // packet_type(6), so packet_type occupies the low six bits of byte 1.
123
+ const packetType = buffer[1]! & 0x3F
124
+
125
+ let offset = 2
126
+
127
+ // Packet ID (2 bytes, big-endian)
128
+ const packetId = view.getUint16(offset, false)
129
+ offset += 2
130
+
131
+ // Timestamp (4 bytes, big-endian)
132
+ const timestamp = BigInt(view.getUint32(offset, false))
133
+ offset += 4
134
+
135
+ // Sequence number (4 bytes, big-endian)
136
+ const sequenceNumber = view.getUint32(offset, false)
137
+ offset += 4
138
+
139
+ let packetCounter: number | undefined
140
+ if (packetCounterFlag) {
141
+ if (offset + 4 > buffer.length) throw new Error('MMTP packet truncated in packet_counter')
142
+ packetCounter = view.getUint32(offset, false)
143
+ offset += 4
144
+ }
145
+
146
+ let extension: { type: number; data: Uint8Array } | undefined
147
+ if (extensionFlag) {
148
+ if (offset + 4 > buffer.length) throw new Error('MMTP packet truncated in header extension')
149
+ const type = view.getUint16(offset, false)
150
+ const length = view.getUint16(offset + 2, false)
151
+ offset += 4
152
+ if (offset + length > buffer.length) throw new Error('MMTP packet truncated in header extension value')
153
+ extension = { type, data: buffer.subarray(offset, offset + length) }
154
+ offset += length
155
+ }
156
+
157
+ return {
158
+ version,
159
+ packetType,
160
+ packetId,
161
+ timestamp,
162
+ sequenceNumber,
163
+ payloadLength: buffer.length - offset,
164
+ fecType,
165
+ packetCounterFlag,
166
+ packetCounter,
167
+ extensionFlag,
168
+ extension,
169
+ headerLength: offset,
170
+ rapFlag,
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Unwrap MMTP packet to extract payload
176
+ */
177
+ export function unwrapMMTP(buffer: Uint8Array): MMTPPacket {
178
+ const header = parseMMTPHeader(buffer)
179
+ const payload = buffer.subarray(header.headerLength)
180
+
181
+ return { header, payload }
182
+ }
183
+
184
+ /**
185
+ * Parse MPU header from payload
186
+ * ISO/IEC 23008-1:2023 Section 9.2.3.3
187
+ *
188
+ * MPU Header format (8 bytes) as written by FFmpeg/libmmt:
189
+ * - Bytes 0-1: payload_length (16 bits, big-endian)
190
+ * - Byte 2: FT(4) | T(1) | FI(2) | A(1)
191
+ * - Byte 3: fragment_counter (8 bits)
192
+ * - Bytes 4-7: MPU_sequence_number (32 bits, big-endian)
193
+ */
194
+ export function parseMPUHeader(payload: Uint8Array): MPUHeader {
195
+ if (payload.length < MPU_HEADER_SIZE) {
196
+ throw new Error(`Payload too small for MPU header: ${payload.length} bytes`)
197
+ }
198
+
199
+ const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
200
+
201
+ // Bytes 0-1: payload_length (16 bits, big-endian)
202
+ const payloadLength = view.getUint16(0, false)
203
+
204
+ // Byte 2: FT(4) | T(1) | FI(2) | A(1)
205
+ const flagsByte = payload[2]!
206
+ const timedFlag = (flagsByte >> 3) & 0x01
207
+ const fragmentationIndicator = (flagsByte >> 1) & 0x03
208
+ const aggregationFlag = (flagsByte & 0x01) !== 0
209
+
210
+ // Byte 3: fragment_counter (8 bits). The high bit is part of the counter in
211
+ // the current libmmt/FFmpeg writer ABI.
212
+ const fragmentCounter = payload[3]!
213
+
214
+ // Bytes 4-7: MPU_sequence_number (32 bits, big-endian)
215
+ const mpuSequenceNumber = view.getUint32(4, false)
216
+
217
+ return {
218
+ mpuSequenceNumber,
219
+ fragmentationIndicator,
220
+ fragmentCounter,
221
+ timedFlag,
222
+ aggregationFlag,
223
+ payloadLength,
224
+ }
225
+ }
226
+
227
+ export function parseTimedMfuDuHeader(payload: Uint8Array, offset: number): MFUDuHeader | null {
228
+ if (offset < 0 || offset + MFU_DU_HEADER_SIZE_TIMED > payload.length) return null
229
+ const view = new DataView(payload.buffer, payload.byteOffset + offset, MFU_DU_HEADER_SIZE_TIMED)
230
+ return {
231
+ movieFragmentSequenceNumber: view.getUint32(0, false),
232
+ sampleNumber: view.getUint32(4, false),
233
+ offset: view.getUint32(8, false),
234
+ priority: payload[offset + 12]!,
235
+ dependencyCounter: payload[offset + 13]!,
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Why `extractMFUPayload` refused a packet.
241
+ *
242
+ * These identifiers are part of the public contract: callers surface them in
243
+ * diagnostics, so renaming one is a breaking change.
244
+ */
245
+ export type MFURejectionGate =
246
+ | 'packet-shorter-than-mmtp-plus-mpu-header'
247
+ | 'mmtp-header-unparseable'
248
+ | 'not-an-mpu-packet'
249
+ | 'payload-shorter-than-mpu-header'
250
+ | 'aggregation-flag-set'
251
+ | 'mpu-payload-length-below-header-remainder'
252
+ | 'mpu-declared-end-exceeds-payload'
253
+ | 'mfu-offset-at-or-past-declared-end'
254
+ | 'source-fec-payload-id-width-mismatch'
255
+ | 'declared-trailers-unresolved'
256
+
257
+ export interface MFURejection {
258
+ gate: MFURejectionGate
259
+ /** Human-readable detail, including the byte arithmetic that decided it. */
260
+ detail: string
261
+ }
262
+
263
+ interface MfuPayloadLayout {
264
+ hasMfuDuHeader: boolean
265
+ mfuOffset: number
266
+ dataEnd: number
267
+ }
268
+
269
+ function resolveMfuPayloadLayout(
270
+ mpu: MPUHeader,
271
+ fragmentType: number,
272
+ payload: Uint8Array,
273
+ ): { layout: MfuPayloadLayout } | { rejection: MFURejection } {
274
+ if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) {
275
+ return {
276
+ rejection: {
277
+ gate: 'mpu-payload-length-below-header-remainder',
278
+ detail: `payload_length=${mpu.payloadLength} < ${MPU_HEADER_REMAINDER_SIZE}`,
279
+ },
280
+ }
281
+ }
282
+
283
+ // ISO/IEC 23008-1 defines payload_length from immediately after its own
284
+ // two-byte field. It therefore includes the remaining six MPU-header bytes.
285
+ const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength
286
+ if (declaredEnd > payload.length) {
287
+ return {
288
+ rejection: {
289
+ gate: 'mpu-declared-end-exceeds-payload',
290
+ detail:
291
+ `payload_length=${mpu.payloadLength} implies end=${declaredEnd}, ` +
292
+ `payload is ${payload.length} bytes`,
293
+ },
294
+ }
295
+ }
296
+
297
+ const hasHeader = fragmentType === 2 && mpu.fragmentationIndicator <= 1
298
+ const mfuHeaderSize = mpu.timedFlag ? MFU_DU_HEADER_SIZE_TIMED : MFU_DU_HEADER_SIZE_NONTIMED
299
+ const mfuOffset = MPU_HEADER_SIZE + (hasHeader ? mfuHeaderSize : 0)
300
+ if (mfuOffset >= declaredEnd) {
301
+ return {
302
+ rejection: {
303
+ gate: 'mfu-offset-at-or-past-declared-end',
304
+ detail: `mfuOffset=${mfuOffset} >= declaredEnd=${declaredEnd}`,
305
+ },
306
+ }
307
+ }
308
+
309
+ return {
310
+ layout: {
311
+ hasMfuDuHeader: hasHeader,
312
+ mfuOffset,
313
+ dataEnd: declaredEnd,
314
+ },
315
+ }
316
+ }
317
+
318
+ function isEmptyOrExactAltaTrailer(payload: Uint8Array, start: number, end: number): boolean {
319
+ if (start === end) return true
320
+ if (start < 0 || end > payload.length || start + _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE > end) {
321
+ return false
322
+ }
323
+
324
+ const view = new DataView(
325
+ payload.buffer,
326
+ payload.byteOffset + start,
327
+ end - start,
328
+ )
329
+ if (view.getUint16(0, false) !== _LEAF_MMTP_EXT_TYPE_ALTA_AUTH) return false
330
+
331
+ const authLength = view.getUint16(2, false)
332
+ return start + _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE + authLength === end
333
+ }
334
+
335
+ interface DeclaredTrailerLayout {
336
+ hasFecId: boolean
337
+ }
338
+
339
+ function resolveDeclaredTrailers(
340
+ payload: Uint8Array,
341
+ declaredEnd: number,
342
+ fecType: number,
343
+ options?: { skipFecId?: boolean },
344
+ ): DeclaredTrailerLayout | null {
345
+ if (declaredEnd > payload.length) return null
346
+
347
+ if (options?.skipFecId) {
348
+ return isEmptyOrExactAltaTrailer(payload, declaredEnd, payload.length)
349
+ ? { hasFecId: false }
350
+ : null
351
+ }
352
+
353
+ if (fecType === 1) {
354
+ const fecStart = payload.length - SOURCE_FEC_PAYLOAD_ID_SIZE
355
+ if (fecStart < declaredEnd || !isEmptyOrExactAltaTrailer(payload, declaredEnd, fecStart)) {
356
+ return null
357
+ }
358
+ return { hasFecId: true }
359
+ }
360
+ if (fecType !== 0) return null
361
+
362
+ if (isEmptyOrExactAltaTrailer(payload, declaredEnd, payload.length)) {
363
+ return { hasFecId: false }
364
+ }
365
+
366
+ return null
367
+ }
368
+
369
+ /**
370
+ * Extract NAL unit payload from MMTP packet
371
+ */
372
+ export function extractNALUnit(mmtpPacket: Uint8Array): Uint8Array {
373
+ const extraction = extractMFUPayload(mmtpPacket)
374
+ if (!extraction) throw new Error('Malformed MMTP MPU payload')
375
+ return extraction.data
376
+ }
377
+
378
+ /** Result of extracting MFU payload from an MMTP packet */
379
+ export interface MFUExtraction {
380
+ /** MMTP header fields */
381
+ header: MMTPHeader
382
+ /** MPU header fields */
383
+ mpu: MPUHeader
384
+ /** Fragment type (FT field, 4 bits): 0=metadata, 2=MFU data */
385
+ fragmentType: number
386
+ /** MFU payload data (video/audio bytes, headers stripped, FEC ID excluded) */
387
+ data: Uint8Array
388
+ /** Parsed timed MFU DU header when present, otherwise null */
389
+ mfuDuHeader: MFUDuHeader | null
390
+ /** Non-timed MFU Item_ID when the 4-byte DU header is present. */
391
+ itemId: number | null
392
+ /** Source FEC Payload ID if present (`FEC_type == 1`), or null */
393
+ fecPayloadId: { ssId: number } | null
394
+ }
395
+
396
+ /**
397
+ * Extract MFU payload from a raw MMTP packet.
398
+ *
399
+ * Handles all the byte arithmetic for:
400
+ * - MMTP header (12 bytes)
401
+ * - MPU sub-header (8 bytes, payloadLength field)
402
+ * - MFU DU header (14 bytes timed / 4 bytes non-timed, only for FI ≤ 1)
403
+ * - Source FEC Payload ID (4 bytes at end, only when `FEC_type == 1`)
404
+ *
405
+ * `payloadLength` is the ISO length-after-field value: six remaining MPU-header
406
+ * bytes, then the MFU DU header when present, then media. ALTA and Source FEC
407
+ * trailers are outside that declared span and must consume the exact suffix.
408
+ *
409
+ * @param mmtpPacket Raw MMTP packet (from multicast or MoQ)
410
+ * @param options.skipFecId When true, do NOT subtract the 4-byte Source FEC Payload ID
411
+ * from the payload boundary, even if fecType >= 1 in the MMTP header. Use this for
412
+ * FEC-recovered symbols where the FEC PID was already stripped before WASM decoding
413
+ * but the fecType bits in byte 0 still reflect the original packet (FEC Type=1 per
414
+ * draft-ramadan-moq-mmt-00 §3.1).
415
+ * @returns Extracted MFU data + metadata, or null if packet is too small
416
+ */
417
+ interface MfuPlan {
418
+ header: MMTPHeader
419
+ payload: Uint8Array
420
+ mpu: MPUHeader
421
+ fragmentType: number
422
+ layout: MfuPayloadLayout
423
+ trailerLayout: DeclaredTrailerLayout
424
+ }
425
+
426
+ /**
427
+ * Single source of truth for whether a packet yields an MFU payload.
428
+ *
429
+ * `extractMFUPayload` and `explainMFURejection` both delegate here, so the
430
+ * reason reported to operators can never drift from the decision the parser
431
+ * actually made.
432
+ */
433
+ function planMfuExtraction(
434
+ mmtpPacket: Uint8Array,
435
+ options?: { skipFecId?: boolean },
436
+ ): { plan: MfuPlan } | { rejection: MFURejection } {
437
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) {
438
+ return {
439
+ rejection: {
440
+ gate: 'packet-shorter-than-mmtp-plus-mpu-header',
441
+ detail: `${mmtpPacket.length} bytes < ${MMTP_HEADER_SIZE + MPU_HEADER_SIZE}`,
442
+ },
443
+ }
444
+ }
445
+
446
+ let header: MMTPHeader
447
+ try {
448
+ header = parseMMTPHeader(mmtpPacket)
449
+ } catch (error) {
450
+ return {
451
+ rejection: {
452
+ gate: 'mmtp-header-unparseable',
453
+ detail: error instanceof Error ? error.message : String(error),
454
+ },
455
+ }
456
+ }
457
+ if (header.packetType !== PACKET_TYPE_MPU) {
458
+ return {
459
+ rejection: {
460
+ gate: 'not-an-mpu-packet',
461
+ detail: `packetType=0x${header.packetType.toString(16).padStart(2, '0')}`,
462
+ },
463
+ }
464
+ }
465
+
466
+ const payload = mmtpPacket.subarray(header.headerLength)
467
+ if (payload.length < MPU_HEADER_SIZE) {
468
+ return {
469
+ rejection: {
470
+ gate: 'payload-shorter-than-mpu-header',
471
+ detail: `${payload.length} bytes < ${MPU_HEADER_SIZE}`,
472
+ },
473
+ }
474
+ }
475
+
476
+ const mpu = parseMPUHeader(payload)
477
+ if (mpu.aggregationFlag) {
478
+ return {
479
+ rejection: { gate: 'aggregation-flag-set', detail: 'strict profile requires A=0' },
480
+ }
481
+ }
482
+
483
+ // Fragment type from byte 2 upper nibble (not returned by parseMPUHeader)
484
+ const fragmentType = (payload[2]! >> 4) & 0x0f
485
+
486
+ const layoutResult = resolveMfuPayloadLayout(mpu, fragmentType, payload)
487
+ if ('rejection' in layoutResult) return layoutResult
488
+ const { layout } = layoutResult
489
+
490
+ const trailerLayout = resolveDeclaredTrailers(
491
+ payload,
492
+ layout.dataEnd,
493
+ header.fecType,
494
+ options,
495
+ )
496
+ if (!trailerLayout) {
497
+ // The most common cause is a producer whose Source FEC Payload ID width
498
+ // disagrees with ISO/IEC 23008-1 §C.5.2. Name that explicitly rather than
499
+ // leaving operators to infer it from a generic trailer error.
500
+ if (!options?.skipFecId && header.fecType === 1) {
501
+ const observed = payload.length - layout.dataEnd
502
+ if (observed !== SOURCE_FEC_PAYLOAD_ID_SIZE) {
503
+ return {
504
+ rejection: {
505
+ gate: 'source-fec-payload-id-width-mismatch',
506
+ detail:
507
+ `producer wrote a ${observed}-byte Source FEC Payload ID, ` +
508
+ `ISO/IEC 23008-1 §C.5.2 requires ${SOURCE_FEC_PAYLOAD_ID_SIZE} ` +
509
+ `(payload=${payload.length}, declaredEnd=${layout.dataEnd})`,
510
+ },
511
+ }
512
+ }
513
+ }
514
+ return {
515
+ rejection: {
516
+ gate: 'declared-trailers-unresolved',
517
+ detail:
518
+ `trailer bytes [${layout.dataEnd}, ${payload.length}) are neither empty ` +
519
+ `nor an exact ALTA trailer (fecType=${header.fecType})`,
520
+ },
521
+ }
522
+ }
523
+
524
+ return { plan: { header, payload, mpu, fragmentType, layout, trailerLayout } }
525
+ }
526
+
527
+ /**
528
+ * Report why `extractMFUPayload` would refuse this packet, or null if it would
529
+ * accept it.
530
+ *
531
+ * `extractMFUPayload` returns bare `null` on ten distinct conditions, which
532
+ * makes a producer/consumer framing disagreement indistinguishable from a
533
+ * publisher that never started — both render as silence. Use this wherever
534
+ * that distinction matters: diagnostics, contract tests, and operator-facing
535
+ * logs.
536
+ */
537
+ export function explainMFURejection(
538
+ mmtpPacket: Uint8Array,
539
+ options?: { skipFecId?: boolean },
540
+ ): MFURejection | null {
541
+ const result = planMfuExtraction(mmtpPacket, options)
542
+ return 'rejection' in result ? result.rejection : null
543
+ }
544
+
545
+ export function extractMFUPayload(
546
+ mmtpPacket: Uint8Array,
547
+ options?: { skipFecId?: boolean },
548
+ ): MFUExtraction | null {
549
+ const result = planMfuExtraction(mmtpPacket, options)
550
+ if ('rejection' in result) return null
551
+ const { header, payload, mpu, fragmentType, layout, trailerLayout } = result.plan
552
+
553
+ const mfuDuHeaderPresent = layout.hasMfuDuHeader
554
+ const mfuOffset = layout.mfuOffset
555
+ const dataEnd = layout.dataEnd
556
+
557
+ const mfuDuHeader =
558
+ mfuDuHeaderPresent && mpu.timedFlag === 1
559
+ ? parseTimedMfuDuHeader(payload, MPU_HEADER_SIZE)
560
+ : null
561
+ const itemId =
562
+ mfuDuHeaderPresent && mpu.timedFlag === 0
563
+ ? new DataView(payload.buffer, payload.byteOffset + MPU_HEADER_SIZE, 4).getUint32(0, false)
564
+ : null
565
+
566
+ const data = payload.subarray(mfuOffset, dataEnd)
567
+
568
+ const fecPayloadId = trailerLayout.hasFecId
569
+ ? {
570
+ ssId: new DataView(
571
+ mmtpPacket.buffer,
572
+ mmtpPacket.byteOffset + mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE,
573
+ SOURCE_FEC_PAYLOAD_ID_SIZE,
574
+ ).getUint32(0, false),
575
+ }
576
+ : null
577
+
578
+ return { header, mpu, fragmentType, data, mfuDuHeader, itemId, fecPayloadId }
579
+ }
580
+
581
+ /**
582
+ * Extract timestamp from MMTP packet
583
+ */
584
+ export function extractTimestamp(mmtpPacket: Uint8Array): bigint {
585
+ const { header } = unwrapMMTP(mmtpPacket)
586
+ return header.timestamp
587
+ }
588
+
589
+ /**
590
+ * Check if MMTP packet is an FEC repair packet
591
+ * FEC_type 2 and 3 are repair packets
592
+ */
593
+ export function isFecRepairPacket(mmtpPacket: Uint8Array): boolean {
594
+ if (mmtpPacket.length < 1) return false
595
+ const fecType = (mmtpPacket[0]! >> 3) & 0x03
596
+ return fecType >= 2
597
+ }
598
+
599
+ /**
600
+ * Extract Source FEC Payload ID (SS_ID) from the last 4 bytes of an MMTP
601
+ * source packet per ISO/IEC 23008-1:2023 Section C.5.2 Figure C.10.
602
+ *
603
+ * SS_ID is a flat 32-bit monotonic counter (incremented by 1 per packet
604
+ * for ssbg_mode0 and ssbg_mode1). Block membership and ESI are derived
605
+ * at recovery time from repair packet SS_Start and SSB_length:
606
+ * SBN = floor(SS_ID / K), ESI = SS_ID % K
607
+ * SS_Start = SBN * K (first SS_ID of the block)
608
+ *
609
+ * This matches FFmpeg moqenc_mmt.c, the drafts (draft-ramadan-moq-fec §6.2),
610
+ * and ATSC A/331 §8.1.2.5 which defers to ISO 23008-1 Annex C.
611
+ */
612
+ export function extractFecPayloadId(mmtpPacket: Uint8Array): { ssId: number } | null {
613
+ if (!hasSourceFecPayloadId(mmtpPacket)) return null
614
+ // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id
615
+ const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE
616
+ const view = new DataView(
617
+ mmtpPacket.buffer,
618
+ mmtpPacket.byteOffset + off,
619
+ SOURCE_FEC_PAYLOAD_ID_SIZE,
620
+ )
621
+ return { ssId: view.getUint32(0, false) }
622
+ }
623
+
624
+ /**
625
+ * Return true when FEC_type=1 and the exact suffix after the ISO-declared MPU
626
+ * payload contains a four-byte Source FEC Payload ID.
627
+ */
628
+ export function hasSourceFecPayloadId(mmtpPacket: Uint8Array): boolean {
629
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return false
630
+
631
+ let header: MMTPHeader
632
+ try {
633
+ header = parseMMTPHeader(mmtpPacket)
634
+ } catch {
635
+ return false
636
+ }
637
+ if (header.packetType !== PACKET_TYPE_MPU) return false
638
+
639
+ const payload = mmtpPacket.subarray(header.headerLength)
640
+ if (payload.length < MPU_HEADER_SIZE) return false
641
+ const mpu = parseMPUHeader(payload)
642
+ if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) return false
643
+ const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength
644
+ return resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId ?? false
645
+ }
646
+
647
+ /**
648
+ * MMTP header-extension ext_type assigned to the detached ALTA authenticator
649
+ * trailer (Path 2 / 08-07). Carried in an `[ext_type][ext_length][auth]`
650
+ * block appended after the MPU's declared payload bytes, before the 4-byte
651
+ * Source FEC Payload ID. MMTP X-bit stays 0 — non-ALTA MMTP receivers treat
652
+ * the trailer as bytes past `payload_length` and skip it (per ISO 23008-1
653
+ * §9.2.2 "may be discarded without impacting correct processing").
654
+ */
655
+ // Re-export the ALTA trailer helper from the zero-dep leaf package. The leaf
656
+ // owns the implementation so container and FEC consumers share one parser
657
+ // without creating a package cycle.
658
+ export const MMTP_EXT_TYPE_ALTA_AUTH = _LEAF_MMTP_EXT_TYPE_ALTA_AUTH
659
+ export const MMTP_EXT_TRAILER_HEADER_SIZE = _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE
660
+ export type AltaTrailer = _LeafAltaTrailer
661
+ export const extractAltaTrailer = _leafExtractAltaTrailer
662
+
663
+ /** Size of Repair FEC Payload ID per ISO/IEC 23008-1 Section C.5.3 */
664
+ export const REPAIR_FEC_PID_SIZE = 13
665
+
666
+ /** Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header. */
667
+ function readRepairFecPayloadIdFields(mmtpPacket: Uint8Array): {
668
+ packetId: number; ssStart: number; rsbLength: number; rsId: number; ssbLength: number; dataOffset: number
669
+ } | null {
670
+ let header: MMTPHeader
671
+ try {
672
+ header = parseMMTPHeader(mmtpPacket)
673
+ } catch {
674
+ return null
675
+ }
676
+ if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2) return null
677
+
678
+ const off = header.headerLength
679
+ if (mmtpPacket.length < off + REPAIR_FEC_PID_SIZE) return null
680
+ const view = new DataView(mmtpPacket.buffer, mmtpPacket.byteOffset + off, REPAIR_FEC_PID_SIZE)
681
+ const ssStart = view.getUint32(0, false) // 32 bits
682
+ const rsbLength = (mmtpPacket[off + 4]! << 16) | (mmtpPacket[off + 5]! << 8) | mmtpPacket[off + 6]! // 24 bits
683
+ const rsId = (mmtpPacket[off + 7]! << 16) | (mmtpPacket[off + 8]! << 8) | mmtpPacket[off + 9]! // 24 bits
684
+ const ssbLength = (mmtpPacket[off + 10]! << 16) | (mmtpPacket[off + 11]! << 8) | mmtpPacket[off + 12]! // 24 bits
685
+ return {
686
+ packetId: header.packetId,
687
+ ssStart,
688
+ rsbLength,
689
+ rsId,
690
+ ssbLength,
691
+ dataOffset: off + REPAIR_FEC_PID_SIZE,
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Extract Repair FEC Payload ID from a raw-multicast MMTP repair packet.
697
+ * Per ISO/IEC 23008-1:2023 Section C.5.3 (ssbg_mode0, one-stage, no FFSRP_TS):
698
+ * [MMTP header][SS_Start:4][RSB_length:3][RS_ID:3][SSB_length:3][repair data]
699
+ * OTI is delivered via AL-FEC signaling message (Table C.3), not per-packet.
700
+ *
701
+ * Byte 1 is reserved(2)|packet_type(6) for V=0, so repair type 0x03 is 0x03.
702
+ * Pre-migration high-bits bytes (0x0c) are rejected — no compatibility decode.
703
+ */
704
+ export function extractRepairFecPayloadId(mmtpPacket: Uint8Array): {
705
+ ssStart: number; rsbLength: number; rsId: number; ssbLength: number
706
+ } | null {
707
+ const fields = readRepairFecPayloadIdFields(mmtpPacket)
708
+ if (!fields) return null
709
+ return {
710
+ ssStart: fields.ssStart,
711
+ rsbLength: fields.rsbLength,
712
+ rsId: fields.rsId,
713
+ ssbLength: fields.ssbLength,
714
+ }
715
+ }
716
+
717
+ /** Parsed MoQ repair frame per ISO/IEC 23008-1 Section C.4.3 + C.5.3 */
718
+ export interface MoqRepairFrame {
719
+ /** MMTP packetId (1=video, 2=audio) — for routing to correct FEC decoder */
720
+ packetId: number
721
+ /** SS_Start (32 bits): SS_ID of first source symbol in block */
722
+ ssStart: number
723
+ /** RSB_length (24 bits): number of repair symbols (P) */
724
+ rsbLength: number
725
+ /** RS_ID (24 bits): repair symbol index (0-based) */
726
+ rsId: number
727
+ /** SSB_length (24 bits): number of source symbols (K) */
728
+ ssbLength: number
729
+ /** Repair symbol data after the dynamic MMTP header and 13-byte FEC payload ID. */
730
+ symbolData: Uint8Array
731
+ }
732
+
733
+ /**
734
+ * Parse a MoQ repair frame (ISO/IEC 23008-1 Annex C format).
735
+ *
736
+ * Wire format per ISO 23008-1 §C.4.3 + §C.5.3:
737
+ * MMTP Header: fixed 12 bytes plus C/X optional fields
738
+ * then SS_Start (4 bytes) → block identifier
739
+ * then RSB_length (3 bytes) → P
740
+ * then RS_ID (3 bytes) → ESI = K + RS_ID
741
+ * then SSB_length (3 bytes) → K
742
+ * then Repair Symbol Data (T bytes)
743
+ *
744
+ * @param frame - Raw MoQ repair frame bytes
745
+ * @returns Parsed frame or null if invalid
746
+ */
747
+ export function parseMoqRepairFrame(frame: Uint8Array): MoqRepairFrame | null {
748
+ const repairId = readRepairFecPayloadIdFields(frame)
749
+ if (!repairId) return null
750
+
751
+ const symbolData = frame.subarray(repairId.dataOffset)
752
+ if (
753
+ repairId.rsbLength === 0 ||
754
+ repairId.ssbLength === 0 ||
755
+ repairId.rsId >= repairId.rsbLength ||
756
+ symbolData.length === 0
757
+ ) return null
758
+
759
+ return {
760
+ packetId: repairId.packetId,
761
+ ssStart: repairId.ssStart,
762
+ rsbLength: repairId.rsbLength,
763
+ rsId: repairId.rsId,
764
+ ssbLength: repairId.ssbLength,
765
+ symbolData,
766
+ }
767
+ }
768
+
769
+ /**
770
+ * AL-FEC signaling configuration parsed from ISO/IEC 23008-1:2023 Table C.3 message.
771
+ */
772
+ export interface AlFecConfig {
773
+ fecCodeId: number
774
+ fecPayloadIdMode: 0
775
+ repairSymbolSize: number
776
+ maximumK: number
777
+ maximumP: number
778
+ interleaveDepth: number
779
+ oti: Uint8Array
780
+ }
781
+
782
+ function canonicalRaptorQOtiK(oti: Uint8Array, repairSymbolSize: number): number | null {
783
+ if (oti.length !== 12 || oti[5] !== 0) return null
784
+ const transferLength = (oti[0]! * 0x1_0000_0000) +
785
+ (((oti[1]! << 24) | (oti[2]! << 16) | (oti[3]! << 8) | oti[4]!) >>> 0)
786
+ const symbolSize = (oti[6]! << 8) | oti[7]!
787
+ const sourceBlocks = oti[8]!
788
+ const subBlocks = (oti[9]! << 8) | oti[10]!
789
+ const alignment = oti[11]!
790
+ if (!Number.isSafeInteger(transferLength) || transferLength <= 0 ||
791
+ symbolSize !== repairSymbolSize || symbolSize === 0 || symbolSize % 8 !== 0 ||
792
+ sourceBlocks !== 1 || subBlocks !== 1 || alignment !== 8 ||
793
+ transferLength % symbolSize !== 0) {
794
+ return null
795
+ }
796
+ const k = transferLength / symbolSize
797
+ return Number.isInteger(k) && k >= 1 && k <= 56_403 ? k : null
798
+ }
799
+
800
+ /**
801
+ * Parse AL-FEC signaling message from a full MMTP signaling packet.
802
+ *
803
+ * Wire format (ISO/IEC 23008-1:2023 Table C.3, Amendment 1:2025):
804
+ * [12-byte MMTP header (packet_type=0x02)]
805
+ * message_id(2) = 0x0203
806
+ * version(1)
807
+ * message_length(2) = length of remaining payload
808
+ * flags(1) = fec_flag(1) | private_fec_flag(1) | reserved(6)
809
+ * fec_flow_descriptor:
810
+ * length(2)
811
+ * num_flows(1)
812
+ * per flow:
813
+ * fec_flow_id(1), source_flow_id(1), num_assets(1), packet_ids(2*N),
814
+ * coding_params(1), repair_symbol_size(2),
815
+ * [one-stage]: repair_flow_id(2), fec_code_id(1),
816
+ * [private_fec_flag]: private_header(1){flag(1)|len(7)},
817
+ * current RaptorQ profile OTI(12) + interleave_depth(1)
818
+ * max_k(3), max_p(3), protection_window_time(4), protection_window_size(4)
819
+ *
820
+ * @param mmtpPacket Full MMTP packet including 12-byte header
821
+ * @returns Parsed config or null if not an AL-FEC signaling message
822
+ */
823
+ export function parseAlFecMessage(mmtpPacket: Uint8Array): AlFecConfig | null {
824
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + 9) return null
825
+ let header: MMTPHeader
826
+ try {
827
+ header = parseMMTPHeader(mmtpPacket)
828
+ } catch {
829
+ return null
830
+ }
831
+ if (header.packetType !== PACKET_TYPE_SIGNALING || header.fecType !== 0) return null
832
+ const payload = mmtpPacket.subarray(header.headerLength)
833
+ if (payload.length < 9) return null
834
+ const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
835
+ let off = 0
836
+
837
+ const messageId = view.getUint16(off, false); off += 2
838
+ if (messageId !== ALFEC_MESSAGE_ID) return null
839
+ const version = payload[off]!; off += 1
840
+ const messageLength = view.getUint16(off, false); off += 2
841
+ if (version !== 1 || messageLength === 0 || messageLength !== payload.length - off) return null
842
+
843
+ const flags = payload[off]!; off += 1
844
+ const fecFlag = (flags >> 7) & 1
845
+ const privateFecFlag = (flags >> 6) & 1
846
+ if (fecFlag !== 1 || privateFecFlag !== 1 || (flags & 0x3f) !== 0x3f) return null
847
+
848
+ if (off + 2 > payload.length) return null
849
+ const descriptorLength = view.getUint16(off, false); off += 2
850
+ const descriptorEnd = off + descriptorLength
851
+ if (descriptorLength === 0 || descriptorEnd !== payload.length) return null
852
+
853
+ const numFlows = payload[off]!; off += 1
854
+ if (numFlows !== 1 || off + 3 > descriptorEnd) return null
855
+
856
+ off += 2 // fec_flow_id, source_flow_id
857
+ const numAssets = payload[off]!; off += 1
858
+ if (numAssets === 0 || off + (numAssets * 2) > descriptorEnd) return null
859
+ off += numAssets * 2
860
+
861
+ if (off + 3 > descriptorEnd) return null
862
+ const codingParams = payload[off]!; off += 1
863
+ const fecCodingStructure = (codingParams >> 4) & 0x0f
864
+ const ssbgMode = (codingParams >> 2) & 0x03
865
+ const ffsrptsFlag = (codingParams >> 1) & 0x01
866
+ const fecPayloadIdMode = codingParams & 0x01
867
+ if (fecCodingStructure !== 1 || ssbgMode !== 0 || ffsrptsFlag !== 0 || fecPayloadIdMode !== 0) {
868
+ return null
869
+ }
870
+ const repairSymbolSize = view.getUint16(off, false); off += 2
871
+ if (repairSymbolSize === 0 || repairSymbolSize % 8 !== 0) return null
872
+
873
+ if (off + 4 > descriptorEnd) return null
874
+ off += 2 // Amendment 1:2025 replaces Table C.3 with a 16-bit repair_flow_id
875
+ const fecCodeId = payload[off]!; off += 1
876
+ if (fecCodeId !== FEC_CODE_RAPTORQ) return null
877
+ const privateHeader = payload[off]!; off += 1
878
+ const privateFlag = privateHeader >> 7
879
+ const privateFieldLength = privateHeader & 0x7f
880
+ if (privateFlag !== 1 || privateFieldLength !== 13 || off + 13 > descriptorEnd) return null
881
+ const oti = payload.slice(off, off + 12); off += 12
882
+ const interleaveDepth = payload[off]!; off += 1
883
+ const otiK = canonicalRaptorQOtiK(oti, repairSymbolSize)
884
+ if (otiK === null || interleaveDepth === 0) return null
885
+
886
+ if (off + 14 !== descriptorEnd) return null
887
+ const maximumK = (payload[off]! << 16) | (payload[off + 1]! << 8) | payload[off + 2]!
888
+ off += 3
889
+ const maximumP = (payload[off]! << 16) | (payload[off + 1]! << 8) | payload[off + 2]!
890
+ off += 3
891
+ if (maximumK !== otiK || maximumP === 0 || maximumK + maximumP - 1 > 0x00ff_ffff) {
892
+ return null
893
+ }
894
+ off += 8 // protection_window_time, protection_window_size
895
+ if (off !== descriptorEnd) return null
896
+
897
+ return { fecCodeId, fecPayloadIdMode, repairSymbolSize, maximumK, maximumP, interleaveDepth, oti }
898
+ }