@invintusmedia/tomp4 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/dist/tomp4.js +312 -363
- package/package.json +6 -3
- package/src/fmp4/converter.js +323 -0
- package/src/fmp4/index.js +25 -0
- package/src/fmp4/stitcher.js +615 -0
- package/src/fmp4/utils.js +201 -0
- package/src/index.js +41 -20
- package/src/mpegts/index.js +7 -0
- package/src/mpegts/stitcher.js +251 -0
- package/src/muxers/mp4.js +85 -85
- package/src/muxers/mpegts.js +101 -19
- package/src/parsers/mp4.js +691 -0
- package/src/parsers/mpegts.js +42 -42
- package/src/remote/index.js +444 -0
- package/src/transcode.js +20 -36
- package/src/ts-to-mp4.js +37 -37
- package/src/fmp4-to-mp4.js +0 -375
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fMP4 Box Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for parsing and creating MP4 boxes
|
|
5
|
+
* Used by both converter and stitcher modules
|
|
6
|
+
*
|
|
7
|
+
* @module fmp4/utils
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ============================================
|
|
11
|
+
// Box Parsing
|
|
12
|
+
// ============================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse top-level or nested boxes from MP4 data
|
|
16
|
+
* @param {Uint8Array} data - Data buffer
|
|
17
|
+
* @param {number} offset - Start offset
|
|
18
|
+
* @param {number} end - End offset
|
|
19
|
+
* @returns {Array<{type: string, offset: number, size: number, data: Uint8Array}>}
|
|
20
|
+
*/
|
|
21
|
+
export function parseBoxes(data, offset = 0, end = data.byteLength) {
|
|
22
|
+
const boxes = [];
|
|
23
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
24
|
+
while (offset < end) {
|
|
25
|
+
if (offset + 8 > end) break;
|
|
26
|
+
const size = view.getUint32(offset);
|
|
27
|
+
const type = String.fromCharCode(data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]);
|
|
28
|
+
if (size === 0 || size < 8) break;
|
|
29
|
+
boxes.push({ type, offset, size, data: data.subarray(offset, offset + size) });
|
|
30
|
+
offset += size;
|
|
31
|
+
}
|
|
32
|
+
return boxes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find a box by type in an array of boxes
|
|
37
|
+
* @param {Array} boxes - Array of parsed boxes
|
|
38
|
+
* @param {string} type - 4-character box type
|
|
39
|
+
* @returns {object|null} Box object or null
|
|
40
|
+
*/
|
|
41
|
+
export function findBox(boxes, type) {
|
|
42
|
+
for (const box of boxes) if (box.type === type) return box;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse child boxes within a container box
|
|
48
|
+
* @param {object} box - Parent box
|
|
49
|
+
* @param {number} headerSize - Header size (8 for regular, 12 for fullbox)
|
|
50
|
+
* @returns {Array} Array of child boxes
|
|
51
|
+
*/
|
|
52
|
+
export function parseChildBoxes(box, headerSize = 8) {
|
|
53
|
+
return parseBoxes(box.data, headerSize, box.size);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create an MP4 box with the given type and payloads
|
|
58
|
+
* @param {string} type - 4-character box type
|
|
59
|
+
* @param {...Uint8Array} payloads - Box content
|
|
60
|
+
* @returns {Uint8Array} Complete box
|
|
61
|
+
*/
|
|
62
|
+
export function createBox(type, ...payloads) {
|
|
63
|
+
let size = 8;
|
|
64
|
+
for (const p of payloads) size += p.byteLength;
|
|
65
|
+
const result = new Uint8Array(size);
|
|
66
|
+
const view = new DataView(result.buffer);
|
|
67
|
+
view.setUint32(0, size);
|
|
68
|
+
result[4] = type.charCodeAt(0);
|
|
69
|
+
result[5] = type.charCodeAt(1);
|
|
70
|
+
result[6] = type.charCodeAt(2);
|
|
71
|
+
result[7] = type.charCodeAt(3);
|
|
72
|
+
let offset = 8;
|
|
73
|
+
for (const p of payloads) {
|
|
74
|
+
result.set(p, offset);
|
|
75
|
+
offset += p.byteLength;
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================
|
|
81
|
+
// Fragment Box Parsing
|
|
82
|
+
// ============================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse tfhd (track fragment header) box
|
|
86
|
+
* Extracts track ID and default sample values
|
|
87
|
+
* @param {Uint8Array} tfhdData - tfhd box data
|
|
88
|
+
* @returns {{trackId: number, defaultSampleDuration: number, defaultSampleSize: number, defaultSampleFlags: number}}
|
|
89
|
+
*/
|
|
90
|
+
export function parseTfhd(tfhdData) {
|
|
91
|
+
const view = new DataView(tfhdData.buffer, tfhdData.byteOffset, tfhdData.byteLength);
|
|
92
|
+
const flags = (tfhdData[9] << 16) | (tfhdData[10] << 8) | tfhdData[11];
|
|
93
|
+
const trackId = view.getUint32(12);
|
|
94
|
+
let offset = 16;
|
|
95
|
+
let defaultSampleDuration = 0, defaultSampleSize = 0, defaultSampleFlags = 0;
|
|
96
|
+
|
|
97
|
+
if (flags & 0x1) offset += 8; // base-data-offset
|
|
98
|
+
if (flags & 0x2) offset += 4; // sample-description-index
|
|
99
|
+
if (flags & 0x8) { defaultSampleDuration = view.getUint32(offset); offset += 4; }
|
|
100
|
+
if (flags & 0x10) { defaultSampleSize = view.getUint32(offset); offset += 4; }
|
|
101
|
+
if (flags & 0x20) { defaultSampleFlags = view.getUint32(offset); offset += 4; }
|
|
102
|
+
|
|
103
|
+
return { trackId, defaultSampleDuration, defaultSampleSize, defaultSampleFlags };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse tfdt (track fragment decode time) box
|
|
108
|
+
* @param {Uint8Array} tfdtData - tfdt box data
|
|
109
|
+
* @returns {number} Base media decode time
|
|
110
|
+
*/
|
|
111
|
+
export function parseTfdt(tfdtData) {
|
|
112
|
+
const view = new DataView(tfdtData.buffer, tfdtData.byteOffset, tfdtData.byteLength);
|
|
113
|
+
const version = tfdtData[8];
|
|
114
|
+
if (version === 1) {
|
|
115
|
+
return Number(view.getBigUint64(12));
|
|
116
|
+
}
|
|
117
|
+
return view.getUint32(12);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse trun (track run) box
|
|
122
|
+
* @param {Uint8Array} trunData - trun box data
|
|
123
|
+
* @param {{defaultSampleDuration?: number, defaultSampleSize?: number, defaultSampleFlags?: number}} defaults - Default values from tfhd
|
|
124
|
+
* @returns {{samples: Array, dataOffset: number, flags: number}}
|
|
125
|
+
*/
|
|
126
|
+
export function parseTrun(trunData, defaults = {}) {
|
|
127
|
+
const view = new DataView(trunData.buffer, trunData.byteOffset, trunData.byteLength);
|
|
128
|
+
const version = trunData[8];
|
|
129
|
+
const flags = (trunData[9] << 16) | (trunData[10] << 8) | trunData[11];
|
|
130
|
+
const sampleCount = view.getUint32(12);
|
|
131
|
+
let offset = 16;
|
|
132
|
+
let dataOffset = 0;
|
|
133
|
+
let firstSampleFlags = null;
|
|
134
|
+
|
|
135
|
+
if (flags & 0x1) { dataOffset = view.getInt32(offset); offset += 4; }
|
|
136
|
+
if (flags & 0x4) { firstSampleFlags = view.getUint32(offset); offset += 4; }
|
|
137
|
+
|
|
138
|
+
const samples = [];
|
|
139
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
140
|
+
const sample = {
|
|
141
|
+
duration: defaults.defaultSampleDuration || 0,
|
|
142
|
+
size: defaults.defaultSampleSize || 0,
|
|
143
|
+
flags: (i === 0 && firstSampleFlags !== null) ? firstSampleFlags : (defaults.defaultSampleFlags || 0),
|
|
144
|
+
compositionTimeOffset: 0
|
|
145
|
+
};
|
|
146
|
+
if (flags & 0x100) { sample.duration = view.getUint32(offset); offset += 4; }
|
|
147
|
+
if (flags & 0x200) { sample.size = view.getUint32(offset); offset += 4; }
|
|
148
|
+
if (flags & 0x400) { sample.flags = view.getUint32(offset); offset += 4; }
|
|
149
|
+
if (flags & 0x800) {
|
|
150
|
+
sample.compositionTimeOffset = version === 0 ? view.getUint32(offset) : view.getInt32(offset);
|
|
151
|
+
offset += 4;
|
|
152
|
+
}
|
|
153
|
+
samples.push(sample);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { samples, dataOffset, flags };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================
|
|
160
|
+
// Track ID Extraction
|
|
161
|
+
// ============================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Extract track IDs from moov box
|
|
165
|
+
* @param {object} moovBox - Parsed moov box
|
|
166
|
+
* @returns {number[]} Array of track IDs
|
|
167
|
+
*/
|
|
168
|
+
export function extractTrackIds(moovBox) {
|
|
169
|
+
const trackIds = [];
|
|
170
|
+
const moovChildren = parseChildBoxes(moovBox);
|
|
171
|
+
for (const child of moovChildren) {
|
|
172
|
+
if (child.type === 'trak') {
|
|
173
|
+
const trakChildren = parseChildBoxes(child);
|
|
174
|
+
for (const tc of trakChildren) {
|
|
175
|
+
if (tc.type === 'tkhd') {
|
|
176
|
+
const view = new DataView(tc.data.buffer, tc.data.byteOffset, tc.data.byteLength);
|
|
177
|
+
const version = tc.data[8];
|
|
178
|
+
trackIds.push(version === 0 ? view.getUint32(20) : view.getUint32(28));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return trackIds;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Extract movie timescale from mvhd box
|
|
188
|
+
* @param {object} moovBox - Parsed moov box
|
|
189
|
+
* @returns {number} Movie timescale (default: 1000)
|
|
190
|
+
*/
|
|
191
|
+
export function getMovieTimescale(moovBox) {
|
|
192
|
+
const moovChildren = parseChildBoxes(moovBox);
|
|
193
|
+
for (const child of moovChildren) {
|
|
194
|
+
if (child.type === 'mvhd') {
|
|
195
|
+
const view = new DataView(child.data.buffer, child.data.byteOffset, child.data.byteLength);
|
|
196
|
+
const version = child.data[8];
|
|
197
|
+
return version === 0 ? view.getUint32(20) : view.getUint32(28);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return 1000;
|
|
201
|
+
}
|
package/src/index.js
CHANGED
|
@@ -31,12 +31,15 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { convertTsToMp4, analyzeTsData } from './ts-to-mp4.js';
|
|
34
|
-
import { convertFmp4ToMp4 } from './fmp4
|
|
34
|
+
import { convertFmp4ToMp4, stitchFmp4 } from './fmp4/index.js';
|
|
35
|
+
import { stitchTs, concatTs } from './mpegts/index.js';
|
|
35
36
|
import { parseHls, downloadHls, isHlsUrl, HlsStream, HlsVariant } from './hls.js';
|
|
36
37
|
import { transcode, isWebCodecsSupported } from './transcode.js';
|
|
37
38
|
import { TSMuxer } from './muxers/mpegts.js';
|
|
38
39
|
import { MP4Muxer } from './muxers/mp4.js';
|
|
39
40
|
import { TSParser } from './parsers/mpegts.js';
|
|
41
|
+
import { MP4Parser } from './parsers/mp4.js';
|
|
42
|
+
import { RemoteMp4 } from './remote/index.js';
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* Result object returned by toMp4()
|
|
@@ -146,7 +149,7 @@ function isStandardMp4(data) {
|
|
|
146
149
|
while (offset + 8 <= data.length) {
|
|
147
150
|
const size = view.getUint32(offset);
|
|
148
151
|
if (size < 8) break;
|
|
149
|
-
const boxType = String.fromCharCode(data[offset+4], data[offset+5], data[offset+6], data[offset+7]);
|
|
152
|
+
const boxType = String.fromCharCode(data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]);
|
|
150
153
|
if (boxType === 'moov') hasMoov = true;
|
|
151
154
|
if (boxType === 'moof') hasMoof = true;
|
|
152
155
|
offset += size;
|
|
@@ -168,7 +171,7 @@ function detectFormat(data) {
|
|
|
168
171
|
function convertData(data, options = {}) {
|
|
169
172
|
const uint8 = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
170
173
|
const format = detectFormat(uint8);
|
|
171
|
-
|
|
174
|
+
|
|
172
175
|
switch (format) {
|
|
173
176
|
case 'mpegts':
|
|
174
177
|
return convertTsToMp4(uint8, options);
|
|
@@ -215,15 +218,15 @@ function convertData(data, options = {}) {
|
|
|
215
218
|
async function toMp4(input, options = {}) {
|
|
216
219
|
let data;
|
|
217
220
|
let filename = options.filename || 'video.mp4';
|
|
218
|
-
const log = options.onProgress || (() => {});
|
|
219
|
-
|
|
221
|
+
const log = options.onProgress || (() => { });
|
|
222
|
+
|
|
220
223
|
// Handle HlsStream object
|
|
221
224
|
if (input instanceof HlsStream) {
|
|
222
225
|
if (!options.filename) {
|
|
223
226
|
const urlPart = (input.masterUrl || '').split('/').pop()?.split('?')[0];
|
|
224
227
|
filename = urlPart ? urlPart.replace('.m3u8', '.mp4') : 'video.mp4';
|
|
225
228
|
}
|
|
226
|
-
data = await downloadHls(input, {
|
|
229
|
+
data = await downloadHls(input, {
|
|
227
230
|
...options,
|
|
228
231
|
quality: options.quality || 'highest'
|
|
229
232
|
});
|
|
@@ -248,7 +251,7 @@ async function toMp4(input, options = {}) {
|
|
|
248
251
|
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
|
249
252
|
}
|
|
250
253
|
data = new Uint8Array(await response.arrayBuffer());
|
|
251
|
-
|
|
254
|
+
|
|
252
255
|
if (!options.filename) {
|
|
253
256
|
const urlFilename = input.split('/').pop()?.split('?')[0];
|
|
254
257
|
if (urlFilename) {
|
|
@@ -272,7 +275,7 @@ async function toMp4(input, options = {}) {
|
|
|
272
275
|
else {
|
|
273
276
|
throw new Error('Input must be a URL string, HlsStream, Uint8Array, ArrayBuffer, or Blob');
|
|
274
277
|
}
|
|
275
|
-
|
|
278
|
+
|
|
276
279
|
// Adjust clip times if we downloaded HLS with a time range
|
|
277
280
|
// The downloaded segments have been normalized to start at 0,
|
|
278
281
|
// so we need to adjust the requested clip times accordingly
|
|
@@ -287,17 +290,20 @@ async function toMp4(input, options = {}) {
|
|
|
287
290
|
}
|
|
288
291
|
log(`Adjusted clip: ${convertOptions.startTime?.toFixed(2) || 0}s - ${convertOptions.endTime?.toFixed(2) || '∞'}s (offset: -${segmentStart.toFixed(2)}s)`);
|
|
289
292
|
}
|
|
290
|
-
|
|
293
|
+
|
|
291
294
|
// Convert
|
|
292
295
|
log('Converting...');
|
|
293
296
|
const mp4Data = convertData(data, convertOptions);
|
|
294
|
-
|
|
297
|
+
|
|
295
298
|
return new Mp4Result(mp4Data, filename);
|
|
296
299
|
}
|
|
297
300
|
|
|
298
301
|
// Attach utilities to main function
|
|
299
302
|
toMp4.fromTs = (data, options) => new Mp4Result(convertTsToMp4(data instanceof ArrayBuffer ? new Uint8Array(data) : data, options));
|
|
300
303
|
toMp4.fromFmp4 = (data) => new Mp4Result(convertFmp4ToMp4(data instanceof ArrayBuffer ? new Uint8Array(data) : data));
|
|
304
|
+
toMp4.stitchFmp4 = (segments, options) => new Mp4Result(stitchFmp4(segments, options));
|
|
305
|
+
toMp4.stitchTs = (segments) => new Mp4Result(stitchTs(segments));
|
|
306
|
+
toMp4.concatTs = concatTs;
|
|
301
307
|
toMp4.detectFormat = detectFormat;
|
|
302
308
|
toMp4.isMpegTs = isMpegTs;
|
|
303
309
|
toMp4.isFmp4 = isFmp4;
|
|
@@ -315,19 +321,29 @@ toMp4.analyze = analyzeTsData;
|
|
|
315
321
|
toMp4.transcode = transcode;
|
|
316
322
|
toMp4.isWebCodecsSupported = isWebCodecsSupported;
|
|
317
323
|
|
|
324
|
+
// Parsers
|
|
325
|
+
toMp4.MP4Parser = MP4Parser;
|
|
326
|
+
toMp4.TSParser = TSParser;
|
|
327
|
+
|
|
328
|
+
// Remote MP4 (on-demand HLS from remote MP4)
|
|
329
|
+
toMp4.RemoteMp4 = RemoteMp4;
|
|
330
|
+
|
|
318
331
|
// Version (injected at build time for dist, read from package.json for ESM)
|
|
319
|
-
toMp4.version = '1.0
|
|
332
|
+
toMp4.version = '1.1.0';
|
|
320
333
|
|
|
321
334
|
// Export
|
|
322
|
-
export {
|
|
323
|
-
toMp4,
|
|
324
|
-
Mp4Result,
|
|
325
|
-
convertTsToMp4,
|
|
326
|
-
convertFmp4ToMp4,
|
|
335
|
+
export {
|
|
336
|
+
toMp4,
|
|
337
|
+
Mp4Result,
|
|
338
|
+
convertTsToMp4,
|
|
339
|
+
convertFmp4ToMp4,
|
|
340
|
+
stitchFmp4,
|
|
341
|
+
stitchTs,
|
|
342
|
+
concatTs,
|
|
327
343
|
analyzeTsData,
|
|
328
|
-
detectFormat,
|
|
329
|
-
isMpegTs,
|
|
330
|
-
isFmp4,
|
|
344
|
+
detectFormat,
|
|
345
|
+
isMpegTs,
|
|
346
|
+
isFmp4,
|
|
331
347
|
isStandardMp4,
|
|
332
348
|
parseHls,
|
|
333
349
|
downloadHls,
|
|
@@ -336,9 +352,14 @@ export {
|
|
|
336
352
|
HlsVariant,
|
|
337
353
|
// Transcoding (browser-only)
|
|
338
354
|
transcode,
|
|
355
|
+
isWebCodecsSupported,
|
|
356
|
+
// Muxers
|
|
339
357
|
TSMuxer,
|
|
340
358
|
MP4Muxer,
|
|
359
|
+
// Parsers
|
|
341
360
|
TSParser,
|
|
342
|
-
|
|
361
|
+
MP4Parser,
|
|
362
|
+
// Remote MP4 (on-demand HLS)
|
|
363
|
+
RemoteMp4
|
|
343
364
|
};
|
|
344
365
|
export default toMp4;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPEG-TS Segment Stitching
|
|
3
|
+
* Combine multiple MPEG-TS segments into a single MP4 or continuous TS stream
|
|
4
|
+
* Pure JavaScript - no dependencies
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { TSParser } from '../parsers/mpegts.js';
|
|
8
|
+
import { MP4Muxer } from '../muxers/mp4.js';
|
|
9
|
+
import { TSMuxer } from '../muxers/mpegts.js';
|
|
10
|
+
|
|
11
|
+
// ============================================
|
|
12
|
+
// Utilities
|
|
13
|
+
// ============================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalize input to Uint8Array
|
|
17
|
+
*/
|
|
18
|
+
function normalizeInput(input) {
|
|
19
|
+
if (input instanceof ArrayBuffer) {
|
|
20
|
+
return new Uint8Array(input);
|
|
21
|
+
}
|
|
22
|
+
return input;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if a video access unit contains a keyframe (IDR NAL unit)
|
|
27
|
+
*/
|
|
28
|
+
function isKeyframe(accessUnit) {
|
|
29
|
+
for (const nalUnit of accessUnit.nalUnits) {
|
|
30
|
+
const nalType = nalUnit[0] & 0x1F;
|
|
31
|
+
if (nalType === 5) return true; // IDR slice
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Calculate segment duration from timestamps
|
|
38
|
+
* Returns duration in PTS ticks (90kHz)
|
|
39
|
+
*/
|
|
40
|
+
function getSegmentDuration(timestamps) {
|
|
41
|
+
if (!timestamps || timestamps.length < 2) return 0;
|
|
42
|
+
|
|
43
|
+
const first = timestamps[0];
|
|
44
|
+
const last = timestamps[timestamps.length - 1];
|
|
45
|
+
|
|
46
|
+
// Estimate last frame duration as average
|
|
47
|
+
const avgDuration = (last - first) / (timestamps.length - 1);
|
|
48
|
+
|
|
49
|
+
return Math.round(last - first + avgDuration);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build ADTS header for AAC frame
|
|
54
|
+
* @param {number} dataLength - Length of AAC data (without header)
|
|
55
|
+
* @param {number} sampleRate - Audio sample rate
|
|
56
|
+
* @param {number} channels - Number of audio channels
|
|
57
|
+
* @returns {Uint8Array} 7-byte ADTS header
|
|
58
|
+
*/
|
|
59
|
+
function buildAdtsHeader(dataLength, sampleRate, channels) {
|
|
60
|
+
const SAMPLE_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
|
|
61
|
+
const samplingFreqIndex = SAMPLE_RATES.indexOf(sampleRate);
|
|
62
|
+
const freqIndex = samplingFreqIndex >= 0 ? samplingFreqIndex : 3; // Default to 48000
|
|
63
|
+
|
|
64
|
+
const frameLength = dataLength + 7; // ADTS header is 7 bytes
|
|
65
|
+
|
|
66
|
+
const header = new Uint8Array(7);
|
|
67
|
+
header[0] = 0xFF; // Sync word
|
|
68
|
+
header[1] = 0xF1; // MPEG-4, Layer 0, no CRC
|
|
69
|
+
header[2] = (1 << 6) | (freqIndex << 2) | ((channels >> 2) & 0x01); // AAC-LC, freq index, channel config high bit
|
|
70
|
+
header[3] = ((channels & 0x03) << 6) | ((frameLength >> 11) & 0x03);
|
|
71
|
+
header[4] = (frameLength >> 3) & 0xFF;
|
|
72
|
+
header[5] = ((frameLength & 0x07) << 5) | 0x1F;
|
|
73
|
+
header[6] = 0xFC;
|
|
74
|
+
|
|
75
|
+
return header;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract SPS and PPS from video access units
|
|
80
|
+
*/
|
|
81
|
+
function extractSpsPps(videoAccessUnits) {
|
|
82
|
+
let sps = null;
|
|
83
|
+
let pps = null;
|
|
84
|
+
|
|
85
|
+
for (const au of videoAccessUnits) {
|
|
86
|
+
for (const nalUnit of au.nalUnits) {
|
|
87
|
+
const nalType = nalUnit[0] & 0x1F;
|
|
88
|
+
if (nalType === 7 && !sps) sps = nalUnit;
|
|
89
|
+
if (nalType === 8 && !pps) pps = nalUnit;
|
|
90
|
+
if (sps && pps) return { sps, pps };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { sps, pps };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================
|
|
98
|
+
// Core Parsing Logic
|
|
99
|
+
// ============================================
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse multiple TS segments and combine with continuous timestamps
|
|
103
|
+
*
|
|
104
|
+
* @param {Uint8Array[]} segments - Array of TS segment data
|
|
105
|
+
* @returns {object} Combined parser-like object compatible with MP4Muxer
|
|
106
|
+
*/
|
|
107
|
+
function parseAndCombineSegments(segments) {
|
|
108
|
+
if (!segments || segments.length === 0) {
|
|
109
|
+
throw new Error('stitchTs: At least one segment is required');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let runningVideoPts = 0;
|
|
113
|
+
let runningAudioPts = 0;
|
|
114
|
+
|
|
115
|
+
const combined = {
|
|
116
|
+
videoAccessUnits: [],
|
|
117
|
+
audioAccessUnits: [],
|
|
118
|
+
videoPts: [],
|
|
119
|
+
videoDts: [],
|
|
120
|
+
audioPts: [],
|
|
121
|
+
// Metadata from first segment with data
|
|
122
|
+
audioSampleRate: null,
|
|
123
|
+
audioChannels: null,
|
|
124
|
+
videoStreamType: null,
|
|
125
|
+
audioStreamType: null
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < segments.length; i++) {
|
|
129
|
+
const segmentData = normalizeInput(segments[i]);
|
|
130
|
+
|
|
131
|
+
const parser = new TSParser();
|
|
132
|
+
parser.parse(segmentData);
|
|
133
|
+
parser.finalize();
|
|
134
|
+
|
|
135
|
+
// Skip empty segments
|
|
136
|
+
if (parser.videoAccessUnits.length === 0 && parser.audioAccessUnits.length === 0) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Capture metadata from first segment with data
|
|
141
|
+
if (combined.audioSampleRate === null && parser.audioSampleRate) {
|
|
142
|
+
combined.audioSampleRate = parser.audioSampleRate;
|
|
143
|
+
combined.audioChannels = parser.audioChannels;
|
|
144
|
+
}
|
|
145
|
+
if (combined.videoStreamType === null && parser.videoStreamType) {
|
|
146
|
+
combined.videoStreamType = parser.videoStreamType;
|
|
147
|
+
}
|
|
148
|
+
if (combined.audioStreamType === null && parser.audioStreamType) {
|
|
149
|
+
combined.audioStreamType = parser.audioStreamType;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Calculate this segment's duration for next offset
|
|
153
|
+
const segmentVideoDuration = getSegmentDuration(parser.videoDts);
|
|
154
|
+
const segmentAudioDuration = getSegmentDuration(parser.audioPts);
|
|
155
|
+
|
|
156
|
+
// Offset and append video access units
|
|
157
|
+
for (const au of parser.videoAccessUnits) {
|
|
158
|
+
combined.videoAccessUnits.push({
|
|
159
|
+
nalUnits: au.nalUnits,
|
|
160
|
+
pts: au.pts + runningVideoPts,
|
|
161
|
+
dts: au.dts + runningVideoPts
|
|
162
|
+
});
|
|
163
|
+
combined.videoPts.push(au.pts + runningVideoPts);
|
|
164
|
+
combined.videoDts.push(au.dts + runningVideoPts);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Offset and append audio access units
|
|
168
|
+
for (const au of parser.audioAccessUnits) {
|
|
169
|
+
combined.audioAccessUnits.push({
|
|
170
|
+
data: au.data,
|
|
171
|
+
pts: au.pts + runningAudioPts
|
|
172
|
+
});
|
|
173
|
+
combined.audioPts.push(au.pts + runningAudioPts);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Advance running offsets for next segment
|
|
177
|
+
runningVideoPts += segmentVideoDuration;
|
|
178
|
+
runningAudioPts += segmentAudioDuration;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (combined.videoAccessUnits.length === 0) {
|
|
182
|
+
throw new Error('stitchTs: No video frames found in any segment');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return combined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================
|
|
189
|
+
// Public API
|
|
190
|
+
// ============================================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Stitch multiple MPEG-TS segments into a single standard MP4
|
|
194
|
+
*
|
|
195
|
+
* @param {(Uint8Array | ArrayBuffer)[]} segments - Array of TS segment data
|
|
196
|
+
* @returns {Uint8Array} MP4 data
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* const mp4Data = stitchTs([segment1, segment2, segment3]);
|
|
200
|
+
*/
|
|
201
|
+
export function stitchTs(segments) {
|
|
202
|
+
const combined = parseAndCombineSegments(segments);
|
|
203
|
+
const muxer = new MP4Muxer(combined);
|
|
204
|
+
return muxer.build();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Concatenate multiple MPEG-TS segments into a single continuous TS stream
|
|
209
|
+
*
|
|
210
|
+
* @param {(Uint8Array | ArrayBuffer)[]} segments - Array of TS segment data
|
|
211
|
+
* @returns {Uint8Array} Combined MPEG-TS data with continuous timestamps
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* const tsData = concatTs([segment1, segment2, segment3]);
|
|
215
|
+
*/
|
|
216
|
+
export function concatTs(segments) {
|
|
217
|
+
const combined = parseAndCombineSegments(segments);
|
|
218
|
+
const { sps, pps } = extractSpsPps(combined.videoAccessUnits);
|
|
219
|
+
|
|
220
|
+
const muxer = new TSMuxer();
|
|
221
|
+
|
|
222
|
+
if (sps && pps) {
|
|
223
|
+
muxer.setSpsPps(sps, pps);
|
|
224
|
+
}
|
|
225
|
+
muxer.setHasAudio(combined.audioAccessUnits.length > 0);
|
|
226
|
+
|
|
227
|
+
// Queue all audio samples (need to wrap raw AAC in ADTS)
|
|
228
|
+
const sampleRate = combined.audioSampleRate || 48000;
|
|
229
|
+
const channels = combined.audioChannels || 2;
|
|
230
|
+
|
|
231
|
+
for (const au of combined.audioAccessUnits) {
|
|
232
|
+
// Build ADTS frame from raw AAC data
|
|
233
|
+
const header = buildAdtsHeader(au.data.length, sampleRate, channels);
|
|
234
|
+
const adtsFrame = new Uint8Array(header.length + au.data.length);
|
|
235
|
+
adtsFrame.set(header, 0);
|
|
236
|
+
adtsFrame.set(au.data, header.length);
|
|
237
|
+
muxer.addAudioSample(adtsFrame, au.pts);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Add video samples using NAL units directly
|
|
241
|
+
for (const au of combined.videoAccessUnits) {
|
|
242
|
+
const isKey = isKeyframe(au);
|
|
243
|
+
muxer.addVideoNalUnits(au.nalUnits, isKey, au.pts, au.dts);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
muxer.flush();
|
|
247
|
+
return muxer.build();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { parseAndCombineSegments, isKeyframe, extractSpsPps };
|
|
251
|
+
export default stitchTs;
|