@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * toMp4.js v1.4.1
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.1';
1189
+ toMp4.version = '1.4.2';
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.2",
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 ─────────────────────────────────────────────
@@ -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, remux TS → fMP4
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 tsData = new Uint8Array(await resp.arrayBuffer());
253
+ const rawData = new Uint8Array(await resp.arrayBuffer());
252
254
 
253
- const parser = parseTs(tsData);
254
- const audioTimescale = parser.audioSampleRate || 48000;
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
- const fragment = remuxToFragment(
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 and parse boundary segments to get codec info + pre-clip
560
+ // Download first boundary segment to detect format
366
561
  log('Downloading boundary segments...');
367
- const firstTsData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
368
- const firstParser = parseTs(firstTsData);
562
+ const firstSegData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
369
563
 
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
- });
564
+ // Detect source format: TS or fMP4
565
+ const sourceFormat = _detectSegmentFormat(firstSegData);
566
+ const isFmp4Source = sourceFormat === 'fmp4';
391
567
 
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
- });
568
+ log(`Source format: ${isFmp4Source ? 'fMP4 (CMAF)' : 'MPEG-TS'}`);
413
569
 
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;
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
- // ── 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;
579
+ let lastSegData = null;
580
+ if (!isSingleSegment) {
581
+ lastSegData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
435
582
  }
436
583
 
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
- }
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
@@ -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.2';
346
346
 
347
347
  // Export
348
348
  export {