@blockcast/mmt-container 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/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/loc.d.ts +100 -0
- package/dist/loc.d.ts.map +1 -0
- package/dist/loc.js +198 -0
- package/dist/loc.js.map +1 -0
- package/dist/mfu-reassembler.d.ts +92 -0
- package/dist/mfu-reassembler.d.ts.map +1 -0
- package/dist/mfu-reassembler.js +320 -0
- package/dist/mfu-reassembler.js.map +1 -0
- package/dist/mmtp.d.ts +73 -0
- package/dist/mmtp.d.ts.map +1 -0
- package/dist/mmtp.js +210 -0
- package/dist/mmtp.js.map +1 -0
- package/package.json +45 -0
- package/src/index.ts +44 -0
- package/src/loc.ts +218 -0
- package/src/mfu-reassembler.ts +416 -0
- package/src/mmtp.ts +277 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mmt/container - MFU Fragment Reassembler
|
|
3
|
+
*
|
|
4
|
+
* Handles out-of-order delivery of fragmented MFUs (Media Fragment Units)
|
|
5
|
+
* according to ISO/IEC 23008-1:2023 Section 9.2.3.3
|
|
6
|
+
*
|
|
7
|
+
* Fragmentation Indicator (FI) values:
|
|
8
|
+
* - 0: Complete MFU (single packet, no fragmentation)
|
|
9
|
+
* - 1: First fragment
|
|
10
|
+
* - 2: Middle fragment
|
|
11
|
+
* - 3: Last fragment
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type MMTPPacket,
|
|
18
|
+
unwrapMMTP,
|
|
19
|
+
parseMPUHeader,
|
|
20
|
+
isFecRepairPacket,
|
|
21
|
+
MPU_HEADER_SIZE,
|
|
22
|
+
MFU_DU_HEADER_SIZE,
|
|
23
|
+
} from './mmtp.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* MFU Fragment for reassembly
|
|
27
|
+
*/
|
|
28
|
+
export interface MFUFragment {
|
|
29
|
+
fragmentationIndicator: number
|
|
30
|
+
mpuSequenceNumber: number
|
|
31
|
+
fragmentCounter: number
|
|
32
|
+
data: Uint8Array
|
|
33
|
+
timestamp: bigint
|
|
34
|
+
receivedAt: number
|
|
35
|
+
/** Random Access Point flag from MMTP header - indicates keyframe */
|
|
36
|
+
rapFlag: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reassembled MFU result
|
|
41
|
+
*/
|
|
42
|
+
export interface ReassembledMFU {
|
|
43
|
+
mpuSequenceNumber: number
|
|
44
|
+
data: Uint8Array
|
|
45
|
+
timestamp: bigint
|
|
46
|
+
fragmentCount: number
|
|
47
|
+
/** Random Access Point flag - indicates keyframe (from first fragment) */
|
|
48
|
+
rapFlag: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reassembler statistics
|
|
53
|
+
*/
|
|
54
|
+
export interface ReassemblerStats {
|
|
55
|
+
totalFragments: number
|
|
56
|
+
reassembledMFUs: number
|
|
57
|
+
timedOutMFUs: number
|
|
58
|
+
outOfOrderFragments: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MFUReassemblerOptions {
|
|
62
|
+
/** Timeout in milliseconds for incomplete MFUs (default: 1000) */
|
|
63
|
+
timeoutMs?: number
|
|
64
|
+
/** Maximum buffered MFUs before eviction (default: 100) */
|
|
65
|
+
maxBuffered?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* MFU Reassembler for out-of-order fragment delivery
|
|
70
|
+
* Based on ISO/IEC 23008-1:2023 Section 9.2.3.3
|
|
71
|
+
*/
|
|
72
|
+
export class MFUReassembler {
|
|
73
|
+
#buffers: Map<number, MFUFragment[]> = new Map()
|
|
74
|
+
#timeouts: Map<number, ReturnType<typeof setTimeout>> = new Map()
|
|
75
|
+
#timeoutMs: number
|
|
76
|
+
#maxBuffered: number
|
|
77
|
+
|
|
78
|
+
/** Statistics for debugging */
|
|
79
|
+
readonly stats: ReassemblerStats = {
|
|
80
|
+
totalFragments: 0,
|
|
81
|
+
reassembledMFUs: 0,
|
|
82
|
+
timedOutMFUs: 0,
|
|
83
|
+
outOfOrderFragments: 0,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
constructor(options?: MFUReassemblerOptions) {
|
|
87
|
+
this.#timeoutMs = options?.timeoutMs ?? 1000
|
|
88
|
+
this.#maxBuffered = options?.maxBuffered ?? 100
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Add a fragment and return reassembled MFU if complete
|
|
93
|
+
*/
|
|
94
|
+
addFragment(fragment: MFUFragment): ReassembledMFU | null {
|
|
95
|
+
this.stats.totalFragments++
|
|
96
|
+
|
|
97
|
+
const { mpuSequenceNumber, fragmentationIndicator, fragmentCounter } = fragment
|
|
98
|
+
|
|
99
|
+
// Handle complete MFU (FI=0)
|
|
100
|
+
if (fragmentationIndicator === 0) {
|
|
101
|
+
this.stats.reassembledMFUs++
|
|
102
|
+
return {
|
|
103
|
+
mpuSequenceNumber,
|
|
104
|
+
data: fragment.data,
|
|
105
|
+
timestamp: fragment.timestamp,
|
|
106
|
+
fragmentCount: 1,
|
|
107
|
+
rapFlag: fragment.rapFlag,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Get or create fragment buffer for this MPU
|
|
112
|
+
let fragments = this.#buffers.get(mpuSequenceNumber)
|
|
113
|
+
if (!fragments) {
|
|
114
|
+
fragments = []
|
|
115
|
+
this.#buffers.set(mpuSequenceNumber, fragments)
|
|
116
|
+
this.#setTimeout(mpuSequenceNumber)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for duplicate fragment
|
|
120
|
+
const existingIdx = fragments.findIndex((f) => f.fragmentCounter === fragmentCounter)
|
|
121
|
+
if (existingIdx >= 0) {
|
|
122
|
+
fragments[existingIdx] = fragment
|
|
123
|
+
} else {
|
|
124
|
+
fragments.push(fragment)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Track out-of-order delivery
|
|
128
|
+
if (fragments.length > 1) {
|
|
129
|
+
const lastFI = fragments[fragments.length - 2]!.fragmentationIndicator
|
|
130
|
+
if (fragmentationIndicator !== lastFI + 1) {
|
|
131
|
+
this.stats.outOfOrderFragments++
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Enforce memory limits
|
|
136
|
+
this.#enforceMemoryLimit()
|
|
137
|
+
|
|
138
|
+
// Try to reassemble
|
|
139
|
+
return this.#tryReassemble(mpuSequenceNumber)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#tryReassemble(mpuSequenceNumber: number): ReassembledMFU | null {
|
|
143
|
+
const fragments = this.#buffers.get(mpuSequenceNumber)
|
|
144
|
+
if (!fragments || fragments.length === 0) {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Sort by fragment counter
|
|
149
|
+
fragments.sort((a, b) => a.fragmentCounter - b.fragmentCounter)
|
|
150
|
+
|
|
151
|
+
const firstFI = fragments[0]!.fragmentationIndicator
|
|
152
|
+
const lastFI = fragments[fragments.length - 1]!.fragmentationIndicator
|
|
153
|
+
|
|
154
|
+
// Need first (FI=1) and last (FI=3) fragments
|
|
155
|
+
if (firstFI !== 1 || lastFI !== 3) {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Verify middle fragments have FI=2
|
|
160
|
+
for (let i = 1; i < fragments.length - 1; i++) {
|
|
161
|
+
if (fragments[i]!.fragmentationIndicator !== 2) {
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Reassemble: concatenate all fragment data
|
|
167
|
+
const totalSize = fragments.reduce((sum, f) => sum + f.data.length, 0)
|
|
168
|
+
const data = new Uint8Array(totalSize)
|
|
169
|
+
|
|
170
|
+
let offset = 0
|
|
171
|
+
for (const fragment of fragments) {
|
|
172
|
+
data.set(fragment.data, offset)
|
|
173
|
+
offset += fragment.data.length
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Cleanup
|
|
177
|
+
this.#clearTimeout(mpuSequenceNumber)
|
|
178
|
+
this.#buffers.delete(mpuSequenceNumber)
|
|
179
|
+
this.stats.reassembledMFUs++
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
mpuSequenceNumber,
|
|
183
|
+
data,
|
|
184
|
+
timestamp: fragments[0]!.timestamp,
|
|
185
|
+
fragmentCount: fragments.length,
|
|
186
|
+
rapFlag: fragments[0]!.rapFlag,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#setTimeout(mpuSequenceNumber: number): void {
|
|
191
|
+
const timeoutId = setTimeout(() => {
|
|
192
|
+
this.#onTimeout(mpuSequenceNumber)
|
|
193
|
+
}, this.#timeoutMs)
|
|
194
|
+
this.#timeouts.set(mpuSequenceNumber, timeoutId)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#clearTimeout(mpuSequenceNumber: number): void {
|
|
198
|
+
const timeoutId = this.#timeouts.get(mpuSequenceNumber)
|
|
199
|
+
if (timeoutId !== undefined) {
|
|
200
|
+
clearTimeout(timeoutId)
|
|
201
|
+
this.#timeouts.delete(mpuSequenceNumber)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#onTimeout(mpuSequenceNumber: number): void {
|
|
206
|
+
const fragments = this.#buffers.get(mpuSequenceNumber)
|
|
207
|
+
if (!fragments) return
|
|
208
|
+
|
|
209
|
+
this.stats.timedOutMFUs++
|
|
210
|
+
this.#buffers.delete(mpuSequenceNumber)
|
|
211
|
+
this.#timeouts.delete(mpuSequenceNumber)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#enforceMemoryLimit(): void {
|
|
215
|
+
if (this.#buffers.size <= this.#maxBuffered) return
|
|
216
|
+
|
|
217
|
+
const sequences = Array.from(this.#buffers.keys()).sort((a, b) => a - b)
|
|
218
|
+
const toRemove = sequences.slice(0, sequences.length - this.#maxBuffered)
|
|
219
|
+
|
|
220
|
+
for (const mpuSeq of toRemove) {
|
|
221
|
+
this.#clearTimeout(mpuSeq)
|
|
222
|
+
this.#buffers.delete(mpuSeq)
|
|
223
|
+
this.stats.timedOutMFUs++
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Flush all buffered fragments
|
|
229
|
+
*/
|
|
230
|
+
flush(): void {
|
|
231
|
+
for (const mpuSeq of this.#buffers.keys()) {
|
|
232
|
+
this.#clearTimeout(mpuSeq)
|
|
233
|
+
}
|
|
234
|
+
this.#buffers.clear()
|
|
235
|
+
this.#timeouts.clear()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get number of pending MFUs
|
|
240
|
+
*/
|
|
241
|
+
get pendingCount(): number {
|
|
242
|
+
return this.#buffers.size
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get current buffer status
|
|
247
|
+
*/
|
|
248
|
+
getStatus(): {
|
|
249
|
+
bufferedMFUs: number
|
|
250
|
+
pendingFragments: number
|
|
251
|
+
stats: ReassemblerStats
|
|
252
|
+
} {
|
|
253
|
+
let pendingFragments = 0
|
|
254
|
+
for (const fragments of this.#buffers.values()) {
|
|
255
|
+
pendingFragments += fragments.length
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
bufferedMFUs: this.#buffers.size,
|
|
260
|
+
pendingFragments,
|
|
261
|
+
stats: { ...this.stats },
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Create MFU fragment from MMTP packet for reassembly
|
|
268
|
+
*/
|
|
269
|
+
export function createFragment(mmtpPacket: Uint8Array): MFUFragment {
|
|
270
|
+
const { header, payload } = unwrapMMTP(mmtpPacket)
|
|
271
|
+
const mpuHeader = parseMPUHeader(payload)
|
|
272
|
+
const { mpuSequenceNumber, fragmentationIndicator, fragmentCounter } = mpuHeader
|
|
273
|
+
|
|
274
|
+
// MFU DU header is present only for FI = 0 (complete) or FI = 1 (first)
|
|
275
|
+
const hasMfuHeader = fragmentationIndicator === 0 || fragmentationIndicator === 1
|
|
276
|
+
const headerSize = MPU_HEADER_SIZE + (hasMfuHeader ? MFU_DU_HEADER_SIZE : 0)
|
|
277
|
+
|
|
278
|
+
if (payload.length < headerSize) {
|
|
279
|
+
throw new Error(`Payload too small for headers: ${payload.length} bytes (need ${headerSize})`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const data = payload.subarray(headerSize)
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
fragmentationIndicator,
|
|
286
|
+
mpuSequenceNumber,
|
|
287
|
+
fragmentCounter,
|
|
288
|
+
data,
|
|
289
|
+
timestamp: header.timestamp,
|
|
290
|
+
receivedAt: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
291
|
+
rapFlag: header.rapFlag,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Process MMTP packet with fragment reassembly
|
|
297
|
+
*/
|
|
298
|
+
export function processMMTPWithReassembly(
|
|
299
|
+
mmtpPacket: Uint8Array,
|
|
300
|
+
reassembler: MFUReassembler,
|
|
301
|
+
): ReassembledMFU | null {
|
|
302
|
+
// Skip FEC repair packets
|
|
303
|
+
if (isFecRepairPacket(mmtpPacket)) {
|
|
304
|
+
return null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const fragment = createFragment(mmtpPacket)
|
|
308
|
+
return reassembler.addFragment(fragment)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// =============================================================================
|
|
312
|
+
// Inline Tests (vitest)
|
|
313
|
+
// =============================================================================
|
|
314
|
+
|
|
315
|
+
if (import.meta.vitest) {
|
|
316
|
+
const { describe, test, expect } = import.meta.vitest
|
|
317
|
+
|
|
318
|
+
describe('MFUReassembler', () => {
|
|
319
|
+
test('should return complete MFU for FI=0', () => {
|
|
320
|
+
const reassembler = new MFUReassembler()
|
|
321
|
+
const result = reassembler.addFragment({
|
|
322
|
+
fragmentationIndicator: 0,
|
|
323
|
+
mpuSequenceNumber: 1,
|
|
324
|
+
fragmentCounter: 0,
|
|
325
|
+
data: new Uint8Array([1, 2, 3, 4]),
|
|
326
|
+
timestamp: 1000n,
|
|
327
|
+
receivedAt: 0,
|
|
328
|
+
rapFlag: true,
|
|
329
|
+
})
|
|
330
|
+
expect(result).not.toBeNull()
|
|
331
|
+
expect(result?.data).toEqual(new Uint8Array([1, 2, 3, 4]))
|
|
332
|
+
expect(result?.fragmentCount).toBe(1)
|
|
333
|
+
expect(result?.rapFlag).toBe(true)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('should buffer and reassemble 2 fragments', () => {
|
|
337
|
+
const reassembler = new MFUReassembler()
|
|
338
|
+
|
|
339
|
+
// First fragment (FI=1)
|
|
340
|
+
const result1 = reassembler.addFragment({
|
|
341
|
+
fragmentationIndicator: 1,
|
|
342
|
+
mpuSequenceNumber: 1,
|
|
343
|
+
fragmentCounter: 0,
|
|
344
|
+
data: new Uint8Array([1, 2]),
|
|
345
|
+
timestamp: 1000n,
|
|
346
|
+
receivedAt: 0,
|
|
347
|
+
rapFlag: true,
|
|
348
|
+
})
|
|
349
|
+
expect(result1).toBeNull()
|
|
350
|
+
|
|
351
|
+
// Last fragment (FI=3)
|
|
352
|
+
const result2 = reassembler.addFragment({
|
|
353
|
+
fragmentationIndicator: 3,
|
|
354
|
+
mpuSequenceNumber: 1,
|
|
355
|
+
fragmentCounter: 1,
|
|
356
|
+
data: new Uint8Array([3, 4]),
|
|
357
|
+
timestamp: 1000n,
|
|
358
|
+
receivedAt: 0,
|
|
359
|
+
rapFlag: false,
|
|
360
|
+
})
|
|
361
|
+
expect(result2).not.toBeNull()
|
|
362
|
+
expect(result2?.data).toEqual(new Uint8Array([1, 2, 3, 4]))
|
|
363
|
+
expect(result2?.fragmentCount).toBe(2)
|
|
364
|
+
expect(result2?.rapFlag).toBe(true) // Should use first fragment's rapFlag
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('should handle out-of-order fragments', () => {
|
|
368
|
+
const reassembler = new MFUReassembler()
|
|
369
|
+
|
|
370
|
+
// Last fragment first (FI=3)
|
|
371
|
+
reassembler.addFragment({
|
|
372
|
+
fragmentationIndicator: 3,
|
|
373
|
+
mpuSequenceNumber: 1,
|
|
374
|
+
fragmentCounter: 1,
|
|
375
|
+
data: new Uint8Array([3, 4]),
|
|
376
|
+
timestamp: 1000n,
|
|
377
|
+
receivedAt: 0,
|
|
378
|
+
rapFlag: false,
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// Then first fragment (FI=1)
|
|
382
|
+
const result = reassembler.addFragment({
|
|
383
|
+
fragmentationIndicator: 1,
|
|
384
|
+
mpuSequenceNumber: 1,
|
|
385
|
+
fragmentCounter: 0,
|
|
386
|
+
data: new Uint8Array([1, 2]),
|
|
387
|
+
timestamp: 1000n,
|
|
388
|
+
receivedAt: 0,
|
|
389
|
+
rapFlag: true,
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
expect(result).not.toBeNull()
|
|
393
|
+
expect(result?.data).toEqual(new Uint8Array([1, 2, 3, 4]))
|
|
394
|
+
expect(result?.rapFlag).toBe(true)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('should track pending count', () => {
|
|
398
|
+
const reassembler = new MFUReassembler()
|
|
399
|
+
expect(reassembler.pendingCount).toBe(0)
|
|
400
|
+
|
|
401
|
+
reassembler.addFragment({
|
|
402
|
+
fragmentationIndicator: 1,
|
|
403
|
+
mpuSequenceNumber: 1,
|
|
404
|
+
fragmentCounter: 0,
|
|
405
|
+
data: new Uint8Array([1]),
|
|
406
|
+
timestamp: 1000n,
|
|
407
|
+
receivedAt: 0,
|
|
408
|
+
rapFlag: false,
|
|
409
|
+
})
|
|
410
|
+
expect(reassembler.pendingCount).toBe(1)
|
|
411
|
+
|
|
412
|
+
reassembler.flush()
|
|
413
|
+
expect(reassembler.pendingCount).toBe(0)
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
}
|
package/src/mmtp.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mmt/container - MMTP Parser
|
|
3
|
+
*
|
|
4
|
+
* MMTP (MMT Protocol) packet parser based on ISO/IEC 23008-1:2023 Section 9.2
|
|
5
|
+
*
|
|
6
|
+
* MMTP header format (12 bytes):
|
|
7
|
+
* - Byte 0: V(2) | C(1) | FEC_type(2) | r(1) | X(1) | R(1)
|
|
8
|
+
* - Byte 1: packet_type(6) | reserved(2)
|
|
9
|
+
* - Bytes 2-3: packet_id (big-endian)
|
|
10
|
+
* - Bytes 4-7: timestamp (big-endian, 32-bit)
|
|
11
|
+
* - Bytes 8-11: sequence_number (big-endian)
|
|
12
|
+
*
|
|
13
|
+
* FEC_type values:
|
|
14
|
+
* - 0: No FEC / source packet
|
|
15
|
+
* - 1: FEC mode 0 (source with FEC payload ID)
|
|
16
|
+
* - 2: FEC repair packet mode 0
|
|
17
|
+
* - 3: FEC repair packet mode 1
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface MMTPHeader {
|
|
23
|
+
version: number
|
|
24
|
+
packetId: number
|
|
25
|
+
timestamp: bigint
|
|
26
|
+
sequenceNumber: number
|
|
27
|
+
payloadLength: number
|
|
28
|
+
fecType: number
|
|
29
|
+
/** Random Access Point flag - indicates keyframe (ISO 23008-1, byte 0 bit 0) */
|
|
30
|
+
rapFlag: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MPUHeader {
|
|
34
|
+
mpuSequenceNumber: number
|
|
35
|
+
fragmentationIndicator: number // FI: 0=complete, 1=first, 2=middle, 3=last
|
|
36
|
+
fragmentCounter: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MMTPPacket {
|
|
40
|
+
header: MMTPHeader
|
|
41
|
+
payload: Uint8Array
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const MMTP_HEADER_SIZE = 12
|
|
45
|
+
export const MPU_HEADER_SIZE = 8
|
|
46
|
+
export const MFU_DU_HEADER_SIZE = 14
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse MMTP header from buffer
|
|
50
|
+
*/
|
|
51
|
+
export function parseMMTPHeader(buffer: Uint8Array): MMTPHeader {
|
|
52
|
+
if (buffer.length < MMTP_HEADER_SIZE) {
|
|
53
|
+
throw new Error(`MMTP packet too small: ${buffer.length} bytes`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
57
|
+
|
|
58
|
+
// Extract FEC type from byte 0, bits 3-4
|
|
59
|
+
const fecType = (buffer[0]! >> 3) & 0x03
|
|
60
|
+
|
|
61
|
+
// Extract RAP flag from byte 0, bit 0 (R bit - Random Access Point)
|
|
62
|
+
const rapFlag = (buffer[0]! & 0x01) === 1
|
|
63
|
+
|
|
64
|
+
// Skip bytes 0-1 (flags and packet_type)
|
|
65
|
+
let offset = 2
|
|
66
|
+
|
|
67
|
+
// Packet ID (2 bytes, big-endian)
|
|
68
|
+
const packetId = view.getUint16(offset, false)
|
|
69
|
+
offset += 2
|
|
70
|
+
|
|
71
|
+
// Timestamp (4 bytes, big-endian)
|
|
72
|
+
const timestamp = BigInt(view.getUint32(offset, false))
|
|
73
|
+
offset += 4
|
|
74
|
+
|
|
75
|
+
// Sequence number (4 bytes, big-endian)
|
|
76
|
+
const sequenceNumber = view.getUint32(offset, false)
|
|
77
|
+
offset += 4
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
version: 1,
|
|
81
|
+
packetId,
|
|
82
|
+
timestamp,
|
|
83
|
+
sequenceNumber,
|
|
84
|
+
payloadLength: buffer.length - offset,
|
|
85
|
+
fecType,
|
|
86
|
+
rapFlag,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Unwrap MMTP packet to extract payload
|
|
92
|
+
*/
|
|
93
|
+
export function unwrapMMTP(buffer: Uint8Array): MMTPPacket {
|
|
94
|
+
const header = parseMMTPHeader(buffer)
|
|
95
|
+
const payload = buffer.subarray(MMTP_HEADER_SIZE)
|
|
96
|
+
|
|
97
|
+
return { header, payload }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse MPU header from payload
|
|
102
|
+
* ISO/IEC 23008-1:2023 Section 9.2.3.3
|
|
103
|
+
*/
|
|
104
|
+
export function parseMPUHeader(payload: Uint8Array): MPUHeader {
|
|
105
|
+
if (payload.length < MPU_HEADER_SIZE) {
|
|
106
|
+
throw new Error(`Payload too small for MPU header: ${payload.length} bytes`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength)
|
|
110
|
+
|
|
111
|
+
// MPU sequence number (4 bytes, big-endian)
|
|
112
|
+
const mpuSequenceNumber = view.getUint32(0, false)
|
|
113
|
+
|
|
114
|
+
// Fragmentation indicator (byte 5, bits 0-1)
|
|
115
|
+
const fragmentationIndicator = payload[5]! & 0x03
|
|
116
|
+
|
|
117
|
+
// Fragment counter (bytes 6-7, big-endian)
|
|
118
|
+
const fragmentCounter = view.getUint16(6, false)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
mpuSequenceNumber,
|
|
122
|
+
fragmentationIndicator,
|
|
123
|
+
fragmentCounter,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract NAL unit payload from MMTP packet
|
|
129
|
+
*/
|
|
130
|
+
export function extractNALUnit(mmtpPacket: Uint8Array): Uint8Array {
|
|
131
|
+
const { payload } = unwrapMMTP(mmtpPacket)
|
|
132
|
+
|
|
133
|
+
if (payload.length < MPU_HEADER_SIZE) {
|
|
134
|
+
throw new Error(`Payload too small for MPU header: ${payload.length} bytes`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Parse MPU header to check fragmentation indicator
|
|
138
|
+
const fragmentationIndicator = payload[5]! & 0x03
|
|
139
|
+
|
|
140
|
+
// MFU DU header is present only for FI = 0 (complete) or FI = 1 (first)
|
|
141
|
+
const hasMfuHeader = fragmentationIndicator === 0 || fragmentationIndicator === 1
|
|
142
|
+
const headerSize = MPU_HEADER_SIZE + (hasMfuHeader ? MFU_DU_HEADER_SIZE : 0)
|
|
143
|
+
|
|
144
|
+
if (payload.length < headerSize) {
|
|
145
|
+
throw new Error(`Payload too small for headers: ${payload.length} bytes (need ${headerSize})`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return payload.subarray(headerSize)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Extract timestamp from MMTP packet
|
|
153
|
+
*/
|
|
154
|
+
export function extractTimestamp(mmtpPacket: Uint8Array): bigint {
|
|
155
|
+
const { header } = unwrapMMTP(mmtpPacket)
|
|
156
|
+
return header.timestamp
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if MMTP packet is an FEC repair packet
|
|
161
|
+
* FEC_type 2 and 3 are repair packets
|
|
162
|
+
*/
|
|
163
|
+
export function isFecRepairPacket(mmtpPacket: Uint8Array): boolean {
|
|
164
|
+
if (mmtpPacket.length < 1) return false
|
|
165
|
+
const fecType = (mmtpPacket[0]! >> 3) & 0x03
|
|
166
|
+
return fecType >= 2
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if MMTP packet has FEC payload ID (FEC_type 1 or higher)
|
|
171
|
+
*/
|
|
172
|
+
export function hasFecPayloadId(mmtpPacket: Uint8Array): boolean {
|
|
173
|
+
if (mmtpPacket.length < 1) return false
|
|
174
|
+
const fecType = (mmtpPacket[0]! >> 3) & 0x03
|
|
175
|
+
return fecType >= 1
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// =============================================================================
|
|
179
|
+
// Inline Tests (vitest)
|
|
180
|
+
// =============================================================================
|
|
181
|
+
|
|
182
|
+
if (import.meta.vitest) {
|
|
183
|
+
const { describe, test, expect } = import.meta.vitest
|
|
184
|
+
|
|
185
|
+
describe('MMTP Parser', () => {
|
|
186
|
+
// Helper to create a minimal MMTP packet
|
|
187
|
+
function createTestPacket(opts: {
|
|
188
|
+
fecType?: number
|
|
189
|
+
packetId?: number
|
|
190
|
+
timestamp?: number
|
|
191
|
+
sequenceNumber?: number
|
|
192
|
+
payload?: Uint8Array
|
|
193
|
+
}): Uint8Array {
|
|
194
|
+
const fecType = opts.fecType ?? 0
|
|
195
|
+
const packetId = opts.packetId ?? 1
|
|
196
|
+
const timestamp = opts.timestamp ?? 12345
|
|
197
|
+
const sequenceNumber = opts.sequenceNumber ?? 1
|
|
198
|
+
const payload = opts.payload ?? new Uint8Array([0x00, 0x00, 0x00, 0x01])
|
|
199
|
+
|
|
200
|
+
const header = new Uint8Array(12)
|
|
201
|
+
const view = new DataView(header.buffer)
|
|
202
|
+
|
|
203
|
+
// Byte 0: V=1, C=0, FEC_type, r=0, X=0, R=0
|
|
204
|
+
header[0] = (1 << 6) | (fecType << 3)
|
|
205
|
+
// Byte 1: packet_type=0 (MPU)
|
|
206
|
+
header[1] = 0
|
|
207
|
+
view.setUint16(2, packetId, false)
|
|
208
|
+
view.setUint32(4, timestamp, false)
|
|
209
|
+
view.setUint32(8, sequenceNumber, false)
|
|
210
|
+
|
|
211
|
+
const result = new Uint8Array(header.length + payload.length)
|
|
212
|
+
result.set(header, 0)
|
|
213
|
+
result.set(payload, header.length)
|
|
214
|
+
return result
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
describe('parseMMTPHeader', () => {
|
|
218
|
+
test('should parse MMTP header correctly', () => {
|
|
219
|
+
const packet = createTestPacket({
|
|
220
|
+
packetId: 42,
|
|
221
|
+
timestamp: 1000000,
|
|
222
|
+
sequenceNumber: 100,
|
|
223
|
+
})
|
|
224
|
+
const header = parseMMTPHeader(packet)
|
|
225
|
+
expect(header.packetId).toBe(42)
|
|
226
|
+
expect(header.timestamp).toBe(1000000n)
|
|
227
|
+
expect(header.sequenceNumber).toBe(100)
|
|
228
|
+
expect(header.fecType).toBe(0)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('should extract FEC type', () => {
|
|
232
|
+
const packet = createTestPacket({ fecType: 2 })
|
|
233
|
+
const header = parseMMTPHeader(packet)
|
|
234
|
+
expect(header.fecType).toBe(2)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('should throw on too small packet', () => {
|
|
238
|
+
expect(() => parseMMTPHeader(new Uint8Array(10))).toThrow()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('isFecRepairPacket', () => {
|
|
243
|
+
test('should return false for source packets (FEC type 0)', () => {
|
|
244
|
+
const packet = createTestPacket({ fecType: 0 })
|
|
245
|
+
expect(isFecRepairPacket(packet)).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('should return false for FEC mode 0 source (FEC type 1)', () => {
|
|
249
|
+
const packet = createTestPacket({ fecType: 1 })
|
|
250
|
+
expect(isFecRepairPacket(packet)).toBe(false)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('should return true for repair packets (FEC type 2)', () => {
|
|
254
|
+
const packet = createTestPacket({ fecType: 2 })
|
|
255
|
+
expect(isFecRepairPacket(packet)).toBe(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('should return true for repair packets (FEC type 3)', () => {
|
|
259
|
+
const packet = createTestPacket({ fecType: 3 })
|
|
260
|
+
expect(isFecRepairPacket(packet)).toBe(true)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
describe('hasFecPayloadId', () => {
|
|
265
|
+
test('should return false for no FEC (type 0)', () => {
|
|
266
|
+
const packet = createTestPacket({ fecType: 0 })
|
|
267
|
+
expect(hasFecPayloadId(packet)).toBe(false)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('should return true for FEC type 1+', () => {
|
|
271
|
+
expect(hasFecPayloadId(createTestPacket({ fecType: 1 }))).toBe(true)
|
|
272
|
+
expect(hasFecPayloadId(createTestPacket({ fecType: 2 }))).toBe(true)
|
|
273
|
+
expect(hasFecPayloadId(createTestPacket({ fecType: 3 }))).toBe(true)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
}
|