@invintusmedia/tomp4 1.4.1 → 1.4.2
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 +1 -1
- package/src/hls-clip.js +246 -106
- package/src/index.js +1 -1
package/dist/tomp4.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toMp4.js v1.4.
|
|
2
|
+
* toMp4.js v1.4.2
|
|
3
3
|
* Convert MPEG-TS and fMP4 to standard MP4
|
|
4
4
|
* https://github.com/TVWIT/toMp4.js
|
|
5
5
|
* MIT License
|
|
@@ -1186,7 +1186,7 @@
|
|
|
1186
1186
|
toMp4.isMpegTs = isMpegTs;
|
|
1187
1187
|
toMp4.isFmp4 = isFmp4;
|
|
1188
1188
|
toMp4.isStandardMp4 = isStandardMp4;
|
|
1189
|
-
toMp4.version = '1.4.
|
|
1189
|
+
toMp4.version = '1.4.2';
|
|
1190
1190
|
|
|
1191
1191
|
return toMp4;
|
|
1192
1192
|
});
|
package/package.json
CHANGED
package/src/hls-clip.js
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
import { parseHls, isHlsUrl, parsePlaylistText, toAbsoluteUrl } from './hls.js';
|
|
24
24
|
import { TSParser, getCodecInfo } from './parsers/mpegts.js';
|
|
25
25
|
import { createInitSegment, createFragment } from './muxers/fmp4.js';
|
|
26
|
+
import { convertFmp4ToMp4 } from './fmp4/converter.js';
|
|
27
|
+
import { parseBoxes, findBox, parseChildBoxes, createBox } from './fmp4/utils.js';
|
|
26
28
|
import { smartRender } from './codecs/smart-render.js';
|
|
27
29
|
|
|
28
30
|
// ── constants ─────────────────────────────────────────────
|
|
@@ -245,16 +247,18 @@ class HlsClipResult {
|
|
|
245
247
|
// Pre-clipped boundary segments are already in memory
|
|
246
248
|
if (seg.data) return seg.data;
|
|
247
249
|
|
|
248
|
-
// Middle segment: fetch from CDN
|
|
250
|
+
// Middle segment: fetch from CDN
|
|
249
251
|
const resp = await fetch(seg.originalUrl);
|
|
250
252
|
if (!resp.ok) throw new Error(`Segment fetch failed: ${resp.status}`);
|
|
251
|
-
const
|
|
253
|
+
const rawData = new Uint8Array(await resp.arrayBuffer());
|
|
252
254
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
// fMP4 segments pass through unchanged (already correct format)
|
|
256
|
+
if (seg._sourceFormat === 'fmp4') return rawData;
|
|
257
|
+
|
|
258
|
+
// TS segments: remux to fMP4
|
|
259
|
+
const parser = parseTs(rawData);
|
|
260
|
+
const audioTimescale = seg._audioTimescale || parser.audioSampleRate || 48000;
|
|
255
261
|
|
|
256
|
-
// Normalize timestamps: subtract the segment's original start PTS,
|
|
257
|
-
// then add the segment's position in the clip timeline
|
|
258
262
|
const firstVideoPts = parser.videoAccessUnits[0]?.pts ?? 0;
|
|
259
263
|
for (const au of parser.videoAccessUnits) { au.pts -= firstVideoPts; au.dts -= firstVideoPts; }
|
|
260
264
|
for (const au of parser.audioAccessUnits) { au.pts -= firstVideoPts; }
|
|
@@ -262,12 +266,7 @@ class HlsClipResult {
|
|
|
262
266
|
const videoBaseTime = Math.round(seg.timelineOffset * PTS_PER_SECOND);
|
|
263
267
|
const audioBaseTime = Math.round(seg.timelineOffset * audioTimescale);
|
|
264
268
|
|
|
265
|
-
|
|
266
|
-
parser, segmentIndex + 1,
|
|
267
|
-
videoBaseTime, audioBaseTime, audioTimescale
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
return fragment;
|
|
269
|
+
return remuxToFragment(parser, segmentIndex + 1, videoBaseTime, audioBaseTime, audioTimescale);
|
|
271
270
|
}
|
|
272
271
|
|
|
273
272
|
/**
|
|
@@ -286,6 +285,202 @@ class HlsClipResult {
|
|
|
286
285
|
}
|
|
287
286
|
}
|
|
288
287
|
|
|
288
|
+
// ── format detection ──────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
function _detectSegmentFormat(data) {
|
|
291
|
+
if (data.length < 8) return 'unknown';
|
|
292
|
+
// Check for TS sync byte
|
|
293
|
+
if (data[0] === 0x47) return 'ts';
|
|
294
|
+
for (let i = 0; i < Math.min(188, data.length); i++) {
|
|
295
|
+
if (data[i] === 0x47 && i + 188 < data.length && data[i + 188] === 0x47) return 'ts';
|
|
296
|
+
}
|
|
297
|
+
// Check for fMP4 (moof, styp, or ftyp box)
|
|
298
|
+
const type = String.fromCharCode(data[4], data[5], data[6], data[7]);
|
|
299
|
+
if (['moof', 'styp', 'ftyp', 'mdat'].includes(type)) return 'fmp4';
|
|
300
|
+
return 'unknown';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── TS variant processing ─────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function _processTsVariant({ firstSegData, lastSegData, overlapping, isSingleSegment, startTime, endTime, firstSeg, lastSeg, log }) {
|
|
306
|
+
const firstParser = parseTs(firstSegData);
|
|
307
|
+
const lastParser = !isSingleSegment && lastSegData ? parseTs(lastSegData) : null;
|
|
308
|
+
|
|
309
|
+
const { sps, pps } = extractCodecInfo(firstParser);
|
|
310
|
+
if (!sps || !pps) throw new Error('Could not extract SPS/PPS from video');
|
|
311
|
+
const audioSampleRate = firstParser.audioSampleRate || 48000;
|
|
312
|
+
const audioChannels = firstParser.audioChannels || 2;
|
|
313
|
+
const hasAudio = firstParser.audioAccessUnits.length > 0;
|
|
314
|
+
const audioTimescale = audioSampleRate;
|
|
315
|
+
|
|
316
|
+
const initSegment = createInitSegment({
|
|
317
|
+
sps, pps, audioSampleRate, audioChannels, hasAudio,
|
|
318
|
+
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const clipSegments = [];
|
|
322
|
+
let timelineOffset = 0;
|
|
323
|
+
|
|
324
|
+
// First segment (smart-rendered)
|
|
325
|
+
const firstRelStart = startTime - firstSeg.startTime;
|
|
326
|
+
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
327
|
+
const firstClipped = clipSegment(firstParser, firstRelStart, firstRelEnd);
|
|
328
|
+
if (!firstClipped) throw new Error('First segment clip produced no samples');
|
|
329
|
+
|
|
330
|
+
const firstFragment = createFragment({
|
|
331
|
+
videoSamples: firstClipped.videoSamples,
|
|
332
|
+
audioSamples: firstClipped.audioSamples,
|
|
333
|
+
sequenceNumber: 1,
|
|
334
|
+
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
335
|
+
videoBaseTime: 0, audioBaseTime: 0, audioSampleDuration: 1024,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
clipSegments.push({
|
|
339
|
+
duration: firstClipped.duration, data: firstFragment,
|
|
340
|
+
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
341
|
+
});
|
|
342
|
+
timelineOffset += firstClipped.duration;
|
|
343
|
+
|
|
344
|
+
// Middle segments
|
|
345
|
+
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
346
|
+
clipSegments.push({
|
|
347
|
+
duration: overlapping[i].duration, data: null,
|
|
348
|
+
originalUrl: overlapping[i].url, timelineOffset, isBoundary: false,
|
|
349
|
+
_sourceFormat: 'ts', _audioTimescale: audioTimescale,
|
|
350
|
+
});
|
|
351
|
+
timelineOffset += overlapping[i].duration;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Last segment
|
|
355
|
+
if (!isSingleSegment && lastParser) {
|
|
356
|
+
const lastRelEnd = endTime - lastSeg.startTime;
|
|
357
|
+
const lastClipped = clipSegment(lastParser, undefined, lastRelEnd);
|
|
358
|
+
if (lastClipped && lastClipped.videoSamples.length > 0) {
|
|
359
|
+
const lastFragment = createFragment({
|
|
360
|
+
videoSamples: lastClipped.videoSamples,
|
|
361
|
+
audioSamples: lastClipped.audioSamples,
|
|
362
|
+
sequenceNumber: overlapping.length,
|
|
363
|
+
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
364
|
+
videoBaseTime: Math.round(timelineOffset * PTS_PER_SECOND),
|
|
365
|
+
audioBaseTime: Math.round(timelineOffset * audioTimescale),
|
|
366
|
+
audioSampleDuration: 1024,
|
|
367
|
+
});
|
|
368
|
+
clipSegments.push({
|
|
369
|
+
duration: lastClipped.duration, data: lastFragment,
|
|
370
|
+
originalUrl: null, timelineOffset, isBoundary: true,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { initSegment, clipSegments, audioTimescale };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── fMP4 variant processing ───────────────────────────────
|
|
379
|
+
|
|
380
|
+
function _processFmp4Variant({ firstSegData, lastSegData, fmp4Init, overlapping, isSingleSegment, startTime, endTime, firstSeg, lastSeg, log }) {
|
|
381
|
+
// For fMP4 sources: the init segment already has the moov with codec info.
|
|
382
|
+
// We pass it through as-is. Boundary segments are clipped using the fMP4
|
|
383
|
+
// converter. Middle segments pass through unchanged.
|
|
384
|
+
|
|
385
|
+
if (!fmp4Init) throw new Error('fMP4 source requires an init segment (#EXT-X-MAP)');
|
|
386
|
+
|
|
387
|
+
// Use the source init segment directly (it has the correct moov)
|
|
388
|
+
const initSegment = fmp4Init;
|
|
389
|
+
|
|
390
|
+
// Detect audio timescale from the init segment's moov
|
|
391
|
+
let audioTimescale = 48000;
|
|
392
|
+
try {
|
|
393
|
+
const boxes = parseBoxes(fmp4Init);
|
|
394
|
+
const moov = findBox(boxes, 'moov');
|
|
395
|
+
if (moov) {
|
|
396
|
+
const moovChildren = parseChildBoxes(moov);
|
|
397
|
+
for (const child of moovChildren) {
|
|
398
|
+
if (child.type === 'trak') {
|
|
399
|
+
const trakChildren = parseChildBoxes(child);
|
|
400
|
+
for (const tc of trakChildren) {
|
|
401
|
+
if (tc.type === 'mdia') {
|
|
402
|
+
const mdiaChildren = parseChildBoxes(tc);
|
|
403
|
+
let isSoun = false;
|
|
404
|
+
for (const mc of mdiaChildren) {
|
|
405
|
+
if (mc.type === 'hdlr' && mc.data.byteLength >= 20) {
|
|
406
|
+
const handler = String.fromCharCode(mc.data[16], mc.data[17], mc.data[18], mc.data[19]);
|
|
407
|
+
if (handler === 'soun') isSoun = true;
|
|
408
|
+
}
|
|
409
|
+
if (mc.type === 'mdhd' && isSoun) {
|
|
410
|
+
const v = new DataView(mc.data.buffer, mc.data.byteOffset, mc.data.byteLength);
|
|
411
|
+
audioTimescale = mc.data[8] === 0 ? v.getUint32(20) : v.getUint32(28);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} catch (e) { /* use default */ }
|
|
420
|
+
|
|
421
|
+
const clipSegments = [];
|
|
422
|
+
let timelineOffset = 0;
|
|
423
|
+
|
|
424
|
+
// First segment: clip using fMP4 converter
|
|
425
|
+
const firstRelStart = startTime - firstSeg.startTime;
|
|
426
|
+
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
427
|
+
|
|
428
|
+
// Combine init + first segment for the converter
|
|
429
|
+
const firstCombined = new Uint8Array(fmp4Init.byteLength + firstSegData.byteLength);
|
|
430
|
+
firstCombined.set(fmp4Init, 0);
|
|
431
|
+
firstCombined.set(firstSegData, fmp4Init.byteLength);
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
// Use convertFmp4ToMp4 with clipping, then re-fragment
|
|
435
|
+
// Actually, we can just pass the raw fMP4 segment through — for boundary
|
|
436
|
+
// segments, we trim at the keyframe level (no smart rendering for fMP4 yet).
|
|
437
|
+
// The segment already starts at a keyframe (HLS requirement).
|
|
438
|
+
|
|
439
|
+
// For the first segment, just pass through — the startTime cut is at keyframe
|
|
440
|
+
// For frame accuracy with fMP4, we'd need to add edit lists to the init segment
|
|
441
|
+
// or do smart rendering. For now, keyframe-accurate is the fMP4 path.
|
|
442
|
+
clipSegments.push({
|
|
443
|
+
duration: (firstRelEnd || firstSeg.duration) - firstRelStart,
|
|
444
|
+
data: firstSegData, // pass through the fMP4 segment
|
|
445
|
+
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
446
|
+
_sourceFormat: 'fmp4',
|
|
447
|
+
});
|
|
448
|
+
timelineOffset += clipSegments[0].duration;
|
|
449
|
+
} catch (e) {
|
|
450
|
+
log('fMP4 first segment processing error: ' + e.message);
|
|
451
|
+
// Fallback: pass through as-is
|
|
452
|
+
clipSegments.push({
|
|
453
|
+
duration: firstSeg.duration, data: firstSegData,
|
|
454
|
+
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
455
|
+
_sourceFormat: 'fmp4',
|
|
456
|
+
});
|
|
457
|
+
timelineOffset += firstSeg.duration;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Middle segments: pass through unchanged (already fMP4!)
|
|
461
|
+
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
462
|
+
clipSegments.push({
|
|
463
|
+
duration: overlapping[i].duration, data: null,
|
|
464
|
+
originalUrl: overlapping[i].url, timelineOffset, isBoundary: false,
|
|
465
|
+
_sourceFormat: 'fmp4',
|
|
466
|
+
});
|
|
467
|
+
timelineOffset += overlapping[i].duration;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Last segment: pass through (truncation at end is handled by player)
|
|
471
|
+
if (!isSingleSegment && lastSegData) {
|
|
472
|
+
const lastRelEnd = endTime - lastSeg.startTime;
|
|
473
|
+
clipSegments.push({
|
|
474
|
+
duration: Math.min(lastRelEnd, lastSeg.duration),
|
|
475
|
+
data: lastSegData,
|
|
476
|
+
originalUrl: null, timelineOffset, isBoundary: true,
|
|
477
|
+
_sourceFormat: 'fmp4',
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { initSegment, clipSegments, audioTimescale };
|
|
482
|
+
}
|
|
483
|
+
|
|
289
484
|
// ── main function ─────────────────────────────────────────
|
|
290
485
|
|
|
291
486
|
/**
|
|
@@ -362,107 +557,52 @@ export async function clipHls(source, options = {}) {
|
|
|
362
557
|
|
|
363
558
|
log(`Segments: ${overlapping.length} (${firstSeg.startTime.toFixed(1)}s – ${lastSeg.endTime.toFixed(1)}s)`);
|
|
364
559
|
|
|
365
|
-
// Download
|
|
560
|
+
// Download first boundary segment to detect format
|
|
366
561
|
log('Downloading boundary segments...');
|
|
367
|
-
const
|
|
368
|
-
const firstParser = parseTs(firstTsData);
|
|
562
|
+
const firstSegData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
|
|
369
563
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
lastTsData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
|
|
374
|
-
lastParser = parseTs(lastTsData);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Extract codec info from first segment
|
|
378
|
-
const { sps, pps } = extractCodecInfo(firstParser);
|
|
379
|
-
if (!sps || !pps) throw new Error('Could not extract SPS/PPS from video');
|
|
380
|
-
const audioSampleRate = firstParser.audioSampleRate || 48000;
|
|
381
|
-
const audioChannels = firstParser.audioChannels || 2;
|
|
382
|
-
const hasAudio = firstParser.audioAccessUnits.length > 0;
|
|
383
|
-
const audioTimescale = audioSampleRate;
|
|
384
|
-
|
|
385
|
-
// Create CMAF init segment
|
|
386
|
-
const initSegment = createInitSegment({
|
|
387
|
-
sps, pps, audioSampleRate, audioChannels, hasAudio,
|
|
388
|
-
videoTimescale: PTS_PER_SECOND,
|
|
389
|
-
audioTimescale,
|
|
390
|
-
});
|
|
564
|
+
// Detect source format: TS or fMP4
|
|
565
|
+
const sourceFormat = _detectSegmentFormat(firstSegData);
|
|
566
|
+
const isFmp4Source = sourceFormat === 'fmp4';
|
|
391
567
|
|
|
392
|
-
|
|
393
|
-
const clipSegments = [];
|
|
394
|
-
let timelineOffset = 0;
|
|
395
|
-
|
|
396
|
-
// ── First segment (clipped at start, possibly also at end) ──
|
|
397
|
-
// Convert absolute times to segment-relative times (TS PTS starts at ~0 per segment)
|
|
398
|
-
const firstRelStart = startTime - firstSeg.startTime;
|
|
399
|
-
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
400
|
-
const firstClipped = clipSegment(firstParser, firstRelStart, firstRelEnd);
|
|
401
|
-
if (!firstClipped) throw new Error('First segment clip produced no samples');
|
|
402
|
-
|
|
403
|
-
const firstFragment = createFragment({
|
|
404
|
-
videoSamples: firstClipped.videoSamples,
|
|
405
|
-
audioSamples: firstClipped.audioSamples,
|
|
406
|
-
sequenceNumber: 1,
|
|
407
|
-
videoTimescale: PTS_PER_SECOND,
|
|
408
|
-
audioTimescale,
|
|
409
|
-
videoBaseTime: 0,
|
|
410
|
-
audioBaseTime: 0,
|
|
411
|
-
audioSampleDuration: 1024,
|
|
412
|
-
});
|
|
568
|
+
log(`Source format: ${isFmp4Source ? 'fMP4 (CMAF)' : 'MPEG-TS'}`);
|
|
413
569
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
570
|
+
// Download fMP4 init segment if needed
|
|
571
|
+
let fmp4Init = null;
|
|
572
|
+
if (isFmp4Source && initSegmentUrl) {
|
|
573
|
+
const initResp = await fetch(initSegmentUrl);
|
|
574
|
+
if (initResp.ok) {
|
|
575
|
+
fmp4Init = new Uint8Array(await initResp.arrayBuffer());
|
|
576
|
+
}
|
|
577
|
+
}
|
|
422
578
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const segDuration = seg.duration;
|
|
427
|
-
clipSegments.push({
|
|
428
|
-
duration: segDuration,
|
|
429
|
-
data: null, // fetched on demand
|
|
430
|
-
originalUrl: seg.url,
|
|
431
|
-
timelineOffset,
|
|
432
|
-
isBoundary: false,
|
|
433
|
-
});
|
|
434
|
-
timelineOffset += segDuration;
|
|
579
|
+
let lastSegData = null;
|
|
580
|
+
if (!isSingleSegment) {
|
|
581
|
+
lastSegData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
|
|
435
582
|
}
|
|
436
583
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
duration: lastClipped.duration,
|
|
460
|
-
data: lastFragment,
|
|
461
|
-
originalUrl: null,
|
|
462
|
-
timelineOffset,
|
|
463
|
-
isBoundary: true,
|
|
464
|
-
});
|
|
465
|
-
}
|
|
584
|
+
let initSegment, clipSegments, audioTimescale;
|
|
585
|
+
|
|
586
|
+
if (isFmp4Source) {
|
|
587
|
+
// ── fMP4 source path ────────────────────────────────
|
|
588
|
+
const result = _processFmp4Variant({
|
|
589
|
+
firstSegData, lastSegData, fmp4Init,
|
|
590
|
+
overlapping, isSingleSegment,
|
|
591
|
+
startTime, endTime, firstSeg, lastSeg, log,
|
|
592
|
+
});
|
|
593
|
+
initSegment = result.initSegment;
|
|
594
|
+
clipSegments = result.clipSegments;
|
|
595
|
+
audioTimescale = result.audioTimescale;
|
|
596
|
+
} else {
|
|
597
|
+
// ── TS source path (existing smart-render pipeline) ──
|
|
598
|
+
const result = _processTsVariant({
|
|
599
|
+
firstSegData, lastSegData,
|
|
600
|
+
overlapping, isSingleSegment,
|
|
601
|
+
startTime, endTime, firstSeg, lastSeg, log,
|
|
602
|
+
});
|
|
603
|
+
initSegment = result.initSegment;
|
|
604
|
+
clipSegments = result.clipSegments;
|
|
605
|
+
audioTimescale = result.audioTimescale;
|
|
466
606
|
}
|
|
467
607
|
|
|
468
608
|
const totalDuration = clipSegments.reduce((sum, s) => sum + s.duration, 0);
|
package/src/index.js
CHANGED