@invintusmedia/tomp4 1.0.8 → 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 +2 -2
- package/package.json +6 -3
- package/src/index.js +16 -2
- 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/dist/tomp4.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toMp4.js v1.0.
|
|
2
|
+
* toMp4.js v1.0.9
|
|
3
3
|
* Convert MPEG-TS and fMP4 to standard MP4
|
|
4
4
|
* https://github.com/TVWIT/toMp4.js
|
|
5
5
|
* MIT License
|
|
@@ -756,7 +756,7 @@
|
|
|
756
756
|
toMp4.isMpegTs = isMpegTs;
|
|
757
757
|
toMp4.isFmp4 = isFmp4;
|
|
758
758
|
toMp4.isStandardMp4 = isStandardMp4;
|
|
759
|
-
toMp4.version = '1.0.
|
|
759
|
+
toMp4.version = '1.0.9';
|
|
760
760
|
|
|
761
761
|
return toMp4;
|
|
762
762
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invintusmedia/tomp4",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Convert MPEG-TS, fMP4, and HLS streams to MP4 with clipping support - pure JavaScript, zero dependencies",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"module": "src/index.js",
|
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "node build.js",
|
|
22
22
|
"dev": "npx serve . -p 3000",
|
|
23
|
-
"test": "
|
|
24
|
-
"
|
|
23
|
+
"test": "npm run test:clip && npm run test:mp4",
|
|
24
|
+
"test:clip": "node tests/clip.test.js",
|
|
25
|
+
"test:mp4": "node tests/mp4-parser.test.js",
|
|
26
|
+
"test:all": "npm run test",
|
|
27
|
+
"release": "npm test && npm run build && git add -A && git commit -m \"v$(node -p \"require('./package.json').version\")\" && git tag v$(node -p \"require('./package.json').version\") && git push && git push --tags",
|
|
25
28
|
"release:patch": "npm version patch --no-git-tag-version && npm run release",
|
|
26
29
|
"release:minor": "npm version minor --no-git-tag-version && npm run release",
|
|
27
30
|
"release:major": "npm version major --no-git-tag-version && npm run release",
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,8 @@ import { transcode, isWebCodecsSupported } from './transcode.js';
|
|
|
37
37
|
import { TSMuxer } from './muxers/mpegts.js';
|
|
38
38
|
import { MP4Muxer } from './muxers/mp4.js';
|
|
39
39
|
import { TSParser } from './parsers/mpegts.js';
|
|
40
|
+
import { MP4Parser } from './parsers/mp4.js';
|
|
41
|
+
import { RemoteMp4 } from './remote/index.js';
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* Result object returned by toMp4()
|
|
@@ -315,8 +317,15 @@ toMp4.analyze = analyzeTsData;
|
|
|
315
317
|
toMp4.transcode = transcode;
|
|
316
318
|
toMp4.isWebCodecsSupported = isWebCodecsSupported;
|
|
317
319
|
|
|
320
|
+
// Parsers
|
|
321
|
+
toMp4.MP4Parser = MP4Parser;
|
|
322
|
+
toMp4.TSParser = TSParser;
|
|
323
|
+
|
|
324
|
+
// Remote MP4 (on-demand HLS from remote MP4)
|
|
325
|
+
toMp4.RemoteMp4 = RemoteMp4;
|
|
326
|
+
|
|
318
327
|
// Version (injected at build time for dist, read from package.json for ESM)
|
|
319
|
-
toMp4.version = '1.0.
|
|
328
|
+
toMp4.version = '1.0.9';
|
|
320
329
|
|
|
321
330
|
// Export
|
|
322
331
|
export {
|
|
@@ -336,9 +345,14 @@ export {
|
|
|
336
345
|
HlsVariant,
|
|
337
346
|
// Transcoding (browser-only)
|
|
338
347
|
transcode,
|
|
348
|
+
isWebCodecsSupported,
|
|
349
|
+
// Muxers
|
|
339
350
|
TSMuxer,
|
|
340
351
|
MP4Muxer,
|
|
352
|
+
// Parsers
|
|
341
353
|
TSParser,
|
|
342
|
-
|
|
354
|
+
MP4Parser,
|
|
355
|
+
// Remote MP4 (on-demand HLS)
|
|
356
|
+
RemoteMp4
|
|
343
357
|
};
|
|
344
358
|
export default toMp4;
|
package/src/muxers/mpegts.js
CHANGED
|
@@ -85,8 +85,9 @@ export class TSMuxer {
|
|
|
85
85
|
* @param {Uint8Array} avccData - AVCC-formatted NAL units
|
|
86
86
|
* @param {boolean} isKey - Is this a keyframe
|
|
87
87
|
* @param {number} pts90k - Presentation timestamp in 90kHz ticks
|
|
88
|
+
* @param {number} [dts90k] - Decode timestamp in 90kHz ticks (defaults to pts90k)
|
|
88
89
|
*/
|
|
89
|
-
addVideoSample(avccData, isKey, pts90k) {
|
|
90
|
+
addVideoSample(avccData, isKey, pts90k, dts90k = pts90k) {
|
|
90
91
|
const nalUnits = [];
|
|
91
92
|
|
|
92
93
|
// Add AUD (Access Unit Delimiter) at start of each access unit
|
|
@@ -102,20 +103,28 @@ export class TSMuxer {
|
|
|
102
103
|
|
|
103
104
|
// Parse AVCC NALs and convert to Annex B
|
|
104
105
|
let offset = 0;
|
|
105
|
-
|
|
106
|
+
let iterations = 0;
|
|
107
|
+
const maxIterations = 10000;
|
|
108
|
+
|
|
109
|
+
while (offset + 4 <= avccData.length && iterations < maxIterations) {
|
|
110
|
+
iterations++;
|
|
106
111
|
const len = (avccData[offset] << 24) | (avccData[offset + 1] << 16) |
|
|
107
112
|
(avccData[offset + 2] << 8) | avccData[offset + 3];
|
|
108
113
|
offset += 4;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
|
|
115
|
+
// Safety: bail on invalid NAL length
|
|
116
|
+
if (len <= 0 || len > avccData.length - offset) {
|
|
117
|
+
break;
|
|
112
118
|
}
|
|
119
|
+
|
|
120
|
+
nalUnits.push(new Uint8Array([0, 0, 0, 1]));
|
|
121
|
+
nalUnits.push(avccData.slice(offset, offset + len));
|
|
113
122
|
offset += len;
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
// Build PES packet
|
|
125
|
+
// Build PES packet with both PTS and DTS
|
|
117
126
|
const annexB = concat(nalUnits);
|
|
118
|
-
const pes = this._buildVideoPES(annexB, pts90k);
|
|
127
|
+
const pes = this._buildVideoPES(annexB, pts90k, dts90k);
|
|
119
128
|
|
|
120
129
|
// Write PAT/PMT before keyframes
|
|
121
130
|
if (isKey) {
|
|
@@ -130,8 +139,8 @@ export class TSMuxer {
|
|
|
130
139
|
this._packetizePES(audioPes, 0x102, false, audio.pts, 'audio');
|
|
131
140
|
}
|
|
132
141
|
|
|
133
|
-
// Packetize video PES into 188-byte TS packets
|
|
134
|
-
this._packetizePES(pes, 0x101, isKey,
|
|
142
|
+
// Packetize video PES into 188-byte TS packets (use DTS for PCR)
|
|
143
|
+
this._packetizePES(pes, 0x101, isKey, dts90k, 'video');
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
/**
|
|
@@ -160,16 +169,32 @@ export class TSMuxer {
|
|
|
160
169
|
|
|
161
170
|
// --- Private methods ---
|
|
162
171
|
|
|
163
|
-
_buildVideoPES(payload, pts90k) {
|
|
164
|
-
|
|
172
|
+
_buildVideoPES(payload, pts90k, dts90k) {
|
|
173
|
+
// If PTS == DTS, only write PTS (saves 5 bytes per frame)
|
|
174
|
+
const hasDts = pts90k !== dts90k;
|
|
175
|
+
const headerLen = hasDts ? 10 : 5;
|
|
176
|
+
const pes = new Uint8Array(9 + headerLen + payload.length);
|
|
177
|
+
|
|
165
178
|
pes[0] = 0; pes[1] = 0; pes[2] = 1; // Start code
|
|
166
179
|
pes[3] = 0xE0; // Stream ID (video)
|
|
167
180
|
pes[4] = 0; pes[5] = 0; // Length = 0 (unbounded)
|
|
168
|
-
pes[6] = 0x80; // Flags
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
181
|
+
pes[6] = 0x80; // Flags: data_alignment
|
|
182
|
+
|
|
183
|
+
if (hasDts) {
|
|
184
|
+
// PTS + DTS present
|
|
185
|
+
pes[7] = 0xC0; // PTS_DTS_flags = 11
|
|
186
|
+
pes[8] = 10; // Header length: 5 (PTS) + 5 (DTS)
|
|
187
|
+
this._writePTS(pes, 9, pts90k, 0x31); // PTS marker = 0011
|
|
188
|
+
this._writePTS(pes, 14, dts90k, 0x11); // DTS marker = 0001
|
|
189
|
+
pes.set(payload, 19);
|
|
190
|
+
} else {
|
|
191
|
+
// PTS only
|
|
192
|
+
pes[7] = 0x80; // PTS_DTS_flags = 10
|
|
193
|
+
pes[8] = 5; // Header length: 5 (PTS)
|
|
194
|
+
this._writePTS(pes, 9, pts90k, 0x21); // PTS marker = 0010
|
|
195
|
+
pes.set(payload, 14);
|
|
196
|
+
}
|
|
197
|
+
|
|
173
198
|
return pes;
|
|
174
199
|
}
|
|
175
200
|
|
|
@@ -220,14 +245,17 @@ export class TSMuxer {
|
|
|
220
245
|
|
|
221
246
|
pkt[3] = 0x30 | (this.cc[cc] & 0x0F);
|
|
222
247
|
pkt[4] = afLen;
|
|
223
|
-
pkt[5] = 0x50; // PCR +
|
|
248
|
+
pkt[5] = 0x50; // PCR flag + random_access_indicator
|
|
249
|
+
|
|
250
|
+
// PCR = 33-bit base (90kHz) + 6 reserved bits + 9-bit extension (27MHz)
|
|
251
|
+
// We only use the base, extension = 0
|
|
224
252
|
const pcrBase = BigInt(pts90k);
|
|
225
253
|
pkt[6] = Number((pcrBase >> 25n) & 0xFFn);
|
|
226
254
|
pkt[7] = Number((pcrBase >> 17n) & 0xFFn);
|
|
227
255
|
pkt[8] = Number((pcrBase >> 9n) & 0xFFn);
|
|
228
256
|
pkt[9] = Number((pcrBase >> 1n) & 0xFFn);
|
|
229
|
-
pkt[10] = (Number(pcrBase & 1n) << 7) | 0x7E;
|
|
230
|
-
pkt[11] = 0;
|
|
257
|
+
pkt[10] = (Number(pcrBase & 1n) << 7) | 0x7E; // LSB of base + 6 reserved (111111)
|
|
258
|
+
pkt[11] = 0; // 9-bit extension = 0
|
|
231
259
|
|
|
232
260
|
pkt.set(pes.slice(offset, offset + payloadLen), 12);
|
|
233
261
|
offset += payloadLen;
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MP4 Parser
|
|
3
|
+
*
|
|
4
|
+
* Fetch and parse MP4 files remotely using byte-range requests.
|
|
5
|
+
* Only downloads metadata (moov) upfront, then fetches segments on-demand.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { RemoteMp4 } from 'tomp4';
|
|
9
|
+
*
|
|
10
|
+
* const source = await RemoteMp4.fromUrl('https://example.com/video.mp4');
|
|
11
|
+
*
|
|
12
|
+
* // Get HLS playlists
|
|
13
|
+
* const masterPlaylist = source.getMasterPlaylist();
|
|
14
|
+
* const mediaPlaylist = source.getMediaPlaylist();
|
|
15
|
+
*
|
|
16
|
+
* // Get a segment as MPEG-TS
|
|
17
|
+
* const tsData = await source.getSegment(0);
|
|
18
|
+
*
|
|
19
|
+
* @module remote
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
readUint32, boxType, findBox,
|
|
24
|
+
analyzeTrack, buildSampleTable, buildSegments, calculateByteRanges
|
|
25
|
+
} from '../parsers/mp4.js';
|
|
26
|
+
|
|
27
|
+
import { TSMuxer } from '../muxers/mpegts.js';
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Configuration
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
const DEFAULT_SEGMENT_DURATION = 4; // seconds
|
|
34
|
+
const FETCH_TIMEOUT = 30000; // 30 seconds
|
|
35
|
+
const MAX_HEADER_SIZE = 256 * 1024; // 256KB for initial probe
|
|
36
|
+
const MAX_TAIL_SIZE = 2 * 1024 * 1024; // 2MB for moov at end
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Fetch Utilities
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
async function fetchWithTimeout(url, options = {}) {
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(url, {
|
|
48
|
+
...options,
|
|
49
|
+
signal: controller.signal
|
|
50
|
+
});
|
|
51
|
+
return response;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err.name === 'AbortError') {
|
|
54
|
+
throw new Error(`Fetch timeout after ${FETCH_TIMEOUT}ms`);
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
} finally {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchRange(url, start, end) {
|
|
63
|
+
const response = await fetchWithTimeout(url, {
|
|
64
|
+
headers: { 'Range': `bytes=${start}-${end}` }
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok && response.status !== 206) {
|
|
67
|
+
throw new Error(`HTTP ${response.status}`);
|
|
68
|
+
}
|
|
69
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function fetchFileSize(url) {
|
|
73
|
+
const response = await fetchWithTimeout(url, { method: 'HEAD' });
|
|
74
|
+
return parseInt(response.headers.get('content-length'), 10);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// ADTS Wrapper for AAC
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
function wrapADTS(aacData, sampleRate, channels) {
|
|
82
|
+
const sampleRateIndex = [96000, 88200, 64000, 48000, 44100, 32000, 24000,
|
|
83
|
+
22050, 16000, 12000, 11025, 8000, 7350].indexOf(sampleRate);
|
|
84
|
+
const frameLength = aacData.length + 7;
|
|
85
|
+
|
|
86
|
+
const adts = new Uint8Array(7 + aacData.length);
|
|
87
|
+
adts[0] = 0xFF;
|
|
88
|
+
adts[1] = 0xF1;
|
|
89
|
+
adts[2] = ((2 - 1) << 6) | ((sampleRateIndex < 0 ? 4 : sampleRateIndex) << 2) | ((channels >> 2) & 0x01);
|
|
90
|
+
adts[3] = ((channels & 0x03) << 6) | ((frameLength >> 11) & 0x03);
|
|
91
|
+
adts[4] = (frameLength >> 3) & 0xFF;
|
|
92
|
+
adts[5] = ((frameLength & 0x07) << 5) | 0x1F;
|
|
93
|
+
adts[6] = 0xFC;
|
|
94
|
+
adts.set(aacData, 7);
|
|
95
|
+
|
|
96
|
+
return adts;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// RemoteMp4 Class
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Remote MP4 source with on-demand HLS segment generation
|
|
105
|
+
*/
|
|
106
|
+
export class RemoteMp4 {
|
|
107
|
+
/**
|
|
108
|
+
* Create a RemoteMp4 instance from a URL
|
|
109
|
+
* @param {string} url - URL to the MP4 file
|
|
110
|
+
* @param {object} options - Options
|
|
111
|
+
* @param {number} options.segmentDuration - Target segment duration (default 4s)
|
|
112
|
+
* @param {function} options.onProgress - Progress callback
|
|
113
|
+
* @returns {Promise<RemoteMp4>}
|
|
114
|
+
*/
|
|
115
|
+
static async fromUrl(url, options = {}) {
|
|
116
|
+
const instance = new RemoteMp4(url, options);
|
|
117
|
+
await instance._init();
|
|
118
|
+
return instance;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
constructor(url, options = {}) {
|
|
122
|
+
this.url = url;
|
|
123
|
+
this.segmentDuration = options.segmentDuration || DEFAULT_SEGMENT_DURATION;
|
|
124
|
+
this.onProgress = options.onProgress || (() => {});
|
|
125
|
+
|
|
126
|
+
// Populated by _init()
|
|
127
|
+
this.fileSize = 0;
|
|
128
|
+
this.moov = null;
|
|
129
|
+
this.videoTrack = null;
|
|
130
|
+
this.audioTrack = null;
|
|
131
|
+
this.videoSamples = [];
|
|
132
|
+
this.audioSamples = [];
|
|
133
|
+
this.segments = [];
|
|
134
|
+
|
|
135
|
+
// Computed properties
|
|
136
|
+
this.duration = 0;
|
|
137
|
+
this.width = 0;
|
|
138
|
+
this.height = 0;
|
|
139
|
+
this.hasAudio = false;
|
|
140
|
+
this.hasBframes = false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async _init() {
|
|
144
|
+
this.onProgress('Fetching metadata...');
|
|
145
|
+
|
|
146
|
+
// Get file size
|
|
147
|
+
this.fileSize = await fetchFileSize(this.url);
|
|
148
|
+
|
|
149
|
+
// Find and fetch moov box
|
|
150
|
+
this.moov = await this._findMoov();
|
|
151
|
+
|
|
152
|
+
// Parse tracks using shared parser
|
|
153
|
+
let trackOffset = 8;
|
|
154
|
+
while (trackOffset < this.moov.length) {
|
|
155
|
+
const trak = findBox(this.moov, 'trak', trackOffset);
|
|
156
|
+
if (!trak) break;
|
|
157
|
+
|
|
158
|
+
const track = analyzeTrack(this.moov, trak.offset, trak.size);
|
|
159
|
+
if (track) {
|
|
160
|
+
if (track.type === 'vide' && !this.videoTrack) {
|
|
161
|
+
this.videoTrack = track;
|
|
162
|
+
this.videoSamples = buildSampleTable(track);
|
|
163
|
+
this.duration = track.durationSeconds;
|
|
164
|
+
this.width = track.width;
|
|
165
|
+
this.height = track.height;
|
|
166
|
+
this.hasBframes = track.ctts && track.ctts.length > 0;
|
|
167
|
+
} else if (track.type === 'soun' && !this.audioTrack) {
|
|
168
|
+
this.audioTrack = track;
|
|
169
|
+
this.audioSamples = buildSampleTable(track);
|
|
170
|
+
this.hasAudio = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
trackOffset = trak.offset + trak.size;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!this.videoTrack) {
|
|
177
|
+
throw new Error('No video track found');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Build segments
|
|
181
|
+
this.segments = buildSegments(this.videoSamples, this.segmentDuration);
|
|
182
|
+
|
|
183
|
+
this.onProgress(`Parsed: ${this.duration.toFixed(1)}s, ${this.segments.length} segments`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async _findMoov() {
|
|
187
|
+
const headerSize = Math.min(MAX_HEADER_SIZE, this.fileSize);
|
|
188
|
+
const header = await fetchRange(this.url, 0, headerSize - 1);
|
|
189
|
+
|
|
190
|
+
// Scan header for boxes
|
|
191
|
+
let offset = 0;
|
|
192
|
+
while (offset < header.length - 8) {
|
|
193
|
+
const size = readUint32(header, offset);
|
|
194
|
+
const type = boxType(header, offset + 4);
|
|
195
|
+
|
|
196
|
+
if (size === 0 || size > this.fileSize) break;
|
|
197
|
+
|
|
198
|
+
if (type === 'moov') {
|
|
199
|
+
// moov in header - fetch complete if needed
|
|
200
|
+
if (offset + size <= header.length) {
|
|
201
|
+
return header.slice(offset, offset + size);
|
|
202
|
+
}
|
|
203
|
+
return fetchRange(this.url, offset, offset + size - 1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (type === 'mdat') {
|
|
207
|
+
// mdat at start means moov is at end
|
|
208
|
+
const moovOffset = offset + size;
|
|
209
|
+
if (moovOffset < this.fileSize) {
|
|
210
|
+
const tailSize = Math.min(MAX_TAIL_SIZE, this.fileSize - moovOffset);
|
|
211
|
+
const tail = await fetchRange(this.url, moovOffset, moovOffset + tailSize - 1);
|
|
212
|
+
const moov = findBox(tail, 'moov');
|
|
213
|
+
if (moov) {
|
|
214
|
+
if (moov.size <= tail.length) {
|
|
215
|
+
return tail.slice(moov.offset, moov.offset + moov.size);
|
|
216
|
+
}
|
|
217
|
+
return fetchRange(this.url, moovOffset + moov.offset,
|
|
218
|
+
moovOffset + moov.offset + moov.size - 1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
offset += size;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Try end of file as fallback
|
|
228
|
+
const tailSize = Math.min(MAX_TAIL_SIZE, this.fileSize);
|
|
229
|
+
const tail = await fetchRange(this.url, this.fileSize - tailSize, this.fileSize - 1);
|
|
230
|
+
const moov = findBox(tail, 'moov');
|
|
231
|
+
|
|
232
|
+
if (moov) {
|
|
233
|
+
const moovStart = this.fileSize - tailSize + moov.offset;
|
|
234
|
+
return fetchRange(this.url, moovStart, moovStart + moov.size - 1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check for fragmented MP4
|
|
238
|
+
const moof = findBox(header, 'moof');
|
|
239
|
+
if (moof) {
|
|
240
|
+
throw new Error('Fragmented MP4 (fMP4) not supported');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw new Error('Could not find moov box');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
// Public API
|
|
248
|
+
// ===========================================================================
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get source information
|
|
252
|
+
*/
|
|
253
|
+
getInfo() {
|
|
254
|
+
return {
|
|
255
|
+
url: this.url,
|
|
256
|
+
fileSize: this.fileSize,
|
|
257
|
+
duration: this.duration,
|
|
258
|
+
width: this.width,
|
|
259
|
+
height: this.height,
|
|
260
|
+
hasAudio: this.hasAudio,
|
|
261
|
+
hasBframes: this.hasBframes,
|
|
262
|
+
segmentCount: this.segments.length,
|
|
263
|
+
videoSampleCount: this.videoSamples.length,
|
|
264
|
+
audioSampleCount: this.audioSamples.length,
|
|
265
|
+
keyframeCount: this.videoTrack?.stss?.length || 0
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get segment definitions
|
|
271
|
+
*/
|
|
272
|
+
getSegments() {
|
|
273
|
+
return this.segments.map(s => ({
|
|
274
|
+
index: s.index,
|
|
275
|
+
startTime: s.startTime,
|
|
276
|
+
endTime: s.endTime,
|
|
277
|
+
duration: s.duration
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Generate HLS master playlist
|
|
283
|
+
*/
|
|
284
|
+
getMasterPlaylist(baseUrl = '') {
|
|
285
|
+
const bandwidth = Math.round(
|
|
286
|
+
(this.videoSamples.reduce((s, v) => s + v.size, 0) / this.duration) * 8
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const resolution = this.width && this.height ?
|
|
290
|
+
`,RESOLUTION=${this.width}x${this.height}` : '';
|
|
291
|
+
|
|
292
|
+
return `#EXTM3U
|
|
293
|
+
#EXT-X-VERSION:3
|
|
294
|
+
#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth}${resolution}
|
|
295
|
+
${baseUrl}playlist.m3u8
|
|
296
|
+
`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Generate HLS media playlist
|
|
301
|
+
*/
|
|
302
|
+
getMediaPlaylist(baseUrl = '') {
|
|
303
|
+
let playlist = `#EXTM3U
|
|
304
|
+
#EXT-X-VERSION:3
|
|
305
|
+
#EXT-X-TARGETDURATION:${Math.ceil(this.segmentDuration)}
|
|
306
|
+
#EXT-X-MEDIA-SEQUENCE:0
|
|
307
|
+
#EXT-X-PLAYLIST-TYPE:VOD
|
|
308
|
+
`;
|
|
309
|
+
|
|
310
|
+
for (const segment of this.segments) {
|
|
311
|
+
playlist += `#EXTINF:${segment.duration.toFixed(6)},\n${baseUrl}segment${segment.index}.ts\n`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
playlist += '#EXT-X-ENDLIST\n';
|
|
315
|
+
return playlist;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get a segment as MPEG-TS data
|
|
320
|
+
* @param {number} index - Segment index
|
|
321
|
+
* @returns {Promise<Uint8Array>} MPEG-TS segment data
|
|
322
|
+
*/
|
|
323
|
+
async getSegment(index) {
|
|
324
|
+
const segment = this.segments[index];
|
|
325
|
+
if (!segment) {
|
|
326
|
+
throw new Error(`Segment ${index} not found`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Get samples for this segment
|
|
330
|
+
const videoSamples = this.videoSamples.slice(segment.videoStart, segment.videoEnd);
|
|
331
|
+
const audioSamples = this.audioSamples.filter(
|
|
332
|
+
s => s.time >= segment.startTime && s.time < segment.endTime
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// Fetch video data using byte ranges
|
|
336
|
+
const videoRanges = calculateByteRanges(videoSamples);
|
|
337
|
+
const videoData = await this._fetchRanges(videoRanges);
|
|
338
|
+
|
|
339
|
+
// Map video sample data
|
|
340
|
+
const parsedVideoSamples = videoSamples.map(sample => {
|
|
341
|
+
const range = videoRanges.find(r => r.samples.includes(sample));
|
|
342
|
+
const data = videoData.get(range);
|
|
343
|
+
const relOffset = sample.offset - range.start;
|
|
344
|
+
return {
|
|
345
|
+
...sample,
|
|
346
|
+
data: data.slice(relOffset, relOffset + sample.size)
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Fetch and map audio data
|
|
351
|
+
let parsedAudioSamples = [];
|
|
352
|
+
if (audioSamples.length > 0) {
|
|
353
|
+
const audioRanges = calculateByteRanges(audioSamples);
|
|
354
|
+
const audioData = await this._fetchRanges(audioRanges);
|
|
355
|
+
|
|
356
|
+
parsedAudioSamples = audioSamples.map(sample => {
|
|
357
|
+
const range = audioRanges.find(r => r.samples.includes(sample));
|
|
358
|
+
const data = audioData.get(range);
|
|
359
|
+
const relOffset = sample.offset - range.start;
|
|
360
|
+
return {
|
|
361
|
+
...sample,
|
|
362
|
+
data: data.slice(relOffset, relOffset + sample.size)
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Build MPEG-TS segment
|
|
368
|
+
return this._buildTsSegment(parsedVideoSamples, parsedAudioSamples);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async _fetchRanges(ranges) {
|
|
372
|
+
const results = new Map();
|
|
373
|
+
|
|
374
|
+
// Fetch ranges in parallel
|
|
375
|
+
await Promise.all(ranges.map(async range => {
|
|
376
|
+
const data = await fetchRange(this.url, range.start, range.end - 1);
|
|
377
|
+
results.set(range, data);
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
return results;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
_buildTsSegment(videoSamples, audioSamples) {
|
|
384
|
+
const muxer = new TSMuxer();
|
|
385
|
+
|
|
386
|
+
if (this.videoTrack?.codecConfig) {
|
|
387
|
+
muxer.setSpsPps(
|
|
388
|
+
this.videoTrack.codecConfig.sps[0],
|
|
389
|
+
this.videoTrack.codecConfig.pps[0]
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
muxer.setHasAudio(audioSamples.length > 0);
|
|
394
|
+
|
|
395
|
+
const PTS_PER_SECOND = 90000;
|
|
396
|
+
const sampleRate = this.audioTrack?.audioConfig?.sampleRate || 44100;
|
|
397
|
+
const channels = this.audioTrack?.audioConfig?.channels || 2;
|
|
398
|
+
|
|
399
|
+
// Add audio samples
|
|
400
|
+
for (const sample of audioSamples) {
|
|
401
|
+
const dts90k = Math.round((sample.dts ?? sample.time) * PTS_PER_SECOND);
|
|
402
|
+
const adts = wrapADTS(sample.data, sampleRate, channels);
|
|
403
|
+
muxer.addAudioSample(adts, dts90k);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Add video samples with PTS and DTS
|
|
407
|
+
for (const sample of videoSamples) {
|
|
408
|
+
const pts90k = Math.round((sample.pts ?? sample.time) * PTS_PER_SECOND);
|
|
409
|
+
const dts90k = Math.round((sample.dts ?? sample.time) * PTS_PER_SECOND);
|
|
410
|
+
muxer.addVideoSample(sample.data, sample.isKeyframe, pts90k, dts90k);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
muxer.flush();
|
|
414
|
+
return muxer.build();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export default RemoteMp4;
|
package/src/transcode.js
CHANGED
|
@@ -551,11 +551,9 @@ function createAvcC(sps, pps) {
|
|
|
551
551
|
// ============================================
|
|
552
552
|
|
|
553
553
|
/**
|
|
554
|
-
* Transcode video using WebCodecs (browser-only)
|
|
554
|
+
* Transcode MPEG-TS video using WebCodecs (browser-only)
|
|
555
555
|
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
* @param {Uint8Array} data - Input video data (MPEG-TS or MP4)
|
|
556
|
+
* @param {Uint8Array} tsData - Input MPEG-TS data
|
|
559
557
|
* @param {Object} [options] - Transcode options
|
|
560
558
|
* @param {number} [options.width] - Output width (default: same as input)
|
|
561
559
|
* @param {number} [options.height] - Output height (default: same as input)
|
|
@@ -565,14 +563,14 @@ function createAvcC(sps, pps) {
|
|
|
565
563
|
* @returns {Promise<Uint8Array>} - Transcoded MPEG-TS data
|
|
566
564
|
*
|
|
567
565
|
* @example
|
|
568
|
-
* const output = await transcode(
|
|
566
|
+
* const output = await transcode(tsData, {
|
|
569
567
|
* width: 640,
|
|
570
568
|
* height: 360,
|
|
571
569
|
* bitrate: 1_000_000,
|
|
572
570
|
* onProgress: msg => console.log(msg)
|
|
573
571
|
* });
|
|
574
572
|
*/
|
|
575
|
-
export async function transcode(
|
|
573
|
+
export async function transcode(tsData, options = {}) {
|
|
576
574
|
requireWebCodecs();
|
|
577
575
|
|
|
578
576
|
const log = options.onProgress || (() => {});
|
|
@@ -581,37 +579,12 @@ export async function transcode(data, options = {}) {
|
|
|
581
579
|
keyFrameInterval = 30
|
|
582
580
|
} = options;
|
|
583
581
|
|
|
584
|
-
// Detect input format and parse
|
|
585
|
-
let parser;
|
|
586
|
-
let sps = null, pps = null;
|
|
587
582
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
// Get SPS/PPS directly from MP4 parser
|
|
595
|
-
sps = parser.sps;
|
|
596
|
-
pps = parser.pps;
|
|
597
|
-
} else if (isMpegTs(data)) {
|
|
598
|
-
log('Parsing input MPEG-TS...');
|
|
599
|
-
parser = new TSParser();
|
|
600
|
-
parser.parse(data);
|
|
601
|
-
parser.finalize();
|
|
602
|
-
|
|
603
|
-
// Find SPS/PPS in NAL units
|
|
604
|
-
for (const au of parser.videoAccessUnits) {
|
|
605
|
-
for (const nal of au.nalUnits) {
|
|
606
|
-
const t = nal[0] & 0x1f;
|
|
607
|
-
if (t === 7 && !sps) sps = nal;
|
|
608
|
-
if (t === 8 && !pps) pps = nal;
|
|
609
|
-
}
|
|
610
|
-
if (sps && pps) break;
|
|
611
|
-
}
|
|
612
|
-
} else {
|
|
613
|
-
throw new Error('Unsupported input format. Expected MPEG-TS or MP4.');
|
|
614
|
-
}
|
|
583
|
+
// Parse input TS
|
|
584
|
+
log('Parsing input MPEG-TS...');
|
|
585
|
+
const parser = new TSParser();
|
|
586
|
+
parser.parse(tsData);
|
|
587
|
+
parser.finalize();
|
|
615
588
|
|
|
616
589
|
if (!parser.videoAccessUnits || parser.videoAccessUnits.length === 0) {
|
|
617
590
|
throw new Error('No video found in input');
|
|
@@ -625,6 +598,17 @@ export async function transcode(data, options = {}) {
|
|
|
625
598
|
log(`Found ${parser.audioAccessUnits.length} audio frames (will passthrough)`);
|
|
626
599
|
}
|
|
627
600
|
|
|
601
|
+
// Find SPS/PPS
|
|
602
|
+
let sps = null, pps = null;
|
|
603
|
+
for (const au of parser.videoAccessUnits) {
|
|
604
|
+
for (const nal of au.nalUnits) {
|
|
605
|
+
const t = nal[0] & 0x1f;
|
|
606
|
+
if (t === 7 && !sps) sps = nal;
|
|
607
|
+
if (t === 8 && !pps) pps = nal;
|
|
608
|
+
}
|
|
609
|
+
if (sps && pps) break;
|
|
610
|
+
}
|
|
611
|
+
|
|
628
612
|
if (!sps || !pps) {
|
|
629
613
|
throw new Error('No SPS/PPS found in input');
|
|
630
614
|
}
|