@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.
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Remote MP4 Parser
3
+ *
4
+ * Fetch and parse MP4 files remotely using byte-range requests.
5
+ * Only downloads metadata (moov) upfront, then fetches segments on-demand.
6
+ *
7
+ * @example
8
+ * import { RemoteMp4 } from 'tomp4';
9
+ *
10
+ * const source = await RemoteMp4.fromUrl('https://example.com/video.mp4');
11
+ *
12
+ * // Get HLS playlists
13
+ * const masterPlaylist = source.getMasterPlaylist();
14
+ * const mediaPlaylist = source.getMediaPlaylist();
15
+ *
16
+ * // Get a segment as MPEG-TS
17
+ * const tsData = await source.getSegment(0);
18
+ *
19
+ * @module remote
20
+ */
21
+
22
+ import {
23
+ readUint32, boxType, findBox,
24
+ analyzeTrack, buildSampleTable, buildSegments, calculateByteRanges
25
+ } from '../parsers/mp4.js';
26
+
27
+ import { TSMuxer } from '../muxers/mpegts.js';
28
+
29
+ // ============================================================================
30
+ // Configuration
31
+ // ============================================================================
32
+
33
+ const DEFAULT_SEGMENT_DURATION = 4; // seconds
34
+ const FETCH_TIMEOUT = 30000; // 30 seconds
35
+ const MAX_HEADER_SIZE = 256 * 1024; // 256KB for initial probe
36
+ const MAX_TAIL_SIZE = 2 * 1024 * 1024; // 2MB for moov at end
37
+
38
+ // ============================================================================
39
+ // Fetch Utilities
40
+ // ============================================================================
41
+
42
+ async function fetchWithTimeout(url, options = {}) {
43
+ const controller = new AbortController();
44
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
45
+
46
+ try {
47
+ const response = await fetch(url, {
48
+ ...options,
49
+ signal: controller.signal
50
+ });
51
+ return response;
52
+ } catch (err) {
53
+ if (err.name === 'AbortError') {
54
+ throw new Error(`Fetch timeout after ${FETCH_TIMEOUT}ms`);
55
+ }
56
+ throw err;
57
+ } finally {
58
+ clearTimeout(timeout);
59
+ }
60
+ }
61
+
62
+ async function fetchRange(url, start, end) {
63
+ const response = await fetchWithTimeout(url, {
64
+ headers: { 'Range': `bytes=${start}-${end}` }
65
+ });
66
+ if (!response.ok && response.status !== 206) {
67
+ throw new Error(`HTTP ${response.status}`);
68
+ }
69
+ return new Uint8Array(await response.arrayBuffer());
70
+ }
71
+
72
+ async function fetchFileSize(url) {
73
+ const response = await fetchWithTimeout(url, { method: 'HEAD' });
74
+ return parseInt(response.headers.get('content-length'), 10);
75
+ }
76
+
77
+ // ============================================================================
78
+ // ADTS Wrapper for AAC
79
+ // ============================================================================
80
+
81
+ function wrapADTS(aacData, sampleRate, channels) {
82
+ const sampleRateIndex = [96000, 88200, 64000, 48000, 44100, 32000, 24000,
83
+ 22050, 16000, 12000, 11025, 8000, 7350].indexOf(sampleRate);
84
+ const frameLength = aacData.length + 7;
85
+
86
+ const adts = new Uint8Array(7 + aacData.length);
87
+ adts[0] = 0xFF;
88
+ adts[1] = 0xF1;
89
+ adts[2] = ((2 - 1) << 6) | ((sampleRateIndex < 0 ? 4 : sampleRateIndex) << 2) | ((channels >> 2) & 0x01);
90
+ adts[3] = ((channels & 0x03) << 6) | ((frameLength >> 11) & 0x03);
91
+ adts[4] = (frameLength >> 3) & 0xFF;
92
+ adts[5] = ((frameLength & 0x07) << 5) | 0x1F;
93
+ adts[6] = 0xFC;
94
+ adts.set(aacData, 7);
95
+
96
+ return adts;
97
+ }
98
+
99
+ // ============================================================================
100
+ // RemoteMp4 Class
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Remote MP4 source with on-demand HLS segment generation
105
+ */
106
+ export class RemoteMp4 {
107
+ /**
108
+ * Create a RemoteMp4 instance from a URL
109
+ * @param {string} url - URL to the MP4 file
110
+ * @param {object} options - Options
111
+ * @param {number} options.segmentDuration - Target segment duration (default 4s)
112
+ * @param {function} options.onProgress - Progress callback
113
+ * @returns {Promise<RemoteMp4>}
114
+ */
115
+ static async fromUrl(url, options = {}) {
116
+ const instance = new RemoteMp4(url, options);
117
+ await instance._init();
118
+ return instance;
119
+ }
120
+
121
+ constructor(url, options = {}) {
122
+ this.url = url;
123
+ this.segmentDuration = options.segmentDuration || DEFAULT_SEGMENT_DURATION;
124
+ this.onProgress = options.onProgress || (() => {});
125
+
126
+ // Populated by _init()
127
+ this.fileSize = 0;
128
+ this.moov = null;
129
+ this.videoTrack = null;
130
+ this.audioTrack = null;
131
+ this.videoSamples = [];
132
+ this.audioSamples = [];
133
+ this.segments = [];
134
+
135
+ // Computed properties
136
+ this.duration = 0;
137
+ this.width = 0;
138
+ this.height = 0;
139
+ this.hasAudio = false;
140
+ this.hasBframes = false;
141
+ }
142
+
143
+ async _init() {
144
+ this.onProgress('Fetching metadata...');
145
+
146
+ // Get file size
147
+ this.fileSize = await fetchFileSize(this.url);
148
+
149
+ // Find and fetch moov box
150
+ this.moov = await this._findMoov();
151
+
152
+ // Parse tracks using shared parser
153
+ let trackOffset = 8;
154
+ while (trackOffset < this.moov.length) {
155
+ const trak = findBox(this.moov, 'trak', trackOffset);
156
+ if (!trak) break;
157
+
158
+ const track = analyzeTrack(this.moov, trak.offset, trak.size);
159
+ if (track) {
160
+ if (track.type === 'vide' && !this.videoTrack) {
161
+ this.videoTrack = track;
162
+ this.videoSamples = buildSampleTable(track);
163
+ this.duration = track.durationSeconds;
164
+ this.width = track.width;
165
+ this.height = track.height;
166
+ this.hasBframes = track.ctts && track.ctts.length > 0;
167
+ } else if (track.type === 'soun' && !this.audioTrack) {
168
+ this.audioTrack = track;
169
+ this.audioSamples = buildSampleTable(track);
170
+ this.hasAudio = true;
171
+ }
172
+ }
173
+ trackOffset = trak.offset + trak.size;
174
+ }
175
+
176
+ if (!this.videoTrack) {
177
+ throw new Error('No video track found');
178
+ }
179
+
180
+ // Build segments
181
+ this.segments = buildSegments(this.videoSamples, this.segmentDuration);
182
+
183
+ this.onProgress(`Parsed: ${this.duration.toFixed(1)}s, ${this.segments.length} segments`);
184
+ }
185
+
186
+ async _findMoov() {
187
+ const headerSize = Math.min(MAX_HEADER_SIZE, this.fileSize);
188
+ const header = await fetchRange(this.url, 0, headerSize - 1);
189
+
190
+ // Scan header for boxes
191
+ let offset = 0;
192
+ while (offset < header.length - 8) {
193
+ const size = readUint32(header, offset);
194
+ const type = boxType(header, offset + 4);
195
+
196
+ if (size === 0 || size > this.fileSize) break;
197
+
198
+ if (type === 'moov') {
199
+ // moov in header - fetch complete if needed
200
+ if (offset + size <= header.length) {
201
+ return header.slice(offset, offset + size);
202
+ }
203
+ return fetchRange(this.url, offset, offset + size - 1);
204
+ }
205
+
206
+ if (type === 'mdat') {
207
+ // mdat at start means moov is at end
208
+ const moovOffset = offset + size;
209
+ if (moovOffset < this.fileSize) {
210
+ const tailSize = Math.min(MAX_TAIL_SIZE, this.fileSize - moovOffset);
211
+ const tail = await fetchRange(this.url, moovOffset, moovOffset + tailSize - 1);
212
+ const moov = findBox(tail, 'moov');
213
+ if (moov) {
214
+ if (moov.size <= tail.length) {
215
+ return tail.slice(moov.offset, moov.offset + moov.size);
216
+ }
217
+ return fetchRange(this.url, moovOffset + moov.offset,
218
+ moovOffset + moov.offset + moov.size - 1);
219
+ }
220
+ }
221
+ break;
222
+ }
223
+
224
+ offset += size;
225
+ }
226
+
227
+ // Try end of file as fallback
228
+ const tailSize = Math.min(MAX_TAIL_SIZE, this.fileSize);
229
+ const tail = await fetchRange(this.url, this.fileSize - tailSize, this.fileSize - 1);
230
+ const moov = findBox(tail, 'moov');
231
+
232
+ if (moov) {
233
+ const moovStart = this.fileSize - tailSize + moov.offset;
234
+ return fetchRange(this.url, moovStart, moovStart + moov.size - 1);
235
+ }
236
+
237
+ // Check for fragmented MP4
238
+ const moof = findBox(header, 'moof');
239
+ if (moof) {
240
+ throw new Error('Fragmented MP4 (fMP4) not supported');
241
+ }
242
+
243
+ throw new Error('Could not find moov box');
244
+ }
245
+
246
+ // ===========================================================================
247
+ // Public API
248
+ // ===========================================================================
249
+
250
+ /**
251
+ * Get source information
252
+ */
253
+ getInfo() {
254
+ return {
255
+ url: this.url,
256
+ fileSize: this.fileSize,
257
+ duration: this.duration,
258
+ width: this.width,
259
+ height: this.height,
260
+ hasAudio: this.hasAudio,
261
+ hasBframes: this.hasBframes,
262
+ segmentCount: this.segments.length,
263
+ videoSampleCount: this.videoSamples.length,
264
+ audioSampleCount: this.audioSamples.length,
265
+ keyframeCount: this.videoTrack?.stss?.length || 0
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Get segment definitions
271
+ */
272
+ getSegments() {
273
+ return this.segments.map(s => ({
274
+ index: s.index,
275
+ startTime: s.startTime,
276
+ endTime: s.endTime,
277
+ duration: s.duration
278
+ }));
279
+ }
280
+
281
+ /**
282
+ * Generate HLS master playlist
283
+ */
284
+ getMasterPlaylist(baseUrl = '') {
285
+ const bandwidth = Math.round(
286
+ (this.videoSamples.reduce((s, v) => s + v.size, 0) / this.duration) * 8
287
+ );
288
+
289
+ const resolution = this.width && this.height ?
290
+ `,RESOLUTION=${this.width}x${this.height}` : '';
291
+
292
+ return `#EXTM3U
293
+ #EXT-X-VERSION:3
294
+ #EXT-X-STREAM-INF:BANDWIDTH=${bandwidth}${resolution}
295
+ ${baseUrl}playlist.m3u8
296
+ `;
297
+ }
298
+
299
+ /**
300
+ * Generate HLS media playlist
301
+ */
302
+ getMediaPlaylist(baseUrl = '') {
303
+ let playlist = `#EXTM3U
304
+ #EXT-X-VERSION:3
305
+ #EXT-X-TARGETDURATION:${Math.ceil(this.segmentDuration)}
306
+ #EXT-X-MEDIA-SEQUENCE:0
307
+ #EXT-X-PLAYLIST-TYPE:VOD
308
+ `;
309
+
310
+ for (const segment of this.segments) {
311
+ playlist += `#EXTINF:${segment.duration.toFixed(6)},\n${baseUrl}segment${segment.index}.ts\n`;
312
+ }
313
+
314
+ playlist += '#EXT-X-ENDLIST\n';
315
+ return playlist;
316
+ }
317
+
318
+ /**
319
+ * Get a segment as MPEG-TS data
320
+ * @param {number} index - Segment index
321
+ * @returns {Promise<Uint8Array>} MPEG-TS segment data
322
+ */
323
+ async getSegment(index) {
324
+ const segment = this.segments[index];
325
+ if (!segment) {
326
+ throw new Error(`Segment ${index} not found`);
327
+ }
328
+
329
+ // Get samples for this segment
330
+ const videoSamples = this.videoSamples.slice(segment.videoStart, segment.videoEnd);
331
+ const audioSamples = this.audioSamples.filter(
332
+ s => s.time >= segment.startTime && s.time < segment.endTime
333
+ );
334
+
335
+ // Fetch video data using byte ranges
336
+ const videoRanges = calculateByteRanges(videoSamples);
337
+ const videoData = await this._fetchRanges(videoRanges);
338
+
339
+ // Map video sample data
340
+ const parsedVideoSamples = videoSamples.map(sample => {
341
+ const range = videoRanges.find(r => r.samples.includes(sample));
342
+ const data = videoData.get(range);
343
+ const relOffset = sample.offset - range.start;
344
+ return {
345
+ ...sample,
346
+ data: data.slice(relOffset, relOffset + sample.size)
347
+ };
348
+ });
349
+
350
+ // Fetch and map audio data
351
+ let parsedAudioSamples = [];
352
+ if (audioSamples.length > 0) {
353
+ const audioRanges = calculateByteRanges(audioSamples);
354
+ const audioData = await this._fetchRanges(audioRanges);
355
+
356
+ parsedAudioSamples = audioSamples.map(sample => {
357
+ const range = audioRanges.find(r => r.samples.includes(sample));
358
+ const data = audioData.get(range);
359
+ const relOffset = sample.offset - range.start;
360
+ return {
361
+ ...sample,
362
+ data: data.slice(relOffset, relOffset + sample.size)
363
+ };
364
+ });
365
+ }
366
+
367
+ // Build MPEG-TS segment
368
+ return this._buildTsSegment(parsedVideoSamples, parsedAudioSamples);
369
+ }
370
+
371
+ async _fetchRanges(ranges) {
372
+ const results = new Map();
373
+
374
+ // Fetch ranges in parallel
375
+ await Promise.all(ranges.map(async range => {
376
+ const data = await fetchRange(this.url, range.start, range.end - 1);
377
+ results.set(range, data);
378
+ }));
379
+
380
+ return results;
381
+ }
382
+
383
+ _buildTsSegment(videoSamples, audioSamples) {
384
+ const muxer = new TSMuxer();
385
+
386
+ if (this.videoTrack?.codecConfig) {
387
+ muxer.setSpsPps(
388
+ this.videoTrack.codecConfig.sps[0],
389
+ this.videoTrack.codecConfig.pps[0]
390
+ );
391
+ }
392
+
393
+ muxer.setHasAudio(audioSamples.length > 0);
394
+
395
+ const PTS_PER_SECOND = 90000;
396
+ const sampleRate = this.audioTrack?.audioConfig?.sampleRate || 44100;
397
+ const channels = this.audioTrack?.audioConfig?.channels || 2;
398
+
399
+ // Add audio samples
400
+ for (const sample of audioSamples) {
401
+ const dts90k = Math.round((sample.dts ?? sample.time) * PTS_PER_SECOND);
402
+ const adts = wrapADTS(sample.data, sampleRate, channels);
403
+ muxer.addAudioSample(adts, dts90k);
404
+ }
405
+
406
+ // Add video samples with PTS and DTS
407
+ for (const sample of videoSamples) {
408
+ const pts90k = Math.round((sample.pts ?? sample.time) * PTS_PER_SECOND);
409
+ const dts90k = Math.round((sample.dts ?? sample.time) * PTS_PER_SECOND);
410
+ muxer.addVideoSample(sample.data, sample.isKeyframe, pts90k, dts90k);
411
+ }
412
+
413
+ muxer.flush();
414
+ return muxer.build();
415
+ }
416
+ }
417
+
418
+ export default RemoteMp4;
package/src/transcode.js CHANGED
@@ -551,11 +551,9 @@ function createAvcC(sps, pps) {
551
551
  // ============================================
552
552
 
553
553
  /**
554
- * Transcode video using WebCodecs (browser-only)
554
+ * Transcode MPEG-TS video using WebCodecs (browser-only)
555
555
  *
556
- * Supports both MPEG-TS and MP4 input files.
557
- *
558
- * @param {Uint8Array} data - Input video data (MPEG-TS or MP4)
556
+ * @param {Uint8Array} tsData - Input MPEG-TS data
559
557
  * @param {Object} [options] - Transcode options
560
558
  * @param {number} [options.width] - Output width (default: same as input)
561
559
  * @param {number} [options.height] - Output height (default: same as input)
@@ -565,14 +563,14 @@ function createAvcC(sps, pps) {
565
563
  * @returns {Promise<Uint8Array>} - Transcoded MPEG-TS data
566
564
  *
567
565
  * @example
568
- * const output = await transcode(videoData, {
566
+ * const output = await transcode(tsData, {
569
567
  * width: 640,
570
568
  * height: 360,
571
569
  * bitrate: 1_000_000,
572
570
  * onProgress: msg => console.log(msg)
573
571
  * });
574
572
  */
575
- export async function transcode(data, options = {}) {
573
+ export async function transcode(tsData, options = {}) {
576
574
  requireWebCodecs();
577
575
 
578
576
  const log = options.onProgress || (() => {});
@@ -581,37 +579,12 @@ export async function transcode(data, options = {}) {
581
579
  keyFrameInterval = 30
582
580
  } = options;
583
581
 
584
- // Detect input format and parse
585
- let parser;
586
- let sps = null, pps = null;
587
582
 
588
- if (isMp4(data)) {
589
- log('Parsing input MP4...');
590
- parser = new MP4Parser();
591
- parser.parse(data);
592
- parser.finalize();
593
-
594
- // Get SPS/PPS directly from MP4 parser
595
- sps = parser.sps;
596
- pps = parser.pps;
597
- } else if (isMpegTs(data)) {
598
- log('Parsing input MPEG-TS...');
599
- parser = new TSParser();
600
- parser.parse(data);
601
- parser.finalize();
602
-
603
- // Find SPS/PPS in NAL units
604
- for (const au of parser.videoAccessUnits) {
605
- for (const nal of au.nalUnits) {
606
- const t = nal[0] & 0x1f;
607
- if (t === 7 && !sps) sps = nal;
608
- if (t === 8 && !pps) pps = nal;
609
- }
610
- if (sps && pps) break;
611
- }
612
- } else {
613
- throw new Error('Unsupported input format. Expected MPEG-TS or MP4.');
614
- }
583
+ // Parse input TS
584
+ log('Parsing input MPEG-TS...');
585
+ const parser = new TSParser();
586
+ parser.parse(tsData);
587
+ parser.finalize();
615
588
 
616
589
  if (!parser.videoAccessUnits || parser.videoAccessUnits.length === 0) {
617
590
  throw new Error('No video found in input');
@@ -625,6 +598,17 @@ export async function transcode(data, options = {}) {
625
598
  log(`Found ${parser.audioAccessUnits.length} audio frames (will passthrough)`);
626
599
  }
627
600
 
601
+ // Find SPS/PPS
602
+ let sps = null, pps = null;
603
+ for (const au of parser.videoAccessUnits) {
604
+ for (const nal of au.nalUnits) {
605
+ const t = nal[0] & 0x1f;
606
+ if (t === 7 && !sps) sps = nal;
607
+ if (t === 8 && !pps) pps = nal;
608
+ }
609
+ if (sps && pps) break;
610
+ }
611
+
628
612
  if (!sps || !pps) {
629
613
  throw new Error('No SPS/PPS found in input');
630
614
  }
package/src/ts-to-mp4.js CHANGED
@@ -93,17 +93,21 @@ function clipAccessUnits(videoAUs, audioAUs, startTime, endTime) {
93
93
  // Clip audio to the REQUESTED time range (not from keyframe)
94
94
  // Audio doesn't need keyframe pre-roll
95
95
  const audioStartPts = startPts;
96
- const audioEndPts = Math.min(endPts, lastFramePts);
96
+ const audioEndPts = Math.min(endPts, lastFramePts + 90000); // Include audio slightly past last video
97
97
  const clippedAudio = audioAUs.filter(au => au.pts >= audioStartPts && au.pts < audioEndPts);
98
98
 
99
- // Normalize all timestamps so keyframe starts at 0
99
+ // Normalize video timestamps so keyframe starts at 0
100
100
  const offset = keyframePts;
101
101
  for (const au of clippedVideo) {
102
102
  au.pts -= offset;
103
103
  au.dts -= offset;
104
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
105
109
  for (const au of clippedAudio) {
106
- au.pts -= offset;
110
+ au.pts -= audioOffset;
107
111
  }
108
112
 
109
113
  return {