@invintusmedia/tomp4 1.4.3 → 1.5.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 +2 -2
- package/package.json +1 -1
- package/src/hls-clip.js +163 -454
- package/src/index.js +1 -6
- package/src/codecs/REFERENCE.md +0 -885
- package/src/codecs/h264-cabac-init.js +0 -546
- package/src/codecs/h264-cabac.js +0 -322
- package/src/codecs/h264-cavlc-tables.js +0 -628
- package/src/codecs/h264-decoder.js +0 -940
- package/src/codecs/h264-encoder.js +0 -502
- package/src/codecs/h264-intra.js +0 -292
- package/src/codecs/h264-sps-pps.js +0 -483
- package/src/codecs/h264-tables.js +0 -217
- package/src/codecs/h264-transform.js +0 -268
- package/src/codecs/smart-render.js +0 -169
package/src/hls-clip.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HLS-to-HLS Clipper
|
|
3
3
|
*
|
|
4
|
-
* Clips an HLS stream to a time range, producing a new HLS
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Clips an HLS stream to a time range, producing a new HLS playlist.
|
|
5
|
+
* Middle segments use original CDN URLs (completely untouched).
|
|
6
|
+
* Boundary segments are re-muxed from the keyframe nearest to the
|
|
7
|
+
* requested start/end times.
|
|
8
|
+
*
|
|
9
|
+
* The result includes `prerollDuration` — the time between the actual
|
|
10
|
+
* start (keyframe) and the requested start. The player should seek
|
|
11
|
+
* past this on load for frame-accurate playback:
|
|
12
|
+
*
|
|
13
|
+
* video.currentTime = clip.prerollDuration;
|
|
8
14
|
*
|
|
9
15
|
* @module hls-clip
|
|
10
16
|
*
|
|
@@ -14,87 +20,83 @@
|
|
|
14
20
|
* endTime: 90,
|
|
15
21
|
* });
|
|
16
22
|
*
|
|
17
|
-
* clip.
|
|
18
|
-
* clip.getMediaPlaylist(0)
|
|
19
|
-
* clip.
|
|
20
|
-
* await clip.getSegment(0, 0) // fMP4 media segment (Uint8Array)
|
|
23
|
+
* clip.prerollDuration // seconds to seek past for frame accuracy
|
|
24
|
+
* clip.getMediaPlaylist(0) // HLS playlist
|
|
25
|
+
* await clip.getSegment(0, 0) // boundary TS data
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
|
-
import { parseHls,
|
|
24
|
-
import { TSParser
|
|
25
|
-
import {
|
|
26
|
-
import { convertFmp4ToMp4 } from './fmp4/converter.js';
|
|
27
|
-
import { parseBoxes, findBox, parseChildBoxes, createBox } from './fmp4/utils.js';
|
|
28
|
-
import { smartRender } from './codecs/smart-render.js';
|
|
29
|
-
|
|
30
|
-
// ── constants ─────────────────────────────────────────────
|
|
28
|
+
import { parseHls, parsePlaylistText } from './hls.js';
|
|
29
|
+
import { TSParser } from './parsers/mpegts.js';
|
|
30
|
+
import { TSMuxer } from './muxers/mpegts.js';
|
|
31
31
|
|
|
32
32
|
const PTS_PER_SECOND = 90000;
|
|
33
33
|
|
|
34
34
|
// ── helpers ───────────────────────────────────────────────
|
|
35
35
|
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
function parseTs(tsData) {
|
|
37
|
+
const parser = new TSParser();
|
|
38
|
+
parser.parse(tsData);
|
|
39
|
+
parser.finalize();
|
|
40
|
+
return parser;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isKeyframe(au) {
|
|
44
|
+
for (const nal of au.nalUnits) {
|
|
45
|
+
if ((nal[0] & 0x1F) === 5) return true;
|
|
39
46
|
}
|
|
40
47
|
return false;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
function
|
|
50
|
+
function wrapADTS(aacData, sampleRate, channels) {
|
|
51
|
+
const RATES = [96000,88200,64000,48000,44100,32000,24000,22050,16000,12000,11025,8000,7350];
|
|
52
|
+
const sri = RATES.indexOf(sampleRate);
|
|
53
|
+
const len = aacData.length + 7;
|
|
54
|
+
const adts = new Uint8Array(len);
|
|
55
|
+
adts[0] = 0xFF;
|
|
56
|
+
adts[1] = 0xF1;
|
|
57
|
+
adts[2] = (1 << 6) | ((sri < 0 ? 4 : sri) << 2) | ((channels >> 2) & 1);
|
|
58
|
+
adts[3] = ((channels & 3) << 6) | ((len >> 11) & 3);
|
|
59
|
+
adts[4] = (len >> 3) & 0xFF;
|
|
60
|
+
adts[5] = ((len & 7) << 5) | 0x1F;
|
|
61
|
+
adts[6] = 0xFC;
|
|
62
|
+
adts.set(aacData, 7);
|
|
63
|
+
return adts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function muxToTs(videoAUs, audioAUs, audioSampleRate, audioChannels) {
|
|
67
|
+
const muxer = new TSMuxer();
|
|
68
|
+
|
|
44
69
|
let sps = null, pps = null;
|
|
45
|
-
for (const au of
|
|
46
|
-
for (const
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
if (
|
|
50
|
-
if (sps && pps) return { sps, pps };
|
|
70
|
+
for (const au of videoAUs) {
|
|
71
|
+
for (const nal of au.nalUnits) {
|
|
72
|
+
const t = nal[0] & 0x1F;
|
|
73
|
+
if (t === 7 && !sps) sps = nal;
|
|
74
|
+
if (t === 8 && !pps) pps = nal;
|
|
51
75
|
}
|
|
76
|
+
if (sps && pps) break;
|
|
77
|
+
}
|
|
78
|
+
if (sps && pps) muxer.setSpsPps(sps, pps);
|
|
79
|
+
muxer.setHasAudio(audioAUs.length > 0);
|
|
80
|
+
|
|
81
|
+
const sr = audioSampleRate || 48000;
|
|
82
|
+
const ch = audioChannels || 2;
|
|
83
|
+
for (const au of audioAUs) {
|
|
84
|
+
const hasADTS = au.data.length > 1 && au.data[0] === 0xFF && (au.data[1] & 0xF0) === 0xF0;
|
|
85
|
+
muxer.addAudioSample(hasADTS ? au.data : wrapADTS(au.data, sr, ch), au.pts);
|
|
52
86
|
}
|
|
53
|
-
return { sps, pps };
|
|
54
|
-
}
|
|
55
87
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
function parseTs(tsData) {
|
|
60
|
-
const parser = new TSParser();
|
|
61
|
-
parser.parse(tsData);
|
|
62
|
-
parser.finalize();
|
|
63
|
-
return parser;
|
|
64
|
-
}
|
|
88
|
+
for (const au of videoAUs) {
|
|
89
|
+
muxer.addVideoNalUnits(au.nalUnits, isKeyframe(au), au.pts, au.dts);
|
|
90
|
+
}
|
|
65
91
|
|
|
66
|
-
|
|
67
|
-
* Remux parsed TS data into an fMP4 fragment.
|
|
68
|
-
* Normalizes timestamps to start at the given base times.
|
|
69
|
-
*/
|
|
70
|
-
function remuxToFragment(parser, sequenceNumber, videoBaseTime, audioBaseTime, audioTimescale) {
|
|
71
|
-
return createFragment({
|
|
72
|
-
videoSamples: parser.videoAccessUnits,
|
|
73
|
-
audioSamples: parser.audioAccessUnits,
|
|
74
|
-
sequenceNumber,
|
|
75
|
-
videoTimescale: PTS_PER_SECOND,
|
|
76
|
-
audioTimescale,
|
|
77
|
-
videoBaseTime,
|
|
78
|
-
audioBaseTime,
|
|
79
|
-
audioSampleDuration: 1024,
|
|
80
|
-
});
|
|
92
|
+
return muxer.build();
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/**
|
|
84
|
-
* Clip a parsed TS segment at
|
|
85
|
-
*
|
|
86
|
-
* Uses smart rendering when clipping at the start: re-encodes the
|
|
87
|
-
* boundary GOP so the segment starts with a new keyframe at the
|
|
88
|
-
* exact requested time. No preroll, no edit list, frame-accurate.
|
|
89
|
-
*
|
|
90
|
-
* @param {TSParser} parser - Parsed TS segment
|
|
91
|
-
* @param {number} [startTime] - Start time in seconds (relative to segment)
|
|
92
|
-
* @param {number} [endTime] - End time in seconds (relative to segment)
|
|
93
|
-
* @param {object} [options]
|
|
94
|
-
* @param {number} [options.qp=20] - Encoding quality for smart-rendered frames
|
|
96
|
+
* Clip a parsed TS segment. Starts at nearest keyframe, ends at endTime.
|
|
97
|
+
* Returns the preroll (time from keyframe to requested start).
|
|
95
98
|
*/
|
|
96
|
-
function clipSegment(parser, startTime, endTime
|
|
97
|
-
const { qp = 20 } = options;
|
|
99
|
+
function clipSegment(parser, startTime, endTime) {
|
|
98
100
|
const startPts = (startTime !== undefined ? startTime : 0) * PTS_PER_SECOND;
|
|
99
101
|
const endPts = (endTime !== undefined ? endTime : Infinity) * PTS_PER_SECOND;
|
|
100
102
|
const videoAUs = parser.videoAccessUnits;
|
|
@@ -102,91 +104,70 @@ function clipSegment(parser, startTime, endTime, options = {}) {
|
|
|
102
104
|
|
|
103
105
|
if (videoAUs.length === 0) return null;
|
|
104
106
|
|
|
105
|
-
//
|
|
107
|
+
// Find keyframe at or before startTime
|
|
106
108
|
let keyframeIdx = 0;
|
|
107
109
|
for (let i = 0; i < videoAUs.length; i++) {
|
|
108
110
|
if (videoAUs[i].pts > startPts) break;
|
|
109
111
|
if (isKeyframe(videoAUs[i])) keyframeIdx = i;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
// Find end
|
|
115
|
+
let endIdx = videoAUs.length;
|
|
113
116
|
for (let i = keyframeIdx; i < videoAUs.length; i++) {
|
|
114
|
-
if (videoAUs[i].pts >=
|
|
117
|
+
if (videoAUs[i].pts >= endPts) { endIdx = i; break; }
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// (works at 288x160 but fails at 1080p). Fall back to keyframe-accurate.
|
|
120
|
-
// TODO: Fix CAVLC encoding for high-resolution frames to re-enable.
|
|
121
|
-
const needsSmartRender = false;
|
|
120
|
+
const clipped = videoAUs.slice(keyframeIdx, endIdx);
|
|
121
|
+
if (clipped.length === 0) return null;
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
const keyframePts = clipped[0].pts;
|
|
124
|
+
const prerollPts = Math.max(0, startPts - keyframePts);
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
startOffset = result.videoAUs.length > 0 ? result.videoAUs[0].pts : 0;
|
|
126
|
+
// Audio from keyframe (same timeline as video for A/V sync)
|
|
127
|
+
const lastVideoPts = clipped[clipped.length - 1].pts;
|
|
128
|
+
const audioEndPts = Math.min(endPts, lastVideoPts + PTS_PER_SECOND);
|
|
129
|
+
const clippedAudio = audioAUs.filter(au => au.pts >= keyframePts && au.pts < audioEndPts);
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// Start is at a keyframe — no smart rendering needed
|
|
136
|
-
let endIdx = videoAUs.length;
|
|
137
|
-
for (let i = keyframeIdx; i < videoAUs.length; i++) {
|
|
138
|
-
if (videoAUs[i].pts >= endPts) { endIdx = i; break; }
|
|
139
|
-
}
|
|
131
|
+
// Normalize to PTS 0
|
|
132
|
+
const offset = keyframePts;
|
|
133
|
+
for (const au of clipped) { au.pts -= offset; au.dts -= offset; }
|
|
134
|
+
for (const au of clippedAudio) { au.pts -= offset; }
|
|
140
135
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
startOffset = clippedVideo[0].pts;
|
|
136
|
+
const frameDur = clipped.length > 1 ? clipped[1].dts - clipped[0].dts : 3003;
|
|
137
|
+
const duration = (clipped[clipped.length - 1].dts - clipped[0].dts + frameDur) / PTS_PER_SECOND;
|
|
144
138
|
|
|
145
|
-
|
|
146
|
-
const audioEndPts = Math.min(endPts, lastVideoPts + PTS_PER_SECOND);
|
|
147
|
-
clippedAudio = audioAUs.filter(au => au.pts >= startOffset && au.pts < audioEndPts);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (clippedVideo.length === 0) return null;
|
|
151
|
-
|
|
152
|
-
// Normalize timestamps to start at 0
|
|
153
|
-
for (const au of clippedVideo) { au.pts -= startOffset; au.dts -= startOffset; }
|
|
154
|
-
for (const au of clippedAudio) { au.pts -= startOffset; }
|
|
155
|
-
|
|
156
|
-
// Duration from actual content
|
|
157
|
-
const duration = clippedVideo.length > 1
|
|
158
|
-
? clippedVideo[clippedVideo.length - 1].dts - clippedVideo[0].dts +
|
|
159
|
-
(clippedVideo.length > 1 ? clippedVideo[1].dts - clippedVideo[0].dts : 3003)
|
|
160
|
-
: 3003;
|
|
139
|
+
const tsData = muxToTs(clipped, clippedAudio, parser.audioSampleRate, parser.audioChannels);
|
|
161
140
|
|
|
162
141
|
return {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
smartRendered: needsSmartRender,
|
|
142
|
+
data: tsData,
|
|
143
|
+
duration,
|
|
144
|
+
preroll: prerollPts / PTS_PER_SECOND,
|
|
167
145
|
};
|
|
168
146
|
}
|
|
169
147
|
|
|
170
148
|
// ── HlsClipResult ─────────────────────────────────────────
|
|
171
149
|
|
|
172
150
|
class HlsClipResult {
|
|
173
|
-
|
|
174
|
-
|
|
151
|
+
/**
|
|
152
|
+
* @param {object} opts
|
|
153
|
+
* @param {number} opts.prerollDuration - Seconds to seek past for frame accuracy
|
|
154
|
+
*/
|
|
155
|
+
constructor({ variants, duration, startTime, endTime, prerollDuration }) {
|
|
156
|
+
this._variants = variants;
|
|
175
157
|
this.duration = duration;
|
|
176
158
|
this.startTime = startTime;
|
|
177
159
|
this.endTime = endTime;
|
|
160
|
+
/** Seconds between the keyframe start and the requested startTime.
|
|
161
|
+
* The player should set video.currentTime = prerollDuration on load. */
|
|
162
|
+
this.prerollDuration = prerollDuration;
|
|
178
163
|
}
|
|
179
164
|
|
|
180
|
-
/** Number of quality variants */
|
|
181
165
|
get variantCount() {
|
|
182
166
|
return this._variants.length;
|
|
183
167
|
}
|
|
184
168
|
|
|
185
|
-
/** Master playlist m3u8 text */
|
|
186
169
|
get masterPlaylist() {
|
|
187
|
-
if (this._variants.length === 1)
|
|
188
|
-
return this.getMediaPlaylist(0);
|
|
189
|
-
}
|
|
170
|
+
if (this._variants.length === 1) return this.getMediaPlaylist(0);
|
|
190
171
|
let m3u8 = '#EXTM3U\n';
|
|
191
172
|
for (let i = 0; i < this._variants.length; i++) {
|
|
192
173
|
const v = this._variants[i];
|
|
@@ -197,11 +178,6 @@ class HlsClipResult {
|
|
|
197
178
|
return m3u8;
|
|
198
179
|
}
|
|
199
180
|
|
|
200
|
-
/**
|
|
201
|
-
* Get CMAF media playlist for a variant.
|
|
202
|
-
* @param {number} variantIndex
|
|
203
|
-
* @returns {string} m3u8 text
|
|
204
|
-
*/
|
|
205
181
|
getMediaPlaylist(variantIndex = 0) {
|
|
206
182
|
const variant = this._variants[variantIndex];
|
|
207
183
|
if (!variant) throw new Error(`Variant ${variantIndex} not found`);
|
|
@@ -209,295 +185,45 @@ class HlsClipResult {
|
|
|
209
185
|
const maxDur = Math.max(...variant.segments.map(s => s.duration));
|
|
210
186
|
|
|
211
187
|
let m3u8 = '#EXTM3U\n';
|
|
212
|
-
m3u8 += '#EXT-X-VERSION:
|
|
188
|
+
m3u8 += '#EXT-X-VERSION:3\n';
|
|
213
189
|
m3u8 += `#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}\n`;
|
|
214
190
|
m3u8 += '#EXT-X-PLAYLIST-TYPE:VOD\n';
|
|
215
191
|
m3u8 += '#EXT-X-MEDIA-SEQUENCE:0\n';
|
|
216
|
-
m3u8 += `#EXT-X-MAP:URI="init-${variantIndex}.m4s"\n`;
|
|
217
192
|
|
|
218
193
|
for (let i = 0; i < variant.segments.length; i++) {
|
|
219
194
|
const seg = variant.segments[i];
|
|
220
195
|
m3u8 += `#EXTINF:${seg.duration.toFixed(6)},\n`;
|
|
221
|
-
m3u8 += `segment-${variantIndex}-${i}.
|
|
196
|
+
m3u8 += seg.originalUrl || `segment-${variantIndex}-${i}.ts\n`;
|
|
222
197
|
}
|
|
223
198
|
m3u8 += '#EXT-X-ENDLIST\n';
|
|
224
199
|
return m3u8;
|
|
225
200
|
}
|
|
226
201
|
|
|
227
|
-
/**
|
|
228
|
-
* Get the CMAF init segment for a variant.
|
|
229
|
-
* @param {number} variantIndex
|
|
230
|
-
* @returns {Uint8Array}
|
|
231
|
-
*/
|
|
232
|
-
getInitSegment(variantIndex = 0) {
|
|
233
|
-
return this._variants[variantIndex]?.initSegment ?? null;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Get a media segment as fMP4 data.
|
|
238
|
-
* Boundary segments are returned from memory (pre-clipped).
|
|
239
|
-
* Middle segments are fetched from CDN and remuxed on-demand.
|
|
240
|
-
*
|
|
241
|
-
* @param {number} variantIndex
|
|
242
|
-
* @param {number} segmentIndex
|
|
243
|
-
* @returns {Promise<Uint8Array>}
|
|
244
|
-
*/
|
|
245
202
|
async getSegment(variantIndex = 0, segmentIndex = 0) {
|
|
246
203
|
const variant = this._variants[variantIndex];
|
|
247
204
|
if (!variant) throw new Error(`Variant ${variantIndex} not found`);
|
|
248
205
|
const seg = variant.segments[segmentIndex];
|
|
249
206
|
if (!seg) throw new Error(`Segment ${segmentIndex} not found`);
|
|
250
|
-
|
|
251
|
-
// Pre-clipped boundary segments are already in memory
|
|
252
207
|
if (seg.data) return seg.data;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const rawData = new Uint8Array(await resp.arrayBuffer());
|
|
258
|
-
|
|
259
|
-
// fMP4 segments pass through unchanged (already correct format)
|
|
260
|
-
if (seg._sourceFormat === 'fmp4') return rawData;
|
|
261
|
-
|
|
262
|
-
// TS segments: remux to fMP4
|
|
263
|
-
const parser = parseTs(rawData);
|
|
264
|
-
const audioTimescale = seg._audioTimescale || parser.audioSampleRate || 48000;
|
|
265
|
-
|
|
266
|
-
const firstVideoPts = parser.videoAccessUnits[0]?.pts ?? 0;
|
|
267
|
-
for (const au of parser.videoAccessUnits) { au.pts -= firstVideoPts; au.dts -= firstVideoPts; }
|
|
268
|
-
for (const au of parser.audioAccessUnits) { au.pts -= firstVideoPts; }
|
|
269
|
-
|
|
270
|
-
const videoBaseTime = Math.round(seg.timelineOffset * PTS_PER_SECOND);
|
|
271
|
-
const audioBaseTime = Math.round(seg.timelineOffset * audioTimescale);
|
|
272
|
-
|
|
273
|
-
return remuxToFragment(parser, segmentIndex + 1, videoBaseTime, audioBaseTime, audioTimescale);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Get all segment data for a variant (fetches middle segments).
|
|
278
|
-
* Useful for downloading the full clip.
|
|
279
|
-
* @param {number} variantIndex
|
|
280
|
-
* @returns {Promise<Uint8Array[]>}
|
|
281
|
-
*/
|
|
282
|
-
async getAllSegments(variantIndex = 0) {
|
|
283
|
-
const variant = this._variants[variantIndex];
|
|
284
|
-
const results = [];
|
|
285
|
-
for (let i = 0; i < variant.segments.length; i++) {
|
|
286
|
-
results.push(await this.getSegment(variantIndex, i));
|
|
208
|
+
if (seg.originalUrl) {
|
|
209
|
+
const resp = await fetch(seg.originalUrl);
|
|
210
|
+
if (!resp.ok) throw new Error(`Segment fetch failed: ${resp.status}`);
|
|
211
|
+
return new Uint8Array(await resp.arrayBuffer());
|
|
287
212
|
}
|
|
288
|
-
return
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ── format detection ──────────────────────────────────────
|
|
293
|
-
|
|
294
|
-
function _detectSegmentFormat(data) {
|
|
295
|
-
if (data.length < 8) return 'unknown';
|
|
296
|
-
// Check for TS sync byte
|
|
297
|
-
if (data[0] === 0x47) return 'ts';
|
|
298
|
-
for (let i = 0; i < Math.min(188, data.length); i++) {
|
|
299
|
-
if (data[i] === 0x47 && i + 188 < data.length && data[i + 188] === 0x47) return 'ts';
|
|
213
|
+
return null;
|
|
300
214
|
}
|
|
301
|
-
// Check for fMP4 (moof, styp, or ftyp box)
|
|
302
|
-
const type = String.fromCharCode(data[4], data[5], data[6], data[7]);
|
|
303
|
-
if (['moof', 'styp', 'ftyp', 'mdat'].includes(type)) return 'fmp4';
|
|
304
|
-
return 'unknown';
|
|
305
215
|
}
|
|
306
216
|
|
|
307
|
-
// ──
|
|
308
|
-
|
|
309
|
-
function _processTsVariant({ firstSegData, lastSegData, overlapping, isSingleSegment, startTime, endTime, firstSeg, lastSeg, log }) {
|
|
310
|
-
const firstParser = parseTs(firstSegData);
|
|
311
|
-
const lastParser = !isSingleSegment && lastSegData ? parseTs(lastSegData) : null;
|
|
312
|
-
|
|
313
|
-
const { sps, pps } = extractCodecInfo(firstParser);
|
|
314
|
-
if (!sps || !pps) throw new Error('Could not extract SPS/PPS from video');
|
|
315
|
-
const audioSampleRate = firstParser.audioSampleRate || 48000;
|
|
316
|
-
const audioChannels = firstParser.audioChannels || 2;
|
|
317
|
-
const hasAudio = firstParser.audioAccessUnits.length > 0;
|
|
318
|
-
const audioTimescale = audioSampleRate;
|
|
319
|
-
|
|
320
|
-
const initSegment = createInitSegment({
|
|
321
|
-
sps, pps, audioSampleRate, audioChannels, hasAudio,
|
|
322
|
-
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
const clipSegments = [];
|
|
326
|
-
let timelineOffset = 0;
|
|
327
|
-
|
|
328
|
-
// First segment (smart-rendered)
|
|
329
|
-
const firstRelStart = startTime - firstSeg.startTime;
|
|
330
|
-
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
331
|
-
const firstClipped = clipSegment(firstParser, firstRelStart, firstRelEnd);
|
|
332
|
-
if (!firstClipped) throw new Error('First segment clip produced no samples');
|
|
333
|
-
|
|
334
|
-
const firstFragment = createFragment({
|
|
335
|
-
videoSamples: firstClipped.videoSamples,
|
|
336
|
-
audioSamples: firstClipped.audioSamples,
|
|
337
|
-
sequenceNumber: 1,
|
|
338
|
-
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
339
|
-
videoBaseTime: 0, audioBaseTime: 0, audioSampleDuration: 1024,
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
clipSegments.push({
|
|
343
|
-
duration: firstClipped.duration, data: firstFragment,
|
|
344
|
-
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
345
|
-
});
|
|
346
|
-
timelineOffset += firstClipped.duration;
|
|
347
|
-
|
|
348
|
-
// Middle segments
|
|
349
|
-
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
350
|
-
clipSegments.push({
|
|
351
|
-
duration: overlapping[i].duration, data: null,
|
|
352
|
-
originalUrl: overlapping[i].url, timelineOffset, isBoundary: false,
|
|
353
|
-
_sourceFormat: 'ts', _audioTimescale: audioTimescale,
|
|
354
|
-
});
|
|
355
|
-
timelineOffset += overlapping[i].duration;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Last segment
|
|
359
|
-
if (!isSingleSegment && lastParser) {
|
|
360
|
-
const lastRelEnd = endTime - lastSeg.startTime;
|
|
361
|
-
const lastClipped = clipSegment(lastParser, undefined, lastRelEnd);
|
|
362
|
-
if (lastClipped && lastClipped.videoSamples.length > 0) {
|
|
363
|
-
const lastFragment = createFragment({
|
|
364
|
-
videoSamples: lastClipped.videoSamples,
|
|
365
|
-
audioSamples: lastClipped.audioSamples,
|
|
366
|
-
sequenceNumber: overlapping.length,
|
|
367
|
-
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
368
|
-
videoBaseTime: Math.round(timelineOffset * PTS_PER_SECOND),
|
|
369
|
-
audioBaseTime: Math.round(timelineOffset * audioTimescale),
|
|
370
|
-
audioSampleDuration: 1024,
|
|
371
|
-
});
|
|
372
|
-
clipSegments.push({
|
|
373
|
-
duration: lastClipped.duration, data: lastFragment,
|
|
374
|
-
originalUrl: null, timelineOffset, isBoundary: true,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return { initSegment, clipSegments, audioTimescale };
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// ── fMP4 variant processing ───────────────────────────────
|
|
383
|
-
|
|
384
|
-
function _processFmp4Variant({ firstSegData, lastSegData, fmp4Init, overlapping, isSingleSegment, startTime, endTime, firstSeg, lastSeg, log }) {
|
|
385
|
-
// For fMP4 sources: the init segment already has the moov with codec info.
|
|
386
|
-
// We pass it through as-is. Boundary segments are clipped using the fMP4
|
|
387
|
-
// converter. Middle segments pass through unchanged.
|
|
388
|
-
|
|
389
|
-
if (!fmp4Init) throw new Error('fMP4 source requires an init segment (#EXT-X-MAP)');
|
|
390
|
-
|
|
391
|
-
// Use the source init segment directly (it has the correct moov)
|
|
392
|
-
const initSegment = fmp4Init;
|
|
393
|
-
|
|
394
|
-
// Detect audio timescale from the init segment's moov
|
|
395
|
-
let audioTimescale = 48000;
|
|
396
|
-
try {
|
|
397
|
-
const boxes = parseBoxes(fmp4Init);
|
|
398
|
-
const moov = findBox(boxes, 'moov');
|
|
399
|
-
if (moov) {
|
|
400
|
-
const moovChildren = parseChildBoxes(moov);
|
|
401
|
-
for (const child of moovChildren) {
|
|
402
|
-
if (child.type === 'trak') {
|
|
403
|
-
const trakChildren = parseChildBoxes(child);
|
|
404
|
-
for (const tc of trakChildren) {
|
|
405
|
-
if (tc.type === 'mdia') {
|
|
406
|
-
const mdiaChildren = parseChildBoxes(tc);
|
|
407
|
-
let isSoun = false;
|
|
408
|
-
for (const mc of mdiaChildren) {
|
|
409
|
-
if (mc.type === 'hdlr' && mc.data.byteLength >= 20) {
|
|
410
|
-
const handler = String.fromCharCode(mc.data[16], mc.data[17], mc.data[18], mc.data[19]);
|
|
411
|
-
if (handler === 'soun') isSoun = true;
|
|
412
|
-
}
|
|
413
|
-
if (mc.type === 'mdhd' && isSoun) {
|
|
414
|
-
const v = new DataView(mc.data.buffer, mc.data.byteOffset, mc.data.byteLength);
|
|
415
|
-
audioTimescale = mc.data[8] === 0 ? v.getUint32(20) : v.getUint32(28);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
} catch (e) { /* use default */ }
|
|
424
|
-
|
|
425
|
-
const clipSegments = [];
|
|
426
|
-
let timelineOffset = 0;
|
|
427
|
-
|
|
428
|
-
// First segment: clip using fMP4 converter
|
|
429
|
-
const firstRelStart = startTime - firstSeg.startTime;
|
|
430
|
-
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
431
|
-
|
|
432
|
-
// Combine init + first segment for the converter
|
|
433
|
-
const firstCombined = new Uint8Array(fmp4Init.byteLength + firstSegData.byteLength);
|
|
434
|
-
firstCombined.set(fmp4Init, 0);
|
|
435
|
-
firstCombined.set(firstSegData, fmp4Init.byteLength);
|
|
436
|
-
|
|
437
|
-
try {
|
|
438
|
-
// Use convertFmp4ToMp4 with clipping, then re-fragment
|
|
439
|
-
// Actually, we can just pass the raw fMP4 segment through — for boundary
|
|
440
|
-
// segments, we trim at the keyframe level (no smart rendering for fMP4 yet).
|
|
441
|
-
// The segment already starts at a keyframe (HLS requirement).
|
|
442
|
-
|
|
443
|
-
// For the first segment, just pass through — the startTime cut is at keyframe
|
|
444
|
-
// For frame accuracy with fMP4, we'd need to add edit lists to the init segment
|
|
445
|
-
// or do smart rendering. For now, keyframe-accurate is the fMP4 path.
|
|
446
|
-
clipSegments.push({
|
|
447
|
-
duration: (firstRelEnd || firstSeg.duration) - firstRelStart,
|
|
448
|
-
data: firstSegData, // pass through the fMP4 segment
|
|
449
|
-
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
450
|
-
_sourceFormat: 'fmp4',
|
|
451
|
-
});
|
|
452
|
-
timelineOffset += clipSegments[0].duration;
|
|
453
|
-
} catch (e) {
|
|
454
|
-
log('fMP4 first segment processing error: ' + e.message);
|
|
455
|
-
// Fallback: pass through as-is
|
|
456
|
-
clipSegments.push({
|
|
457
|
-
duration: firstSeg.duration, data: firstSegData,
|
|
458
|
-
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
459
|
-
_sourceFormat: 'fmp4',
|
|
460
|
-
});
|
|
461
|
-
timelineOffset += firstSeg.duration;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Middle segments: pass through unchanged (already fMP4!)
|
|
465
|
-
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
466
|
-
clipSegments.push({
|
|
467
|
-
duration: overlapping[i].duration, data: null,
|
|
468
|
-
originalUrl: overlapping[i].url, timelineOffset, isBoundary: false,
|
|
469
|
-
_sourceFormat: 'fmp4',
|
|
470
|
-
});
|
|
471
|
-
timelineOffset += overlapping[i].duration;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Last segment: pass through (truncation at end is handled by player)
|
|
475
|
-
if (!isSingleSegment && lastSegData) {
|
|
476
|
-
const lastRelEnd = endTime - lastSeg.startTime;
|
|
477
|
-
clipSegments.push({
|
|
478
|
-
duration: Math.min(lastRelEnd, lastSeg.duration),
|
|
479
|
-
data: lastSegData,
|
|
480
|
-
originalUrl: null, timelineOffset, isBoundary: true,
|
|
481
|
-
_sourceFormat: 'fmp4',
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
return { initSegment, clipSegments, audioTimescale };
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// ── main function ─────────────────────────────────────────
|
|
217
|
+
// ── main ──────────────────────────────────────────────────
|
|
489
218
|
|
|
490
219
|
/**
|
|
491
|
-
* Clip an HLS stream to a time range
|
|
492
|
-
*
|
|
220
|
+
* Clip an HLS stream to a time range.
|
|
221
|
+
*
|
|
222
|
+
* Boundary segments start at the nearest keyframe. The result includes
|
|
223
|
+
* `prerollDuration` — the player should seek to this time on load for
|
|
224
|
+
* frame-accurate start.
|
|
493
225
|
*
|
|
494
|
-
*
|
|
495
|
-
* @param {object} options
|
|
496
|
-
* @param {number} options.startTime - Start time in seconds
|
|
497
|
-
* @param {number} options.endTime - End time in seconds
|
|
498
|
-
* @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth (default: all)
|
|
499
|
-
* @param {function} [options.onProgress] - Progress callback
|
|
500
|
-
* @returns {Promise<HlsClipResult>}
|
|
226
|
+
* Middle segments use original CDN URLs (completely untouched).
|
|
501
227
|
*/
|
|
502
228
|
export async function clipHls(source, options = {}) {
|
|
503
229
|
const { startTime, endTime, quality, onProgress: log = () => {} } = options;
|
|
@@ -508,50 +234,38 @@ export async function clipHls(source, options = {}) {
|
|
|
508
234
|
log('Parsing HLS playlist...');
|
|
509
235
|
const stream = typeof source === 'string' ? await parseHls(source, { onProgress: log }) : source;
|
|
510
236
|
|
|
511
|
-
// Resolve variants to process
|
|
512
237
|
let variantsToProcess = [];
|
|
513
|
-
|
|
514
238
|
if (stream.isMaster) {
|
|
515
|
-
const sorted = stream.qualities;
|
|
516
|
-
if (quality === 'highest')
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
} else if (typeof quality === 'number') {
|
|
521
|
-
stream.select(quality);
|
|
522
|
-
variantsToProcess = [stream.selected];
|
|
523
|
-
} else {
|
|
524
|
-
variantsToProcess = sorted; // all variants
|
|
525
|
-
}
|
|
239
|
+
const sorted = stream.qualities;
|
|
240
|
+
if (quality === 'highest') variantsToProcess = [sorted[0]];
|
|
241
|
+
else if (quality === 'lowest') variantsToProcess = [sorted[sorted.length - 1]];
|
|
242
|
+
else if (typeof quality === 'number') { stream.select(quality); variantsToProcess = [stream.selected]; }
|
|
243
|
+
else variantsToProcess = sorted;
|
|
526
244
|
} else {
|
|
527
|
-
|
|
528
|
-
variantsToProcess = [{ url: null, bandwidth: 0, resolution: null, _segments: stream.segments, _initSegmentUrl: stream.initSegmentUrl }];
|
|
245
|
+
variantsToProcess = [{ url: null, bandwidth: 0, resolution: null, _segments: stream.segments }];
|
|
529
246
|
}
|
|
530
247
|
|
|
531
248
|
log(`Processing ${variantsToProcess.length} variant(s)...`);
|
|
532
249
|
|
|
533
250
|
const variants = [];
|
|
251
|
+
let prerollDuration = 0;
|
|
252
|
+
|
|
534
253
|
for (let vi = 0; vi < variantsToProcess.length; vi++) {
|
|
535
254
|
const variant = variantsToProcess[vi];
|
|
536
255
|
log(`Variant ${vi}: ${variant.resolution || variant.bandwidth || 'default'}`);
|
|
537
256
|
|
|
538
|
-
|
|
539
|
-
let segments, initSegmentUrl;
|
|
257
|
+
let segments;
|
|
540
258
|
if (variant._segments) {
|
|
541
259
|
segments = variant._segments;
|
|
542
|
-
initSegmentUrl = variant._initSegmentUrl;
|
|
543
260
|
} else {
|
|
544
|
-
const
|
|
545
|
-
if (!
|
|
546
|
-
const
|
|
547
|
-
const parsed = parsePlaylistText(mediaText, variant.url);
|
|
261
|
+
const resp = await fetch(variant.url);
|
|
262
|
+
if (!resp.ok) throw new Error(`Failed to fetch media playlist: ${resp.status}`);
|
|
263
|
+
const parsed = parsePlaylistText(await resp.text(), variant.url);
|
|
548
264
|
segments = parsed.segments;
|
|
549
|
-
initSegmentUrl = parsed.initSegmentUrl;
|
|
550
265
|
}
|
|
551
266
|
|
|
552
267
|
if (!segments.length) throw new Error('No segments found');
|
|
553
268
|
|
|
554
|
-
// Find overlapping segments
|
|
555
269
|
const overlapping = segments.filter(seg => seg.endTime > startTime && seg.startTime < endTime);
|
|
556
270
|
if (!overlapping.length) throw new Error('No segments overlap the clip range');
|
|
557
271
|
|
|
@@ -561,71 +275,66 @@ export async function clipHls(source, options = {}) {
|
|
|
561
275
|
|
|
562
276
|
log(`Segments: ${overlapping.length} (${firstSeg.startTime.toFixed(1)}s – ${lastSeg.endTime.toFixed(1)}s)`);
|
|
563
277
|
|
|
564
|
-
// Download first boundary segment
|
|
278
|
+
// Download and clip first boundary segment
|
|
565
279
|
log('Downloading boundary segments...');
|
|
566
|
-
const
|
|
280
|
+
const firstData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
|
|
281
|
+
const firstParser = parseTs(firstData);
|
|
282
|
+
const firstRelStart = startTime - firstSeg.startTime;
|
|
283
|
+
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
284
|
+
const firstClipped = clipSegment(firstParser, firstRelStart, firstRelEnd);
|
|
285
|
+
if (!firstClipped) throw new Error('First segment clip produced no samples');
|
|
567
286
|
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
const isFmp4Source = sourceFormat === 'fmp4';
|
|
287
|
+
// Preroll from the first variant (all variants have similar GOP structure)
|
|
288
|
+
if (vi === 0) prerollDuration = firstClipped.preroll;
|
|
571
289
|
|
|
572
|
-
|
|
290
|
+
const clipSegments = [];
|
|
573
291
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
fmp4Init = new Uint8Array(await initResp.arrayBuffer());
|
|
580
|
-
}
|
|
581
|
-
}
|
|
292
|
+
clipSegments.push({
|
|
293
|
+
duration: firstClipped.duration,
|
|
294
|
+
data: firstClipped.data,
|
|
295
|
+
originalUrl: null,
|
|
296
|
+
});
|
|
582
297
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
298
|
+
// Middle segments: original CDN URLs
|
|
299
|
+
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
300
|
+
clipSegments.push({
|
|
301
|
+
duration: overlapping[i].duration,
|
|
302
|
+
data: null,
|
|
303
|
+
originalUrl: overlapping[i].url,
|
|
304
|
+
});
|
|
586
305
|
}
|
|
587
306
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
// ── TS source path (existing smart-render pipeline) ──
|
|
602
|
-
const result = _processTsVariant({
|
|
603
|
-
firstSegData, lastSegData,
|
|
604
|
-
overlapping, isSingleSegment,
|
|
605
|
-
startTime, endTime, firstSeg, lastSeg, log,
|
|
606
|
-
});
|
|
607
|
-
initSegment = result.initSegment;
|
|
608
|
-
clipSegments = result.clipSegments;
|
|
609
|
-
audioTimescale = result.audioTimescale;
|
|
307
|
+
// Last boundary segment
|
|
308
|
+
if (!isSingleSegment) {
|
|
309
|
+
const lastData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
|
|
310
|
+
const lastParser = parseTs(lastData);
|
|
311
|
+
const lastRelEnd = endTime - lastSeg.startTime;
|
|
312
|
+
const lastClipped = clipSegment(lastParser, undefined, lastRelEnd);
|
|
313
|
+
if (lastClipped && lastClipped.data) {
|
|
314
|
+
clipSegments.push({
|
|
315
|
+
duration: lastClipped.duration,
|
|
316
|
+
data: lastClipped.data,
|
|
317
|
+
originalUrl: null,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
610
320
|
}
|
|
611
321
|
|
|
612
322
|
const totalDuration = clipSegments.reduce((sum, s) => sum + s.duration, 0);
|
|
613
|
-
log(`Clip ready: ${totalDuration.toFixed(2)}s (${clipSegments.length} segments)`);
|
|
323
|
+
log(`Clip ready: ${totalDuration.toFixed(2)}s (${clipSegments.length} segments, preroll: ${firstClipped.preroll.toFixed(2)}s)`);
|
|
614
324
|
|
|
615
325
|
variants.push({
|
|
616
326
|
bandwidth: variant.bandwidth || 0,
|
|
617
327
|
resolution: variant.resolution || null,
|
|
618
|
-
initSegment,
|
|
619
328
|
segments: clipSegments,
|
|
620
329
|
});
|
|
621
330
|
}
|
|
622
331
|
|
|
623
|
-
const clipDuration = endTime - startTime;
|
|
624
332
|
return new HlsClipResult({
|
|
625
333
|
variants,
|
|
626
|
-
duration:
|
|
334
|
+
duration: endTime - startTime,
|
|
627
335
|
startTime,
|
|
628
336
|
endTime,
|
|
337
|
+
prerollDuration,
|
|
629
338
|
});
|
|
630
339
|
}
|
|
631
340
|
|