@invintusmedia/tomp4 1.4.2 → 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 -429
- 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,103 +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
|
-
if (needsSmartRender) {
|
|
122
|
-
// Smart render: re-encode boundary GOP for frame-accurate start
|
|
123
|
-
const result = smartRender(parser, startTime, { endTime, qp });
|
|
124
|
-
clippedVideo = result.videoAUs;
|
|
125
|
-
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
|
+
}
|
|
126
99
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
clippedAudio = audioAUs.filter(au => au.pts >= startOffset && au.pts < audioEnd);
|
|
130
|
-
} else {
|
|
131
|
-
// Start is at a keyframe — no smart rendering needed
|
|
132
|
-
let endIdx = videoAUs.length;
|
|
133
|
-
for (let i = keyframeIdx; i < videoAUs.length; i++) {
|
|
134
|
-
if (videoAUs[i].pts >= endPts) { endIdx = i; break; }
|
|
135
|
-
}
|
|
100
|
+
return muxer.build();
|
|
101
|
+
}
|
|
136
102
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 });
|
|
140
110
|
|
|
141
|
-
|
|
142
|
-
const audioEndPts = Math.min(endPts, lastVideoPts + PTS_PER_SECOND);
|
|
143
|
-
clippedAudio = audioAUs.filter(au => au.pts >= startOffset && au.pts < audioEndPts);
|
|
144
|
-
}
|
|
111
|
+
if (result.videoAUs.length === 0) return null;
|
|
145
112
|
|
|
146
|
-
|
|
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;
|
|
147
120
|
|
|
148
121
|
// Normalize timestamps to start at 0
|
|
149
|
-
|
|
150
|
-
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; }
|
|
151
125
|
|
|
152
|
-
//
|
|
153
|
-
const
|
|
154
|
-
? clippedVideo[clippedVideo.length - 1].dts - clippedVideo[0].dts +
|
|
155
|
-
(clippedVideo.length > 1 ? clippedVideo[1].dts - clippedVideo[0].dts : 3003)
|
|
156
|
-
: 3003;
|
|
126
|
+
// Mux to TS (wrap raw AAC with ADTS headers)
|
|
127
|
+
const tsData = muxToTs(result.videoAUs, result.audioAUs, parser.audioSampleRate, parser.audioChannels);
|
|
157
128
|
|
|
158
129
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
130
|
+
data: tsData,
|
|
131
|
+
duration,
|
|
132
|
+
smartRendered: (result.smartRenderedFrames || 0) > 0,
|
|
133
|
+
smartRenderedFrames: result.smartRenderedFrames || 0,
|
|
163
134
|
};
|
|
164
135
|
}
|
|
165
136
|
|
|
@@ -167,22 +138,19 @@ function clipSegment(parser, startTime, endTime, options = {}) {
|
|
|
167
138
|
|
|
168
139
|
class HlsClipResult {
|
|
169
140
|
constructor({ variants, duration, startTime, endTime }) {
|
|
170
|
-
this._variants = variants;
|
|
141
|
+
this._variants = variants;
|
|
171
142
|
this.duration = duration;
|
|
172
143
|
this.startTime = startTime;
|
|
173
144
|
this.endTime = endTime;
|
|
174
145
|
}
|
|
175
146
|
|
|
176
|
-
/** Number of quality variants */
|
|
177
147
|
get variantCount() {
|
|
178
148
|
return this._variants.length;
|
|
179
149
|
}
|
|
180
150
|
|
|
181
151
|
/** Master playlist m3u8 text */
|
|
182
152
|
get masterPlaylist() {
|
|
183
|
-
if (this._variants.length === 1)
|
|
184
|
-
return this.getMediaPlaylist(0);
|
|
185
|
-
}
|
|
153
|
+
if (this._variants.length === 1) return this.getMediaPlaylist(0);
|
|
186
154
|
let m3u8 = '#EXTM3U\n';
|
|
187
155
|
for (let i = 0; i < this._variants.length; i++) {
|
|
188
156
|
const v = this._variants[i];
|
|
@@ -194,9 +162,9 @@ class HlsClipResult {
|
|
|
194
162
|
}
|
|
195
163
|
|
|
196
164
|
/**
|
|
197
|
-
* Get
|
|
198
|
-
*
|
|
199
|
-
*
|
|
165
|
+
* Get media playlist for a variant.
|
|
166
|
+
* Boundary segments use custom URLs (served from memory).
|
|
167
|
+
* Middle segments use original CDN URLs.
|
|
200
168
|
*/
|
|
201
169
|
getMediaPlaylist(variantIndex = 0) {
|
|
202
170
|
const variant = this._variants[variantIndex];
|
|
@@ -205,38 +173,30 @@ class HlsClipResult {
|
|
|
205
173
|
const maxDur = Math.max(...variant.segments.map(s => s.duration));
|
|
206
174
|
|
|
207
175
|
let m3u8 = '#EXTM3U\n';
|
|
208
|
-
m3u8 += '#EXT-X-VERSION:
|
|
176
|
+
m3u8 += '#EXT-X-VERSION:3\n';
|
|
209
177
|
m3u8 += `#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}\n`;
|
|
210
178
|
m3u8 += '#EXT-X-PLAYLIST-TYPE:VOD\n';
|
|
211
179
|
m3u8 += '#EXT-X-MEDIA-SEQUENCE:0\n';
|
|
212
|
-
m3u8 += `#EXT-X-MAP:URI="init-${variantIndex}.m4s"\n`;
|
|
213
180
|
|
|
214
181
|
for (let i = 0; i < variant.segments.length; i++) {
|
|
215
182
|
const seg = variant.segments[i];
|
|
216
183
|
m3u8 += `#EXTINF:${seg.duration.toFixed(6)},\n`;
|
|
217
|
-
|
|
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
|
+
}
|
|
218
191
|
}
|
|
219
192
|
m3u8 += '#EXT-X-ENDLIST\n';
|
|
220
193
|
return m3u8;
|
|
221
194
|
}
|
|
222
195
|
|
|
223
196
|
/**
|
|
224
|
-
* Get
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*/
|
|
228
|
-
getInitSegment(variantIndex = 0) {
|
|
229
|
-
return this._variants[variantIndex]?.initSegment ?? null;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Get a media segment as fMP4 data.
|
|
234
|
-
* Boundary segments are returned from memory (pre-clipped).
|
|
235
|
-
* Middle segments are fetched from CDN and remuxed on-demand.
|
|
236
|
-
*
|
|
237
|
-
* @param {number} variantIndex
|
|
238
|
-
* @param {number} segmentIndex
|
|
239
|
-
* @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).
|
|
240
200
|
*/
|
|
241
201
|
async getSegment(variantIndex = 0, segmentIndex = 0) {
|
|
242
202
|
const variant = this._variants[variantIndex];
|
|
@@ -244,36 +204,20 @@ class HlsClipResult {
|
|
|
244
204
|
const seg = variant.segments[segmentIndex];
|
|
245
205
|
if (!seg) throw new Error(`Segment ${segmentIndex} not found`);
|
|
246
206
|
|
|
247
|
-
// Pre-clipped boundary segments are already in memory
|
|
248
207
|
if (seg.data) return seg.data;
|
|
249
208
|
|
|
250
|
-
// Middle segment: fetch from CDN
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (seg._sourceFormat === 'fmp4') return rawData;
|
|
257
|
-
|
|
258
|
-
// TS segments: remux to fMP4
|
|
259
|
-
const parser = parseTs(rawData);
|
|
260
|
-
const audioTimescale = seg._audioTimescale || parser.audioSampleRate || 48000;
|
|
261
|
-
|
|
262
|
-
const firstVideoPts = parser.videoAccessUnits[0]?.pts ?? 0;
|
|
263
|
-
for (const au of parser.videoAccessUnits) { au.pts -= firstVideoPts; au.dts -= firstVideoPts; }
|
|
264
|
-
for (const au of parser.audioAccessUnits) { au.pts -= firstVideoPts; }
|
|
265
|
-
|
|
266
|
-
const videoBaseTime = Math.round(seg.timelineOffset * PTS_PER_SECOND);
|
|
267
|
-
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
|
+
}
|
|
268
215
|
|
|
269
|
-
return
|
|
216
|
+
return null;
|
|
270
217
|
}
|
|
271
218
|
|
|
272
219
|
/**
|
|
273
|
-
* Get all segment data
|
|
274
|
-
* Useful for downloading the full clip.
|
|
275
|
-
* @param {number} variantIndex
|
|
276
|
-
* @returns {Promise<Uint8Array[]>}
|
|
220
|
+
* Get all segment data (fetches middle segments from CDN).
|
|
277
221
|
*/
|
|
278
222
|
async getAllSegments(variantIndex = 0) {
|
|
279
223
|
const variant = this._variants[variantIndex];
|
|
@@ -285,213 +229,20 @@ class HlsClipResult {
|
|
|
285
229
|
}
|
|
286
230
|
}
|
|
287
231
|
|
|
288
|
-
// ── format detection ──────────────────────────────────────
|
|
289
|
-
|
|
290
|
-
function _detectSegmentFormat(data) {
|
|
291
|
-
if (data.length < 8) return 'unknown';
|
|
292
|
-
// Check for TS sync byte
|
|
293
|
-
if (data[0] === 0x47) return 'ts';
|
|
294
|
-
for (let i = 0; i < Math.min(188, data.length); i++) {
|
|
295
|
-
if (data[i] === 0x47 && i + 188 < data.length && data[i + 188] === 0x47) return 'ts';
|
|
296
|
-
}
|
|
297
|
-
// Check for fMP4 (moof, styp, or ftyp box)
|
|
298
|
-
const type = String.fromCharCode(data[4], data[5], data[6], data[7]);
|
|
299
|
-
if (['moof', 'styp', 'ftyp', 'mdat'].includes(type)) return 'fmp4';
|
|
300
|
-
return 'unknown';
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ── TS variant processing ─────────────────────────────────
|
|
304
|
-
|
|
305
|
-
function _processTsVariant({ firstSegData, lastSegData, overlapping, isSingleSegment, startTime, endTime, firstSeg, lastSeg, log }) {
|
|
306
|
-
const firstParser = parseTs(firstSegData);
|
|
307
|
-
const lastParser = !isSingleSegment && lastSegData ? parseTs(lastSegData) : null;
|
|
308
|
-
|
|
309
|
-
const { sps, pps } = extractCodecInfo(firstParser);
|
|
310
|
-
if (!sps || !pps) throw new Error('Could not extract SPS/PPS from video');
|
|
311
|
-
const audioSampleRate = firstParser.audioSampleRate || 48000;
|
|
312
|
-
const audioChannels = firstParser.audioChannels || 2;
|
|
313
|
-
const hasAudio = firstParser.audioAccessUnits.length > 0;
|
|
314
|
-
const audioTimescale = audioSampleRate;
|
|
315
|
-
|
|
316
|
-
const initSegment = createInitSegment({
|
|
317
|
-
sps, pps, audioSampleRate, audioChannels, hasAudio,
|
|
318
|
-
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
const clipSegments = [];
|
|
322
|
-
let timelineOffset = 0;
|
|
323
|
-
|
|
324
|
-
// First segment (smart-rendered)
|
|
325
|
-
const firstRelStart = startTime - firstSeg.startTime;
|
|
326
|
-
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
327
|
-
const firstClipped = clipSegment(firstParser, firstRelStart, firstRelEnd);
|
|
328
|
-
if (!firstClipped) throw new Error('First segment clip produced no samples');
|
|
329
|
-
|
|
330
|
-
const firstFragment = createFragment({
|
|
331
|
-
videoSamples: firstClipped.videoSamples,
|
|
332
|
-
audioSamples: firstClipped.audioSamples,
|
|
333
|
-
sequenceNumber: 1,
|
|
334
|
-
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
335
|
-
videoBaseTime: 0, audioBaseTime: 0, audioSampleDuration: 1024,
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
clipSegments.push({
|
|
339
|
-
duration: firstClipped.duration, data: firstFragment,
|
|
340
|
-
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
341
|
-
});
|
|
342
|
-
timelineOffset += firstClipped.duration;
|
|
343
|
-
|
|
344
|
-
// Middle segments
|
|
345
|
-
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
346
|
-
clipSegments.push({
|
|
347
|
-
duration: overlapping[i].duration, data: null,
|
|
348
|
-
originalUrl: overlapping[i].url, timelineOffset, isBoundary: false,
|
|
349
|
-
_sourceFormat: 'ts', _audioTimescale: audioTimescale,
|
|
350
|
-
});
|
|
351
|
-
timelineOffset += overlapping[i].duration;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Last segment
|
|
355
|
-
if (!isSingleSegment && lastParser) {
|
|
356
|
-
const lastRelEnd = endTime - lastSeg.startTime;
|
|
357
|
-
const lastClipped = clipSegment(lastParser, undefined, lastRelEnd);
|
|
358
|
-
if (lastClipped && lastClipped.videoSamples.length > 0) {
|
|
359
|
-
const lastFragment = createFragment({
|
|
360
|
-
videoSamples: lastClipped.videoSamples,
|
|
361
|
-
audioSamples: lastClipped.audioSamples,
|
|
362
|
-
sequenceNumber: overlapping.length,
|
|
363
|
-
videoTimescale: PTS_PER_SECOND, audioTimescale,
|
|
364
|
-
videoBaseTime: Math.round(timelineOffset * PTS_PER_SECOND),
|
|
365
|
-
audioBaseTime: Math.round(timelineOffset * audioTimescale),
|
|
366
|
-
audioSampleDuration: 1024,
|
|
367
|
-
});
|
|
368
|
-
clipSegments.push({
|
|
369
|
-
duration: lastClipped.duration, data: lastFragment,
|
|
370
|
-
originalUrl: null, timelineOffset, isBoundary: true,
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return { initSegment, clipSegments, audioTimescale };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ── fMP4 variant processing ───────────────────────────────
|
|
379
|
-
|
|
380
|
-
function _processFmp4Variant({ firstSegData, lastSegData, fmp4Init, overlapping, isSingleSegment, startTime, endTime, firstSeg, lastSeg, log }) {
|
|
381
|
-
// For fMP4 sources: the init segment already has the moov with codec info.
|
|
382
|
-
// We pass it through as-is. Boundary segments are clipped using the fMP4
|
|
383
|
-
// converter. Middle segments pass through unchanged.
|
|
384
|
-
|
|
385
|
-
if (!fmp4Init) throw new Error('fMP4 source requires an init segment (#EXT-X-MAP)');
|
|
386
|
-
|
|
387
|
-
// Use the source init segment directly (it has the correct moov)
|
|
388
|
-
const initSegment = fmp4Init;
|
|
389
|
-
|
|
390
|
-
// Detect audio timescale from the init segment's moov
|
|
391
|
-
let audioTimescale = 48000;
|
|
392
|
-
try {
|
|
393
|
-
const boxes = parseBoxes(fmp4Init);
|
|
394
|
-
const moov = findBox(boxes, 'moov');
|
|
395
|
-
if (moov) {
|
|
396
|
-
const moovChildren = parseChildBoxes(moov);
|
|
397
|
-
for (const child of moovChildren) {
|
|
398
|
-
if (child.type === 'trak') {
|
|
399
|
-
const trakChildren = parseChildBoxes(child);
|
|
400
|
-
for (const tc of trakChildren) {
|
|
401
|
-
if (tc.type === 'mdia') {
|
|
402
|
-
const mdiaChildren = parseChildBoxes(tc);
|
|
403
|
-
let isSoun = false;
|
|
404
|
-
for (const mc of mdiaChildren) {
|
|
405
|
-
if (mc.type === 'hdlr' && mc.data.byteLength >= 20) {
|
|
406
|
-
const handler = String.fromCharCode(mc.data[16], mc.data[17], mc.data[18], mc.data[19]);
|
|
407
|
-
if (handler === 'soun') isSoun = true;
|
|
408
|
-
}
|
|
409
|
-
if (mc.type === 'mdhd' && isSoun) {
|
|
410
|
-
const v = new DataView(mc.data.buffer, mc.data.byteOffset, mc.data.byteLength);
|
|
411
|
-
audioTimescale = mc.data[8] === 0 ? v.getUint32(20) : v.getUint32(28);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
} catch (e) { /* use default */ }
|
|
420
|
-
|
|
421
|
-
const clipSegments = [];
|
|
422
|
-
let timelineOffset = 0;
|
|
423
|
-
|
|
424
|
-
// First segment: clip using fMP4 converter
|
|
425
|
-
const firstRelStart = startTime - firstSeg.startTime;
|
|
426
|
-
const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
|
|
427
|
-
|
|
428
|
-
// Combine init + first segment for the converter
|
|
429
|
-
const firstCombined = new Uint8Array(fmp4Init.byteLength + firstSegData.byteLength);
|
|
430
|
-
firstCombined.set(fmp4Init, 0);
|
|
431
|
-
firstCombined.set(firstSegData, fmp4Init.byteLength);
|
|
432
|
-
|
|
433
|
-
try {
|
|
434
|
-
// Use convertFmp4ToMp4 with clipping, then re-fragment
|
|
435
|
-
// Actually, we can just pass the raw fMP4 segment through — for boundary
|
|
436
|
-
// segments, we trim at the keyframe level (no smart rendering for fMP4 yet).
|
|
437
|
-
// The segment already starts at a keyframe (HLS requirement).
|
|
438
|
-
|
|
439
|
-
// For the first segment, just pass through — the startTime cut is at keyframe
|
|
440
|
-
// For frame accuracy with fMP4, we'd need to add edit lists to the init segment
|
|
441
|
-
// or do smart rendering. For now, keyframe-accurate is the fMP4 path.
|
|
442
|
-
clipSegments.push({
|
|
443
|
-
duration: (firstRelEnd || firstSeg.duration) - firstRelStart,
|
|
444
|
-
data: firstSegData, // pass through the fMP4 segment
|
|
445
|
-
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
446
|
-
_sourceFormat: 'fmp4',
|
|
447
|
-
});
|
|
448
|
-
timelineOffset += clipSegments[0].duration;
|
|
449
|
-
} catch (e) {
|
|
450
|
-
log('fMP4 first segment processing error: ' + e.message);
|
|
451
|
-
// Fallback: pass through as-is
|
|
452
|
-
clipSegments.push({
|
|
453
|
-
duration: firstSeg.duration, data: firstSegData,
|
|
454
|
-
originalUrl: null, timelineOffset: 0, isBoundary: true,
|
|
455
|
-
_sourceFormat: 'fmp4',
|
|
456
|
-
});
|
|
457
|
-
timelineOffset += firstSeg.duration;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Middle segments: pass through unchanged (already fMP4!)
|
|
461
|
-
for (let i = 1; i < overlapping.length - 1; i++) {
|
|
462
|
-
clipSegments.push({
|
|
463
|
-
duration: overlapping[i].duration, data: null,
|
|
464
|
-
originalUrl: overlapping[i].url, timelineOffset, isBoundary: false,
|
|
465
|
-
_sourceFormat: 'fmp4',
|
|
466
|
-
});
|
|
467
|
-
timelineOffset += overlapping[i].duration;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Last segment: pass through (truncation at end is handled by player)
|
|
471
|
-
if (!isSingleSegment && lastSegData) {
|
|
472
|
-
const lastRelEnd = endTime - lastSeg.startTime;
|
|
473
|
-
clipSegments.push({
|
|
474
|
-
duration: Math.min(lastRelEnd, lastSeg.duration),
|
|
475
|
-
data: lastSegData,
|
|
476
|
-
originalUrl: null, timelineOffset, isBoundary: true,
|
|
477
|
-
_sourceFormat: 'fmp4',
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
return { initSegment, clipSegments, audioTimescale };
|
|
482
|
-
}
|
|
483
|
-
|
|
484
232
|
// ── main function ─────────────────────────────────────────
|
|
485
233
|
|
|
486
234
|
/**
|
|
487
|
-
* Clip an HLS stream to a time range
|
|
488
|
-
*
|
|
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)
|
|
489
240
|
*
|
|
490
|
-
* @param {string} source - HLS URL
|
|
241
|
+
* @param {string|HlsStream} source - HLS URL or parsed HlsStream
|
|
491
242
|
* @param {object} options
|
|
492
243
|
* @param {number} options.startTime - Start time in seconds
|
|
493
244
|
* @param {number} options.endTime - End time in seconds
|
|
494
|
-
* @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth
|
|
245
|
+
* @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth
|
|
495
246
|
* @param {function} [options.onProgress] - Progress callback
|
|
496
247
|
* @returns {Promise<HlsClipResult>}
|
|
497
248
|
*/
|
|
@@ -504,45 +255,44 @@ export async function clipHls(source, options = {}) {
|
|
|
504
255
|
log('Parsing HLS playlist...');
|
|
505
256
|
const stream = typeof source === 'string' ? await parseHls(source, { onProgress: log }) : source;
|
|
506
257
|
|
|
507
|
-
// Resolve variants
|
|
258
|
+
// Resolve variants
|
|
508
259
|
let variantsToProcess = [];
|
|
509
|
-
|
|
510
260
|
if (stream.isMaster) {
|
|
511
|
-
const sorted = stream.qualities;
|
|
512
|
-
if (quality === 'highest')
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
} else if (typeof quality === 'number') {
|
|
517
|
-
stream.select(quality);
|
|
518
|
-
variantsToProcess = [stream.selected];
|
|
519
|
-
} else {
|
|
520
|
-
variantsToProcess = sorted; // all variants
|
|
521
|
-
}
|
|
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;
|
|
522
266
|
} else {
|
|
523
|
-
|
|
524
|
-
|
|
267
|
+
variantsToProcess = [{
|
|
268
|
+
url: null, bandwidth: 0, resolution: null,
|
|
269
|
+
_segments: stream.segments, _initSegmentUrl: stream.initSegmentUrl,
|
|
270
|
+
}];
|
|
525
271
|
}
|
|
526
272
|
|
|
527
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
|
+
}
|
|
528
279
|
|
|
529
280
|
const variants = [];
|
|
281
|
+
|
|
530
282
|
for (let vi = 0; vi < variantsToProcess.length; vi++) {
|
|
531
283
|
const variant = variantsToProcess[vi];
|
|
532
284
|
log(`Variant ${vi}: ${variant.resolution || variant.bandwidth || 'default'}`);
|
|
533
285
|
|
|
534
|
-
// Get segment list
|
|
535
|
-
let segments
|
|
286
|
+
// Get segment list
|
|
287
|
+
let segments;
|
|
536
288
|
if (variant._segments) {
|
|
537
289
|
segments = variant._segments;
|
|
538
|
-
initSegmentUrl = variant._initSegmentUrl;
|
|
539
290
|
} else {
|
|
540
291
|
const mediaResp = await fetch(variant.url);
|
|
541
292
|
if (!mediaResp.ok) throw new Error(`Failed to fetch media playlist: ${mediaResp.status}`);
|
|
542
293
|
const mediaText = await mediaResp.text();
|
|
543
294
|
const parsed = parsePlaylistText(mediaText, variant.url);
|
|
544
295
|
segments = parsed.segments;
|
|
545
|
-
initSegmentUrl = parsed.initSegmentUrl;
|
|
546
296
|
}
|
|
547
297
|
|
|
548
298
|
if (!segments.length) throw new Error('No segments found');
|
|
@@ -557,52 +307,52 @@ export async function clipHls(source, options = {}) {
|
|
|
557
307
|
|
|
558
308
|
log(`Segments: ${overlapping.length} (${firstSeg.startTime.toFixed(1)}s – ${lastSeg.endTime.toFixed(1)}s)`);
|
|
559
309
|
|
|
560
|
-
// Download
|
|
310
|
+
// Download and clip boundary segments
|
|
561
311
|
log('Downloading boundary segments...');
|
|
562
|
-
const
|
|
312
|
+
const firstData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
|
|
313
|
+
const firstParser = parseTs(firstData);
|
|
563
314
|
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
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');
|
|
567
319
|
|
|
568
|
-
|
|
320
|
+
const clipSegments = [];
|
|
569
321
|
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
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
|
+
});
|
|
578
330
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
+
});
|
|
582
339
|
}
|
|
583
340
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
firstSegData, lastSegData,
|
|
600
|
-
overlapping, isSingleSegment,
|
|
601
|
-
startTime, endTime, firstSeg, lastSeg, log,
|
|
602
|
-
});
|
|
603
|
-
initSegment = result.initSegment;
|
|
604
|
-
clipSegments = result.clipSegments;
|
|
605
|
-
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
|
+
}
|
|
606
356
|
}
|
|
607
357
|
|
|
608
358
|
const totalDuration = clipSegments.reduce((sum, s) => sum + s.duration, 0);
|
|
@@ -611,15 +361,13 @@ export async function clipHls(source, options = {}) {
|
|
|
611
361
|
variants.push({
|
|
612
362
|
bandwidth: variant.bandwidth || 0,
|
|
613
363
|
resolution: variant.resolution || null,
|
|
614
|
-
initSegment,
|
|
615
364
|
segments: clipSegments,
|
|
616
365
|
});
|
|
617
366
|
}
|
|
618
367
|
|
|
619
|
-
const clipDuration = endTime - startTime;
|
|
620
368
|
return new HlsClipResult({
|
|
621
369
|
variants,
|
|
622
|
-
duration:
|
|
370
|
+
duration: endTime - startTime,
|
|
623
371
|
startTime,
|
|
624
372
|
endTime,
|
|
625
373
|
});
|