@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.
- package/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-warm/get.js +71 -0
- package/server.js +7 -0
- package/services/container-index/index.js +10 -6
- package/services/hls-session-manager.js +561 -64
- package/services/hwaccel.js +55 -16
- package/test/behind-head-repair.test.js +187 -0
- package/test/keyframe-index-accuracy.test.js +68 -0
- package/test/quality-variants.test.js +111 -20
- package/test/segment-serve-wiring.test.js +50 -34
|
@@ -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
|
-
*
|
|
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({
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1424
|
+
useKeyframeGrid,
|
|
1295
1425
|
durationSeconds,
|
|
1296
1426
|
segDur: this.segmentDurationSec,
|
|
1297
1427
|
keyframeTimes,
|
|
1298
1428
|
startTime: sourceStartTime
|
|
1299
1429
|
})
|
|
1300
1430
|
: [];
|
|
1301
|
-
const usingKeyframeBoundaries =
|
|
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
|
|
1316
|
-
|
|
1317
|
-
:
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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).
|
|
1403
|
-
//
|
|
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
|
|
1492
|
-
|
|
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
|
|
1751
|
-
this.keyframeIndexCache.set(cacheKey,
|
|
1752
|
-
return
|
|
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
|
|
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.
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
2525
|
-
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
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
|
|
3687
|
-
//
|
|
3688
|
-
//
|
|
3689
|
-
//
|
|
3690
|
-
//
|
|
3691
|
-
|
|
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(
|
|
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.#
|
|
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
|
|
3844
|
-
*
|
|
3845
|
-
*
|
|
3846
|
-
*
|
|
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
|
|
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"
|
|
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
|
|
4195
|
-
|
|
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
|