@torrent-tv/proxy 2.12.1 → 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.
@@ -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 top = budget?.ladder?.[0];
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: top.width,
180
- height: top.height,
181
- preset: pickSoftwarePreset(benchmark, top.width * top.height * fps),
182
- rungIndex: 0
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 ? startAtLadderTop(chosenBudget, outputFps, this.softwarePresetBenchmark) : chosenBudget;
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(this.softwarePresetBenchmark, { width: ceiling.w, height: ceiling.h }, outputFps);
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
- session.softwarePreset = pickSoftwarePreset(this.softwarePresetBenchmark, rung.width * rung.height * fps);
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: one file, always the same path,
3615
- * beside the proxy's own installation.
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
- return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "host-timings.json");
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: first-segment ${this.expectedFirstSegmentMs() ?? "n/a"}ms, ` +
3642
- `session-create ${this.expectedSessionCreateMs() ?? "n/a"}ms`
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
- return [...heights].sort((left, right) => right - left);
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