@blockcast/mmt-base 0.1.0 → 0.3.0-main.3f0b3688e8b7

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
@@ -91,6 +91,18 @@ export const PACKET_TYPE_REPAIR = 0x03
91
91
  // AL-FEC Signaling Message ID (ISO/IEC 23008-1:2023 Amendment 1:2025)
92
92
  export const ALFEC_MESSAGE_ID = 0x0203
93
93
 
94
+ /**
95
+ * MMTP `packet_id` of the signaling flow, which carries PACKET_TYPE_SIGNALING
96
+ * packets such as the AL-FEC message (ALFEC_MESSAGE_ID). The FFmpeg `moq_mmt`
97
+ * muxer sends that message on this packet_id into the same multicast group as
98
+ * the media, and moqcast-draft (PR #44) makes advertising it for a track a
99
+ * MUST NOT. A track's packet_id is therefore 1..MMTP_PACKET_ID_MAX.
100
+ */
101
+ export const MMTP_SIGNALING_PACKET_ID = 0
102
+
103
+ /** Largest value of the 16-bit MMTP `packet_id` header field. */
104
+ export const MMTP_PACKET_ID_MAX = 0xffff
105
+
94
106
  // FEC Code IDs
95
107
  export const FEC_CODE_XOR = 1
96
108
  export const FEC_CODE_RAPTORQ = 2
@@ -171,6 +183,32 @@ export function parseMMTPHeader(buffer: Uint8Array): MMTPHeader {
171
183
  }
172
184
  }
173
185
 
186
+ /**
187
+ * The MMTP `packet_id` of `buffer`, or null exactly when parseMMTPHeader would
188
+ * throw for it (short buffer, V != 0, truncated packet_counter or header
189
+ * extension).
190
+ *
191
+ * This is the per-packet filter a receiver runs before any other work, so it
192
+ * builds no header object, DataView or BigInt timestamp (BLO-36599). Use
193
+ * parseMMTPHeader when any other header field is needed.
194
+ */
195
+ export function readMMTPPacketId(buffer: Uint8Array): number | null {
196
+ if (buffer.length < MMTP_HEADER_SIZE) return null
197
+ const flags = buffer[0]!
198
+ if (((flags >> 6) & 0x03) !== 0) return null
199
+ let offset = MMTP_HEADER_SIZE
200
+ if ((flags & 0x20) !== 0) {
201
+ if (offset + 4 > buffer.length) return null
202
+ offset += 4
203
+ }
204
+ if ((flags & 0x02) !== 0) {
205
+ if (offset + 4 > buffer.length) return null
206
+ const length = (buffer[offset + 2]! << 8) | buffer[offset + 3]!
207
+ if (offset + 4 + length > buffer.length) return null
208
+ }
209
+ return (buffer[2]! << 8) | buffer[3]!
210
+ }
211
+
174
212
  /**
175
213
  * Unwrap MMTP packet to extract payload
176
214
  */
@@ -236,6 +274,30 @@ export function parseTimedMfuDuHeader(payload: Uint8Array, offset: number): MFUD
236
274
  }
237
275
  }
238
276
 
277
+ /**
278
+ * Why `extractMFUPayload` refused a packet.
279
+ *
280
+ * These identifiers are part of the public contract: callers surface them in
281
+ * diagnostics, so renaming one is a breaking change.
282
+ */
283
+ export type MFURejectionGate =
284
+ | 'packet-shorter-than-mmtp-plus-mpu-header'
285
+ | 'mmtp-header-unparseable'
286
+ | 'not-an-mpu-packet'
287
+ | 'payload-shorter-than-mpu-header'
288
+ | 'aggregation-flag-set'
289
+ | 'mpu-payload-length-below-header-remainder'
290
+ | 'mpu-declared-end-exceeds-payload'
291
+ | 'mfu-offset-at-or-past-declared-end'
292
+ | 'source-fec-payload-id-width-mismatch'
293
+ | 'declared-trailers-unresolved'
294
+
295
+ export interface MFURejection {
296
+ gate: MFURejectionGate
297
+ /** Human-readable detail, including the byte arithmetic that decided it. */
298
+ detail: string
299
+ }
300
+
239
301
  interface MfuPayloadLayout {
240
302
  hasMfuDuHeader: boolean
241
303
  mfuOffset: number
@@ -246,23 +308,48 @@ function resolveMfuPayloadLayout(
246
308
  mpu: MPUHeader,
247
309
  fragmentType: number,
248
310
  payload: Uint8Array,
249
- ): MfuPayloadLayout | null {
250
- if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) return null
311
+ ): { layout: MfuPayloadLayout } | { rejection: MFURejection } {
312
+ if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) {
313
+ return {
314
+ rejection: {
315
+ gate: 'mpu-payload-length-below-header-remainder',
316
+ detail: `payload_length=${mpu.payloadLength} < ${MPU_HEADER_REMAINDER_SIZE}`,
317
+ },
318
+ }
319
+ }
251
320
 
252
321
  // ISO/IEC 23008-1 defines payload_length from immediately after its own
253
322
  // two-byte field. It therefore includes the remaining six MPU-header bytes.
254
323
  const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength
255
- if (declaredEnd > payload.length) return null
324
+ if (declaredEnd > payload.length) {
325
+ return {
326
+ rejection: {
327
+ gate: 'mpu-declared-end-exceeds-payload',
328
+ detail:
329
+ `payload_length=${mpu.payloadLength} implies end=${declaredEnd}, ` +
330
+ `payload is ${payload.length} bytes`,
331
+ },
332
+ }
333
+ }
256
334
 
257
335
  const hasHeader = fragmentType === 2 && mpu.fragmentationIndicator <= 1
258
336
  const mfuHeaderSize = mpu.timedFlag ? MFU_DU_HEADER_SIZE_TIMED : MFU_DU_HEADER_SIZE_NONTIMED
259
337
  const mfuOffset = MPU_HEADER_SIZE + (hasHeader ? mfuHeaderSize : 0)
260
- if (mfuOffset >= declaredEnd) return null
338
+ if (mfuOffset >= declaredEnd) {
339
+ return {
340
+ rejection: {
341
+ gate: 'mfu-offset-at-or-past-declared-end',
342
+ detail: `mfuOffset=${mfuOffset} >= declaredEnd=${declaredEnd}`,
343
+ },
344
+ }
345
+ }
261
346
 
262
347
  return {
263
- hasMfuDuHeader: hasHeader,
264
- mfuOffset,
265
- dataEnd: declaredEnd,
348
+ layout: {
349
+ hasMfuDuHeader: hasHeader,
350
+ mfuOffset,
351
+ dataEnd: declaredEnd,
352
+ },
266
353
  }
267
354
  }
268
355
 
@@ -365,43 +452,172 @@ export interface MFUExtraction {
365
452
  * draft-ramadan-moq-mmt-00 §3.1).
366
453
  * @returns Extracted MFU data + metadata, or null if packet is too small
367
454
  */
368
- export function extractMFUPayload(
455
+ interface MfuPlan {
456
+ header: MMTPHeader
457
+ payload: Uint8Array
458
+ mpu: MPUHeader
459
+ fragmentType: number
460
+ layout: MfuPayloadLayout
461
+ trailerLayout: DeclaredTrailerLayout
462
+ }
463
+
464
+ /**
465
+ * Single source of truth for whether a packet yields an MFU payload.
466
+ *
467
+ * `extractMFUPayload` and `explainMFURejection` both delegate here, so the
468
+ * reason reported to operators can never drift from the decision the parser
469
+ * actually made.
470
+ */
471
+ function planMfuExtraction(
369
472
  mmtpPacket: Uint8Array,
370
473
  options?: { skipFecId?: boolean },
371
- ): MFUExtraction | null {
372
- if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null
474
+ parsedHeader?: MMTPHeader,
475
+ ): { plan: MfuPlan } | { rejection: MFURejection } {
476
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) {
477
+ return {
478
+ rejection: {
479
+ gate: 'packet-shorter-than-mmtp-plus-mpu-header',
480
+ detail: `${mmtpPacket.length} bytes < ${MMTP_HEADER_SIZE + MPU_HEADER_SIZE}`,
481
+ },
482
+ }
483
+ }
373
484
 
374
485
  let header: MMTPHeader
375
- try {
376
- header = parseMMTPHeader(mmtpPacket)
377
- } catch {
378
- return null
486
+ if (parsedHeader) {
487
+ header = parsedHeader
488
+ } else {
489
+ try {
490
+ header = parseMMTPHeader(mmtpPacket)
491
+ } catch (error) {
492
+ return {
493
+ rejection: {
494
+ gate: 'mmtp-header-unparseable',
495
+ detail: error instanceof Error ? error.message : String(error),
496
+ },
497
+ }
498
+ }
379
499
  }
380
- if (header.packetType !== PACKET_TYPE_MPU) return null
500
+ if (header.packetType !== PACKET_TYPE_MPU) {
501
+ return {
502
+ rejection: {
503
+ gate: 'not-an-mpu-packet',
504
+ detail: `packetType=0x${header.packetType.toString(16).padStart(2, '0')}`,
505
+ },
506
+ }
507
+ }
508
+
381
509
  const payload = mmtpPacket.subarray(header.headerLength)
382
- if (payload.length < MPU_HEADER_SIZE) return null
510
+ if (payload.length < MPU_HEADER_SIZE) {
511
+ return {
512
+ rejection: {
513
+ gate: 'payload-shorter-than-mpu-header',
514
+ detail: `${payload.length} bytes < ${MPU_HEADER_SIZE}`,
515
+ },
516
+ }
517
+ }
518
+
383
519
  const mpu = parseMPUHeader(payload)
384
- if (mpu.aggregationFlag) return null
520
+ if (mpu.aggregationFlag) {
521
+ return {
522
+ rejection: { gate: 'aggregation-flag-set', detail: 'strict profile requires A=0' },
523
+ }
524
+ }
385
525
 
386
526
  // Fragment type from byte 2 upper nibble (not returned by parseMPUHeader)
387
527
  const fragmentType = (payload[2]! >> 4) & 0x0f
388
528
 
389
- const layout = resolveMfuPayloadLayout(
390
- mpu,
391
- fragmentType,
529
+ const layoutResult = resolveMfuPayloadLayout(mpu, fragmentType, payload)
530
+ if ('rejection' in layoutResult) return layoutResult
531
+ const { layout } = layoutResult
532
+
533
+ const trailerLayout = resolveDeclaredTrailers(
392
534
  payload,
535
+ layout.dataEnd,
536
+ header.fecType,
537
+ options,
393
538
  )
394
- if (!layout) return null
539
+ if (!trailerLayout) {
540
+ // The most common cause is a producer whose Source FEC Payload ID width
541
+ // disagrees with ISO/IEC 23008-1 §C.5.2. Name that explicitly rather than
542
+ // leaving operators to infer it from a generic trailer error.
543
+ if (!options?.skipFecId && header.fecType === 1) {
544
+ const observed = payload.length - layout.dataEnd
545
+ if (observed !== SOURCE_FEC_PAYLOAD_ID_SIZE) {
546
+ return {
547
+ rejection: {
548
+ gate: 'source-fec-payload-id-width-mismatch',
549
+ detail:
550
+ `producer wrote a ${observed}-byte Source FEC Payload ID, ` +
551
+ `ISO/IEC 23008-1 §C.5.2 requires ${SOURCE_FEC_PAYLOAD_ID_SIZE} ` +
552
+ `(payload=${payload.length}, declaredEnd=${layout.dataEnd})`,
553
+ },
554
+ }
555
+ }
556
+ }
557
+ return {
558
+ rejection: {
559
+ gate: 'declared-trailers-unresolved',
560
+ detail:
561
+ `trailer bytes [${layout.dataEnd}, ${payload.length}) are neither empty ` +
562
+ `nor an exact ALTA trailer (fecType=${header.fecType})`,
563
+ },
564
+ }
565
+ }
566
+
567
+ return { plan: { header, payload, mpu, fragmentType, layout, trailerLayout } }
568
+ }
569
+
570
+ /**
571
+ * Report why `extractMFUPayload` would refuse this packet, or null if it would
572
+ * accept it.
573
+ *
574
+ * `extractMFUPayload` returns bare `null` on ten distinct conditions, which
575
+ * makes a producer/consumer framing disagreement indistinguishable from a
576
+ * publisher that never started — both render as silence. Use this wherever
577
+ * that distinction matters: diagnostics, contract tests, and operator-facing
578
+ * logs.
579
+ */
580
+ export function explainMFURejection(
581
+ mmtpPacket: Uint8Array,
582
+ options?: { skipFecId?: boolean },
583
+ ): MFURejection | null {
584
+ const result = planMfuExtraction(mmtpPacket, options)
585
+ return 'rejection' in result ? result.rejection : null
586
+ }
395
587
 
396
- const trailerLayout = resolveDeclaredTrailers(payload, layout.dataEnd, header.fecType, options)
397
- if (!trailerLayout) return null
588
+ export function extractMFUPayload(
589
+ mmtpPacket: Uint8Array,
590
+ options?: { skipFecId?: boolean },
591
+ ): MFUExtraction | null {
592
+ return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options))
593
+ }
594
+
595
+ /**
596
+ * extractMFUPayload for a caller that already holds this packet's parsed MMTP
597
+ * header, so the header is not parsed a second time (BLO-36599).
598
+ *
599
+ * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet; the
600
+ * result is then identical to `extractMFUPayload(mmtpPacket, options)`.
601
+ */
602
+ export function extractMFUPayloadWithHeader(
603
+ mmtpPacket: Uint8Array,
604
+ header: MMTPHeader,
605
+ options?: { skipFecId?: boolean },
606
+ ): MFUExtraction | null {
607
+ return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options, header))
608
+ }
609
+
610
+ function extractPlannedMfu(
611
+ mmtpPacket: Uint8Array,
612
+ result: { plan: MfuPlan } | { rejection: MFURejection },
613
+ ): MFUExtraction | null {
614
+ if ('rejection' in result) return null
615
+ const { header, payload, mpu, fragmentType, layout, trailerLayout } = result.plan
398
616
 
399
617
  const mfuDuHeaderPresent = layout.hasMfuDuHeader
400
618
  const mfuOffset = layout.mfuOffset
401
619
  const dataEnd = layout.dataEnd
402
620
 
403
- if (mfuOffset >= dataEnd) return null
404
-
405
621
  const mfuDuHeader =
406
622
  mfuDuHeaderPresent && mpu.timedFlag === 1
407
623
  ? parseTimedMfuDuHeader(payload, MPU_HEADER_SIZE)
@@ -449,24 +665,18 @@ export function isFecRepairPacket(mmtpPacket: Uint8Array): boolean {
449
665
  * source packet per ISO/IEC 23008-1:2023 Section C.5.2 Figure C.10.
450
666
  *
451
667
  * 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
668
+ * for ssbg_mode0 and ssbg_mode1). Block membership and ESI use the
669
+ * configured K; repair SS_Start identifies the first SS_ID of the block and
670
+ * SSB_length records the authored source-symbol count:
671
+ * SBN = floor(SS_ID / K), ESI = SS_ID % K
455
672
  * SS_Start = SBN * K (first SS_ID of the block)
456
673
  *
457
674
  * This matches FFmpeg moqenc_mmt.c, the drafts (draft-ramadan-moq-fec §6.2),
458
675
  * and ATSC A/331 §8.1.2.5 which defers to ISO 23008-1 Annex C.
459
676
  */
460
677
  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) }
678
+ const header = parseHeaderForSourceFecId(mmtpPacket)
679
+ return header ? sourceFecPayloadIdOf(mmtpPacket, header) : null
470
680
  }
471
681
 
472
682
  /**
@@ -474,22 +684,52 @@ export function extractFecPayloadId(mmtpPacket: Uint8Array): { ssId: number } |
474
684
  * payload contains a four-byte Source FEC Payload ID.
475
685
  */
476
686
  export function hasSourceFecPayloadId(mmtpPacket: Uint8Array): boolean {
477
- if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return false
687
+ const header = parseHeaderForSourceFecId(mmtpPacket)
688
+ return header !== null && sourceFecPayloadIdOf(mmtpPacket, header) !== null
689
+ }
478
690
 
479
- let header: MMTPHeader
691
+ function parseHeaderForSourceFecId(mmtpPacket: Uint8Array): MMTPHeader | null {
692
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null
480
693
  try {
481
- header = parseMMTPHeader(mmtpPacket)
694
+ return parseMMTPHeader(mmtpPacket)
482
695
  } catch {
483
- return false
696
+ return null
484
697
  }
485
- if (header.packetType !== PACKET_TYPE_MPU) return false
698
+ }
699
+
700
+ /**
701
+ * The Source FEC Payload ID of a packet whose MMTP header the caller already
702
+ * parsed, or null when the packet carries none: present only when FEC_type=1
703
+ * and the exact suffix after the ISO-declared MPU payload holds the four-byte
704
+ * ID (ISO/IEC 23008-1 §C.5.2). Same answer as extractFecPayloadId, without a
705
+ * second header parse (BLO-36599).
706
+ *
707
+ * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet.
708
+ */
709
+ export function sourceFecPayloadIdOf(
710
+ mmtpPacket: Uint8Array,
711
+ header: MMTPHeader,
712
+ ): { ssId: number } | null {
713
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null
714
+ if (header.packetType !== PACKET_TYPE_MPU) return null
486
715
 
487
716
  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
717
+ if (payload.length < MPU_HEADER_SIZE) return null
718
+ const payloadLength = (payload[0]! << 8) | payload[1]!
719
+ if (payloadLength < MPU_HEADER_REMAINDER_SIZE) return null
720
+ const declaredEnd = MPU_LENGTH_FIELD_SIZE + payloadLength
721
+ if (!resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId) return null
722
+
723
+ // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id
724
+ const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE
725
+ return {
726
+ ssId:
727
+ ((mmtpPacket[off]! << 24) |
728
+ (mmtpPacket[off + 1]! << 16) |
729
+ (mmtpPacket[off + 2]! << 8) |
730
+ mmtpPacket[off + 3]!) >>>
731
+ 0,
732
+ }
493
733
  }
494
734
 
495
735
  /**
@@ -511,22 +751,34 @@ export const extractAltaTrailer = _leafExtractAltaTrailer
511
751
  /** Size of Repair FEC Payload ID per ISO/IEC 23008-1 Section C.5.3 */
512
752
  export const REPAIR_FEC_PID_SIZE = 13
513
753
 
514
- /** Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header. */
515
- function readRepairFecPayloadIdFields(mmtpPacket: Uint8Array): {
754
+ /**
755
+ * Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header.
756
+ * `parsedHeader`, when given, must be `parseMMTPHeader(mmtpPacket)` of this
757
+ * same packet; it spares the second header parse (BLO-36599).
758
+ */
759
+ function readRepairFecPayloadIdFields(mmtpPacket: Uint8Array, parsedHeader?: MMTPHeader): {
516
760
  packetId: number; ssStart: number; rsbLength: number; rsId: number; ssbLength: number; dataOffset: number
517
761
  } | null {
518
762
  let header: MMTPHeader
519
- try {
520
- header = parseMMTPHeader(mmtpPacket)
521
- } catch {
522
- return null
763
+ if (parsedHeader) {
764
+ header = parsedHeader
765
+ } else {
766
+ try {
767
+ header = parseMMTPHeader(mmtpPacket)
768
+ } catch {
769
+ return null
770
+ }
523
771
  }
524
772
  if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2) return null
525
773
 
526
774
  const off = header.headerLength
527
775
  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
776
+ const ssStart =
777
+ ((mmtpPacket[off]! << 24) |
778
+ (mmtpPacket[off + 1]! << 16) |
779
+ (mmtpPacket[off + 2]! << 8) |
780
+ mmtpPacket[off + 3]!) >>>
781
+ 0 // 32 bits
530
782
  const rsbLength = (mmtpPacket[off + 4]! << 16) | (mmtpPacket[off + 5]! << 8) | mmtpPacket[off + 6]! // 24 bits
531
783
  const rsId = (mmtpPacket[off + 7]! << 16) | (mmtpPacket[off + 8]! << 8) | mmtpPacket[off + 9]! // 24 bits
532
784
  const ssbLength = (mmtpPacket[off + 10]! << 16) | (mmtpPacket[off + 11]! << 8) | mmtpPacket[off + 12]! // 24 bits
@@ -572,7 +824,7 @@ export interface MoqRepairFrame {
572
824
  rsbLength: number
573
825
  /** RS_ID (24 bits): repair symbol index (0-based) */
574
826
  rsId: number
575
- /** SSB_length (24 bits): number of source symbols (K) */
827
+ /** SSB_length (24 bits): authored source-symbol count (1..=configured K) */
576
828
  ssbLength: number
577
829
  /** Repair symbol data after the dynamic MMTP header and 13-byte FEC payload ID. */
578
830
  symbolData: Uint8Array
@@ -586,14 +838,36 @@ export interface MoqRepairFrame {
586
838
  * then SS_Start (4 bytes) → block identifier
587
839
  * then RSB_length (3 bytes) → P
588
840
  * then RS_ID (3 bytes) → ESI = K + RS_ID
589
- * then SSB_length (3 bytes) → K
841
+ * then SSB_length (3 bytes) → authored source-symbol count (1..=K)
590
842
  * then Repair Symbol Data (T bytes)
591
843
  *
592
844
  * @param frame - Raw MoQ repair frame bytes
593
845
  * @returns Parsed frame or null if invalid
594
846
  */
595
847
  export function parseMoqRepairFrame(frame: Uint8Array): MoqRepairFrame | null {
596
- const repairId = readRepairFecPayloadIdFields(frame)
848
+ return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame))
849
+ }
850
+
851
+ /**
852
+ * parseMoqRepairFrame for a caller that already holds this frame's parsed MMTP
853
+ * header, so the header is not parsed a second time (BLO-36599). The router's
854
+ * multicast repair branch uses it: one repair packet in every K + P otherwise
855
+ * paid two header parses.
856
+ *
857
+ * `header` must be `parseMMTPHeader(frame)` of this same frame; the result is
858
+ * then identical to `parseMoqRepairFrame(frame)`.
859
+ */
860
+ export function parseMoqRepairFrameWithHeader(
861
+ frame: Uint8Array,
862
+ header: MMTPHeader,
863
+ ): MoqRepairFrame | null {
864
+ return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame, header))
865
+ }
866
+
867
+ function moqRepairFrameFrom(
868
+ frame: Uint8Array,
869
+ repairId: ReturnType<typeof readRepairFecPayloadIdFields>,
870
+ ): MoqRepairFrame | null {
597
871
  if (!repairId) return null
598
872
 
599
873
  const symbolData = frame.subarray(repairId.dataOffset)
@@ -614,133 +888,5 @@ export function parseMoqRepairFrame(frame: Uint8Array): MoqRepairFrame | null {
614
888
  }
615
889
  }
616
890
 
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
- }
891
+ // The signalling-message parsers (9.3.4.2 payload header, AL-FEC message,
892
+ // PA/MPT) live in ./signalling.ts.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * AMT well-known UDP port (RFC 7450 §4).
3
+ *
4
+ * The single definition for the whole repo. It lives here, in the one package
5
+ * both `@blockcast/mmt-transport` and `@blockcast/mmt-container` already
6
+ * depend on, because the two apply it on opposite sides of a boundary that
7
+ * does not otherwise share code:
8
+ *
9
+ * - container resolves it where `networkSource` semantics live, so a catalog
10
+ * relay that names no port is reachable instead of handing a join a
11
+ * portless relay that fails.
12
+ * - transport applies it when reading a catalog, dialling a gateway, and
13
+ * answering DRIAD — but deliberately not at every boundary. Which call
14
+ * sites do and do not take the default is transport's business; the
15
+ * reasoning lives beside those call sites, not here.
16
+ */
17
+ export const DEFAULT_AMT_RELAY_PORT = 2268;