@torrent-tv/proxy 2.12.2 → 2.13.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 +18 -0
- package/assets/calibration/NOTICE.md +30 -0
- package/assets/calibration/cal-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-720.mp4 +0 -0
- package/bin/cli.js +6 -1
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +7 -0
- package/server.js +22 -3
- package/services/ffmpeg-banner.js +42 -0
- package/services/hls-session-manager.js +461 -20
- package/services/hwaccel.js +478 -12
- package/services/playback-planner.js +36 -3
- package/test/decode-cost.test.js +401 -0
|
@@ -13,9 +13,9 @@ import { Readable } from "node:stream";
|
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
16
17
|
import { spawn } from "node:child_process";
|
|
17
18
|
import { createRequire } from "node:module";
|
|
18
|
-
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { logger } from "../utils/logger.js";
|
|
20
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
21
21
|
|
|
@@ -25,10 +25,13 @@ import {
|
|
|
25
25
|
softwareDescriptor,
|
|
26
26
|
chooseSoftwareEncodeSettings,
|
|
27
27
|
pickSoftwarePreset,
|
|
28
|
+
canSustainOutput,
|
|
29
|
+
REALTIME_SPEED_MARGIN,
|
|
28
30
|
TRANSCODE_FPS,
|
|
29
31
|
chooseOutputFps
|
|
30
32
|
} from "./hwaccel.js";
|
|
31
33
|
import {
|
|
34
|
+
parseFfmpegBitrateKbps,
|
|
32
35
|
parseFfmpegDurationSeconds,
|
|
33
36
|
parseFfmpegStartTimeSeconds,
|
|
34
37
|
parseFfmpegVideoDimensions,
|
|
@@ -112,6 +115,31 @@ export function variantHeightsFor(sourceHeight) {
|
|
|
112
115
|
return [Math.round(sourceHeight), ...rungs];
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
/**
|
|
119
|
+
* What a source costs to DECODE, in the two figures the startup fit prices:
|
|
120
|
+
* its pixel rate and its bitrate. Every re-encode of this file pays this,
|
|
121
|
+
* whatever height it is encoded to, because the whole source is decoded first.
|
|
122
|
+
*
|
|
123
|
+
* Returns null when the probe did not report enough — the budget then prices
|
|
124
|
+
* the encoder alone rather than inventing a figure.
|
|
125
|
+
*
|
|
126
|
+
* @param {{ width: number | null, height: number | null, fps: number | null, bitrateKbps: number | null }} mediaInfo
|
|
127
|
+
* @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number } | null}
|
|
128
|
+
*/
|
|
129
|
+
export function sourceDecodeCharacteristics(mediaInfo) {
|
|
130
|
+
const width = Number(mediaInfo?.width);
|
|
131
|
+
const height = Number(mediaInfo?.height);
|
|
132
|
+
const fps = Number(mediaInfo?.fps);
|
|
133
|
+
const kbps = Number(mediaInfo?.bitrateKbps);
|
|
134
|
+
if (!(width > 0) || !(height > 0) || !(fps > 0) || !(kbps > 0)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
megapixelsPerSecond: (width * height * fps) / 1e6,
|
|
139
|
+
megabitsPerSecond: kbps / 1000
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
115
143
|
/**
|
|
116
144
|
* A fresh tally of how well a container's keyframe index matches its file.
|
|
117
145
|
*
|
|
@@ -166,20 +194,51 @@ export function noteIndexDeviation(check, index, deviationSec) {
|
|
|
166
194
|
* @param {{ ladder: { width: number, height: number }[], rungIndex: number } | null} budget
|
|
167
195
|
* @param {number} outputFps
|
|
168
196
|
* @param {unknown} benchmark
|
|
197
|
+
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
|
|
169
198
|
* @returns {object | null}
|
|
170
199
|
*/
|
|
171
|
-
function startAtLadderTop(budget, outputFps, benchmark) {
|
|
172
|
-
const
|
|
200
|
+
function startAtLadderTop(budget, outputFps, benchmark, cost = {}) {
|
|
201
|
+
const ladder = budget?.ladder;
|
|
202
|
+
const top = ladder?.[0];
|
|
173
203
|
if (!top) {
|
|
174
204
|
return null;
|
|
175
205
|
}
|
|
176
206
|
const fps = Number.isInteger(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
|
|
207
|
+
// The top rung the viewer asked for, unless this host cannot hold it. A
|
|
208
|
+
// request naming a height arrives from a browser that was told which heights
|
|
209
|
+
// are on offer — but an older page, a stale tab or a repeated URL can still
|
|
210
|
+
// name one that was refused, and starting there means the encode never
|
|
211
|
+
// catches up. The runtime downshift would eventually step down; starting
|
|
212
|
+
// where the host can hold it means the viewer does not watch that happen.
|
|
213
|
+
// When NOTHING on the ladder can be held, start at its foot — the smallest
|
|
214
|
+
// picture this host has, which is the automatic path's answer to the same
|
|
215
|
+
// question and the best effort available. Starting at the top instead would
|
|
216
|
+
// hand the weakest hosts, the ones this exists for, the heaviest rung.
|
|
217
|
+
let startIndex = ladder.length - 1;
|
|
218
|
+
for (let index = 0; index < ladder.length; index += 1) {
|
|
219
|
+
const { sustainable } = canSustainOutput({
|
|
220
|
+
benchmark,
|
|
221
|
+
decodeModel: cost.decodeModel ?? null,
|
|
222
|
+
source: cost.source ?? null,
|
|
223
|
+
outputPixelsPerSec: ladder[index].width * ladder[index].height * fps,
|
|
224
|
+
observedDecodeCostSec: cost.observedDecodeCostSec ?? null
|
|
225
|
+
});
|
|
226
|
+
if (sustainable) {
|
|
227
|
+
startIndex = index;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const start = ladder[startIndex];
|
|
177
232
|
return {
|
|
178
233
|
...budget,
|
|
179
|
-
width:
|
|
180
|
-
height:
|
|
181
|
-
|
|
182
|
-
|
|
234
|
+
width: start.width,
|
|
235
|
+
height: start.height,
|
|
236
|
+
// Priced the same way the offer was. Without the cost the preset came from
|
|
237
|
+
// the encoder alone — so a rung offered on the combined figure was then
|
|
238
|
+
// encoded with a preset chosen as if decoding were free, which is how the
|
|
239
|
+
// check and the encode came to disagree on every rung a viewer picks.
|
|
240
|
+
preset: pickSoftwarePreset(benchmark, start.width * start.height * fps, cost),
|
|
241
|
+
rungIndex: startIndex
|
|
183
242
|
};
|
|
184
243
|
}
|
|
185
244
|
|
|
@@ -363,6 +422,18 @@ const BUDGET_SUSTAINED_MS = 15_000;
|
|
|
363
422
|
const BUDGET_ACTION_COOLDOWN_MS = 30_000;
|
|
364
423
|
// Never step down more than this many rungs below the startup choice.
|
|
365
424
|
const BUDGET_MAX_DOWNSHIFTS = 3;
|
|
425
|
+
// How long an encode run must have been going before a reading of its speed is
|
|
426
|
+
// taken as evidence about decoding. `speed=` is cumulative over the run, so a
|
|
427
|
+
// restart after a seek, a resume after a suspension and the wait for the first
|
|
428
|
+
// pieces all sit in the denominator of an early reading.
|
|
429
|
+
const DECODE_LEARNING_SETTLE_MS = 20_000;
|
|
430
|
+
// How many readings the median is taken over. Long enough to outvote a single
|
|
431
|
+
// disturbed moment, short enough to follow a host whose load has changed.
|
|
432
|
+
const DECODE_LEARNING_READINGS = 7;
|
|
433
|
+
// A new median has to differ by this much to be adopted. Below it the answer is
|
|
434
|
+
// the same one, and re-publishing it would make every session recompute its
|
|
435
|
+
// offer on the path that serves every playlist, init and segment.
|
|
436
|
+
const DECODE_LEARNING_CHANGE = 0.05;
|
|
366
437
|
// The input counts as "keeping up" when the torrent downloads at least this
|
|
367
438
|
// multiple of the source's average byte rate. Below it (and not yet fully
|
|
368
439
|
// downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
|
|
@@ -621,6 +692,7 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
|
621
692
|
const dims = parseFfmpegVideoDimensions(stderr);
|
|
622
693
|
resolve({
|
|
623
694
|
durationSeconds: parseFfmpegDurationSeconds(stderr),
|
|
695
|
+
bitrateKbps: parseFfmpegBitrateKbps(stderr),
|
|
624
696
|
width: dims.width,
|
|
625
697
|
height: dims.height,
|
|
626
698
|
fps: parseFfmpegVideoFps(stderr),
|
|
@@ -1051,6 +1123,21 @@ export class HlsSessionManager {
|
|
|
1051
1123
|
*/
|
|
1052
1124
|
#sessionCreateLatencies = [];
|
|
1053
1125
|
|
|
1126
|
+
/**
|
|
1127
|
+
* What decoding costs for a source this proxy has actually run, keyed by
|
|
1128
|
+
* `sourceKey:fileIndex` — seconds of work per second of video, with a version
|
|
1129
|
+
* that rises whenever a faster reading replaces the one held.
|
|
1130
|
+
*
|
|
1131
|
+
* The startup clips are H.264 and a source that has to be re-encoded usually
|
|
1132
|
+
* is not, so their model is a first approximation. This is the file itself,
|
|
1133
|
+
* measured by the encoder that is running on it, and it replaces the model
|
|
1134
|
+
* for that file as soon as it exists. Held for the life of the process: it
|
|
1135
|
+
* describes a source, and the same source is commonly opened again.
|
|
1136
|
+
*
|
|
1137
|
+
* @type {Map<string, { costSec: number, version: number }>}
|
|
1138
|
+
*/
|
|
1139
|
+
#observedDecodeCost = new Map();
|
|
1140
|
+
|
|
1054
1141
|
/**
|
|
1055
1142
|
* @param {HlsSessionManagerOptions} options
|
|
1056
1143
|
*/
|
|
@@ -1064,13 +1151,19 @@ export class HlsSessionManager {
|
|
|
1064
1151
|
startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
|
|
1065
1152
|
videoEncoder = null,
|
|
1066
1153
|
softwarePresetBenchmark = null,
|
|
1154
|
+
decodeCostModel = null,
|
|
1067
1155
|
getSourceStats = null,
|
|
1068
1156
|
tonemapSupported = false,
|
|
1069
1157
|
getCachedMediaInfo = null,
|
|
1070
|
-
segmentFormatId = undefined
|
|
1158
|
+
segmentFormatId = undefined,
|
|
1159
|
+
stateDir = ""
|
|
1071
1160
|
}) {
|
|
1072
1161
|
this.enabled = Boolean(enabled);
|
|
1073
1162
|
this.ffmpegBin = ffmpegBin;
|
|
1163
|
+
// Where measurements about this host are kept between runs. Empty means
|
|
1164
|
+
// beside the installed proxy; a deployment with somewhere persistent to
|
|
1165
|
+
// write names it (--state-dir).
|
|
1166
|
+
this.stateDir = typeof stateDir === "string" ? stateDir : "";
|
|
1074
1167
|
// Output container (fMP4/CMAF or MPEG-TS). Everything container-specific —
|
|
1075
1168
|
// muxer args, file naming, playlist header, per-segment correction — lives
|
|
1076
1169
|
// in this module; nothing here branches on the format.
|
|
@@ -1090,6 +1183,13 @@ export class HlsSessionManager {
|
|
|
1090
1183
|
// used to pick the best preset per stream. Null when unavailable (hardware
|
|
1091
1184
|
// encoder, or benchmark skipped/failed).
|
|
1092
1185
|
this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
|
|
1186
|
+
// Host decode cost solved at startup from the calibration clips:
|
|
1187
|
+
// `a × Mpixel/s + b × Mbit/s + c` seconds of decoding per second of video.
|
|
1188
|
+
// A re-encode pays for this as well as for the encoder, and leaving it out
|
|
1189
|
+
// is what made the budget offer rungs this host ran at a third of realtime.
|
|
1190
|
+
// Null when the clips are missing or the fit was rejected — the budget then
|
|
1191
|
+
// prices the encoder alone, as it did before.
|
|
1192
|
+
this.decodeCostModel = decodeCostModel ?? null;
|
|
1093
1193
|
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
1094
1194
|
// Gates the tonemap chain for HDR sources on the software path.
|
|
1095
1195
|
this.tonemapSupported = Boolean(tonemapSupported);
|
|
@@ -1450,13 +1550,17 @@ export class HlsSessionManager {
|
|
|
1450
1550
|
// resolution, so encode exactly that box (capped to source by the scale
|
|
1451
1551
|
// filter) with the default preset, and the runtime downswitch is skipped
|
|
1452
1552
|
// for the session (budgetLadder stays null).
|
|
1553
|
+
// What decoding this source costs, which every re-encode pays on top of
|
|
1554
|
+
// the encoder. Read from the probe; null when it did not say enough.
|
|
1555
|
+
const sourceDecode = sourceDecodeCharacteristics(mediaInfo);
|
|
1453
1556
|
const chosenBudget = this.#chooseEncodeBudget({
|
|
1454
1557
|
transcodeVideo,
|
|
1455
1558
|
targetWidth: normalizedTargetWidth,
|
|
1456
1559
|
targetHeight: normalizedTargetHeight,
|
|
1457
1560
|
sourceWidth,
|
|
1458
1561
|
sourceHeight,
|
|
1459
|
-
outputFps
|
|
1562
|
+
outputFps,
|
|
1563
|
+
source: sourceDecode
|
|
1460
1564
|
});
|
|
1461
1565
|
// A forced resolution starts at exactly that size — the viewer asked for it
|
|
1462
1566
|
// — but KEEPS the ladder beneath it. Discarding the ladder is what left a
|
|
@@ -1466,7 +1570,13 @@ export class HlsSessionManager {
|
|
|
1466
1570
|
// picture that plays beats a correct label that freezes. The rung's NAME is
|
|
1467
1571
|
// settled separately and does not move with a downshift, so the player goes
|
|
1468
1572
|
// on addressing it by the height it chose.
|
|
1469
|
-
const encodeBudget = forceManualQuality
|
|
1573
|
+
const encodeBudget = forceManualQuality
|
|
1574
|
+
? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark, {
|
|
1575
|
+
decodeModel: this.decodeCostModel,
|
|
1576
|
+
source: sourceDecode,
|
|
1577
|
+
observedDecodeCostSec: this.#observedDecodeCost.get(`${sourceKey}:${fileIndex}`)?.costSec ?? null
|
|
1578
|
+
})
|
|
1579
|
+
: chosenBudget;
|
|
1470
1580
|
const softwarePreset = encodeBudget?.preset ?? null;
|
|
1471
1581
|
// Effective encode box: the budget's downscaled resolution when applied,
|
|
1472
1582
|
// otherwise the client target (0 = keep source, handled by buildVideoArgs).
|
|
@@ -1514,6 +1624,15 @@ export class HlsSessionManager {
|
|
|
1514
1624
|
// software hosts, else the client target). 0 = keep source.
|
|
1515
1625
|
encodeWidth,
|
|
1516
1626
|
encodeHeight,
|
|
1627
|
+
// The NAME of this rung, fixed at the height that was asked for. It is
|
|
1628
|
+
// deliberately not the height being encoded: a viewer who picked 480p on
|
|
1629
|
+
// a host that then starts them at 360p, or steps down to it later, goes
|
|
1630
|
+
// on addressing the rung as 480p — and a request under the old name must
|
|
1631
|
+
// not build a second session at a height this host has just refused.
|
|
1632
|
+
// Derived from `encodeHeight` when nothing was named, as before.
|
|
1633
|
+
variantHeight: forceManualQuality && normalizedTargetHeight > 0
|
|
1634
|
+
? normalizedTargetHeight
|
|
1635
|
+
: undefined,
|
|
1517
1636
|
// Whether to insert the HDR→SDR tone-map chain (software path only).
|
|
1518
1637
|
applyTonemap,
|
|
1519
1638
|
// Realtime-budget runtime state (software encoder only). The ladder is the
|
|
@@ -1531,6 +1650,8 @@ export class HlsSessionManager {
|
|
|
1531
1650
|
linkSlowSince: 0,
|
|
1532
1651
|
sourceWidth,
|
|
1533
1652
|
sourceHeight,
|
|
1653
|
+
// Pixel rate and bitrate of the source, for pricing a re-encode of it.
|
|
1654
|
+
sourceDecode,
|
|
1534
1655
|
// Container start time (seconds); subtracted on the copy path so the
|
|
1535
1656
|
// output timeline is 0-based even when the source starts at e.g. 0.1 s.
|
|
1536
1657
|
sourceStartTime,
|
|
@@ -2011,10 +2132,10 @@ export class HlsSessionManager {
|
|
|
2011
2132
|
* (no video transcode, hardware encoder, or missing benchmark/source size) —
|
|
2012
2133
|
* the encode then keeps the ceiling resolution and the default preset.
|
|
2013
2134
|
*
|
|
2014
|
-
* @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number }} params
|
|
2135
|
+
* @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
|
|
2015
2136
|
* @returns {{ width: number, height: number, preset: string } | null}
|
|
2016
2137
|
*/
|
|
2017
|
-
#chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps }) {
|
|
2138
|
+
#chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps, source = null }) {
|
|
2018
2139
|
if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
|
|
2019
2140
|
return null;
|
|
2020
2141
|
}
|
|
@@ -2022,7 +2143,12 @@ export class HlsSessionManager {
|
|
|
2022
2143
|
if (!ceiling) {
|
|
2023
2144
|
return null;
|
|
2024
2145
|
}
|
|
2025
|
-
return chooseSoftwareEncodeSettings(
|
|
2146
|
+
return chooseSoftwareEncodeSettings(
|
|
2147
|
+
this.softwarePresetBenchmark,
|
|
2148
|
+
{ width: ceiling.w, height: ceiling.h },
|
|
2149
|
+
outputFps,
|
|
2150
|
+
{ decodeModel: this.decodeCostModel, source }
|
|
2151
|
+
);
|
|
2026
2152
|
}
|
|
2027
2153
|
|
|
2028
2154
|
/**
|
|
@@ -2439,6 +2565,10 @@ export class HlsSessionManager {
|
|
|
2439
2565
|
}
|
|
2440
2566
|
if (speed >= BUDGET_SPEED_OK) {
|
|
2441
2567
|
session.budgetSlowSince = 0; // recovered — reset the slow window
|
|
2568
|
+
// A run keeping up with realtime cannot be badly starved of input, so
|
|
2569
|
+
// what it reports is about this machine and this source. That is the
|
|
2570
|
+
// reading worth learning from, and it needs no further check.
|
|
2571
|
+
this.#learnDecodeCost(session, speed);
|
|
2442
2572
|
continue;
|
|
2443
2573
|
}
|
|
2444
2574
|
if (speed >= BUDGET_SPEED_SLOW) {
|
|
@@ -2466,6 +2596,10 @@ export class HlsSessionManager {
|
|
|
2466
2596
|
session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
|
|
2467
2597
|
continue;
|
|
2468
2598
|
}
|
|
2599
|
+
// Sustained, and the encoder — not the torrent — is what is short. So the
|
|
2600
|
+
// figure describes the machine on this source, and it is the case that
|
|
2601
|
+
// matters most: a rung nobody can hold teaches exactly here.
|
|
2602
|
+
this.#learnDecodeCost(session, speed);
|
|
2469
2603
|
await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
|
|
2470
2604
|
}
|
|
2471
2605
|
}
|
|
@@ -2529,7 +2663,20 @@ export class HlsSessionManager {
|
|
|
2529
2663
|
session.budgetSlowSince = 0;
|
|
2530
2664
|
session.encodeWidth = rung.width;
|
|
2531
2665
|
session.encodeHeight = rung.height;
|
|
2532
|
-
|
|
2666
|
+
// Priced the same way the offer and the starting rung are. Choosing the
|
|
2667
|
+
// preset on the encoder alone treats decoding as free, which is how the
|
|
2668
|
+
// check and the encode came to disagree in the first place — and here it
|
|
2669
|
+
// matters most, because this runs on a host that has already failed to keep
|
|
2670
|
+
// up and is spending one of its few downshifts.
|
|
2671
|
+
session.softwarePreset = pickSoftwarePreset(
|
|
2672
|
+
this.softwarePresetBenchmark,
|
|
2673
|
+
rung.width * rung.height * fps,
|
|
2674
|
+
{
|
|
2675
|
+
decodeModel: this.decodeCostModel,
|
|
2676
|
+
source: session.sourceDecode ?? null,
|
|
2677
|
+
observedDecodeCostSec: this.#observedDecodeCostFor(session)
|
|
2678
|
+
}
|
|
2679
|
+
);
|
|
2533
2680
|
// Restart at the current live-edge segment so the lighter profile takes over
|
|
2534
2681
|
// from where the viewer is watching (hard-restart tier).
|
|
2535
2682
|
const head = session.encodeStartIndex;
|
|
@@ -2589,6 +2736,10 @@ export class HlsSessionManager {
|
|
|
2589
2736
|
// produce a file that is neither, which is the only reason a restart ever
|
|
2590
2737
|
// had to wait for its predecessor to die.
|
|
2591
2738
|
session.runSerial = (session.runSerial ?? 0) + 1;
|
|
2739
|
+
// When THIS run began. ffmpeg's `speed=` is cumulative over a run, so a
|
|
2740
|
+
// reading of it says something about the machine only once the run has left
|
|
2741
|
+
// its own start behind — see #learnDecodeCost.
|
|
2742
|
+
session.encodeRunStartedAt = Date.now();
|
|
2592
2743
|
session.runDirPath = path.join(session.dirPath, `run-${session.runSerial}`);
|
|
2593
2744
|
await mkdir(session.runDirPath, { recursive: true });
|
|
2594
2745
|
// The restart backs off a segment or two from what was asked for, so the
|
|
@@ -3611,8 +3762,20 @@ export class HlsSessionManager {
|
|
|
3611
3762
|
* @returns {number | null} Milliseconds, or null without a benchmark.
|
|
3612
3763
|
*/
|
|
3613
3764
|
/**
|
|
3614
|
-
* Where this host's recorded timings live:
|
|
3615
|
-
* beside the proxy
|
|
3765
|
+
* Where this host's recorded timings live: `--state-dir` when the deployment
|
|
3766
|
+
* names one, otherwise beside the installed proxy, which is where they have
|
|
3767
|
+
* always been kept.
|
|
3768
|
+
*
|
|
3769
|
+
* The default is deliberately the old location and not the working directory:
|
|
3770
|
+
* measured on the addon, both are inside the container's writable layer and
|
|
3771
|
+
* both are discarded when an update rebuilds it, so moving there bought
|
|
3772
|
+
* nothing — while for an ordinary `npm i -g` install the working directory is
|
|
3773
|
+
* wherever the operator happened to launch from, which splits the history
|
|
3774
|
+
* between runs and drops a file into someone's project.
|
|
3775
|
+
*
|
|
3776
|
+
* A deployment that HAS a persistent directory says so: the addon passes
|
|
3777
|
+
* `/data`, the one path its supervisor keeps across updates. Naming it here
|
|
3778
|
+
* would put Home Assistant into proxy code, which this repo does not do.
|
|
3616
3779
|
*
|
|
3617
3780
|
* Kept so a proxy that has just restarted is not back to knowing nothing —
|
|
3618
3781
|
* the browser was shown an assumed rate for the whole of the first wait after
|
|
@@ -3624,7 +3787,10 @@ export class HlsSessionManager {
|
|
|
3624
3787
|
* @returns {string}
|
|
3625
3788
|
*/
|
|
3626
3789
|
#hostTimingsPath() {
|
|
3627
|
-
|
|
3790
|
+
const stateDir = typeof this.stateDir === "string" && this.stateDir.length > 0
|
|
3791
|
+
? this.stateDir
|
|
3792
|
+
: path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
3793
|
+
return path.join(stateDir, "host-timings.json");
|
|
3628
3794
|
}
|
|
3629
3795
|
|
|
3630
3796
|
/** Load them, if any were ever written. Never throws. */
|
|
@@ -3637,9 +3803,11 @@ export class HlsSessionManager {
|
|
|
3637
3803
|
if (Array.isArray(raw?.sessionCreate)) {
|
|
3638
3804
|
this.#sessionCreateLatencies = raw.sessionCreate.filter((value) => Number.isFinite(value) && value > 0);
|
|
3639
3805
|
}
|
|
3806
|
+
const asMs = (value) => (value === null ? "n/a" : `${value}ms`);
|
|
3640
3807
|
logger.info(
|
|
3641
|
-
`host timings loaded
|
|
3642
|
-
`
|
|
3808
|
+
`host timings loaded from ${this.#hostTimingsPath()}: ` +
|
|
3809
|
+
`first-segment ${asMs(this.expectedFirstSegmentMs())}, ` +
|
|
3810
|
+
`session-create ${asMs(this.expectedSessionCreateMs())}`
|
|
3643
3811
|
);
|
|
3644
3812
|
} catch {
|
|
3645
3813
|
// No file yet, or it is unreadable. The synthetic figure answers instead.
|
|
@@ -4000,12 +4168,279 @@ export class HlsSessionManager {
|
|
|
4000
4168
|
* @returns {number[]}
|
|
4001
4169
|
*/
|
|
4002
4170
|
#variantHeights(session) {
|
|
4171
|
+
// Settled once per session, and re-settled when this file's own decode cost
|
|
4172
|
+
// is measured or improves. Everything else is fixed for the session's life
|
|
4173
|
+
// — the source, the host's benchmarks, the height it settled on — and this
|
|
4174
|
+
// is asked on the path that serves every playlist, init and segment, where
|
|
4175
|
+
// recomputing it meant repeating the refusal in the log every few seconds.
|
|
4176
|
+
const observed = this.#observedDecodeCost.get(`${session.sourceKey}:${session.fileIndex}`) ?? null;
|
|
4177
|
+
const version = observed?.version ?? 0;
|
|
4178
|
+
if (Array.isArray(session.offeredHeightsCache) && session.offeredHeightsVersion === version) {
|
|
4179
|
+
return session.offeredHeightsCache;
|
|
4180
|
+
}
|
|
4181
|
+
session.offeredHeightsVersion = version;
|
|
4003
4182
|
const heights = new Set(variantHeightsFor(Number(session.sourceHeight) || 0));
|
|
4004
4183
|
const own = this.variantHeightOf(session);
|
|
4005
4184
|
if (own > 0) {
|
|
4006
4185
|
heights.add(own);
|
|
4007
4186
|
}
|
|
4008
|
-
|
|
4187
|
+
const ordered = [...heights].sort((left, right) => right - left);
|
|
4188
|
+
// The rung ON SCREEN is never withdrawn. The list is now recomputed as the
|
|
4189
|
+
// host learns what this source costs, and the reading that teaches it comes
|
|
4190
|
+
// from the rung the viewer has just switched to — so the rung that taught
|
|
4191
|
+
// the lesson would be the first to be dropped, and every route guard reads
|
|
4192
|
+
// this list: its next segment would 404 on a stream that is playing, with
|
|
4193
|
+
// its own encoder still running. It leaves the offer when the viewer leaves
|
|
4194
|
+
// it, not while they are watching it.
|
|
4195
|
+
const playing = this.variantHeightOf(this.#activeVariant(session));
|
|
4196
|
+
session.offeredHeightsCache = this.#sustainableHeights({
|
|
4197
|
+
heights: ordered,
|
|
4198
|
+
ownHeight: own,
|
|
4199
|
+
playingHeight: playing,
|
|
4200
|
+
sourceWidth: Number(session.sourceWidth) || 0,
|
|
4201
|
+
sourceHeight: Math.round(Number(session.sourceHeight) || 0),
|
|
4202
|
+
fps: Number(session.outputFps) || TRANSCODE_FPS,
|
|
4203
|
+
source: session.sourceDecode ?? null,
|
|
4204
|
+
transcodeVideo: session.transcodeVideo === true,
|
|
4205
|
+
observedDecodeCostSec: observed?.costSec ?? null
|
|
4206
|
+
});
|
|
4207
|
+
return session.offeredHeightsCache;
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
/**
|
|
4211
|
+
* What decoding THIS source costs, learned from the encoder already running
|
|
4212
|
+
* on it — seconds of work per second of video, or null until it is known.
|
|
4213
|
+
*
|
|
4214
|
+
* @param {HlsSession} session
|
|
4215
|
+
* @returns {number | null}
|
|
4216
|
+
*/
|
|
4217
|
+
#observedDecodeCostFor(session) {
|
|
4218
|
+
const entry = this.#observedDecodeCost.get(`${session.sourceKey}:${session.fileIndex}`);
|
|
4219
|
+
return entry ? entry.costSec : null;
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
/**
|
|
4223
|
+
* Take one reading of a running encode and turn it into the decode cost of
|
|
4224
|
+
* this source.
|
|
4225
|
+
*
|
|
4226
|
+
* A re-encode pays for both halves — unpacking the source and packing the
|
|
4227
|
+
* result — and the running session measures the SUM. The encode half is
|
|
4228
|
+
* priced by the startup benchmark for the preset and pixel rate actually in
|
|
4229
|
+
* use, so subtracting it leaves the half that no startup benchmark can know:
|
|
4230
|
+
* this file's own codec, resolution and grain, on this machine, under
|
|
4231
|
+
* whatever else it is doing.
|
|
4232
|
+
*
|
|
4233
|
+
* The MEDIAN of the recent readings is used, over a bounded window. Keeping
|
|
4234
|
+
* the fastest instead makes the figure a ratchet: `speed=` is cumulative over
|
|
4235
|
+
* a run, its maximum falls in the burst where the encoder races to the
|
|
4236
|
+
* look-ahead cap with the pieces already on disk and nothing competing, and
|
|
4237
|
+
* one such moment would re-admit — permanently — the very rung the field
|
|
4238
|
+
* measured at 0.388-0.947x. The median moves in both directions and describes
|
|
4239
|
+
* the machine as it usually is, which is what a viewer will meet.
|
|
4240
|
+
*
|
|
4241
|
+
* A reading is only taken from a run that has been going long enough to have
|
|
4242
|
+
* left its own start behind: ffmpeg's `speed=` is cumulative, so a restart
|
|
4243
|
+
* after a seek, a resume after a suspension, and the wait for the first
|
|
4244
|
+
* pieces are all in the denominator of an early reading.
|
|
4245
|
+
*
|
|
4246
|
+
* @param {HlsSession} session
|
|
4247
|
+
* @param {number} speed - The `speed=` ffmpeg reports, as a multiple of realtime.
|
|
4248
|
+
*/
|
|
4249
|
+
#learnDecodeCost(session, speed) {
|
|
4250
|
+
if (session.transcodeVideo !== true || !(speed > 0)) {
|
|
4251
|
+
return; // a copied video decodes nothing, so it says nothing about decoding
|
|
4252
|
+
}
|
|
4253
|
+
const runStartedAt = Number(session.encodeRunStartedAt);
|
|
4254
|
+
if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
|
|
4255
|
+
return; // no run, or one still carrying its own start in the average
|
|
4256
|
+
}
|
|
4257
|
+
if (this.videoEncoder?.kind !== "software") {
|
|
4258
|
+
return; // the benchmark that prices the encode half is libx264 only
|
|
4259
|
+
}
|
|
4260
|
+
const benchmark = this.softwarePresetBenchmark;
|
|
4261
|
+
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
4262
|
+
return;
|
|
4263
|
+
}
|
|
4264
|
+
const entry = benchmark.find((item) => item.preset === session.softwarePreset);
|
|
4265
|
+
if (!entry || !(entry.pixelsPerSec > 0)) {
|
|
4266
|
+
return;
|
|
4267
|
+
}
|
|
4268
|
+
const height = Number(session.encodeHeight) || 0;
|
|
4269
|
+
const width = Number(session.encodeWidth) || 0;
|
|
4270
|
+
const fps = Number(session.outputFps) || TRANSCODE_FPS;
|
|
4271
|
+
if (height <= 0 || width <= 0) {
|
|
4272
|
+
return;
|
|
4273
|
+
}
|
|
4274
|
+
const encodeCostSec = (width * height * fps) / entry.pixelsPerSec;
|
|
4275
|
+
const decodeCostSec = 1 / speed - encodeCostSec;
|
|
4276
|
+
if (!(decodeCostSec > 0)) {
|
|
4277
|
+
// The encode half already accounts for everything measured. Nothing is
|
|
4278
|
+
// left to attribute to decoding, and a zero or negative cost would say
|
|
4279
|
+
// decoding is free, which is a claim this reading cannot support.
|
|
4280
|
+
return;
|
|
4281
|
+
}
|
|
4282
|
+
const key = `${session.sourceKey}:${session.fileIndex}`;
|
|
4283
|
+
const known = this.#observedDecodeCost.get(key);
|
|
4284
|
+
const readings = [...(known?.readings ?? []), decodeCostSec].slice(-DECODE_LEARNING_READINGS);
|
|
4285
|
+
const sorted = [...readings].sort((left, right) => left - right);
|
|
4286
|
+
const costSec = sorted[Math.floor(sorted.length / 2)];
|
|
4287
|
+
if (known && Math.abs(costSec - known.costSec) / known.costSec < DECODE_LEARNING_CHANGE) {
|
|
4288
|
+
// The same answer as before. Storing it would bump the version and make
|
|
4289
|
+
// every session recompute its offer, which is asked for on the path that
|
|
4290
|
+
// serves every playlist, init and segment.
|
|
4291
|
+
this.#observedDecodeCost.set(key, { ...known, readings });
|
|
4292
|
+
return;
|
|
4293
|
+
}
|
|
4294
|
+
this.#observedDecodeCost.set(key, { costSec, readings, version: (known?.version ?? 0) + 1 });
|
|
4295
|
+
logger.info(
|
|
4296
|
+
`transcode: ${session.fileName} decodes at ${(1 / costSec).toFixed(2)}x on this host ` +
|
|
4297
|
+
`(median of ${readings.length}, latest ${(1 / decodeCostSec).toFixed(2)}x from ${height}p ` +
|
|
4298
|
+
`at ${speed.toFixed(2)}x, preset ${session.softwarePreset})`
|
|
4299
|
+
);
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
/**
|
|
4303
|
+
* The heights this session's file will be served at, largest first — the
|
|
4304
|
+
* public form of the same answer the master playlist is built from.
|
|
4305
|
+
*
|
|
4306
|
+
* The browser asks because the master is not the only way quality changes: a
|
|
4307
|
+
* stream without variants changes it by re-opening the session at a chosen
|
|
4308
|
+
* height, and that list was being invented in the browser from the source
|
|
4309
|
+
* height alone. It has to come from the host that would have to encode it.
|
|
4310
|
+
*
|
|
4311
|
+
* @param {HlsSession} session
|
|
4312
|
+
* @returns {number[]}
|
|
4313
|
+
*/
|
|
4314
|
+
offeredHeights(session) {
|
|
4315
|
+
if (!session || session.state === "disposed") {
|
|
4316
|
+
return [];
|
|
4317
|
+
}
|
|
4318
|
+
return this.#variantHeights(session);
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
/**
|
|
4322
|
+
* The heights this host would serve a file at, answered from the PROBE alone
|
|
4323
|
+
* — before any session exists.
|
|
4324
|
+
*
|
|
4325
|
+
* The viewer sees the quality menu the moment they open a file, so the list
|
|
4326
|
+
* cannot wait for an encoder to exist. Everything it needs is already known
|
|
4327
|
+
* by then: the source's size, rate and bitrate from the probe, and this
|
|
4328
|
+
* host's two benchmarks from startup.
|
|
4329
|
+
*
|
|
4330
|
+
* Both branches are answered because only the browser knows which one it will
|
|
4331
|
+
* take — it decides per track whether it can play the video as it is. With a
|
|
4332
|
+
* COPIED video the source height costs no encoder and is always there; with a
|
|
4333
|
+
* re-encoded one it is a prediction like every other rung.
|
|
4334
|
+
*
|
|
4335
|
+
* These are first figures, not final ones: what the encoder then really does
|
|
4336
|
+
* with this file replaces them (`offeredHeights` on a live session).
|
|
4337
|
+
*
|
|
4338
|
+
* @param {{ width: number | null, height: number | null, fps: number | null, bitrateKbps: number | null }} mediaInfo
|
|
4339
|
+
* @returns {{ copy: number[], transcode: number[] } | null}
|
|
4340
|
+
*/
|
|
4341
|
+
predictOfferedHeights(mediaInfo) {
|
|
4342
|
+
const sourceHeight = Math.round(Number(mediaInfo?.height) || 0);
|
|
4343
|
+
const sourceWidth = Number(mediaInfo?.width) || 0;
|
|
4344
|
+
if (sourceHeight <= 0 || sourceWidth <= 0) {
|
|
4345
|
+
return null;
|
|
4346
|
+
}
|
|
4347
|
+
const fps = chooseOutputFps(Number(mediaInfo?.fps) || 0);
|
|
4348
|
+
const source = sourceDecodeCharacteristics(mediaInfo);
|
|
4349
|
+
const heights = variantHeightsFor(sourceHeight);
|
|
4350
|
+
// What an encoder has already been seen to cost on this very file, when it
|
|
4351
|
+
// has run before. Without it a second open of a file answers from the
|
|
4352
|
+
// startup clips again, undoing the correction the first playback earned.
|
|
4353
|
+
const observedDecodeCostSec = mediaInfo?.sourceKey !== undefined
|
|
4354
|
+
? (this.#observedDecodeCost.get(`${mediaInfo.sourceKey}:${mediaInfo.fileIndex}`)?.costSec ?? null)
|
|
4355
|
+
: null;
|
|
4356
|
+
const forBranch = (transcodeVideo) =>
|
|
4357
|
+
this.#sustainableHeights({
|
|
4358
|
+
heights,
|
|
4359
|
+
observedDecodeCostSec,
|
|
4360
|
+
// Nothing is running yet, so nothing is exempt from being predicted —
|
|
4361
|
+
// except the copy itself, which the branch flag already covers.
|
|
4362
|
+
ownHeight: 0,
|
|
4363
|
+
sourceWidth,
|
|
4364
|
+
sourceHeight,
|
|
4365
|
+
fps,
|
|
4366
|
+
source,
|
|
4367
|
+
transcodeVideo
|
|
4368
|
+
});
|
|
4369
|
+
return { copy: forBranch(false), transcode: forBranch(true) };
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
/**
|
|
4373
|
+
* Drop the rungs this host cannot hold at realtime.
|
|
4374
|
+
*
|
|
4375
|
+
* Every rung below the source height is a full re-encode — decode the whole
|
|
4376
|
+
* source, encode a smaller picture — and on a weak host that is dearer than
|
|
4377
|
+
* the copy it replaces. Measured 2026-08-14: 1080p was copied at 7.8-8.9x
|
|
4378
|
+
* while the offered 240p rung ran at 0.388-0.947x, its first segment took
|
|
4379
|
+
* 30 s and later ones were held 22 s, so choosing a LOWER quality is what
|
|
4380
|
+
* broke playback. A rung that cannot be produced faster than it is watched
|
|
4381
|
+
* must not be offered at all.
|
|
4382
|
+
*
|
|
4383
|
+
* The session's OWN height always stays: an encoder is already producing it,
|
|
4384
|
+
* and removing it would point the player at a rung nobody is encoding.
|
|
4385
|
+
*
|
|
4386
|
+
* @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
|
|
4387
|
+
* @returns {number[]}
|
|
4388
|
+
*/
|
|
4389
|
+
#sustainableHeights({
|
|
4390
|
+
heights,
|
|
4391
|
+
ownHeight,
|
|
4392
|
+
playingHeight = 0,
|
|
4393
|
+
sourceWidth,
|
|
4394
|
+
sourceHeight,
|
|
4395
|
+
fps,
|
|
4396
|
+
source,
|
|
4397
|
+
transcodeVideo,
|
|
4398
|
+
observedDecodeCostSec = null
|
|
4399
|
+
}) {
|
|
4400
|
+
const benchmark = this.softwarePresetBenchmark;
|
|
4401
|
+
if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
|
|
4402
|
+
return heights;
|
|
4403
|
+
}
|
|
4404
|
+
/** @type {number[]} */
|
|
4405
|
+
const kept = [];
|
|
4406
|
+
/** @type {string[]} */
|
|
4407
|
+
const dropped = [];
|
|
4408
|
+
for (const height of heights) {
|
|
4409
|
+
// The height an encoder is ALREADY producing, and the source's own height
|
|
4410
|
+
// when the video is copied — neither has to be predicted, because it is
|
|
4411
|
+
// happening. A source height that would have to be RE-ENCODED is a
|
|
4412
|
+
// prediction like any other: on a session whose budget downshifted to
|
|
4413
|
+
// 480p, the source's 1080p is neither copied nor being produced, and
|
|
4414
|
+
// keeping it unpriced would offer exactly the kind of rung this refuses.
|
|
4415
|
+
if (
|
|
4416
|
+
height === ownHeight ||
|
|
4417
|
+
height === playingHeight ||
|
|
4418
|
+
(height === sourceHeight && !transcodeVideo)
|
|
4419
|
+
) {
|
|
4420
|
+
kept.push(height);
|
|
4421
|
+
continue;
|
|
4422
|
+
}
|
|
4423
|
+
const width = Math.round(((sourceWidth / sourceHeight) * height) / 2) * 2;
|
|
4424
|
+
const { speed, sustainable } = canSustainOutput({
|
|
4425
|
+
benchmark,
|
|
4426
|
+
decodeModel: this.decodeCostModel,
|
|
4427
|
+
source,
|
|
4428
|
+
outputPixelsPerSec: width * height * fps,
|
|
4429
|
+
observedDecodeCostSec
|
|
4430
|
+
});
|
|
4431
|
+
if (sustainable) {
|
|
4432
|
+
kept.push(height);
|
|
4433
|
+
continue;
|
|
4434
|
+
}
|
|
4435
|
+
dropped.push(`${height}p=${speed === null ? "n/a" : `${speed.toFixed(2)}x`}`);
|
|
4436
|
+
}
|
|
4437
|
+
if (dropped.length > 0) {
|
|
4438
|
+
logger.info(
|
|
4439
|
+
`transcode: not offering ${dropped.join(" ")} — below realtime × ${REALTIME_SPEED_MARGIN} ` +
|
|
4440
|
+
`(offering ${kept.map((height) => `${height}p`).join(" ")})`
|
|
4441
|
+
);
|
|
4442
|
+
}
|
|
4443
|
+
return kept;
|
|
4009
4444
|
}
|
|
4010
4445
|
|
|
4011
4446
|
/**
|
|
@@ -5075,6 +5510,12 @@ export class HlsSessionManager {
|
|
|
5075
5510
|
currentHeight: session.transcodeVideo
|
|
5076
5511
|
? (session.encodeHeight ?? session.sourceHeight ?? 0)
|
|
5077
5512
|
: (session.sourceHeight ?? 0),
|
|
5513
|
+
// The rungs still worth offering, as they stand NOW. The list the browser
|
|
5514
|
+
// was given when the file opened came from the startup benchmarks; this
|
|
5515
|
+
// one is corrected by what the encoder has since been seen to do with
|
|
5516
|
+
// this very source, so a rung that turns out to be beyond the host
|
|
5517
|
+
// disappears from the menu instead of being discovered by switching to it.
|
|
5518
|
+
offeredHeights: this.offeredHeights(session),
|
|
5078
5519
|
// What this host takes to create a session and to make a first segment.
|
|
5079
5520
|
// Also on the playback plan, but the browser reads that once per file:
|
|
5080
5521
|
// measured 2026-08-06 across four seeks, a proxy that had just restarted
|