@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.
- package/README.md +120 -0
- package/dist/.build-stamp +0 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/mmtp.d.ts +271 -0
- package/dist/mmtp.d.ts.map +1 -0
- package/dist/mmtp.js +568 -0
- package/dist/mmtp.js.map +1 -0
- package/package.json +44 -0
- package/src/alfec.test.ts +176 -0
- package/src/index.ts +1 -0
- package/src/mmtp.test.ts +769 -0
- package/src/mmtp.ts +746 -0
package/src/mmtp.test.ts
ADDED
|
@@ -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
|
+
});
|