@invintusmedia/tomp4 1.0.9 → 1.1.1
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 +665 -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
package/src/parsers/mpegts.js
CHANGED
|
@@ -69,7 +69,7 @@ export class TSParser {
|
|
|
69
69
|
this.videoHeight = null;
|
|
70
70
|
this.debug = { packets: 0, patFound: false, pmtFound: false };
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
|
|
73
73
|
/**
|
|
74
74
|
* Parse MPEG-TS data
|
|
75
75
|
* @param {Uint8Array} data - MPEG-TS data
|
|
@@ -79,7 +79,7 @@ export class TSParser {
|
|
|
79
79
|
// Find first sync byte
|
|
80
80
|
while (offset < data.byteLength && data[offset] !== TS_SYNC_BYTE) offset++;
|
|
81
81
|
if (offset > 0) this.debug.skippedBytes = offset;
|
|
82
|
-
|
|
82
|
+
|
|
83
83
|
// Parse all packets
|
|
84
84
|
while (offset + TS_PACKET_SIZE <= data.byteLength) {
|
|
85
85
|
if (data[offset] !== TS_SYNC_BYTE) {
|
|
@@ -94,7 +94,7 @@ export class TSParser {
|
|
|
94
94
|
offset += TS_PACKET_SIZE;
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
|
|
98
98
|
parsePacket(packet) {
|
|
99
99
|
const pid = ((packet[1] & 0x1F) << 8) | packet[2];
|
|
100
100
|
const payloadStart = (packet[1] & 0x40) !== 0;
|
|
@@ -107,23 +107,23 @@ export class TSParser {
|
|
|
107
107
|
}
|
|
108
108
|
if (adaptationField === 2) return;
|
|
109
109
|
if (payloadOffset >= packet.length) return;
|
|
110
|
-
|
|
110
|
+
|
|
111
111
|
const payload = packet.subarray(payloadOffset);
|
|
112
112
|
if (payload.length === 0) return;
|
|
113
|
-
|
|
113
|
+
|
|
114
114
|
if (pid === PAT_PID) this.parsePAT(payload);
|
|
115
115
|
else if (pid === this.pmtPid) this.parsePMT(payload);
|
|
116
116
|
else if (pid === this.videoPid) this.collectPES(payload, payloadStart, 'video');
|
|
117
117
|
else if (pid === this.audioPid) this.collectPES(payload, payloadStart, 'audio');
|
|
118
118
|
}
|
|
119
|
-
|
|
119
|
+
|
|
120
120
|
parsePAT(payload) {
|
|
121
121
|
if (payload.length < 12) return;
|
|
122
122
|
let offset = payload[0] + 1;
|
|
123
123
|
if (offset + 8 > payload.length) return;
|
|
124
|
-
|
|
124
|
+
|
|
125
125
|
offset += 8;
|
|
126
|
-
|
|
126
|
+
|
|
127
127
|
while (offset + 4 <= payload.length - 4) {
|
|
128
128
|
const programNum = (payload[offset] << 8) | payload[offset + 1];
|
|
129
129
|
const pmtPid = ((payload[offset + 2] & 0x1F) << 8) | payload[offset + 3];
|
|
@@ -135,29 +135,29 @@ export class TSParser {
|
|
|
135
135
|
offset += 4;
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
-
|
|
138
|
+
|
|
139
139
|
parsePMT(payload) {
|
|
140
140
|
if (payload.length < 16) return;
|
|
141
141
|
let offset = payload[0] + 1;
|
|
142
142
|
if (offset + 12 > payload.length) return;
|
|
143
|
-
|
|
143
|
+
|
|
144
144
|
offset++;
|
|
145
145
|
const sectionLength = ((payload[offset] & 0x0F) << 8) | payload[offset + 1];
|
|
146
146
|
offset += 2;
|
|
147
147
|
offset += 5;
|
|
148
148
|
offset += 2;
|
|
149
|
-
|
|
149
|
+
|
|
150
150
|
if (offset + 2 > payload.length) return;
|
|
151
151
|
const programInfoLength = ((payload[offset] & 0x0F) << 8) | payload[offset + 1];
|
|
152
152
|
offset += 2 + programInfoLength;
|
|
153
|
-
|
|
153
|
+
|
|
154
154
|
const sectionEnd = Math.min(payload.length - 4, 1 + payload[0] + 3 + sectionLength - 4);
|
|
155
|
-
|
|
155
|
+
|
|
156
156
|
while (offset + 5 <= sectionEnd) {
|
|
157
157
|
const streamType = payload[offset];
|
|
158
158
|
const elementaryPid = ((payload[offset + 1] & 0x1F) << 8) | payload[offset + 2];
|
|
159
159
|
const esInfoLength = ((payload[offset + 3] & 0x0F) << 8) | payload[offset + 4];
|
|
160
|
-
|
|
160
|
+
|
|
161
161
|
if (!this.videoPid && (streamType === 0x01 || streamType === 0x02 || streamType === 0x1B || streamType === 0x24)) {
|
|
162
162
|
this.videoPid = elementaryPid;
|
|
163
163
|
this.videoStreamType = streamType;
|
|
@@ -167,11 +167,11 @@ export class TSParser {
|
|
|
167
167
|
this.audioPid = elementaryPid;
|
|
168
168
|
this.audioStreamType = streamType;
|
|
169
169
|
}
|
|
170
|
-
|
|
170
|
+
|
|
171
171
|
offset += 5 + esInfoLength;
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
-
|
|
174
|
+
|
|
175
175
|
collectPES(payload, isStart, type) {
|
|
176
176
|
const buffer = type === 'video' ? this.videoPesBuffer : this.audioPesBuffer;
|
|
177
177
|
if (isStart) {
|
|
@@ -181,7 +181,7 @@ export class TSParser {
|
|
|
181
181
|
}
|
|
182
182
|
buffer.push(payload.slice());
|
|
183
183
|
}
|
|
184
|
-
|
|
184
|
+
|
|
185
185
|
processPES(pesData, type) {
|
|
186
186
|
if (pesData.length < 9) return;
|
|
187
187
|
if (pesData[0] !== 0 || pesData[1] !== 0 || pesData[2] !== 1) return;
|
|
@@ -194,7 +194,7 @@ export class TSParser {
|
|
|
194
194
|
if (type === 'video') this.processVideoPayload(payload, pts, dts);
|
|
195
195
|
else this.processAudioPayload(payload, pts);
|
|
196
196
|
}
|
|
197
|
-
|
|
197
|
+
|
|
198
198
|
parsePTS(data, offset) {
|
|
199
199
|
return ((data[offset] & 0x0E) << 29) |
|
|
200
200
|
((data[offset + 1]) << 22) |
|
|
@@ -202,7 +202,7 @@ export class TSParser {
|
|
|
202
202
|
((data[offset + 3]) << 7) |
|
|
203
203
|
((data[offset + 4] & 0xFE) >> 1);
|
|
204
204
|
}
|
|
205
|
-
|
|
205
|
+
|
|
206
206
|
processVideoPayload(payload, pts, dts) {
|
|
207
207
|
const nalUnits = this.extractNALUnits(payload);
|
|
208
208
|
if (nalUnits.length > 0 && pts !== null) {
|
|
@@ -211,7 +211,7 @@ export class TSParser {
|
|
|
211
211
|
this.videoDts.push(dts !== null ? dts : pts);
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
|
-
|
|
214
|
+
|
|
215
215
|
extractNALUnits(data) {
|
|
216
216
|
const nalUnits = [];
|
|
217
217
|
let i = 0;
|
|
@@ -223,8 +223,8 @@ export class TSParser {
|
|
|
223
223
|
if (startCodeLen > 0) {
|
|
224
224
|
let end = i + startCodeLen;
|
|
225
225
|
while (end < data.length - 2) {
|
|
226
|
-
if (data[end] === 0 && data[end + 1] === 0 &&
|
|
227
|
-
|
|
226
|
+
if (data[end] === 0 && data[end + 1] === 0 &&
|
|
227
|
+
(data[end + 2] === 1 || (data[end + 2] === 0 && end + 3 < data.length && data[end + 3] === 1))) break;
|
|
228
228
|
end++;
|
|
229
229
|
}
|
|
230
230
|
if (end >= data.length - 2) end = data.length;
|
|
@@ -238,13 +238,13 @@ export class TSParser {
|
|
|
238
238
|
}
|
|
239
239
|
return nalUnits;
|
|
240
240
|
}
|
|
241
|
-
|
|
241
|
+
|
|
242
242
|
processAudioPayload(payload, pts) {
|
|
243
243
|
const frames = this.extractADTSFrames(payload);
|
|
244
|
-
|
|
244
|
+
|
|
245
245
|
this.debug.audioPesCount = (this.debug.audioPesCount || 0) + 1;
|
|
246
246
|
this.debug.audioFramesInPes = (this.debug.audioFramesInPes || 0) + frames.length;
|
|
247
|
-
|
|
247
|
+
|
|
248
248
|
if (pts !== null) {
|
|
249
249
|
this.lastAudioPts = pts;
|
|
250
250
|
} else if (this.lastAudioPts !== null) {
|
|
@@ -253,10 +253,10 @@ export class TSParser {
|
|
|
253
253
|
this.debug.audioSkipped = (this.debug.audioSkipped || 0) + frames.length;
|
|
254
254
|
return;
|
|
255
255
|
}
|
|
256
|
-
|
|
256
|
+
|
|
257
257
|
const sampleRate = this.audioSampleRate || 48000;
|
|
258
258
|
const ptsIncrement = Math.round(1024 * 90000 / sampleRate);
|
|
259
|
-
|
|
259
|
+
|
|
260
260
|
for (const frame of frames) {
|
|
261
261
|
this.audioAccessUnits.push({ data: frame.data, pts });
|
|
262
262
|
this.audioPts.push(pts);
|
|
@@ -264,13 +264,13 @@ export class TSParser {
|
|
|
264
264
|
this.lastAudioPts = pts;
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
|
-
|
|
267
|
+
|
|
268
268
|
extractADTSFrames(data) {
|
|
269
269
|
const SAMPLE_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
|
|
270
|
-
|
|
270
|
+
|
|
271
271
|
const frames = [];
|
|
272
272
|
let i = 0;
|
|
273
|
-
|
|
273
|
+
|
|
274
274
|
if (this.adtsPartial && this.adtsPartial.length > 0) {
|
|
275
275
|
const combined = new Uint8Array(this.adtsPartial.length + data.length);
|
|
276
276
|
combined.set(this.adtsPartial);
|
|
@@ -278,12 +278,12 @@ export class TSParser {
|
|
|
278
278
|
data = combined;
|
|
279
279
|
this.adtsPartial = null;
|
|
280
280
|
}
|
|
281
|
-
|
|
281
|
+
|
|
282
282
|
while (i < data.length - 7) {
|
|
283
283
|
if (data[i] === 0xFF && (data[i + 1] & 0xF0) === 0xF0) {
|
|
284
284
|
const protectionAbsent = data[i + 1] & 0x01;
|
|
285
285
|
const frameLength = ((data[i + 3] & 0x03) << 11) | (data[i + 4] << 3) | ((data[i + 5] & 0xE0) >> 5);
|
|
286
|
-
|
|
286
|
+
|
|
287
287
|
if (!this.audioSampleRate && frameLength > 0) {
|
|
288
288
|
const samplingFreqIndex = ((data[i + 2] & 0x3C) >> 2);
|
|
289
289
|
const channelConfig = ((data[i + 2] & 0x01) << 2) | ((data[i + 3] & 0xC0) >> 6);
|
|
@@ -292,7 +292,7 @@ export class TSParser {
|
|
|
292
292
|
this.audioChannels = channelConfig;
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
|
-
|
|
295
|
+
|
|
296
296
|
if (frameLength > 0) {
|
|
297
297
|
if (i + frameLength <= data.length) {
|
|
298
298
|
const headerSize = protectionAbsent ? 7 : 9;
|
|
@@ -309,7 +309,7 @@ export class TSParser {
|
|
|
309
309
|
}
|
|
310
310
|
return frames;
|
|
311
311
|
}
|
|
312
|
-
|
|
312
|
+
|
|
313
313
|
concatenateBuffers(buffers) {
|
|
314
314
|
const totalLength = buffers.reduce((sum, b) => sum + b.length, 0);
|
|
315
315
|
const result = new Uint8Array(totalLength);
|
|
@@ -317,29 +317,29 @@ export class TSParser {
|
|
|
317
317
|
for (const buf of buffers) { result.set(buf, offset); offset += buf.length; }
|
|
318
318
|
return result;
|
|
319
319
|
}
|
|
320
|
-
|
|
320
|
+
|
|
321
321
|
/**
|
|
322
322
|
* Finalize parsing - process remaining buffers and normalize timestamps
|
|
323
323
|
*/
|
|
324
324
|
finalize() {
|
|
325
325
|
if (this.videoPesBuffer.length > 0) this.processPES(this.concatenateBuffers(this.videoPesBuffer), 'video');
|
|
326
326
|
if (this.audioPesBuffer.length > 0) this.processPES(this.concatenateBuffers(this.audioPesBuffer), 'audio');
|
|
327
|
-
|
|
327
|
+
|
|
328
328
|
this.normalizeTimestamps();
|
|
329
329
|
}
|
|
330
|
-
|
|
330
|
+
|
|
331
331
|
normalizeTimestamps() {
|
|
332
332
|
let minPts = Infinity;
|
|
333
|
-
|
|
333
|
+
|
|
334
334
|
if (this.videoPts.length > 0) {
|
|
335
335
|
minPts = Math.min(minPts, Math.min(...this.videoPts));
|
|
336
336
|
}
|
|
337
337
|
if (this.audioPts.length > 0) {
|
|
338
338
|
minPts = Math.min(minPts, Math.min(...this.audioPts));
|
|
339
339
|
}
|
|
340
|
-
|
|
340
|
+
|
|
341
341
|
if (minPts === Infinity || minPts === 0) return;
|
|
342
|
-
|
|
342
|
+
|
|
343
343
|
for (let i = 0; i < this.videoPts.length; i++) {
|
|
344
344
|
this.videoPts[i] -= minPts;
|
|
345
345
|
}
|
|
@@ -349,7 +349,7 @@ export class TSParser {
|
|
|
349
349
|
for (let i = 0; i < this.audioPts.length; i++) {
|
|
350
350
|
this.audioPts[i] -= minPts;
|
|
351
351
|
}
|
|
352
|
-
|
|
352
|
+
|
|
353
353
|
for (const au of this.videoAccessUnits) {
|
|
354
354
|
au.pts -= minPts;
|
|
355
355
|
au.dts -= minPts;
|
|
@@ -357,7 +357,7 @@ export class TSParser {
|
|
|
357
357
|
for (const au of this.audioAccessUnits) {
|
|
358
358
|
au.pts -= minPts;
|
|
359
359
|
}
|
|
360
|
-
|
|
360
|
+
|
|
361
361
|
this.debug.timestampOffset = minPts;
|
|
362
362
|
this.debug.timestampNormalized = true;
|
|
363
363
|
}
|
package/src/remote/index.js
CHANGED
|
@@ -42,7 +42,7 @@ const MAX_TAIL_SIZE = 2 * 1024 * 1024; // 2MB for moov at end
|
|
|
42
42
|
async function fetchWithTimeout(url, options = {}) {
|
|
43
43
|
const controller = new AbortController();
|
|
44
44
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
45
|
-
|
|
45
|
+
|
|
46
46
|
try {
|
|
47
47
|
const response = await fetch(url, {
|
|
48
48
|
...options,
|
|
@@ -79,10 +79,10 @@ async function fetchFileSize(url) {
|
|
|
79
79
|
// ============================================================================
|
|
80
80
|
|
|
81
81
|
function wrapADTS(aacData, sampleRate, channels) {
|
|
82
|
-
const sampleRateIndex = [96000, 88200, 64000, 48000, 44100, 32000, 24000,
|
|
83
|
-
|
|
82
|
+
const sampleRateIndex = [96000, 88200, 64000, 48000, 44100, 32000, 24000,
|
|
83
|
+
22050, 16000, 12000, 11025, 8000, 7350].indexOf(sampleRate);
|
|
84
84
|
const frameLength = aacData.length + 7;
|
|
85
|
-
|
|
85
|
+
|
|
86
86
|
const adts = new Uint8Array(7 + aacData.length);
|
|
87
87
|
adts[0] = 0xFF;
|
|
88
88
|
adts[1] = 0xF1;
|
|
@@ -92,7 +92,7 @@ function wrapADTS(aacData, sampleRate, channels) {
|
|
|
92
92
|
adts[5] = ((frameLength & 0x07) << 5) | 0x1F;
|
|
93
93
|
adts[6] = 0xFC;
|
|
94
94
|
adts.set(aacData, 7);
|
|
95
|
-
|
|
95
|
+
|
|
96
96
|
return adts;
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -117,12 +117,12 @@ export class RemoteMp4 {
|
|
|
117
117
|
await instance._init();
|
|
118
118
|
return instance;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
|
|
121
121
|
constructor(url, options = {}) {
|
|
122
122
|
this.url = url;
|
|
123
123
|
this.segmentDuration = options.segmentDuration || DEFAULT_SEGMENT_DURATION;
|
|
124
|
-
this.onProgress = options.onProgress || (() => {});
|
|
125
|
-
|
|
124
|
+
this.onProgress = options.onProgress || (() => { });
|
|
125
|
+
|
|
126
126
|
// Populated by _init()
|
|
127
127
|
this.fileSize = 0;
|
|
128
128
|
this.moov = null;
|
|
@@ -131,7 +131,7 @@ export class RemoteMp4 {
|
|
|
131
131
|
this.videoSamples = [];
|
|
132
132
|
this.audioSamples = [];
|
|
133
133
|
this.segments = [];
|
|
134
|
-
|
|
134
|
+
|
|
135
135
|
// Computed properties
|
|
136
136
|
this.duration = 0;
|
|
137
137
|
this.width = 0;
|
|
@@ -139,22 +139,23 @@ export class RemoteMp4 {
|
|
|
139
139
|
this.hasAudio = false;
|
|
140
140
|
this.hasBframes = false;
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
|
|
143
143
|
async _init() {
|
|
144
144
|
this.onProgress('Fetching metadata...');
|
|
145
|
-
|
|
145
|
+
|
|
146
146
|
// Get file size
|
|
147
147
|
this.fileSize = await fetchFileSize(this.url);
|
|
148
|
-
|
|
148
|
+
|
|
149
|
+
|
|
149
150
|
// Find and fetch moov box
|
|
150
151
|
this.moov = await this._findMoov();
|
|
151
|
-
|
|
152
|
+
|
|
152
153
|
// Parse tracks using shared parser
|
|
153
154
|
let trackOffset = 8;
|
|
154
155
|
while (trackOffset < this.moov.length) {
|
|
155
156
|
const trak = findBox(this.moov, 'trak', trackOffset);
|
|
156
157
|
if (!trak) break;
|
|
157
|
-
|
|
158
|
+
|
|
158
159
|
const track = analyzeTrack(this.moov, trak.offset, trak.size);
|
|
159
160
|
if (track) {
|
|
160
161
|
if (track.type === 'vide' && !this.videoTrack) {
|
|
@@ -172,29 +173,54 @@ export class RemoteMp4 {
|
|
|
172
173
|
}
|
|
173
174
|
trackOffset = trak.offset + trak.size;
|
|
174
175
|
}
|
|
175
|
-
|
|
176
|
+
|
|
176
177
|
if (!this.videoTrack) {
|
|
177
178
|
throw new Error('No video track found');
|
|
178
179
|
}
|
|
179
|
-
|
|
180
|
+
|
|
181
|
+
// Filter out samples that are beyond the file size (truncated file support)
|
|
182
|
+
const originalVideoCount = this.videoSamples.length;
|
|
183
|
+
this.videoSamples = this.videoSamples.filter(s => (s.offset + s.size) <= this.fileSize);
|
|
184
|
+
|
|
185
|
+
if (this.videoSamples.length < originalVideoCount) {
|
|
186
|
+
console.warn(`⚠️ File is truncated! content-length=${this.fileSize}. Keeping ${this.videoSamples.length}/${originalVideoCount} video samples.`);
|
|
187
|
+
|
|
188
|
+
// Update duration based on last available sample
|
|
189
|
+
if (this.videoSamples.length > 0) {
|
|
190
|
+
const lastSample = this.videoSamples[this.videoSamples.length - 1];
|
|
191
|
+
// sample.time and duration are in seconds (from mp4 parser)
|
|
192
|
+
this.duration = lastSample.time + (lastSample.duration || 0);
|
|
193
|
+
} else {
|
|
194
|
+
this.duration = 0;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Filter audio samples
|
|
199
|
+
const originalAudioCount = this.audioSamples.length;
|
|
200
|
+
this.audioSamples = this.audioSamples.filter(s => (s.offset + s.size) <= this.fileSize);
|
|
201
|
+
|
|
202
|
+
if (this.audioSamples.length < originalAudioCount) {
|
|
203
|
+
console.warn(`⚠️ Audio truncated. Keeping ${this.audioSamples.length}/${originalAudioCount} samples.`);
|
|
204
|
+
}
|
|
205
|
+
|
|
180
206
|
// Build segments
|
|
181
207
|
this.segments = buildSegments(this.videoSamples, this.segmentDuration);
|
|
182
|
-
|
|
208
|
+
|
|
183
209
|
this.onProgress(`Parsed: ${this.duration.toFixed(1)}s, ${this.segments.length} segments`);
|
|
184
210
|
}
|
|
185
|
-
|
|
211
|
+
|
|
186
212
|
async _findMoov() {
|
|
187
213
|
const headerSize = Math.min(MAX_HEADER_SIZE, this.fileSize);
|
|
188
214
|
const header = await fetchRange(this.url, 0, headerSize - 1);
|
|
189
|
-
|
|
215
|
+
|
|
190
216
|
// Scan header for boxes
|
|
191
217
|
let offset = 0;
|
|
192
218
|
while (offset < header.length - 8) {
|
|
193
219
|
const size = readUint32(header, offset);
|
|
194
220
|
const type = boxType(header, offset + 4);
|
|
195
|
-
|
|
221
|
+
|
|
196
222
|
if (size === 0 || size > this.fileSize) break;
|
|
197
|
-
|
|
223
|
+
|
|
198
224
|
if (type === 'moov') {
|
|
199
225
|
// moov in header - fetch complete if needed
|
|
200
226
|
if (offset + size <= header.length) {
|
|
@@ -202,7 +228,7 @@ export class RemoteMp4 {
|
|
|
202
228
|
}
|
|
203
229
|
return fetchRange(this.url, offset, offset + size - 1);
|
|
204
230
|
}
|
|
205
|
-
|
|
231
|
+
|
|
206
232
|
if (type === 'mdat') {
|
|
207
233
|
// mdat at start means moov is at end
|
|
208
234
|
const moovOffset = offset + size;
|
|
@@ -214,39 +240,39 @@ export class RemoteMp4 {
|
|
|
214
240
|
if (moov.size <= tail.length) {
|
|
215
241
|
return tail.slice(moov.offset, moov.offset + moov.size);
|
|
216
242
|
}
|
|
217
|
-
return fetchRange(this.url, moovOffset + moov.offset,
|
|
218
|
-
|
|
243
|
+
return fetchRange(this.url, moovOffset + moov.offset,
|
|
244
|
+
moovOffset + moov.offset + moov.size - 1);
|
|
219
245
|
}
|
|
220
246
|
}
|
|
221
247
|
break;
|
|
222
248
|
}
|
|
223
|
-
|
|
249
|
+
|
|
224
250
|
offset += size;
|
|
225
251
|
}
|
|
226
|
-
|
|
252
|
+
|
|
227
253
|
// Try end of file as fallback
|
|
228
254
|
const tailSize = Math.min(MAX_TAIL_SIZE, this.fileSize);
|
|
229
255
|
const tail = await fetchRange(this.url, this.fileSize - tailSize, this.fileSize - 1);
|
|
230
256
|
const moov = findBox(tail, 'moov');
|
|
231
|
-
|
|
257
|
+
|
|
232
258
|
if (moov) {
|
|
233
259
|
const moovStart = this.fileSize - tailSize + moov.offset;
|
|
234
260
|
return fetchRange(this.url, moovStart, moovStart + moov.size - 1);
|
|
235
261
|
}
|
|
236
|
-
|
|
262
|
+
|
|
237
263
|
// Check for fragmented MP4
|
|
238
264
|
const moof = findBox(header, 'moof');
|
|
239
265
|
if (moof) {
|
|
240
266
|
throw new Error('Fragmented MP4 (fMP4) not supported');
|
|
241
267
|
}
|
|
242
|
-
|
|
268
|
+
|
|
243
269
|
throw new Error('Could not find moov box');
|
|
244
270
|
}
|
|
245
|
-
|
|
271
|
+
|
|
246
272
|
// ===========================================================================
|
|
247
273
|
// Public API
|
|
248
274
|
// ===========================================================================
|
|
249
|
-
|
|
275
|
+
|
|
250
276
|
/**
|
|
251
277
|
* Get source information
|
|
252
278
|
*/
|
|
@@ -265,7 +291,7 @@ export class RemoteMp4 {
|
|
|
265
291
|
keyframeCount: this.videoTrack?.stss?.length || 0
|
|
266
292
|
};
|
|
267
293
|
}
|
|
268
|
-
|
|
294
|
+
|
|
269
295
|
/**
|
|
270
296
|
* Get segment definitions
|
|
271
297
|
*/
|
|
@@ -277,7 +303,7 @@ export class RemoteMp4 {
|
|
|
277
303
|
duration: s.duration
|
|
278
304
|
}));
|
|
279
305
|
}
|
|
280
|
-
|
|
306
|
+
|
|
281
307
|
/**
|
|
282
308
|
* Generate HLS master playlist
|
|
283
309
|
*/
|
|
@@ -285,17 +311,17 @@ export class RemoteMp4 {
|
|
|
285
311
|
const bandwidth = Math.round(
|
|
286
312
|
(this.videoSamples.reduce((s, v) => s + v.size, 0) / this.duration) * 8
|
|
287
313
|
);
|
|
288
|
-
|
|
289
|
-
const resolution = this.width && this.height ?
|
|
314
|
+
|
|
315
|
+
const resolution = this.width && this.height ?
|
|
290
316
|
`,RESOLUTION=${this.width}x${this.height}` : '';
|
|
291
|
-
|
|
317
|
+
|
|
292
318
|
return `#EXTM3U
|
|
293
319
|
#EXT-X-VERSION:3
|
|
294
320
|
#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth}${resolution}
|
|
295
321
|
${baseUrl}playlist.m3u8
|
|
296
322
|
`;
|
|
297
323
|
}
|
|
298
|
-
|
|
324
|
+
|
|
299
325
|
/**
|
|
300
326
|
* Generate HLS media playlist
|
|
301
327
|
*/
|
|
@@ -306,15 +332,15 @@ ${baseUrl}playlist.m3u8
|
|
|
306
332
|
#EXT-X-MEDIA-SEQUENCE:0
|
|
307
333
|
#EXT-X-PLAYLIST-TYPE:VOD
|
|
308
334
|
`;
|
|
309
|
-
|
|
335
|
+
|
|
310
336
|
for (const segment of this.segments) {
|
|
311
337
|
playlist += `#EXTINF:${segment.duration.toFixed(6)},\n${baseUrl}segment${segment.index}.ts\n`;
|
|
312
338
|
}
|
|
313
|
-
|
|
339
|
+
|
|
314
340
|
playlist += '#EXT-X-ENDLIST\n';
|
|
315
341
|
return playlist;
|
|
316
342
|
}
|
|
317
|
-
|
|
343
|
+
|
|
318
344
|
/**
|
|
319
345
|
* Get a segment as MPEG-TS data
|
|
320
346
|
* @param {number} index - Segment index
|
|
@@ -325,17 +351,17 @@ ${baseUrl}playlist.m3u8
|
|
|
325
351
|
if (!segment) {
|
|
326
352
|
throw new Error(`Segment ${index} not found`);
|
|
327
353
|
}
|
|
328
|
-
|
|
354
|
+
|
|
329
355
|
// Get samples for this segment
|
|
330
356
|
const videoSamples = this.videoSamples.slice(segment.videoStart, segment.videoEnd);
|
|
331
357
|
const audioSamples = this.audioSamples.filter(
|
|
332
358
|
s => s.time >= segment.startTime && s.time < segment.endTime
|
|
333
359
|
);
|
|
334
|
-
|
|
360
|
+
|
|
335
361
|
// Fetch video data using byte ranges
|
|
336
362
|
const videoRanges = calculateByteRanges(videoSamples);
|
|
337
363
|
const videoData = await this._fetchRanges(videoRanges);
|
|
338
|
-
|
|
364
|
+
|
|
339
365
|
// Map video sample data
|
|
340
366
|
const parsedVideoSamples = videoSamples.map(sample => {
|
|
341
367
|
const range = videoRanges.find(r => r.samples.includes(sample));
|
|
@@ -346,13 +372,13 @@ ${baseUrl}playlist.m3u8
|
|
|
346
372
|
data: data.slice(relOffset, relOffset + sample.size)
|
|
347
373
|
};
|
|
348
374
|
});
|
|
349
|
-
|
|
375
|
+
|
|
350
376
|
// Fetch and map audio data
|
|
351
377
|
let parsedAudioSamples = [];
|
|
352
378
|
if (audioSamples.length > 0) {
|
|
353
379
|
const audioRanges = calculateByteRanges(audioSamples);
|
|
354
380
|
const audioData = await this._fetchRanges(audioRanges);
|
|
355
|
-
|
|
381
|
+
|
|
356
382
|
parsedAudioSamples = audioSamples.map(sample => {
|
|
357
383
|
const range = audioRanges.find(r => r.samples.includes(sample));
|
|
358
384
|
const data = audioData.get(range);
|
|
@@ -363,53 +389,53 @@ ${baseUrl}playlist.m3u8
|
|
|
363
389
|
};
|
|
364
390
|
});
|
|
365
391
|
}
|
|
366
|
-
|
|
392
|
+
|
|
367
393
|
// Build MPEG-TS segment
|
|
368
394
|
return this._buildTsSegment(parsedVideoSamples, parsedAudioSamples);
|
|
369
395
|
}
|
|
370
|
-
|
|
396
|
+
|
|
371
397
|
async _fetchRanges(ranges) {
|
|
372
398
|
const results = new Map();
|
|
373
|
-
|
|
399
|
+
|
|
374
400
|
// Fetch ranges in parallel
|
|
375
401
|
await Promise.all(ranges.map(async range => {
|
|
376
402
|
const data = await fetchRange(this.url, range.start, range.end - 1);
|
|
377
403
|
results.set(range, data);
|
|
378
404
|
}));
|
|
379
|
-
|
|
405
|
+
|
|
380
406
|
return results;
|
|
381
407
|
}
|
|
382
|
-
|
|
408
|
+
|
|
383
409
|
_buildTsSegment(videoSamples, audioSamples) {
|
|
384
410
|
const muxer = new TSMuxer();
|
|
385
|
-
|
|
411
|
+
|
|
386
412
|
if (this.videoTrack?.codecConfig) {
|
|
387
413
|
muxer.setSpsPps(
|
|
388
414
|
this.videoTrack.codecConfig.sps[0],
|
|
389
415
|
this.videoTrack.codecConfig.pps[0]
|
|
390
416
|
);
|
|
391
417
|
}
|
|
392
|
-
|
|
418
|
+
|
|
393
419
|
muxer.setHasAudio(audioSamples.length > 0);
|
|
394
|
-
|
|
420
|
+
|
|
395
421
|
const PTS_PER_SECOND = 90000;
|
|
396
422
|
const sampleRate = this.audioTrack?.audioConfig?.sampleRate || 44100;
|
|
397
423
|
const channels = this.audioTrack?.audioConfig?.channels || 2;
|
|
398
|
-
|
|
424
|
+
|
|
399
425
|
// Add audio samples
|
|
400
426
|
for (const sample of audioSamples) {
|
|
401
427
|
const dts90k = Math.round((sample.dts ?? sample.time) * PTS_PER_SECOND);
|
|
402
428
|
const adts = wrapADTS(sample.data, sampleRate, channels);
|
|
403
429
|
muxer.addAudioSample(adts, dts90k);
|
|
404
430
|
}
|
|
405
|
-
|
|
431
|
+
|
|
406
432
|
// Add video samples with PTS and DTS
|
|
407
433
|
for (const sample of videoSamples) {
|
|
408
434
|
const pts90k = Math.round((sample.pts ?? sample.time) * PTS_PER_SECOND);
|
|
409
435
|
const dts90k = Math.round((sample.dts ?? sample.time) * PTS_PER_SECOND);
|
|
410
436
|
muxer.addVideoSample(sample.data, sample.isKeyframe, pts90k, dts90k);
|
|
411
437
|
}
|
|
412
|
-
|
|
438
|
+
|
|
413
439
|
muxer.flush();
|
|
414
440
|
return muxer.build();
|
|
415
441
|
}
|