@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/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 with
5
- * CMAF (fMP4) segments. Boundary segments are pre-clipped with edit lists
6
- * for frame-accurate start/end. Middle segments are remuxed on-demand
7
- * from the original CDN source.
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.getInitSegment(0) // fMP4 init segment (Uint8Array)
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, isHlsUrl, parsePlaylistText, toAbsoluteUrl } from './hls.js';
24
- import { TSParser, getCodecInfo } from './parsers/mpegts.js';
25
- import { createInitSegment, createFragment } from './muxers/fmp4.js';
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 ─────────────────────────────────────────────
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
- // ── helpers ───────────────────────────────────────────────
35
-
36
- function isKeyframe(accessUnit) {
37
- for (const nalUnit of accessUnit.nalUnits) {
38
- if ((nalUnit[0] & 0x1F) === 5) return true;
39
- }
40
- return false;
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
- function extractCodecInfo(parser) {
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
- * 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
- });
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
- * Clip a parsed TS segment at the start and/or end.
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 clipSegment(parser, startTime, endTime, options = {}) {
97
- const { qp = 20 } = options;
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
- let targetIdx = keyframeIdx;
113
- for (let i = keyframeIdx; i < videoAUs.length; i++) {
114
- if (videoAUs[i].pts >= startPts) { targetIdx = i; break; }
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
- // Smart rendering is available but currently disabled for HLS output
118
- // because the JS H.264 encoder's CAVLC output has bugs at high resolutions
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;
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
- // Clip audio to match smart-rendered video
132
- const audioEnd = endPts < Infinity ? Math.min(endPts, videoAUs[videoAUs.length - 1].pts + PTS_PER_SECOND) : Infinity;
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
- clippedVideo = videoAUs.slice(keyframeIdx, endIdx);
142
- if (clippedVideo.length === 0) return null;
143
- startOffset = clippedVideo[0].pts;
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
- const lastVideoPts = clippedVideo[clippedVideo.length - 1].pts;
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
- if (clippedVideo.length === 0) return null;
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
- for (const au of clippedVideo) { au.pts -= startOffset; au.dts -= startOffset; }
154
- for (const au of clippedAudio) { au.pts -= startOffset; }
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
- // 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;
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
- videoSamples: clippedVideo,
164
- audioSamples: clippedAudio,
165
- duration: duration / PTS_PER_SECOND,
166
- smartRendered: needsSmartRender,
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; // array of VariantClip
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 CMAF media playlist for a variant.
202
- * @param {number} variantIndex
203
- * @returns {string} m3u8 text
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:7\n';
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
- m3u8 += `segment-${variantIndex}-${i}.m4s\n`;
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 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>}
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
- const resp = await fetch(seg.originalUrl);
256
- if (!resp.ok) throw new Error(`Segment fetch failed: ${resp.status}`);
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);
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 remuxToFragment(parser, segmentIndex + 1, videoBaseTime, audioBaseTime, audioTimescale);
216
+ return null;
274
217
  }
275
218
 
276
219
  /**
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[]>}
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, producing a new HLS stream
492
- * with CMAF (fMP4) segments.
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 (master or media playlist)
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 (default: all)
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 to process
258
+ // Resolve variants
512
259
  let variantsToProcess = [];
513
-
514
260
  if (stream.isMaster) {
515
- const sorted = stream.qualities; // sorted by bandwidth desc
516
- if (quality === 'highest') {
517
- variantsToProcess = [sorted[0]];
518
- } else if (quality === 'lowest') {
519
- variantsToProcess = [sorted[sorted.length - 1]];
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
- // Single media playlist — treat as one variant
528
- variantsToProcess = [{ url: null, bandwidth: 0, resolution: null, _segments: stream.segments, _initSegmentUrl: stream.initSegmentUrl }];
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 for this variant
539
- let segments, initSegmentUrl;
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 first boundary segment to detect format
310
+ // Download and clip boundary segments
565
311
  log('Downloading boundary segments...');
566
- const firstSegData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
312
+ const firstData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
313
+ const firstParser = parseTs(firstData);
567
314
 
568
- // Detect source format: TS or fMP4
569
- const sourceFormat = _detectSegmentFormat(firstSegData);
570
- const isFmp4Source = sourceFormat === 'fmp4';
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
- log(`Source format: ${isFmp4Source ? 'fMP4 (CMAF)' : 'MPEG-TS'}`);
320
+ const clipSegments = [];
573
321
 
574
- // Download fMP4 init segment if needed
575
- let fmp4Init = null;
576
- if (isFmp4Source && initSegmentUrl) {
577
- const initResp = await fetch(initSegmentUrl);
578
- if (initResp.ok) {
579
- fmp4Init = new Uint8Array(await initResp.arrayBuffer());
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
- let lastSegData = null;
584
- if (!isSingleSegment) {
585
- lastSegData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
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
- let initSegment, clipSegments, audioTimescale;
589
-
590
- if (isFmp4Source) {
591
- // ── fMP4 source path ────────────────────────────────
592
- const result = _processFmp4Variant({
593
- firstSegData, lastSegData, fmp4Init,
594
- overlapping, isSingleSegment,
595
- startTime, endTime, firstSeg, lastSeg, log,
596
- });
597
- initSegment = result.initSegment;
598
- clipSegments = result.clipSegments;
599
- audioTimescale = result.audioTimescale;
600
- } else {
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;
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: clipDuration,
370
+ duration: endTime - startTime,
627
371
  startTime,
628
372
  endTime,
629
373
  });