@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/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,103 +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
- const needsSmartRender = startTime !== undefined && targetIdx > keyframeIdx;
118
-
119
- let clippedVideo, clippedAudio, startOffset;
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
- // Clip audio to match smart-rendered video
128
- const audioEnd = endPts < Infinity ? Math.min(endPts, videoAUs[videoAUs.length - 1].pts + PTS_PER_SECOND) : Infinity;
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
- clippedVideo = videoAUs.slice(keyframeIdx, endIdx);
138
- if (clippedVideo.length === 0) return null;
139
- 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 });
140
110
 
141
- const lastVideoPts = clippedVideo[clippedVideo.length - 1].pts;
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
- 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;
147
120
 
148
121
  // Normalize timestamps to start at 0
149
- for (const au of clippedVideo) { au.pts -= startOffset; au.dts -= startOffset; }
150
- 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; }
151
125
 
152
- // Duration from actual content
153
- const duration = clippedVideo.length > 1
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
- videoSamples: clippedVideo,
160
- audioSamples: clippedAudio,
161
- duration: duration / PTS_PER_SECOND,
162
- smartRendered: needsSmartRender,
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; // array of VariantClip
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 CMAF media playlist for a variant.
198
- * @param {number} variantIndex
199
- * @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.
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:7\n';
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
- 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
+ }
218
191
  }
219
192
  m3u8 += '#EXT-X-ENDLIST\n';
220
193
  return m3u8;
221
194
  }
222
195
 
223
196
  /**
224
- * Get the CMAF init segment for a variant.
225
- * @param {number} variantIndex
226
- * @returns {Uint8Array}
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
- const resp = await fetch(seg.originalUrl);
252
- if (!resp.ok) throw new Error(`Segment fetch failed: ${resp.status}`);
253
- const rawData = new Uint8Array(await resp.arrayBuffer());
254
-
255
- // fMP4 segments pass through unchanged (already correct format)
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 remuxToFragment(parser, segmentIndex + 1, videoBaseTime, audioBaseTime, audioTimescale);
216
+ return null;
270
217
  }
271
218
 
272
219
  /**
273
- * Get all segment data for a variant (fetches middle segments).
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, producing a new HLS stream
488
- * 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)
489
240
  *
490
- * @param {string} source - HLS URL (master or media playlist)
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 (default: all)
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 to process
258
+ // Resolve variants
508
259
  let variantsToProcess = [];
509
-
510
260
  if (stream.isMaster) {
511
- const sorted = stream.qualities; // sorted by bandwidth desc
512
- if (quality === 'highest') {
513
- variantsToProcess = [sorted[0]];
514
- } else if (quality === 'lowest') {
515
- variantsToProcess = [sorted[sorted.length - 1]];
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
- // Single media playlist — treat as one variant
524
- 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
+ }];
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 for this variant
535
- let segments, initSegmentUrl;
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 first boundary segment to detect format
310
+ // Download and clip boundary segments
561
311
  log('Downloading boundary segments...');
562
- 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);
563
314
 
564
- // Detect source format: TS or fMP4
565
- const sourceFormat = _detectSegmentFormat(firstSegData);
566
- 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');
567
319
 
568
- log(`Source format: ${isFmp4Source ? 'fMP4 (CMAF)' : 'MPEG-TS'}`);
320
+ const clipSegments = [];
569
321
 
570
- // Download fMP4 init segment if needed
571
- let fmp4Init = null;
572
- if (isFmp4Source && initSegmentUrl) {
573
- const initResp = await fetch(initSegmentUrl);
574
- if (initResp.ok) {
575
- fmp4Init = new Uint8Array(await initResp.arrayBuffer());
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
- let lastSegData = null;
580
- if (!isSingleSegment) {
581
- 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
+ });
582
339
  }
583
340
 
584
- let initSegment, clipSegments, audioTimescale;
585
-
586
- if (isFmp4Source) {
587
- // ── fMP4 source path ────────────────────────────────
588
- const result = _processFmp4Variant({
589
- firstSegData, lastSegData, fmp4Init,
590
- overlapping, isSingleSegment,
591
- startTime, endTime, firstSeg, lastSeg, log,
592
- });
593
- initSegment = result.initSegment;
594
- clipSegments = result.clipSegments;
595
- audioTimescale = result.audioTimescale;
596
- } else {
597
- // ── TS source path (existing smart-render pipeline) ──
598
- const result = _processTsVariant({
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: clipDuration,
370
+ duration: endTime - startTime,
623
371
  startTime,
624
372
  endTime,
625
373
  });