@invintusmedia/tomp4 1.0.8 → 1.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,615 @@
1
+ /**
2
+ * Fragmented MP4 Segment Stitching
3
+ * Combine multiple fMP4 segments into a single standard MP4
4
+ * Pure JavaScript - no dependencies
5
+ */
6
+
7
+ // ============================================
8
+ // Box Utilities (shared with fmp4-to-mp4.js)
9
+ // ============================================
10
+
11
+ function parseBoxes(data, offset = 0, end = data.byteLength) {
12
+ const boxes = [];
13
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
14
+ while (offset < end) {
15
+ if (offset + 8 > end) break;
16
+ const size = view.getUint32(offset);
17
+ const type = String.fromCharCode(data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]);
18
+ if (size === 0 || size < 8) break;
19
+ boxes.push({ type, offset, size, data: data.subarray(offset, offset + size) });
20
+ offset += size;
21
+ }
22
+ return boxes;
23
+ }
24
+
25
+ function findBox(boxes, type) {
26
+ for (const box of boxes) if (box.type === type) return box;
27
+ return null;
28
+ }
29
+
30
+ function parseChildBoxes(box, headerSize = 8) {
31
+ return parseBoxes(box.data, headerSize, box.size);
32
+ }
33
+
34
+ function createBox(type, ...payloads) {
35
+ let size = 8;
36
+ for (const p of payloads) size += p.byteLength;
37
+ const result = new Uint8Array(size);
38
+ const view = new DataView(result.buffer);
39
+ view.setUint32(0, size);
40
+ result[4] = type.charCodeAt(0); result[5] = type.charCodeAt(1); result[6] = type.charCodeAt(2); result[7] = type.charCodeAt(3);
41
+ let offset = 8;
42
+ for (const p of payloads) { result.set(p, offset); offset += p.byteLength; }
43
+ return result;
44
+ }
45
+
46
+ // ============================================
47
+ // Fragment Parsing
48
+ // ============================================
49
+
50
+ function parseTfhd(tfhdData) {
51
+ const view = new DataView(tfhdData.buffer, tfhdData.byteOffset, tfhdData.byteLength);
52
+ const flags = (tfhdData[9] << 16) | (tfhdData[10] << 8) | tfhdData[11];
53
+ const trackId = view.getUint32(12);
54
+ let offset = 16;
55
+ let baseDataOffset = 0, defaultSampleDuration = 0, defaultSampleSize = 0, defaultSampleFlags = 0;
56
+
57
+ if (flags & 0x1) { baseDataOffset = Number(view.getBigUint64(offset)); offset += 8; }
58
+ if (flags & 0x2) offset += 4; // sample description index
59
+ if (flags & 0x8) { defaultSampleDuration = view.getUint32(offset); offset += 4; }
60
+ if (flags & 0x10) { defaultSampleSize = view.getUint32(offset); offset += 4; }
61
+ if (flags & 0x20) { defaultSampleFlags = view.getUint32(offset); offset += 4; }
62
+
63
+ return { trackId, flags, baseDataOffset, defaultSampleDuration, defaultSampleSize, defaultSampleFlags };
64
+ }
65
+
66
+ function parseTfdt(tfdtData) {
67
+ const view = new DataView(tfdtData.buffer, tfdtData.byteOffset, tfdtData.byteLength);
68
+ const version = tfdtData[8];
69
+ if (version === 1) {
70
+ return Number(view.getBigUint64(12));
71
+ }
72
+ return view.getUint32(12);
73
+ }
74
+
75
+ function parseTrun(trunData, defaults = {}) {
76
+ const view = new DataView(trunData.buffer, trunData.byteOffset, trunData.byteLength);
77
+ const version = trunData[8];
78
+ const flags = (trunData[9] << 16) | (trunData[10] << 8) | trunData[11];
79
+ const sampleCount = view.getUint32(12);
80
+ let offset = 16;
81
+ let dataOffset = 0;
82
+ let firstSampleFlags = null;
83
+
84
+ if (flags & 0x1) { dataOffset = view.getInt32(offset); offset += 4; }
85
+ if (flags & 0x4) { firstSampleFlags = view.getUint32(offset); offset += 4; }
86
+
87
+ const samples = [];
88
+ for (let i = 0; i < sampleCount; i++) {
89
+ const sample = {
90
+ duration: defaults.defaultSampleDuration || 0,
91
+ size: defaults.defaultSampleSize || 0,
92
+ flags: (i === 0 && firstSampleFlags !== null) ? firstSampleFlags : (defaults.defaultSampleFlags || 0),
93
+ compositionTimeOffset: 0
94
+ };
95
+ if (flags & 0x100) { sample.duration = view.getUint32(offset); offset += 4; }
96
+ if (flags & 0x200) { sample.size = view.getUint32(offset); offset += 4; }
97
+ if (flags & 0x400) { sample.flags = view.getUint32(offset); offset += 4; }
98
+ if (flags & 0x800) {
99
+ sample.compositionTimeOffset = version === 0 ? view.getUint32(offset) : view.getInt32(offset);
100
+ offset += 4;
101
+ }
102
+ samples.push(sample);
103
+ }
104
+
105
+ return { samples, dataOffset, flags };
106
+ }
107
+
108
+ // ============================================
109
+ // Moov Rebuilding
110
+ // ============================================
111
+
112
+ function rebuildMvhd(mvhdBox, duration) {
113
+ const data = new Uint8Array(mvhdBox.data);
114
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
115
+ const version = data[8];
116
+ const durationOffset = version === 0 ? 24 : 32;
117
+ if (version === 0) view.setUint32(durationOffset, duration);
118
+ else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, duration); }
119
+ return data;
120
+ }
121
+
122
+ function rebuildTkhd(tkhdBox, trackInfo, movieTimescale) {
123
+ const data = new Uint8Array(tkhdBox.data);
124
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
125
+ const version = data[8];
126
+
127
+ // Duration in tkhd must be in movie timescale (from mvhd)
128
+ let trackDuration = 0;
129
+ if (trackInfo && trackInfo.samples.length > 0) {
130
+ // Sum sample durations (in media timescale)
131
+ let mediaDuration = 0;
132
+ for (const s of trackInfo.samples) mediaDuration += s.duration || 0;
133
+ // Convert from media timescale to movie timescale
134
+ if (trackInfo.timescale && movieTimescale) {
135
+ trackDuration = Math.round(mediaDuration * movieTimescale / trackInfo.timescale);
136
+ } else {
137
+ trackDuration = mediaDuration; // Fallback
138
+ }
139
+ }
140
+
141
+ if (version === 0) view.setUint32(28, trackDuration);
142
+ else { view.setUint32(36, 0); view.setUint32(40, trackDuration); }
143
+ return data;
144
+ }
145
+
146
+ function rebuildMdhd(mdhdBox, trackInfo, maxDuration) {
147
+ const data = new Uint8Array(mdhdBox.data);
148
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
149
+ const version = data[8];
150
+ let trackDuration = 0;
151
+ if (trackInfo) for (const s of trackInfo.samples) trackDuration += s.duration || 0;
152
+ const durationOffset = version === 0 ? 24 : 32;
153
+ if (version === 0) view.setUint32(durationOffset, trackDuration);
154
+ else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, trackDuration); }
155
+ return data;
156
+ }
157
+
158
+ function rebuildStbl(stblBox, trackInfo) {
159
+ const stblChildren = parseChildBoxes(stblBox);
160
+ const newParts = [];
161
+ for (const child of stblChildren) if (child.type === 'stsd') { newParts.push(child.data); break; }
162
+ const samples = trackInfo?.samples || [];
163
+ const chunkOffsets = trackInfo?.chunkOffsets || [];
164
+
165
+ // stts
166
+ const sttsEntries = [];
167
+ let curDur = null, count = 0;
168
+ for (const s of samples) {
169
+ const d = s.duration || 0;
170
+ if (d === curDur) count++;
171
+ else { if (curDur !== null) sttsEntries.push({ count, duration: curDur }); curDur = d; count = 1; }
172
+ }
173
+ if (curDur !== null) sttsEntries.push({ count, duration: curDur });
174
+ const sttsData = new Uint8Array(8 + sttsEntries.length * 8);
175
+ const sttsView = new DataView(sttsData.buffer);
176
+ sttsView.setUint32(4, sttsEntries.length);
177
+ let off = 8;
178
+ for (const e of sttsEntries) { sttsView.setUint32(off, e.count); sttsView.setUint32(off + 4, e.duration); off += 8; }
179
+ newParts.push(createBox('stts', sttsData));
180
+
181
+ // stsc
182
+ const stscEntries = [];
183
+ if (chunkOffsets.length > 0) {
184
+ let currentSampleCount = chunkOffsets[0].sampleCount, firstChunk = 1;
185
+ for (let i = 1; i <= chunkOffsets.length; i++) {
186
+ const sampleCount = i < chunkOffsets.length ? chunkOffsets[i].sampleCount : -1;
187
+ if (sampleCount !== currentSampleCount) {
188
+ stscEntries.push({ firstChunk, samplesPerChunk: currentSampleCount, sampleDescriptionIndex: 1 });
189
+ firstChunk = i + 1; currentSampleCount = sampleCount;
190
+ }
191
+ }
192
+ } else stscEntries.push({ firstChunk: 1, samplesPerChunk: samples.length, sampleDescriptionIndex: 1 });
193
+ const stscData = new Uint8Array(8 + stscEntries.length * 12);
194
+ const stscView = new DataView(stscData.buffer);
195
+ stscView.setUint32(4, stscEntries.length);
196
+ off = 8;
197
+ for (const e of stscEntries) { stscView.setUint32(off, e.firstChunk); stscView.setUint32(off + 4, e.samplesPerChunk); stscView.setUint32(off + 8, e.sampleDescriptionIndex); off += 12; }
198
+ newParts.push(createBox('stsc', stscData));
199
+
200
+ // stsz
201
+ const stszData = new Uint8Array(12 + samples.length * 4);
202
+ const stszView = new DataView(stszData.buffer);
203
+ stszView.setUint32(8, samples.length);
204
+ off = 12;
205
+ for (const s of samples) { stszView.setUint32(off, s.size || 0); off += 4; }
206
+ newParts.push(createBox('stsz', stszData));
207
+
208
+ // stco
209
+ const numChunks = chunkOffsets.length || 1;
210
+ const stcoData = new Uint8Array(8 + numChunks * 4);
211
+ const stcoView = new DataView(stcoData.buffer);
212
+ stcoView.setUint32(4, numChunks);
213
+ for (let i = 0; i < numChunks; i++) stcoView.setUint32(8 + i * 4, chunkOffsets[i]?.offset || 0);
214
+ newParts.push(createBox('stco', stcoData));
215
+
216
+ // ctts
217
+ const hasCtts = samples.some(s => s.compositionTimeOffset);
218
+ if (hasCtts) {
219
+ const cttsEntries = [];
220
+ let curOff = null; count = 0;
221
+ for (const s of samples) {
222
+ const o = s.compositionTimeOffset || 0;
223
+ if (o === curOff) count++;
224
+ else { if (curOff !== null) cttsEntries.push({ count, offset: curOff }); curOff = o; count = 1; }
225
+ }
226
+ if (curOff !== null) cttsEntries.push({ count, offset: curOff });
227
+ const cttsData = new Uint8Array(8 + cttsEntries.length * 8);
228
+ const cttsView = new DataView(cttsData.buffer);
229
+ cttsView.setUint32(4, cttsEntries.length);
230
+ off = 8;
231
+ for (const e of cttsEntries) { cttsView.setUint32(off, e.count); cttsView.setInt32(off + 4, e.offset); off += 8; }
232
+ newParts.push(createBox('ctts', cttsData));
233
+ }
234
+
235
+ // stss
236
+ const syncSamples = [];
237
+ for (let i = 0; i < samples.length; i++) {
238
+ const flags = samples[i].flags;
239
+ if (flags !== undefined) { if (!((flags >> 16) & 0x1)) syncSamples.push(i + 1); }
240
+ }
241
+ if (syncSamples.length > 0 && syncSamples.length < samples.length) {
242
+ const stssData = new Uint8Array(8 + syncSamples.length * 4);
243
+ const stssView = new DataView(stssData.buffer);
244
+ stssView.setUint32(4, syncSamples.length);
245
+ off = 8;
246
+ for (const n of syncSamples) { stssView.setUint32(off, n); off += 4; }
247
+ newParts.push(createBox('stss', stssData));
248
+ }
249
+
250
+ return createBox('stbl', ...newParts);
251
+ }
252
+
253
+ function rebuildMinf(minfBox, trackInfo) {
254
+ const minfChildren = parseChildBoxes(minfBox);
255
+ const newParts = [];
256
+ for (const child of minfChildren) {
257
+ if (child.type === 'stbl') newParts.push(rebuildStbl(child, trackInfo));
258
+ else newParts.push(child.data);
259
+ }
260
+ return createBox('minf', ...newParts);
261
+ }
262
+
263
+ function rebuildMdia(mdiaBox, trackInfo, movieTimescale) {
264
+ const mdiaChildren = parseChildBoxes(mdiaBox);
265
+ const newParts = [];
266
+
267
+ // First pass: extract timescale from mdhd for this track
268
+ for (const child of mdiaChildren) {
269
+ if (child.type === 'mdhd') {
270
+ const view = new DataView(child.data.buffer, child.data.byteOffset, child.data.byteLength);
271
+ const version = child.data[8];
272
+ const timescale = version === 0 ? view.getUint32(20) : view.getUint32(28);
273
+ if (trackInfo) trackInfo.timescale = timescale;
274
+ }
275
+ }
276
+
277
+ for (const child of mdiaChildren) {
278
+ if (child.type === 'minf') newParts.push(rebuildMinf(child, trackInfo));
279
+ else if (child.type === 'mdhd') newParts.push(rebuildMdhd(child, trackInfo, movieTimescale));
280
+ else newParts.push(child.data);
281
+ }
282
+ return createBox('mdia', ...newParts);
283
+ }
284
+
285
+ function rebuildTrak(trakBox, trackIdMap, movieTimescale) {
286
+ const trakChildren = parseChildBoxes(trakBox);
287
+ let trackId = 1;
288
+ for (const child of trakChildren) {
289
+ if (child.type === 'tkhd') {
290
+ const view = new DataView(child.data.buffer, child.data.byteOffset, child.data.byteLength);
291
+ trackId = child.data[8] === 0 ? view.getUint32(20) : view.getUint32(28);
292
+ }
293
+ }
294
+ const trackInfo = trackIdMap.get(trackId);
295
+ const newParts = [];
296
+
297
+ // First rebuild mdia to get timescale
298
+ for (const child of trakChildren) {
299
+ if (child.type === 'mdia') {
300
+ newParts.push(rebuildMdia(child, trackInfo, movieTimescale));
301
+ }
302
+ }
303
+
304
+ // Then rebuild other boxes with proper timescale info
305
+ const tkhdIdx = newParts.length;
306
+ for (const child of trakChildren) {
307
+ if (child.type === 'edts') continue; // Skip - we rebuild edts with correct duration
308
+ else if (child.type === 'mdia') continue; // Already added
309
+ else if (child.type === 'tkhd') newParts.push(rebuildTkhd(child, trackInfo, movieTimescale));
310
+ else newParts.push(child.data);
311
+ }
312
+
313
+ // Reorder: tkhd should come first after rebuilding
314
+ // Find tkhd in newParts and move it to front
315
+ for (let i = tkhdIdx; i < newParts.length; i++) {
316
+ if (newParts[i].length >= 8) {
317
+ const type = String.fromCharCode(newParts[i][4], newParts[i][5], newParts[i][6], newParts[i][7]);
318
+ if (type === 'tkhd') {
319
+ const tkhd = newParts.splice(i, 1)[0];
320
+ newParts.unshift(tkhd);
321
+ break;
322
+ }
323
+ }
324
+ }
325
+
326
+ // Always create new edts with correct duration (don't use original which has duration=0)
327
+ // Remove any existing edts first
328
+ for (let i = newParts.length - 1; i >= 0; i--) {
329
+ if (newParts[i].length >= 8) {
330
+ const type = String.fromCharCode(newParts[i][4], newParts[i][5], newParts[i][6], newParts[i][7]);
331
+ if (type === 'edts') {
332
+ newParts.splice(i, 1);
333
+ }
334
+ }
335
+ }
336
+
337
+ // Create edts with proper duration
338
+ if (trackInfo && trackInfo.samples.length > 0) {
339
+ let mediaDuration = 0;
340
+ for (const s of trackInfo.samples) mediaDuration += s.duration || 0;
341
+ const movieDuration = trackInfo.timescale && movieTimescale
342
+ ? Math.round(mediaDuration * movieTimescale / trackInfo.timescale)
343
+ : mediaDuration;
344
+
345
+ const elstData = new Uint8Array(20);
346
+ const elstView = new DataView(elstData.buffer);
347
+ elstView.setUint32(4, 1); // entry count
348
+ elstView.setUint32(8, movieDuration); // segment duration
349
+ elstView.setInt32(12, 0); // media time (0 = start of track)
350
+ elstView.setInt16(16, 1); // media rate integer (1.0)
351
+ elstView.setInt16(18, 0); // media rate fraction
352
+ const elst = createBox('elst', elstData);
353
+ const edts = createBox('edts', elst);
354
+
355
+ // Insert after tkhd
356
+ for (let i = 0; i < newParts.length; i++) {
357
+ if (newParts[i].length >= 8) {
358
+ const type = String.fromCharCode(newParts[i][4], newParts[i][5], newParts[i][6], newParts[i][7]);
359
+ if (type === 'tkhd') {
360
+ newParts.splice(i + 1, 0, edts);
361
+ break;
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ return createBox('trak', ...newParts);
368
+ }
369
+
370
+ function updateStcoOffsets(output, ftypSize, moovSize) {
371
+ const mdatContentOffset = ftypSize + moovSize + 8;
372
+ const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
373
+ function scan(start, end) {
374
+ let pos = start;
375
+ while (pos + 8 <= end) {
376
+ const size = view.getUint32(pos);
377
+ if (size < 8) break;
378
+ const type = String.fromCharCode(output[pos + 4], output[pos + 5], output[pos + 6], output[pos + 7]);
379
+ if (type === 'stco') {
380
+ const entryCount = view.getUint32(pos + 12);
381
+ for (let i = 0; i < entryCount; i++) {
382
+ const entryPos = pos + 16 + i * 4;
383
+ view.setUint32(entryPos, mdatContentOffset + view.getUint32(entryPos));
384
+ }
385
+ } else if (['moov', 'trak', 'mdia', 'minf', 'stbl'].includes(type)) scan(pos + 8, pos + size);
386
+ pos += size;
387
+ }
388
+ }
389
+ scan(0, output.byteLength);
390
+ }
391
+
392
+ // ============================================
393
+ // Main Stitching Function
394
+ // ============================================
395
+
396
+ /**
397
+ * Stitch multiple fMP4 segments into a single standard MP4
398
+ *
399
+ * @param {(Uint8Array | ArrayBuffer)[]} segments - Array of fMP4 segment data
400
+ * Each segment can be self-contained (init+data) or just data (moof/mdat)
401
+ * @param {Object} [options] - Stitch options
402
+ * @param {Uint8Array | ArrayBuffer} [options.init] - Optional separate init segment data (ftyp/moov)
403
+ * @returns {Uint8Array} Standard MP4 data
404
+ *
405
+ * @example
406
+ * // Self-contained segments (each has init+data)
407
+ * const mp4 = stitchFmp4([segment1, segment2, segment3]);
408
+ *
409
+ * @example
410
+ * // Separate init + data segments
411
+ * const mp4 = stitchFmp4(dataSegments, { init: initSegment });
412
+ */
413
+ export function stitchFmp4(segments, options = {}) {
414
+ if (!segments || segments.length === 0) {
415
+ throw new Error('stitchFmp4: At least one segment is required');
416
+ }
417
+
418
+ // Convert all inputs to Uint8Array
419
+ const normalizedSegments = segments.map(seg =>
420
+ seg instanceof ArrayBuffer ? new Uint8Array(seg) : seg
421
+ );
422
+
423
+ let initData = options.init
424
+ ? (options.init instanceof ArrayBuffer ? new Uint8Array(options.init) : options.init)
425
+ : null;
426
+
427
+ // Track data accumulated from all segments
428
+ const tracks = new Map(); // trackId -> { samples: [], chunkOffsets: [] }
429
+ const mdatChunks = [];
430
+ let combinedMdatOffset = 0;
431
+
432
+ // Init segment info
433
+ let ftyp = null;
434
+ let moov = null;
435
+ let originalTrackIds = [];
436
+
437
+ // Process init segment if provided separately
438
+ if (initData) {
439
+ const initBoxes = parseBoxes(initData);
440
+ ftyp = findBox(initBoxes, 'ftyp');
441
+ moov = findBox(initBoxes, 'moov');
442
+ if (!ftyp || !moov) {
443
+ throw new Error('stitchFmp4: Init segment missing ftyp or moov');
444
+ }
445
+ originalTrackIds = extractTrackIds(moov);
446
+ }
447
+
448
+ // Process each segment
449
+ for (let segIdx = 0; segIdx < normalizedSegments.length; segIdx++) {
450
+ const segmentData = normalizedSegments[segIdx];
451
+ const boxes = parseBoxes(segmentData);
452
+
453
+ // Check if segment has init data
454
+ const segFtyp = findBox(boxes, 'ftyp');
455
+ const segMoov = findBox(boxes, 'moov');
456
+
457
+ // Use first segment's init if no separate init provided
458
+ if (!ftyp && segFtyp) {
459
+ ftyp = segFtyp;
460
+ }
461
+ if (!moov && segMoov) {
462
+ moov = segMoov;
463
+ originalTrackIds = extractTrackIds(moov);
464
+ }
465
+
466
+ // Process fragment boxes (moof + mdat pairs)
467
+ for (let i = 0; i < boxes.length; i++) {
468
+ const box = boxes[i];
469
+
470
+ if (box.type === 'moof') {
471
+ const moofChildren = parseChildBoxes(box);
472
+ const moofStart = box.offset;
473
+
474
+ // Find the next mdat
475
+ let nextMdat = null;
476
+ let nextMdatOffset = 0;
477
+ for (let j = i + 1; j < boxes.length; j++) {
478
+ if (boxes[j].type === 'mdat') {
479
+ nextMdat = boxes[j];
480
+ nextMdatOffset = boxes[j].offset;
481
+ break;
482
+ }
483
+ if (boxes[j].type === 'moof') break;
484
+ }
485
+
486
+ // Process each traf (track fragment)
487
+ for (const child of moofChildren) {
488
+ if (child.type === 'traf') {
489
+ const trafChildren = parseChildBoxes(child);
490
+ const tfhdBox = findBox(trafChildren, 'tfhd');
491
+ const trunBox = findBox(trafChildren, 'trun');
492
+ const tfdtBox = findBox(trafChildren, 'tfdt');
493
+
494
+ if (tfhdBox && trunBox) {
495
+ const tfhd = parseTfhd(tfhdBox.data);
496
+ const { samples, dataOffset } = parseTrun(trunBox.data, tfhd);
497
+
498
+ if (!tracks.has(tfhd.trackId)) {
499
+ tracks.set(tfhd.trackId, { samples: [], chunkOffsets: [] });
500
+ }
501
+ const track = tracks.get(tfhd.trackId);
502
+
503
+ // Calculate chunk offset within combined mdat
504
+ const chunkOffset = combinedMdatOffset + (moofStart + dataOffset) - (nextMdatOffset + 8);
505
+ track.chunkOffsets.push({ offset: chunkOffset, sampleCount: samples.length });
506
+ track.samples.push(...samples);
507
+ }
508
+ }
509
+ }
510
+ } else if (box.type === 'mdat') {
511
+ const mdatContent = box.data.subarray(8);
512
+ mdatChunks.push({ data: mdatContent, offset: combinedMdatOffset });
513
+ combinedMdatOffset += mdatContent.byteLength;
514
+ }
515
+ }
516
+ }
517
+
518
+ if (!ftyp || !moov) {
519
+ throw new Error('stitchFmp4: No init data found (missing ftyp or moov). Provide init segment or use self-contained segments.');
520
+ }
521
+
522
+ // Combine all mdat chunks
523
+ const totalMdatSize = mdatChunks.reduce((sum, c) => sum + c.data.byteLength, 0);
524
+ const combinedMdat = new Uint8Array(totalMdatSize);
525
+ for (const chunk of mdatChunks) {
526
+ combinedMdat.set(chunk.data, chunk.offset);
527
+ }
528
+
529
+ // Map track IDs
530
+ const trackIdMap = new Map();
531
+ const fmp4TrackIds = Array.from(tracks.keys()).sort((a, b) => a - b);
532
+ for (let i = 0; i < fmp4TrackIds.length && i < originalTrackIds.length; i++) {
533
+ trackIdMap.set(originalTrackIds[i], tracks.get(fmp4TrackIds[i]));
534
+ }
535
+
536
+ // Extract movie timescale from mvhd
537
+ const moovChildren = parseChildBoxes(moov);
538
+ let movieTimescale = 1000; // Default
539
+ for (const child of moovChildren) {
540
+ if (child.type === 'mvhd') {
541
+ const view = new DataView(child.data.buffer, child.data.byteOffset, child.data.byteLength);
542
+ const version = child.data[8];
543
+ movieTimescale = version === 0 ? view.getUint32(20) : view.getUint32(28);
544
+ }
545
+ }
546
+
547
+ // Rebuild moov - need to rebuild traks first to get timescales, then calculate duration
548
+ const newMoovParts = [];
549
+ const rebuiltTraks = [];
550
+ for (const child of moovChildren) {
551
+ if (child.type === 'mvex') continue; // Remove mvex (fragmented MP4 extension)
552
+ if (child.type === 'trak') {
553
+ rebuiltTraks.push(rebuildTrak(child, trackIdMap, movieTimescale));
554
+ }
555
+ }
556
+
557
+ // Calculate max duration in movie timescale (after traks are rebuilt with timescales)
558
+ let maxMovieDuration = 0;
559
+ for (const [, track] of tracks) {
560
+ if (track.samples.length > 0) {
561
+ let mediaDuration = 0;
562
+ for (const s of track.samples) mediaDuration += s.duration || 0;
563
+ const movieDuration = track.timescale
564
+ ? Math.round(mediaDuration * movieTimescale / track.timescale)
565
+ : mediaDuration;
566
+ maxMovieDuration = Math.max(maxMovieDuration, movieDuration);
567
+ }
568
+ }
569
+
570
+ // Build moov with correct duration
571
+ for (const child of moovChildren) {
572
+ if (child.type === 'mvex') continue;
573
+ if (child.type === 'trak') continue; // Added separately
574
+ if (child.type === 'mvhd') newMoovParts.push(rebuildMvhd(child, maxMovieDuration));
575
+ else newMoovParts.push(child.data);
576
+ }
577
+ // Add traks after mvhd
578
+ newMoovParts.push(...rebuiltTraks);
579
+
580
+ const newMoov = createBox('moov', ...newMoovParts);
581
+ const newMdat = createBox('mdat', combinedMdat);
582
+
583
+ // Assemble output
584
+ const output = new Uint8Array(ftyp.size + newMoov.byteLength + newMdat.byteLength);
585
+ output.set(ftyp.data, 0);
586
+ output.set(newMoov, ftyp.size);
587
+ output.set(newMdat, ftyp.size + newMoov.byteLength);
588
+
589
+ // Fix stco offsets
590
+ updateStcoOffsets(output, ftyp.size, newMoov.byteLength);
591
+
592
+ return output;
593
+ }
594
+
595
+ /**
596
+ * Extract track IDs from moov box
597
+ */
598
+ function extractTrackIds(moovBox) {
599
+ const trackIds = [];
600
+ const moovChildren = parseChildBoxes(moovBox);
601
+ for (const child of moovChildren) {
602
+ if (child.type === 'trak') {
603
+ const trakChildren = parseChildBoxes(child);
604
+ for (const tc of trakChildren) {
605
+ if (tc.type === 'tkhd') {
606
+ const view = new DataView(tc.data.buffer, tc.data.byteOffset, tc.data.byteLength);
607
+ trackIds.push(tc.data[8] === 0 ? view.getUint32(20) : view.getUint32(28));
608
+ }
609
+ }
610
+ }
611
+ }
612
+ return trackIds;
613
+ }
614
+
615
+ export default stitchFmp4;