@torrent-tv/proxy 2.10.0 → 2.12.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.
@@ -112,6 +112,77 @@ export function variantHeightsFor(sourceHeight) {
112
112
  return [Math.round(sourceHeight), ...rungs];
113
113
  }
114
114
 
115
+ /**
116
+ * A fresh tally of how well a container's keyframe index matches its file.
117
+ *
118
+ * @returns {{ checked: number, disagreed: number, maxDeviationSec: number, firstDisagreementIndex: number, seen: Set<number> }}
119
+ */
120
+ export function newIndexCheck() {
121
+ return {
122
+ checked: 0,
123
+ disagreed: 0,
124
+ maxDeviationSec: 0,
125
+ firstDisagreementIndex: -1,
126
+ // Which boundaries have been counted. A segment can be requested again, and
127
+ // a repeat is the same boundary, not new evidence.
128
+ seen: new Set()
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Add one produced segment's deviation to the tally.
134
+ *
135
+ * @param {ReturnType<typeof newIndexCheck>} check
136
+ * @param {number} index - Segment index, so a repeat can be recognised.
137
+ * @param {number} deviationSec - How far the piece's own start fell from the
138
+ * start the playlist declared for it.
139
+ * @returns {void}
140
+ */
141
+ export function noteIndexDeviation(check, index, deviationSec) {
142
+ if (check.seen.has(index)) {
143
+ return;
144
+ }
145
+ check.seen.add(index);
146
+ check.checked += 1;
147
+ if (deviationSec > SEGMENT_START_DISAGREEMENT_SEC) {
148
+ check.disagreed += 1;
149
+ if (check.firstDisagreementIndex < 0) {
150
+ check.firstDisagreementIndex = index;
151
+ }
152
+ }
153
+ if (deviationSec > check.maxDeviationSec) {
154
+ check.maxDeviationSec = deviationSec;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * The same budget, starting at the top of its own ladder.
160
+ *
161
+ * The automatic choice takes the highest rung this host can encode faster than
162
+ * realtime. A viewer who names a resolution has already made that choice, so
163
+ * the encode starts where they said — and the ladder stays, because a host that
164
+ * turns out unable to keep up must still have somewhere to go.
165
+ *
166
+ * @param {{ ladder: { width: number, height: number }[], rungIndex: number } | null} budget
167
+ * @param {number} outputFps
168
+ * @param {unknown} benchmark
169
+ * @returns {object | null}
170
+ */
171
+ function startAtLadderTop(budget, outputFps, benchmark) {
172
+ const top = budget?.ladder?.[0];
173
+ if (!top) {
174
+ return null;
175
+ }
176
+ const fps = Number.isInteger(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
177
+ return {
178
+ ...budget,
179
+ width: top.width,
180
+ height: top.height,
181
+ preset: pickSoftwarePreset(benchmark, top.width * top.height * fps),
182
+ rungIndex: 0
183
+ };
184
+ }
185
+
115
186
  /**
116
187
  * The consumer a base session registers on its variants.
117
188
  *
@@ -213,6 +284,19 @@ const SEEK_BACKOFF_SEGMENTS = 1;
213
284
  // also steered by segment requests, which arrive in bursts of dozens; measured
214
285
  // 2026-08-04, that cost 1.2 s of every seek.
215
286
  const SEEK_SETTLE_MS = 300;
287
+ // How long a segment BELOW the running encode's start may go unanswered before
288
+ // the encoder is moved back to it. Long enough that a burst around a reported
289
+ // seek settles on its own — the seek is what should move the encoder — and
290
+ // short enough that a session cannot sit on an unanswerable request, which
291
+ // measured two minutes forty-one before a viewer gave up.
292
+ const BEHIND_HEAD_REPAIR_MS = 3_000;
293
+ // How far behind the run a request may be and still be treated as the encoder
294
+ // standing in the wrong place rather than as a player scanning the playlist. A
295
+ // misplaced run is out by at most the buffer the player was holding — measured
296
+ // 2026-08-11 at 14 segments — while a scan probe is out by anything at all.
297
+ // Generous against that measurement, and far short of the hundreds of segments
298
+ // a scan reaches.
299
+ const BEHIND_HEAD_REPAIR_MAX_SEGMENTS = 60;
216
300
  // Hard cap on the total settle wait, measured from the first request of a
217
301
  // burst, so a still-moving scrubber cannot delay a genuine seek forever.
218
302
  const SEEK_SETTLE_MAX_MS = 1_000;
@@ -754,10 +838,18 @@ export function ffmpegSeconds(value) {
754
838
  * spans `[boundaries[i], boundaries[i+1])`). Falls back to a uniform grid when
755
839
  * keyframes are unavailable.
756
840
  *
757
- * @param {{ transcodeVideo: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
841
+ * Which grid applies is NOT the same question as whether the video is copied.
842
+ * A copy has no choice — it can only be cut where the source already has a
843
+ * keyframe. A re-encode normally takes the even grid, because it is producing
844
+ * every frame and may put keyframes where it likes; but when it has to be
845
+ * INTERCHANGEABLE with a copy — a quality variant of one — it takes the
846
+ * source's grid instead and forces its keyframes onto it. So the caller says
847
+ * which grid, and this stopped asking whether the video is re-encoded.
848
+ *
849
+ * @param {{ useKeyframeGrid: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
758
850
  * @returns {number[]}
759
851
  */
760
- export function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, keyframeTimes, startTime }) {
852
+ export function computeSegmentBoundaries({ useKeyframeGrid, durationSeconds, segDur, keyframeTimes, startTime }) {
761
853
  const total = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
762
854
  const step = Number.isFinite(segDur) && segDur > 0 ? segDur : 4;
763
855
  const uniform = () => {
@@ -768,7 +860,7 @@ export function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segD
768
860
  boundaries.push(total);
769
861
  return boundaries;
770
862
  };
771
- if (transcodeVideo || !Array.isArray(keyframeTimes) || keyframeTimes.length === 0 || total <= 0) {
863
+ if (!useKeyframeGrid || !Array.isArray(keyframeTimes) || keyframeTimes.length === 0 || total <= 0) {
772
864
  return uniform();
773
865
  }
774
866
  const base = Number.isFinite(startTime) ? startTime : 0;
@@ -822,7 +914,14 @@ export function describeFfmpegArgs(args) {
822
914
  const parts = [];
823
915
  for (let index = 0; index < args.length; index += 1) {
824
916
  const value = args[index];
825
- if (value === "-segment_times" && typeof args[index + 1] === "string") {
917
+ // Both take the same list, and a keyframe-grid variant passes it twice.
918
+ // `-force_key_frames` also takes an expression, which is short and is left
919
+ // alone — only a list is folded.
920
+ if (
921
+ (value === "-segment_times" || value === "-force_key_frames") &&
922
+ typeof args[index + 1] === "string" &&
923
+ args[index + 1].includes(",")
924
+ ) {
826
925
  const times = args[index + 1].split(",");
827
926
  parts.push(value, `<${times.length} cuts ${times[0]}..${times[times.length - 1]}>`);
828
927
  index += 1;
@@ -1056,6 +1155,11 @@ export class HlsSessionManager {
1056
1155
  audioTrackIndex = 0,
1057
1156
  manualQuality = false,
1058
1157
  segmentFormatId = "",
1158
+ // The cut grid of the session this one is a quality variant of: its
1159
+ // keyframe times and which container they were read from. Present only for
1160
+ // a variant of a session cut at the source's keyframes, and it is what
1161
+ // makes the two interchangeable.
1162
+ inheritedGrid = null,
1059
1163
  // Called once for a session that is actually created, and expected to
1060
1164
  // return a function that lets the source go. It is what keeps the torrent's
1061
1165
  // data alive for as long as a viewer has a session on it — see
@@ -1117,7 +1221,11 @@ export class HlsSessionManager {
1117
1221
  // both of them. Restoring it needs the active variant to be tracked per
1118
1222
  // consumer rather than per session, which is a change to three routes and
1119
1223
  // the variant path; recorded in the roadmap, not attempted here.
1120
- transcodeVideo ? consumerId : ""
1224
+ transcodeVideo ? consumerId : "",
1225
+ // Two sessions at the same height cut on different grids are different
1226
+ // streams: one of them can be spliced into a copy of this file and the
1227
+ // other cannot.
1228
+ inheritedGrid ? "grid-keyframe" : "grid-own"
1121
1229
  ].join(":");
1122
1230
  const existingId = this.sessionIdBySource.get(sourceMapKey);
1123
1231
  if (existingId) {
@@ -1227,7 +1335,18 @@ export class HlsSessionManager {
1227
1335
  // grid for boundaries, raw target for seeking) — no regression.
1228
1336
  let keyframeTimes = null;
1229
1337
  let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
1230
- if (hasDuration && !transcodeVideo) {
1338
+ // Which container supplied the index, carried so the accuracy summary can
1339
+ // say what it is a summary OF.
1340
+ let containerFormat = "";
1341
+ // A quality variant of a session whose cuts are the source's keyframes must
1342
+ // be cut at exactly those same times, or its segments cannot stand where
1343
+ // the other's would have. The grid arrives with the request rather than
1344
+ // being worked out again: it is the same file, so a second reading could
1345
+ // only agree — or, if the index were read differently, disagree silently.
1346
+ if (inheritedGrid) {
1347
+ keyframeTimes = inheritedGrid.keyframeTimes;
1348
+ containerFormat = inheritedGrid.containerFormat ?? "";
1349
+ } else if (hasDuration && !transcodeVideo) {
1231
1350
  // Video-COPY path: keyframeTimes are REQUIRED to build correct segment
1232
1351
  // boundaries (the playlist itself), so this MUST block session creation —
1233
1352
  // an incorrect playlist is worse than a slower start. Short timeout: mp4
@@ -1245,7 +1364,9 @@ export class HlsSessionManager {
1245
1364
  // the file comes off a torrent, and a full packet scan of 5.5 GB found 77
1246
1365
  // keyframes in 45 s without finishing, while the container index yields
1247
1366
  // all 570 in 0.8 s from two point reads (16 KB).
1248
- keyframeTimes = await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
1367
+ const index = await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
1368
+ keyframeTimes = index.times;
1369
+ containerFormat = index.format;
1249
1370
  keyframeMs = Date.now() - keyframeStartMs;
1250
1371
  if (!keyframeTimes) {
1251
1372
  logger.warn(
@@ -1289,16 +1410,25 @@ export class HlsSessionManager {
1289
1410
  `keyframes=${keyframeMs === -1 ? "skipped" : keyframeMs === -2 ? "background" : `${keyframeMs}ms`} ` +
1290
1411
  `create-total=${Date.now() - createEntryMs}ms`
1291
1412
  );
1413
+ // Which grid this session is cut on. A copy has no choice: only where the
1414
+ // source already has a keyframe. A re-encode normally takes the even grid —
1415
+ // it produces every frame and may put keyframes where it likes — unless it
1416
+ // is a variant of a keyframe-cut session, in which case it must land on the
1417
+ // same times to be interchangeable with it.
1418
+ const useKeyframeGrid = hasDuration &&
1419
+ Array.isArray(keyframeTimes) &&
1420
+ keyframeTimes.length > 0 &&
1421
+ (!transcodeVideo || inheritedGrid != null);
1292
1422
  const segmentBoundaries = hasDuration
1293
1423
  ? computeSegmentBoundaries({
1294
- transcodeVideo,
1424
+ useKeyframeGrid,
1295
1425
  durationSeconds,
1296
1426
  segDur: this.segmentDurationSec,
1297
1427
  keyframeTimes,
1298
1428
  startTime: sourceStartTime
1299
1429
  })
1300
1430
  : [];
1301
- const usingKeyframeBoundaries = hasDuration && !transcodeVideo && Array.isArray(keyframeTimes);
1431
+ const usingKeyframeBoundaries = useKeyframeGrid;
1302
1432
  const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
1303
1433
 
1304
1434
  // Realtime budget (software encoder): pick the output resolution + libx264
@@ -1312,16 +1442,23 @@ export class HlsSessionManager {
1312
1442
  // resolution, so encode exactly that box (capped to source by the scale
1313
1443
  // filter) with the default preset, and the runtime downswitch is skipped
1314
1444
  // for the session (budgetLadder stays null).
1315
- const encodeBudget = forceManualQuality
1316
- ? null
1317
- : this.#chooseEncodeBudget({
1318
- transcodeVideo,
1319
- targetWidth: normalizedTargetWidth,
1320
- targetHeight: normalizedTargetHeight,
1321
- sourceWidth,
1322
- sourceHeight,
1323
- outputFps
1324
- });
1445
+ const chosenBudget = this.#chooseEncodeBudget({
1446
+ transcodeVideo,
1447
+ targetWidth: normalizedTargetWidth,
1448
+ targetHeight: normalizedTargetHeight,
1449
+ sourceWidth,
1450
+ sourceHeight,
1451
+ outputFps
1452
+ });
1453
+ // A forced resolution starts at exactly that size — the viewer asked for it
1454
+ // — but KEEPS the ladder beneath it. Discarding the ladder is what left a
1455
+ // viewer with no picture at all on 2026-08-11: they picked 480p on a host
1456
+ // that encodes it at 0.27-0.78x, and with the runtime downshift disabled
1457
+ // nothing could step in, so the stream simply never caught up. A smaller
1458
+ // picture that plays beats a correct label that freezes. The rung's NAME is
1459
+ // settled separately and does not move with a downshift, so the player goes
1460
+ // on addressing it by the height it chose.
1461
+ const encodeBudget = forceManualQuality ? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark) : chosenBudget;
1325
1462
  const softwarePreset = encodeBudget?.preset ?? null;
1326
1463
  // Effective encode box: the budget's downscaled resolution when applied,
1327
1464
  // otherwise the client target (0 = keep source, handled by buildVideoArgs).
@@ -1399,15 +1536,32 @@ export class HlsSessionManager {
1399
1536
  // VOD playlist bookkeeping.
1400
1537
  useSyntheticPlaylist: hasDuration,
1401
1538
  totalDurationSeconds: hasDuration ? durationSeconds : null,
1402
- // Segment start times (0-based). Uniform grid for re-encoded video; real
1403
- // keyframe positions for copied video. Drives the playlist and seeking.
1539
+ // Segment start times (0-based). The source's real keyframes when this
1540
+ // session is cut on that grid — always for copied video, and for a
1541
+ // re-encoded variant of such a session — otherwise a uniform grid.
1542
+ // Drives the playlist and seeking.
1404
1543
  segmentBoundaries,
1544
+ // Which of the two it is, as a fact about the session rather than
1545
+ // something re-derived from "is the video copied" at each call site. The
1546
+ // two questions came apart the moment a re-encode had to be cut like a
1547
+ // copy.
1548
+ cutGrid: useKeyframeGrid ? "keyframe" : "uniform",
1405
1549
  segmentCount,
1406
1550
  // Real source keyframe times (sorted seconds), or null when the probe
1407
1551
  // failed/timed out. Used by #startEncodeRun to snap a source seek onto a
1408
1552
  // KNOWN valid position instead of trusting the container's own on-the-fly
1409
1553
  // seek at an arbitrary target — see the probe call above for why.
1410
1554
  keyframeTimes,
1555
+ // Which container the index came from, and how well it has held up. The
1556
+ // cut times of a copied video ARE its index, and an index can be wrong —
1557
+ // measured 2026-08-06, one claimed a keyframe four seconds from where the
1558
+ // real ones were. Each produced segment states where it truly begins, so
1559
+ // the comparison costs a subtraction on a piece that is already being
1560
+ // read; this counts them so a session can report what it found. It is
1561
+ // what decides whether a re-encoded rung can be cut on this same grid and
1562
+ // spliced into the copy (roadmap item 28).
1563
+ containerFormat,
1564
+ indexCheck: newIndexCheck(),
1411
1565
  playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries, segmentFormat) : "",
1412
1566
  // Segment index the current ffmpeg run started producing from.
1413
1567
  encodeStartIndex: 0,
@@ -1488,8 +1642,12 @@ export class HlsSessionManager {
1488
1642
  `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
1489
1643
  // Effective encode resolution: budget-on (auto downscale from the
1490
1644
  // ceiling), manual (user-forced, budget off), or unset (keep source).
1491
- `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
1492
- `${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
1645
+ `${transcodeVideo && encodeBudget
1646
+ ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} ` +
1647
+ `quality=${forceManualQuality ? "manual" : "auto"} ` +
1648
+ `budget=${encodeBudget.ladder ? `rung ${encodeBudget.rungIndex + 1}/${encodeBudget.ladder.length}` : "off"} `
1649
+ : ""}` +
1650
+ `${transcodeVideo && !encodeBudget && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual budget=off ` : ""}` +
1493
1651
  // HDR source and whether the tone-map chain was applied (vs washed-out
1494
1652
  // fallback when the filters are missing or on a hardware encoder).
1495
1653
  `${transcodeVideo && mediaInfo.isHdr ? `hdr=1 tonemap=${applyTonemap ? "on" : "off"} ` : ""}` +
@@ -1717,6 +1875,12 @@ export class HlsSessionManager {
1717
1875
  }
1718
1876
  }
1719
1877
 
1878
+ /**
1879
+ * The file's keyframe times from its container index, and which container it
1880
+ * turned out to be.
1881
+ *
1882
+ * @returns {Promise<{ times: number[] | null, format: string }>}
1883
+ */
1720
1884
  async #readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName }) {
1721
1885
  const cacheKey = `${sourceKey}:${fileIndex}`;
1722
1886
  if (this.keyframeIndexCache.has(cacheKey)) {
@@ -1729,10 +1893,10 @@ export class HlsSessionManager {
1729
1893
  const head = await fetch(url, { method: "HEAD" });
1730
1894
  fileSize = Number(head.headers.get("content-length")) || 0;
1731
1895
  } catch {
1732
- return null;
1896
+ return { times: null, format: "unknown" };
1733
1897
  }
1734
1898
  if (fileSize <= 0) {
1735
- return null;
1899
+ return { times: null, format: "unknown" };
1736
1900
  }
1737
1901
 
1738
1902
  const readRange = async (start, end) => {
@@ -1747,9 +1911,9 @@ export class HlsSessionManager {
1747
1911
  }
1748
1912
  };
1749
1913
 
1750
- const times = await readKeyframeIndex({ readRange, fileSize, label: logName });
1751
- this.keyframeIndexCache.set(cacheKey, times);
1752
- return times;
1914
+ const result = await readKeyframeIndex({ readRange, fileSize, label: logName });
1915
+ this.keyframeIndexCache.set(cacheKey, result);
1916
+ return result;
1753
1917
  }
1754
1918
 
1755
1919
  #buildVodPlaylist(boundaries, segmentFormat) {
@@ -2435,6 +2599,14 @@ export class HlsSessionManager {
2435
2599
  `${restartEnteredAt - wantedAt}ms after it was first asked for`
2436
2600
  );
2437
2601
  }
2602
+ // Cleared with the run that could not answer them. These are "how long has
2603
+ // this segment gone unanswered", and the question only means anything about
2604
+ // the run in force: a timestamp kept from an abandoned scan probe minutes
2605
+ // ago says a fresh request has already waited long enough, which is how the
2606
+ // behind-head repair came to fire on the very first poll instead of waiting
2607
+ // for the seek that should move the encoder. It also stops the map growing
2608
+ // for the life of a session.
2609
+ session.firstWantedAt = new Map();
2438
2610
  const generation = ++session.encodeRunGeneration;
2439
2611
  const previousFfmpeg = session.ffmpeg;
2440
2612
  // A suspended process does not act on SIGTERM until it is continued, so the
@@ -2473,10 +2645,26 @@ export class HlsSessionManager {
2473
2645
  }
2474
2646
 
2475
2647
  const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
2476
- // 0-based output time of this segment, from the boundary table (uniform for
2477
- // re-encode, real keyframe for copy).
2648
+ // 0-based output time of this segment, from the boundary table.
2478
2649
  const startSeconds = this.#segmentStartTime(session, safeIndex);
2479
2650
  const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
2651
+ // Cut where this session's grid says, whoever is producing the frames. The
2652
+ // times are measured from the start of THIS run; the same list serves as
2653
+ // the cut points and, when re-encoding, as the keyframes to force — one
2654
+ // list, so the two cannot drift apart.
2655
+ const explicitTimes = session.segmentFormat.explicitTimesMuxerArgs?.() ?? null;
2656
+ // A COPY is cut by this list whatever grid it ended up on. Even when no
2657
+ // keyframe index could be read and the boundaries are a plain grid, saying
2658
+ // them outright is what keeps the playlist and the muxer agreeing — ffmpeg
2659
+ // moves each cut forward to the first real keyframe, and serving reads back
2660
+ // where the piece truly begins. Requiring a keyframe grid here dropped a
2661
+ // copy with no index onto the `hls` muxer, which takes no cut list and
2662
+ // writes no self-contained pieces, so nothing could read a true start and
2663
+ // segments were stamped with times the file does not have — the 4.17 s
2664
+ // speech-against-subtitles drift, back again.
2665
+ const cutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
2666
+ ? segmentCutTimesFrom(session.segmentBoundaries, safeIndex)
2667
+ : null;
2480
2668
 
2481
2669
  // Terminate any existing encode process before starting a new one. The
2482
2670
  // old process's exit handler no-ops because session.ffmpeg is reassigned
@@ -2506,7 +2694,11 @@ export class HlsSessionManager {
2506
2694
  // Software-only; hardware descriptors ignore it.
2507
2695
  preset: session.softwarePreset ?? undefined,
2508
2696
  // HDR→SDR tone map (software path only; gated on filter availability).
2509
- tonemap: session.applyTonemap === true
2697
+ tonemap: session.applyTonemap === true,
2698
+ // On the source's grid the cuts are not evenly spaced, so no frame
2699
+ // count can describe them: the encoder is told the times outright,
2700
+ // the same ones the muxer will cut at.
2701
+ forcedKeyframeTimes: cutTimes
2510
2702
  })
2511
2703
  : ["-c:v", "copy"];
2512
2704
  const audioCodecArgs = session.transcodeAudio
@@ -2519,10 +2711,14 @@ export class HlsSessionManager {
2519
2711
  if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
2520
2712
  args.push(...this.videoEncoder.inputArgs);
2521
2713
  }
2522
- // Seek position in SOURCE time. For copy we seek to the real keyframe
2523
- // (startSeconds is already a real-keyframe offset from 0, so add back the
2524
- // container start time); for re-encode startSeconds is a plain grid offset.
2525
- const seekSeconds = session.transcodeVideo ? startSeconds : startSeconds + sourceStartTime;
2714
+ // Seek position in SOURCE time. On the keyframe grid `startSeconds` is a
2715
+ // real keyframe's offset from zero, so the container's own start time goes
2716
+ // back on to reach it; on the uniform grid it is a plain offset. This
2717
+ // follows the GRID, not whether the video is re-encoded — a variant cut on
2718
+ // the source's keyframes has to seek to them like the copy it accompanies.
2719
+ const seekSeconds = session.cutGrid === "keyframe"
2720
+ ? startSeconds + sourceStartTime
2721
+ : startSeconds;
2526
2722
  // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
2527
2723
  // keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
2528
2724
  // not the container's own on-the-fly seek/index) and trim the short
@@ -2602,11 +2798,10 @@ export class HlsSessionManager {
2602
2798
  // outright. Passing the very boundaries the playlist was built from makes
2603
2799
  // the two agree by construction. Only cut points already known to be real
2604
2800
  // keyframes are sent, so ffmpeg never has to move one forward.
2605
- const explicitTimes = session.segmentFormat.explicitTimesMuxerArgs?.() ?? null;
2606
- const cutTimes = explicitTimes && !session.transcodeVideo
2607
- ? segmentCutTimesFrom(session.segmentBoundaries, safeIndex)
2608
- : null;
2609
-
2801
+ //
2802
+ // The list is built above, before the encoder args, because a re-encoded
2803
+ // variant of a copied stream needs the same times twice over: once as the
2804
+ // cuts, once as the keyframes to force at them.
2610
2805
  if (cutTimes && cutTimes.length > 0) {
2611
2806
  args.push(
2612
2807
  "-f",
@@ -2991,6 +3186,26 @@ export class HlsSessionManager {
2991
3186
  if (withinWindow) {
2992
3187
  return;
2993
3188
  }
3189
+ // A request BELOW where the run begins is not noise and never will be
3190
+ // satisfied: this encoder only ever moves forward from `head`, so nothing
3191
+ // it does can produce this segment. Every other far request is a claim that
3192
+ // the running encode may yet reach — this one is a hole, and holding it is
3193
+ // holding it for ever.
3194
+ //
3195
+ // Measured 2026-08-11: a run repositioned to #770 while the player needed
3196
+ // #757 held that request for two minutes forty-one, producing 409 s of
3197
+ // video nobody had asked for at 2.48x, until the viewer gave up. That was a
3198
+ // quality switch placing the run wrongly; the placement is fixed, but the
3199
+ // shape must not be able to hang a session again whatever puts it there.
3200
+ //
3201
+ // Waited on rather than acted on at once: a burst that arrives around a
3202
+ // reported seek settles by itself within a moment, and the seek is what
3203
+ // should move the encoder. Only a request still unanswerable after that is
3204
+ // repaired here.
3205
+ if (index < head) {
3206
+ this.#repairBehindHead(session, index, head);
3207
+ return;
3208
+ }
2994
3209
  // Circuit breaker: this exact target has already failed MAX_SEEK_FAILURES
2995
3210
  // times in a row (fast failures — see #wireEncodeProcess's exit handler).
2996
3211
  // Stop auto-retrying it; session.state stays "failed" so getFileStream
@@ -3022,6 +3237,69 @@ export class HlsSessionManager {
3022
3237
  // See research/hls-seek-prior-art-2026-08-02.md.
3023
3238
  }
3024
3239
 
3240
+ /**
3241
+ * Move the encoder back to a segment it can no longer produce.
3242
+ *
3243
+ * A run only ever goes forward from where it began, so a request below that
3244
+ * point is not a claim the run may yet reach — it is a hole, and holding it
3245
+ * holds it for ever. Measured 2026-08-11: a run placed at #770 while the
3246
+ * player needed #757 held that request for two minutes forty-one, producing
3247
+ * 409 s of video nobody had asked for.
3248
+ *
3249
+ * Deliberately narrow, because moving the encoder from a segment REQUEST is
3250
+ * exactly what this codebase removed once already: a player that cannot get
3251
+ * what it wants scans the playlist, and those probes are scattered across the
3252
+ * whole file (field log: #178, #681, #725, #807, #74, #245, #387 within half
3253
+ * a second). Steering on the lowest of them put the encoder at the start of
3254
+ * the film and left the viewer's own requests unreachable ahead of it.
3255
+ *
3256
+ * What separates the two: a run placed wrongly is out by at most the buffer
3257
+ * the player had — 14 segments in the measured case — while a scan probe is
3258
+ * out by anything at all. So only a request within reach behind the head is
3259
+ * repaired; the rest are what they always were, claims that answer 503.
3260
+ *
3261
+ * @param {HlsSession} session
3262
+ * @param {number} index - The segment being held.
3263
+ * @param {number} head - Where the current run begins.
3264
+ * @returns {void}
3265
+ */
3266
+ #repairBehindHead(session, index, head) {
3267
+ if (head - index > BEHIND_HEAD_REPAIR_MAX_SEGMENTS) {
3268
+ return;
3269
+ }
3270
+ // Nothing is encoding: a rung the viewer has switched away from is left
3271
+ // exactly so, and its held requests must not bring its encoder back.
3272
+ if (session.ffmpeg == null || hasChildExited(session.ffmpeg)) {
3273
+ return;
3274
+ }
3275
+ // A seek already settling is about to move the encoder to where the VIEWER
3276
+ // said they are. That statement outranks anything inferred here.
3277
+ if (session.seekSettleTimer != null) {
3278
+ return;
3279
+ }
3280
+ const wantedAt = session.firstWantedAt?.get(index);
3281
+ if (!Number.isFinite(wantedAt) || Date.now() - wantedAt < BEHIND_HEAD_REPAIR_MS) {
3282
+ return;
3283
+ }
3284
+ const target = Math.max(0, index - SEEK_BACKOFF_SEGMENTS);
3285
+ // The breaker has already refused this target repeatedly. Re-arming for it
3286
+ // would log and re-arm on every poll for as long as the request is held,
3287
+ // and move nothing.
3288
+ if (target === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
3289
+ return;
3290
+ }
3291
+ logger.warn(
3292
+ `transcode ${session.id} segment #${index} is behind the run (#${head}) and has waited ` +
3293
+ `${Date.now() - wantedAt}ms — nothing this run does can produce it; moving the encoder there`
3294
+ );
3295
+ // Through the same settle a viewer's own seek goes through, so a burst of
3296
+ // behind-head requests produces one restart and not one each.
3297
+ session.seekTarget = target;
3298
+ session.seekFirstFarAt = Date.now();
3299
+ session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), SEEK_SETTLE_MS);
3300
+ session.seekSettleTimer.unref?.();
3301
+ }
3302
+
3025
3303
  /**
3026
3304
  * Fire a settled server-side seek: restart the encoder once at the target
3027
3305
  * recorded during the settle window. Enforces the restart cooldown as a
@@ -3471,6 +3749,101 @@ export class HlsSessionManager {
3471
3749
  return highest;
3472
3750
  }
3473
3751
 
3752
+ /**
3753
+ * Record how far a produced segment's real start fell from what the playlist
3754
+ * declared for it.
3755
+ *
3756
+ * The playlist's figure comes from the container's keyframe index; the
3757
+ * segment's own figure comes from the piece ffmpeg wrote. The difference IS
3758
+ * the index's error at that boundary, measured without scanning anything —
3759
+ * the piece is already read whole in order to be stamped, and only boundaries
3760
+ * that were actually produced are counted, which is to say the parts somebody
3761
+ * watched.
3762
+ *
3763
+ * Counted once per boundary: a segment can be requested again, and a repeat
3764
+ * is not new evidence.
3765
+ *
3766
+ * @param {HlsSession} session
3767
+ * @param {number} index
3768
+ * @param {number} trueStart - Seconds, read from the piece itself.
3769
+ * @param {number} declaredStart - Seconds, from the playlist.
3770
+ * @returns {void}
3771
+ */
3772
+ #noteIndexAccuracy(session, index, trueStart, declaredStart) {
3773
+ const deviation = Math.abs(trueStart - declaredStart);
3774
+ session.indexCheck ??= newIndexCheck();
3775
+ noteIndexDeviation(session.indexCheck, index, deviation);
3776
+ if (deviation > SEGMENT_START_DISAGREEMENT_SEC) {
3777
+ // Which boundary the true start DOES match, if any. This is what tells
3778
+ // the two possible faults apart, and they need opposite fixes: matching
3779
+ // boundary #N-1 means our numbering is shifted by one — a fault in this
3780
+ // code, where the run begins — while matching nothing means the container
3781
+ // index describes times the file does not have. Measured 2026-08-11,
3782
+ // three samples all matched N-1, which is why the line now says so
3783
+ // instead of leaving it to be inferred from the numbers.
3784
+ const at = this.#boundaryIndexAt(session, trueStart);
3785
+ logger.warn(
3786
+ `transcode ${session.id} segment #${index} really starts at ` +
3787
+ `${trueStart.toFixed(3)}s (boundary ${at === null ? "none" : `#${at}`}), ` +
3788
+ `the playlist says ${declaredStart.toFixed(3)}s — ` +
3789
+ (session.transcodeVideo
3790
+ // A re-encode was TOLD to put a keyframe here and did not, so this
3791
+ // rung's segments no longer stand where the stream it accompanies
3792
+ // would have put them. That is a broken splice, not a wrong index.
3793
+ ? "this rung did not cut where its grid says; a switch to it will not join cleanly"
3794
+ : "the container's keyframe index disagrees with the file; using the file")
3795
+ );
3796
+ }
3797
+ }
3798
+
3799
+ /**
3800
+ * The boundary a time falls on, or null when it falls on none of them.
3801
+ *
3802
+ * Within the same tolerance a disagreement is judged by, so "matches boundary
3803
+ * #N-1" and "matches nothing" mean what they say.
3804
+ *
3805
+ * @param {HlsSession} session
3806
+ * @param {number} seconds
3807
+ * @returns {number | null}
3808
+ */
3809
+ #boundaryIndexAt(session, seconds) {
3810
+ const boundaries = session.segmentBoundaries;
3811
+ if (!Array.isArray(boundaries)) {
3812
+ return null;
3813
+ }
3814
+ for (let index = 0; index < boundaries.length; index += 1) {
3815
+ if (Math.abs(boundaries[index] - seconds) <= SEGMENT_START_DISAGREEMENT_SEC) {
3816
+ return index;
3817
+ }
3818
+ }
3819
+ return null;
3820
+ }
3821
+
3822
+ /**
3823
+ * What this session learned about its container's keyframe index, as one
3824
+ * line, at the end.
3825
+ *
3826
+ * Written even when nothing disagreed, because that is the finding: with only
3827
+ * the per-boundary warning, silence could not be told from nobody having
3828
+ * watched. Skipped for a session that checked nothing, which says neither.
3829
+ *
3830
+ * @param {HlsSession} session
3831
+ * @returns {void}
3832
+ */
3833
+ #logIndexAccuracy(session) {
3834
+ const check = session.indexCheck;
3835
+ if (!check || check.checked === 0) {
3836
+ return;
3837
+ }
3838
+ logger.info(
3839
+ `keyframe-index ${session.containerFormat || "unknown"} "${session.fileName}": ` +
3840
+ `${check.disagreed} of ${check.checked} produced segments started away from the playlist, ` +
3841
+ `worst ${check.maxDeviationSec.toFixed(3)}s` +
3842
+ (check.firstDisagreementIndex >= 0 ? ` (first at #${check.firstDisagreementIndex})` : "") +
3843
+ ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s]`
3844
+ );
3845
+ }
3846
+
3474
3847
  /**
3475
3848
  * Which variant a session IS, as a height. Zero encode height means "keep the
3476
3849
  * source", so the source's own height is the answer.
@@ -3557,6 +3930,26 @@ export class HlsSessionManager {
3557
3930
  * @param {HlsSession} session
3558
3931
  * @returns {number}
3559
3932
  */
3933
+ /**
3934
+ * Where a variant's first encode run should begin, in seconds.
3935
+ *
3936
+ * The segment the player asked for, when there is one: after a level switch
3937
+ * hls.js discards what it had buffered ahead and fetches from the picture's
3938
+ * own position, so its first request IS that position. Falling back to the
3939
+ * rung being left means falling back to that rung's READ head, which sits a
3940
+ * whole buffer further on.
3941
+ *
3942
+ * @param {HlsSession} base
3943
+ * @param {number} wantedIndex - Segment index asked for, or -1.
3944
+ * @returns {number}
3945
+ */
3946
+ #variantStartSeconds(base, wantedIndex) {
3947
+ if (Number.isInteger(wantedIndex) && wantedIndex >= 0) {
3948
+ return this.#segmentStartTime(base, wantedIndex);
3949
+ }
3950
+ return this.#viewerPositionOf(this.#activeVariant(base));
3951
+ }
3952
+
3560
3953
  #viewerPositionOf(session) {
3561
3954
  if (Number.isFinite(session.viewerPositionSeconds) && session.viewerPositionSeconds > 0) {
3562
3955
  return session.viewerPositionSeconds;
@@ -3633,7 +4026,7 @@ export class HlsSessionManager {
3633
4026
  * @returns {Promise<HlsSession | null>} Null when the base session is unknown,
3634
4027
  * or the height is not offered for it.
3635
4028
  */
3636
- async resolveVariantSession(baseSessionId, height) {
4029
+ async resolveVariantSession(baseSessionId, height, wantedIndex = -1) {
3637
4030
  if (!isSafeSessionId(baseSessionId)) {
3638
4031
  return null;
3639
4032
  }
@@ -3683,12 +4076,21 @@ export class HlsSessionManager {
3683
4076
  consumerId: variantConsumerId(base.id),
3684
4077
  targetWidth: 0,
3685
4078
  targetHeight: height,
3686
- // Where the viewer is now, so a variant created mid-film starts there
3687
- // rather than at the beginning. Floored onto the ten-second grid that
3688
- // session keys are bucketed to: rounding is what that bucket does, and a
3689
- // position rounded UP starts the run past the viewer, so the run just
3690
- // spawned is killed and restarted before it has produced anything.
3691
- startPositionSeconds: Math.floor(this.#viewerPositionOf(this.#activeVariant(base)) / 10) * 10,
4079
+ // Where this variant must begin. The segment the player asked it for when
4080
+ // it can be known that is the player stating outright where it will
4081
+ // start fetching, and it is the only figure that cannot be stale.
4082
+ //
4083
+ // The other rung's read head is NOT that figure, and using it cost a
4084
+ // stuck session on 2026-08-11: a 240p rung encoding at 5-6x had read 56 s
4085
+ // further than the picture had played, so switching back to 400p placed
4086
+ // that run at 3084 s while the player needed 3028 s, and no segment it
4087
+ // wanted was ever produced.
4088
+ //
4089
+ // Floored onto the ten-second grid that session keys are bucketed to:
4090
+ // rounding is what that bucket does, and a position rounded UP starts the
4091
+ // run past the viewer, so the run just spawned is killed and restarted
4092
+ // before it has produced anything.
4093
+ startPositionSeconds: Math.floor(this.#variantStartSeconds(base, wantedIndex) / 10) * 10,
3692
4094
  audioTrackIndex: base.audioTrackIndex,
3693
4095
  // A variant is a resolution the viewer chose, so it is encoded at exactly
3694
4096
  // that size and the realtime budget does not move it — otherwise two
@@ -3696,6 +4098,13 @@ export class HlsSessionManager {
3696
4098
  // nothing.
3697
4099
  manualQuality: true,
3698
4100
  segmentFormatId: base.segmentFormat?.id ?? "",
4101
+ // Cut where the base is cut. Only for a base on the source's own keyframe
4102
+ // grid — a copy — where the variant has to land on those exact times to
4103
+ // be interchangeable with it. A base on the uniform grid needs nothing
4104
+ // passed: the variant computes the same even grid from the same duration.
4105
+ inheritedGrid: base.cutGrid === "keyframe"
4106
+ ? { keyframeTimes: base.keyframeTimes, containerFormat: base.containerFormat }
4107
+ : null,
3699
4108
  acquireSource: base.acquireSource
3700
4109
  })
3701
4110
  .then(async (variant) => {
@@ -3771,7 +4180,11 @@ export class HlsSessionManager {
3771
4180
  }
3772
4181
  let variant;
3773
4182
  try {
3774
- variant = await this.resolveVariantSession(baseSessionId, height);
4183
+ variant = await this.resolveVariantSession(
4184
+ baseSessionId,
4185
+ height,
4186
+ isSegment ? base.segmentFormat.segmentIndexFromName(fileName) : -1
4187
+ );
3775
4188
  } catch (error) {
3776
4189
  const message = error instanceof Error ? error.message : String(error);
3777
4190
  logger.error(
@@ -3783,13 +4196,76 @@ export class HlsSessionManager {
3783
4196
  if (!variant) {
3784
4197
  return { sessionId: null };
3785
4198
  }
3786
- // Only a SEGMENT says the viewer is watching this rung.
4199
+ // Only a SEGMENT says the viewer is watching this rung — and it says more
4200
+ // than that: it names the exact segment the player wants from it.
3787
4201
  if (isSegment) {
3788
- this.#noteVariantActive(base, variant);
4202
+ this.#noteVariantActive(base, variant, variant.segmentFormat.segmentIndexFromName(fileName));
3789
4203
  }
3790
4204
  return { sessionId: variant.id };
3791
4205
  }
3792
4206
 
4207
+ /**
4208
+ * Prepare a rung the viewer is about to switch to, without switching to it.
4209
+ *
4210
+ * The rung does not exist until it is asked for, so the moment the player is
4211
+ * told to switch it has nothing to fetch and the viewer watches a spinner
4212
+ * while an encoder starts from nothing — measured 2026-08-11 at 15 988 ms for
4213
+ * the first segment of a rung producing at 1.2x. Nothing can make that
4214
+ * production instant; what CAN be done is to have it happen while the rung
4215
+ * the viewer is on is still playing.
4216
+ *
4217
+ * So this creates and positions the variant and says which segment to wait
4218
+ * for, and deliberately does NOT mark it active: the rung on screen keeps its
4219
+ * encoder until the player actually moves. Both encoders run for the length
4220
+ * of the warm-up, which is the price of the switch not being visible.
4221
+ *
4222
+ * @param {string} baseSessionId
4223
+ * @param {number} height
4224
+ * @param {number} positionSeconds - Where the switch will happen.
4225
+ * @returns {Promise<{ sessionId: string, fileName: string } | null>}
4226
+ */
4227
+ async prepareVariant(baseSessionId, height, positionSeconds) {
4228
+ if (!isSafeSessionId(baseSessionId)) {
4229
+ return null;
4230
+ }
4231
+ const base = this.sessionsById.get(baseSessionId);
4232
+ if (!base || base.state === "disposed") {
4233
+ return null;
4234
+ }
4235
+ if (!this.#variantHeights(base).includes(height)) {
4236
+ return null;
4237
+ }
4238
+ const index = this.#segmentIndexForTime(base, positionSeconds);
4239
+ const variant = await this.resolveVariantSession(baseSessionId, height, index);
4240
+ if (!variant) {
4241
+ return null;
4242
+ }
4243
+ // A rung warmed for a switch that was never made. Nothing else would ever
4244
+ // stop it: only becoming active stops the rung being left, so a viewer
4245
+ // trying two rungs in a row would leave the first encoding for nobody until
4246
+ // the look-ahead cap suspended it — three encoders at once on a host sized
4247
+ // for one, which is the opposite of what warming is for.
4248
+ const stillWarming = base.warmingVariantId;
4249
+ if (stillWarming && stillWarming !== variant.id) {
4250
+ const abandoned = this.sessionsById.get(stillWarming);
4251
+ if (abandoned && abandoned.id !== this.#activeVariant(base).id) {
4252
+ this.#stopEncodeRun(abandoned, "warmed for a switch the viewer did not make");
4253
+ }
4254
+ }
4255
+ base.warmingVariantId = variant.id === base.id ? null : variant.id;
4256
+ // An existing rung may be parked wherever it was left, so it is pointed at
4257
+ // the switch position exactly as an activation would — the difference is
4258
+ // only that the rung on screen keeps its own encoder meanwhile.
4259
+ variant.lastAccessedAt = Date.now();
4260
+ if (variant.id !== base.id) {
4261
+ this.requestSeek(variant.id, this.#segmentStartTime(base, index));
4262
+ }
4263
+ logger.info(
4264
+ `transcode ${base.id} warming ${height}p at ${positionSeconds.toFixed(1)}s (segment #${index})`
4265
+ );
4266
+ return { sessionId: variant.id, fileName: variant.segmentFormat.segmentFileName(index) };
4267
+ }
4268
+
3793
4269
  /**
3794
4270
  * Record which variant the viewer is watching, and give it the encoder.
3795
4271
  *
@@ -3800,14 +4276,27 @@ export class HlsSessionManager {
3800
4276
  *
3801
4277
  * @param {HlsSession} base
3802
4278
  * @param {HlsSession} variant
4279
+ * @param {number} wantedIndex - The segment this rung was just asked for.
3803
4280
  * @returns {void}
3804
4281
  */
3805
- #noteVariantActive(base, variant) {
4282
+ #noteVariantActive(base, variant, wantedIndex = -1) {
3806
4283
  const previous = this.#activeVariant(base);
4284
+ // Whatever was warmed is decided now: either it is the rung being switched
4285
+ // to, or the viewer went elsewhere and it must stop like any other rung
4286
+ // nobody is watching. Nothing else would ever stop it — only the rung being
4287
+ // LEFT is stopped below.
4288
+ const warmed = base.warmingVariantId;
4289
+ base.warmingVariantId = null;
4290
+ if (warmed && warmed !== variant.id && warmed !== previous.id) {
4291
+ const abandoned = this.sessionsById.get(warmed);
4292
+ if (abandoned) {
4293
+ this.#stopEncodeRun(abandoned, "warmed for a switch the viewer did not make");
4294
+ }
4295
+ }
3807
4296
  if (previous.id === variant.id) {
3808
4297
  return;
3809
4298
  }
3810
- const position = this.#viewerPositionOf(previous);
4299
+ const position = this.#variantStartSeconds(base, wantedIndex);
3811
4300
  base.activeVariantId = variant.id;
3812
4301
  logger.info(
3813
4302
  `transcode ${base.id} variant now ${this.variantHeightOf(variant)}p ` +
@@ -3840,21 +4329,32 @@ export class HlsSessionManager {
3840
4329
  * variant, appends it after what is already buffered, and changes the
3841
4330
  * decoder's type if the codec parameters differ.
3842
4331
  *
3843
- * Offered only where the variants can actually be joined: every one of them
3844
- * is re-encoded, so all are cut on the same uniform grid with keyframes
3845
- * forced onto it. A session that COPIES the video is cut at the source's own
3846
- * keyframes instead, and a rung cut elsewhere cannot be spliced into it.
4332
+ * Offered only where the variants can actually be joined, which is a question
4333
+ * about the CUT GRID and not about who produces the frames:
4334
+ *
4335
+ * - a re-encoded session on the uniform grid its variants are re-encoded on
4336
+ * the same one, keyframes forced onto it;
4337
+ * - a session cut at the source's own keyframes — a copy, which has no other
4338
+ * choice — where the variants are re-encoded and forced onto those very
4339
+ * times, so a rung's segment covers the same span as the copy's.
4340
+ *
4341
+ * What is refused is a session whose own grid is a fiction: a copy with no
4342
+ * readable keyframe index falls back to an even grid that ffmpeg then does
4343
+ * not cut on, and nothing can be aligned to that.
3847
4344
  *
3848
4345
  * @param {string} sessionId
3849
4346
  * @returns {string | null} The playlist text, or null when there is nothing
3850
- * to choose between — a copied video, or a source too small to step down.
4347
+ * to choose between, or nothing to align to.
3851
4348
  */
3852
4349
  buildMasterPlaylist(sessionId) {
3853
4350
  if (!isSafeSessionId(sessionId)) {
3854
4351
  return null;
3855
4352
  }
3856
4353
  const session = this.sessionsById.get(sessionId);
3857
- if (!session || session.state === "disposed" || !session.transcodeVideo) {
4354
+ if (!session || session.state === "disposed") {
4355
+ return null;
4356
+ }
4357
+ if (!session.transcodeVideo && session.cutGrid !== "keyframe") {
3858
4358
  return null;
3859
4359
  }
3860
4360
  const sourceHeight = Number(session.sourceHeight) || 0;
@@ -4191,12 +4691,8 @@ export class HlsSessionManager {
4191
4691
  ? session.segmentFormat.readSegmentStartSeconds?.(raw) ?? null
4192
4692
  : null;
4193
4693
  const declaredStart = this.#segmentStartTime(session, index);
4194
- if (trueStart !== null && Math.abs(trueStart - declaredStart) > SEGMENT_START_DISAGREEMENT_SEC) {
4195
- logger.warn(
4196
- `transcode ${session.id} segment #${index} really starts at ` +
4197
- `${trueStart.toFixed(3)}s, the playlist says ${declaredStart.toFixed(3)}s — ` +
4198
- "the container's keyframe index disagrees with the file; using the file"
4199
- );
4694
+ if (trueStart !== null) {
4695
+ this.#noteIndexAccuracy(session, index, trueStart, declaredStart);
4200
4696
  }
4201
4697
  const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
4202
4698
  startSeconds: trueStart ?? declaredStart,
@@ -4506,6 +5002,7 @@ export class HlsSessionManager {
4506
5002
  session.state = "disposed";
4507
5003
  this.sessionsById.delete(sessionId);
4508
5004
  this.sessionIdBySource.delete(session.sourceMapKey);
5005
+ this.#logIndexAccuracy(session);
4509
5006
 
4510
5007
  // A variant serves this session's viewer and nobody learns its id but us,
4511
5008
  // so it would otherwise encode and occupy disk for no one until its idle