@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/dist/mmtp.js CHANGED
@@ -1,4 +1,23 @@
1
- import { MMTP_EXT_TYPE_ALTA_AUTH as _LEAF_MMTP_EXT_TYPE_ALTA_AUTH, MMTP_EXT_TRAILER_HEADER_SIZE as _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE, extractAltaTrailer as _leafExtractAltaTrailer, } from '@blockcast/mmt-alta-parse';
1
+ /**
2
+ * @mmt/container - MMTP Parser
3
+ *
4
+ * MMTP (MMT Protocol) packet parser based on ISO/IEC 23008-1:2023 Section 9.2
5
+ *
6
+ * MMTP header format (12 bytes):
7
+ * - Byte 0: V(2) | C(1) | FEC_type(2) | r(1) | X(1) | R(1)
8
+ * - Byte 1: reserved(2) | packet_type(6)
9
+ * - Bytes 2-3: packet_id (big-endian)
10
+ * - Bytes 4-7: timestamp (big-endian, 32-bit)
11
+ * - Bytes 8-11: sequence_number (big-endian)
12
+ *
13
+ * FEC_type values:
14
+ * - 0: No FEC / source packet
15
+ * - 1: FEC mode 0 (source with FEC payload ID)
16
+ * - 2: FEC repair packet mode 0
17
+ * - 3: FEC repair packet mode 1
18
+ *
19
+ * @packageDocumentation
20
+ */
2
21
  export const MMTP_HEADER_SIZE = 12;
3
22
  export const MPU_HEADER_SIZE = 8;
4
23
  export const MFU_DU_HEADER_SIZE_TIMED = 14; // T=1: timed media (video/audio samples)
@@ -12,6 +31,16 @@ export const PACKET_TYPE_SIGNALING = 0x02;
12
31
  export const PACKET_TYPE_REPAIR = 0x03;
13
32
  // AL-FEC Signaling Message ID (ISO/IEC 23008-1:2023 Amendment 1:2025)
14
33
  export const ALFEC_MESSAGE_ID = 0x0203;
34
+ /**
35
+ * MMTP `packet_id` of the signaling flow, which carries PACKET_TYPE_SIGNALING
36
+ * packets such as the AL-FEC message (ALFEC_MESSAGE_ID). The FFmpeg `moq_mmt`
37
+ * muxer sends that message on this packet_id into the same multicast group as
38
+ * the media, and moqcast-draft (PR #44) makes advertising it for a track a
39
+ * MUST NOT. A track's packet_id is therefore 1..MMTP_PACKET_ID_MAX.
40
+ */
41
+ export const MMTP_SIGNALING_PACKET_ID = 0;
42
+ /** Largest value of the 16-bit MMTP `packet_id` header field. */
43
+ export const MMTP_PACKET_ID_MAX = 0xffff;
15
44
  // FEC Code IDs
16
45
  export const FEC_CODE_XOR = 1;
17
46
  export const FEC_CODE_RAPTORQ = 2;
@@ -81,6 +110,36 @@ export function parseMMTPHeader(buffer) {
81
110
  rapFlag,
82
111
  };
83
112
  }
113
+ /**
114
+ * The MMTP `packet_id` of `buffer`, or null exactly when parseMMTPHeader would
115
+ * throw for it (short buffer, V != 0, truncated packet_counter or header
116
+ * extension).
117
+ *
118
+ * This is the per-packet filter a receiver runs before any other work, so it
119
+ * builds no header object, DataView or BigInt timestamp (BLO-36599). Use
120
+ * parseMMTPHeader when any other header field is needed.
121
+ */
122
+ export function readMMTPPacketId(buffer) {
123
+ if (buffer.length < MMTP_HEADER_SIZE)
124
+ return null;
125
+ const flags = buffer[0];
126
+ if (((flags >> 6) & 0x03) !== 0)
127
+ return null;
128
+ let offset = MMTP_HEADER_SIZE;
129
+ if ((flags & 0x20) !== 0) {
130
+ if (offset + 4 > buffer.length)
131
+ return null;
132
+ offset += 4;
133
+ }
134
+ if ((flags & 0x02) !== 0) {
135
+ if (offset + 4 > buffer.length)
136
+ return null;
137
+ const length = (buffer[offset + 2] << 8) | buffer[offset + 3];
138
+ if (offset + 4 + length > buffer.length)
139
+ return null;
140
+ }
141
+ return (buffer[2] << 8) | buffer[3];
142
+ }
84
143
  /**
85
144
  * Unwrap MMTP packet to extract payload
86
145
  */
@@ -138,54 +197,82 @@ export function parseTimedMfuDuHeader(payload, offset) {
138
197
  };
139
198
  }
140
199
  function resolveMfuPayloadLayout(mpu, fragmentType, payload) {
141
- if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE)
142
- return null;
200
+ if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE) {
201
+ return {
202
+ rejection: {
203
+ gate: 'mpu-payload-length-below-header-remainder',
204
+ detail: `payload_length=${mpu.payloadLength} < ${MPU_HEADER_REMAINDER_SIZE}`,
205
+ },
206
+ };
207
+ }
143
208
  // ISO/IEC 23008-1 defines payload_length from immediately after its own
144
209
  // two-byte field. It therefore includes the remaining six MPU-header bytes.
145
210
  const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength;
146
- if (declaredEnd > payload.length)
147
- return null;
211
+ if (declaredEnd > payload.length) {
212
+ return {
213
+ rejection: {
214
+ gate: 'mpu-declared-end-exceeds-payload',
215
+ detail: `payload_length=${mpu.payloadLength} implies end=${declaredEnd}, ` +
216
+ `payload is ${payload.length} bytes`,
217
+ },
218
+ };
219
+ }
148
220
  const hasHeader = fragmentType === 2 && mpu.fragmentationIndicator <= 1;
149
221
  const mfuHeaderSize = mpu.timedFlag ? MFU_DU_HEADER_SIZE_TIMED : MFU_DU_HEADER_SIZE_NONTIMED;
150
222
  const mfuOffset = MPU_HEADER_SIZE + (hasHeader ? mfuHeaderSize : 0);
151
- if (mfuOffset >= declaredEnd)
152
- return null;
223
+ if (mfuOffset >= declaredEnd) {
224
+ return {
225
+ rejection: {
226
+ gate: 'mfu-offset-at-or-past-declared-end',
227
+ detail: `mfuOffset=${mfuOffset} >= declaredEnd=${declaredEnd}`,
228
+ },
229
+ };
230
+ }
153
231
  return {
154
- hasMfuDuHeader: hasHeader,
155
- mfuOffset,
156
- dataEnd: declaredEnd,
232
+ layout: {
233
+ hasMfuDuHeader: hasHeader,
234
+ mfuOffset,
235
+ dataEnd: declaredEnd,
236
+ },
157
237
  };
158
238
  }
159
- function isEmptyOrExactAltaTrailer(payload, start, end) {
239
+ // Legacy detached per-packet authenticator suffix `[0xf0a1][len][auth]`. Its
240
+ // producer was removed (BLO-16483) and nothing verifies it any more; provenance
241
+ // is the signed per-group manifest. Receivers still skip an exact suffix so a
242
+ // stale producer degrades to unauthenticated playback instead of dropped
243
+ // packets. ponytail: hard-cut to reject once no deployed producer can emit it.
244
+ const LEGACY_TRAILER_EXT_TYPE = 0xf0a1;
245
+ const LEGACY_TRAILER_HEADER_SIZE = 4;
246
+ function isEmptyOrExactLegacyTrailer(payload, start, end) {
160
247
  if (start === end)
161
248
  return true;
162
- if (start < 0 || end > payload.length || start + _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE > end) {
249
+ if (start < 0 || end > payload.length || start + LEGACY_TRAILER_HEADER_SIZE > end) {
163
250
  return false;
164
251
  }
165
252
  const view = new DataView(payload.buffer, payload.byteOffset + start, end - start);
166
- if (view.getUint16(0, false) !== _LEAF_MMTP_EXT_TYPE_ALTA_AUTH)
253
+ if (view.getUint16(0, false) !== LEGACY_TRAILER_EXT_TYPE)
167
254
  return false;
168
255
  const authLength = view.getUint16(2, false);
169
- return start + _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE + authLength === end;
256
+ return start + LEGACY_TRAILER_HEADER_SIZE + authLength === end;
170
257
  }
171
258
  function resolveDeclaredTrailers(payload, declaredEnd, fecType, options) {
172
259
  if (declaredEnd > payload.length)
173
260
  return null;
174
261
  if (options?.skipFecId) {
175
- return isEmptyOrExactAltaTrailer(payload, declaredEnd, payload.length)
262
+ return isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)
176
263
  ? { hasFecId: false }
177
264
  : null;
178
265
  }
179
266
  if (fecType === 1) {
180
267
  const fecStart = payload.length - SOURCE_FEC_PAYLOAD_ID_SIZE;
181
- if (fecStart < declaredEnd || !isEmptyOrExactAltaTrailer(payload, declaredEnd, fecStart)) {
268
+ if (fecStart < declaredEnd || !isEmptyOrExactLegacyTrailer(payload, declaredEnd, fecStart)) {
182
269
  return null;
183
270
  }
184
271
  return { hasFecId: true };
185
272
  }
186
273
  if (fecType !== 0)
187
274
  return null;
188
- if (isEmptyOrExactAltaTrailer(payload, declaredEnd, payload.length)) {
275
+ if (isEmptyOrExactLegacyTrailer(payload, declaredEnd, payload.length)) {
189
276
  return { hasFecId: false };
190
277
  }
191
278
  return null;
@@ -200,57 +287,129 @@ export function extractNALUnit(mmtpPacket) {
200
287
  return extraction.data;
201
288
  }
202
289
  /**
203
- * Extract MFU payload from a raw MMTP packet.
204
- *
205
- * Handles all the byte arithmetic for:
206
- * - MMTP header (12 bytes)
207
- * - MPU sub-header (8 bytes, payloadLength field)
208
- * - MFU DU header (14 bytes timed / 4 bytes non-timed, only for FI ≤ 1)
209
- * - Source FEC Payload ID (4 bytes at end, only when `FEC_type == 1`)
210
- *
211
- * `payloadLength` is the ISO length-after-field value: six remaining MPU-header
212
- * bytes, then the MFU DU header when present, then media. ALTA and Source FEC
213
- * trailers are outside that declared span and must consume the exact suffix.
290
+ * Single source of truth for whether a packet yields an MFU payload.
214
291
  *
215
- * @param mmtpPacket Raw MMTP packet (from multicast or MoQ)
216
- * @param options.skipFecId When true, do NOT subtract the 4-byte Source FEC Payload ID
217
- * from the payload boundary, even if fecType >= 1 in the MMTP header. Use this for
218
- * FEC-recovered symbols where the FEC PID was already stripped before WASM decoding
219
- * but the fecType bits in byte 0 still reflect the original packet (FEC Type=1 per
220
- * draft-ramadan-moq-mmt-00 §3.1).
221
- * @returns Extracted MFU data + metadata, or null if packet is too small
292
+ * `extractMFUPayload` and `explainMFURejection` both delegate here, so the
293
+ * reason reported to operators can never drift from the decision the parser
294
+ * actually made.
222
295
  */
223
- export function extractMFUPayload(mmtpPacket, options) {
224
- if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE)
225
- return null;
296
+ function planMfuExtraction(mmtpPacket, options, parsedHeader) {
297
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) {
298
+ return {
299
+ rejection: {
300
+ gate: 'packet-shorter-than-mmtp-plus-mpu-header',
301
+ detail: `${mmtpPacket.length} bytes < ${MMTP_HEADER_SIZE + MPU_HEADER_SIZE}`,
302
+ },
303
+ };
304
+ }
226
305
  let header;
227
- try {
228
- header = parseMMTPHeader(mmtpPacket);
306
+ if (parsedHeader) {
307
+ header = parsedHeader;
229
308
  }
230
- catch {
231
- return null;
309
+ else {
310
+ try {
311
+ header = parseMMTPHeader(mmtpPacket);
312
+ }
313
+ catch (error) {
314
+ return {
315
+ rejection: {
316
+ gate: 'mmtp-header-unparseable',
317
+ detail: error instanceof Error ? error.message : String(error),
318
+ },
319
+ };
320
+ }
321
+ }
322
+ if (header.packetType !== PACKET_TYPE_MPU) {
323
+ return {
324
+ rejection: {
325
+ gate: 'not-an-mpu-packet',
326
+ detail: `packetType=0x${header.packetType.toString(16).padStart(2, '0')}`,
327
+ },
328
+ };
232
329
  }
233
- if (header.packetType !== PACKET_TYPE_MPU)
234
- return null;
235
330
  const payload = mmtpPacket.subarray(header.headerLength);
236
- if (payload.length < MPU_HEADER_SIZE)
237
- return null;
331
+ if (payload.length < MPU_HEADER_SIZE) {
332
+ return {
333
+ rejection: {
334
+ gate: 'payload-shorter-than-mpu-header',
335
+ detail: `${payload.length} bytes < ${MPU_HEADER_SIZE}`,
336
+ },
337
+ };
338
+ }
238
339
  const mpu = parseMPUHeader(payload);
239
- if (mpu.aggregationFlag)
240
- return null;
340
+ if (mpu.aggregationFlag) {
341
+ return {
342
+ rejection: { gate: 'aggregation-flag-set', detail: 'strict profile requires A=0' },
343
+ };
344
+ }
241
345
  // Fragment type from byte 2 upper nibble (not returned by parseMPUHeader)
242
346
  const fragmentType = (payload[2] >> 4) & 0x0f;
243
- const layout = resolveMfuPayloadLayout(mpu, fragmentType, payload);
244
- if (!layout)
245
- return null;
347
+ const layoutResult = resolveMfuPayloadLayout(mpu, fragmentType, payload);
348
+ if ('rejection' in layoutResult)
349
+ return layoutResult;
350
+ const { layout } = layoutResult;
246
351
  const trailerLayout = resolveDeclaredTrailers(payload, layout.dataEnd, header.fecType, options);
247
- if (!trailerLayout)
352
+ if (!trailerLayout) {
353
+ // The most common cause is a producer whose Source FEC Payload ID width
354
+ // disagrees with ISO/IEC 23008-1 §C.5.2. Name that explicitly rather than
355
+ // leaving operators to infer it from a generic trailer error.
356
+ if (!options?.skipFecId && header.fecType === 1) {
357
+ const observed = payload.length - layout.dataEnd;
358
+ if (observed !== SOURCE_FEC_PAYLOAD_ID_SIZE) {
359
+ return {
360
+ rejection: {
361
+ gate: 'source-fec-payload-id-width-mismatch',
362
+ detail: `producer wrote a ${observed}-byte Source FEC Payload ID, ` +
363
+ `ISO/IEC 23008-1 §C.5.2 requires ${SOURCE_FEC_PAYLOAD_ID_SIZE} ` +
364
+ `(payload=${payload.length}, declaredEnd=${layout.dataEnd})`,
365
+ },
366
+ };
367
+ }
368
+ }
369
+ return {
370
+ rejection: {
371
+ gate: 'declared-trailers-unresolved',
372
+ detail: `trailer bytes [${layout.dataEnd}, ${payload.length}) are neither empty ` +
373
+ `nor an exact legacy authenticator trailer (fecType=${header.fecType})`,
374
+ },
375
+ };
376
+ }
377
+ return { plan: { header, payload, mpu, fragmentType, layout, trailerLayout } };
378
+ }
379
+ /**
380
+ * Report why `extractMFUPayload` would refuse this packet, or null if it would
381
+ * accept it.
382
+ *
383
+ * `extractMFUPayload` returns bare `null` on ten distinct conditions, which
384
+ * makes a producer/consumer framing disagreement indistinguishable from a
385
+ * publisher that never started — both render as silence. Use this wherever
386
+ * that distinction matters: diagnostics, contract tests, and operator-facing
387
+ * logs.
388
+ */
389
+ export function explainMFURejection(mmtpPacket, options) {
390
+ const result = planMfuExtraction(mmtpPacket, options);
391
+ return 'rejection' in result ? result.rejection : null;
392
+ }
393
+ export function extractMFUPayload(mmtpPacket, options) {
394
+ return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options));
395
+ }
396
+ /**
397
+ * extractMFUPayload for a caller that already holds this packet's parsed MMTP
398
+ * header, so the header is not parsed a second time (BLO-36599).
399
+ *
400
+ * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet; the
401
+ * result is then identical to `extractMFUPayload(mmtpPacket, options)`.
402
+ */
403
+ export function extractMFUPayloadWithHeader(mmtpPacket, header, options) {
404
+ return extractPlannedMfu(mmtpPacket, planMfuExtraction(mmtpPacket, options, header));
405
+ }
406
+ function extractPlannedMfu(mmtpPacket, result) {
407
+ if ('rejection' in result)
248
408
  return null;
409
+ const { header, payload, mpu, fragmentType, layout, trailerLayout } = result.plan;
249
410
  const mfuDuHeaderPresent = layout.hasMfuDuHeader;
250
411
  const mfuOffset = layout.mfuOffset;
251
412
  const dataEnd = layout.dataEnd;
252
- if (mfuOffset >= dataEnd)
253
- return null;
254
413
  const mfuDuHeader = mfuDuHeaderPresent && mpu.timedFlag === 1
255
414
  ? parseTimedMfuDuHeader(payload, MPU_HEADER_SIZE)
256
415
  : null;
@@ -287,79 +446,100 @@ export function isFecRepairPacket(mmtpPacket) {
287
446
  * source packet per ISO/IEC 23008-1:2023 Section C.5.2 Figure C.10.
288
447
  *
289
448
  * SS_ID is a flat 32-bit monotonic counter (incremented by 1 per packet
290
- * for ssbg_mode0 and ssbg_mode1). Block membership and ESI are derived
291
- * at recovery time from repair packet SS_Start and SSB_length:
292
- * SBN = floor(SS_ID / K), ESI = SS_ID % K
449
+ * for ssbg_mode0 and ssbg_mode1). Block membership and ESI use the
450
+ * configured K; repair SS_Start identifies the first SS_ID of the block and
451
+ * SSB_length records the authored source-symbol count:
452
+ * SBN = floor(SS_ID / K), ESI = SS_ID % K
293
453
  * SS_Start = SBN * K (first SS_ID of the block)
294
454
  *
295
455
  * This matches FFmpeg moqenc_mmt.c, the drafts (draft-ramadan-moq-fec §6.2),
296
456
  * and ATSC A/331 §8.1.2.5 which defers to ISO 23008-1 Annex C.
297
457
  */
298
458
  export function extractFecPayloadId(mmtpPacket) {
299
- if (!hasSourceFecPayloadId(mmtpPacket))
300
- return null;
301
- // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id
302
- const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE;
303
- const view = new DataView(mmtpPacket.buffer, mmtpPacket.byteOffset + off, SOURCE_FEC_PAYLOAD_ID_SIZE);
304
- return { ssId: view.getUint32(0, false) };
459
+ const header = parseHeaderForSourceFecId(mmtpPacket);
460
+ return header ? sourceFecPayloadIdOf(mmtpPacket, header) : null;
305
461
  }
306
462
  /**
307
463
  * Return true when FEC_type=1 and the exact suffix after the ISO-declared MPU
308
464
  * payload contains a four-byte Source FEC Payload ID.
309
465
  */
310
466
  export function hasSourceFecPayloadId(mmtpPacket) {
467
+ const header = parseHeaderForSourceFecId(mmtpPacket);
468
+ return header !== null && sourceFecPayloadIdOf(mmtpPacket, header) !== null;
469
+ }
470
+ function parseHeaderForSourceFecId(mmtpPacket) {
311
471
  if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE)
312
- return false;
313
- let header;
472
+ return null;
314
473
  try {
315
- header = parseMMTPHeader(mmtpPacket);
474
+ return parseMMTPHeader(mmtpPacket);
316
475
  }
317
476
  catch {
318
- return false;
477
+ return null;
319
478
  }
479
+ }
480
+ /**
481
+ * The Source FEC Payload ID of a packet whose MMTP header the caller already
482
+ * parsed, or null when the packet carries none: present only when FEC_type=1
483
+ * and the exact suffix after the ISO-declared MPU payload holds the four-byte
484
+ * ID (ISO/IEC 23008-1 §C.5.2). Same answer as extractFecPayloadId, without a
485
+ * second header parse (BLO-36599).
486
+ *
487
+ * `header` must be `parseMMTPHeader(mmtpPacket)` of this same packet.
488
+ */
489
+ export function sourceFecPayloadIdOf(mmtpPacket, header) {
490
+ if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE)
491
+ return null;
320
492
  if (header.packetType !== PACKET_TYPE_MPU)
321
- return false;
493
+ return null;
322
494
  const payload = mmtpPacket.subarray(header.headerLength);
323
495
  if (payload.length < MPU_HEADER_SIZE)
324
- return false;
325
- const mpu = parseMPUHeader(payload);
326
- if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE)
327
- return false;
328
- const declaredEnd = MPU_LENGTH_FIELD_SIZE + mpu.payloadLength;
329
- return resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId ?? false;
496
+ return null;
497
+ const payloadLength = (payload[0] << 8) | payload[1];
498
+ if (payloadLength < MPU_HEADER_REMAINDER_SIZE)
499
+ return null;
500
+ const declaredEnd = MPU_LENGTH_FIELD_SIZE + payloadLength;
501
+ if (!resolveDeclaredTrailers(payload, declaredEnd, header.fecType)?.hasFecId)
502
+ return null;
503
+ // Flat 4-byte SS_ID: encoder writes block_id * K + symbol_id
504
+ const off = mmtpPacket.length - SOURCE_FEC_PAYLOAD_ID_SIZE;
505
+ return {
506
+ ssId: ((mmtpPacket[off] << 24) |
507
+ (mmtpPacket[off + 1] << 16) |
508
+ (mmtpPacket[off + 2] << 8) |
509
+ mmtpPacket[off + 3]) >>>
510
+ 0,
511
+ };
330
512
  }
331
- /**
332
- * MMTP header-extension ext_type assigned to the detached ALTA authenticator
333
- * trailer (Path 2 / 08-07). Carried in an `[ext_type][ext_length][auth]`
334
- * block appended after the MPU's declared payload bytes, before the 4-byte
335
- * Source FEC Payload ID. MMTP X-bit stays 0 — non-ALTA MMTP receivers treat
336
- * the trailer as bytes past `payload_length` and skip it (per ISO 23008-1
337
- * §9.2.2 "may be discarded without impacting correct processing").
338
- */
339
- // Re-export the ALTA trailer helper from the zero-dep leaf package. The leaf
340
- // owns the implementation so container and FEC consumers share one parser
341
- // without creating a package cycle.
342
- export const MMTP_EXT_TYPE_ALTA_AUTH = _LEAF_MMTP_EXT_TYPE_ALTA_AUTH;
343
- export const MMTP_EXT_TRAILER_HEADER_SIZE = _LEAF_MMTP_EXT_TRAILER_HEADER_SIZE;
344
- export const extractAltaTrailer = _leafExtractAltaTrailer;
345
513
  /** Size of Repair FEC Payload ID per ISO/IEC 23008-1 Section C.5.3 */
346
514
  export const REPAIR_FEC_PID_SIZE = 13;
347
- /** Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header. */
348
- function readRepairFecPayloadIdFields(mmtpPacket) {
515
+ /**
516
+ * Parse the 13-byte Repair FEC Payload ID after the complete dynamic MMTP header.
517
+ * `parsedHeader`, when given, must be `parseMMTPHeader(mmtpPacket)` of this
518
+ * same packet; it spares the second header parse (BLO-36599).
519
+ */
520
+ function readRepairFecPayloadIdFields(mmtpPacket, parsedHeader) {
349
521
  let header;
350
- try {
351
- header = parseMMTPHeader(mmtpPacket);
522
+ if (parsedHeader) {
523
+ header = parsedHeader;
352
524
  }
353
- catch {
354
- return null;
525
+ else {
526
+ try {
527
+ header = parseMMTPHeader(mmtpPacket);
528
+ }
529
+ catch {
530
+ return null;
531
+ }
355
532
  }
356
533
  if (header.packetType !== PACKET_TYPE_REPAIR || header.fecType !== 2)
357
534
  return null;
358
535
  const off = header.headerLength;
359
536
  if (mmtpPacket.length < off + REPAIR_FEC_PID_SIZE)
360
537
  return null;
361
- const view = new DataView(mmtpPacket.buffer, mmtpPacket.byteOffset + off, REPAIR_FEC_PID_SIZE);
362
- const ssStart = view.getUint32(0, false); // 32 bits
538
+ const ssStart = ((mmtpPacket[off] << 24) |
539
+ (mmtpPacket[off + 1] << 16) |
540
+ (mmtpPacket[off + 2] << 8) |
541
+ mmtpPacket[off + 3]) >>>
542
+ 0; // 32 bits
363
543
  const rsbLength = (mmtpPacket[off + 4] << 16) | (mmtpPacket[off + 5] << 8) | mmtpPacket[off + 6]; // 24 bits
364
544
  const rsId = (mmtpPacket[off + 7] << 16) | (mmtpPacket[off + 8] << 8) | mmtpPacket[off + 9]; // 24 bits
365
545
  const ssbLength = (mmtpPacket[off + 10] << 16) | (mmtpPacket[off + 11] << 8) | mmtpPacket[off + 12]; // 24 bits
@@ -400,14 +580,28 @@ export function extractRepairFecPayloadId(mmtpPacket) {
400
580
  * then SS_Start (4 bytes) → block identifier
401
581
  * then RSB_length (3 bytes) → P
402
582
  * then RS_ID (3 bytes) → ESI = K + RS_ID
403
- * then SSB_length (3 bytes) → K
583
+ * then SSB_length (3 bytes) → authored source-symbol count (1..=K)
404
584
  * then Repair Symbol Data (T bytes)
405
585
  *
406
586
  * @param frame - Raw MoQ repair frame bytes
407
587
  * @returns Parsed frame or null if invalid
408
588
  */
409
589
  export function parseMoqRepairFrame(frame) {
410
- const repairId = readRepairFecPayloadIdFields(frame);
590
+ return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame));
591
+ }
592
+ /**
593
+ * parseMoqRepairFrame for a caller that already holds this frame's parsed MMTP
594
+ * header, so the header is not parsed a second time (BLO-36599). The router's
595
+ * multicast repair branch uses it: one repair packet in every K + P otherwise
596
+ * paid two header parses.
597
+ *
598
+ * `header` must be `parseMMTPHeader(frame)` of this same frame; the result is
599
+ * then identical to `parseMoqRepairFrame(frame)`.
600
+ */
601
+ export function parseMoqRepairFrameWithHeader(frame, header) {
602
+ return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame, header));
603
+ }
604
+ function moqRepairFrameFrom(frame, repairId) {
411
605
  if (!repairId)
412
606
  return null;
413
607
  const symbolData = frame.subarray(repairId.dataOffset);
@@ -425,144 +619,6 @@ export function parseMoqRepairFrame(frame) {
425
619
  symbolData,
426
620
  };
427
621
  }
428
- function canonicalRaptorQOtiK(oti, repairSymbolSize) {
429
- if (oti.length !== 12 || oti[5] !== 0)
430
- return null;
431
- const transferLength = (oti[0] * 0x1_0000_0000) +
432
- (((oti[1] << 24) | (oti[2] << 16) | (oti[3] << 8) | oti[4]) >>> 0);
433
- const symbolSize = (oti[6] << 8) | oti[7];
434
- const sourceBlocks = oti[8];
435
- const subBlocks = (oti[9] << 8) | oti[10];
436
- const alignment = oti[11];
437
- if (!Number.isSafeInteger(transferLength) || transferLength <= 0 ||
438
- symbolSize !== repairSymbolSize || symbolSize === 0 || symbolSize % 8 !== 0 ||
439
- sourceBlocks !== 1 || subBlocks !== 1 || alignment !== 8 ||
440
- transferLength % symbolSize !== 0) {
441
- return null;
442
- }
443
- const k = transferLength / symbolSize;
444
- return Number.isInteger(k) && k >= 1 && k <= 56_403 ? k : null;
445
- }
446
- /**
447
- * Parse AL-FEC signaling message from a full MMTP signaling packet.
448
- *
449
- * Wire format (ISO/IEC 23008-1:2023 Table C.3, Amendment 1:2025):
450
- * [12-byte MMTP header (packet_type=0x02)]
451
- * message_id(2) = 0x0203
452
- * version(1)
453
- * message_length(2) = length of remaining payload
454
- * flags(1) = fec_flag(1) | private_fec_flag(1) | reserved(6)
455
- * fec_flow_descriptor:
456
- * length(2)
457
- * num_flows(1)
458
- * per flow:
459
- * fec_flow_id(1), source_flow_id(1), num_assets(1), packet_ids(2*N),
460
- * coding_params(1), repair_symbol_size(2),
461
- * [one-stage]: repair_flow_id(2), fec_code_id(1),
462
- * [private_fec_flag]: private_header(1){flag(1)|len(7)},
463
- * current RaptorQ profile OTI(12) + interleave_depth(1)
464
- * max_k(3), max_p(3), protection_window_time(4), protection_window_size(4)
465
- *
466
- * @param mmtpPacket Full MMTP packet including 12-byte header
467
- * @returns Parsed config or null if not an AL-FEC signaling message
468
- */
469
- export function parseAlFecMessage(mmtpPacket) {
470
- if (mmtpPacket.length < MMTP_HEADER_SIZE + 9)
471
- return null;
472
- let header;
473
- try {
474
- header = parseMMTPHeader(mmtpPacket);
475
- }
476
- catch {
477
- return null;
478
- }
479
- if (header.packetType !== PACKET_TYPE_SIGNALING || header.fecType !== 0)
480
- return null;
481
- const payload = mmtpPacket.subarray(header.headerLength);
482
- if (payload.length < 9)
483
- return null;
484
- const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
485
- let off = 0;
486
- const messageId = view.getUint16(off, false);
487
- off += 2;
488
- if (messageId !== ALFEC_MESSAGE_ID)
489
- return null;
490
- const version = payload[off];
491
- off += 1;
492
- const messageLength = view.getUint16(off, false);
493
- off += 2;
494
- if (version !== 1 || messageLength === 0 || messageLength !== payload.length - off)
495
- return null;
496
- const flags = payload[off];
497
- off += 1;
498
- const fecFlag = (flags >> 7) & 1;
499
- const privateFecFlag = (flags >> 6) & 1;
500
- if (fecFlag !== 1 || privateFecFlag !== 1 || (flags & 0x3f) !== 0x3f)
501
- return null;
502
- if (off + 2 > payload.length)
503
- return null;
504
- const descriptorLength = view.getUint16(off, false);
505
- off += 2;
506
- const descriptorEnd = off + descriptorLength;
507
- if (descriptorLength === 0 || descriptorEnd !== payload.length)
508
- return null;
509
- const numFlows = payload[off];
510
- off += 1;
511
- if (numFlows !== 1 || off + 3 > descriptorEnd)
512
- return null;
513
- off += 2; // fec_flow_id, source_flow_id
514
- const numAssets = payload[off];
515
- off += 1;
516
- if (numAssets === 0 || off + (numAssets * 2) > descriptorEnd)
517
- return null;
518
- off += numAssets * 2;
519
- if (off + 3 > descriptorEnd)
520
- return null;
521
- const codingParams = payload[off];
522
- off += 1;
523
- const fecCodingStructure = (codingParams >> 4) & 0x0f;
524
- const ssbgMode = (codingParams >> 2) & 0x03;
525
- const ffsrptsFlag = (codingParams >> 1) & 0x01;
526
- const fecPayloadIdMode = codingParams & 0x01;
527
- if (fecCodingStructure !== 1 || ssbgMode !== 0 || ffsrptsFlag !== 0 || fecPayloadIdMode !== 0) {
528
- return null;
529
- }
530
- const repairSymbolSize = view.getUint16(off, false);
531
- off += 2;
532
- if (repairSymbolSize === 0 || repairSymbolSize % 8 !== 0)
533
- return null;
534
- if (off + 4 > descriptorEnd)
535
- return null;
536
- off += 2; // Amendment 1:2025 replaces Table C.3 with a 16-bit repair_flow_id
537
- const fecCodeId = payload[off];
538
- off += 1;
539
- if (fecCodeId !== FEC_CODE_RAPTORQ)
540
- return null;
541
- const privateHeader = payload[off];
542
- off += 1;
543
- const privateFlag = privateHeader >> 7;
544
- const privateFieldLength = privateHeader & 0x7f;
545
- if (privateFlag !== 1 || privateFieldLength !== 13 || off + 13 > descriptorEnd)
546
- return null;
547
- const oti = payload.slice(off, off + 12);
548
- off += 12;
549
- const interleaveDepth = payload[off];
550
- off += 1;
551
- const otiK = canonicalRaptorQOtiK(oti, repairSymbolSize);
552
- if (otiK === null || interleaveDepth === 0)
553
- return null;
554
- if (off + 14 !== descriptorEnd)
555
- return null;
556
- const maximumK = (payload[off] << 16) | (payload[off + 1] << 8) | payload[off + 2];
557
- off += 3;
558
- const maximumP = (payload[off] << 16) | (payload[off + 1] << 8) | payload[off + 2];
559
- off += 3;
560
- if (maximumK !== otiK || maximumP === 0 || maximumK + maximumP - 1 > 0x00ff_ffff) {
561
- return null;
562
- }
563
- off += 8; // protection_window_time, protection_window_size
564
- if (off !== descriptorEnd)
565
- return null;
566
- return { fecCodeId, fecPayloadIdMode, repairSymbolSize, maximumK, maximumP, interleaveDepth, oti };
567
- }
622
+ // The signalling-message parsers (9.3.4.2 payload header, AL-FEC message,
623
+ // PA/MPT) live in ./signalling.ts.
568
624
  //# sourceMappingURL=mmtp.js.map