@invintusmedia/tomp4 1.0.7 → 1.0.9
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 +9 -5
- package/package.json +6 -3
- package/src/index.js +16 -2
- package/src/muxers/mp4.js +8 -4
- package/src/muxers/mpegts.js +47 -19
- package/src/parsers/mp4.js +691 -0
- package/src/remote/index.js +418 -0
- package/src/transcode.js +20 -36
- package/src/ts-to-mp4.js +7 -3
package/dist/tomp4.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toMp4.js v1.0.
|
|
2
|
+
* toMp4.js v1.0.9
|
|
3
3
|
* Convert MPEG-TS and fMP4 to standard MP4
|
|
4
4
|
* https://github.com/TVWIT/toMp4.js
|
|
5
5
|
* MIT License
|
|
@@ -114,17 +114,21 @@
|
|
|
114
114
|
// Clip audio to the REQUESTED time range (not from keyframe)
|
|
115
115
|
// Audio doesn't need keyframe pre-roll
|
|
116
116
|
const audioStartPts = startPts;
|
|
117
|
-
const audioEndPts = Math.min(endPts, lastFramePts);
|
|
117
|
+
const audioEndPts = Math.min(endPts, lastFramePts + 90000); // Include audio slightly past last video
|
|
118
118
|
const clippedAudio = audioAUs.filter(au => au.pts >= audioStartPts && au.pts < audioEndPts);
|
|
119
119
|
|
|
120
|
-
// Normalize
|
|
120
|
+
// Normalize video timestamps so keyframe starts at 0
|
|
121
121
|
const offset = keyframePts;
|
|
122
122
|
for (const au of clippedVideo) {
|
|
123
123
|
au.pts -= offset;
|
|
124
124
|
au.dts -= offset;
|
|
125
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
|
|
126
130
|
for (const au of clippedAudio) {
|
|
127
|
-
au.pts -=
|
|
131
|
+
au.pts -= audioOffset;
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
return {
|
|
@@ -752,7 +756,7 @@
|
|
|
752
756
|
toMp4.isMpegTs = isMpegTs;
|
|
753
757
|
toMp4.isFmp4 = isFmp4;
|
|
754
758
|
toMp4.isStandardMp4 = isStandardMp4;
|
|
755
|
-
toMp4.version = '1.0.
|
|
759
|
+
toMp4.version = '1.0.9';
|
|
756
760
|
|
|
757
761
|
return toMp4;
|
|
758
762
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invintusmedia/tomp4",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
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,8 +20,11 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "node build.js",
|
|
22
22
|
"dev": "npx serve . -p 3000",
|
|
23
|
-
"test": "
|
|
24
|
-
"
|
|
23
|
+
"test": "npm run test:clip && npm run test:mp4",
|
|
24
|
+
"test:clip": "node tests/clip.test.js",
|
|
25
|
+
"test:mp4": "node tests/mp4-parser.test.js",
|
|
26
|
+
"test:all": "npm run test",
|
|
27
|
+
"release": "npm test && 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",
|
|
25
28
|
"release:patch": "npm version patch --no-git-tag-version && npm run release",
|
|
26
29
|
"release:minor": "npm version minor --no-git-tag-version && npm run release",
|
|
27
30
|
"release:major": "npm version major --no-git-tag-version && npm run release",
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,8 @@ import { transcode, isWebCodecsSupported } from './transcode.js';
|
|
|
37
37
|
import { TSMuxer } from './muxers/mpegts.js';
|
|
38
38
|
import { MP4Muxer } from './muxers/mp4.js';
|
|
39
39
|
import { TSParser } from './parsers/mpegts.js';
|
|
40
|
+
import { MP4Parser } from './parsers/mp4.js';
|
|
41
|
+
import { RemoteMp4 } from './remote/index.js';
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* Result object returned by toMp4()
|
|
@@ -315,8 +317,15 @@ toMp4.analyze = analyzeTsData;
|
|
|
315
317
|
toMp4.transcode = transcode;
|
|
316
318
|
toMp4.isWebCodecsSupported = isWebCodecsSupported;
|
|
317
319
|
|
|
320
|
+
// Parsers
|
|
321
|
+
toMp4.MP4Parser = MP4Parser;
|
|
322
|
+
toMp4.TSParser = TSParser;
|
|
323
|
+
|
|
324
|
+
// Remote MP4 (on-demand HLS from remote MP4)
|
|
325
|
+
toMp4.RemoteMp4 = RemoteMp4;
|
|
326
|
+
|
|
318
327
|
// Version (injected at build time for dist, read from package.json for ESM)
|
|
319
|
-
toMp4.version = '1.0.
|
|
328
|
+
toMp4.version = '1.0.9';
|
|
320
329
|
|
|
321
330
|
// Export
|
|
322
331
|
export {
|
|
@@ -336,9 +345,14 @@ export {
|
|
|
336
345
|
HlsVariant,
|
|
337
346
|
// Transcoding (browser-only)
|
|
338
347
|
transcode,
|
|
348
|
+
isWebCodecsSupported,
|
|
349
|
+
// Muxers
|
|
339
350
|
TSMuxer,
|
|
340
351
|
MP4Muxer,
|
|
352
|
+
// Parsers
|
|
341
353
|
TSParser,
|
|
342
|
-
|
|
354
|
+
MP4Parser,
|
|
355
|
+
// Remote MP4 (on-demand HLS)
|
|
356
|
+
RemoteMp4
|
|
343
357
|
};
|
|
344
358
|
export default toMp4;
|
package/src/muxers/mp4.js
CHANGED
|
@@ -513,15 +513,19 @@ export class MP4Muxer {
|
|
|
513
513
|
if (this.parser.audioPts.length === 0) return null;
|
|
514
514
|
|
|
515
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
|
|
516
519
|
if (firstAudioPts === 0) return null;
|
|
517
520
|
|
|
521
|
+
// For non-clipped content, handle any timestamp offset
|
|
518
522
|
const mediaTime = Math.round(firstAudioPts * this.audioTimescale / 90000);
|
|
519
|
-
const
|
|
523
|
+
const audioDuration = this.audioSampleSizes.length * this.audioSampleDuration;
|
|
520
524
|
|
|
521
525
|
const elstData = new Uint8Array(16);
|
|
522
526
|
const view = new DataView(elstData.buffer);
|
|
523
527
|
view.setUint32(0, 1);
|
|
524
|
-
view.setUint32(4, Math.round(
|
|
528
|
+
view.setUint32(4, Math.round(audioDuration * this.videoTimescale / this.audioTimescale));
|
|
525
529
|
view.setInt32(8, mediaTime);
|
|
526
530
|
view.setUint16(12, 1);
|
|
527
531
|
view.setUint16(14, 0);
|
|
@@ -534,8 +538,8 @@ export class MP4Muxer {
|
|
|
534
538
|
const data = new Uint8Array(80);
|
|
535
539
|
const view = new DataView(data.buffer);
|
|
536
540
|
view.setUint32(8, 257);
|
|
537
|
-
|
|
538
|
-
view.setUint32(16,
|
|
541
|
+
// Use playback duration to match video track (for proper sync with preroll)
|
|
542
|
+
view.setUint32(16, this.calculatePlaybackDuration());
|
|
539
543
|
view.setUint16(32, 0x0100);
|
|
540
544
|
view.setUint32(36, 0x00010000); view.setUint32(52, 0x00010000); view.setUint32(68, 0x40000000);
|
|
541
545
|
return createFullBox('tkhd', 0, 3, data);
|
package/src/muxers/mpegts.js
CHANGED
|
@@ -85,8 +85,9 @@ export class TSMuxer {
|
|
|
85
85
|
* @param {Uint8Array} avccData - AVCC-formatted NAL units
|
|
86
86
|
* @param {boolean} isKey - Is this a keyframe
|
|
87
87
|
* @param {number} pts90k - Presentation timestamp in 90kHz ticks
|
|
88
|
+
* @param {number} [dts90k] - Decode timestamp in 90kHz ticks (defaults to pts90k)
|
|
88
89
|
*/
|
|
89
|
-
addVideoSample(avccData, isKey, pts90k) {
|
|
90
|
+
addVideoSample(avccData, isKey, pts90k, dts90k = pts90k) {
|
|
90
91
|
const nalUnits = [];
|
|
91
92
|
|
|
92
93
|
// Add AUD (Access Unit Delimiter) at start of each access unit
|
|
@@ -102,20 +103,28 @@ export class TSMuxer {
|
|
|
102
103
|
|
|
103
104
|
// Parse AVCC NALs and convert to Annex B
|
|
104
105
|
let offset = 0;
|
|
105
|
-
|
|
106
|
+
let iterations = 0;
|
|
107
|
+
const maxIterations = 10000;
|
|
108
|
+
|
|
109
|
+
while (offset + 4 <= avccData.length && iterations < maxIterations) {
|
|
110
|
+
iterations++;
|
|
106
111
|
const len = (avccData[offset] << 24) | (avccData[offset + 1] << 16) |
|
|
107
112
|
(avccData[offset + 2] << 8) | avccData[offset + 3];
|
|
108
113
|
offset += 4;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
|
|
115
|
+
// Safety: bail on invalid NAL length
|
|
116
|
+
if (len <= 0 || len > avccData.length - offset) {
|
|
117
|
+
break;
|
|
112
118
|
}
|
|
119
|
+
|
|
120
|
+
nalUnits.push(new Uint8Array([0, 0, 0, 1]));
|
|
121
|
+
nalUnits.push(avccData.slice(offset, offset + len));
|
|
113
122
|
offset += len;
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
// Build PES packet
|
|
125
|
+
// Build PES packet with both PTS and DTS
|
|
117
126
|
const annexB = concat(nalUnits);
|
|
118
|
-
const pes = this._buildVideoPES(annexB, pts90k);
|
|
127
|
+
const pes = this._buildVideoPES(annexB, pts90k, dts90k);
|
|
119
128
|
|
|
120
129
|
// Write PAT/PMT before keyframes
|
|
121
130
|
if (isKey) {
|
|
@@ -130,8 +139,8 @@ export class TSMuxer {
|
|
|
130
139
|
this._packetizePES(audioPes, 0x102, false, audio.pts, 'audio');
|
|
131
140
|
}
|
|
132
141
|
|
|
133
|
-
// Packetize video PES into 188-byte TS packets
|
|
134
|
-
this._packetizePES(pes, 0x101, isKey,
|
|
142
|
+
// Packetize video PES into 188-byte TS packets (use DTS for PCR)
|
|
143
|
+
this._packetizePES(pes, 0x101, isKey, dts90k, 'video');
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
/**
|
|
@@ -160,16 +169,32 @@ export class TSMuxer {
|
|
|
160
169
|
|
|
161
170
|
// --- Private methods ---
|
|
162
171
|
|
|
163
|
-
_buildVideoPES(payload, pts90k) {
|
|
164
|
-
|
|
172
|
+
_buildVideoPES(payload, pts90k, dts90k) {
|
|
173
|
+
// If PTS == DTS, only write PTS (saves 5 bytes per frame)
|
|
174
|
+
const hasDts = pts90k !== dts90k;
|
|
175
|
+
const headerLen = hasDts ? 10 : 5;
|
|
176
|
+
const pes = new Uint8Array(9 + headerLen + payload.length);
|
|
177
|
+
|
|
165
178
|
pes[0] = 0; pes[1] = 0; pes[2] = 1; // Start code
|
|
166
179
|
pes[3] = 0xE0; // Stream ID (video)
|
|
167
180
|
pes[4] = 0; pes[5] = 0; // Length = 0 (unbounded)
|
|
168
|
-
pes[6] = 0x80; // Flags
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
181
|
+
pes[6] = 0x80; // Flags: data_alignment
|
|
182
|
+
|
|
183
|
+
if (hasDts) {
|
|
184
|
+
// PTS + DTS present
|
|
185
|
+
pes[7] = 0xC0; // PTS_DTS_flags = 11
|
|
186
|
+
pes[8] = 10; // Header length: 5 (PTS) + 5 (DTS)
|
|
187
|
+
this._writePTS(pes, 9, pts90k, 0x31); // PTS marker = 0011
|
|
188
|
+
this._writePTS(pes, 14, dts90k, 0x11); // DTS marker = 0001
|
|
189
|
+
pes.set(payload, 19);
|
|
190
|
+
} else {
|
|
191
|
+
// PTS only
|
|
192
|
+
pes[7] = 0x80; // PTS_DTS_flags = 10
|
|
193
|
+
pes[8] = 5; // Header length: 5 (PTS)
|
|
194
|
+
this._writePTS(pes, 9, pts90k, 0x21); // PTS marker = 0010
|
|
195
|
+
pes.set(payload, 14);
|
|
196
|
+
}
|
|
197
|
+
|
|
173
198
|
return pes;
|
|
174
199
|
}
|
|
175
200
|
|
|
@@ -220,14 +245,17 @@ export class TSMuxer {
|
|
|
220
245
|
|
|
221
246
|
pkt[3] = 0x30 | (this.cc[cc] & 0x0F);
|
|
222
247
|
pkt[4] = afLen;
|
|
223
|
-
pkt[5] = 0x50; // PCR +
|
|
248
|
+
pkt[5] = 0x50; // PCR flag + random_access_indicator
|
|
249
|
+
|
|
250
|
+
// PCR = 33-bit base (90kHz) + 6 reserved bits + 9-bit extension (27MHz)
|
|
251
|
+
// We only use the base, extension = 0
|
|
224
252
|
const pcrBase = BigInt(pts90k);
|
|
225
253
|
pkt[6] = Number((pcrBase >> 25n) & 0xFFn);
|
|
226
254
|
pkt[7] = Number((pcrBase >> 17n) & 0xFFn);
|
|
227
255
|
pkt[8] = Number((pcrBase >> 9n) & 0xFFn);
|
|
228
256
|
pkt[9] = Number((pcrBase >> 1n) & 0xFFn);
|
|
229
|
-
pkt[10] = (Number(pcrBase & 1n) << 7) | 0x7E;
|
|
230
|
-
pkt[11] = 0;
|
|
257
|
+
pkt[10] = (Number(pcrBase & 1n) << 7) | 0x7E; // LSB of base + 6 reserved (111111)
|
|
258
|
+
pkt[11] = 0; // 9-bit extension = 0
|
|
231
259
|
|
|
232
260
|
pkt.set(pes.slice(offset, offset + payloadLen), 12);
|
|
233
261
|
offset += payloadLen;
|