@blockcast/mmt-base 0.1.0 → 0.3.0-main.1cd3cdea89da

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 CHANGED
@@ -1,10 +1,3 @@
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
1
  /**
9
2
  * @mmt/container - MMTP Parser
10
3
  *
@@ -91,6 +84,18 @@ export const PACKET_TYPE_REPAIR = 0x03
91
84
  // AL-FEC Signaling Message ID (ISO/IEC 23008-1:2023 Amendment 1:2025)
92
85
  export const ALFEC_MESSAGE_ID = 0x0203
93
86
 
87
+ /**
88
+ * MMTP `packet_id` of the signaling flow, which carries PACKET_TYPE_SIGNALING
89
+ * packets such as the AL-FEC message (ALFEC_MESSAGE_ID). The FFmpeg `moq_mmt`
90
+ * muxer sends that message on this packet_id into the same multicast group as
91
+ * the media, and moqcast-draft (PR #44) makes advertising it for a track a
92
+ * MUST NOT. A track's packet_id is therefore 1..MMTP_PACKET_ID_MAX.
93
+ */
94
+ export const MMTP_SIGNALING_PACKET_ID = 0
95
+
96
+ /** Largest value of the 16-bit MMTP `packet_id` header field. */
97
+ export const MMTP_PACKET_ID_MAX = 0xffff
98
+
94
99
  // FEC Code IDs
95
100
  export const FEC_CODE_XOR = 1
96
101
  export const FEC_CODE_RAPTORQ = 2
@@ -171,6 +176,32 @@ export function parseMMTPHeader(buffer: Uint8Array): MMTPHeader {
171
176
  }
172
177
  }
173
178
 
179
+ /**
180
+ * The MMTP `packet_id` of `buffer`, or null exactly when parseMMTPHeader would
181
+ * throw for it (short buffer, V != 0, truncated packet_counter or header
182
+ * extension).
183
+ *
184
+ * This is the per-packet filter a receiver runs before any other work, so it
185
+ * builds no header object, DataView or BigInt timestamp (BLO-36599). Use
186
+ * parseMMTPHeader when any other header field is needed.
187
+ */
188
+ export function readMMTPPacketId(buffer: Uint8Array): number | null {
189
+ if (buffer.length < MMTP_HEADER_SIZE) return null
190
+ const flags = buffer[0]!
191
+ if (((flags >> 6) & 0x03) !== 0) return null
192
+ let offset = MMTP_HEADER_SIZE
193
+ if ((flags & 0x20) !== 0) {
194
+ if (offset + 4 > buffer.length) return null
195
+ offset += 4
196
+ }
197
+ if ((flags & 0x02) !== 0) {
198
+ if (offset + 4 > buffer.length) return null
199
+ const length = (buffer[offset + 2]! << 8) | buffer[offset + 3]!
200
+ if (offset + 4 + length > buffer.length) return null
201
+ }
202
+ return (buffer[2]! << 8) | buffer[3]!
203
+ }
204
+
174
205
  /**
175
206
  * Unwrap MMTP packet to extract payload
176
207
  */
@@ -236,6 +267,30 @@ export function parseTimedMfuDuHeader(payload: Uint8Array, offset: number): MFUD
236
267
  }
237
268
  }
238
269
 
270
+ /**
271
+ * Why `extractMFUPayload` refused a packet.
272
+ *
273
+ * These identifiers are part of the public contract: callers surface them in
274
+ * diagnostics, so renaming one is a breaking change.
275
+ */
276
+ export type MFURejectionGate =
277
+ | 'packet-shorter-than-mmtp-plus-mpu-header'
278
+ | 'mmtp-header-unparseable'
279
+ | 'not-an-mpu-packet'
280
+ | 'payload-shorter-than-mpu-header'
281
+ | 'aggregation-flag-set'
282
+ | 'mpu-payload-length-below-header-remainder'
283
+ | 'mpu-declared-end-exceeds-payload'
284
+ | 'mfu-offset-at-or-past-declared-end'
285
+ | 'source-fec-payload-id-width-mismatch'
286
+ | 'declared-trailers-unresolved'
287
+
288
+ export interface MFURejection {
289
+ gate: MFURejectionGate
290
+ /** Human-readable detail, including the byte arithmetic that decided it. */
291
+ detail: string
292
+ }
293
+
239
294
  interface MfuPayloadLayout {
240
295
  hasMfuDuHeader: boolean
241
296
  mfuOffset: number
@@ -246,29 +301,62 @@ function resolveMfuPayloadLayout(
246
301
  mpu: MPUHeader,
247
302
  fragmentType: number,
248
303
  payload: Uint8Array,
249
- ): MfuPayloadLayout | null {
250
- if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) return null
304
+ ): { layout: MfuPayloadLayout } | { rejection: MFURejection } {
305
+ if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) {
306
+ return {
307
+ rejection: {
308
+ gate: 'mpu-payload-length-below-header-remainder',
309
+ detail: `payload_length=${mpu.payloadLength} < ${MPU_HEADER_REMAINDER_SIZE}`,
310
+ },
311
+ }
312
+ }
251
313
 
252
314
  // ISO/IEC 23008-1 defines payload_length from immediately after its own
253
315
  // two-byte field. It therefore includes the remaining six MPU-header bytes.
254
316
  const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength
255
- if (declaredEnd > payload.length) return null
317
+ if (declaredEnd > payload.length) {
318
+ return {
319
+ rejection: {
320
+ gate: 'mpu-declared-end-exceeds-payload',
321
+ detail:
322
+ `payload_length=${mpu.payloadLength} implies end=${declaredEnd}, ` +
323
+ `payload is ${payload.length} bytes`,
324
+ },
325
+ }
326
+ }
256
327
 
257
328
  const hasHeader = fragmentType === 2 && mpu.fragmentationIndicator <= 1
258
329
  const mfuHeaderSize = mpu.timedFlag ? MFU_DU_HEADER_SIZE_TIMED : MFU_DU_HEADER_SIZE_NONTIMED
259
330
  const mfuOffset = MPU_HEADER_SIZE + (hasHeader ? mfuHeaderSize : 0)
260
- if (mfuOffset >= declaredEnd) return null
331
+ if (mfuOffset >= declaredEnd) {
332
+ return {
333
+ rejection: {
334
+ gate: 'mfu-offset-at-or-past-declared-end',
335
+ detail: `mfuOffset=${mfuOffset} >= declaredEnd=${declaredEnd}`,
336
+ },
337
+ }
338
+ }
261
339
 
262
340
  return {
263
- hasMfuDuHeader: hasHeader,
264
- mfuOffset,
265
- dataEnd: declaredEnd,
341
+ layout: {
342
+ hasMfuDuHeader: hasHeader,
343
+ mfuOffset,
344
+ dataEnd: declaredEnd,
345
+ },
266
346
  }
267
347
  }
268
348
 
269
- function isEmptyOrExactAltaTrailer(payload: Uint8Array, start: number, end: number): boolean {
349
+ // Legacy detached per-packet authenticator suffix `[0xf0a1][len][auth]`. Its
350
+ // producer was removed (BLO-16483) and nothing verifies it any more; provenance
351
+ // is the signed per-group manifest. Receivers still skip an exact suffix so a
352
+ // stale producer degrades to unauthenticated playback instead of dropped
353
+ // packets. ponytail: hard-cut to reject once no deployed producer can emit it.
354
+ const LEGACY_TRAILER_EXT_TYPE = 0xf0a1
355
+ const LEGACY_TRAILER_HEADER_SIZE = 4
356
+
357
+ function isEmptyOrExactLegacyTrailer(payload: Uint8Array, start: number, end: number): boolean {
270
358
  if (start === end) return true
271
- if (start < 0 || end > payload.length || start + _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE > end) {
359
+ if (start < 0 || end > payload.length || start + LEGACY_TRAILER_HEADER_SIZE > end) {
272
360
  return false
273
361
  }
274
362
 
@@ -277,10 +365,10 @@ function isEmptyOrExactAltaTrailer(payload: Uint8Array, start: number, end: numb
277
365
  payload.byteOffset + start,
278
366
  end - start,
279
367
  )
280
- if (view.getUint16(0, false) !== _LEAF_MMTP_EXT_TYPE_ALTA_AUTH) return false
368
+ if (view.getUint16(0, false) !== LEGACY_TRAILER_EXT_TYPE) return false
281
369
 
282
370
  const authLength = view.getUint16(2, false)
283
- return start + _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE + authLength === end
371
+ return start + LEGACY_TRAILER_HEADER_SIZE + authLength === end
284
372
  }
285
373
 
286
374
  interface DeclaredTrailerLayout {
@@ -296,21 +384,21 @@ function resolveDeclaredTrailers(
296
384
  if (declaredEnd > payload.length) return null
297
385
 
298
386
  if (options?.skipFecId) {
299
- return isEmptyOrExactAltaTrailer(payload, declaredEnd, payload.length)
387
+ return isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)
300
388
  ? { hasFecId: false }
301
389
  : null
302
390
  }
303
391
 
304
392
  if (fecType === 1) {
305
393
  const fecStart = payload.length - SOURCE_FEC_PAYLOAD_ID_SIZE
306
- if (fecStart < declaredEnd || !isEmptyOrExactAltaTrailer(payload, declaredEnd, fecStart)) {
394
+ if (fecStart < declaredEnd || !isEmptyOrExactLegacyTrailer(payload, declaredEnd, fecStart)) {
307
395
  return null
308
396
  }
309
397
  return { hasFecId: true }
310
398
  }
311
399
  if (fecType !== 0) return null
312
400
 
313
- if (isEmptyOrExactAltaTrailer(payload, declaredEnd, payload.length)) {
401
+ if (isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)) {
314
402
  return { hasFecId: false }
315
403
  }
316
404
 
@@ -354,8 +442,8 @@ export interface MFUExtraction {
354
442
  * - Source FEC Payload ID (4 bytes at end, only when `FEC_type == 1`)
355
443
  *
356
444
  * `payloadLength` is the ISO length-after-field value: six remaining MPU-header
357
- * bytes, then the MFU DU header when present, then media. ALTA and Source FEC
358
- * trailers are outside that declared span and must consume the exact suffix.
445
+ * bytes, then the MFU DU header when present, then media. The legacy
446
+ * authenticator suffix and the Source FEC trailer are outside that declared span and must consume the exact suffix.
359
447
  *
360
448
  * @param mmtpPacket Raw MMTP packet (from multicast or MoQ)
361
449
  * @param options.skipFecId When true, do NOT subtract the 4-byte Source FEC Payload ID
@@ -365,43 +453,172 @@ export interface MFUExtraction {
365
453
  * draft-ramadan-moq-mmt-00 §3.1).
366
454
  * @returns Extracted MFU data + metadata, or null if packet is too small
367
455
  */
368
- export function extractMFUPayload(
456
+ interface MfuPlan {
457
+ header: MMTPHeader
458
+ payload: Uint8Array
459
+ mpu: MPUHeader
460
+ fragmentType: number
461
+ layout: MfuPayloadLayout
462
+ trailerLayout: DeclaredTrailerLayout
463
+ }
464
+
465
+ /**
466
+ * Single source of truth for whether a packet yields an MFU payload.
467
+ *
468
+ * `extractMFUPayload` and `explainMFURejection` both delegate here, so the
469
+ * reason reported to operators can never drift from the decision the parser
470
+ * actually made.
471
+ */
472
+ function planMfuExtraction(
369
473
  mmtpPacket: Uint8Array,
370
474
  options?: { skipFecId?: boolean },
371
- ): MFUExtraction | null {
372
- if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null
475
+ parsedHeader?: MMTPHeader,
476
+ ): { plan: MfuPlan } | { rejection: MFURejection } {
477
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) {
478
+ return {
479
+ rejection: {
480
+ gate: 'packet-shorter-than-mmtp-plus-mpu-header',
481
+ detail: `${mmtpPacket.length} bytes < ${MMTP_HEADER_SIZE + MPU_HEADER_SIZE}`,
482
+ },
483
+ }
484
+ }
373
485
 
374
486
  let header: MMTPHeader
375
- try {
376
- header = parseMMTPHeader(mmtpPacket)
377
- } catch {
378
- return null
487
+ if (parsedHeader) {
488
+ header = parsedHeader
489
+ } else {
490
+ try {
491
+ header = parseMMTPHeader(mmtpPacket)
492
+ } catch (error) {
493
+ return {
494
+ rejection: {
495
+ gate: 'mmtp-header-unparseable',
496
+ detail: error instanceof Error ? error.message : String(error),
497
+ },
498
+ }
499
+ }
379
500
  }
380
- if (header.packetType !== PACKET_TYPE_MPU) return null
501
+ if (header.packetType !== PACKET_TYPE_MPU) {
502
+ return {
503
+ rejection: {
504
+ gate: 'not-an-mpu-packet',
505
+ detail: `packetType=0x${header.packetType.toString(16).padStart(2, '0')}`,
506
+ },
507
+ }
508
+ }
509
+
381
510
  const payload = mmtpPacket.subarray(header.headerLength)
382
- if (payload.length < MPU_HEADER_SIZE) return null
511
+ if (payload.length < MPU_HEADER_SIZE) {
512
+ return {
513
+ rejection: {
514
+ gate: 'payload-shorter-than-mpu-header',
515
+ detail: `${payload.length} bytes < ${MPU_HEADER_SIZE}`,
516
+ },
517
+ }
518
+ }
519
+
383
520
  const mpu = parseMPUHeader(payload)
384
- if (mpu.aggregationFlag) return null
521
+ if (mpu.aggregationFlag) {
522
+ return {
523
+ rejection: { gate: 'aggregation-flag-set', detail: 'strict profile requires A=0' },
524
+ }
525
+ }
385
526
 
386
527
  // Fragment type from byte 2 upper nibble (not returned by parseMPUHeader)
387
528
  const fragmentType = (payload[2]! >> 4) & 0x0f
388
529
 
389
- const layout = resolveMfuPayloadLayout(
390
- mpu,
391
- fragmentType,
530
+ const layoutResult = resolveMfuPayloadLayout(mpu, fragmentType, payload)
531
+ if ('rejection' in layoutResult) return layoutResult
532
+ const { layout } = layoutResult
533
+
534
+ const trailerLayout = resolveDeclaredTrailers(
392
535
  payload,
536
+ layout.dataEnd,
537
+ header.fecType,
538
+ options,
393
539
  )
394
- if (!layout) return null
540
+ if (!trailerLayout) {
541
+ // The most common cause is a producer whose Source FEC Payload ID width
542
+ // disagrees with ISO/IEC 23008-1 §C.5.2. Name that explicitly rather than
543
+ // leaving operators to infer it from a generic trailer error.
544
+ if (!options?.skipFecId && header.fecType === 1) {
545
+ const observed = payload.length - layout.dataEnd
546
+ if (observed !== SOURCE_FEC_PAYLOAD_ID_SIZE) {
547
+ return {
548
+ rejection: {
549
+ gate: 'source-fec-payload-id-width-mismatch',
550
+ detail:
551
+ `producer wrote a ${observed}-byte Source FEC Payload ID, ` +
552
+ `ISO/IEC 23008-1 §C.5.2 requires ${SOURCE_FEC_PAYLOAD_ID_SIZE} ` +
553
+ `(payload=${payload.length}, declaredEnd=${layout.dataEnd})`,
554
+ },
555
+ }
556
+ }
557
+ }
558
+ return {
559
+ rejection: {
560
+ gate: 'declared-trailers-unresolved',
561
+ detail:
562
+ `trailer bytes [${layout.dataEnd}, ${payload.length}) are neither empty ` +
563
+ `nor an exact legacy authenticator trailer (fecType=${header.fecType})`,
564
+ },
565
+ }
566
+ }
567
+
568
+ return { plan: { header, payload, mpu, fragmentType, layout, trailerLayout } }
569
+ }
570
+
571
+ /**
572
+ * Report why `extractMFUPayload` would refuse this packet, or null if it would
573
+ * accept it.
574
+ *
575
+ * `extractMFUPayload` returns bare `null` on ten distinct conditions, which
576
+ * makes a producer/consumer framing disagreement indistinguishable from a
577
+ * publisher that never started — both render as silence. Use this wherever
578
+ * that distinction matters: diagnostics, contract tests, and operator-facing
579
+ * logs.
580
+ */
581
+ export function explainMFURejection(
582
+ mmtpPacket: Uint8Array,
583
+ options?: { skipFecId?: boolean },
584
+ ): MFURejection | null {
585
+ const result = planMfuExtraction(mmtpPacket, options)
586
+ return 'rejection' in result ? result.rejection : null
587
+ }
588
+
589
+ export function extractMFUPayload(
590
+ mmtpPacket: Uint8Array,
591
+ options?: { skipFecId?: boolean },
592
+ ): MFUExtraction | null {
593
+ return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options))
594
+ }
395
595
 
396
- const trailerLayout = resolveDeclaredTrailers(payload, layout.dataEnd, header.fecType, options)
397
- if (!trailerLayout) return null
596
+ /**
597
+ * extractMFUPayload for a caller that already holds this packet's parsed MMTP
598
+ * header, so the header is not parsed a second time (BLO-36599).
599
+ *
600
+ * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet; the
601
+ * result is then identical to `extractMFUPayload(mmtpPacket, options)`.
602
+ */
603
+ export function extractMFUPayloadWithHeader(
604
+ mmtpPacket: Uint8Array,
605
+ header: MMTPHeader,
606
+ options?: { skipFecId?: boolean },
607
+ ): MFUExtraction | null {
608
+ return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options, header))
609
+ }
610
+
611
+ function extractPlannedMfu(
612
+ mmtpPacket: Uint8Array,
613
+ result: { plan: MfuPlan } | { rejection: MFURejection },
614
+ ): MFUExtraction | null {
615
+ if ('rejection' in result) return null
616
+ const { header, payload, mpu, fragmentType, layout, trailerLayout } = result.plan
398
617
 
399
618
  const mfuDuHeaderPresent = layout.hasMfuDuHeader
400
619
  const mfuOffset = layout.mfuOffset
401
620
  const dataEnd = layout.dataEnd
402
621
 
403
- if (mfuOffset >= dataEnd) return null
404
-
405
622
  const mfuDuHeader =
406
623
  mfuDuHeaderPresent && mpu.timedFlag === 1
407
624
  ? parseTimedMfuDuHeader(payload, MPU_HEADER_SIZE)
@@ -449,24 +666,18 @@ export function isFecRepairPacket(mmtpPacket: Uint8Array): boolean {
449
666
  * source packet per ISO/IEC 23008-1:2023 Section C.5.2 Figure C.10.
450
667
  *
451
668
  * SS_ID is a flat 32-bit monotonic counter (incremented by 1 per packet
452
- * for ssbg_mode0 and ssbg_mode1). Block membership and ESI are derived
453
- * at recovery time from repair packet SS_Start and SSB_length:
454
- * SBN = floor(SS_ID / K), ESI = SS_ID % K
669
+ * for ssbg_mode0 and ssbg_mode1). Block membership and ESI use the
670
+ * configured K; repair SS_Start identifies the first SS_ID of the block and
671
+ * SSB_length records the authored source-symbol count:
672
+ * SBN = floor(SS_ID / K), ESI = SS_ID % K
455
673
  * SS_Start = SBN * K (first SS_ID of the block)
456
674
  *
457
675
  * This matches FFmpeg moqenc_mmt.c, the drafts (draft-ramadan-moq-fec §6.2),
458
676
  * and ATSC A/331 §8.1.2.5 which defers to ISO 23008-1 Annex C.
459
677
  */
460
678
  export function extractFecPayloadId(mmtpPacket: Uint8Array): { ssId: number } | null {
461
- if (!hasSourceFecPayloadId(mmtpPacket)) return null
462
- // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id
463
- const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE
464
- const view = new DataView(
465
- mmtpPacket.buffer,
466
- mmtpPacket.byteOffset + off,
467
- SOURCE_FEC_PAYLOAD_ID_SIZE,
468
- )
469
- return { ssId: view.getUint32(0, false) }
679
+ const header = parseHeaderForSourceFecId(mmtpPacket)
680
+ return header ? sourceFecPayloadIdOf(mmtpPacket, header) : null
470
681
  }
471
682
 
472
683
  /**
@@ -474,59 +685,85 @@ export function extractFecPayloadId(mmtpPacket: Uint8Array): { ssId: number } |
474
685
  * payload contains a four-byte Source FEC Payload ID.
475
686
  */
476
687
  export function hasSourceFecPayloadId(mmtpPacket: Uint8Array): boolean {
477
- if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return false
688
+ const header = parseHeaderForSourceFecId(mmtpPacket)
689
+ return header !== null && sourceFecPayloadIdOf(mmtpPacket, header) !== null
690
+ }
478
691
 
479
- let header: MMTPHeader
692
+ function parseHeaderForSourceFecId(mmtpPacket: Uint8Array): MMTPHeader | null {
693
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null
480
694
  try {
481
- header = parseMMTPHeader(mmtpPacket)
695
+ return parseMMTPHeader(mmtpPacket)
482
696
  } catch {
483
- return false
697
+ return null
484
698
  }
485
- if (header.packetType !== PACKET_TYPE_MPU) return false
486
-
487
- const payload = mmtpPacket.subarray(header.headerLength)
488
- if (payload.length < MPU_HEADER_SIZE) return false
489
- const mpu = parseMPUHeader(payload)
490
- if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) return false
491
- const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength
492
- return resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId ?? false
493
699
  }
494
700
 
495
701
  /**
496
- * MMTP header-extension ext_type assigned to the detached ALTA authenticator
497
- * trailer (Path 2 / 08-07). Carried in an `[ext_type][ext_length][auth]`
498
- * block appended after the MPU's declared payload bytes, before the 4-byte
499
- * Source FEC Payload ID. MMTP X-bit stays 0 — non-ALTA MMTP receivers treat
500
- * the trailer as bytes past `payload_length` and skip it (per ISO 23008-1
501
- * §9.2.2 "may be discarded without impacting correct processing").
702
+ * The Source FEC Payload ID of a packet whose MMTP header the caller already
703
+ * parsed, or null when the packet carries none: present only when FEC_type=1
704
+ * and the exact suffix after the ISO-declared MPU payload holds the four-byte
705
+ * ID (ISO/IEC 23008-1 §C.5.2). Same answer as extractFecPayloadId, without a
706
+ * second header parse (BLO-36599).
707
+ *
708
+ * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet.
502
709
  */
503
- // Re-export the ALTA trailer helper from the zero-dep leaf package. The leaf
504
- // owns the implementation so container and FEC consumers share one parser
505
- // without creating a package cycle.
506
- export const MMTP_EXT_TYPE_ALTA_AUTH = _LEAF_MMTP_EXT_TYPE_ALTA_AUTH
507
- export const MMTP_EXT_TRAILER_HEADER_SIZE = _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE
508
- export type AltaTrailer = _LeafAltaTrailer
509
- export const extractAltaTrailer = _leafExtractAltaTrailer
710
+ export function sourceFecPayloadIdOf(
711
+ mmtpPacket: Uint8Array,
712
+ header: MMTPHeader,
713
+ ): { ssId: number } | null {
714
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null
715
+ if (header.packetType !== PACKET_TYPE_MPU) return null
716
+
717
+ const payload = mmtpPacket.subarray(header.headerLength)
718
+ if (payload.length < MPU_HEADER_SIZE) return null
719
+ const payloadLength = (payload[0]! << 8) | payload[1]!
720
+ if (payloadLength < MPU_HEADER_REMAINDER_SIZE) return null
721
+ const declaredEnd = MPU_LENGTH_FIELD_SIZE + payloadLength
722
+ if (!resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId) return null
723
+
724
+ // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id
725
+ const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE
726
+ return {
727
+ ssId:
728
+ ((mmtpPacket[off]! << 24) |
729
+ (mmtpPacket[off + 1]! << 16) |
730
+ (mmtpPacket[off + 2]! << 8) |
731
+ mmtpPacket[off + 3]!) >>>
732
+ 0,
733
+ }
734
+ }
510
735
 
511
736
  /** Size of Repair FEC Payload ID per ISO/IEC 23008-1 Section C.5.3 */
512
737
  export const REPAIR_FEC_PID_SIZE = 13
513
738
 
514
- /** Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header. */
515
- function readRepairFecPayloadIdFields(mmtpPacket: Uint8Array): {
739
+ /**
740
+ * Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header.
741
+ * `parsedHeader`, when given, must be `parseMMTPHeader(mmtpPacket)` of this
742
+ * same packet; it spares the second header parse (BLO-36599).
743
+ */
744
+ function readRepairFecPayloadIdFields(mmtpPacket: Uint8Array, parsedHeader?: MMTPHeader): {
516
745
  packetId: number; ssStart: number; rsbLength: number; rsId: number; ssbLength: number; dataOffset: number
517
746
  } | null {
518
747
  let header: MMTPHeader
519
- try {
520
- header = parseMMTPHeader(mmtpPacket)
521
- } catch {
522
- return null
748
+ if (parsedHeader) {
749
+ header = parsedHeader
750
+ } else {
751
+ try {
752
+ header = parseMMTPHeader(mmtpPacket)
753
+ } catch {
754
+ return null
755
+ }
523
756
  }
524
757
  if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2) return null
525
758
 
526
759
  const off = header.headerLength
527
760
  if (mmtpPacket.length < off + REPAIR_FEC_PID_SIZE) return null
528
- const view = new DataView(mmtpPacket.buffer, mmtpPacket.byteOffset + off, REPAIR_FEC_PID_SIZE)
529
- const ssStart = view.getUint32(0, false) // 32 bits
761
+ const ssStart =
762
+ ((mmtpPacket[off]! << 24) |
763
+ (mmtpPacket[off + 1]! << 16) |
764
+ (mmtpPacket[off + 2]! << 8) |
765
+ mmtpPacket[off + 3]!) >>>
766
+ 0 // 32 bits
530
767
  const rsbLength = (mmtpPacket[off + 4]! << 16) | (mmtpPacket[off + 5]! << 8) | mmtpPacket[off + 6]! // 24 bits
531
768
  const rsId = (mmtpPacket[off + 7]! << 16) | (mmtpPacket[off + 8]! << 8) | mmtpPacket[off + 9]! // 24 bits
532
769
  const ssbLength = (mmtpPacket[off + 10]! << 16) | (mmtpPacket[off + 11]! << 8) | mmtpPacket[off + 12]! // 24 bits
@@ -572,7 +809,7 @@ export interface MoqRepairFrame {
572
809
  rsbLength: number
573
810
  /** RS_ID (24 bits): repair symbol index (0-based) */
574
811
  rsId: number
575
- /** SSB_length (24 bits): number of source symbols (K) */
812
+ /** SSB_length (24 bits): authored source-symbol count (1..=configured K) */
576
813
  ssbLength: number
577
814
  /** Repair symbol data after the dynamic MMTP header and 13-byte FEC payload ID. */
578
815
  symbolData: Uint8Array
@@ -586,14 +823,36 @@ export interface MoqRepairFrame {
586
823
  * then SS_Start (4 bytes) → block identifier
587
824
  * then RSB_length (3 bytes) → P
588
825
  * then RS_ID (3 bytes) → ESI = K + RS_ID
589
- * then SSB_length (3 bytes) → K
826
+ * then SSB_length (3 bytes) → authored source-symbol count (1..=K)
590
827
  * then Repair Symbol Data (T bytes)
591
828
  *
592
829
  * @param frame - Raw MoQ repair frame bytes
593
830
  * @returns Parsed frame or null if invalid
594
831
  */
595
832
  export function parseMoqRepairFrame(frame: Uint8Array): MoqRepairFrame | null {
596
- const repairId = readRepairFecPayloadIdFields(frame)
833
+ return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame))
834
+ }
835
+
836
+ /**
837
+ * parseMoqRepairFrame for a caller that already holds this frame's parsed MMTP
838
+ * header, so the header is not parsed a second time (BLO-36599). The router's
839
+ * multicast repair branch uses it: one repair packet in every K + P otherwise
840
+ * paid two header parses.
841
+ *
842
+ * `header` must be `parseMMTPHeader(frame)` of this same frame; the result is
843
+ * then identical to `parseMoqRepairFrame(frame)`.
844
+ */
845
+ export function parseMoqRepairFrameWithHeader(
846
+ frame: Uint8Array,
847
+ header: MMTPHeader,
848
+ ): MoqRepairFrame | null {
849
+ return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame, header))
850
+ }
851
+
852
+ function moqRepairFrameFrom(
853
+ frame: Uint8Array,
854
+ repairId: ReturnType<typeof readRepairFecPayloadIdFields>,
855
+ ): MoqRepairFrame | null {
597
856
  if (!repairId) return null
598
857
 
599
858
  const symbolData = frame.subarray(repairId.dataOffset)
@@ -614,133 +873,5 @@ export function parseMoqRepairFrame(frame: Uint8Array): MoqRepairFrame | null {
614
873
  }
615
874
  }
616
875
 
617
- /**
618
- * AL-FEC signaling configuration parsed from ISO/IEC 23008-1:2023 Table C.3 message.
619
- */
620
- export interface AlFecConfig {
621
- fecCodeId: number
622
- fecPayloadIdMode: 0
623
- repairSymbolSize: number
624
- maximumK: number
625
- maximumP: number
626
- interleaveDepth: number
627
- oti: Uint8Array
628
- }
629
-
630
- function canonicalRaptorQOtiK(oti: Uint8Array, repairSymbolSize: number): number | null {
631
- if (oti.length !== 12 || oti[5] !== 0) return null
632
- const transferLength = (oti[0]! * 0x1_0000_0000) +
633
- (((oti[1]! << 24) | (oti[2]! << 16) | (oti[3]! << 8) | oti[4]!) >>> 0)
634
- const symbolSize = (oti[6]! << 8) | oti[7]!
635
- const sourceBlocks = oti[8]!
636
- const subBlocks = (oti[9]! << 8) | oti[10]!
637
- const alignment = oti[11]!
638
- if (!Number.isSafeInteger(transferLength) || transferLength <= 0 ||
639
- symbolSize !== repairSymbolSize || symbolSize === 0 || symbolSize % 8 !== 0 ||
640
- sourceBlocks !== 1 || subBlocks !== 1 || alignment !== 8 ||
641
- transferLength % symbolSize !== 0) {
642
- return null
643
- }
644
- const k = transferLength / symbolSize
645
- return Number.isInteger(k) && k >= 1 && k <= 56_403 ? k : null
646
- }
647
-
648
- /**
649
- * Parse AL-FEC signaling message from a full MMTP signaling packet.
650
- *
651
- * Wire format (ISO/IEC 23008-1:2023 Table C.3, Amendment 1:2025):
652
- * [12-byte MMTP header (packet_type=0x02)]
653
- * message_id(2) = 0x0203
654
- * version(1)
655
- * message_length(2) = length of remaining payload
656
- * flags(1) = fec_flag(1) | private_fec_flag(1) | reserved(6)
657
- * fec_flow_descriptor:
658
- * length(2)
659
- * num_flows(1)
660
- * per flow:
661
- * fec_flow_id(1), source_flow_id(1), num_assets(1), packet_ids(2*N),
662
- * coding_params(1), repair_symbol_size(2),
663
- * [one-stage]: repair_flow_id(2), fec_code_id(1),
664
- * [private_fec_flag]: private_header(1){flag(1)|len(7)},
665
- * current RaptorQ profile OTI(12) + interleave_depth(1)
666
- * max_k(3), max_p(3), protection_window_time(4), protection_window_size(4)
667
- *
668
- * @param mmtpPacket Full MMTP packet including 12-byte header
669
- * @returns Parsed config or null if not an AL-FEC signaling message
670
- */
671
- export function parseAlFecMessage(mmtpPacket: Uint8Array): AlFecConfig | null {
672
- if (mmtpPacket.length < MMTP_HEADER_SIZE + 9) return null
673
- let header: MMTPHeader
674
- try {
675
- header = parseMMTPHeader(mmtpPacket)
676
- } catch {
677
- return null
678
- }
679
- if (header.packetType !== PACKET_TYPE_SIGNALING || header.fecType !== 0) return null
680
- const payload = mmtpPacket.subarray(header.headerLength)
681
- if (payload.length < 9) return null
682
- const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
683
- let off = 0
684
-
685
- const messageId = view.getUint16(off, false); off += 2
686
- if (messageId !== ALFEC_MESSAGE_ID) return null
687
- const version = payload[off]!; off += 1
688
- const messageLength = view.getUint16(off, false); off += 2
689
- if (version !== 1 || messageLength === 0 || messageLength !== payload.length - off) return null
690
-
691
- const flags = payload[off]!; off += 1
692
- const fecFlag = (flags >> 7) & 1
693
- const privateFecFlag = (flags >> 6) & 1
694
- if (fecFlag !== 1 || privateFecFlag !== 1 || (flags & 0x3f) !== 0x3f) return null
695
-
696
- if (off + 2 > payload.length) return null
697
- const descriptorLength = view.getUint16(off, false); off += 2
698
- const descriptorEnd = off + descriptorLength
699
- if (descriptorLength === 0 || descriptorEnd !== payload.length) return null
700
-
701
- const numFlows = payload[off]!; off += 1
702
- if (numFlows !== 1 || off + 3 > descriptorEnd) return null
703
-
704
- off += 2 // fec_flow_id, source_flow_id
705
- const numAssets = payload[off]!; off += 1
706
- if (numAssets === 0 || off + (numAssets * 2) > descriptorEnd) return null
707
- off += numAssets * 2
708
-
709
- if (off + 3 > descriptorEnd) return null
710
- const codingParams = payload[off]!; off += 1
711
- const fecCodingStructure = (codingParams >> 4) & 0x0f
712
- const ssbgMode = (codingParams >> 2) & 0x03
713
- const ffsrptsFlag = (codingParams >> 1) & 0x01
714
- const fecPayloadIdMode = codingParams & 0x01
715
- if (fecCodingStructure !== 1 || ssbgMode !== 0 || ffsrptsFlag !== 0 || fecPayloadIdMode !== 0) {
716
- return null
717
- }
718
- const repairSymbolSize = view.getUint16(off, false); off += 2
719
- if (repairSymbolSize === 0 || repairSymbolSize % 8 !== 0) return null
720
-
721
- if (off + 4 > descriptorEnd) return null
722
- off += 2 // Amendment 1:2025 replaces Table C.3 with a 16-bit repair_flow_id
723
- const fecCodeId = payload[off]!; off += 1
724
- if (fecCodeId !== FEC_CODE_RAPTORQ) return null
725
- const privateHeader = payload[off]!; off += 1
726
- const privateFlag = privateHeader >> 7
727
- const privateFieldLength = privateHeader & 0x7f
728
- if (privateFlag !== 1 || privateFieldLength !== 13 || off + 13 > descriptorEnd) return null
729
- const oti = payload.slice(off, off + 12); off += 12
730
- const interleaveDepth = payload[off]!; off += 1
731
- const otiK = canonicalRaptorQOtiK(oti, repairSymbolSize)
732
- if (otiK === null || interleaveDepth === 0) return null
733
-
734
- if (off + 14 !== descriptorEnd) return null
735
- const maximumK = (payload[off]! << 16) | (payload[off + 1]! << 8) | payload[off + 2]!
736
- off += 3
737
- const maximumP = (payload[off]! << 16) | (payload[off + 1]! << 8) | payload[off + 2]!
738
- off += 3
739
- if (maximumK !== otiK || maximumP === 0 || maximumK + maximumP - 1 > 0x00ff_ffff) {
740
- return null
741
- }
742
- off += 8 // protection_window_time, protection_window_size
743
- if (off !== descriptorEnd) return null
744
-
745
- return { fecCodeId, fecPayloadIdMode, repairSymbolSize, maximumK, maximumP, interleaveDepth, oti }
746
- }
876
+ // The signalling-message parsers (9.3.4.2 payload header, AL-FEC message,
877
+ // PA/MPT) live in ./signalling.ts.