@invintusmedia/tomp4 1.0.8 → 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 CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * toMp4.js v1.0.8
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
@@ -756,7 +756,7 @@
756
756
  toMp4.isMpegTs = isMpegTs;
757
757
  toMp4.isFmp4 = isFmp4;
758
758
  toMp4.isStandardMp4 = isStandardMp4;
759
- toMp4.version = '1.0.8';
759
+ toMp4.version = '1.0.9';
760
760
 
761
761
  return toMp4;
762
762
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invintusmedia/tomp4",
3
- "version": "1.0.8",
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": "node tests/clip.test.js",
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",
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.8';
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
- isWebCodecsSupported
354
+ MP4Parser,
355
+ // Remote MP4 (on-demand HLS)
356
+ RemoteMp4
343
357
  };
344
358
  export default toMp4;
@@ -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
- while (offset < avccData.length - 4) {
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
- if (len > 0 && offset + len <= avccData.length) {
110
- nalUnits.push(new Uint8Array([0, 0, 0, 1]));
111
- nalUnits.push(avccData.slice(offset, offset + len));
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, pts90k, 'video');
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
- const pes = new Uint8Array(14 + payload.length);
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
- pes[7] = 0x80; // PTS present
170
- pes[8] = 5; // Header length
171
- this._writePTS(pes, 9, pts90k, 0x21);
172
- pes.set(payload, 14);
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 + random_access
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;
@@ -0,0 +1,691 @@
1
+ /**
2
+ * MP4 Parser
3
+ *
4
+ * Parse MP4 files to extract tracks, samples, and metadata.
5
+ * Works with both local data (Uint8Array) and can be extended for remote sources.
6
+ *
7
+ * @example
8
+ * // Parse local MP4 data
9
+ * const parser = new MP4Parser(uint8ArrayData);
10
+ * console.log(parser.duration, parser.videoTrack, parser.audioTrack);
11
+ *
12
+ * // Get sample table for a track
13
+ * const samples = parser.getVideoSamples();
14
+ *
15
+ * // Build HLS segments
16
+ * const segments = parser.buildSegments(4); // 4 second segments
17
+ *
18
+ * @module parsers/mp4
19
+ */
20
+
21
+ // ============================================================================
22
+ // Binary Reading Utilities
23
+ // ============================================================================
24
+
25
+ export function readUint32(data, offset) {
26
+ return (data[offset] << 24) | (data[offset + 1] << 16) |
27
+ (data[offset + 2] << 8) | data[offset + 3];
28
+ }
29
+
30
+ export function readUint64(data, offset) {
31
+ // For simplicity, only handle lower 32 bits (files < 4GB)
32
+ return readUint32(data, offset + 4);
33
+ }
34
+
35
+ export function readInt32(data, offset) {
36
+ const val = readUint32(data, offset);
37
+ return (val & 0x80000000) ? val - 0x100000000 : val;
38
+ }
39
+
40
+ export function boxType(data, offset) {
41
+ return String.fromCharCode(
42
+ data[offset], data[offset + 1],
43
+ data[offset + 2], data[offset + 3]
44
+ );
45
+ }
46
+
47
+ // ============================================================================
48
+ // Box Finding
49
+ // ============================================================================
50
+
51
+ /**
52
+ * Find a box by type within a data range
53
+ * @param {Uint8Array} data - MP4 data
54
+ * @param {string} type - 4-character box type (e.g., 'moov', 'trak')
55
+ * @param {number} start - Start offset
56
+ * @param {number} end - End offset
57
+ * @returns {object|null} Box info {offset, size, headerSize} or null
58
+ */
59
+ export function findBox(data, type, start = 0, end = data.length) {
60
+ let offset = start;
61
+ while (offset < end - 8) {
62
+ const size = readUint32(data, offset);
63
+ const btype = boxType(data, offset + 4);
64
+
65
+ if (size === 0 || size > end - offset) break;
66
+
67
+ const headerSize = size === 1 ? 16 : 8;
68
+ const actualSize = size === 1 ? readUint64(data, offset + 8) : size;
69
+
70
+ if (btype === type) {
71
+ return { offset, size: actualSize, headerSize };
72
+ }
73
+ offset += actualSize;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Find all boxes of a type within a data range
80
+ */
81
+ export function findAllBoxes(data, type, start = 0, end = data.length) {
82
+ const boxes = [];
83
+ let offset = start;
84
+ while (offset < end - 8) {
85
+ const size = readUint32(data, offset);
86
+ const btype = boxType(data, offset + 4);
87
+
88
+ if (size === 0 || size > end - offset) break;
89
+
90
+ const headerSize = size === 1 ? 16 : 8;
91
+ const actualSize = size === 1 ? readUint64(data, offset + 8) : size;
92
+
93
+ if (btype === type) {
94
+ boxes.push({ offset, size: actualSize, headerSize });
95
+ }
96
+ offset += actualSize;
97
+ }
98
+ return boxes;
99
+ }
100
+
101
+ // ============================================================================
102
+ // Sample Table Box Parsing
103
+ // ============================================================================
104
+
105
+ /**
106
+ * Parse stts (time-to-sample) box
107
+ */
108
+ export function parseStts(data, offset) {
109
+ const entryCount = readUint32(data, offset + 12);
110
+ const entries = [];
111
+ let pos = offset + 16;
112
+ for (let i = 0; i < entryCount; i++) {
113
+ entries.push({
114
+ sampleCount: readUint32(data, pos),
115
+ sampleDelta: readUint32(data, pos + 4)
116
+ });
117
+ pos += 8;
118
+ }
119
+ return entries;
120
+ }
121
+
122
+ /**
123
+ * Parse stss (sync sample / keyframe) box
124
+ */
125
+ export function parseStss(data, offset) {
126
+ const entryCount = readUint32(data, offset + 12);
127
+ const keyframes = [];
128
+ let pos = offset + 16;
129
+ for (let i = 0; i < entryCount; i++) {
130
+ keyframes.push(readUint32(data, pos));
131
+ pos += 4;
132
+ }
133
+ return keyframes;
134
+ }
135
+
136
+ /**
137
+ * Parse stsz (sample size) box
138
+ */
139
+ export function parseStsz(data, offset) {
140
+ const sampleSize = readUint32(data, offset + 12);
141
+ const sampleCount = readUint32(data, offset + 16);
142
+
143
+ if (sampleSize !== 0) {
144
+ return Array(sampleCount).fill(sampleSize);
145
+ }
146
+
147
+ const sizes = [];
148
+ let pos = offset + 20;
149
+ for (let i = 0; i < sampleCount; i++) {
150
+ sizes.push(readUint32(data, pos));
151
+ pos += 4;
152
+ }
153
+ return sizes;
154
+ }
155
+
156
+ /**
157
+ * Parse stco (chunk offset 32-bit) box
158
+ */
159
+ export function parseStco(data, offset) {
160
+ const entryCount = readUint32(data, offset + 12);
161
+ const offsets = [];
162
+ let pos = offset + 16;
163
+ for (let i = 0; i < entryCount; i++) {
164
+ offsets.push(readUint32(data, pos));
165
+ pos += 4;
166
+ }
167
+ return offsets;
168
+ }
169
+
170
+ /**
171
+ * Parse co64 (chunk offset 64-bit) box
172
+ */
173
+ export function parseCo64(data, offset) {
174
+ const entryCount = readUint32(data, offset + 12);
175
+ const offsets = [];
176
+ let pos = offset + 16;
177
+ for (let i = 0; i < entryCount; i++) {
178
+ offsets.push(readUint64(data, pos));
179
+ pos += 8;
180
+ }
181
+ return offsets;
182
+ }
183
+
184
+ /**
185
+ * Parse stsc (sample-to-chunk) box
186
+ */
187
+ export function parseStsc(data, offset) {
188
+ const entryCount = readUint32(data, offset + 12);
189
+ const entries = [];
190
+ let pos = offset + 16;
191
+ for (let i = 0; i < entryCount; i++) {
192
+ entries.push({
193
+ firstChunk: readUint32(data, pos),
194
+ samplesPerChunk: readUint32(data, pos + 4),
195
+ sampleDescriptionIndex: readUint32(data, pos + 8)
196
+ });
197
+ pos += 12;
198
+ }
199
+ return entries;
200
+ }
201
+
202
+ /**
203
+ * Parse ctts (composition time offset) box for B-frames
204
+ */
205
+ export function parseCtts(data, offset) {
206
+ const version = data[offset + 8];
207
+ const entryCount = readUint32(data, offset + 12);
208
+ const entries = [];
209
+ let pos = offset + 16;
210
+ for (let i = 0; i < entryCount; i++) {
211
+ const sampleCount = readUint32(data, pos);
212
+ let sampleOffset;
213
+ if (version === 0) {
214
+ sampleOffset = readUint32(data, pos + 4);
215
+ } else {
216
+ // Version 1: signed offset
217
+ sampleOffset = readInt32(data, pos + 4);
218
+ }
219
+ entries.push({ sampleCount, sampleOffset });
220
+ pos += 8;
221
+ }
222
+ return entries;
223
+ }
224
+
225
+ /**
226
+ * Parse mdhd (media header) box
227
+ */
228
+ export function parseMdhd(data, offset) {
229
+ const version = data[offset + 8];
230
+ if (version === 0) {
231
+ return {
232
+ timescale: readUint32(data, offset + 20),
233
+ duration: readUint32(data, offset + 24)
234
+ };
235
+ }
236
+ return {
237
+ timescale: readUint32(data, offset + 28),
238
+ duration: readUint64(data, offset + 32)
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Parse tkhd (track header) box
244
+ */
245
+ export function parseTkhd(data, offset) {
246
+ const version = data[offset + 8];
247
+ if (version === 0) {
248
+ return {
249
+ trackId: readUint32(data, offset + 20),
250
+ duration: readUint32(data, offset + 28),
251
+ width: readUint32(data, offset + 84) / 65536,
252
+ height: readUint32(data, offset + 88) / 65536
253
+ };
254
+ }
255
+ return {
256
+ trackId: readUint32(data, offset + 28),
257
+ duration: readUint64(data, offset + 36),
258
+ width: readUint32(data, offset + 96) / 65536,
259
+ height: readUint32(data, offset + 100) / 65536
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Parse avcC (AVC decoder configuration) box
265
+ */
266
+ export function parseAvcC(data, offset) {
267
+ let pos = offset + 8;
268
+ const configVersion = data[pos++];
269
+ const profile = data[pos++];
270
+ const profileCompat = data[pos++];
271
+ const level = data[pos++];
272
+ const lengthSizeMinusOne = data[pos++] & 0x03;
273
+ const numSPS = data[pos++] & 0x1F;
274
+
275
+ const sps = [];
276
+ for (let i = 0; i < numSPS; i++) {
277
+ const spsLen = (data[pos] << 8) | data[pos + 1];
278
+ pos += 2;
279
+ sps.push(data.slice(pos, pos + spsLen));
280
+ pos += spsLen;
281
+ }
282
+
283
+ const numPPS = data[pos++];
284
+ const pps = [];
285
+ for (let i = 0; i < numPPS; i++) {
286
+ const ppsLen = (data[pos] << 8) | data[pos + 1];
287
+ pos += 2;
288
+ pps.push(data.slice(pos, pos + ppsLen));
289
+ pos += ppsLen;
290
+ }
291
+
292
+ return {
293
+ profile,
294
+ level,
295
+ sps,
296
+ pps,
297
+ nalLengthSize: lengthSizeMinusOne + 1
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Parse mp4a audio sample entry for sample rate and channels
303
+ */
304
+ export function parseMp4a(data, offset) {
305
+ const channels = (data[offset + 24] << 8) | data[offset + 25];
306
+ const sampleRate = (data[offset + 32] << 8) | data[offset + 33];
307
+ return { channels, sampleRate };
308
+ }
309
+
310
+ // ============================================================================
311
+ // Track Analysis
312
+ // ============================================================================
313
+
314
+ /**
315
+ * Analyze a single track from moov data
316
+ */
317
+ export function analyzeTrack(moov, trakOffset, trakSize) {
318
+ // Get track header
319
+ const tkhd = findBox(moov, 'tkhd', trakOffset + 8, trakOffset + trakSize);
320
+ const tkhdInfo = tkhd ? parseTkhd(moov, tkhd.offset) : { trackId: 0, width: 0, height: 0 };
321
+
322
+ const mdia = findBox(moov, 'mdia', trakOffset + 8, trakOffset + trakSize);
323
+ if (!mdia) return null;
324
+
325
+ const mdhd = findBox(moov, 'mdhd', mdia.offset + 8, mdia.offset + mdia.size);
326
+ const mediaInfo = mdhd ? parseMdhd(moov, mdhd.offset) : { timescale: 90000, duration: 0 };
327
+
328
+ const hdlr = findBox(moov, 'hdlr', mdia.offset + 8, mdia.offset + mdia.size);
329
+ const handlerType = hdlr ? boxType(moov, hdlr.offset + 16) : 'unkn';
330
+
331
+ const minf = findBox(moov, 'minf', mdia.offset + 8, mdia.offset + mdia.size);
332
+ if (!minf) return null;
333
+
334
+ const stbl = findBox(moov, 'stbl', minf.offset + 8, minf.offset + minf.size);
335
+ if (!stbl) return null;
336
+
337
+ const stblStart = stbl.offset + 8;
338
+ const stblEnd = stbl.offset + stbl.size;
339
+
340
+ // Parse sample tables
341
+ const sttsBox = findBox(moov, 'stts', stblStart, stblEnd);
342
+ const stssBox = findBox(moov, 'stss', stblStart, stblEnd);
343
+ const stszBox = findBox(moov, 'stsz', stblStart, stblEnd);
344
+ const stcoBox = findBox(moov, 'stco', stblStart, stblEnd);
345
+ const co64Box = findBox(moov, 'co64', stblStart, stblEnd);
346
+ const stscBox = findBox(moov, 'stsc', stblStart, stblEnd);
347
+ const stsdBox = findBox(moov, 'stsd', stblStart, stblEnd);
348
+ const cttsBox = findBox(moov, 'ctts', stblStart, stblEnd);
349
+
350
+ // Parse codec config
351
+ let codecConfig = null;
352
+ let audioConfig = null;
353
+
354
+ if (stsdBox && handlerType === 'vide') {
355
+ const avc1 = findBox(moov, 'avc1', stsdBox.offset + 16, stsdBox.offset + stsdBox.size);
356
+ if (avc1) {
357
+ const avcC = findBox(moov, 'avcC', avc1.offset + 86, avc1.offset + avc1.size);
358
+ if (avcC) {
359
+ codecConfig = parseAvcC(moov, avcC.offset);
360
+ }
361
+ }
362
+ }
363
+
364
+ if (stsdBox && handlerType === 'soun') {
365
+ const mp4a = findBox(moov, 'mp4a', stsdBox.offset + 16, stsdBox.offset + stsdBox.size);
366
+ if (mp4a) {
367
+ audioConfig = parseMp4a(moov, mp4a.offset);
368
+ }
369
+ }
370
+
371
+ return {
372
+ trackId: tkhdInfo.trackId,
373
+ type: handlerType,
374
+ width: tkhdInfo.width,
375
+ height: tkhdInfo.height,
376
+ timescale: mediaInfo.timescale,
377
+ duration: mediaInfo.duration,
378
+ durationSeconds: mediaInfo.duration / mediaInfo.timescale,
379
+ stts: sttsBox ? parseStts(moov, sttsBox.offset) : [],
380
+ stss: stssBox ? parseStss(moov, stssBox.offset) : [],
381
+ stsz: stszBox ? parseStsz(moov, stszBox.offset) : [],
382
+ stco: stcoBox ? parseStco(moov, stcoBox.offset) :
383
+ co64Box ? parseCo64(moov, co64Box.offset) : [],
384
+ stsc: stscBox ? parseStsc(moov, stscBox.offset) : [],
385
+ ctts: cttsBox ? parseCtts(moov, cttsBox.offset) : [],
386
+ codecConfig,
387
+ audioConfig
388
+ };
389
+ }
390
+
391
+ // ============================================================================
392
+ // Sample Table Building
393
+ // ============================================================================
394
+
395
+ /**
396
+ * Build a flat sample table with byte offsets and timestamps
397
+ * @param {object} track - Track metadata from analyzeTrack
398
+ * @returns {Array} Array of sample objects
399
+ */
400
+ export function buildSampleTable(track) {
401
+ const { stsz, stco, stsc, stts, stss, ctts, timescale } = track;
402
+ const samples = [];
403
+
404
+ // Build ctts lookup (composition time offset for B-frames)
405
+ const cttsOffsets = [];
406
+ if (ctts && ctts.length > 0) {
407
+ for (const entry of ctts) {
408
+ for (let i = 0; i < entry.sampleCount; i++) {
409
+ cttsOffsets.push(entry.sampleOffset);
410
+ }
411
+ }
412
+ }
413
+
414
+ let sampleIndex = 0;
415
+ let currentDts = 0;
416
+ let sttsEntryIndex = 0;
417
+ let sttsRemaining = stts[0]?.sampleCount || 0;
418
+
419
+ for (let chunkIndex = 0; chunkIndex < stco.length; chunkIndex++) {
420
+ // Find samples per chunk for this chunk
421
+ let samplesInChunk = 1;
422
+ for (let i = stsc.length - 1; i >= 0; i--) {
423
+ if (stsc[i].firstChunk <= chunkIndex + 1) {
424
+ samplesInChunk = stsc[i].samplesPerChunk;
425
+ break;
426
+ }
427
+ }
428
+
429
+ let chunkOffset = stco[chunkIndex];
430
+
431
+ for (let s = 0; s < samplesInChunk && sampleIndex < stsz.length; s++) {
432
+ const size = stsz[sampleIndex];
433
+ const duration = stts[sttsEntryIndex]?.sampleDelta || 0;
434
+
435
+ // PTS = DTS + composition offset (ctts)
436
+ const compositionOffset = cttsOffsets[sampleIndex] || 0;
437
+ const pts = Math.max(currentDts, currentDts + compositionOffset);
438
+
439
+ samples.push({
440
+ index: sampleIndex,
441
+ offset: chunkOffset,
442
+ size,
443
+ dts: currentDts / timescale,
444
+ pts: pts / timescale,
445
+ time: pts / timescale,
446
+ duration: duration / timescale,
447
+ isKeyframe: stss.length === 0 || stss.includes(sampleIndex + 1)
448
+ });
449
+
450
+ chunkOffset += size;
451
+ currentDts += duration;
452
+ sampleIndex++;
453
+
454
+ sttsRemaining--;
455
+ if (sttsRemaining === 0 && sttsEntryIndex < stts.length - 1) {
456
+ sttsEntryIndex++;
457
+ sttsRemaining = stts[sttsEntryIndex].sampleCount;
458
+ }
459
+ }
460
+ }
461
+
462
+ return samples;
463
+ }
464
+
465
+ /**
466
+ * Build HLS-style segments from video samples
467
+ * @param {Array} videoSamples - Video sample table
468
+ * @param {number} targetDuration - Target segment duration in seconds
469
+ * @returns {Array} Array of segment definitions
470
+ */
471
+ export function buildSegments(videoSamples, targetDuration = 4) {
472
+ const segments = [];
473
+ const keyframes = videoSamples.filter(s => s.isKeyframe);
474
+
475
+ for (let i = 0; i < keyframes.length; i++) {
476
+ const start = keyframes[i];
477
+ const end = keyframes[i + 1] || videoSamples[videoSamples.length - 1];
478
+
479
+ const videoStart = start.index;
480
+ const videoEnd = end ? end.index : videoSamples.length;
481
+
482
+ const duration = (end ? end.time :
483
+ videoSamples[videoSamples.length - 1].time +
484
+ videoSamples[videoSamples.length - 1].duration) - start.time;
485
+
486
+ // Combine short segments
487
+ if (segments.length > 0 &&
488
+ segments[segments.length - 1].duration + duration < targetDuration) {
489
+ const prev = segments[segments.length - 1];
490
+ prev.videoEnd = videoEnd;
491
+ prev.duration += duration;
492
+ prev.endTime = start.time + duration;
493
+ } else {
494
+ segments.push({
495
+ index: segments.length,
496
+ startTime: start.time,
497
+ endTime: start.time + duration,
498
+ duration,
499
+ videoStart,
500
+ videoEnd
501
+ });
502
+ }
503
+ }
504
+
505
+ return segments;
506
+ }
507
+
508
+ /**
509
+ * Calculate byte ranges needed for a set of samples
510
+ * @param {Array} samples - Array of samples with offset and size
511
+ * @param {number} maxGap - Maximum gap to coalesce (default 64KB)
512
+ * @returns {Array} Array of {start, end, samples} ranges
513
+ */
514
+ export function calculateByteRanges(samples, maxGap = 65536) {
515
+ if (samples.length === 0) return [];
516
+
517
+ const ranges = [];
518
+ let currentRange = {
519
+ start: samples[0].offset,
520
+ end: samples[0].offset + samples[0].size,
521
+ samples: [samples[0]]
522
+ };
523
+
524
+ for (let i = 1; i < samples.length; i++) {
525
+ const sample = samples[i];
526
+
527
+ if (sample.offset <= currentRange.end + maxGap) {
528
+ currentRange.end = Math.max(currentRange.end, sample.offset + sample.size);
529
+ currentRange.samples.push(sample);
530
+ } else {
531
+ ranges.push(currentRange);
532
+ currentRange = {
533
+ start: sample.offset,
534
+ end: sample.offset + sample.size,
535
+ samples: [sample]
536
+ };
537
+ }
538
+ }
539
+ ranges.push(currentRange);
540
+
541
+ return ranges;
542
+ }
543
+
544
+ // ============================================================================
545
+ // MP4Parser Class
546
+ // ============================================================================
547
+
548
+ /**
549
+ * MP4 Parser - Parse MP4 files to extract tracks and samples
550
+ */
551
+ export class MP4Parser {
552
+ /**
553
+ * Create parser from MP4 data
554
+ * @param {Uint8Array} data - Complete MP4 file data
555
+ */
556
+ constructor(data) {
557
+ this.data = data;
558
+ this.moov = null;
559
+ this.videoTrack = null;
560
+ this.audioTrack = null;
561
+ this.videoSamples = [];
562
+ this.audioSamples = [];
563
+
564
+ this._parse();
565
+ }
566
+
567
+ _parse() {
568
+ // Find moov box
569
+ const moov = findBox(this.data, 'moov');
570
+ if (!moov) {
571
+ throw new Error('No moov box found - not a valid MP4 file');
572
+ }
573
+
574
+ this.moov = this.data.slice(moov.offset, moov.offset + moov.size);
575
+
576
+ // Parse tracks
577
+ let trackOffset = 8;
578
+ while (trackOffset < this.moov.length) {
579
+ const trak = findBox(this.moov, 'trak', trackOffset);
580
+ if (!trak) break;
581
+
582
+ const track = analyzeTrack(this.moov, trak.offset, trak.size);
583
+ if (track) {
584
+ if (track.type === 'vide' && !this.videoTrack) {
585
+ this.videoTrack = track;
586
+ this.videoSamples = buildSampleTable(track);
587
+ } else if (track.type === 'soun' && !this.audioTrack) {
588
+ this.audioTrack = track;
589
+ this.audioSamples = buildSampleTable(track);
590
+ }
591
+ }
592
+ trackOffset = trak.offset + trak.size;
593
+ }
594
+
595
+ if (!this.videoTrack) {
596
+ throw new Error('No video track found');
597
+ }
598
+ }
599
+
600
+ /** Duration in seconds */
601
+ get duration() {
602
+ return this.videoTrack?.durationSeconds || 0;
603
+ }
604
+
605
+ /** Video width */
606
+ get width() {
607
+ return this.videoTrack?.width || 0;
608
+ }
609
+
610
+ /** Video height */
611
+ get height() {
612
+ return this.videoTrack?.height || 0;
613
+ }
614
+
615
+ /** Whether source has audio */
616
+ get hasAudio() {
617
+ return !!this.audioTrack;
618
+ }
619
+
620
+ /** Whether video has B-frames */
621
+ get hasBframes() {
622
+ return this.videoTrack?.ctts?.length > 0;
623
+ }
624
+
625
+ /** Video codec config (SPS/PPS) */
626
+ get videoCodecConfig() {
627
+ return this.videoTrack?.codecConfig;
628
+ }
629
+
630
+ /** Audio config (sample rate, channels) */
631
+ get audioCodecConfig() {
632
+ return this.audioTrack?.audioConfig;
633
+ }
634
+
635
+ /**
636
+ * Get video samples
637
+ * @returns {Array} Video sample table
638
+ */
639
+ getVideoSamples() {
640
+ return this.videoSamples;
641
+ }
642
+
643
+ /**
644
+ * Get audio samples
645
+ * @returns {Array} Audio sample table
646
+ */
647
+ getAudioSamples() {
648
+ return this.audioSamples;
649
+ }
650
+
651
+ /**
652
+ * Build HLS-style segments
653
+ * @param {number} targetDuration - Target segment duration in seconds
654
+ * @returns {Array} Segment definitions
655
+ */
656
+ buildSegments(targetDuration = 4) {
657
+ return buildSegments(this.videoSamples, targetDuration);
658
+ }
659
+
660
+ /**
661
+ * Get sample data for a range of samples
662
+ * @param {Array} samples - Samples to extract (must have offset and size)
663
+ * @returns {Array} Samples with data property added
664
+ */
665
+ getSampleData(samples) {
666
+ return samples.map(sample => ({
667
+ ...sample,
668
+ data: this.data.slice(sample.offset, sample.offset + sample.size)
669
+ }));
670
+ }
671
+
672
+ /**
673
+ * Get parser info
674
+ */
675
+ getInfo() {
676
+ return {
677
+ duration: this.duration,
678
+ width: this.width,
679
+ height: this.height,
680
+ hasAudio: this.hasAudio,
681
+ hasBframes: this.hasBframes,
682
+ videoSampleCount: this.videoSamples.length,
683
+ audioSampleCount: this.audioSamples.length,
684
+ keyframeCount: this.videoTrack?.stss?.length ||
685
+ this.videoSamples.filter(s => s.isKeyframe).length
686
+ };
687
+ }
688
+ }
689
+
690
+ export default MP4Parser;
691
+
@@ -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
  }