@invintusmedia/tomp4 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tomp4.js +9 -5
- package/package.json +6 -3
- package/src/index.js +16 -2
- package/src/muxers/mp4.js +8 -4
- package/src/muxers/mpegts.js +47 -19
- package/src/parsers/mp4.js +691 -0
- package/src/remote/index.js +418 -0
- package/src/transcode.js +20 -36
- package/src/ts-to-mp4.js +7 -3
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MP4 Parser
|
|
3
|
+
*
|
|
4
|
+
* Parse MP4 files to extract tracks, samples, and metadata.
|
|
5
|
+
* Works with both local data (Uint8Array) and can be extended for remote sources.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // Parse local MP4 data
|
|
9
|
+
* const parser = new MP4Parser(uint8ArrayData);
|
|
10
|
+
* console.log(parser.duration, parser.videoTrack, parser.audioTrack);
|
|
11
|
+
*
|
|
12
|
+
* // Get sample table for a track
|
|
13
|
+
* const samples = parser.getVideoSamples();
|
|
14
|
+
*
|
|
15
|
+
* // Build HLS segments
|
|
16
|
+
* const segments = parser.buildSegments(4); // 4 second segments
|
|
17
|
+
*
|
|
18
|
+
* @module parsers/mp4
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Binary Reading Utilities
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export function readUint32(data, offset) {
|
|
26
|
+
return (data[offset] << 24) | (data[offset + 1] << 16) |
|
|
27
|
+
(data[offset + 2] << 8) | data[offset + 3];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readUint64(data, offset) {
|
|
31
|
+
// For simplicity, only handle lower 32 bits (files < 4GB)
|
|
32
|
+
return readUint32(data, offset + 4);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readInt32(data, offset) {
|
|
36
|
+
const val = readUint32(data, offset);
|
|
37
|
+
return (val & 0x80000000) ? val - 0x100000000 : val;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function boxType(data, offset) {
|
|
41
|
+
return String.fromCharCode(
|
|
42
|
+
data[offset], data[offset + 1],
|
|
43
|
+
data[offset + 2], data[offset + 3]
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Box Finding
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Find a box by type within a data range
|
|
53
|
+
* @param {Uint8Array} data - MP4 data
|
|
54
|
+
* @param {string} type - 4-character box type (e.g., 'moov', 'trak')
|
|
55
|
+
* @param {number} start - Start offset
|
|
56
|
+
* @param {number} end - End offset
|
|
57
|
+
* @returns {object|null} Box info {offset, size, headerSize} or null
|
|
58
|
+
*/
|
|
59
|
+
export function findBox(data, type, start = 0, end = data.length) {
|
|
60
|
+
let offset = start;
|
|
61
|
+
while (offset < end - 8) {
|
|
62
|
+
const size = readUint32(data, offset);
|
|
63
|
+
const btype = boxType(data, offset + 4);
|
|
64
|
+
|
|
65
|
+
if (size === 0 || size > end - offset) break;
|
|
66
|
+
|
|
67
|
+
const headerSize = size === 1 ? 16 : 8;
|
|
68
|
+
const actualSize = size === 1 ? readUint64(data, offset + 8) : size;
|
|
69
|
+
|
|
70
|
+
if (btype === type) {
|
|
71
|
+
return { offset, size: actualSize, headerSize };
|
|
72
|
+
}
|
|
73
|
+
offset += actualSize;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find all boxes of a type within a data range
|
|
80
|
+
*/
|
|
81
|
+
export function findAllBoxes(data, type, start = 0, end = data.length) {
|
|
82
|
+
const boxes = [];
|
|
83
|
+
let offset = start;
|
|
84
|
+
while (offset < end - 8) {
|
|
85
|
+
const size = readUint32(data, offset);
|
|
86
|
+
const btype = boxType(data, offset + 4);
|
|
87
|
+
|
|
88
|
+
if (size === 0 || size > end - offset) break;
|
|
89
|
+
|
|
90
|
+
const headerSize = size === 1 ? 16 : 8;
|
|
91
|
+
const actualSize = size === 1 ? readUint64(data, offset + 8) : size;
|
|
92
|
+
|
|
93
|
+
if (btype === type) {
|
|
94
|
+
boxes.push({ offset, size: actualSize, headerSize });
|
|
95
|
+
}
|
|
96
|
+
offset += actualSize;
|
|
97
|
+
}
|
|
98
|
+
return boxes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Sample Table Box Parsing
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse stts (time-to-sample) box
|
|
107
|
+
*/
|
|
108
|
+
export function parseStts(data, offset) {
|
|
109
|
+
const entryCount = readUint32(data, offset + 12);
|
|
110
|
+
const entries = [];
|
|
111
|
+
let pos = offset + 16;
|
|
112
|
+
for (let i = 0; i < entryCount; i++) {
|
|
113
|
+
entries.push({
|
|
114
|
+
sampleCount: readUint32(data, pos),
|
|
115
|
+
sampleDelta: readUint32(data, pos + 4)
|
|
116
|
+
});
|
|
117
|
+
pos += 8;
|
|
118
|
+
}
|
|
119
|
+
return entries;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse stss (sync sample / keyframe) box
|
|
124
|
+
*/
|
|
125
|
+
export function parseStss(data, offset) {
|
|
126
|
+
const entryCount = readUint32(data, offset + 12);
|
|
127
|
+
const keyframes = [];
|
|
128
|
+
let pos = offset + 16;
|
|
129
|
+
for (let i = 0; i < entryCount; i++) {
|
|
130
|
+
keyframes.push(readUint32(data, pos));
|
|
131
|
+
pos += 4;
|
|
132
|
+
}
|
|
133
|
+
return keyframes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse stsz (sample size) box
|
|
138
|
+
*/
|
|
139
|
+
export function parseStsz(data, offset) {
|
|
140
|
+
const sampleSize = readUint32(data, offset + 12);
|
|
141
|
+
const sampleCount = readUint32(data, offset + 16);
|
|
142
|
+
|
|
143
|
+
if (sampleSize !== 0) {
|
|
144
|
+
return Array(sampleCount).fill(sampleSize);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sizes = [];
|
|
148
|
+
let pos = offset + 20;
|
|
149
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
150
|
+
sizes.push(readUint32(data, pos));
|
|
151
|
+
pos += 4;
|
|
152
|
+
}
|
|
153
|
+
return sizes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse stco (chunk offset 32-bit) box
|
|
158
|
+
*/
|
|
159
|
+
export function parseStco(data, offset) {
|
|
160
|
+
const entryCount = readUint32(data, offset + 12);
|
|
161
|
+
const offsets = [];
|
|
162
|
+
let pos = offset + 16;
|
|
163
|
+
for (let i = 0; i < entryCount; i++) {
|
|
164
|
+
offsets.push(readUint32(data, pos));
|
|
165
|
+
pos += 4;
|
|
166
|
+
}
|
|
167
|
+
return offsets;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse co64 (chunk offset 64-bit) box
|
|
172
|
+
*/
|
|
173
|
+
export function parseCo64(data, offset) {
|
|
174
|
+
const entryCount = readUint32(data, offset + 12);
|
|
175
|
+
const offsets = [];
|
|
176
|
+
let pos = offset + 16;
|
|
177
|
+
for (let i = 0; i < entryCount; i++) {
|
|
178
|
+
offsets.push(readUint64(data, pos));
|
|
179
|
+
pos += 8;
|
|
180
|
+
}
|
|
181
|
+
return offsets;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse stsc (sample-to-chunk) box
|
|
186
|
+
*/
|
|
187
|
+
export function parseStsc(data, offset) {
|
|
188
|
+
const entryCount = readUint32(data, offset + 12);
|
|
189
|
+
const entries = [];
|
|
190
|
+
let pos = offset + 16;
|
|
191
|
+
for (let i = 0; i < entryCount; i++) {
|
|
192
|
+
entries.push({
|
|
193
|
+
firstChunk: readUint32(data, pos),
|
|
194
|
+
samplesPerChunk: readUint32(data, pos + 4),
|
|
195
|
+
sampleDescriptionIndex: readUint32(data, pos + 8)
|
|
196
|
+
});
|
|
197
|
+
pos += 12;
|
|
198
|
+
}
|
|
199
|
+
return entries;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Parse ctts (composition time offset) box for B-frames
|
|
204
|
+
*/
|
|
205
|
+
export function parseCtts(data, offset) {
|
|
206
|
+
const version = data[offset + 8];
|
|
207
|
+
const entryCount = readUint32(data, offset + 12);
|
|
208
|
+
const entries = [];
|
|
209
|
+
let pos = offset + 16;
|
|
210
|
+
for (let i = 0; i < entryCount; i++) {
|
|
211
|
+
const sampleCount = readUint32(data, pos);
|
|
212
|
+
let sampleOffset;
|
|
213
|
+
if (version === 0) {
|
|
214
|
+
sampleOffset = readUint32(data, pos + 4);
|
|
215
|
+
} else {
|
|
216
|
+
// Version 1: signed offset
|
|
217
|
+
sampleOffset = readInt32(data, pos + 4);
|
|
218
|
+
}
|
|
219
|
+
entries.push({ sampleCount, sampleOffset });
|
|
220
|
+
pos += 8;
|
|
221
|
+
}
|
|
222
|
+
return entries;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Parse mdhd (media header) box
|
|
227
|
+
*/
|
|
228
|
+
export function parseMdhd(data, offset) {
|
|
229
|
+
const version = data[offset + 8];
|
|
230
|
+
if (version === 0) {
|
|
231
|
+
return {
|
|
232
|
+
timescale: readUint32(data, offset + 20),
|
|
233
|
+
duration: readUint32(data, offset + 24)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
timescale: readUint32(data, offset + 28),
|
|
238
|
+
duration: readUint64(data, offset + 32)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Parse tkhd (track header) box
|
|
244
|
+
*/
|
|
245
|
+
export function parseTkhd(data, offset) {
|
|
246
|
+
const version = data[offset + 8];
|
|
247
|
+
if (version === 0) {
|
|
248
|
+
return {
|
|
249
|
+
trackId: readUint32(data, offset + 20),
|
|
250
|
+
duration: readUint32(data, offset + 28),
|
|
251
|
+
width: readUint32(data, offset + 84) / 65536,
|
|
252
|
+
height: readUint32(data, offset + 88) / 65536
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
trackId: readUint32(data, offset + 28),
|
|
257
|
+
duration: readUint64(data, offset + 36),
|
|
258
|
+
width: readUint32(data, offset + 96) / 65536,
|
|
259
|
+
height: readUint32(data, offset + 100) / 65536
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Parse avcC (AVC decoder configuration) box
|
|
265
|
+
*/
|
|
266
|
+
export function parseAvcC(data, offset) {
|
|
267
|
+
let pos = offset + 8;
|
|
268
|
+
const configVersion = data[pos++];
|
|
269
|
+
const profile = data[pos++];
|
|
270
|
+
const profileCompat = data[pos++];
|
|
271
|
+
const level = data[pos++];
|
|
272
|
+
const lengthSizeMinusOne = data[pos++] & 0x03;
|
|
273
|
+
const numSPS = data[pos++] & 0x1F;
|
|
274
|
+
|
|
275
|
+
const sps = [];
|
|
276
|
+
for (let i = 0; i < numSPS; i++) {
|
|
277
|
+
const spsLen = (data[pos] << 8) | data[pos + 1];
|
|
278
|
+
pos += 2;
|
|
279
|
+
sps.push(data.slice(pos, pos + spsLen));
|
|
280
|
+
pos += spsLen;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const numPPS = data[pos++];
|
|
284
|
+
const pps = [];
|
|
285
|
+
for (let i = 0; i < numPPS; i++) {
|
|
286
|
+
const ppsLen = (data[pos] << 8) | data[pos + 1];
|
|
287
|
+
pos += 2;
|
|
288
|
+
pps.push(data.slice(pos, pos + ppsLen));
|
|
289
|
+
pos += ppsLen;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
profile,
|
|
294
|
+
level,
|
|
295
|
+
sps,
|
|
296
|
+
pps,
|
|
297
|
+
nalLengthSize: lengthSizeMinusOne + 1
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Parse mp4a audio sample entry for sample rate and channels
|
|
303
|
+
*/
|
|
304
|
+
export function parseMp4a(data, offset) {
|
|
305
|
+
const channels = (data[offset + 24] << 8) | data[offset + 25];
|
|
306
|
+
const sampleRate = (data[offset + 32] << 8) | data[offset + 33];
|
|
307
|
+
return { channels, sampleRate };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ============================================================================
|
|
311
|
+
// Track Analysis
|
|
312
|
+
// ============================================================================
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Analyze a single track from moov data
|
|
316
|
+
*/
|
|
317
|
+
export function analyzeTrack(moov, trakOffset, trakSize) {
|
|
318
|
+
// Get track header
|
|
319
|
+
const tkhd = findBox(moov, 'tkhd', trakOffset + 8, trakOffset + trakSize);
|
|
320
|
+
const tkhdInfo = tkhd ? parseTkhd(moov, tkhd.offset) : { trackId: 0, width: 0, height: 0 };
|
|
321
|
+
|
|
322
|
+
const mdia = findBox(moov, 'mdia', trakOffset + 8, trakOffset + trakSize);
|
|
323
|
+
if (!mdia) return null;
|
|
324
|
+
|
|
325
|
+
const mdhd = findBox(moov, 'mdhd', mdia.offset + 8, mdia.offset + mdia.size);
|
|
326
|
+
const mediaInfo = mdhd ? parseMdhd(moov, mdhd.offset) : { timescale: 90000, duration: 0 };
|
|
327
|
+
|
|
328
|
+
const hdlr = findBox(moov, 'hdlr', mdia.offset + 8, mdia.offset + mdia.size);
|
|
329
|
+
const handlerType = hdlr ? boxType(moov, hdlr.offset + 16) : 'unkn';
|
|
330
|
+
|
|
331
|
+
const minf = findBox(moov, 'minf', mdia.offset + 8, mdia.offset + mdia.size);
|
|
332
|
+
if (!minf) return null;
|
|
333
|
+
|
|
334
|
+
const stbl = findBox(moov, 'stbl', minf.offset + 8, minf.offset + minf.size);
|
|
335
|
+
if (!stbl) return null;
|
|
336
|
+
|
|
337
|
+
const stblStart = stbl.offset + 8;
|
|
338
|
+
const stblEnd = stbl.offset + stbl.size;
|
|
339
|
+
|
|
340
|
+
// Parse sample tables
|
|
341
|
+
const sttsBox = findBox(moov, 'stts', stblStart, stblEnd);
|
|
342
|
+
const stssBox = findBox(moov, 'stss', stblStart, stblEnd);
|
|
343
|
+
const stszBox = findBox(moov, 'stsz', stblStart, stblEnd);
|
|
344
|
+
const stcoBox = findBox(moov, 'stco', stblStart, stblEnd);
|
|
345
|
+
const co64Box = findBox(moov, 'co64', stblStart, stblEnd);
|
|
346
|
+
const stscBox = findBox(moov, 'stsc', stblStart, stblEnd);
|
|
347
|
+
const stsdBox = findBox(moov, 'stsd', stblStart, stblEnd);
|
|
348
|
+
const cttsBox = findBox(moov, 'ctts', stblStart, stblEnd);
|
|
349
|
+
|
|
350
|
+
// Parse codec config
|
|
351
|
+
let codecConfig = null;
|
|
352
|
+
let audioConfig = null;
|
|
353
|
+
|
|
354
|
+
if (stsdBox && handlerType === 'vide') {
|
|
355
|
+
const avc1 = findBox(moov, 'avc1', stsdBox.offset + 16, stsdBox.offset + stsdBox.size);
|
|
356
|
+
if (avc1) {
|
|
357
|
+
const avcC = findBox(moov, 'avcC', avc1.offset + 86, avc1.offset + avc1.size);
|
|
358
|
+
if (avcC) {
|
|
359
|
+
codecConfig = parseAvcC(moov, avcC.offset);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (stsdBox && handlerType === 'soun') {
|
|
365
|
+
const mp4a = findBox(moov, 'mp4a', stsdBox.offset + 16, stsdBox.offset + stsdBox.size);
|
|
366
|
+
if (mp4a) {
|
|
367
|
+
audioConfig = parseMp4a(moov, mp4a.offset);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
trackId: tkhdInfo.trackId,
|
|
373
|
+
type: handlerType,
|
|
374
|
+
width: tkhdInfo.width,
|
|
375
|
+
height: tkhdInfo.height,
|
|
376
|
+
timescale: mediaInfo.timescale,
|
|
377
|
+
duration: mediaInfo.duration,
|
|
378
|
+
durationSeconds: mediaInfo.duration / mediaInfo.timescale,
|
|
379
|
+
stts: sttsBox ? parseStts(moov, sttsBox.offset) : [],
|
|
380
|
+
stss: stssBox ? parseStss(moov, stssBox.offset) : [],
|
|
381
|
+
stsz: stszBox ? parseStsz(moov, stszBox.offset) : [],
|
|
382
|
+
stco: stcoBox ? parseStco(moov, stcoBox.offset) :
|
|
383
|
+
co64Box ? parseCo64(moov, co64Box.offset) : [],
|
|
384
|
+
stsc: stscBox ? parseStsc(moov, stscBox.offset) : [],
|
|
385
|
+
ctts: cttsBox ? parseCtts(moov, cttsBox.offset) : [],
|
|
386
|
+
codecConfig,
|
|
387
|
+
audioConfig
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ============================================================================
|
|
392
|
+
// Sample Table Building
|
|
393
|
+
// ============================================================================
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Build a flat sample table with byte offsets and timestamps
|
|
397
|
+
* @param {object} track - Track metadata from analyzeTrack
|
|
398
|
+
* @returns {Array} Array of sample objects
|
|
399
|
+
*/
|
|
400
|
+
export function buildSampleTable(track) {
|
|
401
|
+
const { stsz, stco, stsc, stts, stss, ctts, timescale } = track;
|
|
402
|
+
const samples = [];
|
|
403
|
+
|
|
404
|
+
// Build ctts lookup (composition time offset for B-frames)
|
|
405
|
+
const cttsOffsets = [];
|
|
406
|
+
if (ctts && ctts.length > 0) {
|
|
407
|
+
for (const entry of ctts) {
|
|
408
|
+
for (let i = 0; i < entry.sampleCount; i++) {
|
|
409
|
+
cttsOffsets.push(entry.sampleOffset);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let sampleIndex = 0;
|
|
415
|
+
let currentDts = 0;
|
|
416
|
+
let sttsEntryIndex = 0;
|
|
417
|
+
let sttsRemaining = stts[0]?.sampleCount || 0;
|
|
418
|
+
|
|
419
|
+
for (let chunkIndex = 0; chunkIndex < stco.length; chunkIndex++) {
|
|
420
|
+
// Find samples per chunk for this chunk
|
|
421
|
+
let samplesInChunk = 1;
|
|
422
|
+
for (let i = stsc.length - 1; i >= 0; i--) {
|
|
423
|
+
if (stsc[i].firstChunk <= chunkIndex + 1) {
|
|
424
|
+
samplesInChunk = stsc[i].samplesPerChunk;
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let chunkOffset = stco[chunkIndex];
|
|
430
|
+
|
|
431
|
+
for (let s = 0; s < samplesInChunk && sampleIndex < stsz.length; s++) {
|
|
432
|
+
const size = stsz[sampleIndex];
|
|
433
|
+
const duration = stts[sttsEntryIndex]?.sampleDelta || 0;
|
|
434
|
+
|
|
435
|
+
// PTS = DTS + composition offset (ctts)
|
|
436
|
+
const compositionOffset = cttsOffsets[sampleIndex] || 0;
|
|
437
|
+
const pts = Math.max(currentDts, currentDts + compositionOffset);
|
|
438
|
+
|
|
439
|
+
samples.push({
|
|
440
|
+
index: sampleIndex,
|
|
441
|
+
offset: chunkOffset,
|
|
442
|
+
size,
|
|
443
|
+
dts: currentDts / timescale,
|
|
444
|
+
pts: pts / timescale,
|
|
445
|
+
time: pts / timescale,
|
|
446
|
+
duration: duration / timescale,
|
|
447
|
+
isKeyframe: stss.length === 0 || stss.includes(sampleIndex + 1)
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
chunkOffset += size;
|
|
451
|
+
currentDts += duration;
|
|
452
|
+
sampleIndex++;
|
|
453
|
+
|
|
454
|
+
sttsRemaining--;
|
|
455
|
+
if (sttsRemaining === 0 && sttsEntryIndex < stts.length - 1) {
|
|
456
|
+
sttsEntryIndex++;
|
|
457
|
+
sttsRemaining = stts[sttsEntryIndex].sampleCount;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return samples;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Build HLS-style segments from video samples
|
|
467
|
+
* @param {Array} videoSamples - Video sample table
|
|
468
|
+
* @param {number} targetDuration - Target segment duration in seconds
|
|
469
|
+
* @returns {Array} Array of segment definitions
|
|
470
|
+
*/
|
|
471
|
+
export function buildSegments(videoSamples, targetDuration = 4) {
|
|
472
|
+
const segments = [];
|
|
473
|
+
const keyframes = videoSamples.filter(s => s.isKeyframe);
|
|
474
|
+
|
|
475
|
+
for (let i = 0; i < keyframes.length; i++) {
|
|
476
|
+
const start = keyframes[i];
|
|
477
|
+
const end = keyframes[i + 1] || videoSamples[videoSamples.length - 1];
|
|
478
|
+
|
|
479
|
+
const videoStart = start.index;
|
|
480
|
+
const videoEnd = end ? end.index : videoSamples.length;
|
|
481
|
+
|
|
482
|
+
const duration = (end ? end.time :
|
|
483
|
+
videoSamples[videoSamples.length - 1].time +
|
|
484
|
+
videoSamples[videoSamples.length - 1].duration) - start.time;
|
|
485
|
+
|
|
486
|
+
// Combine short segments
|
|
487
|
+
if (segments.length > 0 &&
|
|
488
|
+
segments[segments.length - 1].duration + duration < targetDuration) {
|
|
489
|
+
const prev = segments[segments.length - 1];
|
|
490
|
+
prev.videoEnd = videoEnd;
|
|
491
|
+
prev.duration += duration;
|
|
492
|
+
prev.endTime = start.time + duration;
|
|
493
|
+
} else {
|
|
494
|
+
segments.push({
|
|
495
|
+
index: segments.length,
|
|
496
|
+
startTime: start.time,
|
|
497
|
+
endTime: start.time + duration,
|
|
498
|
+
duration,
|
|
499
|
+
videoStart,
|
|
500
|
+
videoEnd
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return segments;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Calculate byte ranges needed for a set of samples
|
|
510
|
+
* @param {Array} samples - Array of samples with offset and size
|
|
511
|
+
* @param {number} maxGap - Maximum gap to coalesce (default 64KB)
|
|
512
|
+
* @returns {Array} Array of {start, end, samples} ranges
|
|
513
|
+
*/
|
|
514
|
+
export function calculateByteRanges(samples, maxGap = 65536) {
|
|
515
|
+
if (samples.length === 0) return [];
|
|
516
|
+
|
|
517
|
+
const ranges = [];
|
|
518
|
+
let currentRange = {
|
|
519
|
+
start: samples[0].offset,
|
|
520
|
+
end: samples[0].offset + samples[0].size,
|
|
521
|
+
samples: [samples[0]]
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
for (let i = 1; i < samples.length; i++) {
|
|
525
|
+
const sample = samples[i];
|
|
526
|
+
|
|
527
|
+
if (sample.offset <= currentRange.end + maxGap) {
|
|
528
|
+
currentRange.end = Math.max(currentRange.end, sample.offset + sample.size);
|
|
529
|
+
currentRange.samples.push(sample);
|
|
530
|
+
} else {
|
|
531
|
+
ranges.push(currentRange);
|
|
532
|
+
currentRange = {
|
|
533
|
+
start: sample.offset,
|
|
534
|
+
end: sample.offset + sample.size,
|
|
535
|
+
samples: [sample]
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
ranges.push(currentRange);
|
|
540
|
+
|
|
541
|
+
return ranges;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ============================================================================
|
|
545
|
+
// MP4Parser Class
|
|
546
|
+
// ============================================================================
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* MP4 Parser - Parse MP4 files to extract tracks and samples
|
|
550
|
+
*/
|
|
551
|
+
export class MP4Parser {
|
|
552
|
+
/**
|
|
553
|
+
* Create parser from MP4 data
|
|
554
|
+
* @param {Uint8Array} data - Complete MP4 file data
|
|
555
|
+
*/
|
|
556
|
+
constructor(data) {
|
|
557
|
+
this.data = data;
|
|
558
|
+
this.moov = null;
|
|
559
|
+
this.videoTrack = null;
|
|
560
|
+
this.audioTrack = null;
|
|
561
|
+
this.videoSamples = [];
|
|
562
|
+
this.audioSamples = [];
|
|
563
|
+
|
|
564
|
+
this._parse();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
_parse() {
|
|
568
|
+
// Find moov box
|
|
569
|
+
const moov = findBox(this.data, 'moov');
|
|
570
|
+
if (!moov) {
|
|
571
|
+
throw new Error('No moov box found - not a valid MP4 file');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
this.moov = this.data.slice(moov.offset, moov.offset + moov.size);
|
|
575
|
+
|
|
576
|
+
// Parse tracks
|
|
577
|
+
let trackOffset = 8;
|
|
578
|
+
while (trackOffset < this.moov.length) {
|
|
579
|
+
const trak = findBox(this.moov, 'trak', trackOffset);
|
|
580
|
+
if (!trak) break;
|
|
581
|
+
|
|
582
|
+
const track = analyzeTrack(this.moov, trak.offset, trak.size);
|
|
583
|
+
if (track) {
|
|
584
|
+
if (track.type === 'vide' && !this.videoTrack) {
|
|
585
|
+
this.videoTrack = track;
|
|
586
|
+
this.videoSamples = buildSampleTable(track);
|
|
587
|
+
} else if (track.type === 'soun' && !this.audioTrack) {
|
|
588
|
+
this.audioTrack = track;
|
|
589
|
+
this.audioSamples = buildSampleTable(track);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
trackOffset = trak.offset + trak.size;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!this.videoTrack) {
|
|
596
|
+
throw new Error('No video track found');
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Duration in seconds */
|
|
601
|
+
get duration() {
|
|
602
|
+
return this.videoTrack?.durationSeconds || 0;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Video width */
|
|
606
|
+
get width() {
|
|
607
|
+
return this.videoTrack?.width || 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Video height */
|
|
611
|
+
get height() {
|
|
612
|
+
return this.videoTrack?.height || 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Whether source has audio */
|
|
616
|
+
get hasAudio() {
|
|
617
|
+
return !!this.audioTrack;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Whether video has B-frames */
|
|
621
|
+
get hasBframes() {
|
|
622
|
+
return this.videoTrack?.ctts?.length > 0;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Video codec config (SPS/PPS) */
|
|
626
|
+
get videoCodecConfig() {
|
|
627
|
+
return this.videoTrack?.codecConfig;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Audio config (sample rate, channels) */
|
|
631
|
+
get audioCodecConfig() {
|
|
632
|
+
return this.audioTrack?.audioConfig;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Get video samples
|
|
637
|
+
* @returns {Array} Video sample table
|
|
638
|
+
*/
|
|
639
|
+
getVideoSamples() {
|
|
640
|
+
return this.videoSamples;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Get audio samples
|
|
645
|
+
* @returns {Array} Audio sample table
|
|
646
|
+
*/
|
|
647
|
+
getAudioSamples() {
|
|
648
|
+
return this.audioSamples;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Build HLS-style segments
|
|
653
|
+
* @param {number} targetDuration - Target segment duration in seconds
|
|
654
|
+
* @returns {Array} Segment definitions
|
|
655
|
+
*/
|
|
656
|
+
buildSegments(targetDuration = 4) {
|
|
657
|
+
return buildSegments(this.videoSamples, targetDuration);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Get sample data for a range of samples
|
|
662
|
+
* @param {Array} samples - Samples to extract (must have offset and size)
|
|
663
|
+
* @returns {Array} Samples with data property added
|
|
664
|
+
*/
|
|
665
|
+
getSampleData(samples) {
|
|
666
|
+
return samples.map(sample => ({
|
|
667
|
+
...sample,
|
|
668
|
+
data: this.data.slice(sample.offset, sample.offset + sample.size)
|
|
669
|
+
}));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Get parser info
|
|
674
|
+
*/
|
|
675
|
+
getInfo() {
|
|
676
|
+
return {
|
|
677
|
+
duration: this.duration,
|
|
678
|
+
width: this.width,
|
|
679
|
+
height: this.height,
|
|
680
|
+
hasAudio: this.hasAudio,
|
|
681
|
+
hasBframes: this.hasBframes,
|
|
682
|
+
videoSampleCount: this.videoSamples.length,
|
|
683
|
+
audioSampleCount: this.audioSamples.length,
|
|
684
|
+
keyframeCount: this.videoTrack?.stss?.length ||
|
|
685
|
+
this.videoSamples.filter(s => s.isKeyframe).length
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export default MP4Parser;
|
|
691
|
+
|