@invintusmedia/tomp4 1.0.6 → 1.0.8

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.0.6
2
+ * toMp4.js v1.0.8
3
3
  * Convert MPEG-TS and fMP4 to standard MP4
4
4
  * https://github.com/TVWIT/toMp4.js
5
5
  * MIT License
@@ -59,7 +59,9 @@
59
59
  }
60
60
 
61
61
  /**
62
- * Clip access units to a time range, snapping to keyframes
62
+ * Clip access units to a time range, snapping to keyframes for decode
63
+ * but using edit list for precise playback timing
64
+ *
63
65
  * @param {Array} videoAUs - Video access units
64
66
  * @param {Array} audioAUs - Audio access units
65
67
  * @param {number} startTime - Start time in seconds
@@ -71,48 +73,73 @@
71
73
  const startPts = startTime * PTS_PER_SECOND;
72
74
  const endPts = endTime * PTS_PER_SECOND;
73
75
 
74
- // Find keyframe at or before startTime
75
- let startIdx = 0;
76
+ // Find keyframe at or before startTime (needed for decoding)
77
+ let keyframeIdx = 0;
76
78
  for (let i = 0; i < videoAUs.length; i++) {
77
79
  if (videoAUs[i].pts > startPts) break;
78
- if (isKeyframe(videoAUs[i])) startIdx = i;
80
+ if (isKeyframe(videoAUs[i])) keyframeIdx = i;
79
81
  }
80
82
 
81
- // Find first frame after endTime
83
+ // Find first frame at or after endTime
82
84
  let endIdx = videoAUs.length;
83
- for (let i = startIdx; i < videoAUs.length; i++) {
85
+ for (let i = keyframeIdx; i < videoAUs.length; i++) {
84
86
  if (videoAUs[i].pts >= endPts) {
85
87
  endIdx = i;
86
88
  break;
87
89
  }
88
90
  }
89
91
 
90
- // Clip video
91
- const clippedVideo = videoAUs.slice(startIdx, endIdx);
92
+ // Clip video starting from keyframe (for proper decoding)
93
+ const clippedVideo = videoAUs.slice(keyframeIdx, endIdx);
94
+
95
+ if (clippedVideo.length === 0) {
96
+ return {
97
+ video: [],
98
+ audio: [],
99
+ actualStartTime: startTime,
100
+ actualEndTime: endTime,
101
+ offset: 0,
102
+ preroll: 0
103
+ };
104
+ }
105
+
106
+ // Get PTS of keyframe and requested start
107
+ const keyframePts = clippedVideo[0].pts;
108
+ const lastFramePts = clippedVideo[clippedVideo.length - 1].pts;
92
109
 
93
- // Get actual PTS range from clipped video
94
- const actualStartPts = clippedVideo.length > 0 ? clippedVideo[0].pts : 0;
95
- const actualEndPts = clippedVideo.length > 0 ? clippedVideo[clippedVideo.length - 1].pts : 0;
110
+ // Pre-roll: time between keyframe and requested start
111
+ // This is the time the decoder needs to process but player shouldn't display
112
+ const prerollPts = Math.max(0, startPts - keyframePts);
96
113
 
97
- // Clip audio to match video time range
98
- const clippedAudio = audioAUs.filter(au => au.pts >= actualStartPts && au.pts <= actualEndPts);
114
+ // Clip audio to the REQUESTED time range (not from keyframe)
115
+ // Audio doesn't need keyframe pre-roll
116
+ const audioStartPts = startPts;
117
+ const audioEndPts = Math.min(endPts, lastFramePts + 90000); // Include audio slightly past last video
118
+ const clippedAudio = audioAUs.filter(au => au.pts >= audioStartPts && au.pts < audioEndPts);
99
119
 
100
- // Normalize timestamps so clip starts at 0
101
- const offset = actualStartPts;
120
+ // Normalize video timestamps so keyframe starts at 0
121
+ const offset = keyframePts;
102
122
  for (const au of clippedVideo) {
103
123
  au.pts -= offset;
104
124
  au.dts -= offset;
105
125
  }
126
+
127
+ // Normalize audio timestamps so it starts at 0 (matching video playback start after preroll)
128
+ // Audio doesn't have preroll, so it should start at PTS 0 to sync with video after edit list
129
+ const audioOffset = audioStartPts; // Use requested start, not keyframe
106
130
  for (const au of clippedAudio) {
107
- au.pts -= offset;
131
+ au.pts -= audioOffset;
108
132
  }
109
133
 
110
134
  return {
111
135
  video: clippedVideo,
112
136
  audio: clippedAudio,
113
- actualStartTime: actualStartPts / PTS_PER_SECOND,
114
- actualEndTime: actualEndPts / PTS_PER_SECOND,
115
- offset
137
+ actualStartTime: keyframePts / PTS_PER_SECOND, // Where decode starts (keyframe)
138
+ actualEndTime: lastFramePts / PTS_PER_SECOND,
139
+ requestedStartTime: startTime, // Where playback should start
140
+ requestedEndTime: endTime,
141
+ offset,
142
+ preroll: prerollPts // Edit list will use this to skip pre-roll frames during playback
116
143
  };
117
144
  }
118
145
 
@@ -246,6 +273,9 @@
246
273
 
247
274
  log(`Processing...`, { phase: 'convert', percent: 70 });
248
275
 
276
+ // Track preroll for edit list (used for precise clipping)
277
+ let clipPreroll = 0;
278
+
249
279
  // Apply time range clipping if specified
250
280
  if (options.startTime !== undefined || options.endTime !== undefined) {
251
281
  const startTime = options.startTime || 0;
@@ -260,17 +290,23 @@
260
290
 
261
291
  parser.videoAccessUnits = clipResult.video;
262
292
  parser.audioAccessUnits = clipResult.audio;
293
+ clipPreroll = clipResult.preroll;
263
294
 
264
295
  // Update PTS arrays to match
265
296
  parser.videoPts = clipResult.video.map(au => au.pts);
266
297
  parser.videoDts = clipResult.video.map(au => au.dts);
267
298
  parser.audioPts = clipResult.audio.map(au => au.pts);
268
299
 
269
- log(`Clipped: ${clipResult.actualStartTime.toFixed(2)}s - ${clipResult.actualEndTime.toFixed(2)}s (${clipResult.video.length} video, ${clipResult.audio.length} audio frames)`, { phase: 'convert', percent: 80 });
300
+ const prerollMs = (clipPreroll / 90).toFixed(0);
301
+ const endTimeStr = clipResult.requestedEndTime === Infinity ? 'end' : clipResult.requestedEndTime.toFixed(2) + 's';
302
+ const clipDuration = clipResult.requestedEndTime === Infinity
303
+ ? (clipResult.actualEndTime - clipResult.requestedStartTime).toFixed(2)
304
+ : (clipResult.requestedEndTime - clipResult.requestedStartTime).toFixed(2);
305
+ log(`Clipped: ${clipResult.requestedStartTime.toFixed(2)}s - ${endTimeStr} (${clipDuration}s, ${prerollMs}ms preroll)`, { phase: 'convert', percent: 80 });
270
306
  }
271
307
 
272
308
  log(`Building MP4...`, { phase: 'convert', percent: 85 });
273
- const muxer = new MP4Muxer(parser);
309
+ const muxer = new MP4Muxer(parser, { preroll: clipPreroll });
274
310
  const { width, height } = muxer.getVideoDimensions();
275
311
  log(`Dimensions: ${width}x${height}`);
276
312
 
@@ -720,7 +756,7 @@
720
756
  toMp4.isMpegTs = isMpegTs;
721
757
  toMp4.isFmp4 = isFmp4;
722
758
  toMp4.isStandardMp4 = isStandardMp4;
723
- toMp4.version = '1.0.6';
759
+ toMp4.version = '1.0.8';
724
760
 
725
761
  return toMp4;
726
762
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invintusmedia/tomp4",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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",
@@ -20,6 +20,7 @@
20
20
  "scripts": {
21
21
  "build": "node build.js",
22
22
  "dev": "npx serve . -p 3000",
23
+ "test": "node tests/clip.test.js",
23
24
  "release": "npm run build && git add -A && git commit -m \"v$(node -p \"require('./package.json').version\")\" && git tag v$(node -p \"require('./package.json').version\") && git push && git push --tags",
24
25
  "release:patch": "npm version patch --no-git-tag-version && npm run release",
25
26
  "release:minor": "npm version minor --no-git-tag-version && npm run release",
package/src/index.js CHANGED
@@ -316,7 +316,7 @@ toMp4.transcode = transcode;
316
316
  toMp4.isWebCodecsSupported = isWebCodecsSupported;
317
317
 
318
318
  // Version (injected at build time for dist, read from package.json for ESM)
319
- toMp4.version = '1.0.6';
319
+ toMp4.version = '1.0.8';
320
320
 
321
321
  // Export
322
322
  export {
package/src/muxers/mp4.js CHANGED
@@ -149,13 +149,16 @@ export function parseSPS(sps) {
149
149
  export class MP4Muxer {
150
150
  /**
151
151
  * @param {TSParser} parser - Parser with video/audio access units
152
+ * @param {Object} [options] - Muxer options
153
+ * @param {number} [options.preroll=0] - Pre-roll time in 90kHz ticks (for edit list)
152
154
  */
153
- constructor(parser) {
155
+ constructor(parser, options = {}) {
154
156
  this.parser = parser;
155
157
  this.videoTimescale = 90000;
156
158
  this.audioTimescale = parser.audioSampleRate || 48000;
157
159
  this.audioSampleDuration = 1024;
158
160
  this.videoDimensions = null;
161
+ this.preroll = options.preroll || 0; // Pre-roll for precise clipping
159
162
  }
160
163
 
161
164
  getVideoDimensions() {
@@ -265,7 +268,7 @@ export class MP4Muxer {
265
268
  const data = new Uint8Array(96);
266
269
  const view = new DataView(data.buffer);
267
270
  view.setUint32(8, this.videoTimescale);
268
- view.setUint32(12, this.calculateVideoDuration());
271
+ view.setUint32(12, this.calculatePlaybackDuration()); // Use playback duration for movie header
269
272
  view.setUint32(16, 0x00010000);
270
273
  view.setUint16(20, 0x0100);
271
274
  view.setUint32(32, 0x00010000);
@@ -283,6 +286,15 @@ export class MP4Muxer {
283
286
  return Math.round(lastDts - firstDts + avgDuration);
284
287
  }
285
288
 
289
+ /**
290
+ * Calculate playback duration (total duration minus pre-roll)
291
+ * This is what the player should report as the video length
292
+ */
293
+ calculatePlaybackDuration() {
294
+ const totalDuration = this.calculateVideoDuration();
295
+ return this.preroll > 0 ? Math.max(0, totalDuration - this.preroll) : totalDuration;
296
+ }
297
+
286
298
  buildVideoTrak() {
287
299
  const edts = this.buildVideoEdts();
288
300
  if (edts) {
@@ -297,18 +309,25 @@ export class MP4Muxer {
297
309
  const firstAU = this.parser.videoAccessUnits[0];
298
310
  const firstVideoPts = firstAU.pts;
299
311
 
300
- if (firstVideoPts === 0) return null;
312
+ // Use preroll for precise clipping, or firstVideoPts for timestamp normalization
313
+ const mediaTime = this.preroll > 0 ? this.preroll : firstVideoPts;
314
+
315
+ // If no offset needed, skip edit list
316
+ if (mediaTime === 0) return null;
301
317
 
302
- const duration = this.calculateVideoDuration();
303
- const mediaTime = firstVideoPts;
318
+ // Calculate playback duration (total duration minus pre-roll)
319
+ const totalDuration = this.calculateVideoDuration();
320
+ const playbackDuration = this.preroll > 0
321
+ ? Math.max(0, totalDuration - this.preroll)
322
+ : totalDuration;
304
323
 
305
324
  const elstData = new Uint8Array(16);
306
325
  const view = new DataView(elstData.buffer);
307
- view.setUint32(0, 1);
308
- view.setUint32(4, duration);
309
- view.setInt32(8, mediaTime);
310
- view.setUint16(12, 1);
311
- view.setUint16(14, 0);
326
+ view.setUint32(0, 1); // entry_count
327
+ view.setUint32(4, playbackDuration); // segment_duration (what player reports)
328
+ view.setInt32(8, mediaTime); // media_time (where to start in media)
329
+ view.setUint16(12, 1); // media_rate_integer
330
+ view.setUint16(14, 0); // media_rate_fraction
312
331
 
313
332
  const elst = createFullBox('elst', 0, 0, elstData);
314
333
  return createBox('edts', elst);
@@ -319,7 +338,7 @@ export class MP4Muxer {
319
338
  const data = new Uint8Array(80);
320
339
  const view = new DataView(data.buffer);
321
340
  view.setUint32(8, 256);
322
- view.setUint32(16, this.calculateVideoDuration());
341
+ view.setUint32(16, this.calculatePlaybackDuration()); // Use playback duration for track header
323
342
  view.setUint16(32, 0);
324
343
  view.setUint32(36, 0x00010000);
325
344
  view.setUint32(52, 0x00010000);
@@ -494,15 +513,19 @@ export class MP4Muxer {
494
513
  if (this.parser.audioPts.length === 0) return null;
495
514
 
496
515
  const firstAudioPts = this.parser.audioPts[0];
516
+
517
+ // When clipping with preroll, audio is normalized to start at PTS 0
518
+ // (matching video playback start after edit list), so no edit list needed
497
519
  if (firstAudioPts === 0) return null;
498
520
 
521
+ // For non-clipped content, handle any timestamp offset
499
522
  const mediaTime = Math.round(firstAudioPts * this.audioTimescale / 90000);
500
- const duration = this.audioSampleSizes.length * this.audioSampleDuration;
523
+ const audioDuration = this.audioSampleSizes.length * this.audioSampleDuration;
501
524
 
502
525
  const elstData = new Uint8Array(16);
503
526
  const view = new DataView(elstData.buffer);
504
527
  view.setUint32(0, 1);
505
- view.setUint32(4, Math.round(duration * this.videoTimescale / this.audioTimescale));
528
+ view.setUint32(4, Math.round(audioDuration * this.videoTimescale / this.audioTimescale));
506
529
  view.setInt32(8, mediaTime);
507
530
  view.setUint16(12, 1);
508
531
  view.setUint16(14, 0);
@@ -515,8 +538,8 @@ export class MP4Muxer {
515
538
  const data = new Uint8Array(80);
516
539
  const view = new DataView(data.buffer);
517
540
  view.setUint32(8, 257);
518
- const audioDuration = this.audioSampleSizes.length * this.audioSampleDuration;
519
- view.setUint32(16, Math.round(audioDuration * this.videoTimescale / this.audioTimescale));
541
+ // Use playback duration to match video track (for proper sync with preroll)
542
+ view.setUint32(16, this.calculatePlaybackDuration());
520
543
  view.setUint16(32, 0x0100);
521
544
  view.setUint32(36, 0x00010000); view.setUint32(52, 0x00010000); view.setUint32(68, 0x40000000);
522
545
  return createFullBox('tkhd', 0, 3, data);
@@ -354,3 +354,4 @@ export class TSMuxer {
354
354
  export default TSMuxer;
355
355
 
356
356
 
357
+
package/src/ts-to-mp4.js CHANGED
@@ -38,7 +38,9 @@ function isKeyframe(accessUnit) {
38
38
  }
39
39
 
40
40
  /**
41
- * Clip access units to a time range, snapping to keyframes
41
+ * Clip access units to a time range, snapping to keyframes for decode
42
+ * but using edit list for precise playback timing
43
+ *
42
44
  * @param {Array} videoAUs - Video access units
43
45
  * @param {Array} audioAUs - Audio access units
44
46
  * @param {number} startTime - Start time in seconds
@@ -50,48 +52,73 @@ function clipAccessUnits(videoAUs, audioAUs, startTime, endTime) {
50
52
  const startPts = startTime * PTS_PER_SECOND;
51
53
  const endPts = endTime * PTS_PER_SECOND;
52
54
 
53
- // Find keyframe at or before startTime
54
- let startIdx = 0;
55
+ // Find keyframe at or before startTime (needed for decoding)
56
+ let keyframeIdx = 0;
55
57
  for (let i = 0; i < videoAUs.length; i++) {
56
58
  if (videoAUs[i].pts > startPts) break;
57
- if (isKeyframe(videoAUs[i])) startIdx = i;
59
+ if (isKeyframe(videoAUs[i])) keyframeIdx = i;
58
60
  }
59
61
 
60
- // Find first frame after endTime
62
+ // Find first frame at or after endTime
61
63
  let endIdx = videoAUs.length;
62
- for (let i = startIdx; i < videoAUs.length; i++) {
64
+ for (let i = keyframeIdx; i < videoAUs.length; i++) {
63
65
  if (videoAUs[i].pts >= endPts) {
64
66
  endIdx = i;
65
67
  break;
66
68
  }
67
69
  }
68
70
 
69
- // Clip video
70
- const clippedVideo = videoAUs.slice(startIdx, endIdx);
71
+ // Clip video starting from keyframe (for proper decoding)
72
+ const clippedVideo = videoAUs.slice(keyframeIdx, endIdx);
73
+
74
+ if (clippedVideo.length === 0) {
75
+ return {
76
+ video: [],
77
+ audio: [],
78
+ actualStartTime: startTime,
79
+ actualEndTime: endTime,
80
+ offset: 0,
81
+ preroll: 0
82
+ };
83
+ }
84
+
85
+ // Get PTS of keyframe and requested start
86
+ const keyframePts = clippedVideo[0].pts;
87
+ const lastFramePts = clippedVideo[clippedVideo.length - 1].pts;
71
88
 
72
- // Get actual PTS range from clipped video
73
- const actualStartPts = clippedVideo.length > 0 ? clippedVideo[0].pts : 0;
74
- const actualEndPts = clippedVideo.length > 0 ? clippedVideo[clippedVideo.length - 1].pts : 0;
89
+ // Pre-roll: time between keyframe and requested start
90
+ // This is the time the decoder needs to process but player shouldn't display
91
+ const prerollPts = Math.max(0, startPts - keyframePts);
75
92
 
76
- // Clip audio to match video time range
77
- const clippedAudio = audioAUs.filter(au => au.pts >= actualStartPts && au.pts <= actualEndPts);
93
+ // Clip audio to the REQUESTED time range (not from keyframe)
94
+ // Audio doesn't need keyframe pre-roll
95
+ const audioStartPts = startPts;
96
+ const audioEndPts = Math.min(endPts, lastFramePts + 90000); // Include audio slightly past last video
97
+ const clippedAudio = audioAUs.filter(au => au.pts >= audioStartPts && au.pts < audioEndPts);
78
98
 
79
- // Normalize timestamps so clip starts at 0
80
- const offset = actualStartPts;
99
+ // Normalize video timestamps so keyframe starts at 0
100
+ const offset = keyframePts;
81
101
  for (const au of clippedVideo) {
82
102
  au.pts -= offset;
83
103
  au.dts -= offset;
84
104
  }
105
+
106
+ // Normalize audio timestamps so it starts at 0 (matching video playback start after preroll)
107
+ // Audio doesn't have preroll, so it should start at PTS 0 to sync with video after edit list
108
+ const audioOffset = audioStartPts; // Use requested start, not keyframe
85
109
  for (const au of clippedAudio) {
86
- au.pts -= offset;
110
+ au.pts -= audioOffset;
87
111
  }
88
112
 
89
113
  return {
90
114
  video: clippedVideo,
91
115
  audio: clippedAudio,
92
- actualStartTime: actualStartPts / PTS_PER_SECOND,
93
- actualEndTime: actualEndPts / PTS_PER_SECOND,
94
- offset
116
+ actualStartTime: keyframePts / PTS_PER_SECOND, // Where decode starts (keyframe)
117
+ actualEndTime: lastFramePts / PTS_PER_SECOND,
118
+ requestedStartTime: startTime, // Where playback should start
119
+ requestedEndTime: endTime,
120
+ offset,
121
+ preroll: prerollPts // Edit list will use this to skip pre-roll frames during playback
95
122
  };
96
123
  }
97
124
 
@@ -225,6 +252,9 @@ export function convertTsToMp4(tsData, options = {}) {
225
252
 
226
253
  log(`Processing...`, { phase: 'convert', percent: 70 });
227
254
 
255
+ // Track preroll for edit list (used for precise clipping)
256
+ let clipPreroll = 0;
257
+
228
258
  // Apply time range clipping if specified
229
259
  if (options.startTime !== undefined || options.endTime !== undefined) {
230
260
  const startTime = options.startTime || 0;
@@ -239,17 +269,23 @@ export function convertTsToMp4(tsData, options = {}) {
239
269
 
240
270
  parser.videoAccessUnits = clipResult.video;
241
271
  parser.audioAccessUnits = clipResult.audio;
272
+ clipPreroll = clipResult.preroll;
242
273
 
243
274
  // Update PTS arrays to match
244
275
  parser.videoPts = clipResult.video.map(au => au.pts);
245
276
  parser.videoDts = clipResult.video.map(au => au.dts);
246
277
  parser.audioPts = clipResult.audio.map(au => au.pts);
247
278
 
248
- log(`Clipped: ${clipResult.actualStartTime.toFixed(2)}s - ${clipResult.actualEndTime.toFixed(2)}s (${clipResult.video.length} video, ${clipResult.audio.length} audio frames)`, { phase: 'convert', percent: 80 });
279
+ const prerollMs = (clipPreroll / 90).toFixed(0);
280
+ const endTimeStr = clipResult.requestedEndTime === Infinity ? 'end' : clipResult.requestedEndTime.toFixed(2) + 's';
281
+ const clipDuration = clipResult.requestedEndTime === Infinity
282
+ ? (clipResult.actualEndTime - clipResult.requestedStartTime).toFixed(2)
283
+ : (clipResult.requestedEndTime - clipResult.requestedStartTime).toFixed(2);
284
+ log(`Clipped: ${clipResult.requestedStartTime.toFixed(2)}s - ${endTimeStr} (${clipDuration}s, ${prerollMs}ms preroll)`, { phase: 'convert', percent: 80 });
249
285
  }
250
286
 
251
287
  log(`Building MP4...`, { phase: 'convert', percent: 85 });
252
- const muxer = new MP4Muxer(parser);
288
+ const muxer = new MP4Muxer(parser, { preroll: clipPreroll });
253
289
  const { width, height } = muxer.getVideoDimensions();
254
290
  log(`Dimensions: ${width}x${height}`);
255
291