@invintusmedia/tomp4 1.0.9 → 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 +1 -1
- 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 +26 -19
- 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 +54 -0
- package/src/parsers/mpegts.js +42 -42
- package/src/remote/index.js +82 -56
- 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,7 +31,8 @@
|
|
|
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';
|
|
@@ -148,7 +149,7 @@ function isStandardMp4(data) {
|
|
|
148
149
|
while (offset + 8 <= data.length) {
|
|
149
150
|
const size = view.getUint32(offset);
|
|
150
151
|
if (size < 8) break;
|
|
151
|
-
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]);
|
|
152
153
|
if (boxType === 'moov') hasMoov = true;
|
|
153
154
|
if (boxType === 'moof') hasMoof = true;
|
|
154
155
|
offset += size;
|
|
@@ -170,7 +171,7 @@ function detectFormat(data) {
|
|
|
170
171
|
function convertData(data, options = {}) {
|
|
171
172
|
const uint8 = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
172
173
|
const format = detectFormat(uint8);
|
|
173
|
-
|
|
174
|
+
|
|
174
175
|
switch (format) {
|
|
175
176
|
case 'mpegts':
|
|
176
177
|
return convertTsToMp4(uint8, options);
|
|
@@ -217,15 +218,15 @@ function convertData(data, options = {}) {
|
|
|
217
218
|
async function toMp4(input, options = {}) {
|
|
218
219
|
let data;
|
|
219
220
|
let filename = options.filename || 'video.mp4';
|
|
220
|
-
const log = options.onProgress || (() => {});
|
|
221
|
-
|
|
221
|
+
const log = options.onProgress || (() => { });
|
|
222
|
+
|
|
222
223
|
// Handle HlsStream object
|
|
223
224
|
if (input instanceof HlsStream) {
|
|
224
225
|
if (!options.filename) {
|
|
225
226
|
const urlPart = (input.masterUrl || '').split('/').pop()?.split('?')[0];
|
|
226
227
|
filename = urlPart ? urlPart.replace('.m3u8', '.mp4') : 'video.mp4';
|
|
227
228
|
}
|
|
228
|
-
data = await downloadHls(input, {
|
|
229
|
+
data = await downloadHls(input, {
|
|
229
230
|
...options,
|
|
230
231
|
quality: options.quality || 'highest'
|
|
231
232
|
});
|
|
@@ -250,7 +251,7 @@ async function toMp4(input, options = {}) {
|
|
|
250
251
|
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
|
251
252
|
}
|
|
252
253
|
data = new Uint8Array(await response.arrayBuffer());
|
|
253
|
-
|
|
254
|
+
|
|
254
255
|
if (!options.filename) {
|
|
255
256
|
const urlFilename = input.split('/').pop()?.split('?')[0];
|
|
256
257
|
if (urlFilename) {
|
|
@@ -274,7 +275,7 @@ async function toMp4(input, options = {}) {
|
|
|
274
275
|
else {
|
|
275
276
|
throw new Error('Input must be a URL string, HlsStream, Uint8Array, ArrayBuffer, or Blob');
|
|
276
277
|
}
|
|
277
|
-
|
|
278
|
+
|
|
278
279
|
// Adjust clip times if we downloaded HLS with a time range
|
|
279
280
|
// The downloaded segments have been normalized to start at 0,
|
|
280
281
|
// so we need to adjust the requested clip times accordingly
|
|
@@ -289,17 +290,20 @@ async function toMp4(input, options = {}) {
|
|
|
289
290
|
}
|
|
290
291
|
log(`Adjusted clip: ${convertOptions.startTime?.toFixed(2) || 0}s - ${convertOptions.endTime?.toFixed(2) || '∞'}s (offset: -${segmentStart.toFixed(2)}s)`);
|
|
291
292
|
}
|
|
292
|
-
|
|
293
|
+
|
|
293
294
|
// Convert
|
|
294
295
|
log('Converting...');
|
|
295
296
|
const mp4Data = convertData(data, convertOptions);
|
|
296
|
-
|
|
297
|
+
|
|
297
298
|
return new Mp4Result(mp4Data, filename);
|
|
298
299
|
}
|
|
299
300
|
|
|
300
301
|
// Attach utilities to main function
|
|
301
302
|
toMp4.fromTs = (data, options) => new Mp4Result(convertTsToMp4(data instanceof ArrayBuffer ? new Uint8Array(data) : data, options));
|
|
302
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;
|
|
303
307
|
toMp4.detectFormat = detectFormat;
|
|
304
308
|
toMp4.isMpegTs = isMpegTs;
|
|
305
309
|
toMp4.isFmp4 = isFmp4;
|
|
@@ -325,18 +329,21 @@ toMp4.TSParser = TSParser;
|
|
|
325
329
|
toMp4.RemoteMp4 = RemoteMp4;
|
|
326
330
|
|
|
327
331
|
// Version (injected at build time for dist, read from package.json for ESM)
|
|
328
|
-
toMp4.version = '1.0
|
|
332
|
+
toMp4.version = '1.1.0';
|
|
329
333
|
|
|
330
334
|
// Export
|
|
331
|
-
export {
|
|
332
|
-
toMp4,
|
|
333
|
-
Mp4Result,
|
|
334
|
-
convertTsToMp4,
|
|
335
|
-
convertFmp4ToMp4,
|
|
335
|
+
export {
|
|
336
|
+
toMp4,
|
|
337
|
+
Mp4Result,
|
|
338
|
+
convertTsToMp4,
|
|
339
|
+
convertFmp4ToMp4,
|
|
340
|
+
stitchFmp4,
|
|
341
|
+
stitchTs,
|
|
342
|
+
concatTs,
|
|
336
343
|
analyzeTsData,
|
|
337
|
-
detectFormat,
|
|
338
|
-
isMpegTs,
|
|
339
|
-
isFmp4,
|
|
344
|
+
detectFormat,
|
|
345
|
+
isMpegTs,
|
|
346
|
+
isFmp4,
|
|
340
347
|
isStandardMp4,
|
|
341
348
|
parseHls,
|
|
342
349
|
downloadHls,
|
|
@@ -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;
|