@invintusmedia/tomp4 1.2.1 → 1.3.1
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 +56 -18
- package/package.json +4 -2
- package/src/fmp4/converter.js +46 -7
- package/src/hls-clip.js +461 -0
- package/src/index.d.ts +413 -0
- package/src/index.js +18 -2
- package/src/mp4-clip.js +132 -0
- package/src/muxers/fmp4.js +493 -0
- package/src/muxers/mp4.js +14 -7
- package/src/ts-to-mp4.js +8 -9
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
declare module '@invintusmedia/tomp4' {
|
|
2
|
+
export interface Mp4Result {
|
|
3
|
+
/** Raw MP4 data */
|
|
4
|
+
data: Uint8Array;
|
|
5
|
+
/** Suggested filename */
|
|
6
|
+
filename: string;
|
|
7
|
+
/** Size in bytes */
|
|
8
|
+
size: number;
|
|
9
|
+
/** Human-readable size (e.g. "2.5 MB") */
|
|
10
|
+
sizeFormatted: string;
|
|
11
|
+
/** Get as Blob */
|
|
12
|
+
toBlob(): Blob;
|
|
13
|
+
/** Get as object URL for video.src */
|
|
14
|
+
toURL(): string;
|
|
15
|
+
/** Revoke the object URL to free memory */
|
|
16
|
+
revokeURL(): void;
|
|
17
|
+
/** Trigger browser download */
|
|
18
|
+
download(filename?: string): void;
|
|
19
|
+
/** Get as ArrayBuffer */
|
|
20
|
+
toArrayBuffer(): ArrayBuffer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HlsVariant {
|
|
24
|
+
url: string;
|
|
25
|
+
bandwidth: number;
|
|
26
|
+
resolution?: string;
|
|
27
|
+
width?: number;
|
|
28
|
+
height?: number;
|
|
29
|
+
codecs?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HlsStream {
|
|
33
|
+
masterUrl: string;
|
|
34
|
+
variants: HlsVariant[];
|
|
35
|
+
qualities: string[];
|
|
36
|
+
select(quality: string | number): HlsStream;
|
|
37
|
+
segments: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProgressInfo {
|
|
41
|
+
/** Current phase: 'download' or 'convert' */
|
|
42
|
+
phase: 'download' | 'convert';
|
|
43
|
+
/** Progress percentage (0-100) */
|
|
44
|
+
percent: number;
|
|
45
|
+
/** Current segment (download phase only) */
|
|
46
|
+
segment?: number;
|
|
47
|
+
/** Total segments (download phase only) */
|
|
48
|
+
totalSegments?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ToMp4Options {
|
|
52
|
+
/** Progress callback - receives message string and optional progress info */
|
|
53
|
+
onProgress?: (message: string, info?: ProgressInfo) => void;
|
|
54
|
+
/** Suggested filename for downloads */
|
|
55
|
+
filename?: string;
|
|
56
|
+
/** HLS quality: 'highest', 'lowest', or bandwidth number */
|
|
57
|
+
quality?: 'highest' | 'lowest' | number;
|
|
58
|
+
/** Max HLS segments to download */
|
|
59
|
+
maxSegments?: number;
|
|
60
|
+
/** Start time in seconds (snaps to nearest keyframe) */
|
|
61
|
+
startTime?: number;
|
|
62
|
+
/** End time in seconds */
|
|
63
|
+
endTime?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ThumbnailOptions {
|
|
67
|
+
/** Time in seconds to capture (default ~0.15) */
|
|
68
|
+
time?: number;
|
|
69
|
+
/** Resize output to this width (preserve aspect) */
|
|
70
|
+
maxWidth?: number;
|
|
71
|
+
/** Output mime type */
|
|
72
|
+
mimeType?: 'image/jpeg' | 'image/webp' | 'image/png';
|
|
73
|
+
/** 0..1 (jpeg/webp only) */
|
|
74
|
+
quality?: number;
|
|
75
|
+
/** Overall timeout (ms) */
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
/** HLS quality to download for thumbnail generation */
|
|
78
|
+
hlsQuality?: 'lowest' | 'highest';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class ImageResult {
|
|
82
|
+
blob: Blob;
|
|
83
|
+
filename: string;
|
|
84
|
+
constructor(blob: Blob, filename?: string);
|
|
85
|
+
toBlob(): Blob;
|
|
86
|
+
toURL(): string;
|
|
87
|
+
revokeURL(): void;
|
|
88
|
+
download(filename?: string): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface StitchFmp4Options {
|
|
92
|
+
/** Separate init segment (ftyp/moov) if not included in data segments */
|
|
93
|
+
init?: Uint8Array | ArrayBuffer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface KeyframeInfo {
|
|
97
|
+
/** Frame index */
|
|
98
|
+
index: number;
|
|
99
|
+
/** Time in seconds */
|
|
100
|
+
time: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface AnalysisResult {
|
|
104
|
+
/** Total duration in seconds */
|
|
105
|
+
duration: number;
|
|
106
|
+
/** Number of video frames */
|
|
107
|
+
videoFrames: number;
|
|
108
|
+
/** Number of audio frames */
|
|
109
|
+
audioFrames: number;
|
|
110
|
+
/** Keyframe positions */
|
|
111
|
+
keyframes: KeyframeInfo[];
|
|
112
|
+
/** Number of keyframes */
|
|
113
|
+
keyframeCount: number;
|
|
114
|
+
/** Video codec name */
|
|
115
|
+
videoCodec: string;
|
|
116
|
+
/** Audio codec name */
|
|
117
|
+
audioCodec: string;
|
|
118
|
+
/** Audio sample rate */
|
|
119
|
+
audioSampleRate: number | null;
|
|
120
|
+
/** Audio channel count */
|
|
121
|
+
audioChannels: number | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface MP4Sample {
|
|
125
|
+
/** Sample index */
|
|
126
|
+
index: number;
|
|
127
|
+
/** Byte offset in file */
|
|
128
|
+
offset: number;
|
|
129
|
+
/** Sample size in bytes */
|
|
130
|
+
size: number;
|
|
131
|
+
/** Decode timestamp in seconds */
|
|
132
|
+
dts: number;
|
|
133
|
+
/** Presentation timestamp in seconds */
|
|
134
|
+
pts: number;
|
|
135
|
+
/** Alias for pts */
|
|
136
|
+
time: number;
|
|
137
|
+
/** Sample duration in seconds */
|
|
138
|
+
duration: number;
|
|
139
|
+
/** Whether this is a keyframe */
|
|
140
|
+
isKeyframe: boolean;
|
|
141
|
+
/** Sample data (only present after getSampleData) */
|
|
142
|
+
data?: Uint8Array;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface MP4ParserInfo {
|
|
146
|
+
/** Duration in seconds */
|
|
147
|
+
duration: number;
|
|
148
|
+
/** Video width */
|
|
149
|
+
width: number;
|
|
150
|
+
/** Video height */
|
|
151
|
+
height: number;
|
|
152
|
+
/** Whether source has audio */
|
|
153
|
+
hasAudio: boolean;
|
|
154
|
+
/** Whether video has B-frames */
|
|
155
|
+
hasBframes: boolean;
|
|
156
|
+
/** Number of video samples */
|
|
157
|
+
videoSampleCount: number;
|
|
158
|
+
/** Number of audio samples */
|
|
159
|
+
audioSampleCount: number;
|
|
160
|
+
/** Number of keyframes */
|
|
161
|
+
keyframeCount: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* MP4 Parser - Parse local MP4 files
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* const parser = new MP4Parser(uint8ArrayData);
|
|
169
|
+
* console.log(parser.duration, parser.width, parser.height);
|
|
170
|
+
*
|
|
171
|
+
* // Get samples
|
|
172
|
+
* const videoSamples = parser.getVideoSamples();
|
|
173
|
+
* const audioSamples = parser.getAudioSamples();
|
|
174
|
+
*
|
|
175
|
+
* // Build HLS segments
|
|
176
|
+
* const segments = parser.buildSegments(4);
|
|
177
|
+
*
|
|
178
|
+
* // Get sample data
|
|
179
|
+
* const samplesWithData = parser.getSampleData(videoSamples.slice(0, 10));
|
|
180
|
+
*/
|
|
181
|
+
export class MP4Parser {
|
|
182
|
+
/** Duration in seconds */
|
|
183
|
+
readonly duration: number;
|
|
184
|
+
/** Video width */
|
|
185
|
+
readonly width: number;
|
|
186
|
+
/** Video height */
|
|
187
|
+
readonly height: number;
|
|
188
|
+
/** Whether source has audio */
|
|
189
|
+
readonly hasAudio: boolean;
|
|
190
|
+
/** Whether video has B-frames */
|
|
191
|
+
readonly hasBframes: boolean;
|
|
192
|
+
/** Video codec config (SPS/PPS) */
|
|
193
|
+
readonly videoCodecConfig: { sps: Uint8Array[]; pps: Uint8Array[]; profile: number; level: number; nalLengthSize: number } | null;
|
|
194
|
+
/** Audio codec config */
|
|
195
|
+
readonly audioCodecConfig: { sampleRate: number; channels: number } | null;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create parser from MP4 data
|
|
199
|
+
* @param data - Complete MP4 file data
|
|
200
|
+
*/
|
|
201
|
+
constructor(data: Uint8Array);
|
|
202
|
+
|
|
203
|
+
/** Get video sample table */
|
|
204
|
+
getVideoSamples(): MP4Sample[];
|
|
205
|
+
|
|
206
|
+
/** Get audio sample table */
|
|
207
|
+
getAudioSamples(): MP4Sample[];
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build HLS-style segments
|
|
211
|
+
* @param targetDuration - Target segment duration in seconds (default 4)
|
|
212
|
+
*/
|
|
213
|
+
buildSegments(targetDuration?: number): RemoteMp4Segment[];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get sample data for a range of samples
|
|
217
|
+
* Reads data from the original buffer
|
|
218
|
+
* @param samples - Samples to extract
|
|
219
|
+
*/
|
|
220
|
+
getSampleData(samples: MP4Sample[]): MP4Sample[];
|
|
221
|
+
|
|
222
|
+
/** Get parser info */
|
|
223
|
+
getInfo(): MP4ParserInfo;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface RemoteMp4Info {
|
|
227
|
+
/** Source URL */
|
|
228
|
+
url: string;
|
|
229
|
+
/** File size in bytes */
|
|
230
|
+
fileSize: number;
|
|
231
|
+
/** Duration in seconds */
|
|
232
|
+
duration: number;
|
|
233
|
+
/** Video width */
|
|
234
|
+
width: number;
|
|
235
|
+
/** Video height */
|
|
236
|
+
height: number;
|
|
237
|
+
/** Whether source has audio */
|
|
238
|
+
hasAudio: boolean;
|
|
239
|
+
/** Whether video has B-frames */
|
|
240
|
+
hasBframes: boolean;
|
|
241
|
+
/** Number of HLS segments */
|
|
242
|
+
segmentCount: number;
|
|
243
|
+
/** Number of video samples */
|
|
244
|
+
videoSampleCount: number;
|
|
245
|
+
/** Number of audio samples */
|
|
246
|
+
audioSampleCount: number;
|
|
247
|
+
/** Number of keyframes */
|
|
248
|
+
keyframeCount: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface RemoteMp4Segment {
|
|
252
|
+
/** Segment index */
|
|
253
|
+
index: number;
|
|
254
|
+
/** Start time in seconds */
|
|
255
|
+
startTime: number;
|
|
256
|
+
/** End time in seconds */
|
|
257
|
+
endTime: number;
|
|
258
|
+
/** Duration in seconds */
|
|
259
|
+
duration: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface RemoteMp4Options {
|
|
263
|
+
/** Target segment duration in seconds (default 4) */
|
|
264
|
+
segmentDuration?: number;
|
|
265
|
+
/** Progress callback */
|
|
266
|
+
onProgress?: (message: string) => void;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Remote MP4 parser for on-demand HLS serving
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* const source = await RemoteMp4.fromUrl('https://example.com/video.mp4');
|
|
274
|
+
* console.log(source.duration, source.segments.length);
|
|
275
|
+
*
|
|
276
|
+
* // Get HLS playlists
|
|
277
|
+
* const masterPlaylist = source.getMasterPlaylist();
|
|
278
|
+
* const mediaPlaylist = source.getMediaPlaylist();
|
|
279
|
+
*
|
|
280
|
+
* // Get segment as MPEG-TS
|
|
281
|
+
* const tsData = await source.getSegment(0);
|
|
282
|
+
*/
|
|
283
|
+
export class RemoteMp4 {
|
|
284
|
+
/** Source URL */
|
|
285
|
+
readonly url: string;
|
|
286
|
+
/** File size in bytes */
|
|
287
|
+
readonly fileSize: number;
|
|
288
|
+
/** Duration in seconds */
|
|
289
|
+
readonly duration: number;
|
|
290
|
+
/** Video width */
|
|
291
|
+
readonly width: number;
|
|
292
|
+
/** Video height */
|
|
293
|
+
readonly height: number;
|
|
294
|
+
/** Whether source has audio */
|
|
295
|
+
readonly hasAudio: boolean;
|
|
296
|
+
/** Whether video has B-frames */
|
|
297
|
+
readonly hasBframes: boolean;
|
|
298
|
+
/** HLS segments */
|
|
299
|
+
readonly segments: RemoteMp4Segment[];
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Create RemoteMp4 from URL
|
|
303
|
+
* Only fetches metadata (moov) - segments are loaded on-demand
|
|
304
|
+
*/
|
|
305
|
+
static fromUrl(url: string, options?: RemoteMp4Options): Promise<RemoteMp4>;
|
|
306
|
+
|
|
307
|
+
/** Get source information */
|
|
308
|
+
getInfo(): RemoteMp4Info;
|
|
309
|
+
|
|
310
|
+
/** Get segment definitions */
|
|
311
|
+
getSegments(): RemoteMp4Segment[];
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Generate HLS master playlist
|
|
315
|
+
* @param baseUrl - Base URL for playlist references (default '')
|
|
316
|
+
*/
|
|
317
|
+
getMasterPlaylist(baseUrl?: string): string;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Generate HLS media playlist
|
|
321
|
+
* @param baseUrl - Base URL for segment references (default '')
|
|
322
|
+
*/
|
|
323
|
+
getMediaPlaylist(baseUrl?: string): string;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get a segment as MPEG-TS data
|
|
327
|
+
* Fetches only the required byte ranges from the source
|
|
328
|
+
* @param index - Segment index
|
|
329
|
+
*/
|
|
330
|
+
getSegment(index: number): Promise<Uint8Array>;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Convert video to MP4
|
|
335
|
+
* @param input - URL, HLS stream, or video data
|
|
336
|
+
* @param options - Conversion options
|
|
337
|
+
*/
|
|
338
|
+
function toMp4(
|
|
339
|
+
input: string | Uint8Array | ArrayBuffer | Blob | HlsStream,
|
|
340
|
+
options?: ToMp4Options
|
|
341
|
+
): Promise<Mp4Result>;
|
|
342
|
+
|
|
343
|
+
namespace toMp4 {
|
|
344
|
+
/** Library version */
|
|
345
|
+
const version: string;
|
|
346
|
+
|
|
347
|
+
/** Convert MPEG-TS data to MP4 */
|
|
348
|
+
function fromTs(data: Uint8Array | ArrayBuffer, options?: ToMp4Options): Mp4Result;
|
|
349
|
+
|
|
350
|
+
/** Convert fMP4 data to MP4 */
|
|
351
|
+
function fromFmp4(data: Uint8Array | ArrayBuffer, options?: ToMp4Options): Mp4Result;
|
|
352
|
+
|
|
353
|
+
/** Detect format of video data */
|
|
354
|
+
function detectFormat(data: Uint8Array): 'mpegts' | 'fmp4' | 'mp4' | 'unknown';
|
|
355
|
+
|
|
356
|
+
/** Check if data is MPEG-TS */
|
|
357
|
+
function isMpegTs(data: Uint8Array): boolean;
|
|
358
|
+
|
|
359
|
+
/** Check if data is fMP4 */
|
|
360
|
+
function isFmp4(data: Uint8Array): boolean;
|
|
361
|
+
|
|
362
|
+
/** Check if data is standard MP4 */
|
|
363
|
+
function isStandardMp4(data: Uint8Array): boolean;
|
|
364
|
+
|
|
365
|
+
/** Parse HLS playlist */
|
|
366
|
+
function parseHls(url: string): Promise<HlsStream>;
|
|
367
|
+
|
|
368
|
+
/** Download and combine HLS segments */
|
|
369
|
+
function downloadHls(
|
|
370
|
+
input: string | HlsStream,
|
|
371
|
+
options?: ToMp4Options
|
|
372
|
+
): Promise<Uint8Array>;
|
|
373
|
+
|
|
374
|
+
/** Check if URL is an HLS playlist */
|
|
375
|
+
function isHlsUrl(url: string): boolean;
|
|
376
|
+
|
|
377
|
+
/** Analyze MPEG-TS data without converting */
|
|
378
|
+
function analyze(data: Uint8Array): AnalysisResult;
|
|
379
|
+
|
|
380
|
+
/** MP4 Parser for local files */
|
|
381
|
+
const MP4Parser: typeof import('@invintusmedia/tomp4').MP4Parser;
|
|
382
|
+
|
|
383
|
+
/** Remote MP4 parser for on-demand HLS serving */
|
|
384
|
+
const RemoteMp4: typeof import('@invintusmedia/tomp4').RemoteMp4;
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Stitch multiple fMP4 segments into a single MP4
|
|
388
|
+
* For live streams saved as 4-second fMP4 chunks
|
|
389
|
+
*/
|
|
390
|
+
function stitchFmp4(
|
|
391
|
+
segments: (Uint8Array | ArrayBuffer)[],
|
|
392
|
+
options?: StitchFmp4Options
|
|
393
|
+
): Mp4Result;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Extract a single frame as an image (browser-only).
|
|
397
|
+
* For HLS inputs, downloads a minimal range and remuxes to MP4 before capture.
|
|
398
|
+
*/
|
|
399
|
+
function thumbnail(
|
|
400
|
+
input:
|
|
401
|
+
| string
|
|
402
|
+
| Uint8Array
|
|
403
|
+
| ArrayBuffer
|
|
404
|
+
| Blob
|
|
405
|
+
| HlsStream
|
|
406
|
+
| { init?: Uint8Array | ArrayBuffer; segments: (Uint8Array | ArrayBuffer)[] },
|
|
407
|
+
options?: ThumbnailOptions
|
|
408
|
+
): Promise<ImageResult>;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export default toMp4;
|
|
412
|
+
export { toMp4, MP4Parser, RemoteMp4, stitchFmp4, thumbnail, ImageResult };
|
|
413
|
+
}
|
package/src/index.js
CHANGED
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
|
|
33
33
|
import { convertTsToMp4, analyzeTsData } from './ts-to-mp4.js';
|
|
34
34
|
import { convertFmp4ToMp4, stitchFmp4 } from './fmp4/index.js';
|
|
35
|
+
import { clipMp4 } from './mp4-clip.js';
|
|
36
|
+
import { clipHls, HlsClipResult } from './hls-clip.js';
|
|
37
|
+
import { createInitSegment, createFragment } from './muxers/fmp4.js';
|
|
35
38
|
import { stitchTs, concatTs } from './mpegts/index.js';
|
|
36
39
|
import { parseHls, downloadHls, isHlsUrl, HlsStream, HlsVariant } from './hls.js';
|
|
37
40
|
import { transcode, isWebCodecsSupported } from './transcode.js';
|
|
@@ -179,9 +182,13 @@ function convertData(data, options = {}) {
|
|
|
179
182
|
case 'fmp4':
|
|
180
183
|
return convertFmp4ToMp4(uint8, options);
|
|
181
184
|
case 'mp4':
|
|
185
|
+
// Clip if time range specified, otherwise pass through
|
|
186
|
+
if (options.startTime !== undefined || options.endTime !== undefined) {
|
|
187
|
+
return clipMp4(uint8, options);
|
|
188
|
+
}
|
|
182
189
|
return uint8;
|
|
183
190
|
default:
|
|
184
|
-
throw new Error('Unrecognized video format. Expected MPEG-TS or
|
|
191
|
+
throw new Error('Unrecognized video format. Expected MPEG-TS, fMP4, or MP4.');
|
|
185
192
|
}
|
|
186
193
|
}
|
|
187
194
|
|
|
@@ -302,6 +309,10 @@ async function toMp4(input, options = {}) {
|
|
|
302
309
|
// Attach utilities to main function
|
|
303
310
|
toMp4.fromTs = (data, options) => new Mp4Result(convertTsToMp4(data instanceof ArrayBuffer ? new Uint8Array(data) : data, options));
|
|
304
311
|
toMp4.fromFmp4 = (data, options = {}) => new Mp4Result(convertFmp4ToMp4(data instanceof ArrayBuffer ? new Uint8Array(data) : data, options));
|
|
312
|
+
toMp4.clipMp4 = (data, options = {}) => new Mp4Result(clipMp4(data instanceof ArrayBuffer ? new Uint8Array(data) : data, options));
|
|
313
|
+
toMp4.clipHls = clipHls;
|
|
314
|
+
toMp4.createInitSegment = createInitSegment;
|
|
315
|
+
toMp4.createFragment = createFragment;
|
|
305
316
|
toMp4.stitchFmp4 = (segments, options) => new Mp4Result(stitchFmp4(segments, options));
|
|
306
317
|
toMp4.stitchTs = (segments) => new Mp4Result(stitchTs(segments));
|
|
307
318
|
toMp4.concatTs = concatTs;
|
|
@@ -331,7 +342,7 @@ toMp4.TSParser = TSParser;
|
|
|
331
342
|
toMp4.RemoteMp4 = RemoteMp4;
|
|
332
343
|
|
|
333
344
|
// Version (injected at build time for dist, read from package.json for ESM)
|
|
334
|
-
toMp4.version = '1.
|
|
345
|
+
toMp4.version = '1.3.1';
|
|
335
346
|
|
|
336
347
|
// Export
|
|
337
348
|
export {
|
|
@@ -339,6 +350,11 @@ export {
|
|
|
339
350
|
Mp4Result,
|
|
340
351
|
convertTsToMp4,
|
|
341
352
|
convertFmp4ToMp4,
|
|
353
|
+
clipMp4,
|
|
354
|
+
clipHls,
|
|
355
|
+
HlsClipResult,
|
|
356
|
+
createInitSegment,
|
|
357
|
+
createFragment,
|
|
342
358
|
stitchFmp4,
|
|
343
359
|
stitchTs,
|
|
344
360
|
concatTs,
|
package/src/mp4-clip.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard MP4 Clipper
|
|
3
|
+
*
|
|
4
|
+
* Clips a standard (non-fragmented) MP4 to a time range, producing a new MP4
|
|
5
|
+
* with frame-accurate edit lists. Reuses the fMP4 converter's rebuild pipeline.
|
|
6
|
+
*
|
|
7
|
+
* @module mp4-clip
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { MP4Parser } from './parsers/mp4.js';
|
|
11
|
+
import {
|
|
12
|
+
parseBoxes,
|
|
13
|
+
findBox,
|
|
14
|
+
parseChildBoxes,
|
|
15
|
+
createBox,
|
|
16
|
+
getMovieTimescale,
|
|
17
|
+
} from './fmp4/utils.js';
|
|
18
|
+
import {
|
|
19
|
+
applyClipToTracks,
|
|
20
|
+
rebuildMdatContent,
|
|
21
|
+
calculateMovieDuration,
|
|
22
|
+
rebuildTrak,
|
|
23
|
+
rebuildMvhd,
|
|
24
|
+
updateStcoOffsets,
|
|
25
|
+
} from './fmp4/converter.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert MP4Parser samples to the format expected by the clipping pipeline.
|
|
29
|
+
* MP4Parser returns times in seconds; the clipper needs ticks.
|
|
30
|
+
*/
|
|
31
|
+
function convertSamples(parserSamples, timescale) {
|
|
32
|
+
return parserSamples.map(s => ({
|
|
33
|
+
duration: Math.round(s.duration * timescale),
|
|
34
|
+
size: s.size,
|
|
35
|
+
flags: s.isKeyframe ? 0 : 0x10000,
|
|
36
|
+
compositionTimeOffset: Math.round((s.pts - s.dts) * timescale),
|
|
37
|
+
dts: Math.round(s.dts * timescale),
|
|
38
|
+
pts: Math.round(s.pts * timescale),
|
|
39
|
+
byteOffset: s.offset,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Clip a standard MP4 to a time range.
|
|
45
|
+
*
|
|
46
|
+
* @param {Uint8Array} mp4Data - Standard MP4 data
|
|
47
|
+
* @param {object} [options]
|
|
48
|
+
* @param {number} [options.startTime] - Start time in seconds
|
|
49
|
+
* @param {number} [options.endTime] - End time in seconds
|
|
50
|
+
* @returns {Uint8Array} Clipped MP4 data
|
|
51
|
+
*/
|
|
52
|
+
export function clipMp4(mp4Data, options = {}) {
|
|
53
|
+
const parser = new MP4Parser(mp4Data);
|
|
54
|
+
const videoTrack = parser.videoTrack;
|
|
55
|
+
const audioTrack = parser.audioTrack;
|
|
56
|
+
|
|
57
|
+
if (!videoTrack) throw new Error('No video track found in MP4');
|
|
58
|
+
|
|
59
|
+
// Build tracks map in the format expected by applyClipToTracks
|
|
60
|
+
const tracks = new Map();
|
|
61
|
+
const trackOrder = [];
|
|
62
|
+
|
|
63
|
+
const vSamples = convertSamples(parser.getVideoSamples(), videoTrack.timescale);
|
|
64
|
+
tracks.set(videoTrack.trackId, {
|
|
65
|
+
trackId: videoTrack.trackId,
|
|
66
|
+
timescale: videoTrack.timescale,
|
|
67
|
+
handlerType: 'vide',
|
|
68
|
+
samples: vSamples,
|
|
69
|
+
chunkOffsets: [],
|
|
70
|
+
mediaTime: 0,
|
|
71
|
+
playbackDuration: 0,
|
|
72
|
+
});
|
|
73
|
+
trackOrder.push(videoTrack.trackId);
|
|
74
|
+
|
|
75
|
+
if (audioTrack) {
|
|
76
|
+
const aSamples = convertSamples(parser.getAudioSamples(), audioTrack.timescale);
|
|
77
|
+
tracks.set(audioTrack.trackId, {
|
|
78
|
+
trackId: audioTrack.trackId,
|
|
79
|
+
timescale: audioTrack.timescale,
|
|
80
|
+
handlerType: 'soun',
|
|
81
|
+
samples: aSamples,
|
|
82
|
+
chunkOffsets: [],
|
|
83
|
+
mediaTime: 0,
|
|
84
|
+
playbackDuration: 0,
|
|
85
|
+
});
|
|
86
|
+
trackOrder.push(audioTrack.trackId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Clip samples (reuses fMP4 converter's logic, including A/V sync fix)
|
|
90
|
+
const clippedTracks = applyClipToTracks(tracks, options);
|
|
91
|
+
if (clippedTracks.size === 0) throw new Error('Clip range produced no samples');
|
|
92
|
+
|
|
93
|
+
// Parse top-level boxes for rebuild
|
|
94
|
+
const boxes = parseBoxes(mp4Data);
|
|
95
|
+
const ftyp = findBox(boxes, 'ftyp');
|
|
96
|
+
const moov = findBox(boxes, 'moov');
|
|
97
|
+
if (!ftyp || !moov) throw new Error('Invalid MP4: missing ftyp or moov');
|
|
98
|
+
|
|
99
|
+
const movieTimescale = getMovieTimescale(moov);
|
|
100
|
+
|
|
101
|
+
// Rebuild mdat — sample byteOffsets are absolute file offsets, so pass the
|
|
102
|
+
// entire file as the source buffer
|
|
103
|
+
const rebuiltMdat = rebuildMdatContent(clippedTracks, trackOrder, mp4Data);
|
|
104
|
+
const maxMovieDuration = calculateMovieDuration(clippedTracks, movieTimescale);
|
|
105
|
+
|
|
106
|
+
// Rebuild moov with clipped timing
|
|
107
|
+
const moovChildren = parseChildBoxes(moov);
|
|
108
|
+
const newMoovParts = [];
|
|
109
|
+
for (const child of moovChildren) {
|
|
110
|
+
if (child.type === 'mvex') continue;
|
|
111
|
+
if (child.type === 'trak') {
|
|
112
|
+
const trak = rebuildTrak(child, clippedTracks, maxMovieDuration);
|
|
113
|
+
if (trak) newMoovParts.push(trak);
|
|
114
|
+
} else if (child.type === 'mvhd') {
|
|
115
|
+
newMoovParts.push(rebuildMvhd(child, maxMovieDuration));
|
|
116
|
+
} else {
|
|
117
|
+
newMoovParts.push(child.data);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Assemble final MP4: ftyp + moov + mdat
|
|
122
|
+
const newMoov = createBox('moov', ...newMoovParts);
|
|
123
|
+
const newMdat = createBox('mdat', rebuiltMdat);
|
|
124
|
+
const output = new Uint8Array(ftyp.size + newMoov.byteLength + newMdat.byteLength);
|
|
125
|
+
output.set(ftyp.data, 0);
|
|
126
|
+
output.set(newMoov, ftyp.size);
|
|
127
|
+
output.set(newMdat, ftyp.size + newMoov.byteLength);
|
|
128
|
+
updateStcoOffsets(output, ftyp.size, newMoov.byteLength);
|
|
129
|
+
return output;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default clipMp4;
|