@blockcast/mmt-base 0.1.0

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.
@@ -0,0 +1,769 @@
1
+ /**
2
+ * Tests for parseMoqRepairFrame and extractMFUPayload.
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ extractAltaTrailer,
8
+ extractMFUPayload,
9
+ extractRepairFecPayloadId,
10
+ MMTP_HEADER_SIZE,
11
+ MPU_HEADER_SIZE,
12
+ MFU_DU_HEADER_SIZE_TIMED,
13
+ MMTP_EXT_TYPE_ALTA_AUTH,
14
+ MMTP_EXT_TRAILER_HEADER_SIZE,
15
+ PACKET_TYPE_SIGNALING,
16
+ parseMMTPHeader,
17
+ parseMoqRepairFrame,
18
+ parseMPUHeader,
19
+ hasSourceFecPayloadId,
20
+ parseAlFecMessage,
21
+ unwrapMMTP,
22
+ } from "./mmtp.js";
23
+
24
+ const REPAIR_FEC_PID = 13; // SS_Start(4) + RSB_length(3) + RS_ID(3) + SSB_length(3)
25
+ const REPAIR_HDR = MMTP_HEADER_SIZE + REPAIR_FEC_PID; // 25
26
+
27
+ function buildMoqRepairFrame(opts: {
28
+ packetId?: number;
29
+ ssStart?: number;
30
+ rsbLength?: number;
31
+ rsId?: number;
32
+ ssbLength?: number;
33
+ symbolData?: Uint8Array;
34
+ }): Uint8Array {
35
+ const {
36
+ packetId = 1,
37
+ ssStart = 64,
38
+ rsbLength = 8,
39
+ rsId = 2,
40
+ ssbLength = 32,
41
+ symbolData = new Uint8Array(100),
42
+ } = opts;
43
+
44
+ const frame = new Uint8Array(REPAIR_HDR + symbolData.length);
45
+ const view = new DataView(frame.buffer);
46
+
47
+ // MMTP header
48
+ frame[0] = 0x10; // FEC_type=2 (mode-0 repair)
49
+ frame[1] = 0x03; // packetType=repair
50
+ view.setUint16(2, packetId, false);
51
+
52
+ // FEC Payload ID: SS_Start(4) + RSB_length(3) + RS_ID(3) + SSB_length(3) at offset 12
53
+ view.setUint32(12, ssStart, false); // 4 bytes
54
+ frame[16] = (rsbLength >> 16) & 0xff;
55
+ frame[17] = (rsbLength >> 8) & 0xff;
56
+ frame[18] = rsbLength & 0xff;
57
+ frame[19] = (rsId >> 16) & 0xff;
58
+ frame[20] = (rsId >> 8) & 0xff;
59
+ frame[21] = rsId & 0xff;
60
+ frame[22] = (ssbLength >> 16) & 0xff;
61
+ frame[23] = (ssbLength >> 8) & 0xff;
62
+ frame[24] = ssbLength & 0xff;
63
+
64
+ // Repair symbol data
65
+ frame.set(symbolData, REPAIR_HDR);
66
+
67
+ return frame;
68
+ }
69
+
70
+ describe("parseMoqRepairFrame", () => {
71
+ it("uses the same ISO V=0 low-bits identity as the raw-multicast parser", () => {
72
+ const frame = buildMoqRepairFrame({ ssStart: 64, ssbLength: 32 });
73
+ expect(frame[1]).toBe(0x03);
74
+ expect(extractRepairFecPayloadId(frame)).toEqual({
75
+ ssStart: 64,
76
+ rsbLength: 8,
77
+ rsId: 2,
78
+ ssbLength: 32,
79
+ });
80
+ expect(parseMoqRepairFrame(frame)).not.toBeNull();
81
+ });
82
+ it("rejects pre-migration FFmpeg high-bits repair in the raw-multicast parser", () => {
83
+ const frame = buildMoqRepairFrame({ ssStart: 64, ssbLength: 32 });
84
+ frame[1] = 0x0c;
85
+ expect(extractRepairFecPayloadId(frame)).toBeNull();
86
+ });
87
+ it("returns the authoritative Annex C coordinates without inventing SBN/ESI", () => {
88
+ const frame = buildMoqRepairFrame({ ssStart: 65, ssbLength: 32, rsId: 3 });
89
+ const result = parseMoqRepairFrame(frame);
90
+ expect(result).not.toBeNull();
91
+ expect(result!.ssStart).toBe(65);
92
+ expect(result!.ssbLength).toBe(32);
93
+ expect(result!.rsId).toBe(3);
94
+ expect(result).not.toHaveProperty("sbn");
95
+ expect(result).not.toHaveProperty("esi");
96
+ });
97
+
98
+ it("should extract SSB_length (K) from repair frame", () => {
99
+ const frame = buildMoqRepairFrame({ ssbLength: 32 });
100
+ const result = parseMoqRepairFrame(frame);
101
+ expect(result!.ssbLength).toBe(32);
102
+ });
103
+
104
+ it("should extract repair symbol data after header", () => {
105
+ const symbolData = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
106
+ const frame = buildMoqRepairFrame({ symbolData });
107
+ const result = parseMoqRepairFrame(frame);
108
+ expect(result!.symbolData.length).toBe(4);
109
+ expect(Array.from(result!.symbolData)).toEqual([0xde, 0xad, 0xbe, 0xef]);
110
+ });
111
+
112
+ it("starts the repair payload ID after the ISO C/X optional fields", () => {
113
+ const symbolData = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
114
+ const frame = addOptionalHeaderFields(buildMoqRepairFrame({
115
+ ssStart: 64,
116
+ ssbLength: 32,
117
+ symbolData,
118
+ }));
119
+
120
+ expect(extractRepairFecPayloadId(frame)).toEqual({
121
+ ssStart: 64,
122
+ rsbLength: 8,
123
+ rsId: 2,
124
+ ssbLength: 32,
125
+ });
126
+ expect(parseMoqRepairFrame(frame)?.symbolData).toEqual(symbolData);
127
+ });
128
+
129
+ it("should extract packetId for audio/video routing", () => {
130
+ const frame = buildMoqRepairFrame({ packetId: 2 });
131
+ const result = parseMoqRepairFrame(frame);
132
+ expect(result!.packetId).toBe(2);
133
+ });
134
+
135
+ it("should return null for frames smaller than repair header", () => {
136
+ const result = parseMoqRepairFrame(new Uint8Array(20));
137
+ expect(result).toBeNull();
138
+ });
139
+
140
+ it("should return null for non-repair packets", () => {
141
+ const frame = buildMoqRepairFrame({});
142
+ frame[1] = 0x00; // packetType=MPU
143
+ const result = parseMoqRepairFrame(frame);
144
+ expect(result).toBeNull();
145
+ });
146
+
147
+ it("accepts an authored audio SSB_length without a caller-supplied fallback", () => {
148
+ const frame = buildMoqRepairFrame({ ssStart: 8, ssbLength: 4, rsId: 1 });
149
+ const result = parseMoqRepairFrame(frame);
150
+ expect(result!.ssStart).toBe(8);
151
+ expect(result!.ssbLength).toBe(4);
152
+ expect(result!.rsId).toBe(1);
153
+ });
154
+
155
+ it("rejects non-mode-0 and structurally invalid repair packets", () => {
156
+ const wrongFecType = buildMoqRepairFrame({});
157
+ wrongFecType[0] = 0;
158
+ expect(parseMoqRepairFrame(wrongFecType)).toBeNull();
159
+
160
+ expect(parseMoqRepairFrame(buildMoqRepairFrame({ rsbLength: 0 }))).toBeNull();
161
+ expect(parseMoqRepairFrame(buildMoqRepairFrame({ ssbLength: 0 }))).toBeNull();
162
+ expect(parseMoqRepairFrame(buildMoqRepairFrame({ rsbLength: 2, rsId: 2 }))).toBeNull();
163
+ expect(parseMoqRepairFrame(buildMoqRepairFrame({ symbolData: new Uint8Array() }))).toBeNull();
164
+ });
165
+ });
166
+
167
+ describe("parseMPUHeader", () => {
168
+ it("parses byte 3 as an 8-bit fragment counter", () => {
169
+ const payload = new Uint8Array(8);
170
+ const view = new DataView(payload.buffer);
171
+ view.setUint16(0, 1234, false);
172
+ payload[2] = (2 << 4) | (1 << 3) | (1 << 1); // FT=2, T=1, FI=1
173
+ payload[3] = 0x83;
174
+ view.setUint32(4, 42, false);
175
+
176
+ const header = parseMPUHeader(payload);
177
+
178
+ expect(header.fragmentCounter).toBe(0x83);
179
+ expect(header.fragmentationIndicator).toBe(1);
180
+ expect(header.timedFlag).toBe(1);
181
+ expect(header.aggregationFlag).toBe(false);
182
+ expect(header.payloadLength).toBe(1234);
183
+ expect(header.mpuSequenceNumber).toBe(42);
184
+ });
185
+ });
186
+
187
+ describe("MMTP V/C/X header fields", () => {
188
+ it("parses packet_counter and extension before the payload", () => {
189
+ const packet = new Uint8Array(12 + 4 + 4 + 3 + 2);
190
+ const view = new DataView(packet.buffer);
191
+ packet[0] = 0x22; // C=1, X=1, fecType=0
192
+ view.setUint16(2, 7, false);
193
+ view.setUint32(4, 1000, false);
194
+ view.setUint32(8, 9, false);
195
+ view.setUint32(12, 0x01020304, false);
196
+ view.setUint16(16, 0x1234, false);
197
+ view.setUint16(18, 3, false);
198
+ packet.set([0xaa, 0xbb, 0xcc], 20);
199
+ packet.set([0xde, 0xad], 23);
200
+
201
+ const { header, payload } = unwrapMMTP(packet);
202
+ expect(header.packetCounterFlag).toBe(true);
203
+ expect(header.packetCounter).toBe(0x01020304);
204
+ expect(header.extensionFlag).toBe(true);
205
+ expect(header.extension?.type).toBe(0x1234);
206
+ expect(header.extension?.data).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc]));
207
+ expect(header.headerLength).toBe(23);
208
+ expect(payload).toEqual(new Uint8Array([0xde, 0xad]));
209
+ });
210
+
211
+ it("parses optional fields on repair packets before their payload", () => {
212
+ const packet = addOptionalHeaderFields(buildMoqRepairFrame({ ssStart: 64 }));
213
+ packet[0] = (packet[0]! & ~0x18) | 0x10; // repair FEC_type=2, C=1, X=1
214
+ const header = parseMMTPHeader(packet);
215
+ expect(header.headerLength).toBe(22);
216
+ expect(header.packetCounter).toBe(0x01020304);
217
+ expect(header.extension?.data).toEqual(new Uint8Array([0xaa, 0xbb]));
218
+ });
219
+
220
+ it("rejects unsupported MMTP versions instead of decoding a V=1 layout as V=0", () => {
221
+ const packet = new Uint8Array(MMTP_HEADER_SIZE);
222
+ packet[0] = 0x40;
223
+ expect(() => parseMMTPHeader(packet)).toThrow("Unsupported MMTP version: 1");
224
+ });
225
+
226
+ it("nullable network helpers fail closed for malformed dynamic headers", () => {
227
+ const malformed = [
228
+ new Uint8Array(15),
229
+ new Uint8Array(15),
230
+ new Uint8Array(19),
231
+ new Uint8Array(24),
232
+ ];
233
+ malformed[0]![0] = 0x20; // truncated C-only packet_counter
234
+ malformed[1]![0] = 0x02; // truncated X-only extension header
235
+ malformed[2]![0] = 0x22; // C+X, truncated extension header
236
+ malformed[3]![0] = 0x02; // oversized extension value
237
+ for (const packet of malformed) packet[1] = 0x02;
238
+ new DataView(malformed[3]!.buffer).setUint16(14, 0xffff, false);
239
+
240
+ for (const packet of malformed) {
241
+ expect(() => extractMFUPayload(packet)).not.toThrow();
242
+ expect(extractMFUPayload(packet)).toBeNull();
243
+ expect(() => hasSourceFecPayloadId(packet)).not.toThrow();
244
+ expect(hasSourceFecPayloadId(packet)).toBe(false);
245
+ expect(() => parseAlFecMessage(packet)).not.toThrow();
246
+ expect(parseAlFecMessage(packet)).toBeNull();
247
+ }
248
+ });
249
+ });
250
+
251
+ // ── extractMFUPayload tests ──────────────────────────────────────
252
+ // Reproduces moqtail AVCC corruption: MMTP source packets (fecType=1)
253
+ // with various payloadLength values going through extractMFUPayload.
254
+
255
+ /** Build a complete MMTP+MPU+MFU packet for testing extractMFUPayload. */
256
+ function buildMFUPacket(opts: {
257
+ fecType?: number;
258
+ fi?: number;
259
+ payloadLength?: number;
260
+ videoData?: Uint8Array;
261
+ appendFecId?: boolean;
262
+ rapFlag?: boolean;
263
+ packetId?: number;
264
+ fragmentCounter?: number;
265
+ movieFragmentSequenceNumber?: number;
266
+ sampleNumber?: number;
267
+ sampleOffset?: number;
268
+ includeMfuDuHeader?: boolean;
269
+ fragmentType?: number;
270
+ timedFlag?: number;
271
+ aggregationFlag?: boolean;
272
+ itemId?: number;
273
+ }): Uint8Array {
274
+ const fi = opts.fi ?? 0;
275
+ const fragmentType = opts.fragmentType ?? 2;
276
+ const timedFlag = opts.timedFlag ?? 1;
277
+ const fecType = opts.fecType ?? (opts.appendFecId ? 1 : 0);
278
+ const rapFlag = opts.rapFlag ?? false;
279
+ const videoData = opts.videoData ?? new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
280
+ const hasMfuDu = opts.includeMfuDuHeader ?? (fragmentType === 2 && fi <= 1);
281
+ const mfuDuSize = hasMfuDu ? (timedFlag === 1 ? MFU_DU_HEADER_SIZE_TIMED : 4) : 0;
282
+ const payloadLength = opts.payloadLength ?? (MPU_HEADER_SIZE - 2 + mfuDuSize + videoData.length);
283
+ const movieFragmentSequenceNumber = opts.movieFragmentSequenceNumber ?? 7;
284
+ const sampleNumber = opts.sampleNumber ?? 11;
285
+ const sampleOffset = opts.sampleOffset ?? 0;
286
+ const parts: number[] = [];
287
+ // MMTP header (12 bytes)
288
+ parts.push((fecType << 3) | (rapFlag ? 1 : 0));
289
+ parts.push(0x00); // packet_type=MPU
290
+ parts.push(0, opts.packetId ?? 1);
291
+ parts.push(0, 0, 0x03, 0xE8); // timestamp
292
+ parts.push(0, 0, 0, 1); // sequence number
293
+ // MPU header (8 bytes)
294
+ parts.push((payloadLength >> 8) & 0xff, payloadLength & 0xff);
295
+ parts.push((fragmentType << 4) | (timedFlag << 3) | (fi << 1) |
296
+ (opts.aggregationFlag ? 1 : 0));
297
+ parts.push((opts.fragmentCounter ?? 0) & 0xff); // fragment_counter
298
+ parts.push(0, 0, 0, 42); // mpuSequenceNumber
299
+ // MFU DU header is present only on complete/first MFU fragments.
300
+ if (hasMfuDu) {
301
+ if (timedFlag === 1) {
302
+ parts.push(
303
+ (movieFragmentSequenceNumber >> 24) & 0xff,
304
+ (movieFragmentSequenceNumber >> 16) & 0xff,
305
+ (movieFragmentSequenceNumber >> 8) & 0xff,
306
+ movieFragmentSequenceNumber & 0xff,
307
+ (sampleNumber >> 24) & 0xff,
308
+ (sampleNumber >> 16) & 0xff,
309
+ (sampleNumber >> 8) & 0xff,
310
+ sampleNumber & 0xff,
311
+ (sampleOffset >> 24) & 0xff,
312
+ (sampleOffset >> 16) & 0xff,
313
+ (sampleOffset >> 8) & 0xff,
314
+ sampleOffset & 0xff,
315
+ 0x80,
316
+ 0,
317
+ );
318
+ } else {
319
+ const itemId = opts.itemId ?? 0x01020304;
320
+ parts.push(
321
+ (itemId >> 24) & 0xff,
322
+ (itemId >> 16) & 0xff,
323
+ (itemId >> 8) & 0xff,
324
+ itemId & 0xff,
325
+ );
326
+ }
327
+ }
328
+ // Video data
329
+ for (const b of videoData) parts.push(b);
330
+ // Source FEC Payload ID: SS_ID (4 bytes)
331
+ if (opts.appendFecId) {
332
+ parts.push(0, 0, 0, 163); // SS_ID=163
333
+ }
334
+ return new Uint8Array(parts);
335
+ }
336
+
337
+ function addOptionalHeaderFields(packet: Uint8Array): Uint8Array {
338
+ const expanded = new Uint8Array(packet.length + 4 + 4 + 2);
339
+ expanded.set(packet.subarray(0, MMTP_HEADER_SIZE), 0);
340
+ expanded[0] = expanded[0]! | 0x22;
341
+ const view = new DataView(expanded.buffer);
342
+ view.setUint32(12, 0x01020304, false);
343
+ view.setUint16(16, 0x2001, false);
344
+ view.setUint16(18, 2, false);
345
+ expanded.set([0xaa, 0xbb], 20);
346
+ expanded.set(packet.subarray(MMTP_HEADER_SIZE), 22);
347
+ return expanded;
348
+ }
349
+
350
+ describe("extractMFUPayload — FEC PID boundary", () => {
351
+ it("rejects aggregated MPU payloads in the strict one-data-unit profile", () => {
352
+ expect(extractMFUPayload(buildMFUPacket({ aggregationFlag: true }))).toBeNull();
353
+ });
354
+
355
+ it("uses the ISO length-after-field declaration for complete, continuation, and init payloads", () => {
356
+ const media = new Uint8Array([0xaa, 0xbb, 0xcc]);
357
+ const complete = extractMFUPayload(buildMFUPacket({ videoData: media }))!;
358
+ expect(complete.mpu.payloadLength).toBe(6 + MFU_DU_HEADER_SIZE_TIMED + media.length);
359
+ expect(complete.data).toEqual(media);
360
+
361
+ for (const fi of [2, 3]) {
362
+ const continuation = extractMFUPayload(buildMFUPacket({ fi, videoData: media }))!;
363
+ expect(continuation.mpu.payloadLength).toBe(6 + media.length);
364
+ expect(continuation.mfuDuHeader).toBeNull();
365
+ expect(continuation.data).toEqual(media);
366
+ }
367
+
368
+ const init = extractMFUPayload(buildMFUPacket({
369
+ fragmentType: 0,
370
+ includeMfuDuHeader: false,
371
+ videoData: media,
372
+ }))!;
373
+ expect(init.mpu.payloadLength).toBe(6 + media.length);
374
+ expect(init.data).toEqual(media);
375
+ });
376
+
377
+ it("parses a non-timed complete MFU within the declared span", () => {
378
+ const media = new Uint8Array([0x10, 0x20]);
379
+ const result = extractMFUPayload(buildMFUPacket({
380
+ timedFlag: 0,
381
+ itemId: 0x10203040,
382
+ videoData: media,
383
+ }))!;
384
+ expect(result.itemId).toBe(0x10203040);
385
+ expect(result.data).toEqual(media);
386
+ });
387
+
388
+ it("starts the MPU payload after packet_counter and header extension", () => {
389
+ const video = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
390
+ const pkt = addOptionalHeaderFields(buildMFUPacket({ videoData: video, fecType: 0 }));
391
+ const r = extractMFUPayload(pkt)!;
392
+ expect(r.header.packetCounter).toBe(0x01020304);
393
+ expect(r.header.extension?.data).toEqual(new Uint8Array([0xaa, 0xbb]));
394
+ expect(r.data).toEqual(video);
395
+ });
396
+
397
+ it("rejects a truncated non-timed MFU before reading Item_ID", () => {
398
+ const pkt = new Uint8Array(MMTP_HEADER_SIZE + MPU_HEADER_SIZE);
399
+ const view = new DataView(pkt.buffer);
400
+ view.setUint16(MMTP_HEADER_SIZE, 1, false);
401
+ pkt[MMTP_HEADER_SIZE + 2] = 0x20; // FT=2, T=0, FI=0
402
+
403
+ expect(extractMFUPayload(pkt)).toBeNull();
404
+ });
405
+
406
+ it("rejects declarations shorter than the MPU-header remainder", () => {
407
+ for (const payloadLength of [0, 5]) {
408
+ expect(extractMFUPayload(buildMFUPacket({ payloadLength, fecType: 0 }))).toBeNull();
409
+ }
410
+ });
411
+
412
+ it("rejects a declaration that overruns the packet", () => {
413
+ expect(extractMFUPayload(buildMFUPacket({ payloadLength: 0xffff, fecType: 0 }))).toBeNull();
414
+ });
415
+
416
+ it("rejects the obsolete media-only declaration", () => {
417
+ const video = new Uint8Array(40).fill(0xab);
418
+ video.set([0, 0, 0, 5, 0x65], 0);
419
+ expect(extractMFUPayload(buildMFUPacket({
420
+ videoData: video,
421
+ payloadLength: video.length,
422
+ fecType: 0,
423
+ }))).toBeNull();
424
+ });
425
+
426
+ it("fecType=1 + FEC PID present: strips PID, extracts clean video data", () => {
427
+ const video = new Uint8Array(100).fill(0x42);
428
+ const pkt = buildMFUPacket({ videoData: video, appendFecId: true });
429
+ const r = extractMFUPayload(pkt)!;
430
+ expect(r).not.toBeNull();
431
+ expect(r.data.length).toBe(100);
432
+ expect(r.data).toEqual(video);
433
+ expect(r.fecPayloadId).toEqual({ ssId: 163 });
434
+ });
435
+
436
+ it("fecType=0 + no FEC PID: uses full payload", () => {
437
+ const video = new Uint8Array(50).fill(0xAA);
438
+ const pkt = buildMFUPacket({ videoData: video, fecType: 0 });
439
+ const r = extractMFUPayload(pkt)!;
440
+ expect(r.data.length).toBe(50);
441
+ expect(r.data).toEqual(video);
442
+ expect(r.fecPayloadId).toBeNull();
443
+ });
444
+
445
+ it("rejects an unmarked trailing Source FEC Payload ID", () => {
446
+ const video = new Uint8Array(50).fill(0xAB);
447
+ const pkt = buildMFUPacket({ videoData: video, fecType: 0, appendFecId: true });
448
+ expect(extractMFUPayload(pkt)).toBeNull();
449
+ });
450
+
451
+ it("rejects payloadLength=0 with a Source FEC Payload ID", () => {
452
+ const video = new Uint8Array(100).fill(0x42);
453
+ const pkt = buildMFUPacket({ videoData: video, payloadLength: 0, appendFecId: true });
454
+ expect(extractMFUPayload(pkt)).toBeNull();
455
+ });
456
+
457
+ it("rejects payloadLength=0 without FEC", () => {
458
+ const video = new Uint8Array(50).fill(0xBB);
459
+ const pkt = buildMFUPacket({ videoData: video, payloadLength: 0, fecType: 0 });
460
+ expect(extractMFUPayload(pkt)).toBeNull();
461
+ });
462
+
463
+ it("accepts the canonical ISO declaration with a Source FEC Payload ID", () => {
464
+ const video = new Uint8Array(80).fill(0xCC);
465
+ const pkt = buildMFUPacket({ videoData: video, payloadLength: 6 + MFU_DU_HEADER_SIZE_TIMED + 80, appendFecId: true });
466
+ const r = extractMFUPayload(pkt)!;
467
+ expect(r.data.length).toBe(80);
468
+ expect(r.data).toEqual(video);
469
+ });
470
+
471
+ it("skipFecId prevents truncation on FEC-recovered symbol", () => {
472
+ // FEC-recovered symbol: fecType=1 but FEC PID already stripped
473
+ const video = new Uint8Array(100).fill(0xDD);
474
+ const pkt = buildMFUPacket({ videoData: video, fecType: 1 });
475
+ // Without skipFecId, fecType=1 requires the missing PID and fails closed.
476
+ expect(extractMFUPayload(pkt)).toBeNull();
477
+ // With skipFecId — full data preserved
478
+ const good = extractMFUPayload(pkt, { skipFecId: true })!;
479
+ expect(good.data.length).toBe(100);
480
+ expect(good.data).toEqual(video);
481
+ });
482
+
483
+ it("AVCC keyframe integrity: NALU length prefix survives extraction", () => {
484
+ // Build realistic AVCC: [4B len][SPS][4B len][IDR]
485
+ const sps = new Uint8Array([0x67, 0x42, 0xC0, 0x1E, 0xD9, 0x00, 0xA0, 0x47, 0xFE, 0x88]);
486
+ const idr = new Uint8Array(200).fill(0x65);
487
+ const avcc = new Uint8Array(4 + sps.length + 4 + idr.length);
488
+ const dv = new DataView(avcc.buffer);
489
+ dv.setUint32(0, sps.length, false);
490
+ avcc.set(sps, 4);
491
+ dv.setUint32(4 + sps.length, idr.length, false);
492
+ avcc.set(idr, 4 + sps.length + 4);
493
+
494
+ const pkt = buildMFUPacket({ videoData: avcc, appendFecId: true, rapFlag: true });
495
+ const r = extractMFUPayload(pkt)!;
496
+ expect(r.data.length).toBe(avcc.length);
497
+ // First 4 bytes must be SPS length, not garbage from FEC PID
498
+ const naluLen = new DataView(r.data.buffer, r.data.byteOffset, 4).getUint32(0, false);
499
+ expect(naluLen).toBe(sps.length);
500
+ expect(r.data).toEqual(avcc);
501
+ });
502
+
503
+ it("middle/last fragments without DU header preserve raw continuation bytes", () => {
504
+ for (const fi of [2, 3]) {
505
+ const video = new Uint8Array([0x00, 0x00, 0x04, 0x31, 0xaa, 0xbb]);
506
+ const pkt = buildMFUPacket({
507
+ fi,
508
+ videoData: video,
509
+ payloadLength: 6 + video.length,
510
+ appendFecId: true,
511
+ includeMfuDuHeader: false,
512
+ });
513
+ const r = extractMFUPayload(pkt)!;
514
+ expect(r.data).toEqual(video);
515
+ expect(r.mfuDuHeader).toBeNull();
516
+ expect(r.fecPayloadId).toEqual({ ssId: 163 });
517
+ }
518
+ });
519
+
520
+ it("does not treat sane-looking raw continuation bytes as a timed DU header", () => {
521
+ const video = new Uint8Array([
522
+ 0x00, 0x10, 0x3d, 0xe2, // movie_fragment_sequence_number seen in failed continuation data
523
+ 0x00, 0x00, 0x87, 0x50, // sample_number is within the sanity bound
524
+ 0x00, 0x00, 0x05, 0x00, // plausible offset
525
+ 0x80, 0x00,
526
+ 0xaa, 0xbb,
527
+ ]);
528
+ const pkt = buildMFUPacket({
529
+ fi: 2,
530
+ videoData: video,
531
+ payloadLength: 6 + video.length,
532
+ appendFecId: true,
533
+ includeMfuDuHeader: false,
534
+ });
535
+ const r = extractMFUPayload(pkt)!;
536
+ expect(r.data).toEqual(video);
537
+ expect(r.mfuDuHeader).toBeNull();
538
+ expect(r.fecPayloadId).toEqual({ ssId: 163 });
539
+ });
540
+
541
+ it("does not scan FI=2 media for a timed DU header", () => {
542
+ const video = new Uint8Array([0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
543
+ const pkt = buildMFUPacket({
544
+ fi: 2,
545
+ videoData: video,
546
+ payloadLength: 6 + MFU_DU_HEADER_SIZE_TIMED + video.length,
547
+ appendFecId: true,
548
+ includeMfuDuHeader: true,
549
+ movieFragmentSequenceNumber: 9,
550
+ sampleNumber: 17,
551
+ sampleOffset: 1280,
552
+ });
553
+ const r = extractMFUPayload(pkt)!;
554
+ expect(r.data.subarray(MFU_DU_HEADER_SIZE_TIMED)).toEqual(video);
555
+ expect(r.mfuDuHeader).toBeNull();
556
+ });
557
+ });
558
+
559
+ /**
560
+ * Build an MMTP source packet that optionally carries the ALTA authenticator
561
+ * trailer between the declared MFU payload and the Source FEC Payload ID.
562
+ * Wire layout when both trailer and FEC PID are present:
563
+ * [MMTP 12][length 2][remaining MPU header 6][MFU DU][MFU data]
564
+ * [ext_type(2) ext_length(2) auth_bytes][SS_ID 4]
565
+ * payload_length is the declared `6 + MFU DU + MFU data` span.
566
+ */
567
+ function buildPacketWithAltaTrailer(opts: {
568
+ videoData?: Uint8Array;
569
+ auth?: Uint8Array;
570
+ fi?: number;
571
+ appendFecId?: boolean;
572
+ extType?: number; // override for negative tests
573
+ extLenOverride?: number; // override ext_length field (negative tests)
574
+ packetType?: number; // override MMTP packet_type (negative tests)
575
+ payloadLengthOverride?: number; // override MPU payload_length
576
+ }): Uint8Array {
577
+ const fi = opts.fi ?? 0;
578
+ const appendFecId = opts.appendFecId ?? true;
579
+ const videoData = opts.videoData ?? new Uint8Array(40).fill(0x11);
580
+ const hasMfuDu = fi <= 1;
581
+ const mfuDuSize = hasMfuDu ? MFU_DU_HEADER_SIZE_TIMED : 0;
582
+ const payloadLength = opts.payloadLengthOverride ?? (6 + mfuDuSize + videoData.length);
583
+
584
+ const parts: number[] = [];
585
+ // MMTP header: fecType=1 (so extractFecPayloadId path is reached when appendFecId)
586
+ const fecType = appendFecId ? 1 : 0;
587
+ parts.push((fecType << 3) | 0);
588
+ // ISO V=0 multicast wire encoding: reserved(2)|packet_type(6).
589
+ parts.push((opts.packetType ?? 0x00) & 0x3f);
590
+ parts.push(0, 1); // packet_id
591
+ parts.push(0, 0, 0x03, 0xE8); // timestamp
592
+ parts.push(0, 0, 0, 1); // sequence_number
593
+ // MPU header
594
+ parts.push((payloadLength >> 8) & 0xff, payloadLength & 0xff);
595
+ parts.push((2 << 4) | (1 << 3) | (fi << 1)); // FT=2 (MFU), timed=1, FI
596
+ parts.push(0); // fragment_counter
597
+ parts.push(0, 0, 0, 42); // MPU_sequence_number
598
+ // MFU DU header (complete/first timed MFU fragments)
599
+ if (hasMfuDu) {
600
+ for (let i = 0; i < MFU_DU_HEADER_SIZE_TIMED; i++) parts.push(0);
601
+ }
602
+ // MFU data
603
+ for (const b of videoData) parts.push(b);
604
+ // ALTA trailer (if auth provided)
605
+ if (opts.auth) {
606
+ const extType = opts.extType ?? MMTP_EXT_TYPE_ALTA_AUTH;
607
+ const extLen = opts.extLenOverride ?? opts.auth.length;
608
+ parts.push((extType >> 8) & 0xff, extType & 0xff);
609
+ parts.push((extLen >> 8) & 0xff, extLen & 0xff);
610
+ for (const b of opts.auth) parts.push(b);
611
+ }
612
+ // Source FEC Payload ID
613
+ if (appendFecId) {
614
+ parts.push(0, 0, 0x04, 0x00); // SS_ID = 1024
615
+ }
616
+ return new Uint8Array(parts);
617
+ }
618
+
619
+ describe("extractAltaTrailer", () => {
620
+ it("keeps ALTA-only and ALTA+FEC suffixes outside extracted media", () => {
621
+ const videoData = new Uint8Array([0x11, 0x22, 0x33]);
622
+ const auth = new Uint8Array([0xaa, 0xbb]);
623
+
624
+ const altaOnly = extractMFUPayload(buildPacketWithAltaTrailer({
625
+ videoData,
626
+ auth,
627
+ appendFecId: false,
628
+ }))!;
629
+ expect(altaOnly.data).toEqual(videoData);
630
+ expect(altaOnly.fecPayloadId).toBeNull();
631
+
632
+ const combined = extractMFUPayload(buildPacketWithAltaTrailer({
633
+ videoData,
634
+ auth,
635
+ appendFecId: true,
636
+ }))!;
637
+ expect(combined.data).toEqual(videoData);
638
+ expect(combined.fecPayloadId).toEqual({ ssId: 1024 });
639
+ });
640
+
641
+ it("rejects an unknown suffix at the declared boundary", () => {
642
+ const packet = buildPacketWithAltaTrailer({
643
+ auth: new Uint8Array([0xaa]),
644
+ appendFecId: false,
645
+ extType: 0x1234,
646
+ });
647
+ expect(extractMFUPayload(packet)).toBeNull();
648
+ });
649
+
650
+ it("returns null when no trailer present (MFU + SS_ID only)", () => {
651
+ const pkt = buildPacketWithAltaTrailer({ appendFecId: true });
652
+ expect(extractAltaTrailer(pkt)).toBeNull();
653
+ });
654
+
655
+ it("returns {auth, signedEnd} when valid ALTA trailer is present", () => {
656
+ const auth = new Uint8Array([0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
657
+ const pkt = buildPacketWithAltaTrailer({ auth });
658
+ const r = extractAltaTrailer(pkt);
659
+ expect(r).not.toBeNull();
660
+ expect(r!.auth).toEqual(auth);
661
+ // signedEnd marks where the signed payload ends (= where the trailer begins)
662
+ // = MMTP_HEADER_SIZE + MPU_HEADER_SIZE + MFU_DU + videoData.length
663
+ const expectedSignedEnd =
664
+ MMTP_HEADER_SIZE + MPU_HEADER_SIZE + MFU_DU_HEADER_SIZE_TIMED + 40;
665
+ expect(r!.signedEnd).toBe(expectedSignedEnd);
666
+ });
667
+
668
+ it("accounts for packet_counter and MMTP header extension in signedEnd", () => {
669
+ const auth = new Uint8Array([0xaa, 0xbb]);
670
+ const packet = buildPacketWithAltaTrailer({ auth });
671
+ const expanded = new Uint8Array(packet.length + 10);
672
+ expanded.set(packet.subarray(0, MMTP_HEADER_SIZE), 0);
673
+ expanded[0] = expanded[0]! | 0x22;
674
+ const view = new DataView(expanded.buffer);
675
+ view.setUint32(12, 0x01020304, false);
676
+ view.setUint16(16, 0x1234, false);
677
+ view.setUint16(18, 2, false);
678
+ expanded.set([0x55, 0x66], 20);
679
+ expanded.set(packet.subarray(MMTP_HEADER_SIZE), 22);
680
+
681
+ const result = extractAltaTrailer(expanded);
682
+
683
+ expect(result?.auth).toEqual(auth);
684
+ expect(result?.signedEnd).toBe(MMTP_HEADER_SIZE + 10 + MPU_HEADER_SIZE + MFU_DU_HEADER_SIZE_TIMED + 40);
685
+ });
686
+
687
+ it("returns null for the obsolete payloadLength=0 convention", () => {
688
+ const auth = new Uint8Array([0xAA, 0xBB]);
689
+ const pkt = buildPacketWithAltaTrailer({ auth, payloadLengthOverride: 0 });
690
+ expect(extractAltaTrailer(pkt)).toBeNull();
691
+ });
692
+
693
+ it("returns null for non-MPU packet types (signaling / repair)", () => {
694
+ const auth = new Uint8Array([0xAA]);
695
+ const signalingPkt = buildPacketWithAltaTrailer({
696
+ auth,
697
+ packetType: PACKET_TYPE_SIGNALING,
698
+ });
699
+ expect(extractAltaTrailer(signalingPkt)).toBeNull();
700
+ });
701
+
702
+ it("returns null when ext_type does not match MMTP_EXT_TYPE_ALTA_AUTH", () => {
703
+ const auth = new Uint8Array([0xAA, 0xBB]);
704
+ // Use a different ext_type (0x1234) — not the ALTA authenticator sentinel.
705
+ const pkt = buildPacketWithAltaTrailer({ auth, extType: 0x1234 });
706
+ expect(extractAltaTrailer(pkt)).toBeNull();
707
+ });
708
+
709
+ it("returns null when ext_length would extend past the SS_ID boundary", () => {
710
+ const auth = new Uint8Array([0xAA, 0xBB, 0xCC]);
711
+ // Advertised length is 99 bytes but only 3 auth bytes exist in the packet.
712
+ const pkt = buildPacketWithAltaTrailer({ auth, extLenOverride: 99 });
713
+ expect(extractAltaTrailer(pkt)).toBeNull();
714
+ });
715
+
716
+ it("returns null when the packet is smaller than MMTP+MPU header", () => {
717
+ // Arbitrary truncated packet — below the minimum header budget.
718
+ const tiny = new Uint8Array(4);
719
+ expect(extractAltaTrailer(tiny)).toBeNull();
720
+ });
721
+
722
+ it("skipFecId=true: trailer extends to end-of-packet (no 4-byte SS_ID reserved)", () => {
723
+ // When the caller is an FEC-recovered symbol, the SS_ID has already been
724
+ // stripped upstream and the last 4 bytes belong to the trailer itself.
725
+ const auth = new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]);
726
+ const pkt = buildPacketWithAltaTrailer({ auth, appendFecId: false });
727
+ const r = extractAltaTrailer(pkt, { skipFecId: true });
728
+ expect(r).not.toBeNull();
729
+ expect(r!.auth).toEqual(auth);
730
+ });
731
+
732
+ it("exposes MMTP_EXT_TRAILER_HEADER_SIZE as 4 (2B ext_type + 2B ext_length)", () => {
733
+ expect(MMTP_EXT_TRAILER_HEADER_SIZE).toBe(4);
734
+ });
735
+
736
+ // Pins the contract between the two call shapes that fec-worker and
737
+ // mmtp-router use. Historically fec-worker had its own
738
+ // `extractAltaTrailerFromRecovered` duplicate to avoid a package cycle
739
+ // with @blockcast/mmt-container. After the dedup
740
+ // (@blockcast/mmt-alta-parse leaf), fec-worker now calls
741
+ // `extractAltaTrailer(recovered, { skipFecId: true })`. If that flag ever
742
+ // diverges from the default FEC-path behavior in subtle ways, recovered
743
+ // ALTA verification silently drops to 0 — this test would fail first.
744
+ it("equivalence: wire packet (default) and recovered bytes (skipFecId:true) yield identical auth + signedEnd", () => {
745
+ const videoData = new Uint8Array(40).fill(0x11);
746
+ const auth = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x12, 0x34, 0x56, 0x78]);
747
+
748
+ // Wire packet: fecType=1 at byte 0, has trailing 4-byte SS_ID.
749
+ const wirePkt = buildPacketWithAltaTrailer({ videoData, auth, appendFecId: true });
750
+
751
+ // Real FEC-recovered bytes are the wire packet MINUS the trailing SS_ID.
752
+ // fecType=1 is preserved in byte 0 — the flag `skipFecId: true` tells the
753
+ // parser that those 4 SS_ID bytes aren't really present, not that the
754
+ // MMTP header has been rewritten.
755
+ const recoveredBytes = wirePkt.subarray(0, wirePkt.length - 4);
756
+
757
+ const viaWire = extractAltaTrailer(wirePkt);
758
+ const viaRecovered = extractAltaTrailer(recoveredBytes, { skipFecId: true });
759
+
760
+ expect(viaWire).not.toBeNull();
761
+ expect(viaRecovered).not.toBeNull();
762
+ // Byte-identical auth payload — this is what the ALTA verifier hashes.
763
+ expect(viaRecovered!.auth).toEqual(viaWire!.auth);
764
+ expect(viaRecovered!.auth).toEqual(auth);
765
+ // signedEnd is the offset the verifier slices to — must also match so
766
+ // both call shapes produce the same signed payload range.
767
+ expect(viaRecovered!.signedEnd).toBe(viaWire!.signedEnd);
768
+ });
769
+ });