@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/README.md +16 -13
- package/dist/fec-block-profile.d.ts +55 -0
- package/dist/fec-block-profile.d.ts.map +1 -0
- package/dist/fec-block-profile.js +132 -0
- package/dist/fec-block-profile.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/mmtp.d.ts +75 -67
- package/dist/mmtp.d.ts.map +1 -1
- package/dist/mmtp.js +294 -238
- package/dist/mmtp.js.map +1 -1
- package/dist/net-constants.d.ts +18 -0
- package/dist/net-constants.d.ts.map +1 -0
- package/dist/net-constants.js +18 -0
- package/dist/net-constants.js.map +1 -0
- package/dist/ntp-short.d.ts +47 -0
- package/dist/ntp-short.d.ts.map +1 -0
- package/dist/ntp-short.js +54 -0
- package/dist/ntp-short.js.map +1 -0
- package/dist/serial-number.d.ts +28 -0
- package/dist/serial-number.d.ts.map +1 -0
- package/dist/serial-number.js +44 -0
- package/dist/serial-number.js.map +1 -0
- package/dist/signalling.d.ts +314 -0
- package/dist/signalling.d.ts.map +1 -0
- package/dist/signalling.js +637 -0
- package/dist/signalling.js.map +1 -0
- package/package.json +9 -6
- package/src/fec-block-profile.ts +145 -0
- package/src/index.ts +5 -0
- package/src/mmtp.ts +350 -219
- package/src/net-constants.ts +17 -0
- package/src/ntp-short.ts +67 -0
- package/src/serial-number.ts +49 -0
- package/src/signalling.ts +860 -0
- package/src/alfec.test.ts +0 -176
- package/src/mmtp.test.ts +0 -769
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 |
|
|
250
|
-
if (mpu.payloadLength < MPU_HEADER_REMAINDER_SIZE)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
341
|
+
layout: {
|
|
342
|
+
hasMfuDuHeader: hasHeader,
|
|
343
|
+
mfuOffset,
|
|
344
|
+
dataEnd: declaredEnd,
|
|
345
|
+
},
|
|
266
346
|
}
|
|
267
347
|
}
|
|
268
348
|
|
|
269
|
-
|
|
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 +
|
|
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) !==
|
|
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 +
|
|
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
|
|
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 || !
|
|
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 (
|
|
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.
|
|
358
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
header =
|
|
377
|
-
}
|
|
378
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
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 (!
|
|
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
|
-
|
|
397
|
-
|
|
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).
|
|
453
|
-
*
|
|
454
|
-
*
|
|
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
|
-
|
|
462
|
-
|
|
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
|
-
|
|
688
|
+
const header = parseHeaderForSourceFecId(mmtpPacket)
|
|
689
|
+
return header !== null && sourceFecPayloadIdOf(mmtpPacket, header) !== null
|
|
690
|
+
}
|
|
478
691
|
|
|
479
|
-
|
|
692
|
+
function parseHeaderForSourceFecId(mmtpPacket: Uint8Array): MMTPHeader | null {
|
|
693
|
+
if (mmtpPacket.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null
|
|
480
694
|
try {
|
|
481
|
-
|
|
695
|
+
return parseMMTPHeader(mmtpPacket)
|
|
482
696
|
} catch {
|
|
483
|
-
return
|
|
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
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
/**
|
|
515
|
-
|
|
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
|
-
|
|
520
|
-
header =
|
|
521
|
-
}
|
|
522
|
-
|
|
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
|
|
529
|
-
|
|
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):
|
|
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
|
-
|
|
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
|
-
|
|
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.
|