@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/dist/tomp4.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toMp4.js v1.
|
|
2
|
+
* toMp4.js v1.3.1
|
|
3
3
|
* Convert MPEG-TS and fMP4 to standard MP4
|
|
4
4
|
* https://github.com/TVWIT/toMp4.js
|
|
5
5
|
* MIT License
|
|
@@ -111,24 +111,23 @@
|
|
|
111
111
|
// This is the time the decoder needs to process but player shouldn't display
|
|
112
112
|
const prerollPts = Math.max(0, startPts - keyframePts);
|
|
113
113
|
|
|
114
|
-
// Clip audio
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
const
|
|
114
|
+
// Clip audio from KEYFRAME time (same as video) so A/V stays in sync
|
|
115
|
+
// even on players that ignore edit lists. The edit list will skip the
|
|
116
|
+
// audio preroll on compliant players, just like it does for video.
|
|
117
|
+
const audioStartPts = keyframePts;
|
|
118
|
+
const audioEndPts = Math.min(endPts, lastFramePts + 90000);
|
|
118
119
|
const clippedAudio = audioAUs.filter(au => au.pts >= audioStartPts && au.pts < audioEndPts);
|
|
119
120
|
|
|
120
|
-
// Normalize video
|
|
121
|
+
// Normalize both video and audio to the same base (keyframe PTS)
|
|
122
|
+
// so they share a common timeline regardless of edit list support
|
|
121
123
|
const offset = keyframePts;
|
|
122
124
|
for (const au of clippedVideo) {
|
|
123
125
|
au.pts -= offset;
|
|
124
126
|
au.dts -= offset;
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
// Normalize audio timestamps so it starts at 0 (matching video playback start after preroll)
|
|
128
|
-
// Audio doesn't have preroll, so it should start at PTS 0 to sync with video after edit list
|
|
129
|
-
const audioOffset = audioStartPts; // Use requested start, not keyframe
|
|
130
129
|
for (const au of clippedAudio) {
|
|
131
|
-
au.pts -=
|
|
130
|
+
au.pts -= offset;
|
|
132
131
|
}
|
|
133
132
|
|
|
134
133
|
return {
|
|
@@ -580,23 +579,52 @@
|
|
|
580
579
|
}
|
|
581
580
|
}
|
|
582
581
|
|
|
582
|
+
// First pass: clip video to determine preroll duration
|
|
583
583
|
const clipped = new Map();
|
|
584
|
+
let videoPrerollSec = 0;
|
|
585
|
+
|
|
586
|
+
if (videoTrackId !== null) {
|
|
587
|
+
const vTrack = tracks.get(videoTrackId);
|
|
588
|
+
if (vTrack && vTrack.samples.length) {
|
|
589
|
+
const startTick = Math.round(startSec * vTrack.timescale);
|
|
590
|
+
const endTick = Number.isFinite(endSec) ? Math.round(endSec * vTrack.timescale) : Infinity;
|
|
591
|
+
const clip = clipVideoSamples(vTrack.samples, startTick, endTick);
|
|
592
|
+
|
|
593
|
+
if (clip.samples.length) {
|
|
594
|
+
videoPrerollSec = clip.mediaTime / vTrack.timescale;
|
|
595
|
+
clipped.set(videoTrackId, {
|
|
596
|
+
...vTrack,
|
|
597
|
+
samples: clip.samples,
|
|
598
|
+
mediaTime: clip.mediaTime,
|
|
599
|
+
playbackDuration: clip.playbackDuration,
|
|
600
|
+
chunkOffsets: [],
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Second pass: clip non-video tracks, including audio from the video's
|
|
607
|
+
// decode start (keyframe) so A/V stays in sync without edit lists
|
|
584
608
|
for (const [trackId, track] of tracks) {
|
|
585
|
-
if (!track.samples.length) continue;
|
|
609
|
+
if (!track.samples.length || trackId === videoTrackId) continue;
|
|
586
610
|
|
|
587
|
-
const
|
|
611
|
+
const adjustedStartSec = Math.max(0, startSec - videoPrerollSec);
|
|
612
|
+
const startTick = Math.round(adjustedStartSec * track.timescale);
|
|
588
613
|
const endTick = Number.isFinite(endSec) ? Math.round(endSec * track.timescale) : Infinity;
|
|
589
|
-
const clip =
|
|
590
|
-
? clipVideoSamples(track.samples, startTick, endTick)
|
|
591
|
-
: clipNonVideoSamples(track.samples, startTick, endTick);
|
|
614
|
+
const clip = clipNonVideoSamples(track.samples, startTick, endTick);
|
|
592
615
|
|
|
593
616
|
if (!clip.samples.length) continue;
|
|
594
617
|
|
|
618
|
+
// Audio preroll matches video preroll so both tracks share the same timeline
|
|
619
|
+
const audioPreroll = Math.round(videoPrerollSec * track.timescale);
|
|
620
|
+
const totalDur = sumSampleDurations(clip.samples);
|
|
621
|
+
const playbackDuration = Math.max(0, totalDur - audioPreroll);
|
|
622
|
+
|
|
595
623
|
clipped.set(trackId, {
|
|
596
624
|
...track,
|
|
597
625
|
samples: clip.samples,
|
|
598
|
-
mediaTime:
|
|
599
|
-
playbackDuration
|
|
626
|
+
mediaTime: audioPreroll,
|
|
627
|
+
playbackDuration,
|
|
600
628
|
chunkOffsets: [],
|
|
601
629
|
});
|
|
602
630
|
}
|
|
@@ -1041,6 +1069,16 @@
|
|
|
1041
1069
|
* @param {number} [options.endTime] - Clip end time (seconds)
|
|
1042
1070
|
* @returns {Uint8Array} Standard MP4 data
|
|
1043
1071
|
*/
|
|
1072
|
+
// Shared rebuild functions — also used by mp4-clip.js for standard MP4 clipping
|
|
1073
|
+
{
|
|
1074
|
+
applyClipToTracks,
|
|
1075
|
+
rebuildMdatContent,
|
|
1076
|
+
calculateMovieDuration,
|
|
1077
|
+
rebuildTrak,
|
|
1078
|
+
rebuildMvhd,
|
|
1079
|
+
updateStcoOffsets,
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1044
1082
|
function convertFmp4ToMp4(fmp4Data, options = {}) {
|
|
1045
1083
|
const boxes = parseBoxes(fmp4Data);
|
|
1046
1084
|
const ftyp = findBox(boxes, 'ftyp');
|
|
@@ -1148,7 +1186,7 @@
|
|
|
1148
1186
|
toMp4.isMpegTs = isMpegTs;
|
|
1149
1187
|
toMp4.isFmp4 = isFmp4;
|
|
1150
1188
|
toMp4.isStandardMp4 = isStandardMp4;
|
|
1151
|
-
toMp4.version = '1.
|
|
1189
|
+
toMp4.version = '1.3.1';
|
|
1152
1190
|
|
|
1153
1191
|
return toMp4;
|
|
1154
1192
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invintusmedia/tomp4",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
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,11 +20,13 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "node build.js",
|
|
22
22
|
"dev": "npx serve . -p 3000",
|
|
23
|
-
"test": "npm run test:hls-map && npm run test:thumbnail && npm run test:clip && npm run test:mp4",
|
|
23
|
+
"test": "npm run test:hls-map && npm run test:thumbnail && npm run test:clip && npm run test:mp4 && npm run test:av-sync && npm run test:mp4-clip",
|
|
24
24
|
"test:hls-map": "node tests/hls-map.test.js",
|
|
25
25
|
"test:thumbnail": "node tests/thumbnail.node.test.js",
|
|
26
26
|
"test:clip": "node tests/clip.test.js",
|
|
27
27
|
"test:fmp4-clip": "node tests/fmp4-clip.test.js",
|
|
28
|
+
"test:av-sync": "node tests/av-sync.test.js",
|
|
29
|
+
"test:mp4-clip": "node tests/mp4-clip.test.js",
|
|
28
30
|
"test:mp4": "node tests/mp4-parser.test.js",
|
|
29
31
|
"test:all": "npm run test",
|
|
30
32
|
"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",
|
package/src/fmp4/converter.js
CHANGED
|
@@ -257,23 +257,52 @@ function applyClipToTracks(tracks, options = {}) {
|
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// First pass: clip video to determine preroll duration
|
|
260
261
|
const clipped = new Map();
|
|
262
|
+
let videoPrerollSec = 0;
|
|
263
|
+
|
|
264
|
+
if (videoTrackId !== null) {
|
|
265
|
+
const vTrack = tracks.get(videoTrackId);
|
|
266
|
+
if (vTrack && vTrack.samples.length) {
|
|
267
|
+
const startTick = Math.round(startSec * vTrack.timescale);
|
|
268
|
+
const endTick = Number.isFinite(endSec) ? Math.round(endSec * vTrack.timescale) : Infinity;
|
|
269
|
+
const clip = clipVideoSamples(vTrack.samples, startTick, endTick);
|
|
270
|
+
|
|
271
|
+
if (clip.samples.length) {
|
|
272
|
+
videoPrerollSec = clip.mediaTime / vTrack.timescale;
|
|
273
|
+
clipped.set(videoTrackId, {
|
|
274
|
+
...vTrack,
|
|
275
|
+
samples: clip.samples,
|
|
276
|
+
mediaTime: clip.mediaTime,
|
|
277
|
+
playbackDuration: clip.playbackDuration,
|
|
278
|
+
chunkOffsets: [],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Second pass: clip non-video tracks, including audio from the video's
|
|
285
|
+
// decode start (keyframe) so A/V stays in sync without edit lists
|
|
261
286
|
for (const [trackId, track] of tracks) {
|
|
262
|
-
if (!track.samples.length) continue;
|
|
287
|
+
if (!track.samples.length || trackId === videoTrackId) continue;
|
|
263
288
|
|
|
264
|
-
const
|
|
289
|
+
const adjustedStartSec = Math.max(0, startSec - videoPrerollSec);
|
|
290
|
+
const startTick = Math.round(adjustedStartSec * track.timescale);
|
|
265
291
|
const endTick = Number.isFinite(endSec) ? Math.round(endSec * track.timescale) : Infinity;
|
|
266
|
-
const clip =
|
|
267
|
-
? clipVideoSamples(track.samples, startTick, endTick)
|
|
268
|
-
: clipNonVideoSamples(track.samples, startTick, endTick);
|
|
292
|
+
const clip = clipNonVideoSamples(track.samples, startTick, endTick);
|
|
269
293
|
|
|
270
294
|
if (!clip.samples.length) continue;
|
|
271
295
|
|
|
296
|
+
// Audio preroll matches video preroll so both tracks share the same timeline
|
|
297
|
+
const audioPreroll = Math.round(videoPrerollSec * track.timescale);
|
|
298
|
+
const totalDur = sumSampleDurations(clip.samples);
|
|
299
|
+
const playbackDuration = Math.max(0, totalDur - audioPreroll);
|
|
300
|
+
|
|
272
301
|
clipped.set(trackId, {
|
|
273
302
|
...track,
|
|
274
303
|
samples: clip.samples,
|
|
275
|
-
mediaTime:
|
|
276
|
-
playbackDuration
|
|
304
|
+
mediaTime: audioPreroll,
|
|
305
|
+
playbackDuration,
|
|
277
306
|
chunkOffsets: [],
|
|
278
307
|
});
|
|
279
308
|
}
|
|
@@ -718,6 +747,16 @@ function updateStcoOffsets(output, ftypSize, moovSize) {
|
|
|
718
747
|
* @param {number} [options.endTime] - Clip end time (seconds)
|
|
719
748
|
* @returns {Uint8Array} Standard MP4 data
|
|
720
749
|
*/
|
|
750
|
+
// Shared rebuild functions — also used by mp4-clip.js for standard MP4 clipping
|
|
751
|
+
export {
|
|
752
|
+
applyClipToTracks,
|
|
753
|
+
rebuildMdatContent,
|
|
754
|
+
calculateMovieDuration,
|
|
755
|
+
rebuildTrak,
|
|
756
|
+
rebuildMvhd,
|
|
757
|
+
updateStcoOffsets,
|
|
758
|
+
};
|
|
759
|
+
|
|
721
760
|
export function convertFmp4ToMp4(fmp4Data, options = {}) {
|
|
722
761
|
const boxes = parseBoxes(fmp4Data);
|
|
723
762
|
const ftyp = findBox(boxes, 'ftyp');
|
package/src/hls-clip.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HLS-to-HLS Clipper
|
|
3
|
+
*
|
|
4
|
+
* Clips an HLS stream to a time range, producing a new HLS stream with
|
|
5
|
+
* CMAF (fMP4) segments. Boundary segments are pre-clipped with edit lists
|
|
6
|
+
* for frame-accurate start/end. Middle segments are remuxed on-demand
|
|
7
|
+
* from the original CDN source.
|
|
8
|
+
*
|
|
9
|
+
* @module hls-clip
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const clip = await clipHls('https://example.com/stream.m3u8', {
|
|
13
|
+
* startTime: 30,
|
|
14
|
+
* endTime: 90,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* clip.masterPlaylist // modified m3u8 text
|
|
18
|
+
* clip.getMediaPlaylist(0) // variant media playlist
|
|
19
|
+
* clip.getInitSegment(0) // fMP4 init segment (Uint8Array)
|
|
20
|
+
* await clip.getSegment(0, 0) // fMP4 media segment (Uint8Array)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { parseHls, isHlsUrl, parsePlaylistText, toAbsoluteUrl } from './hls.js';
|
|
24
|
+
import { TSParser, getCodecInfo } from './parsers/mpegts.js';
|
|
25
|
+
import { createInitSegment, createFragment } from './muxers/fmp4.js';
|
|
26
|
+
|
|
27
|
+
// ── constants ─────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const PTS_PER_SECOND = 90000;
|
|
30
|
+
|
|
31
|
+
// ── helpers ───────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function isKeyframe(accessUnit) {
|
|
34
|
+
for (const nalUnit of accessUnit.nalUnits) {
|
|
35
|
+
if ((nalUnit[0] & 0x1F) === 5) return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractCodecInfo(parser) {
|
|
41
|
+
let sps = null, pps = null;
|
|
42
|
+
for (const au of parser.videoAccessUnits) {
|
|
43
|
+
for (const nalUnit of au.nalUnits) {
|
|
44
|
+
const nalType = nalUnit[0] & 0x1F;
|
|
45
|
+
if (nalType === 7 && !sps) sps = nalUnit;
|
|
46
|
+
if (nalType === 8 && !pps) pps = nalUnit;
|
|
47
|
+
if (sps && pps) return { sps, pps };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { sps, pps };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse a TS segment and return the parsed data.
|
|
55
|
+
*/
|
|
56
|
+
function parseTs(tsData) {
|
|
57
|
+
const parser = new TSParser();
|
|
58
|
+
parser.parse(tsData);
|
|
59
|
+
parser.finalize();
|
|
60
|
+
return parser;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remux parsed TS data into an fMP4 fragment.
|
|
65
|
+
* Normalizes timestamps to start at the given base times.
|
|
66
|
+
*/
|
|
67
|
+
function remuxToFragment(parser, sequenceNumber, videoBaseTime, audioBaseTime, audioTimescale) {
|
|
68
|
+
return createFragment({
|
|
69
|
+
videoSamples: parser.videoAccessUnits,
|
|
70
|
+
audioSamples: parser.audioAccessUnits,
|
|
71
|
+
sequenceNumber,
|
|
72
|
+
videoTimescale: PTS_PER_SECOND,
|
|
73
|
+
audioTimescale,
|
|
74
|
+
videoBaseTime,
|
|
75
|
+
audioBaseTime,
|
|
76
|
+
audioSampleDuration: 1024,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Clip a parsed TS segment at the start and/or end.
|
|
82
|
+
*
|
|
83
|
+
* Starts at the nearest keyframe at or before startTime (required for
|
|
84
|
+
* decoding). No preroll/edit-list — hls.js doesn't read edit lists, so
|
|
85
|
+
* every frame in the fMP4 gets played. The EXTINF duration matches the
|
|
86
|
+
* actual content, which means the clip may start slightly before the
|
|
87
|
+
* requested time (at the keyframe).
|
|
88
|
+
*/
|
|
89
|
+
function clipSegment(parser, startTime, endTime) {
|
|
90
|
+
const startPts = (startTime !== undefined ? startTime : 0) * PTS_PER_SECOND;
|
|
91
|
+
const endPts = (endTime !== undefined ? endTime : Infinity) * PTS_PER_SECOND;
|
|
92
|
+
const videoAUs = parser.videoAccessUnits;
|
|
93
|
+
const audioAUs = parser.audioAccessUnits;
|
|
94
|
+
|
|
95
|
+
// Find keyframe at or before startTime
|
|
96
|
+
let keyframeIdx = 0;
|
|
97
|
+
for (let i = 0; i < videoAUs.length; i++) {
|
|
98
|
+
if (videoAUs[i].pts > startPts) break;
|
|
99
|
+
if (isKeyframe(videoAUs[i])) keyframeIdx = i;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Find end index
|
|
103
|
+
let endIdx = videoAUs.length;
|
|
104
|
+
for (let i = keyframeIdx; i < videoAUs.length; i++) {
|
|
105
|
+
if (videoAUs[i].pts >= endPts) { endIdx = i; break; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const clippedVideo = videoAUs.slice(keyframeIdx, endIdx);
|
|
109
|
+
if (clippedVideo.length === 0) return null;
|
|
110
|
+
|
|
111
|
+
const keyframePts = clippedVideo[0].pts;
|
|
112
|
+
|
|
113
|
+
// Clip audio from keyframe (same start as video for A/V sync)
|
|
114
|
+
const lastVideoPts = clippedVideo[clippedVideo.length - 1].pts;
|
|
115
|
+
const audioEndPts = Math.min(endPts, lastVideoPts + PTS_PER_SECOND);
|
|
116
|
+
const clippedAudio = audioAUs.filter(au => au.pts >= keyframePts && au.pts < audioEndPts);
|
|
117
|
+
|
|
118
|
+
// Normalize timestamps to start at 0
|
|
119
|
+
const offset = keyframePts;
|
|
120
|
+
for (const au of clippedVideo) { au.pts -= offset; au.dts -= offset; }
|
|
121
|
+
for (const au of clippedAudio) { au.pts -= offset; }
|
|
122
|
+
|
|
123
|
+
// Duration = full content from keyframe (no preroll subtraction)
|
|
124
|
+
const duration = clippedVideo.length > 1
|
|
125
|
+
? clippedVideo[clippedVideo.length - 1].dts - clippedVideo[0].dts +
|
|
126
|
+
(clippedVideo[1].dts - clippedVideo[0].dts)
|
|
127
|
+
: 3003;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
videoSamples: clippedVideo,
|
|
131
|
+
audioSamples: clippedAudio,
|
|
132
|
+
duration: duration / PTS_PER_SECOND,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── HlsClipResult ─────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
class HlsClipResult {
|
|
139
|
+
constructor({ variants, duration, startTime, endTime }) {
|
|
140
|
+
this._variants = variants; // array of VariantClip
|
|
141
|
+
this.duration = duration;
|
|
142
|
+
this.startTime = startTime;
|
|
143
|
+
this.endTime = endTime;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Number of quality variants */
|
|
147
|
+
get variantCount() {
|
|
148
|
+
return this._variants.length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Master playlist m3u8 text */
|
|
152
|
+
get masterPlaylist() {
|
|
153
|
+
if (this._variants.length === 1) {
|
|
154
|
+
return this.getMediaPlaylist(0);
|
|
155
|
+
}
|
|
156
|
+
let m3u8 = '#EXTM3U\n';
|
|
157
|
+
for (let i = 0; i < this._variants.length; i++) {
|
|
158
|
+
const v = this._variants[i];
|
|
159
|
+
const res = v.resolution ? `,RESOLUTION=${v.resolution}` : '';
|
|
160
|
+
m3u8 += `#EXT-X-STREAM-INF:BANDWIDTH=${v.bandwidth}${res}\n`;
|
|
161
|
+
m3u8 += `variant-${i}.m3u8\n`;
|
|
162
|
+
}
|
|
163
|
+
return m3u8;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get CMAF media playlist for a variant.
|
|
168
|
+
* @param {number} variantIndex
|
|
169
|
+
* @returns {string} m3u8 text
|
|
170
|
+
*/
|
|
171
|
+
getMediaPlaylist(variantIndex = 0) {
|
|
172
|
+
const variant = this._variants[variantIndex];
|
|
173
|
+
if (!variant) throw new Error(`Variant ${variantIndex} not found`);
|
|
174
|
+
|
|
175
|
+
const maxDur = Math.max(...variant.segments.map(s => s.duration));
|
|
176
|
+
|
|
177
|
+
let m3u8 = '#EXTM3U\n';
|
|
178
|
+
m3u8 += '#EXT-X-VERSION:7\n';
|
|
179
|
+
m3u8 += `#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}\n`;
|
|
180
|
+
m3u8 += '#EXT-X-PLAYLIST-TYPE:VOD\n';
|
|
181
|
+
m3u8 += '#EXT-X-MEDIA-SEQUENCE:0\n';
|
|
182
|
+
m3u8 += `#EXT-X-MAP:URI="init-${variantIndex}.m4s"\n`;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < variant.segments.length; i++) {
|
|
185
|
+
const seg = variant.segments[i];
|
|
186
|
+
m3u8 += `#EXTINF:${seg.duration.toFixed(6)},\n`;
|
|
187
|
+
m3u8 += `segment-${variantIndex}-${i}.m4s\n`;
|
|
188
|
+
}
|
|
189
|
+
m3u8 += '#EXT-X-ENDLIST\n';
|
|
190
|
+
return m3u8;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get the CMAF init segment for a variant.
|
|
195
|
+
* @param {number} variantIndex
|
|
196
|
+
* @returns {Uint8Array}
|
|
197
|
+
*/
|
|
198
|
+
getInitSegment(variantIndex = 0) {
|
|
199
|
+
return this._variants[variantIndex]?.initSegment ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get a media segment as fMP4 data.
|
|
204
|
+
* Boundary segments are returned from memory (pre-clipped).
|
|
205
|
+
* Middle segments are fetched from CDN and remuxed on-demand.
|
|
206
|
+
*
|
|
207
|
+
* @param {number} variantIndex
|
|
208
|
+
* @param {number} segmentIndex
|
|
209
|
+
* @returns {Promise<Uint8Array>}
|
|
210
|
+
*/
|
|
211
|
+
async getSegment(variantIndex = 0, segmentIndex = 0) {
|
|
212
|
+
const variant = this._variants[variantIndex];
|
|
213
|
+
if (!variant) throw new Error(`Variant ${variantIndex} not found`);
|
|
214
|
+
const seg = variant.segments[segmentIndex];
|
|
215
|
+
if (!seg) throw new Error(`Segment ${segmentIndex} not found`);
|
|
216
|
+
|
|
217
|
+
// Pre-clipped boundary segments are already in memory
|
|
218
|
+
if (seg.data) return seg.data;
|
|
219
|
+
|
|
220
|
+
// Middle segment: fetch from CDN, remux TS → fMP4
|
|
221
|
+
const resp = await fetch(seg.originalUrl);
|
|
222
|
+
if (!resp.ok) throw new Error(`Segment fetch failed: ${resp.status}`);
|
|
223
|
+
const tsData = new Uint8Array(await resp.arrayBuffer());
|
|
224
|
+
|
|
225
|
+
const parser = parseTs(tsData);
|
|
226
|
+
const audioTimescale = parser.audioSampleRate || 48000;
|
|
227
|
+
|
|
228
|
+
// Normalize timestamps: subtract the segment's original start PTS,
|
|
229
|
+
// then add the segment's position in the clip timeline
|
|
230
|
+
const firstVideoPts = parser.videoAccessUnits[0]?.pts ?? 0;
|
|
231
|
+
for (const au of parser.videoAccessUnits) { au.pts -= firstVideoPts; au.dts -= firstVideoPts; }
|
|
232
|
+
for (const au of parser.audioAccessUnits) { au.pts -= firstVideoPts; }
|
|
233
|
+
|
|
234
|
+
const videoBaseTime = Math.round(seg.timelineOffset * PTS_PER_SECOND);
|
|
235
|
+
const audioBaseTime = Math.round(seg.timelineOffset * audioTimescale);
|
|
236
|
+
|
|
237
|
+
const fragment = remuxToFragment(
|
|
238
|
+
parser, segmentIndex + 1,
|
|
239
|
+
videoBaseTime, audioBaseTime, audioTimescale
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return fragment;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get all segment data for a variant (fetches middle segments).
|
|
247
|
+
* Useful for downloading the full clip.
|
|
248
|
+
* @param {number} variantIndex
|
|
249
|
+
* @returns {Promise<Uint8Array[]>}
|
|
250
|
+
*/
|
|
251
|
+
async getAllSegments(variantIndex = 0) {
|
|
252
|
+
const variant = this._variants[variantIndex];
|
|
253
|
+
const results = [];
|
|
254
|
+
for (let i = 0; i < variant.segments.length; i++) {
|
|
255
|
+
results.push(await this.getSegment(variantIndex, i));
|
|
256
|
+
}
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── main function ─────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Clip an HLS stream to a time range, producing a new HLS stream
|
|
265
|
+
* with CMAF (fMP4) segments.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} source - HLS URL (master or media playlist)
|
|
268
|
+
* @param {object} options
|
|
269
|
+
* @param {number} options.startTime - Start time in seconds
|
|
270
|
+
* @param {number} options.endTime - End time in seconds
|
|
271
|
+
* @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth (default: all)
|
|
272
|
+
* @param {function} [options.onProgress] - Progress callback
|
|
273
|
+
* @returns {Promise<HlsClipResult>}
|
|
274
|
+
*/
|
|
275
|
+
export async function clipHls(source, options = {}) {
|
|
276
|
+
const { startTime, endTime, quality, onProgress: log = () => {} } = options;
|
|
277
|
+
if (startTime === undefined || endTime === undefined) {
|
|
278
|
+
throw new Error('clipHls requires both startTime and endTime');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
log('Parsing HLS playlist...');
|
|
282
|
+
const stream = typeof source === 'string' ? await parseHls(source, { onProgress: log }) : source;
|
|
283
|
+
|
|
284
|
+
// Resolve variants to process
|
|
285
|
+
let variantsToProcess = [];
|
|
286
|
+
|
|
287
|
+
if (stream.isMaster) {
|
|
288
|
+
const sorted = stream.qualities; // sorted by bandwidth desc
|
|
289
|
+
if (quality === 'highest') {
|
|
290
|
+
variantsToProcess = [sorted[0]];
|
|
291
|
+
} else if (quality === 'lowest') {
|
|
292
|
+
variantsToProcess = [sorted[sorted.length - 1]];
|
|
293
|
+
} else if (typeof quality === 'number') {
|
|
294
|
+
stream.select(quality);
|
|
295
|
+
variantsToProcess = [stream.selected];
|
|
296
|
+
} else {
|
|
297
|
+
variantsToProcess = sorted; // all variants
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
// Single media playlist — treat as one variant
|
|
301
|
+
variantsToProcess = [{ url: null, bandwidth: 0, resolution: null, _segments: stream.segments, _initSegmentUrl: stream.initSegmentUrl }];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
log(`Processing ${variantsToProcess.length} variant(s)...`);
|
|
305
|
+
|
|
306
|
+
const variants = [];
|
|
307
|
+
for (let vi = 0; vi < variantsToProcess.length; vi++) {
|
|
308
|
+
const variant = variantsToProcess[vi];
|
|
309
|
+
log(`Variant ${vi}: ${variant.resolution || variant.bandwidth || 'default'}`);
|
|
310
|
+
|
|
311
|
+
// Get segment list for this variant
|
|
312
|
+
let segments, initSegmentUrl;
|
|
313
|
+
if (variant._segments) {
|
|
314
|
+
segments = variant._segments;
|
|
315
|
+
initSegmentUrl = variant._initSegmentUrl;
|
|
316
|
+
} else {
|
|
317
|
+
const mediaResp = await fetch(variant.url);
|
|
318
|
+
if (!mediaResp.ok) throw new Error(`Failed to fetch media playlist: ${mediaResp.status}`);
|
|
319
|
+
const mediaText = await mediaResp.text();
|
|
320
|
+
const parsed = parsePlaylistText(mediaText, variant.url);
|
|
321
|
+
segments = parsed.segments;
|
|
322
|
+
initSegmentUrl = parsed.initSegmentUrl;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!segments.length) throw new Error('No segments found');
|
|
326
|
+
|
|
327
|
+
// Find overlapping segments
|
|
328
|
+
const overlapping = segments.filter(seg => seg.endTime > startTime && seg.startTime < endTime);
|
|
329
|
+
if (!overlapping.length) throw new Error('No segments overlap the clip range');
|
|
330
|
+
|
|
331
|
+
const firstSeg = overlapping[0];
|
|
332
|
+
const lastSeg = overlapping[overlapping.length - 1];
|
|
333
|
+
const isSingleSegment = overlapping.length === 1;
|
|
334
|
+
|
|
335
|
+
log(`Segments: ${overlapping.length} (${firstSeg.startTime.toFixed(1)}s – ${lastSeg.endTime.toFixed(1)}s)`);
|
|
336
|
+
|
|
337
|
+
// Download and parse boundary segments to get codec info + pre-clip
|
|
338
|
+
log('Downloading boundary segments...');
|
|
339
|
+
const firstTsData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
|
|
340
|
+
const firstParser = parseTs(firstTsData);
|
|
341
|
+
|
|
342
|
+
let lastParser = null;
|
|
343
|
+
let lastTsData = null;
|
|
344
|
+
if (!isSingleSegment) {
|
|
345
|
+
lastTsData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
|
|
346
|
+
lastParser = parseTs(lastTsData);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Extract codec info from first segment
|
|
350
|
+
const { sps, pps } = extractCodecInfo(firstParser);
|
|
351
|
+
if (!sps || !pps) throw new Error('Could not extract SPS/PPS from video');
|
|
352
|
+
const audioSampleRate = firstParser.audioSampleRate || 48000;
|
|
353
|
+
const audioChannels = firstParser.audioChannels || 2;
|
|
354
|
+
const hasAudio = firstParser.audioAccessUnits.length > 0;
|
|
355
|
+
const audioTimescale = audioSampleRate;
|
|
356
|
+
|
|
357
|
+
// Create CMAF init segment
|
|
358
|
+
const initSegment = createInitSegment({
|
|
359
|
+
sps, pps, audioSampleRate, audioChannels, hasAudio,
|
|
360
|
+
videoTimescale: PTS_PER_SECOND,
|
|
361
|
+
audioTimescale,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Build the clip segment list
|
|
365
|
+
const clipSegments = [];
|
|
366
|
+
let timelineOffset = 0;
|
|
367
|
+
|
|
368
|
+
// ── First segment (clipped at start, possibly also at end) ──
|
|
369
|
+
// Convert absolute times to segment-relative times (TS PTS starts at ~0 per segment)
|
|
370
|
+
const firstRelStart = startTime - firstSeg.startTime;
|
|
371
|
+
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
372
|
+
const firstClipped = clipSegment(firstParser, firstRelStart, firstRelEnd);
|
|
373
|
+
if (!firstClipped) throw new Error('First segment clip produced no samples');
|
|
374
|
+
|
|
375
|
+
const firstFragment = createFragment({
|
|
376
|
+
videoSamples: firstClipped.videoSamples,
|
|
377
|
+
audioSamples: firstClipped.audioSamples,
|
|
378
|
+
sequenceNumber: 1,
|
|
379
|
+
videoTimescale: PTS_PER_SECOND,
|
|
380
|
+
audioTimescale,
|
|
381
|
+
videoBaseTime: 0,
|
|
382
|
+
audioBaseTime: 0,
|
|
383
|
+
audioSampleDuration: 1024,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
clipSegments.push({
|
|
387
|
+
duration: firstClipped.duration,
|
|
388
|
+
data: firstFragment, // pre-clipped, in memory
|
|
389
|
+
originalUrl: null,
|
|
390
|
+
timelineOffset: 0,
|
|
391
|
+
isBoundary: true,
|
|
392
|
+
});
|
|
393
|
+
timelineOffset += firstClipped.duration;
|
|
394
|
+
|
|
395
|
+
// ── Middle segments (pass-through, remuxed on demand) ──
|
|
396
|
+
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
397
|
+
const seg = overlapping[i];
|
|
398
|
+
const segDuration = seg.duration;
|
|
399
|
+
clipSegments.push({
|
|
400
|
+
duration: segDuration,
|
|
401
|
+
data: null, // fetched on demand
|
|
402
|
+
originalUrl: seg.url,
|
|
403
|
+
timelineOffset,
|
|
404
|
+
isBoundary: false,
|
|
405
|
+
});
|
|
406
|
+
timelineOffset += segDuration;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Last segment (clipped at end, if different from first) ──
|
|
410
|
+
if (!isSingleSegment && lastParser) {
|
|
411
|
+
const lastRelEnd = endTime - lastSeg.startTime;
|
|
412
|
+
const lastClipped = clipSegment(lastParser, undefined, lastRelEnd);
|
|
413
|
+
if (lastClipped && lastClipped.videoSamples.length > 0) {
|
|
414
|
+
const lastDuration = lastClipped.duration;
|
|
415
|
+
const lastSeqNum = overlapping.length;
|
|
416
|
+
const lastVideoBaseTime = Math.round(timelineOffset * PTS_PER_SECOND);
|
|
417
|
+
const lastAudioBaseTime = Math.round(timelineOffset * audioTimescale);
|
|
418
|
+
|
|
419
|
+
const lastFragment = createFragment({
|
|
420
|
+
videoSamples: lastClipped.videoSamples,
|
|
421
|
+
audioSamples: lastClipped.audioSamples,
|
|
422
|
+
sequenceNumber: lastSeqNum,
|
|
423
|
+
videoTimescale: PTS_PER_SECOND,
|
|
424
|
+
audioTimescale,
|
|
425
|
+
videoBaseTime: lastVideoBaseTime,
|
|
426
|
+
audioBaseTime: lastAudioBaseTime,
|
|
427
|
+
audioSampleDuration: 1024,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
clipSegments.push({
|
|
431
|
+
duration: lastClipped.duration,
|
|
432
|
+
data: lastFragment,
|
|
433
|
+
originalUrl: null,
|
|
434
|
+
timelineOffset,
|
|
435
|
+
isBoundary: true,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const totalDuration = clipSegments.reduce((sum, s) => sum + s.duration, 0);
|
|
441
|
+
log(`Clip ready: ${totalDuration.toFixed(2)}s (${clipSegments.length} segments)`);
|
|
442
|
+
|
|
443
|
+
variants.push({
|
|
444
|
+
bandwidth: variant.bandwidth || 0,
|
|
445
|
+
resolution: variant.resolution || null,
|
|
446
|
+
initSegment,
|
|
447
|
+
segments: clipSegments,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const clipDuration = endTime - startTime;
|
|
452
|
+
return new HlsClipResult({
|
|
453
|
+
variants,
|
|
454
|
+
duration: clipDuration,
|
|
455
|
+
startTime,
|
|
456
|
+
endTime,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export { HlsClipResult };
|
|
461
|
+
export default clipHls;
|