@invintusmedia/tomp4 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +95 -0
- package/dist/tomp4.js +1617 -0
- package/package.json +43 -0
- package/src/fmp4-to-mp4.js +375 -0
- package/src/hls.js +280 -0
- package/src/index.js +311 -0
- package/src/ts-to-mp4.js +1154 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@invintusmedia/tomp4",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Convert MPEG-TS and fMP4 streams to standard MP4 - pure JavaScript, zero dependencies",
|
|
5
|
+
"main": "dist/tomp4.js",
|
|
6
|
+
"module": "src/index.js",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "node build.js",
|
|
14
|
+
"dev": "npx serve . -p 3000",
|
|
15
|
+
"release": "npm run build && git add -A && git commit -m \"Build v$(node -p \"require('./package.json').version\")\" && git push",
|
|
16
|
+
"release:patch": "npm version patch --no-git-tag-version && npm run release",
|
|
17
|
+
"release:minor": "npm version minor --no-git-tag-version && npm run release",
|
|
18
|
+
"release:major": "npm version major --no-git-tag-version && npm run release",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/TVWIT/toMp4.js.git"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mp4",
|
|
27
|
+
"mpeg-ts",
|
|
28
|
+
"mpegts",
|
|
29
|
+
"fmp4",
|
|
30
|
+
"video",
|
|
31
|
+
"converter",
|
|
32
|
+
"transmux",
|
|
33
|
+
"hls",
|
|
34
|
+
"streaming"
|
|
35
|
+
],
|
|
36
|
+
"author": "Invintus Media",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/TVWIT/toMp4.js/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://tvwit.github.io/toMp4.js/"
|
|
42
|
+
}
|
|
43
|
+
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fragmented MP4 to Standard MP4 Converter
|
|
3
|
+
* Pure JavaScript - no dependencies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================
|
|
7
|
+
// Box Utilities
|
|
8
|
+
// ============================================
|
|
9
|
+
function parseBoxes(data, offset = 0, end = data.byteLength) {
|
|
10
|
+
const boxes = [];
|
|
11
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
12
|
+
while (offset < end) {
|
|
13
|
+
if (offset + 8 > end) break;
|
|
14
|
+
const size = view.getUint32(offset);
|
|
15
|
+
const type = String.fromCharCode(data[offset+4], data[offset+5], data[offset+6], data[offset+7]);
|
|
16
|
+
if (size === 0 || size < 8) break;
|
|
17
|
+
boxes.push({ type, offset, size, data: data.subarray(offset, offset + size) });
|
|
18
|
+
offset += size;
|
|
19
|
+
}
|
|
20
|
+
return boxes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findBox(boxes, type) {
|
|
24
|
+
for (const box of boxes) if (box.type === type) return box;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseChildBoxes(box, headerSize = 8) {
|
|
29
|
+
return parseBoxes(box.data, headerSize, box.size);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createBox(type, ...payloads) {
|
|
33
|
+
let size = 8;
|
|
34
|
+
for (const p of payloads) size += p.byteLength;
|
|
35
|
+
const result = new Uint8Array(size);
|
|
36
|
+
const view = new DataView(result.buffer);
|
|
37
|
+
view.setUint32(0, size);
|
|
38
|
+
result[4] = type.charCodeAt(0); result[5] = type.charCodeAt(1); result[6] = type.charCodeAt(2); result[7] = type.charCodeAt(3);
|
|
39
|
+
let offset = 8;
|
|
40
|
+
for (const p of payloads) { result.set(p, offset); offset += p.byteLength; }
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================
|
|
45
|
+
// trun/tfhd Parsing
|
|
46
|
+
// ============================================
|
|
47
|
+
function parseTrunWithOffset(trunData) {
|
|
48
|
+
const view = new DataView(trunData.buffer, trunData.byteOffset, trunData.byteLength);
|
|
49
|
+
const version = trunData[8];
|
|
50
|
+
const flags = (trunData[9] << 16) | (trunData[10] << 8) | trunData[11];
|
|
51
|
+
const sampleCount = view.getUint32(12);
|
|
52
|
+
let offset = 16, dataOffset = 0;
|
|
53
|
+
if (flags & 0x1) { dataOffset = view.getInt32(offset); offset += 4; }
|
|
54
|
+
if (flags & 0x4) offset += 4;
|
|
55
|
+
const samples = [];
|
|
56
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
57
|
+
const sample = {};
|
|
58
|
+
if (flags & 0x100) { sample.duration = view.getUint32(offset); offset += 4; }
|
|
59
|
+
if (flags & 0x200) { sample.size = view.getUint32(offset); offset += 4; }
|
|
60
|
+
if (flags & 0x400) { sample.flags = view.getUint32(offset); offset += 4; }
|
|
61
|
+
if (flags & 0x800) { sample.compositionTimeOffset = version === 0 ? view.getUint32(offset) : view.getInt32(offset); offset += 4; }
|
|
62
|
+
samples.push(sample);
|
|
63
|
+
}
|
|
64
|
+
return { samples, dataOffset };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseTfhd(tfhdData) {
|
|
68
|
+
return new DataView(tfhdData.buffer, tfhdData.byteOffset, tfhdData.byteLength).getUint32(12);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================
|
|
72
|
+
// Moov Rebuilding
|
|
73
|
+
// ============================================
|
|
74
|
+
function rebuildMvhd(mvhdBox, duration) {
|
|
75
|
+
const data = new Uint8Array(mvhdBox.data);
|
|
76
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
77
|
+
const version = data[8];
|
|
78
|
+
const durationOffset = version === 0 ? 24 : 32;
|
|
79
|
+
if (version === 0) view.setUint32(durationOffset, duration);
|
|
80
|
+
else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, duration); }
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rebuildTkhd(tkhdBox, trackInfo, maxDuration) {
|
|
85
|
+
const data = new Uint8Array(tkhdBox.data);
|
|
86
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
87
|
+
const version = data[8];
|
|
88
|
+
let trackDuration = maxDuration;
|
|
89
|
+
if (trackInfo) { trackDuration = 0; for (const s of trackInfo.samples) trackDuration += s.duration || 0; }
|
|
90
|
+
if (version === 0) view.setUint32(28, trackDuration);
|
|
91
|
+
else { view.setUint32(36, 0); view.setUint32(40, trackDuration); }
|
|
92
|
+
return data;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rebuildMdhd(mdhdBox, trackInfo, maxDuration) {
|
|
96
|
+
const data = new Uint8Array(mdhdBox.data);
|
|
97
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
98
|
+
const version = data[8];
|
|
99
|
+
let trackDuration = 0;
|
|
100
|
+
if (trackInfo) for (const s of trackInfo.samples) trackDuration += s.duration || 0;
|
|
101
|
+
const durationOffset = version === 0 ? 24 : 32;
|
|
102
|
+
if (version === 0) view.setUint32(durationOffset, trackDuration);
|
|
103
|
+
else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, trackDuration); }
|
|
104
|
+
return data;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rebuildStbl(stblBox, trackInfo) {
|
|
108
|
+
const stblChildren = parseChildBoxes(stblBox);
|
|
109
|
+
const newParts = [];
|
|
110
|
+
for (const child of stblChildren) if (child.type === 'stsd') { newParts.push(child.data); break; }
|
|
111
|
+
const samples = trackInfo?.samples || [];
|
|
112
|
+
const chunkOffsets = trackInfo?.chunkOffsets || [];
|
|
113
|
+
|
|
114
|
+
// stts
|
|
115
|
+
const sttsEntries = [];
|
|
116
|
+
let curDur = null, count = 0;
|
|
117
|
+
for (const s of samples) {
|
|
118
|
+
const d = s.duration || 0;
|
|
119
|
+
if (d === curDur) count++;
|
|
120
|
+
else { if (curDur !== null) sttsEntries.push({ count, duration: curDur }); curDur = d; count = 1; }
|
|
121
|
+
}
|
|
122
|
+
if (curDur !== null) sttsEntries.push({ count, duration: curDur });
|
|
123
|
+
const sttsData = new Uint8Array(8 + sttsEntries.length * 8);
|
|
124
|
+
const sttsView = new DataView(sttsData.buffer);
|
|
125
|
+
sttsView.setUint32(4, sttsEntries.length);
|
|
126
|
+
let off = 8;
|
|
127
|
+
for (const e of sttsEntries) { sttsView.setUint32(off, e.count); sttsView.setUint32(off + 4, e.duration); off += 8; }
|
|
128
|
+
newParts.push(createBox('stts', sttsData));
|
|
129
|
+
|
|
130
|
+
// stsc
|
|
131
|
+
const stscEntries = [];
|
|
132
|
+
if (chunkOffsets.length > 0) {
|
|
133
|
+
let currentSampleCount = chunkOffsets[0].sampleCount, firstChunk = 1;
|
|
134
|
+
for (let i = 1; i <= chunkOffsets.length; i++) {
|
|
135
|
+
const sampleCount = i < chunkOffsets.length ? chunkOffsets[i].sampleCount : -1;
|
|
136
|
+
if (sampleCount !== currentSampleCount) {
|
|
137
|
+
stscEntries.push({ firstChunk, samplesPerChunk: currentSampleCount, sampleDescriptionIndex: 1 });
|
|
138
|
+
firstChunk = i + 1; currentSampleCount = sampleCount;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} else stscEntries.push({ firstChunk: 1, samplesPerChunk: samples.length, sampleDescriptionIndex: 1 });
|
|
142
|
+
const stscData = new Uint8Array(8 + stscEntries.length * 12);
|
|
143
|
+
const stscView = new DataView(stscData.buffer);
|
|
144
|
+
stscView.setUint32(4, stscEntries.length);
|
|
145
|
+
off = 8;
|
|
146
|
+
for (const e of stscEntries) { stscView.setUint32(off, e.firstChunk); stscView.setUint32(off + 4, e.samplesPerChunk); stscView.setUint32(off + 8, e.sampleDescriptionIndex); off += 12; }
|
|
147
|
+
newParts.push(createBox('stsc', stscData));
|
|
148
|
+
|
|
149
|
+
// stsz
|
|
150
|
+
const stszData = new Uint8Array(12 + samples.length * 4);
|
|
151
|
+
const stszView = new DataView(stszData.buffer);
|
|
152
|
+
stszView.setUint32(8, samples.length);
|
|
153
|
+
off = 12;
|
|
154
|
+
for (const s of samples) { stszView.setUint32(off, s.size || 0); off += 4; }
|
|
155
|
+
newParts.push(createBox('stsz', stszData));
|
|
156
|
+
|
|
157
|
+
// stco
|
|
158
|
+
const numChunks = chunkOffsets.length || 1;
|
|
159
|
+
const stcoData = new Uint8Array(8 + numChunks * 4);
|
|
160
|
+
const stcoView = new DataView(stcoData.buffer);
|
|
161
|
+
stcoView.setUint32(4, numChunks);
|
|
162
|
+
for (let i = 0; i < numChunks; i++) stcoView.setUint32(8 + i * 4, chunkOffsets[i]?.offset || 0);
|
|
163
|
+
newParts.push(createBox('stco', stcoData));
|
|
164
|
+
|
|
165
|
+
// ctts
|
|
166
|
+
const hasCtts = samples.some(s => s.compositionTimeOffset);
|
|
167
|
+
if (hasCtts) {
|
|
168
|
+
const cttsEntries = [];
|
|
169
|
+
let curOff = null; count = 0;
|
|
170
|
+
for (const s of samples) {
|
|
171
|
+
const o = s.compositionTimeOffset || 0;
|
|
172
|
+
if (o === curOff) count++;
|
|
173
|
+
else { if (curOff !== null) cttsEntries.push({ count, offset: curOff }); curOff = o; count = 1; }
|
|
174
|
+
}
|
|
175
|
+
if (curOff !== null) cttsEntries.push({ count, offset: curOff });
|
|
176
|
+
const cttsData = new Uint8Array(8 + cttsEntries.length * 8);
|
|
177
|
+
const cttsView = new DataView(cttsData.buffer);
|
|
178
|
+
cttsView.setUint32(4, cttsEntries.length);
|
|
179
|
+
off = 8;
|
|
180
|
+
for (const e of cttsEntries) { cttsView.setUint32(off, e.count); cttsView.setInt32(off + 4, e.offset); off += 8; }
|
|
181
|
+
newParts.push(createBox('ctts', cttsData));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// stss
|
|
185
|
+
const syncSamples = [];
|
|
186
|
+
for (let i = 0; i < samples.length; i++) {
|
|
187
|
+
const flags = samples[i].flags;
|
|
188
|
+
if (flags !== undefined) { if (!((flags >> 16) & 0x1)) syncSamples.push(i + 1); }
|
|
189
|
+
}
|
|
190
|
+
if (syncSamples.length > 0 && syncSamples.length < samples.length) {
|
|
191
|
+
const stssData = new Uint8Array(8 + syncSamples.length * 4);
|
|
192
|
+
const stssView = new DataView(stssData.buffer);
|
|
193
|
+
stssView.setUint32(4, syncSamples.length);
|
|
194
|
+
off = 8;
|
|
195
|
+
for (const n of syncSamples) { stssView.setUint32(off, n); off += 4; }
|
|
196
|
+
newParts.push(createBox('stss', stssData));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return createBox('stbl', ...newParts);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function rebuildMinf(minfBox, trackInfo) {
|
|
203
|
+
const minfChildren = parseChildBoxes(minfBox);
|
|
204
|
+
const newParts = [];
|
|
205
|
+
for (const child of minfChildren) {
|
|
206
|
+
if (child.type === 'stbl') newParts.push(rebuildStbl(child, trackInfo));
|
|
207
|
+
else newParts.push(child.data);
|
|
208
|
+
}
|
|
209
|
+
return createBox('minf', ...newParts);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function rebuildMdia(mdiaBox, trackInfo, maxDuration) {
|
|
213
|
+
const mdiaChildren = parseChildBoxes(mdiaBox);
|
|
214
|
+
const newParts = [];
|
|
215
|
+
for (const child of mdiaChildren) {
|
|
216
|
+
if (child.type === 'minf') newParts.push(rebuildMinf(child, trackInfo));
|
|
217
|
+
else if (child.type === 'mdhd') newParts.push(rebuildMdhd(child, trackInfo, maxDuration));
|
|
218
|
+
else newParts.push(child.data);
|
|
219
|
+
}
|
|
220
|
+
return createBox('mdia', ...newParts);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function rebuildTrak(trakBox, trackIdMap, maxDuration) {
|
|
224
|
+
const trakChildren = parseChildBoxes(trakBox);
|
|
225
|
+
let trackId = 1;
|
|
226
|
+
for (const child of trakChildren) {
|
|
227
|
+
if (child.type === 'tkhd') {
|
|
228
|
+
const view = new DataView(child.data.buffer, child.data.byteOffset, child.data.byteLength);
|
|
229
|
+
trackId = child.data[8] === 0 ? view.getUint32(20) : view.getUint32(28);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const trackInfo = trackIdMap.get(trackId);
|
|
233
|
+
const newParts = [];
|
|
234
|
+
let hasEdts = false;
|
|
235
|
+
for (const child of trakChildren) {
|
|
236
|
+
if (child.type === 'edts') { hasEdts = true; newParts.push(child.data); }
|
|
237
|
+
else if (child.type === 'mdia') newParts.push(rebuildMdia(child, trackInfo, maxDuration));
|
|
238
|
+
else if (child.type === 'tkhd') newParts.push(rebuildTkhd(child, trackInfo, maxDuration));
|
|
239
|
+
else newParts.push(child.data);
|
|
240
|
+
}
|
|
241
|
+
if (!hasEdts && trackInfo) {
|
|
242
|
+
let trackDuration = 0;
|
|
243
|
+
for (const s of trackInfo.samples) trackDuration += s.duration || 0;
|
|
244
|
+
const elstData = new Uint8Array(20);
|
|
245
|
+
const elstView = new DataView(elstData.buffer);
|
|
246
|
+
elstView.setUint32(4, 1); elstView.setUint32(8, maxDuration); elstView.setInt32(12, 0); elstView.setInt16(16, 1);
|
|
247
|
+
const elst = createBox('elst', elstData);
|
|
248
|
+
const edts = createBox('edts', elst);
|
|
249
|
+
const tkhdIndex = newParts.findIndex(p => p.length >= 8 && String.fromCharCode(p[4], p[5], p[6], p[7]) === 'tkhd');
|
|
250
|
+
if (tkhdIndex >= 0) newParts.splice(tkhdIndex + 1, 0, edts);
|
|
251
|
+
}
|
|
252
|
+
return createBox('trak', ...newParts);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function updateStcoOffsets(output, ftypSize, moovSize) {
|
|
256
|
+
const mdatContentOffset = ftypSize + moovSize + 8;
|
|
257
|
+
const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
|
|
258
|
+
function scan(start, end) {
|
|
259
|
+
let pos = start;
|
|
260
|
+
while (pos + 8 <= end) {
|
|
261
|
+
const size = view.getUint32(pos);
|
|
262
|
+
if (size < 8) break;
|
|
263
|
+
const type = String.fromCharCode(output[pos+4], output[pos+5], output[pos+6], output[pos+7]);
|
|
264
|
+
if (type === 'stco') {
|
|
265
|
+
const entryCount = view.getUint32(pos + 12);
|
|
266
|
+
for (let i = 0; i < entryCount; i++) {
|
|
267
|
+
const entryPos = pos + 16 + i * 4;
|
|
268
|
+
view.setUint32(entryPos, mdatContentOffset + view.getUint32(entryPos));
|
|
269
|
+
}
|
|
270
|
+
} else if (['moov', 'trak', 'mdia', 'minf', 'stbl'].includes(type)) scan(pos + 8, pos + size);
|
|
271
|
+
pos += size;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
scan(0, output.byteLength);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Convert fragmented MP4 to standard MP4
|
|
279
|
+
* @param {Uint8Array} fmp4Data - fMP4 data
|
|
280
|
+
* @returns {Uint8Array} Standard MP4 data
|
|
281
|
+
*/
|
|
282
|
+
export function convertFmp4ToMp4(fmp4Data) {
|
|
283
|
+
const boxes = parseBoxes(fmp4Data);
|
|
284
|
+
const ftyp = findBox(boxes, 'ftyp');
|
|
285
|
+
const moov = findBox(boxes, 'moov');
|
|
286
|
+
if (!ftyp || !moov) throw new Error('Invalid fMP4: missing ftyp or moov');
|
|
287
|
+
|
|
288
|
+
const moovChildren = parseChildBoxes(moov);
|
|
289
|
+
const originalTrackIds = [];
|
|
290
|
+
for (const child of moovChildren) {
|
|
291
|
+
if (child.type === 'trak') {
|
|
292
|
+
const trakChildren = parseChildBoxes(child);
|
|
293
|
+
for (const tc of trakChildren) {
|
|
294
|
+
if (tc.type === 'tkhd') {
|
|
295
|
+
const view = new DataView(tc.data.buffer, tc.data.byteOffset, tc.data.byteLength);
|
|
296
|
+
originalTrackIds.push(tc.data[8] === 0 ? view.getUint32(20) : view.getUint32(28));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const tracks = new Map();
|
|
303
|
+
const mdatChunks = [];
|
|
304
|
+
let combinedMdatOffset = 0;
|
|
305
|
+
|
|
306
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
307
|
+
const box = boxes[i];
|
|
308
|
+
if (box.type === 'moof') {
|
|
309
|
+
const moofChildren = parseChildBoxes(box);
|
|
310
|
+
const moofStart = box.offset;
|
|
311
|
+
let nextMdatOffset = 0;
|
|
312
|
+
for (let j = i + 1; j < boxes.length; j++) {
|
|
313
|
+
if (boxes[j].type === 'mdat') { nextMdatOffset = boxes[j].offset; break; }
|
|
314
|
+
if (boxes[j].type === 'moof') break;
|
|
315
|
+
}
|
|
316
|
+
for (const child of moofChildren) {
|
|
317
|
+
if (child.type === 'traf') {
|
|
318
|
+
const trafChildren = parseChildBoxes(child);
|
|
319
|
+
const tfhd = findBox(trafChildren, 'tfhd');
|
|
320
|
+
const trun = findBox(trafChildren, 'trun');
|
|
321
|
+
if (tfhd && trun) {
|
|
322
|
+
const trackId = parseTfhd(tfhd.data);
|
|
323
|
+
const { samples, dataOffset } = parseTrunWithOffset(trun.data);
|
|
324
|
+
if (!tracks.has(trackId)) tracks.set(trackId, { samples: [], chunkOffsets: [] });
|
|
325
|
+
const track = tracks.get(trackId);
|
|
326
|
+
const chunkOffset = combinedMdatOffset + (moofStart + dataOffset) - (nextMdatOffset + 8);
|
|
327
|
+
track.chunkOffsets.push({ offset: chunkOffset, sampleCount: samples.length });
|
|
328
|
+
track.samples.push(...samples);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} else if (box.type === 'mdat') {
|
|
333
|
+
mdatChunks.push({ data: box.data.subarray(8), offset: combinedMdatOffset });
|
|
334
|
+
combinedMdatOffset += box.data.subarray(8).byteLength;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const totalMdatSize = mdatChunks.reduce((sum, c) => sum + c.data.byteLength, 0);
|
|
339
|
+
const combinedMdat = new Uint8Array(totalMdatSize);
|
|
340
|
+
for (const chunk of mdatChunks) combinedMdat.set(chunk.data, chunk.offset);
|
|
341
|
+
|
|
342
|
+
const trackIdMap = new Map();
|
|
343
|
+
const fmp4TrackIds = Array.from(tracks.keys()).sort((a, b) => a - b);
|
|
344
|
+
for (let i = 0; i < fmp4TrackIds.length && i < originalTrackIds.length; i++) {
|
|
345
|
+
trackIdMap.set(originalTrackIds[i], tracks.get(fmp4TrackIds[i]));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let maxDuration = 0;
|
|
349
|
+
for (const [, track] of tracks) {
|
|
350
|
+
let dur = 0;
|
|
351
|
+
for (const s of track.samples) dur += s.duration || 0;
|
|
352
|
+
maxDuration = Math.max(maxDuration, dur);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const newMoovParts = [];
|
|
356
|
+
for (const child of moovChildren) {
|
|
357
|
+
if (child.type === 'mvex') continue;
|
|
358
|
+
if (child.type === 'trak') newMoovParts.push(rebuildTrak(child, trackIdMap, maxDuration));
|
|
359
|
+
else if (child.type === 'mvhd') newMoovParts.push(rebuildMvhd(child, maxDuration));
|
|
360
|
+
else newMoovParts.push(child.data);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const newMoov = createBox('moov', ...newMoovParts);
|
|
364
|
+
const newMdat = createBox('mdat', combinedMdat);
|
|
365
|
+
const output = new Uint8Array(ftyp.size + newMoov.byteLength + newMdat.byteLength);
|
|
366
|
+
output.set(ftyp.data, 0);
|
|
367
|
+
output.set(newMoov, ftyp.size);
|
|
368
|
+
output.set(newMdat, ftyp.size + newMoov.byteLength);
|
|
369
|
+
updateStcoOffsets(output, ftyp.size, newMoov.byteLength);
|
|
370
|
+
|
|
371
|
+
return output;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export default convertFmp4ToMp4;
|
|
375
|
+
|
package/src/hls.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HLS Playlist Parser and Downloader
|
|
3
|
+
* Handles master playlists, variant selection, and segment downloading
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Represents a quality variant in an HLS stream
|
|
8
|
+
*/
|
|
9
|
+
class HlsVariant {
|
|
10
|
+
constructor({ bandwidth, resolution, codecs, url, name }) {
|
|
11
|
+
this.bandwidth = bandwidth;
|
|
12
|
+
this.resolution = resolution;
|
|
13
|
+
this.codecs = codecs;
|
|
14
|
+
this.url = url;
|
|
15
|
+
this.name = name || this._generateName();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_generateName() {
|
|
19
|
+
if (this.resolution) return this.resolution;
|
|
20
|
+
if (this.bandwidth) return `${Math.round(this.bandwidth / 1000)}kbps`;
|
|
21
|
+
return 'unknown';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Bandwidth in kbps */
|
|
25
|
+
get kbps() {
|
|
26
|
+
return Math.round(this.bandwidth / 1000);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Bandwidth in Mbps */
|
|
30
|
+
get mbps() {
|
|
31
|
+
return (this.bandwidth / 1000000).toFixed(2);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Represents a parsed HLS stream with quality variants
|
|
37
|
+
*/
|
|
38
|
+
class HlsStream {
|
|
39
|
+
constructor(masterUrl, variants, segments = null) {
|
|
40
|
+
this.masterUrl = masterUrl;
|
|
41
|
+
this.variants = variants;
|
|
42
|
+
this.segments = segments;
|
|
43
|
+
this._selectedVariant = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether this is a master playlist with multiple qualities */
|
|
47
|
+
get isMaster() {
|
|
48
|
+
return this.variants.length > 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Get all available qualities sorted by bandwidth (highest first) */
|
|
52
|
+
get qualities() {
|
|
53
|
+
return [...this.variants].sort((a, b) => b.bandwidth - a.bandwidth);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Get the highest quality variant */
|
|
57
|
+
get highest() {
|
|
58
|
+
return this.qualities[0] || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Get the lowest quality variant */
|
|
62
|
+
get lowest() {
|
|
63
|
+
const q = this.qualities;
|
|
64
|
+
return q[q.length - 1] || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Currently selected variant */
|
|
68
|
+
get selected() {
|
|
69
|
+
return this._selectedVariant || this.highest;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Select a quality variant
|
|
74
|
+
* @param {string|number|HlsVariant} selector - 'highest', 'lowest', bandwidth number, or variant object
|
|
75
|
+
* @returns {HlsStream} this for chaining
|
|
76
|
+
*/
|
|
77
|
+
select(selector) {
|
|
78
|
+
if (selector === 'highest') {
|
|
79
|
+
this._selectedVariant = this.highest;
|
|
80
|
+
} else if (selector === 'lowest') {
|
|
81
|
+
this._selectedVariant = this.lowest;
|
|
82
|
+
} else if (typeof selector === 'number') {
|
|
83
|
+
// Find by bandwidth (closest match)
|
|
84
|
+
this._selectedVariant = this.qualities.reduce((best, v) =>
|
|
85
|
+
Math.abs(v.bandwidth - selector) < Math.abs(best.bandwidth - selector) ? v : best
|
|
86
|
+
);
|
|
87
|
+
} else if (selector instanceof HlsVariant) {
|
|
88
|
+
this._selectedVariant = selector;
|
|
89
|
+
} else if (typeof selector === 'string' && selector.includes('x')) {
|
|
90
|
+
// Match by resolution string like "1920x1080"
|
|
91
|
+
this._selectedVariant = this.variants.find(v => v.resolution === selector) || this.highest;
|
|
92
|
+
}
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Convert relative URL to absolute
|
|
99
|
+
*/
|
|
100
|
+
function toAbsoluteUrl(relative, base) {
|
|
101
|
+
if (relative.startsWith('http://') || relative.startsWith('https://')) {
|
|
102
|
+
return relative;
|
|
103
|
+
}
|
|
104
|
+
return new URL(relative, base).href;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse an HLS playlist text
|
|
109
|
+
* @param {string} text - Playlist content
|
|
110
|
+
* @param {string} baseUrl - Base URL for resolving relative paths
|
|
111
|
+
* @returns {{ variants: HlsVariant[], segments: string[] }}
|
|
112
|
+
*/
|
|
113
|
+
function parsePlaylistText(text, baseUrl) {
|
|
114
|
+
const lines = text.split('\n').map(l => l.trim());
|
|
115
|
+
const variants = [];
|
|
116
|
+
const segments = [];
|
|
117
|
+
|
|
118
|
+
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
const line = lines[i];
|
|
120
|
+
|
|
121
|
+
// Parse master playlist variants
|
|
122
|
+
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
|
123
|
+
const attrs = line.substring(18);
|
|
124
|
+
const bandwidth = parseInt(attrs.match(/BANDWIDTH=(\d+)/)?.[1] || '0');
|
|
125
|
+
const resolution = attrs.match(/RESOLUTION=(\d+x\d+)/)?.[1] || null;
|
|
126
|
+
const codecs = attrs.match(/CODECS="([^"]+)"/)?.[1] || null;
|
|
127
|
+
|
|
128
|
+
// Next non-comment line is the URL
|
|
129
|
+
let urlLine = lines[i + 1];
|
|
130
|
+
if (urlLine && !urlLine.startsWith('#')) {
|
|
131
|
+
variants.push(new HlsVariant({
|
|
132
|
+
bandwidth,
|
|
133
|
+
resolution,
|
|
134
|
+
codecs,
|
|
135
|
+
url: toAbsoluteUrl(urlLine, baseUrl)
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Parse media playlist segments
|
|
141
|
+
if (line && !line.startsWith('#')) {
|
|
142
|
+
// It's a segment URL
|
|
143
|
+
if (!lines.some(l => l.startsWith('#EXT-X-STREAM-INF'))) {
|
|
144
|
+
segments.push(toAbsoluteUrl(line, baseUrl));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { variants, segments };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse an HLS playlist from URL
|
|
154
|
+
* If it's a master playlist, returns variants. If media playlist, returns segments.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} url - HLS playlist URL
|
|
157
|
+
* @param {object} [options] - Options
|
|
158
|
+
* @param {function} [options.onProgress] - Progress callback
|
|
159
|
+
* @returns {Promise<HlsStream>}
|
|
160
|
+
*/
|
|
161
|
+
async function parseHls(url, options = {}) {
|
|
162
|
+
const log = options.onProgress || (() => {});
|
|
163
|
+
|
|
164
|
+
log('Fetching playlist...');
|
|
165
|
+
const response = await fetch(url);
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`Failed to fetch playlist: ${response.status} ${response.statusText}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const text = await response.text();
|
|
171
|
+
const { variants, segments } = parsePlaylistText(text, url);
|
|
172
|
+
|
|
173
|
+
if (variants.length > 0) {
|
|
174
|
+
// Master playlist
|
|
175
|
+
log(`Found ${variants.length} quality variants`);
|
|
176
|
+
return new HlsStream(url, variants);
|
|
177
|
+
} else if (segments.length > 0) {
|
|
178
|
+
// Media playlist (no variants)
|
|
179
|
+
log(`Found ${segments.length} segments`);
|
|
180
|
+
return new HlsStream(url, [], segments);
|
|
181
|
+
} else {
|
|
182
|
+
throw new Error('Invalid HLS playlist: no variants or segments found');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Download segments from an HLS stream
|
|
188
|
+
*
|
|
189
|
+
* @param {HlsStream|string} source - HlsStream object or URL
|
|
190
|
+
* @param {object} [options] - Options
|
|
191
|
+
* @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth number
|
|
192
|
+
* @param {number} [options.maxSegments] - Max segments to download (default: all)
|
|
193
|
+
* @param {function} [options.onProgress] - Progress callback
|
|
194
|
+
* @returns {Promise<Uint8Array>} Combined segment data
|
|
195
|
+
*/
|
|
196
|
+
async function downloadHls(source, options = {}) {
|
|
197
|
+
const log = options.onProgress || (() => {});
|
|
198
|
+
|
|
199
|
+
// Parse if given a URL string
|
|
200
|
+
let stream = source;
|
|
201
|
+
if (typeof source === 'string') {
|
|
202
|
+
stream = await parseHls(source, options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Select quality if specified
|
|
206
|
+
if (options.quality) {
|
|
207
|
+
stream.select(options.quality);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Get segments
|
|
211
|
+
let segments = stream.segments;
|
|
212
|
+
|
|
213
|
+
// If master playlist, fetch the selected variant's media playlist
|
|
214
|
+
if (stream.isMaster && stream.selected) {
|
|
215
|
+
const variant = stream.selected;
|
|
216
|
+
log(`Selected: ${variant.name} (${variant.kbps} kbps)`);
|
|
217
|
+
|
|
218
|
+
const mediaResponse = await fetch(variant.url);
|
|
219
|
+
if (!mediaResponse.ok) {
|
|
220
|
+
throw new Error(`Failed to fetch media playlist: ${mediaResponse.status}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const mediaText = await mediaResponse.text();
|
|
224
|
+
const { segments: mediaSegments } = parsePlaylistText(mediaText, variant.url);
|
|
225
|
+
segments = mediaSegments;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!segments || segments.length === 0) {
|
|
229
|
+
throw new Error('No segments found in playlist');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Limit segments if specified
|
|
233
|
+
const toDownload = options.maxSegments ? segments.slice(0, options.maxSegments) : segments;
|
|
234
|
+
log(`Downloading ${toDownload.length} segment${toDownload.length > 1 ? 's' : ''}...`);
|
|
235
|
+
|
|
236
|
+
// Download all segments in parallel
|
|
237
|
+
const buffers = await Promise.all(
|
|
238
|
+
toDownload.map(async (url, i) => {
|
|
239
|
+
const resp = await fetch(url);
|
|
240
|
+
if (!resp.ok) {
|
|
241
|
+
throw new Error(`Segment ${i + 1} failed: ${resp.status}`);
|
|
242
|
+
}
|
|
243
|
+
return new Uint8Array(await resp.arrayBuffer());
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Combine into single buffer
|
|
248
|
+
const totalSize = buffers.reduce((sum, buf) => sum + buf.length, 0);
|
|
249
|
+
const combined = new Uint8Array(totalSize);
|
|
250
|
+
let offset = 0;
|
|
251
|
+
for (const buf of buffers) {
|
|
252
|
+
combined.set(buf, offset);
|
|
253
|
+
offset += buf.length;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
log(`Downloaded ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
|
|
257
|
+
return combined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Check if a URL looks like an HLS playlist
|
|
262
|
+
* @param {string} url - URL to check
|
|
263
|
+
* @returns {boolean}
|
|
264
|
+
*/
|
|
265
|
+
function isHlsUrl(url) {
|
|
266
|
+
if (typeof url !== 'string') return false;
|
|
267
|
+
const lower = url.toLowerCase();
|
|
268
|
+
return lower.includes('.m3u8') || lower.includes('format=m3u8');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export {
|
|
272
|
+
HlsStream,
|
|
273
|
+
HlsVariant,
|
|
274
|
+
parseHls,
|
|
275
|
+
downloadHls,
|
|
276
|
+
isHlsUrl,
|
|
277
|
+
parsePlaylistText,
|
|
278
|
+
toAbsoluteUrl
|
|
279
|
+
};
|
|
280
|
+
|