@invintusmedia/tomp4 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tomp4.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * toMp4.js v1.2.1
2
+ * toMp4.js v1.3.0
3
3
  * Convert MPEG-TS and fMP4 to standard MP4
4
4
  * https://github.com/TVWIT/toMp4.js
5
5
  * MIT License
@@ -111,24 +111,23 @@
111
111
  // This is the time the decoder needs to process but player shouldn't display
112
112
  const prerollPts = Math.max(0, startPts - keyframePts);
113
113
 
114
- // Clip audio to the REQUESTED time range (not from keyframe)
115
- // Audio doesn't need keyframe pre-roll
116
- const audioStartPts = startPts;
117
- const audioEndPts = Math.min(endPts, lastFramePts + 90000); // Include audio slightly past last video
114
+ // Clip audio from KEYFRAME time (same as video) so A/V stays in sync
115
+ // even on players that ignore edit lists. The edit list will skip the
116
+ // audio preroll on compliant players, just like it does for video.
117
+ const audioStartPts = keyframePts;
118
+ const audioEndPts = Math.min(endPts, lastFramePts + 90000);
118
119
  const clippedAudio = audioAUs.filter(au => au.pts >= audioStartPts && au.pts < audioEndPts);
119
120
 
120
- // Normalize video timestamps so keyframe starts at 0
121
+ // Normalize both video and audio to the same base (keyframe PTS)
122
+ // so they share a common timeline regardless of edit list support
121
123
  const offset = keyframePts;
122
124
  for (const au of clippedVideo) {
123
125
  au.pts -= offset;
124
126
  au.dts -= offset;
125
127
  }
126
128
 
127
- // Normalize audio timestamps so it starts at 0 (matching video playback start after preroll)
128
- // Audio doesn't have preroll, so it should start at PTS 0 to sync with video after edit list
129
- const audioOffset = audioStartPts; // Use requested start, not keyframe
130
129
  for (const au of clippedAudio) {
131
- au.pts -= audioOffset;
130
+ au.pts -= offset;
132
131
  }
133
132
 
134
133
  return {
@@ -580,23 +579,52 @@
580
579
  }
581
580
  }
582
581
 
582
+ // First pass: clip video to determine preroll duration
583
583
  const clipped = new Map();
584
+ let videoPrerollSec = 0;
585
+
586
+ if (videoTrackId !== null) {
587
+ const vTrack = tracks.get(videoTrackId);
588
+ if (vTrack && vTrack.samples.length) {
589
+ const startTick = Math.round(startSec * vTrack.timescale);
590
+ const endTick = Number.isFinite(endSec) ? Math.round(endSec * vTrack.timescale) : Infinity;
591
+ const clip = clipVideoSamples(vTrack.samples, startTick, endTick);
592
+
593
+ if (clip.samples.length) {
594
+ videoPrerollSec = clip.mediaTime / vTrack.timescale;
595
+ clipped.set(videoTrackId, {
596
+ ...vTrack,
597
+ samples: clip.samples,
598
+ mediaTime: clip.mediaTime,
599
+ playbackDuration: clip.playbackDuration,
600
+ chunkOffsets: [],
601
+ });
602
+ }
603
+ }
604
+ }
605
+
606
+ // Second pass: clip non-video tracks, including audio from the video's
607
+ // decode start (keyframe) so A/V stays in sync without edit lists
584
608
  for (const [trackId, track] of tracks) {
585
- if (!track.samples.length) continue;
609
+ if (!track.samples.length || trackId === videoTrackId) continue;
586
610
 
587
- const startTick = Math.round(startSec * track.timescale);
611
+ const adjustedStartSec = Math.max(0, startSec - videoPrerollSec);
612
+ const startTick = Math.round(adjustedStartSec * track.timescale);
588
613
  const endTick = Number.isFinite(endSec) ? Math.round(endSec * track.timescale) : Infinity;
589
- const clip = trackId === videoTrackId
590
- ? clipVideoSamples(track.samples, startTick, endTick)
591
- : clipNonVideoSamples(track.samples, startTick, endTick);
614
+ const clip = clipNonVideoSamples(track.samples, startTick, endTick);
592
615
 
593
616
  if (!clip.samples.length) continue;
594
617
 
618
+ // Audio preroll matches video preroll so both tracks share the same timeline
619
+ const audioPreroll = Math.round(videoPrerollSec * track.timescale);
620
+ const totalDur = sumSampleDurations(clip.samples);
621
+ const playbackDuration = Math.max(0, totalDur - audioPreroll);
622
+
595
623
  clipped.set(trackId, {
596
624
  ...track,
597
625
  samples: clip.samples,
598
- mediaTime: clip.mediaTime,
599
- playbackDuration: clip.playbackDuration,
626
+ mediaTime: audioPreroll,
627
+ playbackDuration,
600
628
  chunkOffsets: [],
601
629
  });
602
630
  }
@@ -1041,6 +1069,16 @@
1041
1069
  * @param {number} [options.endTime] - Clip end time (seconds)
1042
1070
  * @returns {Uint8Array} Standard MP4 data
1043
1071
  */
1072
+ // Shared rebuild functions — also used by mp4-clip.js for standard MP4 clipping
1073
+ {
1074
+ applyClipToTracks,
1075
+ rebuildMdatContent,
1076
+ calculateMovieDuration,
1077
+ rebuildTrak,
1078
+ rebuildMvhd,
1079
+ updateStcoOffsets,
1080
+ };
1081
+
1044
1082
  function convertFmp4ToMp4(fmp4Data, options = {}) {
1045
1083
  const boxes = parseBoxes(fmp4Data);
1046
1084
  const ftyp = findBox(boxes, 'ftyp');
@@ -1148,7 +1186,7 @@
1148
1186
  toMp4.isMpegTs = isMpegTs;
1149
1187
  toMp4.isFmp4 = isFmp4;
1150
1188
  toMp4.isStandardMp4 = isStandardMp4;
1151
- toMp4.version = '1.2.1';
1189
+ toMp4.version = '1.3.0';
1152
1190
 
1153
1191
  return toMp4;
1154
1192
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invintusmedia/tomp4",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Convert MPEG-TS, fMP4, and HLS streams to MP4 with clipping support - pure JavaScript, zero dependencies",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -20,11 +20,13 @@
20
20
  "scripts": {
21
21
  "build": "node build.js",
22
22
  "dev": "npx serve . -p 3000",
23
- "test": "npm run test:hls-map && npm run test:thumbnail && npm run test:clip && npm run test:mp4",
23
+ "test": "npm run test:hls-map && npm run test:thumbnail && npm run test:clip && npm run test:mp4 && npm run test:av-sync && npm run test:mp4-clip",
24
24
  "test:hls-map": "node tests/hls-map.test.js",
25
25
  "test:thumbnail": "node tests/thumbnail.node.test.js",
26
26
  "test:clip": "node tests/clip.test.js",
27
27
  "test:fmp4-clip": "node tests/fmp4-clip.test.js",
28
+ "test:av-sync": "node tests/av-sync.test.js",
29
+ "test:mp4-clip": "node tests/mp4-clip.test.js",
28
30
  "test:mp4": "node tests/mp4-parser.test.js",
29
31
  "test:all": "npm run test",
30
32
  "release": "npm test && npm run build && git add -A && git commit -m \"v$(node -p \"require('./package.json').version\")\" && git tag v$(node -p \"require('./package.json').version\") && git push && git push --tags",
@@ -257,23 +257,52 @@ function applyClipToTracks(tracks, options = {}) {
257
257
  }
258
258
  }
259
259
 
260
+ // First pass: clip video to determine preroll duration
260
261
  const clipped = new Map();
262
+ let videoPrerollSec = 0;
263
+
264
+ if (videoTrackId !== null) {
265
+ const vTrack = tracks.get(videoTrackId);
266
+ if (vTrack && vTrack.samples.length) {
267
+ const startTick = Math.round(startSec * vTrack.timescale);
268
+ const endTick = Number.isFinite(endSec) ? Math.round(endSec * vTrack.timescale) : Infinity;
269
+ const clip = clipVideoSamples(vTrack.samples, startTick, endTick);
270
+
271
+ if (clip.samples.length) {
272
+ videoPrerollSec = clip.mediaTime / vTrack.timescale;
273
+ clipped.set(videoTrackId, {
274
+ ...vTrack,
275
+ samples: clip.samples,
276
+ mediaTime: clip.mediaTime,
277
+ playbackDuration: clip.playbackDuration,
278
+ chunkOffsets: [],
279
+ });
280
+ }
281
+ }
282
+ }
283
+
284
+ // Second pass: clip non-video tracks, including audio from the video's
285
+ // decode start (keyframe) so A/V stays in sync without edit lists
261
286
  for (const [trackId, track] of tracks) {
262
- if (!track.samples.length) continue;
287
+ if (!track.samples.length || trackId === videoTrackId) continue;
263
288
 
264
- const startTick = Math.round(startSec * track.timescale);
289
+ const adjustedStartSec = Math.max(0, startSec - videoPrerollSec);
290
+ const startTick = Math.round(adjustedStartSec * track.timescale);
265
291
  const endTick = Number.isFinite(endSec) ? Math.round(endSec * track.timescale) : Infinity;
266
- const clip = trackId === videoTrackId
267
- ? clipVideoSamples(track.samples, startTick, endTick)
268
- : clipNonVideoSamples(track.samples, startTick, endTick);
292
+ const clip = clipNonVideoSamples(track.samples, startTick, endTick);
269
293
 
270
294
  if (!clip.samples.length) continue;
271
295
 
296
+ // Audio preroll matches video preroll so both tracks share the same timeline
297
+ const audioPreroll = Math.round(videoPrerollSec * track.timescale);
298
+ const totalDur = sumSampleDurations(clip.samples);
299
+ const playbackDuration = Math.max(0, totalDur - audioPreroll);
300
+
272
301
  clipped.set(trackId, {
273
302
  ...track,
274
303
  samples: clip.samples,
275
- mediaTime: clip.mediaTime,
276
- playbackDuration: clip.playbackDuration,
304
+ mediaTime: audioPreroll,
305
+ playbackDuration,
277
306
  chunkOffsets: [],
278
307
  });
279
308
  }
@@ -718,6 +747,16 @@ function updateStcoOffsets(output, ftypSize, moovSize) {
718
747
  * @param {number} [options.endTime] - Clip end time (seconds)
719
748
  * @returns {Uint8Array} Standard MP4 data
720
749
  */
750
+ // Shared rebuild functions — also used by mp4-clip.js for standard MP4 clipping
751
+ export {
752
+ applyClipToTracks,
753
+ rebuildMdatContent,
754
+ calculateMovieDuration,
755
+ rebuildTrak,
756
+ rebuildMvhd,
757
+ updateStcoOffsets,
758
+ };
759
+
721
760
  export function convertFmp4ToMp4(fmp4Data, options = {}) {
722
761
  const boxes = parseBoxes(fmp4Data);
723
762
  const ftyp = findBox(boxes, 'ftyp');
@@ -0,0 +1,459 @@
1
+ /**
2
+ * HLS-to-HLS Clipper
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.
8
+ *
9
+ * @module hls-clip
10
+ *
11
+ * @example
12
+ * const clip = await clipHls('https://example.com/stream.m3u8', {
13
+ * startTime: 30,
14
+ * endTime: 90,
15
+ * });
16
+ *
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)
21
+ */
22
+
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
+
27
+ // ── constants ─────────────────────────────────────────────
28
+
29
+ const PTS_PER_SECOND = 90000;
30
+
31
+ // ── helpers ───────────────────────────────────────────────
32
+
33
+ function isKeyframe(accessUnit) {
34
+ for (const nalUnit of accessUnit.nalUnits) {
35
+ if ((nalUnit[0] & 0x1F) === 5) return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ function extractCodecInfo(parser) {
41
+ let sps = null, pps = null;
42
+ for (const au of parser.videoAccessUnits) {
43
+ for (const nalUnit of au.nalUnits) {
44
+ const nalType = nalUnit[0] & 0x1F;
45
+ if (nalType === 7 && !sps) sps = nalUnit;
46
+ if (nalType === 8 && !pps) pps = nalUnit;
47
+ if (sps && pps) return { sps, pps };
48
+ }
49
+ }
50
+ return { sps, pps };
51
+ }
52
+
53
+ /**
54
+ * Parse a TS segment and return the parsed data.
55
+ */
56
+ function parseTs(tsData) {
57
+ const parser = new TSParser();
58
+ parser.parse(tsData);
59
+ parser.finalize();
60
+ return parser;
61
+ }
62
+
63
+ /**
64
+ * Remux parsed TS data into an fMP4 fragment.
65
+ * Normalizes timestamps to start at the given base times.
66
+ */
67
+ function remuxToFragment(parser, sequenceNumber, videoBaseTime, audioBaseTime, audioTimescale) {
68
+ return createFragment({
69
+ videoSamples: parser.videoAccessUnits,
70
+ audioSamples: parser.audioAccessUnits,
71
+ sequenceNumber,
72
+ videoTimescale: PTS_PER_SECOND,
73
+ audioTimescale,
74
+ videoBaseTime,
75
+ audioBaseTime,
76
+ audioSampleDuration: 1024,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Clip a parsed TS segment at the start (frame-accurate with preroll)
82
+ * and/or at the end. Returns clipped access units + timing metadata.
83
+ */
84
+ function clipSegment(parser, startTime, endTime) {
85
+ const startPts = (startTime !== undefined ? startTime : 0) * PTS_PER_SECOND;
86
+ const endPts = (endTime !== undefined ? endTime : Infinity) * PTS_PER_SECOND;
87
+ const videoAUs = parser.videoAccessUnits;
88
+ const audioAUs = parser.audioAccessUnits;
89
+
90
+ // Find keyframe at or before startTime
91
+ let keyframeIdx = 0;
92
+ for (let i = 0; i < videoAUs.length; i++) {
93
+ if (videoAUs[i].pts > startPts) break;
94
+ if (isKeyframe(videoAUs[i])) keyframeIdx = i;
95
+ }
96
+
97
+ // Find end index
98
+ let endIdx = videoAUs.length;
99
+ for (let i = keyframeIdx; i < videoAUs.length; i++) {
100
+ if (videoAUs[i].pts >= endPts) { endIdx = i; break; }
101
+ }
102
+
103
+ const clippedVideo = videoAUs.slice(keyframeIdx, endIdx);
104
+ if (clippedVideo.length === 0) return null;
105
+
106
+ const keyframePts = clippedVideo[0].pts;
107
+ const prerollPts = Math.max(0, startPts - keyframePts);
108
+
109
+ // Clip audio from keyframe (for A/V sync, matching the fix in ts-to-mp4.js)
110
+ const lastVideoPts = clippedVideo[clippedVideo.length - 1].pts;
111
+ const audioEndPts = Math.min(endPts, lastVideoPts + PTS_PER_SECOND);
112
+ const clippedAudio = audioAUs.filter(au => au.pts >= keyframePts && au.pts < audioEndPts);
113
+
114
+ // Normalize timestamps to start at 0
115
+ const offset = keyframePts;
116
+ for (const au of clippedVideo) { au.pts -= offset; au.dts -= offset; }
117
+ for (const au of clippedAudio) { au.pts -= offset; }
118
+
119
+ // Calculate durations
120
+ const videoDuration = clippedVideo.length > 1
121
+ ? clippedVideo[clippedVideo.length - 1].dts - clippedVideo[0].dts +
122
+ (clippedVideo[1].dts - clippedVideo[0].dts) // add one frame for last
123
+ : 3003;
124
+ const playbackDuration = (videoDuration - prerollPts) / PTS_PER_SECOND;
125
+
126
+ return {
127
+ videoSamples: clippedVideo,
128
+ audioSamples: clippedAudio,
129
+ prerollPts,
130
+ playbackDuration: Math.max(0, playbackDuration),
131
+ mediaDuration: videoDuration / PTS_PER_SECOND,
132
+ };
133
+ }
134
+
135
+ // ── HlsClipResult ─────────────────────────────────────────
136
+
137
+ class HlsClipResult {
138
+ constructor({ variants, duration, startTime, endTime }) {
139
+ this._variants = variants; // array of VariantClip
140
+ this.duration = duration;
141
+ this.startTime = startTime;
142
+ this.endTime = endTime;
143
+ }
144
+
145
+ /** Number of quality variants */
146
+ get variantCount() {
147
+ return this._variants.length;
148
+ }
149
+
150
+ /** Master playlist m3u8 text */
151
+ get masterPlaylist() {
152
+ if (this._variants.length === 1) {
153
+ return this.getMediaPlaylist(0);
154
+ }
155
+ let m3u8 = '#EXTM3U\n';
156
+ for (let i = 0; i < this._variants.length; i++) {
157
+ const v = this._variants[i];
158
+ const res = v.resolution ? `,RESOLUTION=${v.resolution}` : '';
159
+ m3u8 += `#EXT-X-STREAM-INF:BANDWIDTH=${v.bandwidth}${res}\n`;
160
+ m3u8 += `variant-${i}.m3u8\n`;
161
+ }
162
+ return m3u8;
163
+ }
164
+
165
+ /**
166
+ * Get CMAF media playlist for a variant.
167
+ * @param {number} variantIndex
168
+ * @returns {string} m3u8 text
169
+ */
170
+ getMediaPlaylist(variantIndex = 0) {
171
+ const variant = this._variants[variantIndex];
172
+ if (!variant) throw new Error(`Variant ${variantIndex} not found`);
173
+
174
+ const maxDur = Math.max(...variant.segments.map(s => s.duration));
175
+
176
+ let m3u8 = '#EXTM3U\n';
177
+ m3u8 += '#EXT-X-VERSION:7\n';
178
+ m3u8 += `#EXT-X-TARGETDURATION:${Math.ceil(maxDur)}\n`;
179
+ m3u8 += '#EXT-X-PLAYLIST-TYPE:VOD\n';
180
+ m3u8 += '#EXT-X-MEDIA-SEQUENCE:0\n';
181
+ m3u8 += `#EXT-X-MAP:URI="init-${variantIndex}.m4s"\n`;
182
+
183
+ for (let i = 0; i < variant.segments.length; i++) {
184
+ const seg = variant.segments[i];
185
+ m3u8 += `#EXTINF:${seg.duration.toFixed(6)},\n`;
186
+ m3u8 += `segment-${variantIndex}-${i}.m4s\n`;
187
+ }
188
+ m3u8 += '#EXT-X-ENDLIST\n';
189
+ return m3u8;
190
+ }
191
+
192
+ /**
193
+ * Get the CMAF init segment for a variant.
194
+ * @param {number} variantIndex
195
+ * @returns {Uint8Array}
196
+ */
197
+ getInitSegment(variantIndex = 0) {
198
+ return this._variants[variantIndex]?.initSegment ?? null;
199
+ }
200
+
201
+ /**
202
+ * Get a media segment as fMP4 data.
203
+ * Boundary segments are returned from memory (pre-clipped).
204
+ * Middle segments are fetched from CDN and remuxed on-demand.
205
+ *
206
+ * @param {number} variantIndex
207
+ * @param {number} segmentIndex
208
+ * @returns {Promise<Uint8Array>}
209
+ */
210
+ async getSegment(variantIndex = 0, segmentIndex = 0) {
211
+ const variant = this._variants[variantIndex];
212
+ if (!variant) throw new Error(`Variant ${variantIndex} not found`);
213
+ const seg = variant.segments[segmentIndex];
214
+ if (!seg) throw new Error(`Segment ${segmentIndex} not found`);
215
+
216
+ // Pre-clipped boundary segments are already in memory
217
+ if (seg.data) return seg.data;
218
+
219
+ // Middle segment: fetch from CDN, remux TS → fMP4
220
+ const resp = await fetch(seg.originalUrl);
221
+ if (!resp.ok) throw new Error(`Segment fetch failed: ${resp.status}`);
222
+ const tsData = new Uint8Array(await resp.arrayBuffer());
223
+
224
+ const parser = parseTs(tsData);
225
+ const audioTimescale = parser.audioSampleRate || 48000;
226
+
227
+ // Normalize timestamps: subtract the segment's original start PTS,
228
+ // then add the segment's position in the clip timeline
229
+ const firstVideoPts = parser.videoAccessUnits[0]?.pts ?? 0;
230
+ for (const au of parser.videoAccessUnits) { au.pts -= firstVideoPts; au.dts -= firstVideoPts; }
231
+ for (const au of parser.audioAccessUnits) { au.pts -= firstVideoPts; }
232
+
233
+ const videoBaseTime = Math.round(seg.timelineOffset * PTS_PER_SECOND);
234
+ const audioBaseTime = Math.round(seg.timelineOffset * audioTimescale);
235
+
236
+ const fragment = remuxToFragment(
237
+ parser, segmentIndex + 1,
238
+ videoBaseTime, audioBaseTime, audioTimescale
239
+ );
240
+
241
+ return fragment;
242
+ }
243
+
244
+ /**
245
+ * Get all segment data for a variant (fetches middle segments).
246
+ * Useful for downloading the full clip.
247
+ * @param {number} variantIndex
248
+ * @returns {Promise<Uint8Array[]>}
249
+ */
250
+ async getAllSegments(variantIndex = 0) {
251
+ const variant = this._variants[variantIndex];
252
+ const results = [];
253
+ for (let i = 0; i < variant.segments.length; i++) {
254
+ results.push(await this.getSegment(variantIndex, i));
255
+ }
256
+ return results;
257
+ }
258
+ }
259
+
260
+ // ── main function ─────────────────────────────────────────
261
+
262
+ /**
263
+ * Clip an HLS stream to a time range, producing a new HLS stream
264
+ * with CMAF (fMP4) segments.
265
+ *
266
+ * @param {string} source - HLS URL (master or media playlist)
267
+ * @param {object} options
268
+ * @param {number} options.startTime - Start time in seconds
269
+ * @param {number} options.endTime - End time in seconds
270
+ * @param {string|number} [options.quality] - 'highest', 'lowest', or bandwidth (default: all)
271
+ * @param {function} [options.onProgress] - Progress callback
272
+ * @returns {Promise<HlsClipResult>}
273
+ */
274
+ export async function clipHls(source, options = {}) {
275
+ const { startTime, endTime, quality, onProgress: log = () => {} } = options;
276
+ if (startTime === undefined || endTime === undefined) {
277
+ throw new Error('clipHls requires both startTime and endTime');
278
+ }
279
+
280
+ log('Parsing HLS playlist...');
281
+ const stream = typeof source === 'string' ? await parseHls(source, { onProgress: log }) : source;
282
+
283
+ // Resolve variants to process
284
+ let variantsToProcess = [];
285
+
286
+ if (stream.isMaster) {
287
+ const sorted = stream.qualities; // sorted by bandwidth desc
288
+ if (quality === 'highest') {
289
+ variantsToProcess = [sorted[0]];
290
+ } else if (quality === 'lowest') {
291
+ variantsToProcess = [sorted[sorted.length - 1]];
292
+ } else if (typeof quality === 'number') {
293
+ stream.select(quality);
294
+ variantsToProcess = [stream.selected];
295
+ } else {
296
+ variantsToProcess = sorted; // all variants
297
+ }
298
+ } else {
299
+ // Single media playlist — treat as one variant
300
+ variantsToProcess = [{ url: null, bandwidth: 0, resolution: null, _segments: stream.segments, _initSegmentUrl: stream.initSegmentUrl }];
301
+ }
302
+
303
+ log(`Processing ${variantsToProcess.length} variant(s)...`);
304
+
305
+ const variants = [];
306
+ for (let vi = 0; vi < variantsToProcess.length; vi++) {
307
+ const variant = variantsToProcess[vi];
308
+ log(`Variant ${vi}: ${variant.resolution || variant.bandwidth || 'default'}`);
309
+
310
+ // Get segment list for this variant
311
+ let segments, initSegmentUrl;
312
+ if (variant._segments) {
313
+ segments = variant._segments;
314
+ initSegmentUrl = variant._initSegmentUrl;
315
+ } else {
316
+ const mediaResp = await fetch(variant.url);
317
+ if (!mediaResp.ok) throw new Error(`Failed to fetch media playlist: ${mediaResp.status}`);
318
+ const mediaText = await mediaResp.text();
319
+ const parsed = parsePlaylistText(mediaText, variant.url);
320
+ segments = parsed.segments;
321
+ initSegmentUrl = parsed.initSegmentUrl;
322
+ }
323
+
324
+ if (!segments.length) throw new Error('No segments found');
325
+
326
+ // Find overlapping segments
327
+ const overlapping = segments.filter(seg => seg.endTime > startTime && seg.startTime < endTime);
328
+ if (!overlapping.length) throw new Error('No segments overlap the clip range');
329
+
330
+ const firstSeg = overlapping[0];
331
+ const lastSeg = overlapping[overlapping.length - 1];
332
+ const isSingleSegment = overlapping.length === 1;
333
+
334
+ log(`Segments: ${overlapping.length} (${firstSeg.startTime.toFixed(1)}s – ${lastSeg.endTime.toFixed(1)}s)`);
335
+
336
+ // Download and parse boundary segments to get codec info + pre-clip
337
+ log('Downloading boundary segments...');
338
+ const firstTsData = new Uint8Array(await (await fetch(firstSeg.url)).arrayBuffer());
339
+ const firstParser = parseTs(firstTsData);
340
+
341
+ let lastParser = null;
342
+ let lastTsData = null;
343
+ if (!isSingleSegment) {
344
+ lastTsData = new Uint8Array(await (await fetch(lastSeg.url)).arrayBuffer());
345
+ lastParser = parseTs(lastTsData);
346
+ }
347
+
348
+ // Extract codec info from first segment
349
+ const { sps, pps } = extractCodecInfo(firstParser);
350
+ if (!sps || !pps) throw new Error('Could not extract SPS/PPS from video');
351
+ const audioSampleRate = firstParser.audioSampleRate || 48000;
352
+ const audioChannels = firstParser.audioChannels || 2;
353
+ const hasAudio = firstParser.audioAccessUnits.length > 0;
354
+ const audioTimescale = audioSampleRate;
355
+
356
+ // Create CMAF init segment
357
+ const initSegment = createInitSegment({
358
+ sps, pps, audioSampleRate, audioChannels, hasAudio,
359
+ videoTimescale: PTS_PER_SECOND,
360
+ audioTimescale,
361
+ });
362
+
363
+ // Build the clip segment list
364
+ const clipSegments = [];
365
+ let timelineOffset = 0;
366
+
367
+ // ── First segment (clipped at start, possibly also at end) ──
368
+ // Convert absolute times to segment-relative times (TS PTS starts at ~0 per segment)
369
+ const firstRelStart = startTime - firstSeg.startTime;
370
+ const firstRelEnd = isSingleSegment ? endTime - firstSeg.startTime : undefined;
371
+ const firstClipped = clipSegment(firstParser, firstRelStart, firstRelEnd);
372
+ if (!firstClipped) throw new Error('First segment clip produced no samples');
373
+
374
+ const firstFragment = createFragment({
375
+ videoSamples: firstClipped.videoSamples,
376
+ audioSamples: firstClipped.audioSamples,
377
+ sequenceNumber: 1,
378
+ videoTimescale: PTS_PER_SECOND,
379
+ audioTimescale,
380
+ videoBaseTime: 0,
381
+ audioBaseTime: 0,
382
+ audioSampleDuration: 1024,
383
+ });
384
+
385
+ clipSegments.push({
386
+ duration: firstClipped.playbackDuration,
387
+ data: firstFragment, // pre-clipped, in memory
388
+ originalUrl: null,
389
+ timelineOffset: 0,
390
+ isBoundary: true,
391
+ });
392
+ timelineOffset += firstClipped.mediaDuration;
393
+
394
+ // ── Middle segments (pass-through, remuxed on demand) ──
395
+ for (let i = 1; i < overlapping.length - 1; i++) {
396
+ const seg = overlapping[i];
397
+ const segDuration = seg.duration;
398
+ clipSegments.push({
399
+ duration: segDuration,
400
+ data: null, // fetched on demand
401
+ originalUrl: seg.url,
402
+ timelineOffset,
403
+ isBoundary: false,
404
+ });
405
+ timelineOffset += segDuration;
406
+ }
407
+
408
+ // ── Last segment (clipped at end, if different from first) ──
409
+ if (!isSingleSegment && lastParser) {
410
+ const lastRelEnd = endTime - lastSeg.startTime;
411
+ const lastClipped = clipSegment(lastParser, undefined, lastRelEnd);
412
+ if (lastClipped && lastClipped.videoSamples.length > 0) {
413
+ const lastSeqNum = overlapping.length;
414
+ const lastVideoBaseTime = Math.round(timelineOffset * PTS_PER_SECOND);
415
+ const lastAudioBaseTime = Math.round(timelineOffset * audioTimescale);
416
+
417
+ const lastFragment = createFragment({
418
+ videoSamples: lastClipped.videoSamples,
419
+ audioSamples: lastClipped.audioSamples,
420
+ sequenceNumber: lastSeqNum,
421
+ videoTimescale: PTS_PER_SECOND,
422
+ audioTimescale,
423
+ videoBaseTime: lastVideoBaseTime,
424
+ audioBaseTime: lastAudioBaseTime,
425
+ audioSampleDuration: 1024,
426
+ });
427
+
428
+ clipSegments.push({
429
+ duration: lastClipped.playbackDuration,
430
+ data: lastFragment,
431
+ originalUrl: null,
432
+ timelineOffset,
433
+ isBoundary: true,
434
+ });
435
+ }
436
+ }
437
+
438
+ const totalDuration = clipSegments.reduce((sum, s) => sum + s.duration, 0);
439
+ log(`Clip ready: ${totalDuration.toFixed(2)}s (${clipSegments.length} segments)`);
440
+
441
+ variants.push({
442
+ bandwidth: variant.bandwidth || 0,
443
+ resolution: variant.resolution || null,
444
+ initSegment,
445
+ segments: clipSegments,
446
+ });
447
+ }
448
+
449
+ const clipDuration = endTime - startTime;
450
+ return new HlsClipResult({
451
+ variants,
452
+ duration: clipDuration,
453
+ startTime,
454
+ endTime,
455
+ });
456
+ }
457
+
458
+ export { HlsClipResult };
459
+ export default clipHls;