@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 +54 -22
- package/package.json +2 -1
- package/src/index.js +1 -1
- package/src/muxers/mp4.js +30 -11
- package/src/muxers/mpegts.js +1 -0
- package/src/ts-to-mp4.js +52 -20
package/dist/tomp4.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toMp4.js v1.0.
|
|
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
|
|
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]))
|
|
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 =
|
|
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(
|
|
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
|
|
94
|
-
const
|
|
95
|
-
const
|
|
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
|
-
//
|
|
98
|
-
|
|
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
|
-
//
|
|
101
|
-
|
|
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:
|
|
114
|
-
actualEndTime:
|
|
115
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
312
|
+
// Use preroll for precise clipping, or firstVideoPts for timestamp normalization
|
|
313
|
+
const mediaTime = this.preroll > 0 ? this.preroll : firstVideoPts;
|
|
301
314
|
|
|
302
|
-
|
|
303
|
-
|
|
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,
|
|
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.
|
|
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);
|
package/src/muxers/mpegts.js
CHANGED
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
|
|
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]))
|
|
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 =
|
|
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(
|
|
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
|
|
73
|
-
const
|
|
74
|
-
const
|
|
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
|
-
//
|
|
77
|
-
|
|
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
|
-
//
|
|
80
|
-
|
|
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:
|
|
93
|
-
actualEndTime:
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|