@invintusmedia/tomp4 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hls-clip.js CHANGED
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * HLS-to-HLS Clipper
3
3
  *
4
- * Clips an HLS stream to a time range, producing a new HLS 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 playlist.
5
+ * Middle segments use original CDN URLs (completely untouched).
6
+ * Boundary segments are re-muxed from the keyframe nearest to the
7
+ * requested start/end times.
8
+ *
9
+ * The result includes `prerollDuration` — the time between the actual
10
+ * start (keyframe) and the requested start. The player should seek
11
+ * past this on load for frame-accurate playback:
12
+ *
13
+ * video.currentTime = clip.prerollDuration;
8
14
  *
9
15
  * @module hls-clip
10
16
  *
@@ -14,87 +20,83 @@
14
20
  * endTime: 90,
15
21
  * });
16
22
  *
17
- * clip.masterPlaylist // modified m3u8 text
18
- * 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
+ * clip.prerollDuration // seconds to seek past for frame accuracy
24
+ * clip.getMediaPlaylist(0) // HLS playlist
25
+ * await clip.getSegment(0, 0) // boundary TS data
21
26
  */
22
27
 
23
- import { parseHls, 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 ─────────────────────────────────────────────
28
+ import { parseHls, parsePlaylistText } from './hls.js';
29
+ import { TSParser } from './parsers/mpegts.js';
30
+ import { TSMuxer } from './muxers/mpegts.js';
31
31
 
32
32
  const PTS_PER_SECOND = 90000;
33
33
 
34
34
  // ── helpers ───────────────────────────────────────────────
35
35
 
36
- function isKeyframe(accessUnit) {
37
- for (const nalUnit of accessUnit.nalUnits) {
38
- if ((nalUnit[0] & 0x1F) === 5) return true;
36
+ function parseTs(tsData) {
37
+ const parser = new TSParser();
38
+ parser.parse(tsData);
39
+ parser.finalize();
40
+ return parser;
41
+ }
42
+
43
+ function isKeyframe(au) {
44
+ for (const nal of au.nalUnits) {
45
+ if ((nal[0] & 0x1F) === 5) return true;
39
46
  }
40
47
  return false;
41
48
  }
42
49
 
43
- function extractCodecInfo(parser) {
50
+ function wrapADTS(aacData, sampleRate, channels) {
51
+ const RATES = [96000,88200,64000,48000,44100,32000,24000,22050,16000,12000,11025,8000,7350];
52
+ const sri = RATES.indexOf(sampleRate);
53
+ const len = aacData.length + 7;
54
+ const adts = new Uint8Array(len);
55
+ adts[0] = 0xFF;
56
+ adts[1] = 0xF1;
57
+ adts[2] = (1 << 6) | ((sri < 0 ? 4 : sri) << 2) | ((channels >> 2) & 1);
58
+ adts[3] = ((channels & 3) << 6) | ((len >> 11) & 3);
59
+ adts[4] = (len >> 3) & 0xFF;
60
+ adts[5] = ((len & 7) << 5) | 0x1F;
61
+ adts[6] = 0xFC;
62
+ adts.set(aacData, 7);
63
+ return adts;
64
+ }
65
+
66
+ function muxToTs(videoAUs, audioAUs, audioSampleRate, audioChannels) {
67
+ const muxer = new TSMuxer();
68
+
44
69
  let sps = null, pps = null;
45
- for (const au of 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 };
70
+ for (const au of videoAUs) {
71
+ for (const nal of au.nalUnits) {
72
+ const t = nal[0] & 0x1F;
73
+ if (t === 7 && !sps) sps = nal;
74
+ if (t === 8 && !pps) pps = nal;
51
75
  }
76
+ if (sps && pps) break;
77
+ }
78
+ if (sps && pps) muxer.setSpsPps(sps, pps);
79
+ muxer.setHasAudio(audioAUs.length > 0);
80
+
81
+ const sr = audioSampleRate || 48000;
82
+ const ch = audioChannels || 2;
83
+ for (const au of audioAUs) {
84
+ const hasADTS = au.data.length > 1 && au.data[0] === 0xFF && (au.data[1] & 0xF0) === 0xF0;
85
+ muxer.addAudioSample(hasADTS ? au.data : wrapADTS(au.data, sr, ch), au.pts);
52
86
  }
53
- return { sps, pps };
54
- }
55
87
 
56
- /**
57
- * Parse a TS segment and return the parsed data.
58
- */
59
- function parseTs(tsData) {
60
- const parser = new TSParser();
61
- parser.parse(tsData);
62
- parser.finalize();
63
- return parser;
64
- }
88
+ for (const au of videoAUs) {
89
+ muxer.addVideoNalUnits(au.nalUnits, isKeyframe(au), au.pts, au.dts);
90
+ }
65
91
 
66
- /**
67
- * Remux parsed TS data into an fMP4 fragment.
68
- * Normalizes timestamps to start at the given base times.
69
- */
70
- function remuxToFragment(parser, sequenceNumber, videoBaseTime, audioBaseTime, audioTimescale) {
71
- return createFragment({
72
- videoSamples: parser.videoAccessUnits,
73
- audioSamples: parser.audioAccessUnits,
74
- sequenceNumber,
75
- videoTimescale: PTS_PER_SECOND,
76
- audioTimescale,
77
- videoBaseTime,
78
- audioBaseTime,
79
- audioSampleDuration: 1024,
80
- });
92
+ return muxer.build();
81
93
  }
82
94
 
83
95
  /**
84
- * Clip a parsed TS segment at 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
96
+ * Clip a parsed TS segment. Starts at nearest keyframe, ends at endTime.
97
+ * Returns the preroll (time from keyframe to requested start).
95
98
  */
96
- function clipSegment(parser, startTime, endTime, options = {}) {
97
- const { qp = 20 } = options;
99
+ function clipSegment(parser, startTime, endTime) {
98
100
  const startPts = (startTime !== undefined ? startTime : 0) * PTS_PER_SECOND;
99
101
  const endPts = (endTime !== undefined ? endTime : Infinity) * PTS_PER_SECOND;
100
102
  const videoAUs = parser.videoAccessUnits;
@@ -102,91 +104,70 @@ function clipSegment(parser, startTime, endTime, options = {}) {
102
104
 
103
105
  if (videoAUs.length === 0) return null;
104
106
 
105
- // Check if startTime falls between keyframes (needs smart rendering)
107
+ // Find keyframe at or before startTime
106
108
  let keyframeIdx = 0;
107
109
  for (let i = 0; i < videoAUs.length; i++) {
108
110
  if (videoAUs[i].pts > startPts) break;
109
111
  if (isKeyframe(videoAUs[i])) keyframeIdx = i;
110
112
  }
111
113
 
112
- let targetIdx = keyframeIdx;
114
+ // Find end
115
+ let endIdx = videoAUs.length;
113
116
  for (let i = keyframeIdx; i < videoAUs.length; i++) {
114
- if (videoAUs[i].pts >= startPts) { targetIdx = i; break; }
117
+ if (videoAUs[i].pts >= endPts) { endIdx = i; break; }
115
118
  }
116
119
 
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;
120
+ const clipped = videoAUs.slice(keyframeIdx, endIdx);
121
+ if (clipped.length === 0) return null;
122
122
 
123
- let clippedVideo, clippedAudio, startOffset;
123
+ const keyframePts = clipped[0].pts;
124
+ const prerollPts = Math.max(0, startPts - keyframePts);
124
125
 
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;
126
+ // Audio from keyframe (same timeline as video for A/V sync)
127
+ const lastVideoPts = clipped[clipped.length - 1].pts;
128
+ const audioEndPts = Math.min(endPts, lastVideoPts + PTS_PER_SECOND);
129
+ const clippedAudio = audioAUs.filter(au => au.pts >= keyframePts && au.pts < audioEndPts);
130
130
 
131
- // 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
- }
131
+ // Normalize to PTS 0
132
+ const offset = keyframePts;
133
+ for (const au of clipped) { au.pts -= offset; au.dts -= offset; }
134
+ for (const au of clippedAudio) { au.pts -= offset; }
140
135
 
141
- clippedVideo = videoAUs.slice(keyframeIdx, endIdx);
142
- if (clippedVideo.length === 0) return null;
143
- startOffset = clippedVideo[0].pts;
136
+ const frameDur = clipped.length > 1 ? clipped[1].dts - clipped[0].dts : 3003;
137
+ const duration = (clipped[clipped.length - 1].dts - clipped[0].dts + frameDur) / PTS_PER_SECOND;
144
138
 
145
- 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
- }
149
-
150
- if (clippedVideo.length === 0) return null;
151
-
152
- // Normalize timestamps to start at 0
153
- for (const au of clippedVideo) { au.pts -= startOffset; au.dts -= startOffset; }
154
- for (const au of clippedAudio) { au.pts -= startOffset; }
155
-
156
- // Duration from actual content
157
- const duration = clippedVideo.length > 1
158
- ? clippedVideo[clippedVideo.length - 1].dts - clippedVideo[0].dts +
159
- (clippedVideo.length > 1 ? clippedVideo[1].dts - clippedVideo[0].dts : 3003)
160
- : 3003;
139
+ const tsData = muxToTs(clipped, clippedAudio, parser.audioSampleRate, parser.audioChannels);
161
140
 
162
141
  return {
163
- videoSamples: clippedVideo,
164
- audioSamples: clippedAudio,
165
- duration: duration / PTS_PER_SECOND,
166
- smartRendered: needsSmartRender,
142
+ data: tsData,
143
+ duration,
144
+ preroll: prerollPts / PTS_PER_SECOND,
167
145
  };
168
146
  }
169
147
 
170
148
  // ── HlsClipResult ─────────────────────────────────────────
171
149
 
172
150
  class HlsClipResult {
173
- constructor({ variants, duration, startTime, endTime }) {
174
- this._variants = variants; // array of VariantClip
151
+ /**
152
+ * @param {object} opts
153
+ * @param {number} opts.prerollDuration - Seconds to seek past for frame accuracy
154
+ */
155
+ constructor({ variants, duration, startTime, endTime, prerollDuration }) {
156
+ this._variants = variants;
175
157
  this.duration = duration;
176
158
  this.startTime = startTime;
177
159
  this.endTime = endTime;
160
+ /** Seconds between the keyframe start and the requested startTime.
161
+ * The player should set video.currentTime = prerollDuration on load. */
162
+ this.prerollDuration = prerollDuration;
178
163
  }
179
164
 
180
- /** Number of quality variants */
181
165
  get variantCount() {
182
166
  return this._variants.length;
183
167
  }
184
168
 
185
- /** Master playlist m3u8 text */
186
169
  get masterPlaylist() {
187
- if (this._variants.length === 1) {
188
- return this.getMediaPlaylist(0);
189
- }
170
+ if (this._variants.length === 1) return this.getMediaPlaylist(0);
190
171
  let m3u8 = '#EXTM3U\n';
191
172
  for (let i = 0; i < this._variants.length; i++) {
192
173
  const v = this._variants[i];
@@ -197,11 +178,6 @@ class HlsClipResult {
197
178
  return m3u8;
198
179
  }
199
180
 
200
- /**
201
- * Get CMAF media playlist for a variant.
202
- * @param {number} variantIndex
203
- * @returns {string} m3u8 text
204
- */
205
181
  getMediaPlaylist(variantIndex = 0) {
206
182
  const variant = this._variants[variantIndex];
207
183
  if (!variant) throw new Error(`Variant ${variantIndex} not found`);
@@ -209,295 +185,45 @@ class HlsClipResult {
209
185
  const maxDur = Math.max(...variant.segments.map(s => s.duration));
210
186
 
211
187
  let m3u8 = '#EXTM3U\n';
212
- m3u8 += '#EXT-X-VERSION:7\n';
188
+ m3u8 += '#EXT-X-VERSION:3\n';
213
189
  m3u8 += `#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}\n`;
214
190
  m3u8 += '#EXT-X-PLAYLIST-TYPE:VOD\n';
215
191
  m3u8 += '#EXT-X-MEDIA-SEQUENCE:0\n';
216
- m3u8 += `#EXT-X-MAP:URI="init-${variantIndex}.m4s"\n`;
217
192
 
218
193
  for (let i = 0; i < variant.segments.length; i++) {
219
194
  const seg = variant.segments[i];
220
195
  m3u8 += `#EXTINF:${seg.duration.toFixed(6)},\n`;
221
- m3u8 += `segment-${variantIndex}-${i}.m4s\n`;
196
+ m3u8 += seg.originalUrl || `segment-${variantIndex}-${i}.ts\n`;
222
197
  }
223
198
  m3u8 += '#EXT-X-ENDLIST\n';
224
199
  return m3u8;
225
200
  }
226
201
 
227
- /**
228
- * Get the CMAF init segment for a variant.
229
- * @param {number} variantIndex
230
- * @returns {Uint8Array}
231
- */
232
- getInitSegment(variantIndex = 0) {
233
- return this._variants[variantIndex]?.initSegment ?? null;
234
- }
235
-
236
- /**
237
- * Get a media segment as fMP4 data.
238
- * Boundary segments are returned from memory (pre-clipped).
239
- * Middle segments are fetched from CDN and remuxed on-demand.
240
- *
241
- * @param {number} variantIndex
242
- * @param {number} segmentIndex
243
- * @returns {Promise<Uint8Array>}
244
- */
245
202
  async getSegment(variantIndex = 0, segmentIndex = 0) {
246
203
  const variant = this._variants[variantIndex];
247
204
  if (!variant) throw new Error(`Variant ${variantIndex} not found`);
248
205
  const seg = variant.segments[segmentIndex];
249
206
  if (!seg) throw new Error(`Segment ${segmentIndex} not found`);
250
-
251
- // Pre-clipped boundary segments are already in memory
252
207
  if (seg.data) return seg.data;
253
-
254
- // 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);
272
-
273
- return remuxToFragment(parser, segmentIndex + 1, videoBaseTime, audioBaseTime, audioTimescale);
274
- }
275
-
276
- /**
277
- * Get all segment data for a variant (fetches middle segments).
278
- * Useful for downloading the full clip.
279
- * @param {number} variantIndex
280
- * @returns {Promise<Uint8Array[]>}
281
- */
282
- async getAllSegments(variantIndex = 0) {
283
- const variant = this._variants[variantIndex];
284
- const results = [];
285
- for (let i = 0; i < variant.segments.length; i++) {
286
- results.push(await this.getSegment(variantIndex, i));
208
+ if (seg.originalUrl) {
209
+ const resp = await fetch(seg.originalUrl);
210
+ if (!resp.ok) throw new Error(`Segment fetch failed: ${resp.status}`);
211
+ return new Uint8Array(await resp.arrayBuffer());
287
212
  }
288
- return results;
289
- }
290
- }
291
-
292
- // ── format detection ──────────────────────────────────────
293
-
294
- function _detectSegmentFormat(data) {
295
- if (data.length < 8) return 'unknown';
296
- // Check for TS sync byte
297
- if (data[0] === 0x47) return 'ts';
298
- for (let i = 0; i < Math.min(188, data.length); i++) {
299
- if (data[i] === 0x47 && i + 188 < data.length && data[i + 188] === 0x47) return 'ts';
213
+ return null;
300
214
  }
301
- // Check for fMP4 (moof, styp, or ftyp box)
302
- const type = String.fromCharCode(data[4], data[5], data[6], data[7]);
303
- if (['moof', 'styp', 'ftyp', 'mdat'].includes(type)) return 'fmp4';
304
- return 'unknown';
305
215
  }
306
216
 
307
- // ── 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
- // ── main function ─────────────────────────────────────────
217
+ // ── main ──────────────────────────────────────────────────
489
218
 
490
219
  /**
491
- * Clip an HLS stream to a time range, producing a new HLS stream
492
- * with CMAF (fMP4) segments.
220
+ * Clip an HLS stream to a time range.
221
+ *
222
+ * Boundary segments start at the nearest keyframe. The result includes
223
+ * `prerollDuration` — the player should seek to this time on load for
224
+ * frame-accurate start.
493
225
  *
494
- * @param {string} source - HLS URL (master or media playlist)
495
- * @param {object} options
496
- * @param {number} options.startTime - Start time in seconds
497
- * @param {number} options.endTime - End time in seconds
498
- * @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth (default: all)
499
- * @param {function} [options.onProgress] - Progress callback
500
- * @returns {Promise<HlsClipResult>}
226
+ * Middle segments use original CDN URLs (completely untouched).
501
227
  */
502
228
  export async function clipHls(source, options = {}) {
503
229
  const { startTime, endTime, quality, onProgress: log = () => {} } = options;
@@ -508,50 +234,38 @@ export async function clipHls(source, options = {}) {
508
234
  log('Parsing HLS playlist...');
509
235
  const stream = typeof source === 'string' ? await parseHls(source, { onProgress: log }) : source;
510
236
 
511
- // Resolve variants to process
512
237
  let variantsToProcess = [];
513
-
514
238
  if (stream.isMaster) {
515
- const sorted = stream.qualities; // 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
- }
239
+ const sorted = stream.qualities;
240
+ if (quality === 'highest') variantsToProcess = [sorted[0]];
241
+ else if (quality === 'lowest') variantsToProcess = [sorted[sorted.length - 1]];
242
+ else if (typeof quality === 'number') { stream.select(quality); variantsToProcess = [stream.selected]; }
243
+ else variantsToProcess = sorted;
526
244
  } else {
527
- // Single media playlist treat as one variant
528
- variantsToProcess = [{ url: null, bandwidth: 0, resolution: null, _segments: stream.segments, _initSegmentUrl: stream.initSegmentUrl }];
245
+ variantsToProcess = [{ url: null, bandwidth: 0, resolution: null, _segments: stream.segments }];
529
246
  }
530
247
 
531
248
  log(`Processing ${variantsToProcess.length} variant(s)...`);
532
249
 
533
250
  const variants = [];
251
+ let prerollDuration = 0;
252
+
534
253
  for (let vi = 0; vi < variantsToProcess.length; vi++) {
535
254
  const variant = variantsToProcess[vi];
536
255
  log(`Variant ${vi}: ${variant.resolution || variant.bandwidth || 'default'}`);
537
256
 
538
- // Get segment list for this variant
539
- let segments, initSegmentUrl;
257
+ let segments;
540
258
  if (variant._segments) {
541
259
  segments = variant._segments;
542
- initSegmentUrl = variant._initSegmentUrl;
543
260
  } else {
544
- const mediaResp = await fetch(variant.url);
545
- if (!mediaResp.ok) throw new Error(`Failed to fetch media playlist: ${mediaResp.status}`);
546
- const mediaText = await mediaResp.text();
547
- const parsed = parsePlaylistText(mediaText, variant.url);
261
+ const resp = await fetch(variant.url);
262
+ if (!resp.ok) throw new Error(`Failed to fetch media playlist: ${resp.status}`);
263
+ const parsed = parsePlaylistText(await resp.text(), variant.url);
548
264
  segments = parsed.segments;
549
- initSegmentUrl = parsed.initSegmentUrl;
550
265
  }
551
266
 
552
267
  if (!segments.length) throw new Error('No segments found');
553
268
 
554
- // Find overlapping segments
555
269
  const overlapping = segments.filter(seg => seg.endTime > startTime && seg.startTime < endTime);
556
270
  if (!overlapping.length) throw new Error('No segments overlap the clip range');
557
271
 
@@ -561,71 +275,66 @@ export async function clipHls(source, options = {}) {
561
275
 
562
276
  log(`Segments: ${overlapping.length} (${firstSeg.startTime.toFixed(1)}s – ${lastSeg.endTime.toFixed(1)}s)`);
563
277
 
564
- // Download first boundary segment to detect format
278
+ // Download and clip first boundary segment
565
279
  log('Downloading boundary segments...');
566
- const firstSegData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
280
+ const firstData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
281
+ const firstParser = parseTs(firstData);
282
+ const firstRelStart = startTime - firstSeg.startTime;
283
+ const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
284
+ const firstClipped = clipSegment(firstParser, firstRelStart, firstRelEnd);
285
+ if (!firstClipped) throw new Error('First segment clip produced no samples');
567
286
 
568
- // Detect source format: TS or fMP4
569
- const sourceFormat = _detectSegmentFormat(firstSegData);
570
- const isFmp4Source = sourceFormat === 'fmp4';
287
+ // Preroll from the first variant (all variants have similar GOP structure)
288
+ if (vi === 0) prerollDuration = firstClipped.preroll;
571
289
 
572
- log(`Source format: ${isFmp4Source ? 'fMP4 (CMAF)' : 'MPEG-TS'}`);
290
+ const clipSegments = [];
573
291
 
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
- }
292
+ clipSegments.push({
293
+ duration: firstClipped.duration,
294
+ data: firstClipped.data,
295
+ originalUrl: null,
296
+ });
582
297
 
583
- let lastSegData = null;
584
- if (!isSingleSegment) {
585
- lastSegData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
298
+ // Middle segments: original CDN URLs
299
+ for (let i = 1; i < overlapping.length - 1; i++) {
300
+ clipSegments.push({
301
+ duration: overlapping[i].duration,
302
+ data: null,
303
+ originalUrl: overlapping[i].url,
304
+ });
586
305
  }
587
306
 
588
- 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;
307
+ // Last boundary segment
308
+ if (!isSingleSegment) {
309
+ const lastData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
310
+ const lastParser = parseTs(lastData);
311
+ const lastRelEnd = endTime - lastSeg.startTime;
312
+ const lastClipped = clipSegment(lastParser, undefined, lastRelEnd);
313
+ if (lastClipped && lastClipped.data) {
314
+ clipSegments.push({
315
+ duration: lastClipped.duration,
316
+ data: lastClipped.data,
317
+ originalUrl: null,
318
+ });
319
+ }
610
320
  }
611
321
 
612
322
  const totalDuration = clipSegments.reduce((sum, s) => sum + s.duration, 0);
613
- log(`Clip ready: ${totalDuration.toFixed(2)}s (${clipSegments.length} segments)`);
323
+ log(`Clip ready: ${totalDuration.toFixed(2)}s (${clipSegments.length} segments, preroll: ${firstClipped.preroll.toFixed(2)}s)`);
614
324
 
615
325
  variants.push({
616
326
  bandwidth: variant.bandwidth || 0,
617
327
  resolution: variant.resolution || null,
618
- initSegment,
619
328
  segments: clipSegments,
620
329
  });
621
330
  }
622
331
 
623
- const clipDuration = endTime - startTime;
624
332
  return new HlsClipResult({
625
333
  variants,
626
- duration: clipDuration,
334
+ duration: endTime - startTime,
627
335
  startTime,
628
336
  endTime,
337
+ prerollDuration,
629
338
  });
630
339
  }
631
340