@invintusmedia/tomp4 1.0.6 → 1.0.7

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.7
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,34 +73,52 @@
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
+ }
92
105
 
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;
106
+ // Get PTS of keyframe and requested start
107
+ const keyframePts = clippedVideo[0].pts;
108
+ const lastFramePts = clippedVideo[clippedVideo.length - 1].pts;
96
109
 
97
- // Clip audio to match video time range
98
- const clippedAudio = audioAUs.filter(au => au.pts >= actualStartPts && au.pts <= actualEndPts);
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);
99
113
 
100
- // Normalize timestamps so clip starts at 0
101
- const offset = actualStartPts;
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);
118
+ const clippedAudio = audioAUs.filter(au => au.pts >= audioStartPts && au.pts < audioEndPts);
119
+
120
+ // Normalize all 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;
@@ -110,9 +130,12 @@
110
130
  return {
111
131
  video: clippedVideo,
112
132
  audio: clippedAudio,
113
- actualStartTime: actualStartPts / PTS_PER_SECOND,
114
- actualEndTime: actualEndPts / PTS_PER_SECOND,
115
- offset
133
+ actualStartTime: keyframePts / PTS_PER_SECOND, // Where decode starts (keyframe)
134
+ actualEndTime: lastFramePts / PTS_PER_SECOND,
135
+ requestedStartTime: startTime, // Where playback should start
136
+ requestedEndTime: endTime,
137
+ offset,
138
+ preroll: prerollPts // Edit list will use this to skip pre-roll frames during playback
116
139
  };
117
140
  }
118
141
 
@@ -246,6 +269,9 @@
246
269
 
247
270
  log(`Processing...`, { phase: 'convert', percent: 70 });
248
271
 
272
+ // Track preroll for edit list (used for precise clipping)
273
+ let clipPreroll = 0;
274
+
249
275
  // Apply time range clipping if specified
250
276
  if (options.startTime !== undefined || options.endTime !== undefined) {
251
277
  const startTime = options.startTime || 0;
@@ -260,17 +286,23 @@
260
286
 
261
287
  parser.videoAccessUnits = clipResult.video;
262
288
  parser.audioAccessUnits = clipResult.audio;
289
+ clipPreroll = clipResult.preroll;
263
290
 
264
291
  // Update PTS arrays to match
265
292
  parser.videoPts = clipResult.video.map(au => au.pts);
266
293
  parser.videoDts = clipResult.video.map(au => au.dts);
267
294
  parser.audioPts = clipResult.audio.map(au => au.pts);
268
295
 
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 });
296
+ const prerollMs = (clipPreroll / 90).toFixed(0);
297
+ const endTimeStr = clipResult.requestedEndTime === Infinity ? 'end' : clipResult.requestedEndTime.toFixed(2) + 's';
298
+ const clipDuration = clipResult.requestedEndTime === Infinity
299
+ ? (clipResult.actualEndTime - clipResult.requestedStartTime).toFixed(2)
300
+ : (clipResult.requestedEndTime - clipResult.requestedStartTime).toFixed(2);
301
+ log(`Clipped: ${clipResult.requestedStartTime.toFixed(2)}s - ${endTimeStr} (${clipDuration}s, ${prerollMs}ms preroll)`, { phase: 'convert', percent: 80 });
270
302
  }
271
303
 
272
304
  log(`Building MP4...`, { phase: 'convert', percent: 85 });
273
- const muxer = new MP4Muxer(parser);
305
+ const muxer = new MP4Muxer(parser, { preroll: clipPreroll });
274
306
  const { width, height } = muxer.getVideoDimensions();
275
307
  log(`Dimensions: ${width}x${height}`);
276
308
 
@@ -720,7 +752,7 @@
720
752
  toMp4.isMpegTs = isMpegTs;
721
753
  toMp4.isFmp4 = isFmp4;
722
754
  toMp4.isStandardMp4 = isStandardMp4;
723
- toMp4.version = '1.0.6';
755
+ toMp4.version = '1.0.7';
724
756
 
725
757
  return toMp4;
726
758
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invintusmedia/tomp4",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
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.7';
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;
301
314
 
302
- const duration = this.calculateVideoDuration();
303
- const mediaTime = firstVideoPts;
315
+ // If no offset needed, skip edit list
316
+ if (mediaTime === 0) return null;
317
+
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);
@@ -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,34 +52,52 @@ 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
+ }
71
84
 
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;
85
+ // Get PTS of keyframe and requested start
86
+ const keyframePts = clippedVideo[0].pts;
87
+ const lastFramePts = clippedVideo[clippedVideo.length - 1].pts;
75
88
 
76
- // Clip audio to match video time range
77
- const clippedAudio = audioAUs.filter(au => au.pts >= actualStartPts && au.pts <= actualEndPts);
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);
78
92
 
79
- // Normalize timestamps so clip starts at 0
80
- const offset = actualStartPts;
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);
97
+ const clippedAudio = audioAUs.filter(au => au.pts >= audioStartPts && au.pts < audioEndPts);
98
+
99
+ // Normalize all 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;
@@ -89,9 +109,12 @@ function clipAccessUnits(videoAUs, audioAUs, startTime, endTime) {
89
109
  return {
90
110
  video: clippedVideo,
91
111
  audio: clippedAudio,
92
- actualStartTime: actualStartPts / PTS_PER_SECOND,
93
- actualEndTime: actualEndPts / PTS_PER_SECOND,
94
- offset
112
+ actualStartTime: keyframePts / PTS_PER_SECOND, // Where decode starts (keyframe)
113
+ actualEndTime: lastFramePts / PTS_PER_SECOND,
114
+ requestedStartTime: startTime, // Where playback should start
115
+ requestedEndTime: endTime,
116
+ offset,
117
+ preroll: prerollPts // Edit list will use this to skip pre-roll frames during playback
95
118
  };
96
119
  }
97
120
 
@@ -225,6 +248,9 @@ export function convertTsToMp4(tsData, options = {}) {
225
248
 
226
249
  log(`Processing...`, { phase: 'convert', percent: 70 });
227
250
 
251
+ // Track preroll for edit list (used for precise clipping)
252
+ let clipPreroll = 0;
253
+
228
254
  // Apply time range clipping if specified
229
255
  if (options.startTime !== undefined || options.endTime !== undefined) {
230
256
  const startTime = options.startTime || 0;
@@ -239,17 +265,23 @@ export function convertTsToMp4(tsData, options = {}) {
239
265
 
240
266
  parser.videoAccessUnits = clipResult.video;
241
267
  parser.audioAccessUnits = clipResult.audio;
268
+ clipPreroll = clipResult.preroll;
242
269
 
243
270
  // Update PTS arrays to match
244
271
  parser.videoPts = clipResult.video.map(au => au.pts);
245
272
  parser.videoDts = clipResult.video.map(au => au.dts);
246
273
  parser.audioPts = clipResult.audio.map(au => au.pts);
247
274
 
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 });
275
+ const prerollMs = (clipPreroll / 90).toFixed(0);
276
+ const endTimeStr = clipResult.requestedEndTime === Infinity ? 'end' : clipResult.requestedEndTime.toFixed(2) + 's';
277
+ const clipDuration = clipResult.requestedEndTime === Infinity
278
+ ? (clipResult.actualEndTime - clipResult.requestedStartTime).toFixed(2)
279
+ : (clipResult.requestedEndTime - clipResult.requestedStartTime).toFixed(2);
280
+ log(`Clipped: ${clipResult.requestedStartTime.toFixed(2)}s - ${endTimeStr} (${clipDuration}s, ${prerollMs}ms preroll)`, { phase: 'convert', percent: 80 });
249
281
  }
250
282
 
251
283
  log(`Building MP4...`, { phase: 'convert', percent: 85 });
252
- const muxer = new MP4Muxer(parser);
284
+ const muxer = new MP4Muxer(parser, { preroll: clipPreroll });
253
285
  const { width, height } = muxer.getVideoDimensions();
254
286
  log(`Dimensions: ${width}x${height}`);
255
287