@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.
- package/README.md +25 -1
- package/dist/tomp4.js +312 -363
- package/package.json +6 -3
- package/src/fmp4/converter.js +323 -0
- package/src/fmp4/index.js +25 -0
- package/src/fmp4/stitcher.js +615 -0
- package/src/fmp4/utils.js +201 -0
- package/src/index.js +41 -20
- package/src/mpegts/index.js +7 -0
- package/src/mpegts/stitcher.js +251 -0
- package/src/muxers/mp4.js +85 -85
- package/src/muxers/mpegts.js +101 -19
- package/src/parsers/mp4.js +691 -0
- package/src/parsers/mpegts.js +42 -42
- package/src/remote/index.js +444 -0
- package/src/transcode.js +20 -36
- package/src/ts-to-mp4.js +37 -37
- package/src/fmp4-to-mp4.js +0 -375
|
@@ -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;
|