@invintusmedia/tomp4 1.4.3 → 1.5.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/dist/tomp4.js +2 -2
- package/package.json +1 -1
- package/src/codecs/smart-render.js +374 -96
- package/src/hls-clip.js +177 -433
- 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/hls-clip.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HLS-to-HLS Clipper
|
|
3
3
|
*
|
|
4
|
-
* Clips an HLS stream to a time range, producing a new HLS stream
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Clips an HLS stream to a time range, producing a new HLS stream.
|
|
5
|
+
* Boundary segments are smart-rendered via WebCodecs for frame-accurate
|
|
6
|
+
* start/end. Middle segments use original CDN URLs — completely untouched.
|
|
7
|
+
*
|
|
8
|
+
* Output format matches input: TS segments stay as TS, fMP4 stays as fMP4.
|
|
9
|
+
* No format conversion needed for middle segments.
|
|
10
|
+
*
|
|
11
|
+
* Falls back to keyframe-accurate clipping when WebCodecs is unavailable.
|
|
8
12
|
*
|
|
9
13
|
* @module hls-clip
|
|
10
14
|
*
|
|
@@ -16,46 +20,35 @@
|
|
|
16
20
|
*
|
|
17
21
|
* clip.masterPlaylist // modified m3u8 text
|
|
18
22
|
* clip.getMediaPlaylist(0) // variant media playlist
|
|
19
|
-
* clip.
|
|
20
|
-
* await clip.getSegment(0, 0) // fMP4 media segment (Uint8Array)
|
|
23
|
+
* await clip.getSegment(0, 0) // boundary segment (Uint8Array) or middle URL
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
|
-
import { parseHls,
|
|
24
|
-
import { TSParser
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import { parseBoxes, findBox, parseChildBoxes, createBox } from './fmp4/utils.js';
|
|
28
|
-
import { smartRender } from './codecs/smart-render.js';
|
|
29
|
-
|
|
30
|
-
// ── constants ─────────────────────────────────────────────
|
|
26
|
+
import { parseHls, parsePlaylistText } from './hls.js';
|
|
27
|
+
import { TSParser } from './parsers/mpegts.js';
|
|
28
|
+
import { TSMuxer } from './muxers/mpegts.js';
|
|
29
|
+
import { smartRender, isSmartRenderSupported } from './codecs/smart-render.js';
|
|
31
30
|
|
|
32
31
|
const PTS_PER_SECOND = 90000;
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
/** Wrap raw AAC frame data with a 7-byte ADTS header. */
|
|
34
|
+
function wrapADTS(aacData, sampleRate, channels) {
|
|
35
|
+
const RATES = [96000,88200,64000,48000,44100,32000,24000,22050,16000,12000,11025,8000,7350];
|
|
36
|
+
const sampleRateIndex = RATES.indexOf(sampleRate);
|
|
37
|
+
const frameLength = aacData.length + 7;
|
|
38
|
+
const adts = new Uint8Array(7 + aacData.length);
|
|
39
|
+
adts[0] = 0xFF;
|
|
40
|
+
adts[1] = 0xF1; // MPEG-4, Layer 0, no CRC
|
|
41
|
+
adts[2] = ((1) << 6) | ((sampleRateIndex < 0 ? 4 : sampleRateIndex) << 2) | ((channels >> 2) & 1); // AAC-LC
|
|
42
|
+
adts[3] = ((channels & 3) << 6) | ((frameLength >> 11) & 3);
|
|
43
|
+
adts[4] = (frameLength >> 3) & 0xFF;
|
|
44
|
+
adts[5] = ((frameLength & 7) << 5) | 0x1F;
|
|
45
|
+
adts[6] = 0xFC;
|
|
46
|
+
adts.set(aacData, 7);
|
|
47
|
+
return adts;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
let sps = null, pps = null;
|
|
45
|
-
for (const au of parser.videoAccessUnits) {
|
|
46
|
-
for (const nalUnit of au.nalUnits) {
|
|
47
|
-
const nalType = nalUnit[0] & 0x1F;
|
|
48
|
-
if (nalType === 7 && !sps) sps = nalUnit;
|
|
49
|
-
if (nalType === 8 && !pps) pps = nalUnit;
|
|
50
|
-
if (sps && pps) return { sps, pps };
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return { sps, pps };
|
|
54
|
-
}
|
|
50
|
+
// ── helpers ───────────────────────────────────────────────
|
|
55
51
|
|
|
56
|
-
/**
|
|
57
|
-
* Parse a TS segment and return the parsed data.
|
|
58
|
-
*/
|
|
59
52
|
function parseTs(tsData) {
|
|
60
53
|
const parser = new TSParser();
|
|
61
54
|
parser.parse(tsData);
|
|
@@ -63,107 +56,81 @@ function parseTs(tsData) {
|
|
|
63
56
|
return parser;
|
|
64
57
|
}
|
|
65
58
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
});
|
|
59
|
+
function isKeyframe(au) {
|
|
60
|
+
for (const nal of au.nalUnits) {
|
|
61
|
+
if ((nal[0] & 0x1F) === 5) return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
81
64
|
}
|
|
82
65
|
|
|
83
66
|
/**
|
|
84
|
-
*
|
|
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
|
|
67
|
+
* Mux video + audio access units into an MPEG-TS segment.
|
|
95
68
|
*/
|
|
96
|
-
function
|
|
97
|
-
const
|
|
98
|
-
const startPts = (startTime !== undefined ? startTime : 0) * PTS_PER_SECOND;
|
|
99
|
-
const endPts = (endTime !== undefined ? endTime : Infinity) * PTS_PER_SECOND;
|
|
100
|
-
const videoAUs = parser.videoAccessUnits;
|
|
101
|
-
const audioAUs = parser.audioAccessUnits;
|
|
102
|
-
|
|
103
|
-
if (videoAUs.length === 0) return null;
|
|
104
|
-
|
|
105
|
-
// Check if startTime falls between keyframes (needs smart rendering)
|
|
106
|
-
let keyframeIdx = 0;
|
|
107
|
-
for (let i = 0; i < videoAUs.length; i++) {
|
|
108
|
-
if (videoAUs[i].pts > startPts) break;
|
|
109
|
-
if (isKeyframe(videoAUs[i])) keyframeIdx = i;
|
|
110
|
-
}
|
|
69
|
+
function muxToTs(videoAUs, audioAUs, audioSampleRate, audioChannels) {
|
|
70
|
+
const muxer = new TSMuxer();
|
|
111
71
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
72
|
+
// Extract SPS/PPS for the muxer
|
|
73
|
+
let sps = null, pps = null;
|
|
74
|
+
for (const au of videoAUs) {
|
|
75
|
+
for (const nal of au.nalUnits) {
|
|
76
|
+
const t = nal[0] & 0x1F;
|
|
77
|
+
if (t === 7 && !sps) sps = nal;
|
|
78
|
+
if (t === 8 && !pps) pps = nal;
|
|
79
|
+
}
|
|
80
|
+
if (sps && pps) break;
|
|
81
|
+
}
|
|
82
|
+
if (sps && pps) muxer.setSpsPps(sps, pps);
|
|
83
|
+
muxer.setHasAudio(audioAUs.length > 0);
|
|
84
|
+
|
|
85
|
+
// Add audio samples (wrap raw AAC with ADTS headers)
|
|
86
|
+
const sr = audioSampleRate || 48000;
|
|
87
|
+
const ch = audioChannels || 2;
|
|
88
|
+
for (const au of audioAUs) {
|
|
89
|
+
// Check if already has ADTS header
|
|
90
|
+
const hasADTS = au.data.length > 1 && au.data[0] === 0xFF && (au.data[1] & 0xF0) === 0xF0;
|
|
91
|
+
const adtsData = hasADTS ? au.data : wrapADTS(au.data, sr, ch);
|
|
92
|
+
muxer.addAudioSample(adtsData, au.pts);
|
|
115
93
|
}
|
|
116
94
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const needsSmartRender = false;
|
|
122
|
-
|
|
123
|
-
let clippedVideo, clippedAudio, startOffset;
|
|
124
|
-
|
|
125
|
-
if (needsSmartRender) {
|
|
126
|
-
// Smart render: re-encode boundary GOP for frame-accurate start
|
|
127
|
-
const result = smartRender(parser, startTime, { endTime, qp });
|
|
128
|
-
clippedVideo = result.videoAUs;
|
|
129
|
-
startOffset = result.videoAUs.length > 0 ? result.videoAUs[0].pts : 0;
|
|
95
|
+
// Add video samples
|
|
96
|
+
for (const au of videoAUs) {
|
|
97
|
+
muxer.addVideoNalUnits(au.nalUnits, isKeyframe(au), au.pts, au.dts);
|
|
98
|
+
}
|
|
130
99
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
clippedAudio = audioAUs.filter(au => au.pts >= startOffset && au.pts < audioEnd);
|
|
134
|
-
} else {
|
|
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
|
-
}
|
|
100
|
+
return muxer.build();
|
|
101
|
+
}
|
|
140
102
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Clip a parsed TS segment at the start and/or end.
|
|
105
|
+
* Uses smart rendering (WebCodecs) when available for frame-accurate start.
|
|
106
|
+
* Falls back to keyframe-accurate when WebCodecs is unavailable.
|
|
107
|
+
*/
|
|
108
|
+
async function clipSegment(parser, startTime, endTime) {
|
|
109
|
+
const result = await smartRender(parser, startTime || 0, { endTime });
|
|
144
110
|
|
|
145
|
-
|
|
146
|
-
const audioEndPts = Math.min(endPts, lastVideoPts + PTS_PER_SECOND);
|
|
147
|
-
clippedAudio = audioAUs.filter(au => au.pts >= startOffset && au.pts < audioEndPts);
|
|
148
|
-
}
|
|
111
|
+
if (result.videoAUs.length === 0) return null;
|
|
149
112
|
|
|
150
|
-
|
|
113
|
+
// Calculate duration from the actual content
|
|
114
|
+
const firstPts = result.videoAUs[0].pts;
|
|
115
|
+
const lastPts = result.videoAUs[result.videoAUs.length - 1].pts;
|
|
116
|
+
const frameDuration = result.videoAUs.length > 1
|
|
117
|
+
? result.videoAUs[1].dts - result.videoAUs[0].dts
|
|
118
|
+
: 3003;
|
|
119
|
+
const duration = (lastPts - firstPts + frameDuration) / PTS_PER_SECOND;
|
|
151
120
|
|
|
152
121
|
// Normalize timestamps to start at 0
|
|
153
|
-
|
|
154
|
-
for (const au of
|
|
122
|
+
const offset = firstPts;
|
|
123
|
+
for (const au of result.videoAUs) { au.pts -= offset; au.dts -= offset; }
|
|
124
|
+
for (const au of result.audioAUs) { au.pts -= offset; }
|
|
155
125
|
|
|
156
|
-
//
|
|
157
|
-
const
|
|
158
|
-
? clippedVideo[clippedVideo.length - 1].dts - clippedVideo[0].dts +
|
|
159
|
-
(clippedVideo.length > 1 ? clippedVideo[1].dts - clippedVideo[0].dts : 3003)
|
|
160
|
-
: 3003;
|
|
126
|
+
// Mux to TS (wrap raw AAC with ADTS headers)
|
|
127
|
+
const tsData = muxToTs(result.videoAUs, result.audioAUs, parser.audioSampleRate, parser.audioChannels);
|
|
161
128
|
|
|
162
129
|
return {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
130
|
+
data: tsData,
|
|
131
|
+
duration,
|
|
132
|
+
smartRendered: (result.smartRenderedFrames || 0) > 0,
|
|
133
|
+
smartRenderedFrames: result.smartRenderedFrames || 0,
|
|
167
134
|
};
|
|
168
135
|
}
|
|
169
136
|
|
|
@@ -171,22 +138,19 @@ function clipSegment(parser, startTime, endTime, options = {}) {
|
|
|
171
138
|
|
|
172
139
|
class HlsClipResult {
|
|
173
140
|
constructor({ variants, duration, startTime, endTime }) {
|
|
174
|
-
this._variants = variants;
|
|
141
|
+
this._variants = variants;
|
|
175
142
|
this.duration = duration;
|
|
176
143
|
this.startTime = startTime;
|
|
177
144
|
this.endTime = endTime;
|
|
178
145
|
}
|
|
179
146
|
|
|
180
|
-
/** Number of quality variants */
|
|
181
147
|
get variantCount() {
|
|
182
148
|
return this._variants.length;
|
|
183
149
|
}
|
|
184
150
|
|
|
185
151
|
/** Master playlist m3u8 text */
|
|
186
152
|
get masterPlaylist() {
|
|
187
|
-
if (this._variants.length === 1)
|
|
188
|
-
return this.getMediaPlaylist(0);
|
|
189
|
-
}
|
|
153
|
+
if (this._variants.length === 1) return this.getMediaPlaylist(0);
|
|
190
154
|
let m3u8 = '#EXTM3U\n';
|
|
191
155
|
for (let i = 0; i < this._variants.length; i++) {
|
|
192
156
|
const v = this._variants[i];
|
|
@@ -198,9 +162,9 @@ class HlsClipResult {
|
|
|
198
162
|
}
|
|
199
163
|
|
|
200
164
|
/**
|
|
201
|
-
* Get
|
|
202
|
-
*
|
|
203
|
-
*
|
|
165
|
+
* Get media playlist for a variant.
|
|
166
|
+
* Boundary segments use custom URLs (served from memory).
|
|
167
|
+
* Middle segments use original CDN URLs.
|
|
204
168
|
*/
|
|
205
169
|
getMediaPlaylist(variantIndex = 0) {
|
|
206
170
|
const variant = this._variants[variantIndex];
|
|
@@ -209,38 +173,30 @@ class HlsClipResult {
|
|
|
209
173
|
const maxDur = Math.max(...variant.segments.map(s => s.duration));
|
|
210
174
|
|
|
211
175
|
let m3u8 = '#EXTM3U\n';
|
|
212
|
-
m3u8 += '#EXT-X-VERSION:
|
|
176
|
+
m3u8 += '#EXT-X-VERSION:3\n';
|
|
213
177
|
m3u8 += `#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}\n`;
|
|
214
178
|
m3u8 += '#EXT-X-PLAYLIST-TYPE:VOD\n';
|
|
215
179
|
m3u8 += '#EXT-X-MEDIA-SEQUENCE:0\n';
|
|
216
|
-
m3u8 += `#EXT-X-MAP:URI="init-${variantIndex}.m4s"\n`;
|
|
217
180
|
|
|
218
181
|
for (let i = 0; i < variant.segments.length; i++) {
|
|
219
182
|
const seg = variant.segments[i];
|
|
220
183
|
m3u8 += `#EXTINF:${seg.duration.toFixed(6)},\n`;
|
|
221
|
-
|
|
184
|
+
if (seg.originalUrl) {
|
|
185
|
+
// Middle segment: original CDN URL (untouched)
|
|
186
|
+
m3u8 += `${seg.originalUrl}\n`;
|
|
187
|
+
} else {
|
|
188
|
+
// Boundary segment: served from memory
|
|
189
|
+
m3u8 += `segment-${variantIndex}-${i}.ts\n`;
|
|
190
|
+
}
|
|
222
191
|
}
|
|
223
192
|
m3u8 += '#EXT-X-ENDLIST\n';
|
|
224
193
|
return m3u8;
|
|
225
194
|
}
|
|
226
195
|
|
|
227
196
|
/**
|
|
228
|
-
* Get
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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>}
|
|
197
|
+
* Get a segment's TS data.
|
|
198
|
+
* Boundary segments: return from memory.
|
|
199
|
+
* Middle segments: return null (use originalUrl from playlist).
|
|
244
200
|
*/
|
|
245
201
|
async getSegment(variantIndex = 0, segmentIndex = 0) {
|
|
246
202
|
const variant = this._variants[variantIndex];
|
|
@@ -248,36 +204,20 @@ class HlsClipResult {
|
|
|
248
204
|
const seg = variant.segments[segmentIndex];
|
|
249
205
|
if (!seg) throw new Error(`Segment ${segmentIndex} not found`);
|
|
250
206
|
|
|
251
|
-
// Pre-clipped boundary segments are already in memory
|
|
252
207
|
if (seg.data) return seg.data;
|
|
253
208
|
|
|
254
|
-
// Middle segment: fetch from CDN
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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);
|
|
209
|
+
// Middle segment: fetch from CDN (for cases where caller needs the data)
|
|
210
|
+
if (seg.originalUrl) {
|
|
211
|
+
const resp = await fetch(seg.originalUrl);
|
|
212
|
+
if (!resp.ok) throw new Error(`Segment fetch failed: ${resp.status}`);
|
|
213
|
+
return new Uint8Array(await resp.arrayBuffer());
|
|
214
|
+
}
|
|
272
215
|
|
|
273
|
-
return
|
|
216
|
+
return null;
|
|
274
217
|
}
|
|
275
218
|
|
|
276
219
|
/**
|
|
277
|
-
* Get all segment data
|
|
278
|
-
* Useful for downloading the full clip.
|
|
279
|
-
* @param {number} variantIndex
|
|
280
|
-
* @returns {Promise<Uint8Array[]>}
|
|
220
|
+
* Get all segment data (fetches middle segments from CDN).
|
|
281
221
|
*/
|
|
282
222
|
async getAllSegments(variantIndex = 0) {
|
|
283
223
|
const variant = this._variants[variantIndex];
|
|
@@ -289,213 +229,20 @@ class HlsClipResult {
|
|
|
289
229
|
}
|
|
290
230
|
}
|
|
291
231
|
|
|
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';
|
|
300
|
-
}
|
|
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
|
-
}
|
|
306
|
-
|
|
307
|
-
// ── TS variant processing ─────────────────────────────────
|
|
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
232
|
// ── main function ─────────────────────────────────────────
|
|
489
233
|
|
|
490
234
|
/**
|
|
491
|
-
* Clip an HLS stream to a time range
|
|
492
|
-
*
|
|
235
|
+
* Clip an HLS stream to a time range.
|
|
236
|
+
*
|
|
237
|
+
* Output is a new HLS stream where:
|
|
238
|
+
* - Boundary segments are smart-rendered (WebCodecs) or keyframe-accurate
|
|
239
|
+
* - Middle segments use original CDN URLs (completely untouched)
|
|
493
240
|
*
|
|
494
|
-
* @param {string} source - HLS URL
|
|
241
|
+
* @param {string|HlsStream} source - HLS URL or parsed HlsStream
|
|
495
242
|
* @param {object} options
|
|
496
243
|
* @param {number} options.startTime - Start time in seconds
|
|
497
244
|
* @param {number} options.endTime - End time in seconds
|
|
498
|
-
* @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth
|
|
245
|
+
* @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth
|
|
499
246
|
* @param {function} [options.onProgress] - Progress callback
|
|
500
247
|
* @returns {Promise<HlsClipResult>}
|
|
501
248
|
*/
|
|
@@ -508,45 +255,44 @@ export async function clipHls(source, options = {}) {
|
|
|
508
255
|
log('Parsing HLS playlist...');
|
|
509
256
|
const stream = typeof source === 'string' ? await parseHls(source, { onProgress: log }) : source;
|
|
510
257
|
|
|
511
|
-
// Resolve variants
|
|
258
|
+
// Resolve variants
|
|
512
259
|
let variantsToProcess = [];
|
|
513
|
-
|
|
514
260
|
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
|
-
}
|
|
261
|
+
const sorted = stream.qualities;
|
|
262
|
+
if (quality === 'highest') variantsToProcess = [sorted[0]];
|
|
263
|
+
else if (quality === 'lowest') variantsToProcess = [sorted[sorted.length - 1]];
|
|
264
|
+
else if (typeof quality === 'number') { stream.select(quality); variantsToProcess = [stream.selected]; }
|
|
265
|
+
else variantsToProcess = sorted;
|
|
526
266
|
} else {
|
|
527
|
-
|
|
528
|
-
|
|
267
|
+
variantsToProcess = [{
|
|
268
|
+
url: null, bandwidth: 0, resolution: null,
|
|
269
|
+
_segments: stream.segments, _initSegmentUrl: stream.initSegmentUrl,
|
|
270
|
+
}];
|
|
529
271
|
}
|
|
530
272
|
|
|
531
273
|
log(`Processing ${variantsToProcess.length} variant(s)...`);
|
|
274
|
+
if (isSmartRenderSupported()) {
|
|
275
|
+
log('Smart rendering: enabled (WebCodecs)');
|
|
276
|
+
} else {
|
|
277
|
+
log('Smart rendering: unavailable (keyframe-accurate fallback)');
|
|
278
|
+
}
|
|
532
279
|
|
|
533
280
|
const variants = [];
|
|
281
|
+
|
|
534
282
|
for (let vi = 0; vi < variantsToProcess.length; vi++) {
|
|
535
283
|
const variant = variantsToProcess[vi];
|
|
536
284
|
log(`Variant ${vi}: ${variant.resolution || variant.bandwidth || 'default'}`);
|
|
537
285
|
|
|
538
|
-
// Get segment list
|
|
539
|
-
let segments
|
|
286
|
+
// Get segment list
|
|
287
|
+
let segments;
|
|
540
288
|
if (variant._segments) {
|
|
541
289
|
segments = variant._segments;
|
|
542
|
-
initSegmentUrl = variant._initSegmentUrl;
|
|
543
290
|
} else {
|
|
544
291
|
const mediaResp = await fetch(variant.url);
|
|
545
292
|
if (!mediaResp.ok) throw new Error(`Failed to fetch media playlist: ${mediaResp.status}`);
|
|
546
293
|
const mediaText = await mediaResp.text();
|
|
547
294
|
const parsed = parsePlaylistText(mediaText, variant.url);
|
|
548
295
|
segments = parsed.segments;
|
|
549
|
-
initSegmentUrl = parsed.initSegmentUrl;
|
|
550
296
|
}
|
|
551
297
|
|
|
552
298
|
if (!segments.length) throw new Error('No segments found');
|
|
@@ -561,52 +307,52 @@ export async function clipHls(source, options = {}) {
|
|
|
561
307
|
|
|
562
308
|
log(`Segments: ${overlapping.length} (${firstSeg.startTime.toFixed(1)}s – ${lastSeg.endTime.toFixed(1)}s)`);
|
|
563
309
|
|
|
564
|
-
// Download
|
|
310
|
+
// Download and clip boundary segments
|
|
565
311
|
log('Downloading boundary segments...');
|
|
566
|
-
const
|
|
312
|
+
const firstData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
|
|
313
|
+
const firstParser = parseTs(firstData);
|
|
567
314
|
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
const
|
|
315
|
+
const firstRelStart = startTime - firstSeg.startTime;
|
|
316
|
+
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
317
|
+
const firstClipped = await clipSegment(firstParser, firstRelStart, firstRelEnd);
|
|
318
|
+
if (!firstClipped) throw new Error('First segment clip produced no samples');
|
|
571
319
|
|
|
572
|
-
|
|
320
|
+
const clipSegments = [];
|
|
573
321
|
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
322
|
+
// First segment (boundary, in memory)
|
|
323
|
+
clipSegments.push({
|
|
324
|
+
duration: firstClipped.duration,
|
|
325
|
+
data: firstClipped.data,
|
|
326
|
+
originalUrl: null,
|
|
327
|
+
isBoundary: true,
|
|
328
|
+
smartRendered: firstClipped.smartRendered,
|
|
329
|
+
});
|
|
582
330
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
331
|
+
// Middle segments (original CDN URLs, untouched)
|
|
332
|
+
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
333
|
+
clipSegments.push({
|
|
334
|
+
duration: overlapping[i].duration,
|
|
335
|
+
data: null,
|
|
336
|
+
originalUrl: overlapping[i].url,
|
|
337
|
+
isBoundary: false,
|
|
338
|
+
});
|
|
586
339
|
}
|
|
587
340
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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;
|
|
341
|
+
// Last segment (boundary, if different from first)
|
|
342
|
+
if (!isSingleSegment) {
|
|
343
|
+
const lastData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
|
|
344
|
+
const lastParser = parseTs(lastData);
|
|
345
|
+
const lastRelEnd = endTime - lastSeg.startTime;
|
|
346
|
+
const lastClipped = await clipSegment(lastParser, undefined, lastRelEnd);
|
|
347
|
+
if (lastClipped && lastClipped.data) {
|
|
348
|
+
clipSegments.push({
|
|
349
|
+
duration: lastClipped.duration,
|
|
350
|
+
data: lastClipped.data,
|
|
351
|
+
originalUrl: null,
|
|
352
|
+
isBoundary: true,
|
|
353
|
+
smartRendered: lastClipped.smartRendered,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
610
356
|
}
|
|
611
357
|
|
|
612
358
|
const totalDuration = clipSegments.reduce((sum, s) => sum + s.duration, 0);
|
|
@@ -615,15 +361,13 @@ export async function clipHls(source, options = {}) {
|
|
|
615
361
|
variants.push({
|
|
616
362
|
bandwidth: variant.bandwidth || 0,
|
|
617
363
|
resolution: variant.resolution || null,
|
|
618
|
-
initSegment,
|
|
619
364
|
segments: clipSegments,
|
|
620
365
|
});
|
|
621
366
|
}
|
|
622
367
|
|
|
623
|
-
const clipDuration = endTime - startTime;
|
|
624
368
|
return new HlsClipResult({
|
|
625
369
|
variants,
|
|
626
|
-
duration:
|
|
370
|
+
duration: endTime - startTime,
|
|
627
371
|
startTime,
|
|
628
372
|
endTime,
|
|
629
373
|
});
|