@torrent-tv/proxy 2.10.0 → 2.11.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,49 @@ 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
+
115
158
  /**
116
159
  * The consumer a base session registers on its variants.
117
160
  *
@@ -213,6 +256,19 @@ const SEEK_BACKOFF_SEGMENTS = 1;
213
256
  // also steered by segment requests, which arrive in bursts of dozens; measured
214
257
  // 2026-08-04, that cost 1.2 s of every seek.
215
258
  const SEEK_SETTLE_MS = 300;
259
+ // How long a segment BELOW the running encode's start may go unanswered before
260
+ // the encoder is moved back to it. Long enough that a burst around a reported
261
+ // seek settles on its own — the seek is what should move the encoder — and
262
+ // short enough that a session cannot sit on an unanswerable request, which
263
+ // measured two minutes forty-one before a viewer gave up.
264
+ const BEHIND_HEAD_REPAIR_MS = 3_000;
265
+ // How far behind the run a request may be and still be treated as the encoder
266
+ // standing in the wrong place rather than as a player scanning the playlist. A
267
+ // misplaced run is out by at most the buffer the player was holding — measured
268
+ // 2026-08-11 at 14 segments — while a scan probe is out by anything at all.
269
+ // Generous against that measurement, and far short of the hundreds of segments
270
+ // a scan reaches.
271
+ const BEHIND_HEAD_REPAIR_MAX_SEGMENTS = 60;
216
272
  // Hard cap on the total settle wait, measured from the first request of a
217
273
  // burst, so a still-moving scrubber cannot delay a genuine seek forever.
218
274
  const SEEK_SETTLE_MAX_MS = 1_000;
@@ -754,10 +810,18 @@ export function ffmpegSeconds(value) {
754
810
  * spans `[boundaries[i], boundaries[i+1])`). Falls back to a uniform grid when
755
811
  * keyframes are unavailable.
756
812
  *
757
- * @param {{ transcodeVideo: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
813
+ * Which grid applies is NOT the same question as whether the video is copied.
814
+ * A copy has no choice — it can only be cut where the source already has a
815
+ * keyframe. A re-encode normally takes the even grid, because it is producing
816
+ * every frame and may put keyframes where it likes; but when it has to be
817
+ * INTERCHANGEABLE with a copy — a quality variant of one — it takes the
818
+ * source's grid instead and forces its keyframes onto it. So the caller says
819
+ * which grid, and this stopped asking whether the video is re-encoded.
820
+ *
821
+ * @param {{ useKeyframeGrid: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
758
822
  * @returns {number[]}
759
823
  */
760
- export function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, keyframeTimes, startTime }) {
824
+ export function computeSegmentBoundaries({ useKeyframeGrid, durationSeconds, segDur, keyframeTimes, startTime }) {
761
825
  const total = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
762
826
  const step = Number.isFinite(segDur) && segDur > 0 ? segDur : 4;
763
827
  const uniform = () => {
@@ -768,7 +832,7 @@ export function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segD
768
832
  boundaries.push(total);
769
833
  return boundaries;
770
834
  };
771
- if (transcodeVideo || !Array.isArray(keyframeTimes) || keyframeTimes.length === 0 || total <= 0) {
835
+ if (!useKeyframeGrid || !Array.isArray(keyframeTimes) || keyframeTimes.length === 0 || total <= 0) {
772
836
  return uniform();
773
837
  }
774
838
  const base = Number.isFinite(startTime) ? startTime : 0;
@@ -822,7 +886,14 @@ export function describeFfmpegArgs(args) {
822
886
  const parts = [];
823
887
  for (let index = 0; index < args.length; index += 1) {
824
888
  const value = args[index];
825
- if (value === "-segment_times" && typeof args[index + 1] === "string") {
889
+ // Both take the same list, and a keyframe-grid variant passes it twice.
890
+ // `-force_key_frames` also takes an expression, which is short and is left
891
+ // alone — only a list is folded.
892
+ if (
893
+ (value === "-segment_times" || value === "-force_key_frames") &&
894
+ typeof args[index + 1] === "string" &&
895
+ args[index + 1].includes(",")
896
+ ) {
826
897
  const times = args[index + 1].split(",");
827
898
  parts.push(value, `<${times.length} cuts ${times[0]}..${times[times.length - 1]}>`);
828
899
  index += 1;
@@ -1056,6 +1127,11 @@ export class HlsSessionManager {
1056
1127
  audioTrackIndex = 0,
1057
1128
  manualQuality = false,
1058
1129
  segmentFormatId = "",
1130
+ // The cut grid of the session this one is a quality variant of: its
1131
+ // keyframe times and which container they were read from. Present only for
1132
+ // a variant of a session cut at the source's keyframes, and it is what
1133
+ // makes the two interchangeable.
1134
+ inheritedGrid = null,
1059
1135
  // Called once for a session that is actually created, and expected to
1060
1136
  // return a function that lets the source go. It is what keeps the torrent's
1061
1137
  // data alive for as long as a viewer has a session on it — see
@@ -1117,7 +1193,11 @@ export class HlsSessionManager {
1117
1193
  // both of them. Restoring it needs the active variant to be tracked per
1118
1194
  // consumer rather than per session, which is a change to three routes and
1119
1195
  // the variant path; recorded in the roadmap, not attempted here.
1120
- transcodeVideo ? consumerId : ""
1196
+ transcodeVideo ? consumerId : "",
1197
+ // Two sessions at the same height cut on different grids are different
1198
+ // streams: one of them can be spliced into a copy of this file and the
1199
+ // other cannot.
1200
+ inheritedGrid ? "grid-keyframe" : "grid-own"
1121
1201
  ].join(":");
1122
1202
  const existingId = this.sessionIdBySource.get(sourceMapKey);
1123
1203
  if (existingId) {
@@ -1227,7 +1307,18 @@ export class HlsSessionManager {
1227
1307
  // grid for boundaries, raw target for seeking) — no regression.
1228
1308
  let keyframeTimes = null;
1229
1309
  let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
1230
- if (hasDuration && !transcodeVideo) {
1310
+ // Which container supplied the index, carried so the accuracy summary can
1311
+ // say what it is a summary OF.
1312
+ let containerFormat = "";
1313
+ // A quality variant of a session whose cuts are the source's keyframes must
1314
+ // be cut at exactly those same times, or its segments cannot stand where
1315
+ // the other's would have. The grid arrives with the request rather than
1316
+ // being worked out again: it is the same file, so a second reading could
1317
+ // only agree — or, if the index were read differently, disagree silently.
1318
+ if (inheritedGrid) {
1319
+ keyframeTimes = inheritedGrid.keyframeTimes;
1320
+ containerFormat = inheritedGrid.containerFormat ?? "";
1321
+ } else if (hasDuration && !transcodeVideo) {
1231
1322
  // Video-COPY path: keyframeTimes are REQUIRED to build correct segment
1232
1323
  // boundaries (the playlist itself), so this MUST block session creation —
1233
1324
  // an incorrect playlist is worse than a slower start. Short timeout: mp4
@@ -1245,7 +1336,9 @@ export class HlsSessionManager {
1245
1336
  // the file comes off a torrent, and a full packet scan of 5.5 GB found 77
1246
1337
  // keyframes in 45 s without finishing, while the container index yields
1247
1338
  // all 570 in 0.8 s from two point reads (16 KB).
1248
- keyframeTimes = await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
1339
+ const index = await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
1340
+ keyframeTimes = index.times;
1341
+ containerFormat = index.format;
1249
1342
  keyframeMs = Date.now() - keyframeStartMs;
1250
1343
  if (!keyframeTimes) {
1251
1344
  logger.warn(
@@ -1289,16 +1382,25 @@ export class HlsSessionManager {
1289
1382
  `keyframes=${keyframeMs === -1 ? "skipped" : keyframeMs === -2 ? "background" : `${keyframeMs}ms`} ` +
1290
1383
  `create-total=${Date.now() - createEntryMs}ms`
1291
1384
  );
1385
+ // Which grid this session is cut on. A copy has no choice: only where the
1386
+ // source already has a keyframe. A re-encode normally takes the even grid —
1387
+ // it produces every frame and may put keyframes where it likes — unless it
1388
+ // is a variant of a keyframe-cut session, in which case it must land on the
1389
+ // same times to be interchangeable with it.
1390
+ const useKeyframeGrid = hasDuration &&
1391
+ Array.isArray(keyframeTimes) &&
1392
+ keyframeTimes.length > 0 &&
1393
+ (!transcodeVideo || inheritedGrid != null);
1292
1394
  const segmentBoundaries = hasDuration
1293
1395
  ? computeSegmentBoundaries({
1294
- transcodeVideo,
1396
+ useKeyframeGrid,
1295
1397
  durationSeconds,
1296
1398
  segDur: this.segmentDurationSec,
1297
1399
  keyframeTimes,
1298
1400
  startTime: sourceStartTime
1299
1401
  })
1300
1402
  : [];
1301
- const usingKeyframeBoundaries = hasDuration && !transcodeVideo && Array.isArray(keyframeTimes);
1403
+ const usingKeyframeBoundaries = useKeyframeGrid;
1302
1404
  const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
1303
1405
 
1304
1406
  // Realtime budget (software encoder): pick the output resolution + libx264
@@ -1399,15 +1501,32 @@ export class HlsSessionManager {
1399
1501
  // VOD playlist bookkeeping.
1400
1502
  useSyntheticPlaylist: hasDuration,
1401
1503
  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.
1504
+ // Segment start times (0-based). The source's real keyframes when this
1505
+ // session is cut on that grid — always for copied video, and for a
1506
+ // re-encoded variant of such a session — otherwise a uniform grid.
1507
+ // Drives the playlist and seeking.
1404
1508
  segmentBoundaries,
1509
+ // Which of the two it is, as a fact about the session rather than
1510
+ // something re-derived from "is the video copied" at each call site. The
1511
+ // two questions came apart the moment a re-encode had to be cut like a
1512
+ // copy.
1513
+ cutGrid: useKeyframeGrid ? "keyframe" : "uniform",
1405
1514
  segmentCount,
1406
1515
  // Real source keyframe times (sorted seconds), or null when the probe
1407
1516
  // failed/timed out. Used by #startEncodeRun to snap a source seek onto a
1408
1517
  // KNOWN valid position instead of trusting the container's own on-the-fly
1409
1518
  // seek at an arbitrary target — see the probe call above for why.
1410
1519
  keyframeTimes,
1520
+ // Which container the index came from, and how well it has held up. The
1521
+ // cut times of a copied video ARE its index, and an index can be wrong —
1522
+ // measured 2026-08-06, one claimed a keyframe four seconds from where the
1523
+ // real ones were. Each produced segment states where it truly begins, so
1524
+ // the comparison costs a subtraction on a piece that is already being
1525
+ // read; this counts them so a session can report what it found. It is
1526
+ // what decides whether a re-encoded rung can be cut on this same grid and
1527
+ // spliced into the copy (roadmap item 28).
1528
+ containerFormat,
1529
+ indexCheck: newIndexCheck(),
1411
1530
  playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries, segmentFormat) : "",
1412
1531
  // Segment index the current ffmpeg run started producing from.
1413
1532
  encodeStartIndex: 0,
@@ -1717,6 +1836,12 @@ export class HlsSessionManager {
1717
1836
  }
1718
1837
  }
1719
1838
 
1839
+ /**
1840
+ * The file's keyframe times from its container index, and which container it
1841
+ * turned out to be.
1842
+ *
1843
+ * @returns {Promise<{ times: number[] | null, format: string }>}
1844
+ */
1720
1845
  async #readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName }) {
1721
1846
  const cacheKey = `${sourceKey}:${fileIndex}`;
1722
1847
  if (this.keyframeIndexCache.has(cacheKey)) {
@@ -1729,10 +1854,10 @@ export class HlsSessionManager {
1729
1854
  const head = await fetch(url, { method: "HEAD" });
1730
1855
  fileSize = Number(head.headers.get("content-length")) || 0;
1731
1856
  } catch {
1732
- return null;
1857
+ return { times: null, format: "unknown" };
1733
1858
  }
1734
1859
  if (fileSize <= 0) {
1735
- return null;
1860
+ return { times: null, format: "unknown" };
1736
1861
  }
1737
1862
 
1738
1863
  const readRange = async (start, end) => {
@@ -1747,9 +1872,9 @@ export class HlsSessionManager {
1747
1872
  }
1748
1873
  };
1749
1874
 
1750
- const times = await readKeyframeIndex({ readRange, fileSize, label: logName });
1751
- this.keyframeIndexCache.set(cacheKey, times);
1752
- return times;
1875
+ const result = await readKeyframeIndex({ readRange, fileSize, label: logName });
1876
+ this.keyframeIndexCache.set(cacheKey, result);
1877
+ return result;
1753
1878
  }
1754
1879
 
1755
1880
  #buildVodPlaylist(boundaries, segmentFormat) {
@@ -2435,6 +2560,14 @@ export class HlsSessionManager {
2435
2560
  `${restartEnteredAt - wantedAt}ms after it was first asked for`
2436
2561
  );
2437
2562
  }
2563
+ // Cleared with the run that could not answer them. These are "how long has
2564
+ // this segment gone unanswered", and the question only means anything about
2565
+ // the run in force: a timestamp kept from an abandoned scan probe minutes
2566
+ // ago says a fresh request has already waited long enough, which is how the
2567
+ // behind-head repair came to fire on the very first poll instead of waiting
2568
+ // for the seek that should move the encoder. It also stops the map growing
2569
+ // for the life of a session.
2570
+ session.firstWantedAt = new Map();
2438
2571
  const generation = ++session.encodeRunGeneration;
2439
2572
  const previousFfmpeg = session.ffmpeg;
2440
2573
  // A suspended process does not act on SIGTERM until it is continued, so the
@@ -2473,10 +2606,26 @@ export class HlsSessionManager {
2473
2606
  }
2474
2607
 
2475
2608
  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).
2609
+ // 0-based output time of this segment, from the boundary table.
2478
2610
  const startSeconds = this.#segmentStartTime(session, safeIndex);
2479
2611
  const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
2612
+ // Cut where this session's grid says, whoever is producing the frames. The
2613
+ // times are measured from the start of THIS run; the same list serves as
2614
+ // the cut points and, when re-encoding, as the keyframes to force — one
2615
+ // list, so the two cannot drift apart.
2616
+ const explicitTimes = session.segmentFormat.explicitTimesMuxerArgs?.() ?? null;
2617
+ // A COPY is cut by this list whatever grid it ended up on. Even when no
2618
+ // keyframe index could be read and the boundaries are a plain grid, saying
2619
+ // them outright is what keeps the playlist and the muxer agreeing — ffmpeg
2620
+ // moves each cut forward to the first real keyframe, and serving reads back
2621
+ // where the piece truly begins. Requiring a keyframe grid here dropped a
2622
+ // copy with no index onto the `hls` muxer, which takes no cut list and
2623
+ // writes no self-contained pieces, so nothing could read a true start and
2624
+ // segments were stamped with times the file does not have — the 4.17 s
2625
+ // speech-against-subtitles drift, back again.
2626
+ const cutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
2627
+ ? segmentCutTimesFrom(session.segmentBoundaries, safeIndex)
2628
+ : null;
2480
2629
 
2481
2630
  // Terminate any existing encode process before starting a new one. The
2482
2631
  // old process's exit handler no-ops because session.ffmpeg is reassigned
@@ -2506,7 +2655,11 @@ export class HlsSessionManager {
2506
2655
  // Software-only; hardware descriptors ignore it.
2507
2656
  preset: session.softwarePreset ?? undefined,
2508
2657
  // HDR→SDR tone map (software path only; gated on filter availability).
2509
- tonemap: session.applyTonemap === true
2658
+ tonemap: session.applyTonemap === true,
2659
+ // On the source's grid the cuts are not evenly spaced, so no frame
2660
+ // count can describe them: the encoder is told the times outright,
2661
+ // the same ones the muxer will cut at.
2662
+ forcedKeyframeTimes: cutTimes
2510
2663
  })
2511
2664
  : ["-c:v", "copy"];
2512
2665
  const audioCodecArgs = session.transcodeAudio
@@ -2519,10 +2672,14 @@ export class HlsSessionManager {
2519
2672
  if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
2520
2673
  args.push(...this.videoEncoder.inputArgs);
2521
2674
  }
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;
2675
+ // Seek position in SOURCE time. On the keyframe grid `startSeconds` is a
2676
+ // real keyframe's offset from zero, so the container's own start time goes
2677
+ // back on to reach it; on the uniform grid it is a plain offset. This
2678
+ // follows the GRID, not whether the video is re-encoded — a variant cut on
2679
+ // the source's keyframes has to seek to them like the copy it accompanies.
2680
+ const seekSeconds = session.cutGrid === "keyframe"
2681
+ ? startSeconds + sourceStartTime
2682
+ : startSeconds;
2526
2683
  // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
2527
2684
  // keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
2528
2685
  // not the container's own on-the-fly seek/index) and trim the short
@@ -2602,11 +2759,10 @@ export class HlsSessionManager {
2602
2759
  // outright. Passing the very boundaries the playlist was built from makes
2603
2760
  // the two agree by construction. Only cut points already known to be real
2604
2761
  // 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
-
2762
+ //
2763
+ // The list is built above, before the encoder args, because a re-encoded
2764
+ // variant of a copied stream needs the same times twice over: once as the
2765
+ // cuts, once as the keyframes to force at them.
2610
2766
  if (cutTimes && cutTimes.length > 0) {
2611
2767
  args.push(
2612
2768
  "-f",
@@ -2991,6 +3147,26 @@ export class HlsSessionManager {
2991
3147
  if (withinWindow) {
2992
3148
  return;
2993
3149
  }
3150
+ // A request BELOW where the run begins is not noise and never will be
3151
+ // satisfied: this encoder only ever moves forward from `head`, so nothing
3152
+ // it does can produce this segment. Every other far request is a claim that
3153
+ // the running encode may yet reach — this one is a hole, and holding it is
3154
+ // holding it for ever.
3155
+ //
3156
+ // Measured 2026-08-11: a run repositioned to #770 while the player needed
3157
+ // #757 held that request for two minutes forty-one, producing 409 s of
3158
+ // video nobody had asked for at 2.48x, until the viewer gave up. That was a
3159
+ // quality switch placing the run wrongly; the placement is fixed, but the
3160
+ // shape must not be able to hang a session again whatever puts it there.
3161
+ //
3162
+ // Waited on rather than acted on at once: a burst that arrives around a
3163
+ // reported seek settles by itself within a moment, and the seek is what
3164
+ // should move the encoder. Only a request still unanswerable after that is
3165
+ // repaired here.
3166
+ if (index < head) {
3167
+ this.#repairBehindHead(session, index, head);
3168
+ return;
3169
+ }
2994
3170
  // Circuit breaker: this exact target has already failed MAX_SEEK_FAILURES
2995
3171
  // times in a row (fast failures — see #wireEncodeProcess's exit handler).
2996
3172
  // Stop auto-retrying it; session.state stays "failed" so getFileStream
@@ -3022,6 +3198,69 @@ export class HlsSessionManager {
3022
3198
  // See research/hls-seek-prior-art-2026-08-02.md.
3023
3199
  }
3024
3200
 
3201
+ /**
3202
+ * Move the encoder back to a segment it can no longer produce.
3203
+ *
3204
+ * A run only ever goes forward from where it began, so a request below that
3205
+ * point is not a claim the run may yet reach — it is a hole, and holding it
3206
+ * holds it for ever. Measured 2026-08-11: a run placed at #770 while the
3207
+ * player needed #757 held that request for two minutes forty-one, producing
3208
+ * 409 s of video nobody had asked for.
3209
+ *
3210
+ * Deliberately narrow, because moving the encoder from a segment REQUEST is
3211
+ * exactly what this codebase removed once already: a player that cannot get
3212
+ * what it wants scans the playlist, and those probes are scattered across the
3213
+ * whole file (field log: #178, #681, #725, #807, #74, #245, #387 within half
3214
+ * a second). Steering on the lowest of them put the encoder at the start of
3215
+ * the film and left the viewer's own requests unreachable ahead of it.
3216
+ *
3217
+ * What separates the two: a run placed wrongly is out by at most the buffer
3218
+ * the player had — 14 segments in the measured case — while a scan probe is
3219
+ * out by anything at all. So only a request within reach behind the head is
3220
+ * repaired; the rest are what they always were, claims that answer 503.
3221
+ *
3222
+ * @param {HlsSession} session
3223
+ * @param {number} index - The segment being held.
3224
+ * @param {number} head - Where the current run begins.
3225
+ * @returns {void}
3226
+ */
3227
+ #repairBehindHead(session, index, head) {
3228
+ if (head - index > BEHIND_HEAD_REPAIR_MAX_SEGMENTS) {
3229
+ return;
3230
+ }
3231
+ // Nothing is encoding: a rung the viewer has switched away from is left
3232
+ // exactly so, and its held requests must not bring its encoder back.
3233
+ if (session.ffmpeg == null || hasChildExited(session.ffmpeg)) {
3234
+ return;
3235
+ }
3236
+ // A seek already settling is about to move the encoder to where the VIEWER
3237
+ // said they are. That statement outranks anything inferred here.
3238
+ if (session.seekSettleTimer != null) {
3239
+ return;
3240
+ }
3241
+ const wantedAt = session.firstWantedAt?.get(index);
3242
+ if (!Number.isFinite(wantedAt) || Date.now() - wantedAt < BEHIND_HEAD_REPAIR_MS) {
3243
+ return;
3244
+ }
3245
+ const target = Math.max(0, index - SEEK_BACKOFF_SEGMENTS);
3246
+ // The breaker has already refused this target repeatedly. Re-arming for it
3247
+ // would log and re-arm on every poll for as long as the request is held,
3248
+ // and move nothing.
3249
+ if (target === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
3250
+ return;
3251
+ }
3252
+ logger.warn(
3253
+ `transcode ${session.id} segment #${index} is behind the run (#${head}) and has waited ` +
3254
+ `${Date.now() - wantedAt}ms — nothing this run does can produce it; moving the encoder there`
3255
+ );
3256
+ // Through the same settle a viewer's own seek goes through, so a burst of
3257
+ // behind-head requests produces one restart and not one each.
3258
+ session.seekTarget = target;
3259
+ session.seekFirstFarAt = Date.now();
3260
+ session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), SEEK_SETTLE_MS);
3261
+ session.seekSettleTimer.unref?.();
3262
+ }
3263
+
3025
3264
  /**
3026
3265
  * Fire a settled server-side seek: restart the encoder once at the target
3027
3266
  * recorded during the settle window. Enforces the restart cooldown as a
@@ -3471,6 +3710,69 @@ export class HlsSessionManager {
3471
3710
  return highest;
3472
3711
  }
3473
3712
 
3713
+ /**
3714
+ * Record how far a produced segment's real start fell from what the playlist
3715
+ * declared for it.
3716
+ *
3717
+ * The playlist's figure comes from the container's keyframe index; the
3718
+ * segment's own figure comes from the piece ffmpeg wrote. The difference IS
3719
+ * the index's error at that boundary, measured without scanning anything —
3720
+ * the piece is already read whole in order to be stamped, and only boundaries
3721
+ * that were actually produced are counted, which is to say the parts somebody
3722
+ * watched.
3723
+ *
3724
+ * Counted once per boundary: a segment can be requested again, and a repeat
3725
+ * is not new evidence.
3726
+ *
3727
+ * @param {HlsSession} session
3728
+ * @param {number} index
3729
+ * @param {number} trueStart - Seconds, read from the piece itself.
3730
+ * @param {number} declaredStart - Seconds, from the playlist.
3731
+ * @returns {void}
3732
+ */
3733
+ #noteIndexAccuracy(session, index, trueStart, declaredStart) {
3734
+ const deviation = Math.abs(trueStart - declaredStart);
3735
+ session.indexCheck ??= newIndexCheck();
3736
+ noteIndexDeviation(session.indexCheck, index, deviation);
3737
+ if (deviation > SEGMENT_START_DISAGREEMENT_SEC) {
3738
+ logger.warn(
3739
+ `transcode ${session.id} segment #${index} really starts at ` +
3740
+ `${trueStart.toFixed(3)}s, the playlist says ${declaredStart.toFixed(3)}s — ` +
3741
+ (session.transcodeVideo
3742
+ // A re-encode was TOLD to put a keyframe here and did not, so this
3743
+ // rung's segments no longer stand where the stream it accompanies
3744
+ // would have put them. That is a broken splice, not a wrong index.
3745
+ ? "this rung did not cut where its grid says; a switch to it will not join cleanly"
3746
+ : "the container's keyframe index disagrees with the file; using the file")
3747
+ );
3748
+ }
3749
+ }
3750
+
3751
+ /**
3752
+ * What this session learned about its container's keyframe index, as one
3753
+ * line, at the end.
3754
+ *
3755
+ * Written even when nothing disagreed, because that is the finding: with only
3756
+ * the per-boundary warning, silence could not be told from nobody having
3757
+ * watched. Skipped for a session that checked nothing, which says neither.
3758
+ *
3759
+ * @param {HlsSession} session
3760
+ * @returns {void}
3761
+ */
3762
+ #logIndexAccuracy(session) {
3763
+ const check = session.indexCheck;
3764
+ if (!check || check.checked === 0) {
3765
+ return;
3766
+ }
3767
+ logger.info(
3768
+ `keyframe-index ${session.containerFormat || "unknown"} "${session.fileName}": ` +
3769
+ `${check.disagreed} of ${check.checked} produced segments started away from the playlist, ` +
3770
+ `worst ${check.maxDeviationSec.toFixed(3)}s` +
3771
+ (check.firstDisagreementIndex >= 0 ? ` (first at #${check.firstDisagreementIndex})` : "") +
3772
+ ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s]`
3773
+ );
3774
+ }
3775
+
3474
3776
  /**
3475
3777
  * Which variant a session IS, as a height. Zero encode height means "keep the
3476
3778
  * source", so the source's own height is the answer.
@@ -3557,6 +3859,26 @@ export class HlsSessionManager {
3557
3859
  * @param {HlsSession} session
3558
3860
  * @returns {number}
3559
3861
  */
3862
+ /**
3863
+ * Where a variant's first encode run should begin, in seconds.
3864
+ *
3865
+ * The segment the player asked for, when there is one: after a level switch
3866
+ * hls.js discards what it had buffered ahead and fetches from the picture's
3867
+ * own position, so its first request IS that position. Falling back to the
3868
+ * rung being left means falling back to that rung's READ head, which sits a
3869
+ * whole buffer further on.
3870
+ *
3871
+ * @param {HlsSession} base
3872
+ * @param {number} wantedIndex - Segment index asked for, or -1.
3873
+ * @returns {number}
3874
+ */
3875
+ #variantStartSeconds(base, wantedIndex) {
3876
+ if (Number.isInteger(wantedIndex) && wantedIndex >= 0) {
3877
+ return this.#segmentStartTime(base, wantedIndex);
3878
+ }
3879
+ return this.#viewerPositionOf(this.#activeVariant(base));
3880
+ }
3881
+
3560
3882
  #viewerPositionOf(session) {
3561
3883
  if (Number.isFinite(session.viewerPositionSeconds) && session.viewerPositionSeconds > 0) {
3562
3884
  return session.viewerPositionSeconds;
@@ -3633,7 +3955,7 @@ export class HlsSessionManager {
3633
3955
  * @returns {Promise<HlsSession | null>} Null when the base session is unknown,
3634
3956
  * or the height is not offered for it.
3635
3957
  */
3636
- async resolveVariantSession(baseSessionId, height) {
3958
+ async resolveVariantSession(baseSessionId, height, wantedIndex = -1) {
3637
3959
  if (!isSafeSessionId(baseSessionId)) {
3638
3960
  return null;
3639
3961
  }
@@ -3683,12 +4005,21 @@ export class HlsSessionManager {
3683
4005
  consumerId: variantConsumerId(base.id),
3684
4006
  targetWidth: 0,
3685
4007
  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,
4008
+ // Where this variant must begin. The segment the player asked it for when
4009
+ // it can be known that is the player stating outright where it will
4010
+ // start fetching, and it is the only figure that cannot be stale.
4011
+ //
4012
+ // The other rung's read head is NOT that figure, and using it cost a
4013
+ // stuck session on 2026-08-11: a 240p rung encoding at 5-6x had read 56 s
4014
+ // further than the picture had played, so switching back to 400p placed
4015
+ // that run at 3084 s while the player needed 3028 s, and no segment it
4016
+ // wanted was ever produced.
4017
+ //
4018
+ // Floored onto the ten-second grid that session keys are bucketed to:
4019
+ // rounding is what that bucket does, and a position rounded UP starts the
4020
+ // run past the viewer, so the run just spawned is killed and restarted
4021
+ // before it has produced anything.
4022
+ startPositionSeconds: Math.floor(this.#variantStartSeconds(base, wantedIndex) / 10) * 10,
3692
4023
  audioTrackIndex: base.audioTrackIndex,
3693
4024
  // A variant is a resolution the viewer chose, so it is encoded at exactly
3694
4025
  // that size and the realtime budget does not move it — otherwise two
@@ -3696,6 +4027,13 @@ export class HlsSessionManager {
3696
4027
  // nothing.
3697
4028
  manualQuality: true,
3698
4029
  segmentFormatId: base.segmentFormat?.id ?? "",
4030
+ // Cut where the base is cut. Only for a base on the source's own keyframe
4031
+ // grid — a copy — where the variant has to land on those exact times to
4032
+ // be interchangeable with it. A base on the uniform grid needs nothing
4033
+ // passed: the variant computes the same even grid from the same duration.
4034
+ inheritedGrid: base.cutGrid === "keyframe"
4035
+ ? { keyframeTimes: base.keyframeTimes, containerFormat: base.containerFormat }
4036
+ : null,
3699
4037
  acquireSource: base.acquireSource
3700
4038
  })
3701
4039
  .then(async (variant) => {
@@ -3771,7 +4109,11 @@ export class HlsSessionManager {
3771
4109
  }
3772
4110
  let variant;
3773
4111
  try {
3774
- variant = await this.resolveVariantSession(baseSessionId, height);
4112
+ variant = await this.resolveVariantSession(
4113
+ baseSessionId,
4114
+ height,
4115
+ isSegment ? base.segmentFormat.segmentIndexFromName(fileName) : -1
4116
+ );
3775
4117
  } catch (error) {
3776
4118
  const message = error instanceof Error ? error.message : String(error);
3777
4119
  logger.error(
@@ -3783,9 +4125,10 @@ export class HlsSessionManager {
3783
4125
  if (!variant) {
3784
4126
  return { sessionId: null };
3785
4127
  }
3786
- // Only a SEGMENT says the viewer is watching this rung.
4128
+ // Only a SEGMENT says the viewer is watching this rung — and it says more
4129
+ // than that: it names the exact segment the player wants from it.
3787
4130
  if (isSegment) {
3788
- this.#noteVariantActive(base, variant);
4131
+ this.#noteVariantActive(base, variant, variant.segmentFormat.segmentIndexFromName(fileName));
3789
4132
  }
3790
4133
  return { sessionId: variant.id };
3791
4134
  }
@@ -3800,14 +4143,15 @@ export class HlsSessionManager {
3800
4143
  *
3801
4144
  * @param {HlsSession} base
3802
4145
  * @param {HlsSession} variant
4146
+ * @param {number} wantedIndex - The segment this rung was just asked for.
3803
4147
  * @returns {void}
3804
4148
  */
3805
- #noteVariantActive(base, variant) {
4149
+ #noteVariantActive(base, variant, wantedIndex = -1) {
3806
4150
  const previous = this.#activeVariant(base);
3807
4151
  if (previous.id === variant.id) {
3808
4152
  return;
3809
4153
  }
3810
- const position = this.#viewerPositionOf(previous);
4154
+ const position = this.#variantStartSeconds(base, wantedIndex);
3811
4155
  base.activeVariantId = variant.id;
3812
4156
  logger.info(
3813
4157
  `transcode ${base.id} variant now ${this.variantHeightOf(variant)}p ` +
@@ -3840,21 +4184,32 @@ export class HlsSessionManager {
3840
4184
  * variant, appends it after what is already buffered, and changes the
3841
4185
  * decoder's type if the codec parameters differ.
3842
4186
  *
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.
4187
+ * Offered only where the variants can actually be joined, which is a question
4188
+ * about the CUT GRID and not about who produces the frames:
4189
+ *
4190
+ * - a re-encoded session on the uniform grid its variants are re-encoded on
4191
+ * the same one, keyframes forced onto it;
4192
+ * - a session cut at the source's own keyframes — a copy, which has no other
4193
+ * choice — where the variants are re-encoded and forced onto those very
4194
+ * times, so a rung's segment covers the same span as the copy's.
4195
+ *
4196
+ * What is refused is a session whose own grid is a fiction: a copy with no
4197
+ * readable keyframe index falls back to an even grid that ffmpeg then does
4198
+ * not cut on, and nothing can be aligned to that.
3847
4199
  *
3848
4200
  * @param {string} sessionId
3849
4201
  * @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.
4202
+ * to choose between, or nothing to align to.
3851
4203
  */
3852
4204
  buildMasterPlaylist(sessionId) {
3853
4205
  if (!isSafeSessionId(sessionId)) {
3854
4206
  return null;
3855
4207
  }
3856
4208
  const session = this.sessionsById.get(sessionId);
3857
- if (!session || session.state === "disposed" || !session.transcodeVideo) {
4209
+ if (!session || session.state === "disposed") {
4210
+ return null;
4211
+ }
4212
+ if (!session.transcodeVideo && session.cutGrid !== "keyframe") {
3858
4213
  return null;
3859
4214
  }
3860
4215
  const sourceHeight = Number(session.sourceHeight) || 0;
@@ -4191,12 +4546,8 @@ export class HlsSessionManager {
4191
4546
  ? session.segmentFormat.readSegmentStartSeconds?.(raw) ?? null
4192
4547
  : null;
4193
4548
  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
- );
4549
+ if (trueStart !== null) {
4550
+ this.#noteIndexAccuracy(session, index, trueStart, declaredStart);
4200
4551
  }
4201
4552
  const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
4202
4553
  startSeconds: trueStart ?? declaredStart,
@@ -4506,6 +4857,7 @@ export class HlsSessionManager {
4506
4857
  session.state = "disposed";
4507
4858
  this.sessionsById.delete(sessionId);
4508
4859
  this.sessionIdBySource.delete(session.sourceMapKey);
4860
+ this.#logIndexAccuracy(session);
4509
4861
 
4510
4862
  // A variant serves this session's viewer and nobody learns its id but us,
4511
4863
  // so it would otherwise encode and occupy disk for no one until its idle