@invintusmedia/tomp4 1.4.1 → 1.4.3

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