@invintusmedia/tomp4 1.0.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/LICENSE +22 -0
- package/README.md +95 -0
- package/dist/tomp4.js +1617 -0
- package/package.json +43 -0
- package/src/fmp4-to-mp4.js +375 -0
- package/src/hls.js +280 -0
- package/src/index.js +311 -0
- package/src/ts-to-mp4.js +1154 -0
package/dist/tomp4.js
ADDED
|
@@ -0,0 +1,1617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toMp4.js v1.0.0
|
|
3
|
+
* Convert MPEG-TS and fMP4 to standard MP4
|
|
4
|
+
* https://github.com/TVWIT/toMp4.js
|
|
5
|
+
* MIT License
|
|
6
|
+
*/
|
|
7
|
+
(function(global, factory) {
|
|
8
|
+
if (typeof exports === 'object' && typeof module !== 'undefined') {
|
|
9
|
+
module.exports = factory();
|
|
10
|
+
} else if (typeof define === 'function' && define.amd) {
|
|
11
|
+
define(factory);
|
|
12
|
+
} else {
|
|
13
|
+
global = global || self;
|
|
14
|
+
global.toMp4 = factory();
|
|
15
|
+
}
|
|
16
|
+
})(this, function() {
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
// ============================================
|
|
20
|
+
// MPEG-TS to MP4 Converter
|
|
21
|
+
// ============================================
|
|
22
|
+
/**
|
|
23
|
+
* MPEG-TS to MP4 Converter
|
|
24
|
+
* Pure JavaScript - no dependencies
|
|
25
|
+
*
|
|
26
|
+
* SUPPORTED (remux only, no transcoding):
|
|
27
|
+
* ───────────────────────────────────────
|
|
28
|
+
* Video:
|
|
29
|
+
* ✅ H.264/AVC (0x1B)
|
|
30
|
+
* ✅ H.265/HEVC (0x24)
|
|
31
|
+
*
|
|
32
|
+
* Audio:
|
|
33
|
+
* ✅ AAC (0x0F)
|
|
34
|
+
* ✅ AAC-LATM (0x11)
|
|
35
|
+
*
|
|
36
|
+
* NOT SUPPORTED (requires transcoding):
|
|
37
|
+
* ─────────────────────────────────────
|
|
38
|
+
* ❌ MPEG-1 Video (0x01)
|
|
39
|
+
* ❌ MPEG-2 Video (0x02)
|
|
40
|
+
* ❌ MPEG-1 Audio (0x03)
|
|
41
|
+
* ❌ MPEG-2 Audio (0x04)
|
|
42
|
+
* ❌ AC-3/Dolby (0x81)
|
|
43
|
+
* ❌ E-AC-3 (0x87)
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
// Stream type info
|
|
47
|
+
const STREAM_TYPES = {
|
|
48
|
+
0x01: { name: 'MPEG-1 Video', supported: false },
|
|
49
|
+
0x02: { name: 'MPEG-2 Video', supported: false },
|
|
50
|
+
0x03: { name: 'MPEG-1 Audio (MP3)', supported: false },
|
|
51
|
+
0x04: { name: 'MPEG-2 Audio', supported: false },
|
|
52
|
+
0x0F: { name: 'AAC', supported: true },
|
|
53
|
+
0x11: { name: 'AAC-LATM', supported: true },
|
|
54
|
+
0x1B: { name: 'H.264/AVC', supported: true },
|
|
55
|
+
0x24: { name: 'H.265/HEVC', supported: true },
|
|
56
|
+
0x81: { name: 'AC-3 (Dolby)', supported: false },
|
|
57
|
+
0x87: { name: 'E-AC-3', supported: false }
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ============================================
|
|
61
|
+
// MP4 BOX HELPERS
|
|
62
|
+
// ============================================
|
|
63
|
+
function createBox(type, ...payloads) {
|
|
64
|
+
let size = 8;
|
|
65
|
+
for (const p of payloads) size += p.byteLength;
|
|
66
|
+
const result = new Uint8Array(size);
|
|
67
|
+
const view = new DataView(result.buffer);
|
|
68
|
+
view.setUint32(0, size);
|
|
69
|
+
result[4] = type.charCodeAt(0);
|
|
70
|
+
result[5] = type.charCodeAt(1);
|
|
71
|
+
result[6] = type.charCodeAt(2);
|
|
72
|
+
result[7] = type.charCodeAt(3);
|
|
73
|
+
let offset = 8;
|
|
74
|
+
for (const p of payloads) {
|
|
75
|
+
result.set(p, offset);
|
|
76
|
+
offset += p.byteLength;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createFullBox(type, version, flags, ...payloads) {
|
|
82
|
+
const header = new Uint8Array(4);
|
|
83
|
+
header[0] = version;
|
|
84
|
+
header[1] = (flags >> 16) & 0xFF;
|
|
85
|
+
header[2] = (flags >> 8) & 0xFF;
|
|
86
|
+
header[3] = flags & 0xFF;
|
|
87
|
+
return createBox(type, header, ...payloads);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================
|
|
91
|
+
// MPEG-TS PARSER
|
|
92
|
+
// ============================================
|
|
93
|
+
const TS_PACKET_SIZE = 188;
|
|
94
|
+
const TS_SYNC_BYTE = 0x47;
|
|
95
|
+
const PAT_PID = 0x0000;
|
|
96
|
+
|
|
97
|
+
class TSParser {
|
|
98
|
+
constructor() {
|
|
99
|
+
this.pmtPid = null;
|
|
100
|
+
this.videoPid = null;
|
|
101
|
+
this.audioPid = null;
|
|
102
|
+
this.videoStreamType = null;
|
|
103
|
+
this.audioStreamType = null;
|
|
104
|
+
this.videoPesBuffer = [];
|
|
105
|
+
this.audioPesBuffer = [];
|
|
106
|
+
this.videoAccessUnits = [];
|
|
107
|
+
this.audioAccessUnits = [];
|
|
108
|
+
this.videoPts = [];
|
|
109
|
+
this.videoDts = [];
|
|
110
|
+
this.audioPts = [];
|
|
111
|
+
this.lastAudioPts = null; // Track running audio timestamp
|
|
112
|
+
this.adtsPartial = null; // Partial ADTS frame from previous PES
|
|
113
|
+
this.audioSampleRate = null; // Detected from ADTS header
|
|
114
|
+
this.audioChannels = null;
|
|
115
|
+
this.debug = { packets: 0, patFound: false, pmtFound: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
parse(data) {
|
|
119
|
+
let offset = 0;
|
|
120
|
+
// Find first sync byte
|
|
121
|
+
while (offset < data.byteLength && data[offset] !== TS_SYNC_BYTE) offset++;
|
|
122
|
+
if (offset > 0) this.debug.skippedBytes = offset;
|
|
123
|
+
|
|
124
|
+
// Parse all packets
|
|
125
|
+
while (offset + TS_PACKET_SIZE <= data.byteLength) {
|
|
126
|
+
if (data[offset] !== TS_SYNC_BYTE) {
|
|
127
|
+
// Try to resync
|
|
128
|
+
const nextSync = data.indexOf(TS_SYNC_BYTE, offset + 1);
|
|
129
|
+
if (nextSync === -1) break;
|
|
130
|
+
offset = nextSync;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
this.parsePacket(data.subarray(offset, offset + TS_PACKET_SIZE));
|
|
134
|
+
this.debug.packets++;
|
|
135
|
+
offset += TS_PACKET_SIZE;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
parsePacket(packet) {
|
|
140
|
+
const pid = ((packet[1] & 0x1F) << 8) | packet[2];
|
|
141
|
+
const payloadStart = (packet[1] & 0x40) !== 0;
|
|
142
|
+
const adaptationField = (packet[3] & 0x30) >> 4;
|
|
143
|
+
let payloadOffset = 4;
|
|
144
|
+
if (adaptationField === 2 || adaptationField === 3) {
|
|
145
|
+
const adaptLen = packet[4];
|
|
146
|
+
payloadOffset = 5 + adaptLen;
|
|
147
|
+
if (payloadOffset >= TS_PACKET_SIZE) return; // Invalid adaptation field
|
|
148
|
+
}
|
|
149
|
+
if (adaptationField === 2) return; // No payload
|
|
150
|
+
if (payloadOffset >= packet.length) return;
|
|
151
|
+
|
|
152
|
+
const payload = packet.subarray(payloadOffset);
|
|
153
|
+
if (payload.length === 0) return;
|
|
154
|
+
|
|
155
|
+
if (pid === PAT_PID) this.parsePAT(payload);
|
|
156
|
+
else if (pid === this.pmtPid) this.parsePMT(payload);
|
|
157
|
+
else if (pid === this.videoPid) this.collectPES(payload, payloadStart, 'video');
|
|
158
|
+
else if (pid === this.audioPid) this.collectPES(payload, payloadStart, 'audio');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
parsePAT(payload) {
|
|
162
|
+
if (payload.length < 12) return;
|
|
163
|
+
let offset = payload[0] + 1; // pointer field
|
|
164
|
+
if (offset + 8 > payload.length) return;
|
|
165
|
+
|
|
166
|
+
// table_id + section_syntax + section_length + transport_stream_id + version + section_number + last_section_number
|
|
167
|
+
offset += 8;
|
|
168
|
+
|
|
169
|
+
while (offset + 4 <= payload.length - 4) { // -4 for CRC
|
|
170
|
+
const programNum = (payload[offset] << 8) | payload[offset + 1];
|
|
171
|
+
const pmtPid = ((payload[offset + 2] & 0x1F) << 8) | payload[offset + 3];
|
|
172
|
+
if (programNum !== 0 && pmtPid !== 0) {
|
|
173
|
+
this.pmtPid = pmtPid;
|
|
174
|
+
this.debug.patFound = true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
offset += 4;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
parsePMT(payload) {
|
|
182
|
+
if (payload.length < 16) return;
|
|
183
|
+
let offset = payload[0] + 1; // pointer field
|
|
184
|
+
if (offset + 12 > payload.length) return;
|
|
185
|
+
|
|
186
|
+
// table_id
|
|
187
|
+
offset++;
|
|
188
|
+
|
|
189
|
+
const sectionLength = ((payload[offset] & 0x0F) << 8) | payload[offset + 1];
|
|
190
|
+
offset += 2;
|
|
191
|
+
|
|
192
|
+
// program_number(2) + version(1) + section_number(1) + last_section(1)
|
|
193
|
+
offset += 5;
|
|
194
|
+
|
|
195
|
+
// PCR_PID (2)
|
|
196
|
+
offset += 2;
|
|
197
|
+
|
|
198
|
+
// program_info_length
|
|
199
|
+
if (offset + 2 > payload.length) return;
|
|
200
|
+
const programInfoLength = ((payload[offset] & 0x0F) << 8) | payload[offset + 1];
|
|
201
|
+
offset += 2 + programInfoLength;
|
|
202
|
+
|
|
203
|
+
// Calculate end of stream entries (before CRC)
|
|
204
|
+
const sectionEnd = Math.min(payload.length - 4, 1 + payload[0] + 3 + sectionLength - 4);
|
|
205
|
+
|
|
206
|
+
while (offset + 5 <= sectionEnd) {
|
|
207
|
+
const streamType = payload[offset];
|
|
208
|
+
const elementaryPid = ((payload[offset + 1] & 0x1F) << 8) | payload[offset + 2];
|
|
209
|
+
const esInfoLength = ((payload[offset + 3] & 0x0F) << 8) | payload[offset + 4];
|
|
210
|
+
|
|
211
|
+
// Track ANY video stream we find (we'll validate codec support later)
|
|
212
|
+
// Video types: 0x01=MPEG-1, 0x02=MPEG-2, 0x1B=H.264, 0x24=HEVC
|
|
213
|
+
if (!this.videoPid && (streamType === 0x01 || streamType === 0x02 || streamType === 0x1B || streamType === 0x24)) {
|
|
214
|
+
this.videoPid = elementaryPid;
|
|
215
|
+
this.videoStreamType = streamType;
|
|
216
|
+
this.debug.pmtFound = true;
|
|
217
|
+
}
|
|
218
|
+
// Track ANY audio stream we find (we'll validate codec support later)
|
|
219
|
+
// Audio types: 0x03=MPEG-1, 0x04=MPEG-2, 0x0F=AAC, 0x11=AAC-LATM, 0x81=AC3, 0x87=EAC3
|
|
220
|
+
else if (!this.audioPid && (streamType === 0x03 || streamType === 0x04 || streamType === 0x0F || streamType === 0x11 || streamType === 0x81 || streamType === 0x87)) {
|
|
221
|
+
this.audioPid = elementaryPid;
|
|
222
|
+
this.audioStreamType = streamType;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
offset += 5 + esInfoLength;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
collectPES(payload, isStart, type) {
|
|
230
|
+
const buffer = type === 'video' ? this.videoPesBuffer : this.audioPesBuffer;
|
|
231
|
+
if (isStart) {
|
|
232
|
+
if (type === 'audio') this.debug.audioPesStarts = (this.debug.audioPesStarts || 0) + 1;
|
|
233
|
+
if (buffer.length > 0) this.processPES(this.concatenateBuffers(buffer), type);
|
|
234
|
+
buffer.length = 0;
|
|
235
|
+
}
|
|
236
|
+
buffer.push(payload.slice());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
processPES(pesData, type) {
|
|
240
|
+
if (pesData.length < 9) return;
|
|
241
|
+
if (pesData[0] !== 0 || pesData[1] !== 0 || pesData[2] !== 1) return;
|
|
242
|
+
const flags = pesData[7];
|
|
243
|
+
const headerDataLength = pesData[8];
|
|
244
|
+
let pts = null, dts = null;
|
|
245
|
+
if (flags & 0x80) pts = this.parsePTS(pesData, 9);
|
|
246
|
+
if (flags & 0x40) dts = this.parsePTS(pesData, 14);
|
|
247
|
+
const payload = pesData.subarray(9 + headerDataLength);
|
|
248
|
+
if (type === 'video') this.processVideoPayload(payload, pts, dts);
|
|
249
|
+
else this.processAudioPayload(payload, pts);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
parsePTS(data, offset) {
|
|
253
|
+
return ((data[offset] & 0x0E) << 29) |
|
|
254
|
+
((data[offset + 1]) << 22) |
|
|
255
|
+
((data[offset + 2] & 0xFE) << 14) |
|
|
256
|
+
((data[offset + 3]) << 7) |
|
|
257
|
+
((data[offset + 4] & 0xFE) >> 1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
processVideoPayload(payload, pts, dts) {
|
|
261
|
+
const nalUnits = this.extractNALUnits(payload);
|
|
262
|
+
if (nalUnits.length > 0 && pts !== null) {
|
|
263
|
+
this.videoAccessUnits.push({ nalUnits, pts, dts: dts !== null ? dts : pts });
|
|
264
|
+
this.videoPts.push(pts);
|
|
265
|
+
this.videoDts.push(dts !== null ? dts : pts);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
extractNALUnits(data) {
|
|
270
|
+
const nalUnits = [];
|
|
271
|
+
let i = 0;
|
|
272
|
+
while (i < data.length - 3) {
|
|
273
|
+
if (data[i] === 0 && data[i + 1] === 0) {
|
|
274
|
+
let startCodeLen = 0;
|
|
275
|
+
if (data[i + 2] === 1) startCodeLen = 3;
|
|
276
|
+
else if (data[i + 2] === 0 && i + 3 < data.length && data[i + 3] === 1) startCodeLen = 4;
|
|
277
|
+
if (startCodeLen > 0) {
|
|
278
|
+
let end = i + startCodeLen;
|
|
279
|
+
while (end < data.length - 2) {
|
|
280
|
+
if (data[end] === 0 && data[end + 1] === 0 &&
|
|
281
|
+
(data[end + 2] === 1 || (data[end + 2] === 0 && end + 3 < data.length && data[end + 3] === 1))) break;
|
|
282
|
+
end++;
|
|
283
|
+
}
|
|
284
|
+
if (end >= data.length - 2) end = data.length;
|
|
285
|
+
const nalUnit = data.subarray(i + startCodeLen, end);
|
|
286
|
+
if (nalUnit.length > 0) nalUnits.push(nalUnit);
|
|
287
|
+
i = end;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
i++;
|
|
292
|
+
}
|
|
293
|
+
return nalUnits;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
processAudioPayload(payload, pts) {
|
|
297
|
+
const frames = this.extractADTSFrames(payload);
|
|
298
|
+
|
|
299
|
+
// Debug: track audio PES processing
|
|
300
|
+
this.debug.audioPesCount = (this.debug.audioPesCount || 0) + 1;
|
|
301
|
+
this.debug.audioFramesInPes = (this.debug.audioFramesInPes || 0) + frames.length;
|
|
302
|
+
|
|
303
|
+
// Use provided PTS or continue from last known PTS
|
|
304
|
+
if (pts !== null) {
|
|
305
|
+
this.lastAudioPts = pts;
|
|
306
|
+
} else if (this.lastAudioPts !== null) {
|
|
307
|
+
pts = this.lastAudioPts;
|
|
308
|
+
} else {
|
|
309
|
+
// No PTS available yet, skip these frames
|
|
310
|
+
this.debug.audioSkipped = (this.debug.audioSkipped || 0) + frames.length;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Calculate PTS increment based on detected sample rate (or default 48000)
|
|
315
|
+
const sampleRate = this.audioSampleRate || 48000;
|
|
316
|
+
const ptsIncrement = Math.round(1024 * 90000 / sampleRate);
|
|
317
|
+
|
|
318
|
+
for (const frame of frames) {
|
|
319
|
+
this.audioAccessUnits.push({ data: frame.data, pts });
|
|
320
|
+
this.audioPts.push(pts);
|
|
321
|
+
pts += ptsIncrement;
|
|
322
|
+
this.lastAudioPts = pts;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
extractADTSFrames(data) {
|
|
327
|
+
// ADTS sample rate table
|
|
328
|
+
const SAMPLE_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
|
|
329
|
+
|
|
330
|
+
const frames = [];
|
|
331
|
+
let i = 0;
|
|
332
|
+
|
|
333
|
+
// Check for leftover partial frame from previous PES
|
|
334
|
+
if (this.adtsPartial && this.adtsPartial.length > 0) {
|
|
335
|
+
const combined = new Uint8Array(this.adtsPartial.length + data.length);
|
|
336
|
+
combined.set(this.adtsPartial);
|
|
337
|
+
combined.set(data, this.adtsPartial.length);
|
|
338
|
+
data = combined;
|
|
339
|
+
this.adtsPartial = null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
while (i < data.length - 7) {
|
|
343
|
+
if (data[i] === 0xFF && (data[i + 1] & 0xF0) === 0xF0) {
|
|
344
|
+
const protectionAbsent = data[i + 1] & 0x01;
|
|
345
|
+
const frameLength = ((data[i + 3] & 0x03) << 11) | (data[i + 4] << 3) | ((data[i + 5] & 0xE0) >> 5);
|
|
346
|
+
|
|
347
|
+
// Extract sample rate and channel config from first valid frame
|
|
348
|
+
if (!this.audioSampleRate && frameLength > 0) {
|
|
349
|
+
const samplingFreqIndex = ((data[i + 2] & 0x3C) >> 2);
|
|
350
|
+
const channelConfig = ((data[i + 2] & 0x01) << 2) | ((data[i + 3] & 0xC0) >> 6);
|
|
351
|
+
if (samplingFreqIndex < SAMPLE_RATES.length) {
|
|
352
|
+
this.audioSampleRate = SAMPLE_RATES[samplingFreqIndex];
|
|
353
|
+
this.audioChannels = channelConfig;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (frameLength > 0) {
|
|
358
|
+
if (i + frameLength <= data.length) {
|
|
359
|
+
const headerSize = protectionAbsent ? 7 : 9;
|
|
360
|
+
frames.push({ header: data.subarray(i, i + headerSize), data: data.subarray(i + headerSize, i + frameLength) });
|
|
361
|
+
i += frameLength;
|
|
362
|
+
continue;
|
|
363
|
+
} else {
|
|
364
|
+
this.adtsPartial = data.slice(i);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
i++;
|
|
370
|
+
}
|
|
371
|
+
return frames;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
concatenateBuffers(buffers) {
|
|
375
|
+
const totalLength = buffers.reduce((sum, b) => sum + b.length, 0);
|
|
376
|
+
const result = new Uint8Array(totalLength);
|
|
377
|
+
let offset = 0;
|
|
378
|
+
for (const buf of buffers) { result.set(buf, offset); offset += buf.length; }
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
finalize() {
|
|
383
|
+
if (this.videoPesBuffer.length > 0) this.processPES(this.concatenateBuffers(this.videoPesBuffer), 'video');
|
|
384
|
+
if (this.audioPesBuffer.length > 0) this.processPES(this.concatenateBuffers(this.audioPesBuffer), 'audio');
|
|
385
|
+
|
|
386
|
+
// Normalize timestamps so both audio and video start at 0
|
|
387
|
+
// This fixes A/V sync issues when streams have different start times
|
|
388
|
+
this.normalizeTimestamps();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
normalizeTimestamps() {
|
|
392
|
+
// Find the minimum timestamp across all streams
|
|
393
|
+
let minPts = Infinity;
|
|
394
|
+
|
|
395
|
+
if (this.videoPts.length > 0) {
|
|
396
|
+
minPts = Math.min(minPts, Math.min(...this.videoPts));
|
|
397
|
+
}
|
|
398
|
+
if (this.audioPts.length > 0) {
|
|
399
|
+
minPts = Math.min(minPts, Math.min(...this.audioPts));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// If no valid timestamps, nothing to normalize
|
|
403
|
+
if (minPts === Infinity || minPts === 0) return;
|
|
404
|
+
|
|
405
|
+
// Subtract minimum from all timestamps
|
|
406
|
+
for (let i = 0; i < this.videoPts.length; i++) {
|
|
407
|
+
this.videoPts[i] -= minPts;
|
|
408
|
+
}
|
|
409
|
+
for (let i = 0; i < this.videoDts.length; i++) {
|
|
410
|
+
this.videoDts[i] -= minPts;
|
|
411
|
+
}
|
|
412
|
+
for (let i = 0; i < this.audioPts.length; i++) {
|
|
413
|
+
this.audioPts[i] -= minPts;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Also update the access units
|
|
417
|
+
for (const au of this.videoAccessUnits) {
|
|
418
|
+
au.pts -= minPts;
|
|
419
|
+
au.dts -= minPts;
|
|
420
|
+
}
|
|
421
|
+
for (const au of this.audioAccessUnits) {
|
|
422
|
+
au.pts -= minPts;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
this.debug.timestampOffset = minPts;
|
|
426
|
+
this.debug.timestampNormalized = true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ============================================
|
|
431
|
+
// MP4 BUILDER
|
|
432
|
+
// ============================================
|
|
433
|
+
// Parse H.264 SPS to extract video dimensions
|
|
434
|
+
function parseSPS(sps) {
|
|
435
|
+
// Default fallback
|
|
436
|
+
const result = { width: 1920, height: 1080 };
|
|
437
|
+
if (!sps || sps.length < 4) return result;
|
|
438
|
+
|
|
439
|
+
// Skip NAL header byte, start at profile_idc
|
|
440
|
+
let offset = 1;
|
|
441
|
+
const profile = sps[offset++];
|
|
442
|
+
offset++; // constraint flags
|
|
443
|
+
offset++; // level_idc
|
|
444
|
+
|
|
445
|
+
// Exponential-Golomb decoder
|
|
446
|
+
let bitPos = offset * 8;
|
|
447
|
+
const getBit = () => (sps[Math.floor(bitPos / 8)] >> (7 - (bitPos++ % 8))) & 1;
|
|
448
|
+
const readUE = () => {
|
|
449
|
+
let zeros = 0;
|
|
450
|
+
while (bitPos < sps.length * 8 && getBit() === 0) zeros++;
|
|
451
|
+
let val = (1 << zeros) - 1;
|
|
452
|
+
for (let i = 0; i < zeros; i++) val += getBit() << (zeros - 1 - i);
|
|
453
|
+
return val;
|
|
454
|
+
};
|
|
455
|
+
const readSE = () => {
|
|
456
|
+
const val = readUE();
|
|
457
|
+
return (val & 1) ? (val + 1) >> 1 : -(val >> 1);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
readUE(); // seq_parameter_set_id
|
|
462
|
+
|
|
463
|
+
// High profile needs chroma_format_idc parsing
|
|
464
|
+
if (profile === 100 || profile === 110 || profile === 122 || profile === 244 ||
|
|
465
|
+
profile === 44 || profile === 83 || profile === 86 || profile === 118 || profile === 128) {
|
|
466
|
+
const chromaFormat = readUE();
|
|
467
|
+
if (chromaFormat === 3) getBit(); // separate_colour_plane_flag
|
|
468
|
+
readUE(); // bit_depth_luma_minus8
|
|
469
|
+
readUE(); // bit_depth_chroma_minus8
|
|
470
|
+
getBit(); // qpprime_y_zero_transform_bypass_flag
|
|
471
|
+
if (getBit()) { // seq_scaling_matrix_present_flag
|
|
472
|
+
for (let i = 0; i < (chromaFormat !== 3 ? 8 : 12); i++) {
|
|
473
|
+
if (getBit()) { // scaling_list_present
|
|
474
|
+
const size = i < 6 ? 16 : 64;
|
|
475
|
+
for (let j = 0; j < size; j++) readSE();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
readUE(); // log2_max_frame_num_minus4
|
|
482
|
+
const pocType = readUE();
|
|
483
|
+
if (pocType === 0) {
|
|
484
|
+
readUE(); // log2_max_pic_order_cnt_lsb_minus4
|
|
485
|
+
} else if (pocType === 1) {
|
|
486
|
+
getBit(); // delta_pic_order_always_zero_flag
|
|
487
|
+
readSE(); // offset_for_non_ref_pic
|
|
488
|
+
readSE(); // offset_for_top_to_bottom_field
|
|
489
|
+
const numRefFrames = readUE();
|
|
490
|
+
for (let i = 0; i < numRefFrames; i++) readSE();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
readUE(); // max_num_ref_frames
|
|
494
|
+
getBit(); // gaps_in_frame_num_value_allowed_flag
|
|
495
|
+
|
|
496
|
+
const picWidthMbs = readUE() + 1;
|
|
497
|
+
const picHeightMapUnits = readUE() + 1;
|
|
498
|
+
const frameMbsOnly = getBit();
|
|
499
|
+
|
|
500
|
+
if (!frameMbsOnly) getBit(); // mb_adaptive_frame_field_flag
|
|
501
|
+
getBit(); // direct_8x8_inference_flag
|
|
502
|
+
|
|
503
|
+
let cropLeft = 0, cropRight = 0, cropTop = 0, cropBottom = 0;
|
|
504
|
+
if (getBit()) { // frame_cropping_flag
|
|
505
|
+
cropLeft = readUE();
|
|
506
|
+
cropRight = readUE();
|
|
507
|
+
cropTop = readUE();
|
|
508
|
+
cropBottom = readUE();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Calculate dimensions
|
|
512
|
+
const mbWidth = 16;
|
|
513
|
+
const mbHeight = frameMbsOnly ? 16 : 32;
|
|
514
|
+
result.width = picWidthMbs * mbWidth - (cropLeft + cropRight) * 2;
|
|
515
|
+
result.height = (2 - frameMbsOnly) * picHeightMapUnits * mbHeight / (frameMbsOnly ? 1 : 2) - (cropTop + cropBottom) * 2;
|
|
516
|
+
|
|
517
|
+
} catch (e) {
|
|
518
|
+
// Fall back to defaults on parse error
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
class MP4Builder {
|
|
525
|
+
constructor(parser) {
|
|
526
|
+
this.parser = parser;
|
|
527
|
+
this.videoTimescale = 90000;
|
|
528
|
+
// Use detected sample rate or default to 48000
|
|
529
|
+
this.audioTimescale = parser.audioSampleRate || 48000;
|
|
530
|
+
this.audioSampleDuration = 1024;
|
|
531
|
+
this.videoDimensions = null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
getVideoDimensions() {
|
|
535
|
+
if (this.videoDimensions) return this.videoDimensions;
|
|
536
|
+
|
|
537
|
+
// Find SPS NAL unit
|
|
538
|
+
for (const au of this.parser.videoAccessUnits) {
|
|
539
|
+
for (const nalUnit of au.nalUnits) {
|
|
540
|
+
const nalType = nalUnit[0] & 0x1F;
|
|
541
|
+
if (nalType === 7) {
|
|
542
|
+
this.videoDimensions = parseSPS(nalUnit);
|
|
543
|
+
return this.videoDimensions;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Fallback
|
|
549
|
+
this.videoDimensions = { width: 1920, height: 1080 };
|
|
550
|
+
return this.videoDimensions;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
build() {
|
|
554
|
+
const mdatContent = this.buildMdatContent();
|
|
555
|
+
const moov = this.buildMoov(mdatContent.byteLength);
|
|
556
|
+
const ftyp = this.buildFtyp();
|
|
557
|
+
const mdatOffset = ftyp.byteLength + moov.byteLength + 8;
|
|
558
|
+
this.updateChunkOffsets(moov, mdatOffset);
|
|
559
|
+
const mdat = createBox('mdat', mdatContent);
|
|
560
|
+
const result = new Uint8Array(ftyp.byteLength + moov.byteLength + mdat.byteLength);
|
|
561
|
+
result.set(ftyp, 0);
|
|
562
|
+
result.set(moov, ftyp.byteLength);
|
|
563
|
+
result.set(mdat, ftyp.byteLength + moov.byteLength);
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
buildFtyp() {
|
|
568
|
+
const data = new Uint8Array(16);
|
|
569
|
+
data[0] = 'i'.charCodeAt(0); data[1] = 's'.charCodeAt(0); data[2] = 'o'.charCodeAt(0); data[3] = 'm'.charCodeAt(0);
|
|
570
|
+
data[7] = 1;
|
|
571
|
+
data[8] = 'i'.charCodeAt(0); data[9] = 's'.charCodeAt(0); data[10] = 'o'.charCodeAt(0); data[11] = 'm'.charCodeAt(0);
|
|
572
|
+
data[12] = 'a'.charCodeAt(0); data[13] = 'v'.charCodeAt(0); data[14] = 'c'.charCodeAt(0); data[15] = '1'.charCodeAt(0);
|
|
573
|
+
return createBox('ftyp', data);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
buildMdatContent() {
|
|
577
|
+
const chunks = [];
|
|
578
|
+
this.videoSampleSizes = [];
|
|
579
|
+
this.videoSampleOffsets = [];
|
|
580
|
+
let currentOffset = 0;
|
|
581
|
+
for (const au of this.parser.videoAccessUnits) {
|
|
582
|
+
this.videoSampleOffsets.push(currentOffset);
|
|
583
|
+
let sampleSize = 0;
|
|
584
|
+
for (const nalUnit of au.nalUnits) {
|
|
585
|
+
const prefixed = new Uint8Array(4 + nalUnit.length);
|
|
586
|
+
new DataView(prefixed.buffer).setUint32(0, nalUnit.length);
|
|
587
|
+
prefixed.set(nalUnit, 4);
|
|
588
|
+
chunks.push(prefixed);
|
|
589
|
+
sampleSize += prefixed.length;
|
|
590
|
+
}
|
|
591
|
+
this.videoSampleSizes.push(sampleSize);
|
|
592
|
+
currentOffset += sampleSize;
|
|
593
|
+
}
|
|
594
|
+
this.videoChunkOffset = 0;
|
|
595
|
+
this.audioChunkOffset = currentOffset;
|
|
596
|
+
this.audioSampleSizes = [];
|
|
597
|
+
for (const frame of this.parser.audioAccessUnits) {
|
|
598
|
+
chunks.push(frame.data);
|
|
599
|
+
this.audioSampleSizes.push(frame.data.length);
|
|
600
|
+
currentOffset += frame.data.length;
|
|
601
|
+
}
|
|
602
|
+
const totalSize = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
603
|
+
const result = new Uint8Array(totalSize);
|
|
604
|
+
let offset = 0;
|
|
605
|
+
for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; }
|
|
606
|
+
return result;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
buildMoov(mdatSize) {
|
|
610
|
+
const mvhd = this.buildMvhd();
|
|
611
|
+
const videoTrak = this.buildVideoTrak();
|
|
612
|
+
const audioTrak = this.buildAudioTrak();
|
|
613
|
+
const udta = this.buildUdta();
|
|
614
|
+
return createBox('moov', mvhd, videoTrak, audioTrak, udta);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
buildUdta() {
|
|
618
|
+
const toolName = 'toMp4.js';
|
|
619
|
+
const toolBytes = new TextEncoder().encode(toolName);
|
|
620
|
+
const dataBox = new Uint8Array(16 + toolBytes.length);
|
|
621
|
+
const dataView = new DataView(dataBox.buffer);
|
|
622
|
+
dataView.setUint32(0, 16 + toolBytes.length);
|
|
623
|
+
dataBox[4] = 'd'.charCodeAt(0); dataBox[5] = 'a'.charCodeAt(0); dataBox[6] = 't'.charCodeAt(0); dataBox[7] = 'a'.charCodeAt(0);
|
|
624
|
+
dataView.setUint32(8, 1); dataView.setUint32(12, 0);
|
|
625
|
+
dataBox.set(toolBytes, 16);
|
|
626
|
+
const tooBox = createBox('©too', dataBox);
|
|
627
|
+
const ilst = createBox('ilst', tooBox);
|
|
628
|
+
const hdlrData = new Uint8Array(21);
|
|
629
|
+
hdlrData[4] = 'm'.charCodeAt(0); hdlrData[5] = 'd'.charCodeAt(0); hdlrData[6] = 'i'.charCodeAt(0); hdlrData[7] = 'r'.charCodeAt(0);
|
|
630
|
+
const metaHdlr = createFullBox('hdlr', 0, 0, hdlrData);
|
|
631
|
+
const meta = createFullBox('meta', 0, 0, new Uint8Array(0), metaHdlr, ilst);
|
|
632
|
+
return createBox('udta', meta);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
buildMvhd() {
|
|
636
|
+
const data = new Uint8Array(96);
|
|
637
|
+
const view = new DataView(data.buffer);
|
|
638
|
+
view.setUint32(8, this.videoTimescale);
|
|
639
|
+
view.setUint32(12, this.calculateVideoDuration());
|
|
640
|
+
view.setUint32(16, 0x00010000);
|
|
641
|
+
view.setUint16(20, 0x0100);
|
|
642
|
+
view.setUint32(32, 0x00010000);
|
|
643
|
+
view.setUint32(48, 0x00010000);
|
|
644
|
+
view.setUint32(64, 0x40000000);
|
|
645
|
+
view.setUint32(92, 258);
|
|
646
|
+
return createFullBox('mvhd', 0, 0, data);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
calculateVideoDuration() {
|
|
650
|
+
if (this.parser.videoDts.length < 2) return 0;
|
|
651
|
+
const firstDts = this.parser.videoDts[0];
|
|
652
|
+
const lastDts = this.parser.videoDts[this.parser.videoDts.length - 1];
|
|
653
|
+
const avgDuration = (lastDts - firstDts) / (this.parser.videoDts.length - 1);
|
|
654
|
+
return Math.round(lastDts - firstDts + avgDuration);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
buildVideoTrak() {
|
|
658
|
+
const edts = this.buildVideoEdts();
|
|
659
|
+
if (edts) {
|
|
660
|
+
return createBox('trak', this.buildVideoTkhd(), edts, this.buildVideoMdia());
|
|
661
|
+
}
|
|
662
|
+
return createBox('trak', this.buildVideoTkhd(), this.buildVideoMdia());
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Build edit list to fix A/V sync
|
|
666
|
+
// The elst box tells the player where media actually starts
|
|
667
|
+
buildVideoEdts() {
|
|
668
|
+
// Get first video PTS (presentation time)
|
|
669
|
+
if (this.parser.videoAccessUnits.length === 0) return null;
|
|
670
|
+
|
|
671
|
+
const firstAU = this.parser.videoAccessUnits[0];
|
|
672
|
+
const firstVideoPts = firstAU.pts;
|
|
673
|
+
|
|
674
|
+
// If video starts at 0, no edit needed
|
|
675
|
+
if (firstVideoPts === 0) return null;
|
|
676
|
+
|
|
677
|
+
// Create elst box: tells player to start at firstVideoPts in the media
|
|
678
|
+
// This compensates for CTTS offset making video appear to start late
|
|
679
|
+
const duration = this.calculateVideoDuration();
|
|
680
|
+
const mediaTime = firstVideoPts; // Start playback at this media time
|
|
681
|
+
|
|
682
|
+
// elst entry: segment_duration (4), media_time (4), media_rate (4)
|
|
683
|
+
const elstData = new Uint8Array(16);
|
|
684
|
+
const view = new DataView(elstData.buffer);
|
|
685
|
+
view.setUint32(0, 1); // entry count
|
|
686
|
+
view.setUint32(4, duration); // segment duration in movie timescale
|
|
687
|
+
view.setInt32(8, mediaTime); // media time - where to start
|
|
688
|
+
view.setUint16(12, 1); // media rate integer (1.0)
|
|
689
|
+
view.setUint16(14, 0); // media rate fraction
|
|
690
|
+
|
|
691
|
+
const elst = createFullBox('elst', 0, 0, elstData);
|
|
692
|
+
return createBox('edts', elst);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
buildVideoTkhd() {
|
|
696
|
+
const { width, height } = this.getVideoDimensions();
|
|
697
|
+
const data = new Uint8Array(80);
|
|
698
|
+
const view = new DataView(data.buffer);
|
|
699
|
+
view.setUint32(8, 256);
|
|
700
|
+
view.setUint32(16, this.calculateVideoDuration());
|
|
701
|
+
view.setUint16(32, 0);
|
|
702
|
+
view.setUint32(36, 0x00010000);
|
|
703
|
+
view.setUint32(52, 0x00010000);
|
|
704
|
+
view.setUint32(68, 0x40000000);
|
|
705
|
+
view.setUint32(72, width << 16);
|
|
706
|
+
view.setUint32(76, height << 16);
|
|
707
|
+
return createFullBox('tkhd', 0, 3, data);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
buildVideoMdia() {
|
|
711
|
+
return createBox('mdia', this.buildVideoMdhd(), this.buildVideoHdlr(), this.buildVideoMinf());
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
buildVideoMdhd() {
|
|
715
|
+
const data = new Uint8Array(20);
|
|
716
|
+
const view = new DataView(data.buffer);
|
|
717
|
+
view.setUint32(8, this.videoTimescale);
|
|
718
|
+
view.setUint32(12, this.calculateVideoDuration());
|
|
719
|
+
view.setUint16(16, 0x55C4);
|
|
720
|
+
return createFullBox('mdhd', 0, 0, data);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
buildVideoHdlr() {
|
|
724
|
+
const data = new Uint8Array(21);
|
|
725
|
+
data[4] = 'v'.charCodeAt(0); data[5] = 'i'.charCodeAt(0); data[6] = 'd'.charCodeAt(0); data[7] = 'e'.charCodeAt(0);
|
|
726
|
+
return createFullBox('hdlr', 0, 0, data);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
buildVideoMinf() {
|
|
730
|
+
return createBox('minf', this.buildVmhd(), this.buildDinf(), this.buildVideoStbl());
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
buildVmhd() { return createFullBox('vmhd', 0, 1, new Uint8Array(8)); }
|
|
734
|
+
|
|
735
|
+
buildDinf() {
|
|
736
|
+
const urlBox = createFullBox('url ', 0, 1, new Uint8Array(0));
|
|
737
|
+
const dref = createFullBox('dref', 0, 0, new Uint8Array([0, 0, 0, 1]), urlBox);
|
|
738
|
+
return createBox('dinf', dref);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
buildVideoStbl() {
|
|
742
|
+
const boxes = [this.buildVideoStsd(), this.buildVideoStts(), this.buildVideoCtts(), this.buildVideoStsc(), this.buildVideoStsz(), this.buildVideoStco()];
|
|
743
|
+
const stss = this.buildVideoStss();
|
|
744
|
+
if (stss) boxes.push(stss);
|
|
745
|
+
return createBox('stbl', ...boxes);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
buildVideoStsd() {
|
|
749
|
+
const { width, height } = this.getVideoDimensions();
|
|
750
|
+
const avcC = this.buildAvcC();
|
|
751
|
+
const btrtData = new Uint8Array(12);
|
|
752
|
+
const btrtView = new DataView(btrtData.buffer);
|
|
753
|
+
btrtView.setUint32(4, 2000000); btrtView.setUint32(8, 2000000);
|
|
754
|
+
const btrt = createBox('btrt', btrtData);
|
|
755
|
+
const paspData = new Uint8Array(8);
|
|
756
|
+
const paspView = new DataView(paspData.buffer);
|
|
757
|
+
paspView.setUint32(0, 1); paspView.setUint32(4, 1);
|
|
758
|
+
const pasp = createBox('pasp', paspData);
|
|
759
|
+
const avc1Data = new Uint8Array(78 + avcC.byteLength + btrt.byteLength + pasp.byteLength);
|
|
760
|
+
const view = new DataView(avc1Data.buffer);
|
|
761
|
+
view.setUint16(6, 1); view.setUint16(24, width); view.setUint16(26, height);
|
|
762
|
+
view.setUint32(28, 0x00480000); view.setUint32(32, 0x00480000);
|
|
763
|
+
view.setUint16(40, 1); view.setUint16(74, 0x0018); view.setInt16(76, -1);
|
|
764
|
+
avc1Data.set(avcC, 78); avc1Data.set(btrt, 78 + avcC.byteLength); avc1Data.set(pasp, 78 + avcC.byteLength + btrt.byteLength);
|
|
765
|
+
const avc1 = createBox('avc1', avc1Data);
|
|
766
|
+
const stsdHeader = new Uint8Array(4);
|
|
767
|
+
new DataView(stsdHeader.buffer).setUint32(0, 1);
|
|
768
|
+
return createFullBox('stsd', 0, 0, stsdHeader, avc1);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
buildAvcC() {
|
|
772
|
+
let sps = null, pps = null;
|
|
773
|
+
for (const au of this.parser.videoAccessUnits) {
|
|
774
|
+
for (const nalUnit of au.nalUnits) {
|
|
775
|
+
const nalType = nalUnit[0] & 0x1F;
|
|
776
|
+
if (nalType === 7 && !sps) sps = nalUnit;
|
|
777
|
+
if (nalType === 8 && !pps) pps = nalUnit;
|
|
778
|
+
if (sps && pps) break;
|
|
779
|
+
}
|
|
780
|
+
if (sps && pps) break;
|
|
781
|
+
}
|
|
782
|
+
if (!sps || !pps) {
|
|
783
|
+
sps = new Uint8Array([0x67, 0x64, 0x00, 0x1f, 0xac, 0xd9, 0x40, 0x78, 0x02, 0x27, 0xe5, 0xc0, 0x44, 0x00, 0x00, 0x03, 0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc6, 0x58]);
|
|
784
|
+
pps = new Uint8Array([0x68, 0xeb, 0xe3, 0xcb, 0x22, 0xc0]);
|
|
785
|
+
}
|
|
786
|
+
const data = new Uint8Array(11 + sps.length + pps.length);
|
|
787
|
+
const view = new DataView(data.buffer);
|
|
788
|
+
data[0] = 1; data[1] = sps[1]; data[2] = sps[2]; data[3] = sps[3]; data[4] = 0xFF; data[5] = 0xE1;
|
|
789
|
+
view.setUint16(6, sps.length); data.set(sps, 8);
|
|
790
|
+
data[8 + sps.length] = 1; view.setUint16(9 + sps.length, pps.length); data.set(pps, 11 + sps.length);
|
|
791
|
+
return createBox('avcC', data);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
buildVideoStts() {
|
|
795
|
+
const entries = [];
|
|
796
|
+
let lastDuration = -1, count = 0;
|
|
797
|
+
for (let i = 0; i < this.parser.videoDts.length; i++) {
|
|
798
|
+
const duration = i < this.parser.videoDts.length - 1
|
|
799
|
+
? this.parser.videoDts[i + 1] - this.parser.videoDts[i]
|
|
800
|
+
: (entries.length > 0 ? entries[entries.length - 1].duration : 3003);
|
|
801
|
+
if (duration === lastDuration) count++;
|
|
802
|
+
else { if (count > 0) entries.push({ count, duration: lastDuration }); lastDuration = duration; count = 1; }
|
|
803
|
+
}
|
|
804
|
+
if (count > 0) entries.push({ count, duration: lastDuration });
|
|
805
|
+
const data = new Uint8Array(4 + entries.length * 8);
|
|
806
|
+
const view = new DataView(data.buffer);
|
|
807
|
+
view.setUint32(0, entries.length);
|
|
808
|
+
for (let i = 0; i < entries.length; i++) { view.setUint32(4 + i * 8, entries[i].count); view.setUint32(8 + i * 8, entries[i].duration); }
|
|
809
|
+
return createFullBox('stts', 0, 0, data);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
buildVideoCtts() {
|
|
813
|
+
const entries = [];
|
|
814
|
+
for (const au of this.parser.videoAccessUnits) {
|
|
815
|
+
const cts = au.pts - au.dts;
|
|
816
|
+
if (entries.length > 0 && entries[entries.length - 1].offset === cts) entries[entries.length - 1].count++;
|
|
817
|
+
else entries.push({ count: 1, offset: cts });
|
|
818
|
+
}
|
|
819
|
+
const data = new Uint8Array(4 + entries.length * 8);
|
|
820
|
+
const view = new DataView(data.buffer);
|
|
821
|
+
view.setUint32(0, entries.length);
|
|
822
|
+
for (let i = 0; i < entries.length; i++) { view.setUint32(4 + i * 8, entries[i].count); view.setUint32(8 + i * 8, entries[i].offset); }
|
|
823
|
+
return createFullBox('ctts', 0, 0, data);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
buildVideoStsc() {
|
|
827
|
+
const data = new Uint8Array(4 + 12);
|
|
828
|
+
const view = new DataView(data.buffer);
|
|
829
|
+
view.setUint32(0, 1); view.setUint32(4, 1); view.setUint32(8, this.videoSampleSizes.length); view.setUint32(12, 1);
|
|
830
|
+
return createFullBox('stsc', 0, 0, data);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
buildVideoStsz() {
|
|
834
|
+
const data = new Uint8Array(8 + this.videoSampleSizes.length * 4);
|
|
835
|
+
const view = new DataView(data.buffer);
|
|
836
|
+
view.setUint32(0, 0); view.setUint32(4, this.videoSampleSizes.length);
|
|
837
|
+
for (let i = 0; i < this.videoSampleSizes.length; i++) view.setUint32(8 + i * 4, this.videoSampleSizes[i]);
|
|
838
|
+
return createFullBox('stsz', 0, 0, data);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
buildVideoStco() {
|
|
842
|
+
const data = new Uint8Array(8);
|
|
843
|
+
const view = new DataView(data.buffer);
|
|
844
|
+
view.setUint32(0, 1); view.setUint32(4, 0);
|
|
845
|
+
return createFullBox('stco', 0, 0, data);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
buildVideoStss() {
|
|
849
|
+
const keyframes = [];
|
|
850
|
+
for (let i = 0; i < this.parser.videoAccessUnits.length; i++) {
|
|
851
|
+
for (const nalUnit of this.parser.videoAccessUnits[i].nalUnits) {
|
|
852
|
+
if ((nalUnit[0] & 0x1F) === 5) { keyframes.push(i + 1); break; }
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (keyframes.length === 0) return null;
|
|
856
|
+
const data = new Uint8Array(4 + keyframes.length * 4);
|
|
857
|
+
const view = new DataView(data.buffer);
|
|
858
|
+
view.setUint32(0, keyframes.length);
|
|
859
|
+
for (let i = 0; i < keyframes.length; i++) view.setUint32(4 + i * 4, keyframes[i]);
|
|
860
|
+
return createFullBox('stss', 0, 0, data);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
buildAudioTrak() {
|
|
864
|
+
const edts = this.buildAudioEdts();
|
|
865
|
+
if (edts) {
|
|
866
|
+
return createBox('trak', this.buildAudioTkhd(), edts, this.buildAudioMdia());
|
|
867
|
+
}
|
|
868
|
+
return createBox('trak', this.buildAudioTkhd(), this.buildAudioMdia());
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Build edit list for audio to sync with video
|
|
872
|
+
buildAudioEdts() {
|
|
873
|
+
if (this.parser.audioPts.length === 0) return null;
|
|
874
|
+
|
|
875
|
+
const firstAudioPts = this.parser.audioPts[0];
|
|
876
|
+
|
|
877
|
+
// If audio starts at 0, no edit needed
|
|
878
|
+
if (firstAudioPts === 0) return null;
|
|
879
|
+
|
|
880
|
+
// Convert audio PTS (90kHz) to audio timescale (48kHz)
|
|
881
|
+
const mediaTime = Math.round(firstAudioPts * this.audioTimescale / 90000);
|
|
882
|
+
const duration = this.audioSampleSizes.length * this.audioSampleDuration;
|
|
883
|
+
|
|
884
|
+
const elstData = new Uint8Array(16);
|
|
885
|
+
const view = new DataView(elstData.buffer);
|
|
886
|
+
view.setUint32(0, 1); // entry count
|
|
887
|
+
view.setUint32(4, Math.round(duration * this.videoTimescale / this.audioTimescale)); // segment duration in movie timescale
|
|
888
|
+
view.setInt32(8, mediaTime); // media time
|
|
889
|
+
view.setUint16(12, 1); // media rate integer
|
|
890
|
+
view.setUint16(14, 0); // media rate fraction
|
|
891
|
+
|
|
892
|
+
const elst = createFullBox('elst', 0, 0, elstData);
|
|
893
|
+
return createBox('edts', elst);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
buildAudioTkhd() {
|
|
897
|
+
const data = new Uint8Array(80);
|
|
898
|
+
const view = new DataView(data.buffer);
|
|
899
|
+
view.setUint32(8, 257);
|
|
900
|
+
const audioDuration = this.audioSampleSizes.length * this.audioSampleDuration;
|
|
901
|
+
view.setUint32(16, Math.round(audioDuration * this.videoTimescale / this.audioTimescale));
|
|
902
|
+
view.setUint16(32, 0x0100);
|
|
903
|
+
view.setUint32(36, 0x00010000); view.setUint32(52, 0x00010000); view.setUint32(68, 0x40000000);
|
|
904
|
+
return createFullBox('tkhd', 0, 3, data);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
buildAudioMdia() { return createBox('mdia', this.buildAudioMdhd(), this.buildAudioHdlr(), this.buildAudioMinf()); }
|
|
908
|
+
|
|
909
|
+
buildAudioMdhd() {
|
|
910
|
+
const data = new Uint8Array(20);
|
|
911
|
+
const view = new DataView(data.buffer);
|
|
912
|
+
view.setUint32(8, this.audioTimescale);
|
|
913
|
+
view.setUint32(12, this.audioSampleSizes.length * this.audioSampleDuration);
|
|
914
|
+
view.setUint16(16, 0x55C4);
|
|
915
|
+
return createFullBox('mdhd', 0, 0, data);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
buildAudioHdlr() {
|
|
919
|
+
const data = new Uint8Array(21);
|
|
920
|
+
data[4] = 's'.charCodeAt(0); data[5] = 'o'.charCodeAt(0); data[6] = 'u'.charCodeAt(0); data[7] = 'n'.charCodeAt(0);
|
|
921
|
+
return createFullBox('hdlr', 0, 0, data);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
buildAudioMinf() { return createBox('minf', this.buildSmhd(), this.buildDinf(), this.buildAudioStbl()); }
|
|
925
|
+
buildSmhd() { return createFullBox('smhd', 0, 0, new Uint8Array(4)); }
|
|
926
|
+
|
|
927
|
+
buildAudioStbl() {
|
|
928
|
+
return createBox('stbl', this.buildAudioStsd(), this.buildAudioStts(), this.buildAudioStsc(), this.buildAudioStsz(), this.buildAudioStco());
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
buildAudioStsd() {
|
|
932
|
+
const esds = this.buildEsds();
|
|
933
|
+
const channels = this.parser.audioChannels || 2;
|
|
934
|
+
const mp4aData = new Uint8Array(28 + esds.byteLength);
|
|
935
|
+
const view = new DataView(mp4aData.buffer);
|
|
936
|
+
view.setUint16(6, 1);
|
|
937
|
+
view.setUint16(16, channels); // channel count
|
|
938
|
+
view.setUint16(18, 16); // sample size
|
|
939
|
+
view.setUint32(24, this.audioTimescale << 16);
|
|
940
|
+
mp4aData.set(esds, 28);
|
|
941
|
+
const mp4a = createBox('mp4a', mp4aData);
|
|
942
|
+
const stsdHeader = new Uint8Array(4);
|
|
943
|
+
new DataView(stsdHeader.buffer).setUint32(0, 1);
|
|
944
|
+
return createFullBox('stsd', 0, 0, stsdHeader, mp4a);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
buildEsds() {
|
|
948
|
+
// Build AudioSpecificConfig based on detected parameters
|
|
949
|
+
const SAMPLE_RATE_INDEX = {
|
|
950
|
+
96000: 0, 88200: 1, 64000: 2, 48000: 3, 44100: 4, 32000: 5,
|
|
951
|
+
24000: 6, 22050: 7, 16000: 8, 12000: 9, 11025: 10, 8000: 11, 7350: 12
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const sampleRate = this.audioTimescale;
|
|
955
|
+
const channels = this.parser.audioChannels || 2;
|
|
956
|
+
const samplingFreqIndex = SAMPLE_RATE_INDEX[sampleRate] ?? 4; // Default to 44100
|
|
957
|
+
|
|
958
|
+
// AudioSpecificConfig: 5 bits objType + 4 bits freqIndex + 4 bits channels + 3 bits padding
|
|
959
|
+
// AAC-LC = 2
|
|
960
|
+
const audioConfig = ((2 << 11) | (samplingFreqIndex << 7) | (channels << 3)) & 0xFFFF;
|
|
961
|
+
const audioConfigHigh = (audioConfig >> 8) & 0xFF;
|
|
962
|
+
const audioConfigLow = audioConfig & 0xFF;
|
|
963
|
+
|
|
964
|
+
const data = new Uint8Array([
|
|
965
|
+
0x00, 0x00, 0x00, 0x00, // version/flags
|
|
966
|
+
0x03, 0x19, // ES_Descriptor tag + length
|
|
967
|
+
0x00, 0x02, // ES_ID
|
|
968
|
+
0x00, // flags
|
|
969
|
+
0x04, 0x11, // DecoderConfigDescriptor tag + length
|
|
970
|
+
0x40, // objectTypeIndication (AAC)
|
|
971
|
+
0x15, // streamType (audio) + upstream + reserved
|
|
972
|
+
0x00, 0x00, 0x00, // bufferSizeDB
|
|
973
|
+
0x00, 0x01, 0xF4, 0x00, // maxBitrate
|
|
974
|
+
0x00, 0x01, 0xF4, 0x00, // avgBitrate
|
|
975
|
+
0x05, 0x02, // DecoderSpecificInfo tag + length
|
|
976
|
+
audioConfigHigh, audioConfigLow, // AudioSpecificConfig
|
|
977
|
+
0x06, 0x01, 0x02 // SLConfigDescriptor
|
|
978
|
+
]);
|
|
979
|
+
return createBox('esds', data);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
buildAudioStts() {
|
|
983
|
+
// Use actual PTS differences for accurate timing (like video does)
|
|
984
|
+
const audioPts = this.parser.audioPts;
|
|
985
|
+
|
|
986
|
+
// If we don't have PTS data, fall back to constant duration
|
|
987
|
+
if (audioPts.length < 2) {
|
|
988
|
+
const data = new Uint8Array(12);
|
|
989
|
+
const view = new DataView(data.buffer);
|
|
990
|
+
view.setUint32(0, 1);
|
|
991
|
+
view.setUint32(4, this.audioSampleSizes.length);
|
|
992
|
+
view.setUint32(8, this.audioSampleDuration);
|
|
993
|
+
return createFullBox('stts', 0, 0, data);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Convert 90kHz PTS to audio timescale (48kHz)
|
|
997
|
+
// PTS is in 90kHz, we need durations in 48kHz
|
|
998
|
+
const entries = [];
|
|
999
|
+
let lastDuration = -1, count = 0;
|
|
1000
|
+
|
|
1001
|
+
for (let i = 0; i < audioPts.length; i++) {
|
|
1002
|
+
let duration;
|
|
1003
|
+
if (i < audioPts.length - 1) {
|
|
1004
|
+
// Calculate actual duration from PTS difference
|
|
1005
|
+
const ptsDiff = audioPts[i + 1] - audioPts[i];
|
|
1006
|
+
// Convert from 90kHz to 48kHz: duration = ptsDiff * 48000 / 90000
|
|
1007
|
+
duration = Math.round(ptsDiff * this.audioTimescale / 90000);
|
|
1008
|
+
} else {
|
|
1009
|
+
// Last frame - use standard AAC frame duration
|
|
1010
|
+
duration = this.audioSampleDuration;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Clamp to reasonable values (handle discontinuities)
|
|
1014
|
+
if (duration <= 0 || duration > this.audioSampleDuration * 2) {
|
|
1015
|
+
duration = this.audioSampleDuration;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (duration === lastDuration) {
|
|
1019
|
+
count++;
|
|
1020
|
+
} else {
|
|
1021
|
+
if (count > 0) entries.push({ count, duration: lastDuration });
|
|
1022
|
+
lastDuration = duration;
|
|
1023
|
+
count = 1;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (count > 0) entries.push({ count, duration: lastDuration });
|
|
1027
|
+
|
|
1028
|
+
const data = new Uint8Array(4 + entries.length * 8);
|
|
1029
|
+
const view = new DataView(data.buffer);
|
|
1030
|
+
view.setUint32(0, entries.length);
|
|
1031
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1032
|
+
view.setUint32(4 + i * 8, entries[i].count);
|
|
1033
|
+
view.setUint32(8 + i * 8, entries[i].duration);
|
|
1034
|
+
}
|
|
1035
|
+
return createFullBox('stts', 0, 0, data);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
buildAudioStsc() {
|
|
1039
|
+
const data = new Uint8Array(4 + 12);
|
|
1040
|
+
const view = new DataView(data.buffer);
|
|
1041
|
+
view.setUint32(0, 1); view.setUint32(4, 1); view.setUint32(8, this.audioSampleSizes.length); view.setUint32(12, 1);
|
|
1042
|
+
return createFullBox('stsc', 0, 0, data);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
buildAudioStsz() {
|
|
1046
|
+
const data = new Uint8Array(8 + this.audioSampleSizes.length * 4);
|
|
1047
|
+
const view = new DataView(data.buffer);
|
|
1048
|
+
view.setUint32(0, 0); view.setUint32(4, this.audioSampleSizes.length);
|
|
1049
|
+
for (let i = 0; i < this.audioSampleSizes.length; i++) view.setUint32(8 + i * 4, this.audioSampleSizes[i]);
|
|
1050
|
+
return createFullBox('stsz', 0, 0, data);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
buildAudioStco() {
|
|
1054
|
+
const data = new Uint8Array(8);
|
|
1055
|
+
const view = new DataView(data.buffer);
|
|
1056
|
+
view.setUint32(0, 1); view.setUint32(4, 0);
|
|
1057
|
+
return createFullBox('stco', 0, 0, data);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
updateChunkOffsets(moov, mdatOffset) { this.updateStcoInBox(moov, mdatOffset, 0); }
|
|
1061
|
+
|
|
1062
|
+
updateStcoInBox(data, mdatOffset, trackIndex) {
|
|
1063
|
+
let offset = 8;
|
|
1064
|
+
while (offset < data.byteLength - 8) {
|
|
1065
|
+
const view = new DataView(data.buffer, data.byteOffset + offset);
|
|
1066
|
+
const size = view.getUint32(0);
|
|
1067
|
+
const type = String.fromCharCode(data[offset+4], data[offset+5], data[offset+6], data[offset+7]);
|
|
1068
|
+
if (size < 8 || offset + size > data.byteLength) break;
|
|
1069
|
+
if (type === 'stco') {
|
|
1070
|
+
view.setUint32(16, trackIndex === 0 ? mdatOffset + this.videoChunkOffset : mdatOffset + this.audioChunkOffset);
|
|
1071
|
+
trackIndex++;
|
|
1072
|
+
} else if (['moov', 'trak', 'mdia', 'minf', 'stbl'].includes(type)) {
|
|
1073
|
+
trackIndex = this.updateStcoInBox(data.subarray(offset, offset + size), mdatOffset, trackIndex);
|
|
1074
|
+
}
|
|
1075
|
+
offset += size;
|
|
1076
|
+
}
|
|
1077
|
+
return trackIndex;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Get codec info for a stream type
|
|
1083
|
+
*/
|
|
1084
|
+
function getCodecInfo(streamType) {
|
|
1085
|
+
return STREAM_TYPES[streamType] || { name: `Unknown (0x${streamType?.toString(16)})`, supported: false };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Convert MPEG-TS data to MP4
|
|
1090
|
+
*
|
|
1091
|
+
* @param {Uint8Array} tsData - MPEG-TS data
|
|
1092
|
+
* @param {object} options - Optional settings
|
|
1093
|
+
* @param {function} options.onProgress - Progress callback
|
|
1094
|
+
* @returns {Uint8Array} MP4 data
|
|
1095
|
+
* @throws {Error} If codecs are unsupported or no video found
|
|
1096
|
+
*/
|
|
1097
|
+
function convertTsToMp4(tsData, options = {}) {
|
|
1098
|
+
const log = options.onProgress || (() => {});
|
|
1099
|
+
|
|
1100
|
+
const parser = new TSParser();
|
|
1101
|
+
parser.parse(tsData);
|
|
1102
|
+
parser.finalize();
|
|
1103
|
+
|
|
1104
|
+
const debug = parser.debug;
|
|
1105
|
+
const videoInfo = getCodecInfo(parser.videoStreamType);
|
|
1106
|
+
const audioInfo = getCodecInfo(parser.audioStreamType);
|
|
1107
|
+
|
|
1108
|
+
// Log parsing results
|
|
1109
|
+
log(`Parsed ${debug.packets} TS packets`);
|
|
1110
|
+
log(`PAT: ${debug.patFound ? '✓' : '✗'}, PMT: ${debug.pmtFound ? '✓' : '✗'}`);
|
|
1111
|
+
log(`Video: ${parser.videoPid ? `PID ${parser.videoPid}` : 'none'} → ${videoInfo.name}`);
|
|
1112
|
+
const audioDetails = [];
|
|
1113
|
+
if (parser.audioSampleRate) audioDetails.push(`${parser.audioSampleRate}Hz`);
|
|
1114
|
+
if (parser.audioChannels) audioDetails.push(`${parser.audioChannels}ch`);
|
|
1115
|
+
log(`Audio: ${parser.audioPid ? `PID ${parser.audioPid}` : 'none'} → ${audioInfo.name}${audioDetails.length ? ` (${audioDetails.join(', ')})` : ''}`);
|
|
1116
|
+
|
|
1117
|
+
// Check for structural issues first
|
|
1118
|
+
if (!debug.patFound) {
|
|
1119
|
+
throw new Error('Invalid MPEG-TS: No PAT (Program Association Table) found. File may be corrupted or not MPEG-TS format.');
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (!debug.pmtFound) {
|
|
1123
|
+
throw new Error('Invalid MPEG-TS: No PMT (Program Map Table) found. File may be corrupted or missing stream info.');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Check for unsupported video codec BEFORE we report frame counts
|
|
1127
|
+
if (parser.videoStreamType && !videoInfo.supported) {
|
|
1128
|
+
throw new Error(
|
|
1129
|
+
`Unsupported video codec: ${videoInfo.name}\n` +
|
|
1130
|
+
`This library only supports H.264 and H.265 video.\n` +
|
|
1131
|
+
`Your file needs to be transcoded to H.264 first.`
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Check for unsupported audio codec
|
|
1136
|
+
if (parser.audioStreamType && !audioInfo.supported) {
|
|
1137
|
+
throw new Error(
|
|
1138
|
+
`Unsupported audio codec: ${audioInfo.name}\n` +
|
|
1139
|
+
`This library only supports AAC audio.\n` +
|
|
1140
|
+
`Your file needs to be transcoded to AAC first.`
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Check if we found any supported video
|
|
1145
|
+
if (!parser.videoPid) {
|
|
1146
|
+
throw new Error(
|
|
1147
|
+
'No supported video stream found in MPEG-TS.\n' +
|
|
1148
|
+
'This library supports: H.264/AVC, H.265/HEVC'
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
log(`Frames: ${parser.videoAccessUnits.length} video, ${parser.audioAccessUnits.length} audio`);
|
|
1153
|
+
if (debug.audioPesStarts) {
|
|
1154
|
+
log(`Audio: ${debug.audioPesStarts} PES starts → ${debug.audioPesCount || 0} processed → ${debug.audioFramesInPes || 0} ADTS frames${debug.audioSkipped ? ` (${debug.audioSkipped} skipped)` : ''}`);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (parser.videoAccessUnits.length === 0) {
|
|
1158
|
+
throw new Error('Video stream found but no frames could be extracted. File may be corrupted.');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Report timestamp normalization
|
|
1162
|
+
if (debug.timestampNormalized) {
|
|
1163
|
+
const offsetMs = (debug.timestampOffset / 90).toFixed(1);
|
|
1164
|
+
log(`Timestamps normalized: -${offsetMs}ms offset`);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const builder = new MP4Builder(parser);
|
|
1168
|
+
const { width, height } = builder.getVideoDimensions();
|
|
1169
|
+
log(`Dimensions: ${width}x${height}`);
|
|
1170
|
+
|
|
1171
|
+
return builder.build();
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
default convertTsToMp4;
|
|
1175
|
+
|
|
1176
|
+
// ============================================
|
|
1177
|
+
// fMP4 to MP4 Converter
|
|
1178
|
+
// ============================================
|
|
1179
|
+
/**
|
|
1180
|
+
* Fragmented MP4 to Standard MP4 Converter
|
|
1181
|
+
* Pure JavaScript - no dependencies
|
|
1182
|
+
*/
|
|
1183
|
+
|
|
1184
|
+
// ============================================
|
|
1185
|
+
// Box Utilities
|
|
1186
|
+
// ============================================
|
|
1187
|
+
function parseBoxes(data, offset = 0, end = data.byteLength) {
|
|
1188
|
+
const boxes = [];
|
|
1189
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1190
|
+
while (offset < end) {
|
|
1191
|
+
if (offset + 8 > end) break;
|
|
1192
|
+
const size = view.getUint32(offset);
|
|
1193
|
+
const type = String.fromCharCode(data[offset+4], data[offset+5], data[offset+6], data[offset+7]);
|
|
1194
|
+
if (size === 0 || size < 8) break;
|
|
1195
|
+
boxes.push({ type, offset, size, data: data.subarray(offset, offset + size) });
|
|
1196
|
+
offset += size;
|
|
1197
|
+
}
|
|
1198
|
+
return boxes;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function findBox(boxes, type) {
|
|
1202
|
+
for (const box of boxes) if (box.type === type) return box;
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function parseChildBoxes(box, headerSize = 8) {
|
|
1207
|
+
return parseBoxes(box.data, headerSize, box.size);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function createBox(type, ...payloads) {
|
|
1211
|
+
let size = 8;
|
|
1212
|
+
for (const p of payloads) size += p.byteLength;
|
|
1213
|
+
const result = new Uint8Array(size);
|
|
1214
|
+
const view = new DataView(result.buffer);
|
|
1215
|
+
view.setUint32(0, size);
|
|
1216
|
+
result[4] = type.charCodeAt(0); result[5] = type.charCodeAt(1); result[6] = type.charCodeAt(2); result[7] = type.charCodeAt(3);
|
|
1217
|
+
let offset = 8;
|
|
1218
|
+
for (const p of payloads) { result.set(p, offset); offset += p.byteLength; }
|
|
1219
|
+
return result;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ============================================
|
|
1223
|
+
// trun/tfhd Parsing
|
|
1224
|
+
// ============================================
|
|
1225
|
+
function parseTrunWithOffset(trunData) {
|
|
1226
|
+
const view = new DataView(trunData.buffer, trunData.byteOffset, trunData.byteLength);
|
|
1227
|
+
const version = trunData[8];
|
|
1228
|
+
const flags = (trunData[9] << 16) | (trunData[10] << 8) | trunData[11];
|
|
1229
|
+
const sampleCount = view.getUint32(12);
|
|
1230
|
+
let offset = 16, dataOffset = 0;
|
|
1231
|
+
if (flags & 0x1) { dataOffset = view.getInt32(offset); offset += 4; }
|
|
1232
|
+
if (flags & 0x4) offset += 4;
|
|
1233
|
+
const samples = [];
|
|
1234
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
1235
|
+
const sample = {};
|
|
1236
|
+
if (flags & 0x100) { sample.duration = view.getUint32(offset); offset += 4; }
|
|
1237
|
+
if (flags & 0x200) { sample.size = view.getUint32(offset); offset += 4; }
|
|
1238
|
+
if (flags & 0x400) { sample.flags = view.getUint32(offset); offset += 4; }
|
|
1239
|
+
if (flags & 0x800) { sample.compositionTimeOffset = version === 0 ? view.getUint32(offset) : view.getInt32(offset); offset += 4; }
|
|
1240
|
+
samples.push(sample);
|
|
1241
|
+
}
|
|
1242
|
+
return { samples, dataOffset };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function parseTfhd(tfhdData) {
|
|
1246
|
+
return new DataView(tfhdData.buffer, tfhdData.byteOffset, tfhdData.byteLength).getUint32(12);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// ============================================
|
|
1250
|
+
// Moov Rebuilding
|
|
1251
|
+
// ============================================
|
|
1252
|
+
function rebuildMvhd(mvhdBox, duration) {
|
|
1253
|
+
const data = new Uint8Array(mvhdBox.data);
|
|
1254
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1255
|
+
const version = data[8];
|
|
1256
|
+
const durationOffset = version === 0 ? 24 : 32;
|
|
1257
|
+
if (version === 0) view.setUint32(durationOffset, duration);
|
|
1258
|
+
else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, duration); }
|
|
1259
|
+
return data;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function rebuildTkhd(tkhdBox, trackInfo, maxDuration) {
|
|
1263
|
+
const data = new Uint8Array(tkhdBox.data);
|
|
1264
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1265
|
+
const version = data[8];
|
|
1266
|
+
let trackDuration = maxDuration;
|
|
1267
|
+
if (trackInfo) { trackDuration = 0; for (const s of trackInfo.samples) trackDuration += s.duration || 0; }
|
|
1268
|
+
if (version === 0) view.setUint32(28, trackDuration);
|
|
1269
|
+
else { view.setUint32(36, 0); view.setUint32(40, trackDuration); }
|
|
1270
|
+
return data;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function rebuildMdhd(mdhdBox, trackInfo, maxDuration) {
|
|
1274
|
+
const data = new Uint8Array(mdhdBox.data);
|
|
1275
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1276
|
+
const version = data[8];
|
|
1277
|
+
let trackDuration = 0;
|
|
1278
|
+
if (trackInfo) for (const s of trackInfo.samples) trackDuration += s.duration || 0;
|
|
1279
|
+
const durationOffset = version === 0 ? 24 : 32;
|
|
1280
|
+
if (version === 0) view.setUint32(durationOffset, trackDuration);
|
|
1281
|
+
else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, trackDuration); }
|
|
1282
|
+
return data;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function rebuildStbl(stblBox, trackInfo) {
|
|
1286
|
+
const stblChildren = parseChildBoxes(stblBox);
|
|
1287
|
+
const newParts = [];
|
|
1288
|
+
for (const child of stblChildren) if (child.type === 'stsd') { newParts.push(child.data); break; }
|
|
1289
|
+
const samples = trackInfo?.samples || [];
|
|
1290
|
+
const chunkOffsets = trackInfo?.chunkOffsets || [];
|
|
1291
|
+
|
|
1292
|
+
// stts
|
|
1293
|
+
const sttsEntries = [];
|
|
1294
|
+
let curDur = null, count = 0;
|
|
1295
|
+
for (const s of samples) {
|
|
1296
|
+
const d = s.duration || 0;
|
|
1297
|
+
if (d === curDur) count++;
|
|
1298
|
+
else { if (curDur !== null) sttsEntries.push({ count, duration: curDur }); curDur = d; count = 1; }
|
|
1299
|
+
}
|
|
1300
|
+
if (curDur !== null) sttsEntries.push({ count, duration: curDur });
|
|
1301
|
+
const sttsData = new Uint8Array(8 + sttsEntries.length * 8);
|
|
1302
|
+
const sttsView = new DataView(sttsData.buffer);
|
|
1303
|
+
sttsView.setUint32(4, sttsEntries.length);
|
|
1304
|
+
let off = 8;
|
|
1305
|
+
for (const e of sttsEntries) { sttsView.setUint32(off, e.count); sttsView.setUint32(off + 4, e.duration); off += 8; }
|
|
1306
|
+
newParts.push(createBox('stts', sttsData));
|
|
1307
|
+
|
|
1308
|
+
// stsc
|
|
1309
|
+
const stscEntries = [];
|
|
1310
|
+
if (chunkOffsets.length > 0) {
|
|
1311
|
+
let currentSampleCount = chunkOffsets[0].sampleCount, firstChunk = 1;
|
|
1312
|
+
for (let i = 1; i <= chunkOffsets.length; i++) {
|
|
1313
|
+
const sampleCount = i < chunkOffsets.length ? chunkOffsets[i].sampleCount : -1;
|
|
1314
|
+
if (sampleCount !== currentSampleCount) {
|
|
1315
|
+
stscEntries.push({ firstChunk, samplesPerChunk: currentSampleCount, sampleDescriptionIndex: 1 });
|
|
1316
|
+
firstChunk = i + 1; currentSampleCount = sampleCount;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
} else stscEntries.push({ firstChunk: 1, samplesPerChunk: samples.length, sampleDescriptionIndex: 1 });
|
|
1320
|
+
const stscData = new Uint8Array(8 + stscEntries.length * 12);
|
|
1321
|
+
const stscView = new DataView(stscData.buffer);
|
|
1322
|
+
stscView.setUint32(4, stscEntries.length);
|
|
1323
|
+
off = 8;
|
|
1324
|
+
for (const e of stscEntries) { stscView.setUint32(off, e.firstChunk); stscView.setUint32(off + 4, e.samplesPerChunk); stscView.setUint32(off + 8, e.sampleDescriptionIndex); off += 12; }
|
|
1325
|
+
newParts.push(createBox('stsc', stscData));
|
|
1326
|
+
|
|
1327
|
+
// stsz
|
|
1328
|
+
const stszData = new Uint8Array(12 + samples.length * 4);
|
|
1329
|
+
const stszView = new DataView(stszData.buffer);
|
|
1330
|
+
stszView.setUint32(8, samples.length);
|
|
1331
|
+
off = 12;
|
|
1332
|
+
for (const s of samples) { stszView.setUint32(off, s.size || 0); off += 4; }
|
|
1333
|
+
newParts.push(createBox('stsz', stszData));
|
|
1334
|
+
|
|
1335
|
+
// stco
|
|
1336
|
+
const numChunks = chunkOffsets.length || 1;
|
|
1337
|
+
const stcoData = new Uint8Array(8 + numChunks * 4);
|
|
1338
|
+
const stcoView = new DataView(stcoData.buffer);
|
|
1339
|
+
stcoView.setUint32(4, numChunks);
|
|
1340
|
+
for (let i = 0; i < numChunks; i++) stcoView.setUint32(8 + i * 4, chunkOffsets[i]?.offset || 0);
|
|
1341
|
+
newParts.push(createBox('stco', stcoData));
|
|
1342
|
+
|
|
1343
|
+
// ctts
|
|
1344
|
+
const hasCtts = samples.some(s => s.compositionTimeOffset);
|
|
1345
|
+
if (hasCtts) {
|
|
1346
|
+
const cttsEntries = [];
|
|
1347
|
+
let curOff = null; count = 0;
|
|
1348
|
+
for (const s of samples) {
|
|
1349
|
+
const o = s.compositionTimeOffset || 0;
|
|
1350
|
+
if (o === curOff) count++;
|
|
1351
|
+
else { if (curOff !== null) cttsEntries.push({ count, offset: curOff }); curOff = o; count = 1; }
|
|
1352
|
+
}
|
|
1353
|
+
if (curOff !== null) cttsEntries.push({ count, offset: curOff });
|
|
1354
|
+
const cttsData = new Uint8Array(8 + cttsEntries.length * 8);
|
|
1355
|
+
const cttsView = new DataView(cttsData.buffer);
|
|
1356
|
+
cttsView.setUint32(4, cttsEntries.length);
|
|
1357
|
+
off = 8;
|
|
1358
|
+
for (const e of cttsEntries) { cttsView.setUint32(off, e.count); cttsView.setInt32(off + 4, e.offset); off += 8; }
|
|
1359
|
+
newParts.push(createBox('ctts', cttsData));
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// stss
|
|
1363
|
+
const syncSamples = [];
|
|
1364
|
+
for (let i = 0; i < samples.length; i++) {
|
|
1365
|
+
const flags = samples[i].flags;
|
|
1366
|
+
if (flags !== undefined) { if (!((flags >> 16) & 0x1)) syncSamples.push(i + 1); }
|
|
1367
|
+
}
|
|
1368
|
+
if (syncSamples.length > 0 && syncSamples.length < samples.length) {
|
|
1369
|
+
const stssData = new Uint8Array(8 + syncSamples.length * 4);
|
|
1370
|
+
const stssView = new DataView(stssData.buffer);
|
|
1371
|
+
stssView.setUint32(4, syncSamples.length);
|
|
1372
|
+
off = 8;
|
|
1373
|
+
for (const n of syncSamples) { stssView.setUint32(off, n); off += 4; }
|
|
1374
|
+
newParts.push(createBox('stss', stssData));
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
return createBox('stbl', ...newParts);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function rebuildMinf(minfBox, trackInfo) {
|
|
1381
|
+
const minfChildren = parseChildBoxes(minfBox);
|
|
1382
|
+
const newParts = [];
|
|
1383
|
+
for (const child of minfChildren) {
|
|
1384
|
+
if (child.type === 'stbl') newParts.push(rebuildStbl(child, trackInfo));
|
|
1385
|
+
else newParts.push(child.data);
|
|
1386
|
+
}
|
|
1387
|
+
return createBox('minf', ...newParts);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function rebuildMdia(mdiaBox, trackInfo, maxDuration) {
|
|
1391
|
+
const mdiaChildren = parseChildBoxes(mdiaBox);
|
|
1392
|
+
const newParts = [];
|
|
1393
|
+
for (const child of mdiaChildren) {
|
|
1394
|
+
if (child.type === 'minf') newParts.push(rebuildMinf(child, trackInfo));
|
|
1395
|
+
else if (child.type === 'mdhd') newParts.push(rebuildMdhd(child, trackInfo, maxDuration));
|
|
1396
|
+
else newParts.push(child.data);
|
|
1397
|
+
}
|
|
1398
|
+
return createBox('mdia', ...newParts);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function rebuildTrak(trakBox, trackIdMap, maxDuration) {
|
|
1402
|
+
const trakChildren = parseChildBoxes(trakBox);
|
|
1403
|
+
let trackId = 1;
|
|
1404
|
+
for (const child of trakChildren) {
|
|
1405
|
+
if (child.type === 'tkhd') {
|
|
1406
|
+
const view = new DataView(child.data.buffer, child.data.byteOffset, child.data.byteLength);
|
|
1407
|
+
trackId = child.data[8] === 0 ? view.getUint32(20) : view.getUint32(28);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
const trackInfo = trackIdMap.get(trackId);
|
|
1411
|
+
const newParts = [];
|
|
1412
|
+
let hasEdts = false;
|
|
1413
|
+
for (const child of trakChildren) {
|
|
1414
|
+
if (child.type === 'edts') { hasEdts = true; newParts.push(child.data); }
|
|
1415
|
+
else if (child.type === 'mdia') newParts.push(rebuildMdia(child, trackInfo, maxDuration));
|
|
1416
|
+
else if (child.type === 'tkhd') newParts.push(rebuildTkhd(child, trackInfo, maxDuration));
|
|
1417
|
+
else newParts.push(child.data);
|
|
1418
|
+
}
|
|
1419
|
+
if (!hasEdts && trackInfo) {
|
|
1420
|
+
let trackDuration = 0;
|
|
1421
|
+
for (const s of trackInfo.samples) trackDuration += s.duration || 0;
|
|
1422
|
+
const elstData = new Uint8Array(20);
|
|
1423
|
+
const elstView = new DataView(elstData.buffer);
|
|
1424
|
+
elstView.setUint32(4, 1); elstView.setUint32(8, maxDuration); elstView.setInt32(12, 0); elstView.setInt16(16, 1);
|
|
1425
|
+
const elst = createBox('elst', elstData);
|
|
1426
|
+
const edts = createBox('edts', elst);
|
|
1427
|
+
const tkhdIndex = newParts.findIndex(p => p.length >= 8 && String.fromCharCode(p[4], p[5], p[6], p[7]) === 'tkhd');
|
|
1428
|
+
if (tkhdIndex >= 0) newParts.splice(tkhdIndex + 1, 0, edts);
|
|
1429
|
+
}
|
|
1430
|
+
return createBox('trak', ...newParts);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function updateStcoOffsets(output, ftypSize, moovSize) {
|
|
1434
|
+
const mdatContentOffset = ftypSize + moovSize + 8;
|
|
1435
|
+
const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
|
|
1436
|
+
function scan(start, end) {
|
|
1437
|
+
let pos = start;
|
|
1438
|
+
while (pos + 8 <= end) {
|
|
1439
|
+
const size = view.getUint32(pos);
|
|
1440
|
+
if (size < 8) break;
|
|
1441
|
+
const type = String.fromCharCode(output[pos+4], output[pos+5], output[pos+6], output[pos+7]);
|
|
1442
|
+
if (type === 'stco') {
|
|
1443
|
+
const entryCount = view.getUint32(pos + 12);
|
|
1444
|
+
for (let i = 0; i < entryCount; i++) {
|
|
1445
|
+
const entryPos = pos + 16 + i * 4;
|
|
1446
|
+
view.setUint32(entryPos, mdatContentOffset + view.getUint32(entryPos));
|
|
1447
|
+
}
|
|
1448
|
+
} else if (['moov', 'trak', 'mdia', 'minf', 'stbl'].includes(type)) scan(pos + 8, pos + size);
|
|
1449
|
+
pos += size;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
scan(0, output.byteLength);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Convert fragmented MP4 to standard MP4
|
|
1457
|
+
* @param {Uint8Array} fmp4Data - fMP4 data
|
|
1458
|
+
* @returns {Uint8Array} Standard MP4 data
|
|
1459
|
+
*/
|
|
1460
|
+
function convertFmp4ToMp4(fmp4Data) {
|
|
1461
|
+
const boxes = parseBoxes(fmp4Data);
|
|
1462
|
+
const ftyp = findBox(boxes, 'ftyp');
|
|
1463
|
+
const moov = findBox(boxes, 'moov');
|
|
1464
|
+
if (!ftyp || !moov) throw new Error('Invalid fMP4: missing ftyp or moov');
|
|
1465
|
+
|
|
1466
|
+
const moovChildren = parseChildBoxes(moov);
|
|
1467
|
+
const originalTrackIds = [];
|
|
1468
|
+
for (const child of moovChildren) {
|
|
1469
|
+
if (child.type === 'trak') {
|
|
1470
|
+
const trakChildren = parseChildBoxes(child);
|
|
1471
|
+
for (const tc of trakChildren) {
|
|
1472
|
+
if (tc.type === 'tkhd') {
|
|
1473
|
+
const view = new DataView(tc.data.buffer, tc.data.byteOffset, tc.data.byteLength);
|
|
1474
|
+
originalTrackIds.push(tc.data[8] === 0 ? view.getUint32(20) : view.getUint32(28));
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const tracks = new Map();
|
|
1481
|
+
const mdatChunks = [];
|
|
1482
|
+
let combinedMdatOffset = 0;
|
|
1483
|
+
|
|
1484
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
1485
|
+
const box = boxes[i];
|
|
1486
|
+
if (box.type === 'moof') {
|
|
1487
|
+
const moofChildren = parseChildBoxes(box);
|
|
1488
|
+
const moofStart = box.offset;
|
|
1489
|
+
let nextMdatOffset = 0;
|
|
1490
|
+
for (let j = i + 1; j < boxes.length; j++) {
|
|
1491
|
+
if (boxes[j].type === 'mdat') { nextMdatOffset = boxes[j].offset; break; }
|
|
1492
|
+
if (boxes[j].type === 'moof') break;
|
|
1493
|
+
}
|
|
1494
|
+
for (const child of moofChildren) {
|
|
1495
|
+
if (child.type === 'traf') {
|
|
1496
|
+
const trafChildren = parseChildBoxes(child);
|
|
1497
|
+
const tfhd = findBox(trafChildren, 'tfhd');
|
|
1498
|
+
const trun = findBox(trafChildren, 'trun');
|
|
1499
|
+
if (tfhd && trun) {
|
|
1500
|
+
const trackId = parseTfhd(tfhd.data);
|
|
1501
|
+
const { samples, dataOffset } = parseTrunWithOffset(trun.data);
|
|
1502
|
+
if (!tracks.has(trackId)) tracks.set(trackId, { samples: [], chunkOffsets: [] });
|
|
1503
|
+
const track = tracks.get(trackId);
|
|
1504
|
+
const chunkOffset = combinedMdatOffset + (moofStart + dataOffset) - (nextMdatOffset + 8);
|
|
1505
|
+
track.chunkOffsets.push({ offset: chunkOffset, sampleCount: samples.length });
|
|
1506
|
+
track.samples.push(...samples);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
} else if (box.type === 'mdat') {
|
|
1511
|
+
mdatChunks.push({ data: box.data.subarray(8), offset: combinedMdatOffset });
|
|
1512
|
+
combinedMdatOffset += box.data.subarray(8).byteLength;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const totalMdatSize = mdatChunks.reduce((sum, c) => sum + c.data.byteLength, 0);
|
|
1517
|
+
const combinedMdat = new Uint8Array(totalMdatSize);
|
|
1518
|
+
for (const chunk of mdatChunks) combinedMdat.set(chunk.data, chunk.offset);
|
|
1519
|
+
|
|
1520
|
+
const trackIdMap = new Map();
|
|
1521
|
+
const fmp4TrackIds = Array.from(tracks.keys()).sort((a, b) => a - b);
|
|
1522
|
+
for (let i = 0; i < fmp4TrackIds.length && i < originalTrackIds.length; i++) {
|
|
1523
|
+
trackIdMap.set(originalTrackIds[i], tracks.get(fmp4TrackIds[i]));
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
let maxDuration = 0;
|
|
1527
|
+
for (const [, track] of tracks) {
|
|
1528
|
+
let dur = 0;
|
|
1529
|
+
for (const s of track.samples) dur += s.duration || 0;
|
|
1530
|
+
maxDuration = Math.max(maxDuration, dur);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const newMoovParts = [];
|
|
1534
|
+
for (const child of moovChildren) {
|
|
1535
|
+
if (child.type === 'mvex') continue;
|
|
1536
|
+
if (child.type === 'trak') newMoovParts.push(rebuildTrak(child, trackIdMap, maxDuration));
|
|
1537
|
+
else if (child.type === 'mvhd') newMoovParts.push(rebuildMvhd(child, maxDuration));
|
|
1538
|
+
else newMoovParts.push(child.data);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const newMoov = createBox('moov', ...newMoovParts);
|
|
1542
|
+
const newMdat = createBox('mdat', combinedMdat);
|
|
1543
|
+
const output = new Uint8Array(ftyp.size + newMoov.byteLength + newMdat.byteLength);
|
|
1544
|
+
output.set(ftyp.data, 0);
|
|
1545
|
+
output.set(newMoov, ftyp.size);
|
|
1546
|
+
output.set(newMdat, ftyp.size + newMoov.byteLength);
|
|
1547
|
+
updateStcoOffsets(output, ftyp.size, newMoov.byteLength);
|
|
1548
|
+
|
|
1549
|
+
return output;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
default convertFmp4ToMp4;
|
|
1553
|
+
|
|
1554
|
+
// ============================================
|
|
1555
|
+
// Main API
|
|
1556
|
+
// ============================================
|
|
1557
|
+
function isMpegTs(data) {
|
|
1558
|
+
if (data.length < 4) return false;
|
|
1559
|
+
if (data[0] === 0x47) return true;
|
|
1560
|
+
for (var i = 0; i < Math.min(188, data.length); i++) {
|
|
1561
|
+
if (data[i] === 0x47 && i + 188 < data.length && data[i + 188] === 0x47) return true;
|
|
1562
|
+
}
|
|
1563
|
+
return false;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function isFmp4(data) {
|
|
1567
|
+
if (data.length < 8) return false;
|
|
1568
|
+
var type = String.fromCharCode(data[4], data[5], data[6], data[7]);
|
|
1569
|
+
return type === 'ftyp' || type === 'styp' || type === 'moof';
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function isStandardMp4(data) {
|
|
1573
|
+
if (data.length < 12) return false;
|
|
1574
|
+
var type = String.fromCharCode(data[4], data[5], data[6], data[7]);
|
|
1575
|
+
if (type !== 'ftyp') return false;
|
|
1576
|
+
var offset = 0;
|
|
1577
|
+
var view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
1578
|
+
var hasMoov = false, hasMoof = false;
|
|
1579
|
+
while (offset + 8 <= data.length) {
|
|
1580
|
+
var size = view.getUint32(offset);
|
|
1581
|
+
if (size < 8) break;
|
|
1582
|
+
var boxType = String.fromCharCode(data[offset+4], data[offset+5], data[offset+6], data[offset+7]);
|
|
1583
|
+
if (boxType === 'moov') hasMoov = true;
|
|
1584
|
+
if (boxType === 'moof') hasMoof = true;
|
|
1585
|
+
offset += size;
|
|
1586
|
+
}
|
|
1587
|
+
return hasMoov && !hasMoof;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function detectFormat(data) {
|
|
1591
|
+
if (isMpegTs(data)) return 'mpegts';
|
|
1592
|
+
if (isStandardMp4(data)) return 'mp4';
|
|
1593
|
+
if (isFmp4(data)) return 'fmp4';
|
|
1594
|
+
return 'unknown';
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function toMp4(data) {
|
|
1598
|
+
var uint8 = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
1599
|
+
var format = detectFormat(uint8);
|
|
1600
|
+
switch (format) {
|
|
1601
|
+
case 'mpegts': return convertTsToMp4(uint8);
|
|
1602
|
+
case 'fmp4': return convertFmp4ToMp4(uint8);
|
|
1603
|
+
case 'mp4': return uint8;
|
|
1604
|
+
default: throw new Error('Unrecognized video format. Expected MPEG-TS or fMP4.');
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
toMp4.fromTs = convertTsToMp4;
|
|
1609
|
+
toMp4.fromFmp4 = convertFmp4ToMp4;
|
|
1610
|
+
toMp4.detectFormat = detectFormat;
|
|
1611
|
+
toMp4.isMpegTs = isMpegTs;
|
|
1612
|
+
toMp4.isFmp4 = isFmp4;
|
|
1613
|
+
toMp4.isStandardMp4 = isStandardMp4;
|
|
1614
|
+
toMp4.version = '1.0.0';
|
|
1615
|
+
|
|
1616
|
+
return toMp4;
|
|
1617
|
+
});
|