@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.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/services/container-index/index.js +10 -6
- package/services/hls-session-manager.js +404 -52
- 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 +84 -20
- package/test/segment-serve-wiring.test.js +50 -34
|
@@ -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
|
-
*
|
|
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({
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1396
|
+
useKeyframeGrid,
|
|
1295
1397
|
durationSeconds,
|
|
1296
1398
|
segDur: this.segmentDurationSec,
|
|
1297
1399
|
keyframeTimes,
|
|
1298
1400
|
startTime: sourceStartTime
|
|
1299
1401
|
})
|
|
1300
1402
|
: [];
|
|
1301
|
-
const usingKeyframeBoundaries =
|
|
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).
|
|
1403
|
-
//
|
|
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
|
|
1751
|
-
this.keyframeIndexCache.set(cacheKey,
|
|
1752
|
-
return
|
|
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
|
|
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.
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
2525
|
-
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
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
|
|
3687
|
-
//
|
|
3688
|
-
//
|
|
3689
|
-
//
|
|
3690
|
-
//
|
|
3691
|
-
|
|
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(
|
|
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.#
|
|
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
|
|
3844
|
-
*
|
|
3845
|
-
*
|
|
3846
|
-
*
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
);
|
|
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
|