@invintusmedia/tomp4 1.0.8 → 1.1.0
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/README.md +25 -1
- package/dist/tomp4.js +312 -363
- package/package.json +6 -3
- package/src/fmp4/converter.js +323 -0
- package/src/fmp4/index.js +25 -0
- package/src/fmp4/stitcher.js +615 -0
- package/src/fmp4/utils.js +201 -0
- package/src/index.js +41 -20
- package/src/mpegts/index.js +7 -0
- package/src/mpegts/stitcher.js +251 -0
- package/src/muxers/mp4.js +85 -85
- package/src/muxers/mpegts.js +101 -19
- package/src/parsers/mp4.js +691 -0
- package/src/parsers/mpegts.js +42 -42
- package/src/remote/index.js +444 -0
- package/src/transcode.js +20 -36
- package/src/ts-to-mp4.js +37 -37
- package/src/fmp4-to-mp4.js +0 -375
package/src/ts-to-mp4.js
CHANGED
|
@@ -51,14 +51,14 @@ function clipAccessUnits(videoAUs, audioAUs, startTime, endTime) {
|
|
|
51
51
|
const PTS_PER_SECOND = 90000;
|
|
52
52
|
const startPts = startTime * PTS_PER_SECOND;
|
|
53
53
|
const endPts = endTime * PTS_PER_SECOND;
|
|
54
|
-
|
|
54
|
+
|
|
55
55
|
// Find keyframe at or before startTime (needed for decoding)
|
|
56
56
|
let keyframeIdx = 0;
|
|
57
57
|
for (let i = 0; i < videoAUs.length; i++) {
|
|
58
58
|
if (videoAUs[i].pts > startPts) break;
|
|
59
59
|
if (isKeyframe(videoAUs[i])) keyframeIdx = i;
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
// Find first frame at or after endTime
|
|
63
63
|
let endIdx = videoAUs.length;
|
|
64
64
|
for (let i = keyframeIdx; i < videoAUs.length; i++) {
|
|
@@ -67,10 +67,10 @@ function clipAccessUnits(videoAUs, audioAUs, startTime, endTime) {
|
|
|
67
67
|
break;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
|
|
70
|
+
|
|
71
71
|
// Clip video starting from keyframe (for proper decoding)
|
|
72
72
|
const clippedVideo = videoAUs.slice(keyframeIdx, endIdx);
|
|
73
|
-
|
|
73
|
+
|
|
74
74
|
if (clippedVideo.length === 0) {
|
|
75
75
|
return {
|
|
76
76
|
video: [],
|
|
@@ -81,35 +81,35 @@ function clipAccessUnits(videoAUs, audioAUs, startTime, endTime) {
|
|
|
81
81
|
preroll: 0
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
-
|
|
84
|
+
|
|
85
85
|
// Get PTS of keyframe and requested start
|
|
86
86
|
const keyframePts = clippedVideo[0].pts;
|
|
87
87
|
const lastFramePts = clippedVideo[clippedVideo.length - 1].pts;
|
|
88
|
-
|
|
88
|
+
|
|
89
89
|
// Pre-roll: time between keyframe and requested start
|
|
90
90
|
// This is the time the decoder needs to process but player shouldn't display
|
|
91
91
|
const prerollPts = Math.max(0, startPts - keyframePts);
|
|
92
|
-
|
|
92
|
+
|
|
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
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
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
|
-
|
|
105
|
+
|
|
106
106
|
// Normalize audio timestamps so it starts at 0 (matching video playback start after preroll)
|
|
107
107
|
// Audio doesn't have preroll, so it should start at PTS 0 to sync with video after edit list
|
|
108
108
|
const audioOffset = audioStartPts; // Use requested start, not keyframe
|
|
109
109
|
for (const au of clippedAudio) {
|
|
110
110
|
au.pts -= audioOffset;
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
|
|
113
113
|
return {
|
|
114
114
|
video: clippedVideo,
|
|
115
115
|
audio: clippedAudio,
|
|
@@ -144,9 +144,9 @@ export function analyzeTsData(tsData) {
|
|
|
144
144
|
const parser = new TSParser();
|
|
145
145
|
parser.parse(tsData);
|
|
146
146
|
parser.finalize();
|
|
147
|
-
|
|
147
|
+
|
|
148
148
|
const PTS_PER_SECOND = 90000;
|
|
149
|
-
|
|
149
|
+
|
|
150
150
|
// Find keyframes and their timestamps
|
|
151
151
|
const keyframes = [];
|
|
152
152
|
for (let i = 0; i < parser.videoAccessUnits.length; i++) {
|
|
@@ -157,15 +157,15 @@ export function analyzeTsData(tsData) {
|
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
-
|
|
160
|
+
|
|
161
161
|
// Calculate duration
|
|
162
|
-
const videoDuration = parser.videoPts.length > 0
|
|
162
|
+
const videoDuration = parser.videoPts.length > 0
|
|
163
163
|
? (Math.max(...parser.videoPts) - Math.min(...parser.videoPts)) / PTS_PER_SECOND
|
|
164
164
|
: 0;
|
|
165
165
|
const audioDuration = parser.audioPts.length > 0
|
|
166
166
|
? (Math.max(...parser.audioPts) - Math.min(...parser.audioPts)) / PTS_PER_SECOND
|
|
167
167
|
: 0;
|
|
168
|
-
|
|
168
|
+
|
|
169
169
|
return {
|
|
170
170
|
duration: Math.max(videoDuration, audioDuration),
|
|
171
171
|
videoFrames: parser.videoAccessUnits.length,
|
|
@@ -180,17 +180,17 @@ export function analyzeTsData(tsData) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
export function convertTsToMp4(tsData, options = {}) {
|
|
183
|
-
const log = options.onProgress || (() => {});
|
|
184
|
-
|
|
183
|
+
const log = options.onProgress || (() => { });
|
|
184
|
+
|
|
185
185
|
log(`Parsing...`, { phase: 'convert', percent: 52 });
|
|
186
186
|
const parser = new TSParser();
|
|
187
187
|
parser.parse(tsData);
|
|
188
188
|
parser.finalize();
|
|
189
|
-
|
|
189
|
+
|
|
190
190
|
const debug = parser.debug;
|
|
191
191
|
const videoInfo = getCodecInfo(parser.videoStreamType);
|
|
192
192
|
const audioInfo = getCodecInfo(parser.audioStreamType);
|
|
193
|
-
|
|
193
|
+
|
|
194
194
|
// Log parsing results
|
|
195
195
|
log(`Parsed ${debug.packets} TS packets`, { phase: 'convert', percent: 55 });
|
|
196
196
|
log(`PAT: ${debug.patFound ? '✓' : '✗'}, PMT: ${debug.pmtFound ? '✓' : '✗'}`);
|
|
@@ -199,16 +199,16 @@ export function convertTsToMp4(tsData, options = {}) {
|
|
|
199
199
|
if (parser.audioSampleRate) audioDetails.push(`${parser.audioSampleRate}Hz`);
|
|
200
200
|
if (parser.audioChannels) audioDetails.push(`${parser.audioChannels}ch`);
|
|
201
201
|
log(`Audio: ${parser.audioPid ? `PID ${parser.audioPid}` : 'none'} → ${audioInfo.name}${audioDetails.length ? ` (${audioDetails.join(', ')})` : ''}`);
|
|
202
|
-
|
|
202
|
+
|
|
203
203
|
// Check for structural issues first
|
|
204
204
|
if (!debug.patFound) {
|
|
205
205
|
throw new Error('Invalid MPEG-TS: No PAT (Program Association Table) found. File may be corrupted or not MPEG-TS format.');
|
|
206
206
|
}
|
|
207
|
-
|
|
207
|
+
|
|
208
208
|
if (!debug.pmtFound) {
|
|
209
209
|
throw new Error('Invalid MPEG-TS: No PMT (Program Map Table) found. File may be corrupted or missing stream info.');
|
|
210
210
|
}
|
|
211
|
-
|
|
211
|
+
|
|
212
212
|
// Check for unsupported video codec BEFORE we report frame counts
|
|
213
213
|
if (parser.videoStreamType && !videoInfo.supported) {
|
|
214
214
|
throw new Error(
|
|
@@ -217,7 +217,7 @@ export function convertTsToMp4(tsData, options = {}) {
|
|
|
217
217
|
`Your file needs to be transcoded to H.264 first.`
|
|
218
218
|
);
|
|
219
219
|
}
|
|
220
|
-
|
|
220
|
+
|
|
221
221
|
// Check for unsupported audio codec
|
|
222
222
|
if (parser.audioStreamType && !audioInfo.supported) {
|
|
223
223
|
throw new Error(
|
|
@@ -226,7 +226,7 @@ export function convertTsToMp4(tsData, options = {}) {
|
|
|
226
226
|
`Your file needs to be transcoded to AAC first.`
|
|
227
227
|
);
|
|
228
228
|
}
|
|
229
|
-
|
|
229
|
+
|
|
230
230
|
// Check if we found any supported video
|
|
231
231
|
if (!parser.videoPid) {
|
|
232
232
|
throw new Error(
|
|
@@ -234,61 +234,61 @@ export function convertTsToMp4(tsData, options = {}) {
|
|
|
234
234
|
'This library supports: H.264/AVC, H.265/HEVC'
|
|
235
235
|
);
|
|
236
236
|
}
|
|
237
|
-
|
|
237
|
+
|
|
238
238
|
log(`Frames: ${parser.videoAccessUnits.length} video, ${parser.audioAccessUnits.length} audio`, { phase: 'convert', percent: 60 });
|
|
239
239
|
if (debug.audioPesStarts) {
|
|
240
240
|
log(`Audio: ${debug.audioPesStarts} PES starts → ${debug.audioPesCount || 0} processed → ${debug.audioFramesInPes || 0} ADTS frames${debug.audioSkipped ? ` (${debug.audioSkipped} skipped)` : ''}`);
|
|
241
241
|
}
|
|
242
|
-
|
|
242
|
+
|
|
243
243
|
if (parser.videoAccessUnits.length === 0) {
|
|
244
244
|
throw new Error('Video stream found but no frames could be extracted. File may be corrupted.');
|
|
245
245
|
}
|
|
246
|
-
|
|
246
|
+
|
|
247
247
|
// Report timestamp normalization
|
|
248
248
|
if (debug.timestampNormalized) {
|
|
249
249
|
const offsetMs = (debug.timestampOffset / 90).toFixed(1);
|
|
250
250
|
log(`Timestamps normalized: -${offsetMs}ms offset`);
|
|
251
251
|
}
|
|
252
|
-
|
|
252
|
+
|
|
253
253
|
log(`Processing...`, { phase: 'convert', percent: 70 });
|
|
254
|
-
|
|
254
|
+
|
|
255
255
|
// Track preroll for edit list (used for precise clipping)
|
|
256
256
|
let clipPreroll = 0;
|
|
257
|
-
|
|
257
|
+
|
|
258
258
|
// Apply time range clipping if specified
|
|
259
259
|
if (options.startTime !== undefined || options.endTime !== undefined) {
|
|
260
260
|
const startTime = options.startTime || 0;
|
|
261
261
|
const endTime = options.endTime !== undefined ? options.endTime : Infinity;
|
|
262
|
-
|
|
262
|
+
|
|
263
263
|
const clipResult = clipAccessUnits(
|
|
264
264
|
parser.videoAccessUnits,
|
|
265
265
|
parser.audioAccessUnits,
|
|
266
266
|
startTime,
|
|
267
267
|
endTime
|
|
268
268
|
);
|
|
269
|
-
|
|
269
|
+
|
|
270
270
|
parser.videoAccessUnits = clipResult.video;
|
|
271
271
|
parser.audioAccessUnits = clipResult.audio;
|
|
272
272
|
clipPreroll = clipResult.preroll;
|
|
273
|
-
|
|
273
|
+
|
|
274
274
|
// Update PTS arrays to match
|
|
275
275
|
parser.videoPts = clipResult.video.map(au => au.pts);
|
|
276
276
|
parser.videoDts = clipResult.video.map(au => au.dts);
|
|
277
277
|
parser.audioPts = clipResult.audio.map(au => au.pts);
|
|
278
|
-
|
|
278
|
+
|
|
279
279
|
const prerollMs = (clipPreroll / 90).toFixed(0);
|
|
280
280
|
const endTimeStr = clipResult.requestedEndTime === Infinity ? 'end' : clipResult.requestedEndTime.toFixed(2) + 's';
|
|
281
|
-
const clipDuration = clipResult.requestedEndTime === Infinity
|
|
281
|
+
const clipDuration = clipResult.requestedEndTime === Infinity
|
|
282
282
|
? (clipResult.actualEndTime - clipResult.requestedStartTime).toFixed(2)
|
|
283
283
|
: (clipResult.requestedEndTime - clipResult.requestedStartTime).toFixed(2);
|
|
284
284
|
log(`Clipped: ${clipResult.requestedStartTime.toFixed(2)}s - ${endTimeStr} (${clipDuration}s, ${prerollMs}ms preroll)`, { phase: 'convert', percent: 80 });
|
|
285
285
|
}
|
|
286
|
-
|
|
286
|
+
|
|
287
287
|
log(`Building MP4...`, { phase: 'convert', percent: 85 });
|
|
288
288
|
const muxer = new MP4Muxer(parser, { preroll: clipPreroll });
|
|
289
289
|
const { width, height } = muxer.getVideoDimensions();
|
|
290
290
|
log(`Dimensions: ${width}x${height}`);
|
|
291
|
-
|
|
291
|
+
|
|
292
292
|
const result = muxer.build();
|
|
293
293
|
log(`Complete`, { phase: 'convert', percent: 100 });
|
|
294
294
|
return result;
|
package/src/fmp4-to-mp4.js
DELETED
|
@@ -1,375 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fragmented MP4 to Standard MP4 Converter
|
|
3
|
-
* Pure JavaScript - no dependencies
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// ============================================
|
|
7
|
-
// Box Utilities
|
|
8
|
-
// ============================================
|
|
9
|
-
function parseBoxes(data, offset = 0, end = data.byteLength) {
|
|
10
|
-
const boxes = [];
|
|
11
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
12
|
-
while (offset < end) {
|
|
13
|
-
if (offset + 8 > end) break;
|
|
14
|
-
const size = view.getUint32(offset);
|
|
15
|
-
const type = String.fromCharCode(data[offset+4], data[offset+5], data[offset+6], data[offset+7]);
|
|
16
|
-
if (size === 0 || size < 8) break;
|
|
17
|
-
boxes.push({ type, offset, size, data: data.subarray(offset, offset + size) });
|
|
18
|
-
offset += size;
|
|
19
|
-
}
|
|
20
|
-
return boxes;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function findBox(boxes, type) {
|
|
24
|
-
for (const box of boxes) if (box.type === type) return box;
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function parseChildBoxes(box, headerSize = 8) {
|
|
29
|
-
return parseBoxes(box.data, headerSize, box.size);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function createBox(type, ...payloads) {
|
|
33
|
-
let size = 8;
|
|
34
|
-
for (const p of payloads) size += p.byteLength;
|
|
35
|
-
const result = new Uint8Array(size);
|
|
36
|
-
const view = new DataView(result.buffer);
|
|
37
|
-
view.setUint32(0, size);
|
|
38
|
-
result[4] = type.charCodeAt(0); result[5] = type.charCodeAt(1); result[6] = type.charCodeAt(2); result[7] = type.charCodeAt(3);
|
|
39
|
-
let offset = 8;
|
|
40
|
-
for (const p of payloads) { result.set(p, offset); offset += p.byteLength; }
|
|
41
|
-
return result;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ============================================
|
|
45
|
-
// trun/tfhd Parsing
|
|
46
|
-
// ============================================
|
|
47
|
-
function parseTrunWithOffset(trunData) {
|
|
48
|
-
const view = new DataView(trunData.buffer, trunData.byteOffset, trunData.byteLength);
|
|
49
|
-
const version = trunData[8];
|
|
50
|
-
const flags = (trunData[9] << 16) | (trunData[10] << 8) | trunData[11];
|
|
51
|
-
const sampleCount = view.getUint32(12);
|
|
52
|
-
let offset = 16, dataOffset = 0;
|
|
53
|
-
if (flags & 0x1) { dataOffset = view.getInt32(offset); offset += 4; }
|
|
54
|
-
if (flags & 0x4) offset += 4;
|
|
55
|
-
const samples = [];
|
|
56
|
-
for (let i = 0; i < sampleCount; i++) {
|
|
57
|
-
const sample = {};
|
|
58
|
-
if (flags & 0x100) { sample.duration = view.getUint32(offset); offset += 4; }
|
|
59
|
-
if (flags & 0x200) { sample.size = view.getUint32(offset); offset += 4; }
|
|
60
|
-
if (flags & 0x400) { sample.flags = view.getUint32(offset); offset += 4; }
|
|
61
|
-
if (flags & 0x800) { sample.compositionTimeOffset = version === 0 ? view.getUint32(offset) : view.getInt32(offset); offset += 4; }
|
|
62
|
-
samples.push(sample);
|
|
63
|
-
}
|
|
64
|
-
return { samples, dataOffset };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function parseTfhd(tfhdData) {
|
|
68
|
-
return new DataView(tfhdData.buffer, tfhdData.byteOffset, tfhdData.byteLength).getUint32(12);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ============================================
|
|
72
|
-
// Moov Rebuilding
|
|
73
|
-
// ============================================
|
|
74
|
-
function rebuildMvhd(mvhdBox, duration) {
|
|
75
|
-
const data = new Uint8Array(mvhdBox.data);
|
|
76
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
77
|
-
const version = data[8];
|
|
78
|
-
const durationOffset = version === 0 ? 24 : 32;
|
|
79
|
-
if (version === 0) view.setUint32(durationOffset, duration);
|
|
80
|
-
else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, duration); }
|
|
81
|
-
return data;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function rebuildTkhd(tkhdBox, trackInfo, maxDuration) {
|
|
85
|
-
const data = new Uint8Array(tkhdBox.data);
|
|
86
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
87
|
-
const version = data[8];
|
|
88
|
-
let trackDuration = maxDuration;
|
|
89
|
-
if (trackInfo) { trackDuration = 0; for (const s of trackInfo.samples) trackDuration += s.duration || 0; }
|
|
90
|
-
if (version === 0) view.setUint32(28, trackDuration);
|
|
91
|
-
else { view.setUint32(36, 0); view.setUint32(40, trackDuration); }
|
|
92
|
-
return data;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function rebuildMdhd(mdhdBox, trackInfo, maxDuration) {
|
|
96
|
-
const data = new Uint8Array(mdhdBox.data);
|
|
97
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
98
|
-
const version = data[8];
|
|
99
|
-
let trackDuration = 0;
|
|
100
|
-
if (trackInfo) for (const s of trackInfo.samples) trackDuration += s.duration || 0;
|
|
101
|
-
const durationOffset = version === 0 ? 24 : 32;
|
|
102
|
-
if (version === 0) view.setUint32(durationOffset, trackDuration);
|
|
103
|
-
else { view.setUint32(durationOffset, 0); view.setUint32(durationOffset + 4, trackDuration); }
|
|
104
|
-
return data;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function rebuildStbl(stblBox, trackInfo) {
|
|
108
|
-
const stblChildren = parseChildBoxes(stblBox);
|
|
109
|
-
const newParts = [];
|
|
110
|
-
for (const child of stblChildren) if (child.type === 'stsd') { newParts.push(child.data); break; }
|
|
111
|
-
const samples = trackInfo?.samples || [];
|
|
112
|
-
const chunkOffsets = trackInfo?.chunkOffsets || [];
|
|
113
|
-
|
|
114
|
-
// stts
|
|
115
|
-
const sttsEntries = [];
|
|
116
|
-
let curDur = null, count = 0;
|
|
117
|
-
for (const s of samples) {
|
|
118
|
-
const d = s.duration || 0;
|
|
119
|
-
if (d === curDur) count++;
|
|
120
|
-
else { if (curDur !== null) sttsEntries.push({ count, duration: curDur }); curDur = d; count = 1; }
|
|
121
|
-
}
|
|
122
|
-
if (curDur !== null) sttsEntries.push({ count, duration: curDur });
|
|
123
|
-
const sttsData = new Uint8Array(8 + sttsEntries.length * 8);
|
|
124
|
-
const sttsView = new DataView(sttsData.buffer);
|
|
125
|
-
sttsView.setUint32(4, sttsEntries.length);
|
|
126
|
-
let off = 8;
|
|
127
|
-
for (const e of sttsEntries) { sttsView.setUint32(off, e.count); sttsView.setUint32(off + 4, e.duration); off += 8; }
|
|
128
|
-
newParts.push(createBox('stts', sttsData));
|
|
129
|
-
|
|
130
|
-
// stsc
|
|
131
|
-
const stscEntries = [];
|
|
132
|
-
if (chunkOffsets.length > 0) {
|
|
133
|
-
let currentSampleCount = chunkOffsets[0].sampleCount, firstChunk = 1;
|
|
134
|
-
for (let i = 1; i <= chunkOffsets.length; i++) {
|
|
135
|
-
const sampleCount = i < chunkOffsets.length ? chunkOffsets[i].sampleCount : -1;
|
|
136
|
-
if (sampleCount !== currentSampleCount) {
|
|
137
|
-
stscEntries.push({ firstChunk, samplesPerChunk: currentSampleCount, sampleDescriptionIndex: 1 });
|
|
138
|
-
firstChunk = i + 1; currentSampleCount = sampleCount;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
} else stscEntries.push({ firstChunk: 1, samplesPerChunk: samples.length, sampleDescriptionIndex: 1 });
|
|
142
|
-
const stscData = new Uint8Array(8 + stscEntries.length * 12);
|
|
143
|
-
const stscView = new DataView(stscData.buffer);
|
|
144
|
-
stscView.setUint32(4, stscEntries.length);
|
|
145
|
-
off = 8;
|
|
146
|
-
for (const e of stscEntries) { stscView.setUint32(off, e.firstChunk); stscView.setUint32(off + 4, e.samplesPerChunk); stscView.setUint32(off + 8, e.sampleDescriptionIndex); off += 12; }
|
|
147
|
-
newParts.push(createBox('stsc', stscData));
|
|
148
|
-
|
|
149
|
-
// stsz
|
|
150
|
-
const stszData = new Uint8Array(12 + samples.length * 4);
|
|
151
|
-
const stszView = new DataView(stszData.buffer);
|
|
152
|
-
stszView.setUint32(8, samples.length);
|
|
153
|
-
off = 12;
|
|
154
|
-
for (const s of samples) { stszView.setUint32(off, s.size || 0); off += 4; }
|
|
155
|
-
newParts.push(createBox('stsz', stszData));
|
|
156
|
-
|
|
157
|
-
// stco
|
|
158
|
-
const numChunks = chunkOffsets.length || 1;
|
|
159
|
-
const stcoData = new Uint8Array(8 + numChunks * 4);
|
|
160
|
-
const stcoView = new DataView(stcoData.buffer);
|
|
161
|
-
stcoView.setUint32(4, numChunks);
|
|
162
|
-
for (let i = 0; i < numChunks; i++) stcoView.setUint32(8 + i * 4, chunkOffsets[i]?.offset || 0);
|
|
163
|
-
newParts.push(createBox('stco', stcoData));
|
|
164
|
-
|
|
165
|
-
// ctts
|
|
166
|
-
const hasCtts = samples.some(s => s.compositionTimeOffset);
|
|
167
|
-
if (hasCtts) {
|
|
168
|
-
const cttsEntries = [];
|
|
169
|
-
let curOff = null; count = 0;
|
|
170
|
-
for (const s of samples) {
|
|
171
|
-
const o = s.compositionTimeOffset || 0;
|
|
172
|
-
if (o === curOff) count++;
|
|
173
|
-
else { if (curOff !== null) cttsEntries.push({ count, offset: curOff }); curOff = o; count = 1; }
|
|
174
|
-
}
|
|
175
|
-
if (curOff !== null) cttsEntries.push({ count, offset: curOff });
|
|
176
|
-
const cttsData = new Uint8Array(8 + cttsEntries.length * 8);
|
|
177
|
-
const cttsView = new DataView(cttsData.buffer);
|
|
178
|
-
cttsView.setUint32(4, cttsEntries.length);
|
|
179
|
-
off = 8;
|
|
180
|
-
for (const e of cttsEntries) { cttsView.setUint32(off, e.count); cttsView.setInt32(off + 4, e.offset); off += 8; }
|
|
181
|
-
newParts.push(createBox('ctts', cttsData));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// stss
|
|
185
|
-
const syncSamples = [];
|
|
186
|
-
for (let i = 0; i < samples.length; i++) {
|
|
187
|
-
const flags = samples[i].flags;
|
|
188
|
-
if (flags !== undefined) { if (!((flags >> 16) & 0x1)) syncSamples.push(i + 1); }
|
|
189
|
-
}
|
|
190
|
-
if (syncSamples.length > 0 && syncSamples.length < samples.length) {
|
|
191
|
-
const stssData = new Uint8Array(8 + syncSamples.length * 4);
|
|
192
|
-
const stssView = new DataView(stssData.buffer);
|
|
193
|
-
stssView.setUint32(4, syncSamples.length);
|
|
194
|
-
off = 8;
|
|
195
|
-
for (const n of syncSamples) { stssView.setUint32(off, n); off += 4; }
|
|
196
|
-
newParts.push(createBox('stss', stssData));
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return createBox('stbl', ...newParts);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function rebuildMinf(minfBox, trackInfo) {
|
|
203
|
-
const minfChildren = parseChildBoxes(minfBox);
|
|
204
|
-
const newParts = [];
|
|
205
|
-
for (const child of minfChildren) {
|
|
206
|
-
if (child.type === 'stbl') newParts.push(rebuildStbl(child, trackInfo));
|
|
207
|
-
else newParts.push(child.data);
|
|
208
|
-
}
|
|
209
|
-
return createBox('minf', ...newParts);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function rebuildMdia(mdiaBox, trackInfo, maxDuration) {
|
|
213
|
-
const mdiaChildren = parseChildBoxes(mdiaBox);
|
|
214
|
-
const newParts = [];
|
|
215
|
-
for (const child of mdiaChildren) {
|
|
216
|
-
if (child.type === 'minf') newParts.push(rebuildMinf(child, trackInfo));
|
|
217
|
-
else if (child.type === 'mdhd') newParts.push(rebuildMdhd(child, trackInfo, maxDuration));
|
|
218
|
-
else newParts.push(child.data);
|
|
219
|
-
}
|
|
220
|
-
return createBox('mdia', ...newParts);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function rebuildTrak(trakBox, trackIdMap, maxDuration) {
|
|
224
|
-
const trakChildren = parseChildBoxes(trakBox);
|
|
225
|
-
let trackId = 1;
|
|
226
|
-
for (const child of trakChildren) {
|
|
227
|
-
if (child.type === 'tkhd') {
|
|
228
|
-
const view = new DataView(child.data.buffer, child.data.byteOffset, child.data.byteLength);
|
|
229
|
-
trackId = child.data[8] === 0 ? view.getUint32(20) : view.getUint32(28);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
const trackInfo = trackIdMap.get(trackId);
|
|
233
|
-
const newParts = [];
|
|
234
|
-
let hasEdts = false;
|
|
235
|
-
for (const child of trakChildren) {
|
|
236
|
-
if (child.type === 'edts') { hasEdts = true; newParts.push(child.data); }
|
|
237
|
-
else if (child.type === 'mdia') newParts.push(rebuildMdia(child, trackInfo, maxDuration));
|
|
238
|
-
else if (child.type === 'tkhd') newParts.push(rebuildTkhd(child, trackInfo, maxDuration));
|
|
239
|
-
else newParts.push(child.data);
|
|
240
|
-
}
|
|
241
|
-
if (!hasEdts && trackInfo) {
|
|
242
|
-
let trackDuration = 0;
|
|
243
|
-
for (const s of trackInfo.samples) trackDuration += s.duration || 0;
|
|
244
|
-
const elstData = new Uint8Array(20);
|
|
245
|
-
const elstView = new DataView(elstData.buffer);
|
|
246
|
-
elstView.setUint32(4, 1); elstView.setUint32(8, maxDuration); elstView.setInt32(12, 0); elstView.setInt16(16, 1);
|
|
247
|
-
const elst = createBox('elst', elstData);
|
|
248
|
-
const edts = createBox('edts', elst);
|
|
249
|
-
const tkhdIndex = newParts.findIndex(p => p.length >= 8 && String.fromCharCode(p[4], p[5], p[6], p[7]) === 'tkhd');
|
|
250
|
-
if (tkhdIndex >= 0) newParts.splice(tkhdIndex + 1, 0, edts);
|
|
251
|
-
}
|
|
252
|
-
return createBox('trak', ...newParts);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function updateStcoOffsets(output, ftypSize, moovSize) {
|
|
256
|
-
const mdatContentOffset = ftypSize + moovSize + 8;
|
|
257
|
-
const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
|
|
258
|
-
function scan(start, end) {
|
|
259
|
-
let pos = start;
|
|
260
|
-
while (pos + 8 <= end) {
|
|
261
|
-
const size = view.getUint32(pos);
|
|
262
|
-
if (size < 8) break;
|
|
263
|
-
const type = String.fromCharCode(output[pos+4], output[pos+5], output[pos+6], output[pos+7]);
|
|
264
|
-
if (type === 'stco') {
|
|
265
|
-
const entryCount = view.getUint32(pos + 12);
|
|
266
|
-
for (let i = 0; i < entryCount; i++) {
|
|
267
|
-
const entryPos = pos + 16 + i * 4;
|
|
268
|
-
view.setUint32(entryPos, mdatContentOffset + view.getUint32(entryPos));
|
|
269
|
-
}
|
|
270
|
-
} else if (['moov', 'trak', 'mdia', 'minf', 'stbl'].includes(type)) scan(pos + 8, pos + size);
|
|
271
|
-
pos += size;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
scan(0, output.byteLength);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Convert fragmented MP4 to standard MP4
|
|
279
|
-
* @param {Uint8Array} fmp4Data - fMP4 data
|
|
280
|
-
* @returns {Uint8Array} Standard MP4 data
|
|
281
|
-
*/
|
|
282
|
-
export function convertFmp4ToMp4(fmp4Data) {
|
|
283
|
-
const boxes = parseBoxes(fmp4Data);
|
|
284
|
-
const ftyp = findBox(boxes, 'ftyp');
|
|
285
|
-
const moov = findBox(boxes, 'moov');
|
|
286
|
-
if (!ftyp || !moov) throw new Error('Invalid fMP4: missing ftyp or moov');
|
|
287
|
-
|
|
288
|
-
const moovChildren = parseChildBoxes(moov);
|
|
289
|
-
const originalTrackIds = [];
|
|
290
|
-
for (const child of moovChildren) {
|
|
291
|
-
if (child.type === 'trak') {
|
|
292
|
-
const trakChildren = parseChildBoxes(child);
|
|
293
|
-
for (const tc of trakChildren) {
|
|
294
|
-
if (tc.type === 'tkhd') {
|
|
295
|
-
const view = new DataView(tc.data.buffer, tc.data.byteOffset, tc.data.byteLength);
|
|
296
|
-
originalTrackIds.push(tc.data[8] === 0 ? view.getUint32(20) : view.getUint32(28));
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const tracks = new Map();
|
|
303
|
-
const mdatChunks = [];
|
|
304
|
-
let combinedMdatOffset = 0;
|
|
305
|
-
|
|
306
|
-
for (let i = 0; i < boxes.length; i++) {
|
|
307
|
-
const box = boxes[i];
|
|
308
|
-
if (box.type === 'moof') {
|
|
309
|
-
const moofChildren = parseChildBoxes(box);
|
|
310
|
-
const moofStart = box.offset;
|
|
311
|
-
let nextMdatOffset = 0;
|
|
312
|
-
for (let j = i + 1; j < boxes.length; j++) {
|
|
313
|
-
if (boxes[j].type === 'mdat') { nextMdatOffset = boxes[j].offset; break; }
|
|
314
|
-
if (boxes[j].type === 'moof') break;
|
|
315
|
-
}
|
|
316
|
-
for (const child of moofChildren) {
|
|
317
|
-
if (child.type === 'traf') {
|
|
318
|
-
const trafChildren = parseChildBoxes(child);
|
|
319
|
-
const tfhd = findBox(trafChildren, 'tfhd');
|
|
320
|
-
const trun = findBox(trafChildren, 'trun');
|
|
321
|
-
if (tfhd && trun) {
|
|
322
|
-
const trackId = parseTfhd(tfhd.data);
|
|
323
|
-
const { samples, dataOffset } = parseTrunWithOffset(trun.data);
|
|
324
|
-
if (!tracks.has(trackId)) tracks.set(trackId, { samples: [], chunkOffsets: [] });
|
|
325
|
-
const track = tracks.get(trackId);
|
|
326
|
-
const chunkOffset = combinedMdatOffset + (moofStart + dataOffset) - (nextMdatOffset + 8);
|
|
327
|
-
track.chunkOffsets.push({ offset: chunkOffset, sampleCount: samples.length });
|
|
328
|
-
track.samples.push(...samples);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
} else if (box.type === 'mdat') {
|
|
333
|
-
mdatChunks.push({ data: box.data.subarray(8), offset: combinedMdatOffset });
|
|
334
|
-
combinedMdatOffset += box.data.subarray(8).byteLength;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const totalMdatSize = mdatChunks.reduce((sum, c) => sum + c.data.byteLength, 0);
|
|
339
|
-
const combinedMdat = new Uint8Array(totalMdatSize);
|
|
340
|
-
for (const chunk of mdatChunks) combinedMdat.set(chunk.data, chunk.offset);
|
|
341
|
-
|
|
342
|
-
const trackIdMap = new Map();
|
|
343
|
-
const fmp4TrackIds = Array.from(tracks.keys()).sort((a, b) => a - b);
|
|
344
|
-
for (let i = 0; i < fmp4TrackIds.length && i < originalTrackIds.length; i++) {
|
|
345
|
-
trackIdMap.set(originalTrackIds[i], tracks.get(fmp4TrackIds[i]));
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
let maxDuration = 0;
|
|
349
|
-
for (const [, track] of tracks) {
|
|
350
|
-
let dur = 0;
|
|
351
|
-
for (const s of track.samples) dur += s.duration || 0;
|
|
352
|
-
maxDuration = Math.max(maxDuration, dur);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const newMoovParts = [];
|
|
356
|
-
for (const child of moovChildren) {
|
|
357
|
-
if (child.type === 'mvex') continue;
|
|
358
|
-
if (child.type === 'trak') newMoovParts.push(rebuildTrak(child, trackIdMap, maxDuration));
|
|
359
|
-
else if (child.type === 'mvhd') newMoovParts.push(rebuildMvhd(child, maxDuration));
|
|
360
|
-
else newMoovParts.push(child.data);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const newMoov = createBox('moov', ...newMoovParts);
|
|
364
|
-
const newMdat = createBox('mdat', combinedMdat);
|
|
365
|
-
const output = new Uint8Array(ftyp.size + newMoov.byteLength + newMdat.byteLength);
|
|
366
|
-
output.set(ftyp.data, 0);
|
|
367
|
-
output.set(newMoov, ftyp.size);
|
|
368
|
-
output.set(newMdat, ftyp.size + newMoov.byteLength);
|
|
369
|
-
updateStcoOffsets(output, ftyp.size, newMoov.byteLength);
|
|
370
|
-
|
|
371
|
-
return output;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
export default convertFmp4ToMp4;
|
|
375
|
-
|