@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
|
@@ -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
|
-
*
|
|
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(
|
|
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(
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
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 -=
|
|
110
|
+
au.pts -= audioOffset;
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
return {
|