@torrent-tv/proxy 2.17.0 → 2.19.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 CHANGED
@@ -1,3 +1,14 @@
1
+ ## 2.19.0
2
+
3
+ - **New**: What the torrent itself costs this machine is measured and charged. Downloading a file, verifying every piece of it and pushing segments down a data channel are work on the same box as the encoder, they scale with the file's own bitrate, and the budget counted none of it — measured on the addon host with every encoder suspended, the machine was still 20-29 % busy. The figure is taken only while NOTHING is encoding, which is the one moment it can be attributed without arithmetic, and it is expressed per megabyte moved so any file's rate can be priced from it. A viewer's file is then charged at its own byte rate when deciding what quality this host can offer.
4
+ - **New**: The host-load line reports the proxy's own share of the machine beside the encoders'. The two answer different questions — whether ffmpeg is getting the cores, and how much of the box goes to everything around it — and only the first was visible.
5
+
6
+ ## 2.18.0
7
+
8
+ - **New**: Copying the picture is no longer priced at nothing. It demuxes, re-encodes the audio and writes segments, and it is what runs BESIDE every rung warmed for a quality change — the field measured it at 7.92-8.02x, about an eighth of a second of work per second of video. The figure is not a constant: a session that is copying reports its own speed, and the reciprocal of that IS the cost, learned per file as the decode cost already is (median of recent readings, only from a run past its own start, never from a suspended one).
9
+ - **New**: A rung is judged against the machine it will actually have. The cost of what the family is already committed to is added to the rung's own before the check, so the arithmetic of 2026-08-15 comes out as it did in the field: 0.125 for the copy plus about 1.05 for the 240p rung is more than the one second of work per second the machine has. Unmeasured means zero, so a host that has observed nothing is exactly as permissive as before.
10
+ - **Fix**: A copy reading taken while the torrent is short is discarded. A re-encode near realtime may be the host's limit; a copy near realtime is a copy waiting for data, since copying runs at eight times realtime — and filing that as the price of copying would refuse rungs on the download's account. An audio rendition is excluded from this learning too: it carries no picture, and its speed is the price of a soundtrack, not of a copy.
11
+
1
12
  ## 2.17.0
2
13
 
3
14
  - **New**: The encoder is benchmarked on real footage instead of a generated test pattern, and measured by ffmpeg's own progress rather than by the clock around the process. The pattern has flat areas and no grain and encodes **1.23x** cheaper than film on the same machine and preset — an error that always points at offering a rung the host cannot hold. Timing whole runs was the second error: process startup is ~0.4 s, which put `fast` and `ultrafast` within 1.24x of each other when they differ by three times. The clip is decoded once to raw frames in a temp file (feeding them through a pipe measured the pipe: the fastest presets want hundreds of megabytes a second), each preset is read from the slope between two progress reports, and the run is stopped as soon as a second of it has been covered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -152,6 +152,17 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
152
152
  stateDir,
153
153
  // Live download stats accessor for the realtime budget: lets it tell a
154
154
  // CPU-bound transcode from a download-starved input before downscaling.
155
+ // What every torrent here has moved, so the proxy can price its own
156
+ // downloading, hashing and delivery against the machine (roadmap item 7).
157
+ getTorrentTotals: async () => {
158
+ let bytesMoved = 0;
159
+ for (const torrent of torrentPool.client?.torrents ?? []) {
160
+ const downloaded = Number(torrent?.downloaded);
161
+ const uploaded = Number(torrent?.uploaded);
162
+ bytesMoved += (Number.isFinite(downloaded) ? downloaded : 0) + (Number.isFinite(uploaded) ? uploaded : 0);
163
+ }
164
+ return { bytesMoved };
165
+ },
155
166
  getSourceStats: async (sourceKey, fileIndex) => {
156
167
  const record = sourceRegistry.get(sourceKey);
157
168
  if (!record) {
@@ -18,7 +18,7 @@ import { spawn } from "node:child_process";
18
18
  import { createRequire } from "node:module";
19
19
  import { logger } from "../utils/logger.js";
20
20
  import { readKeyframeIndex } from "./container-index/index.js";
21
- import { readMachineState, readProcessCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
21
+ import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
22
22
 
23
23
  /** Own package version, stamped onto session-start log lines. */
24
24
  const PROXY_VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -466,6 +466,8 @@ const DEFAULT_STARTUP_WAIT_MS = 5_000;
466
466
  // and restart at the current segment. Conservative so it never thrashes: a long
467
467
  // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
468
468
  const BUDGET_CHECK_INTERVAL_MS = 5_000;
469
+ /** Below this, a tick has not moved enough of the torrent to price it. */
470
+ const TORRENT_COST_MIN_MEGABYTES = 2;
469
471
  // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
470
472
  // realtime resets the slow window (hysteresis).
471
473
  const BUDGET_SPEED_SLOW = 0.95;
@@ -1161,6 +1163,23 @@ function normalizeLogFileName(fileName, fileIndex) {
1161
1163
  * combination. Sessions are reused across consumers and are automatically
1162
1164
  * expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
1163
1165
  */
1166
+ /**
1167
+ * How many megabytes a second of this source is, from what the probe read.
1168
+ *
1169
+ * A viewer consumes the file at its own rate, so this is also the rate at which
1170
+ * the machine must fetch, verify and deliver it while they watch.
1171
+ *
1172
+ * @param {HlsSession} session
1173
+ * @returns {number | null}
1174
+ */
1175
+ function sourceMegabytesPerSecond(session) {
1176
+ const megabitsPerSecond = Number(session.sourceDecode?.megabitsPerSecond);
1177
+ if (!Number.isFinite(megabitsPerSecond) || megabitsPerSecond <= 0) {
1178
+ return null;
1179
+ }
1180
+ return megabitsPerSecond / 8;
1181
+ }
1182
+
1164
1183
  export class HlsSessionManager {
1165
1184
  /**
1166
1185
  * Recent times from session-create to a servable first segment, in ms.
@@ -1194,8 +1213,23 @@ export class HlsSessionManager {
1194
1213
  * @type {Map<string, { costSec: number, version: number }>}
1195
1214
  */
1196
1215
  #observedDecodeCost = new Map();
1216
+ /**
1217
+ * What copying costs, per source file, learned the same way: `key ->
1218
+ * { costSec, readings, version }`. A copy is what runs BESIDE a rung being
1219
+ * warmed, and pricing it at nothing is what let a host be told it had a whole
1220
+ * machine for the rung.
1221
+ *
1222
+ * @type {Map<string, { costSec: number, readings: number[], version: number }>}
1223
+ */
1224
+ #observedCopyCost = new Map();
1197
1225
  /** The previous reading of the machine, to compare the next one against. */
1198
1226
  #hostLoadSample = null;
1227
+ /** The previous reading taken while nothing was encoding, for the torrent's own cost. */
1228
+ #idleLoadSample = null;
1229
+ /** Seconds of this process's CPU per megabyte the torrent moves, once measured. */
1230
+ #observedTorrentCostPerMegabyte = null;
1231
+ /** @type {number[]} Recent readings behind that median. */
1232
+ #torrentCostReadings = [];
1199
1233
 
1200
1234
  /**
1201
1235
  * @param {HlsSessionManagerOptions} options
@@ -1216,8 +1250,8 @@ export class HlsSessionManager {
1216
1250
  getCachedMediaInfo = null,
1217
1251
  getCachedAudioTracks = null,
1218
1252
  segmentFormatId = undefined,
1219
- stateDir = ""
1220
- }) {
1253
+ stateDir = "",
1254
+ getTorrentTotals}) {
1221
1255
  this.enabled = Boolean(enabled);
1222
1256
  this.ffmpegBin = ffmpegBin;
1223
1257
  // Where measurements about this host are kept between runs. Empty means
@@ -1238,6 +1272,10 @@ export class HlsSessionManager {
1238
1272
  // realtime budget to tell a CPU limit from a download-starved input:
1239
1273
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
1240
1274
  this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
1275
+ // Totals across every torrent this proxy holds, used to price what the
1276
+ // torrent itself costs the machine (item 7). Optional: a proxy wired
1277
+ // without it simply never learns that figure.
1278
+ this.getTorrentTotals = typeof getTorrentTotals === "function" ? getTorrentTotals : null;
1241
1279
  // Detected H.264 encoder descriptor (hardware or software). Defaults to
1242
1280
  // software libx264 when no detection result is supplied. May be downgraded
1243
1281
  // to software at runtime if a hardware encode fails.
@@ -2657,14 +2695,85 @@ export class HlsSessionManager {
2657
2695
  * Written only while something is encoding, and only when a reading is
2658
2696
  * available: on a host without `/proc` this says nothing at all.
2659
2697
  */
2698
+ /**
2699
+ * What the torrent itself costs this machine, per megabyte it moves.
2700
+ *
2701
+ * Downloading, verifying every piece and pushing segments down a data channel
2702
+ * are work on the same box as the encoder, they scale with the file's own
2703
+ * bitrate, and the budget counts none of it. Measured on the addon host with
2704
+ * every encoder suspended, the machine was still 20-29 % busy.
2705
+ *
2706
+ * Taken only while NOTHING is encoding, because that is the only moment the
2707
+ * spending can be attributed without arithmetic: what this process uses then
2708
+ * is the torrent's.
2709
+ */
2710
+ async #learnTorrentCost() {
2711
+ const now = {
2712
+ takenAt: Date.now(),
2713
+ cpuSeconds: readProxyCpuSeconds(),
2714
+ bytes: await this.#torrentBytesMoved()
2715
+ };
2716
+ const previous = this.#idleLoadSample;
2717
+ this.#idleLoadSample = now;
2718
+ if (previous === null || now.bytes === null || previous.bytes === null) {
2719
+ return;
2720
+ }
2721
+ const elapsedSec = (now.takenAt - previous.takenAt) / 1000;
2722
+ const megabytes = (now.bytes - previous.bytes) / 1e6;
2723
+ const cpuSeconds = now.cpuSeconds - previous.cpuSeconds;
2724
+ // Enough movement to divide by: a tick with almost nothing downloaded
2725
+ // measures the idle loop, not the torrent.
2726
+ if (!(elapsedSec > 0) || !(megabytes >= TORRENT_COST_MIN_MEGABYTES) || !(cpuSeconds > 0)) {
2727
+ return;
2728
+ }
2729
+ const costPerMegabyte = cpuSeconds / megabytes;
2730
+ const readings = [...this.#torrentCostReadings, costPerMegabyte].slice(-DECODE_LEARNING_READINGS);
2731
+ this.#torrentCostReadings = readings;
2732
+ const sorted = [...readings].sort((left, right) => left - right);
2733
+ const median = sorted[Math.floor(sorted.length / 2)];
2734
+ if (this.#observedTorrentCostPerMegabyte !== null &&
2735
+ Math.abs(median - this.#observedTorrentCostPerMegabyte) / this.#observedTorrentCostPerMegabyte < DECODE_LEARNING_CHANGE) {
2736
+ return;
2737
+ }
2738
+ this.#observedTorrentCostPerMegabyte = median;
2739
+ logger.info(
2740
+ `host-load: the torrent costs ${(median * 1000).toFixed(1)}ms of CPU per MB on this host ` +
2741
+ `(median of ${readings.length}, latest ${(costPerMegabyte * 1000).toFixed(1)}ms over ${megabytes.toFixed(1)}MB)`
2742
+ );
2743
+ }
2744
+
2745
+ /**
2746
+ * Bytes this proxy's torrents have moved in total, or null when it cannot be
2747
+ * asked.
2748
+ *
2749
+ * @returns {Promise<number | null>}
2750
+ */
2751
+ async #torrentBytesMoved() {
2752
+ if (typeof this.getTorrentTotals !== "function") {
2753
+ return null;
2754
+ }
2755
+ try {
2756
+ const totals = await this.getTorrentTotals();
2757
+ const moved = Number(totals?.bytesMoved);
2758
+ return Number.isFinite(moved) ? moved : null;
2759
+ } catch {
2760
+ return null; // the pool is busy or gone; a reading missed is not a fault
2761
+ }
2762
+ }
2763
+
2660
2764
  async #reportHostLoad() {
2661
2765
  const encoding = [...this.sessionsById.values()].filter(
2662
2766
  (session) => session?.ffmpeg != null && !hasChildExited(session.ffmpeg) && session.state !== "disposed"
2663
2767
  );
2664
2768
  if (encoding.length === 0) {
2769
+ // Nothing is encoding, which is the ONLY moment the torrent's own cost
2770
+ // can be attributed cleanly: whatever this process spends now is the
2771
+ // download, the hashing, the piece store and the delivery. Item 7.
2772
+ await this.#learnTorrentCost();
2665
2773
  this.#hostLoadSample = null;
2666
2774
  return;
2667
2775
  }
2776
+ this.#idleLoadSample = null;
2668
2777
  // EVERY encoder, added up. One of them is meaningless on a host that runs a
2669
2778
  // picture and an audio track at once, and picking the first would have
2670
2779
  // reported whichever the map happened to hold.
@@ -2688,7 +2797,16 @@ export class HlsSessionManager {
2688
2797
  byPid.set(pid, seconds);
2689
2798
  }
2690
2799
  });
2691
- const sample = { takenAt: Date.now(), byPid, system };
2800
+ const sample = {
2801
+ takenAt: Date.now(),
2802
+ byPid,
2803
+ system,
2804
+ // The proxy's own CPU, across every thread: the torrent, the hashing, the
2805
+ // piece store and the delivery. None of it is in the encode budget, and
2806
+ // on the addon host it is most of what the machine does while encoders
2807
+ // are suspended.
2808
+ proxyCpuSeconds: readProxyCpuSeconds()
2809
+ };
2692
2810
  const previous = this.#hostLoadSample;
2693
2811
  this.#hostLoadSample = sample;
2694
2812
  if (previous === null) {
@@ -2722,8 +2840,13 @@ export class HlsSessionManager {
2722
2840
  const running = encoding.length - suspended;
2723
2841
  const machine = await readMachineState();
2724
2842
  const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
2843
+ const cores = Math.max(1, os.cpus().length);
2844
+ const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
2845
+ ? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
2846
+ : null;
2725
2847
  logger.info(
2726
- `host-load: ffmpeg=${asPercent(share.processShare)} system=${asPercent(share.systemShare)} ` +
2848
+ `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
2849
+ `system=${asPercent(share.systemShare)} ` +
2727
2850
  `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
2728
2851
  `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
2729
2852
  `encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
@@ -4549,6 +4672,10 @@ export class HlsSessionManager {
4549
4672
  heights: ordered,
4550
4673
  ownHeight: own,
4551
4674
  playingHeight: playing,
4675
+ // What the family is already spending while a rung is considered. The
4676
+ // picture being COPIED is the common case and used to be priced at
4677
+ // nothing; measured, it is about an eighth of the machine.
4678
+ concurrentCostSec: this.#committedCostOf(owner),
4552
4679
  sourceWidth: Number(owner.sourceWidth) || 0,
4553
4680
  sourceHeight: Math.round(Number(owner.sourceHeight) || 0),
4554
4681
  fps: Number(owner.outputFps) || TRANSCODE_FPS,
@@ -4648,9 +4775,76 @@ export class HlsSessionManager {
4648
4775
  this.#learnDecodeCost(session, speed);
4649
4776
  }
4650
4777
 
4778
+ /**
4779
+ * What COPYING this file costs on this host, learned from a session that is
4780
+ * doing it: seconds of work per second of video.
4781
+ *
4782
+ * A copy is not free. It demuxes, it re-encodes the audio, it writes
4783
+ * segments, and it runs BESIDE every rung warmed for a quality change — so a
4784
+ * budget that prices it at nothing predicts a machine that does not exist.
4785
+ * The figure is the reciprocal of the speed the session reports, which is the
4786
+ * measurement itself rather than a model of it.
4787
+ *
4788
+ * Median of recent readings, taken only from a run past its own start, for
4789
+ * the same reasons as the decode cost beside it.
4790
+ *
4791
+ * @param {HlsSession} session
4792
+ * @param {number} speed
4793
+ */
4794
+ async #learnCopyCost(session, speed) {
4795
+ const runStartedAt = Number(session.encodeRunStartedAt);
4796
+ if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
4797
+ return;
4798
+ }
4799
+ if (session.encoderPaused === true) {
4800
+ return; // a suspended run reports a cumulative figure that is decaying
4801
+ }
4802
+ // Always asked, not only below realtime. A re-encode near 1x may be the
4803
+ // host; a COPY near 1x is a copy waiting for the torrent, because copying
4804
+ // is what a machine does at eight times realtime — and a starved reading
4805
+ // filed as the price of copying would refuse rungs on the download's
4806
+ // account.
4807
+ if (await this.#classifyTranscodeBound(session) === "download") {
4808
+ return;
4809
+ }
4810
+ const costSec = 1 / speed;
4811
+ if (!(costSec > 0) || !Number.isFinite(costSec)) {
4812
+ return;
4813
+ }
4814
+ const key = `${session.sourceKey}:${session.fileIndex}`;
4815
+ const known = this.#observedCopyCost.get(key);
4816
+ const readings = [...(known?.readings ?? []), costSec].slice(-DECODE_LEARNING_READINGS);
4817
+ const sorted = [...readings].sort((left, right) => left - right);
4818
+ const median = sorted[Math.floor(sorted.length / 2)];
4819
+ if (known && Math.abs(median - known.costSec) / known.costSec < DECODE_LEARNING_CHANGE) {
4820
+ this.#observedCopyCost.set(key, { ...known, readings });
4821
+ return;
4822
+ }
4823
+ this.#observedCopyCost.set(key, { costSec: median, readings, version: (known?.version ?? 0) + 1 });
4824
+ logger.info(
4825
+ `transcode: ${session.fileName} copies at ${(1 / median).toFixed(2)}x on this host ` +
4826
+ `(median of ${readings.length}, latest ${speed.toFixed(2)}x)`
4827
+ );
4828
+ }
4829
+
4651
4830
  #learnDecodeCost(session, speed) {
4652
- if (session.transcodeVideo !== true || !(speed > 0)) {
4653
- return; // a copied video decodes nothing, so it says nothing about decoding
4831
+ if (!(speed > 0)) {
4832
+ return;
4833
+ }
4834
+ if (session.transcodeVideo !== true) {
4835
+ // A copied video decodes nothing, so it says nothing about DECODING —
4836
+ // but it says exactly what COPYING costs, which the budget has been
4837
+ // treating as free. Field 2026-08-15: a copy ran at 7.92-8.02x, i.e.
4838
+ // about an eighth of a second of work per second of video, while a rung
4839
+ // warmed beside it needed the rest of the machine.
4840
+ //
4841
+ // An audio rendition also carries no video, and its speed is the cost of
4842
+ // encoding a soundtrack — a different quantity that must not be filed
4843
+ // under what copying the picture costs.
4844
+ if (session.audioOnly !== true) {
4845
+ void this.#learnCopyCost(session, speed);
4846
+ }
4847
+ return;
4654
4848
  }
4655
4849
  const runStartedAt = Number(session.encodeRunStartedAt);
4656
4850
  if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
@@ -4788,6 +4982,39 @@ export class HlsSessionManager {
4788
4982
  * @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
4789
4983
  * @returns {number[]}
4790
4984
  */
4985
+ /**
4986
+ * Seconds of work per second of video this family is ALREADY committed to,
4987
+ * beside any rung being considered.
4988
+ *
4989
+ * Today that is the copy of the picture, where the video is copied and its
4990
+ * cost has been observed. An encoded picture is not added: a viewer changing
4991
+ * quality leaves the rung they are on, so the two do not overlap for long,
4992
+ * and the warm-up that does overlap is bounded by the switch. An audio
4993
+ * rendition is not added either, until its cost is measured the same way —
4994
+ * counting it at a guess would refuse rungs on arithmetic nobody took.
4995
+ *
4996
+ * @param {HlsSession} session
4997
+ * @returns {number}
4998
+ */
4999
+ #committedCostOf(session) {
5000
+ let cost = 0;
5001
+ if (session.transcodeVideo !== true) {
5002
+ const observed = this.#observedCopyCost.get(`${session.sourceKey}:${session.fileIndex}`);
5003
+ cost += observed && observed.costSec > 0 ? observed.costSec : 0;
5004
+ }
5005
+ // And what the FILE costs simply by being fetched and delivered while it is
5006
+ // watched: a viewer consumes it at its own byte rate, and every one of
5007
+ // those bytes is downloaded, verified and pushed by this process. Priced
5008
+ // per megabyte from readings taken while nothing was encoding, so the two
5009
+ // measurements do not contain each other.
5010
+ const perMegabyte = this.#observedTorrentCostPerMegabyte;
5011
+ const megabytesPerSecond = sourceMegabytesPerSecond(session);
5012
+ if (perMegabyte !== null && megabytesPerSecond !== null) {
5013
+ cost += perMegabyte * megabytesPerSecond;
5014
+ }
5015
+ return cost;
5016
+ }
5017
+
4791
5018
  #sustainableHeights({
4792
5019
  heights,
4793
5020
  ownHeight,
@@ -4797,7 +5024,8 @@ export class HlsSessionManager {
4797
5024
  fps,
4798
5025
  source,
4799
5026
  transcodeVideo,
4800
- observedDecodeCostSec = null
5027
+ observedDecodeCostSec = null,
5028
+ concurrentCostSec = 0
4801
5029
  }) {
4802
5030
  const benchmark = this.softwarePresetBenchmark;
4803
5031
  if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
@@ -4835,7 +5063,8 @@ export class HlsSessionManager {
4835
5063
  decodeModel: this.decodeCostModel,
4836
5064
  source,
4837
5065
  outputPixelsPerSec: width * height * fps,
4838
- observedDecodeCostSec
5066
+ observedDecodeCostSec,
5067
+ concurrentCostSec
4839
5068
  });
4840
5069
  if (sustainable) {
4841
5070
  kept.push(height);
@@ -152,3 +152,23 @@ export async function sampleHost(pid) {
152
152
  ]);
153
153
  return { takenAt: Date.now(), processCpuSeconds, system };
154
154
  }
155
+
156
+ /**
157
+ * How much CPU THIS process has used, across every thread it owns.
158
+ *
159
+ * The proxy is not only its encoders. It downloads the torrent, verifies every
160
+ * piece it receives, keeps the piece store, and pushes segments down a data
161
+ * channel — work that happens in this process and its workers, that scales with
162
+ * the file's own bitrate, and that the encode budget counts as nothing.
163
+ * Measured on the addon host with both encoders suspended, the machine was
164
+ * still 20-29 % busy; this is the number that says how much of that is ours.
165
+ *
166
+ * `process.cpuUsage()` covers all threads of the process, so the torrent
167
+ * worker's hashing is included without asking it anything.
168
+ *
169
+ * @returns {number} Seconds of CPU used since this process began.
170
+ */
171
+ export function readProxyCpuSeconds() {
172
+ const usage = process.cpuUsage();
173
+ return (usage.user + usage.system) / 1e6;
174
+ }
@@ -1054,7 +1054,8 @@ export function canSustainOutput({
1054
1054
  decodeModel = null,
1055
1055
  source = null,
1056
1056
  outputPixelsPerSec,
1057
- observedDecodeCostSec = null
1057
+ observedDecodeCostSec = null,
1058
+ concurrentCostSec = 0
1058
1059
  }) {
1059
1060
  if (!Array.isArray(benchmark) || benchmark.length === 0) {
1060
1061
  // Nothing measured on this host: the budget cannot refuse what it cannot
@@ -1070,13 +1071,28 @@ export function canSustainOutput({
1070
1071
  // term the ladder is offered whole, exactly as it was before.
1071
1072
  return { speed: null, sustainable: true };
1072
1073
  }
1073
- const speed = predictedRealtimeSpeed({
1074
+ const alone = predictedRealtimeSpeed({
1074
1075
  decodeModel,
1075
1076
  encodePixelsPerSec: cheapestPresetPixelsPerSec(benchmark),
1076
1077
  outputPixelsPerSec,
1077
1078
  source,
1078
1079
  observedDecodeCostSec: observed
1079
1080
  });
1081
+ // What ELSE will be running while this rung is. A rung is never the only
1082
+ // thing on the machine: the picture it accompanies is being copied or
1083
+ // encoded, an audio track may have its own encoder, and a warm-up is two
1084
+ // encoders by design. Measured on the addon host, a copy alone takes about an
1085
+ // eighth of the machine per second of video, and the field case of
1086
+ // 2026-08-15 adds up exactly: 0.125 for the copy plus ~1.05 for the rung is
1087
+ // more than the one second per second the machine has, which is what was
1088
+ // observed.
1089
+ //
1090
+ // Zero when nothing else is known to be running, or when nothing has been
1091
+ // measured yet — then this is a LOWER bound on the cost and the check is as
1092
+ // permissive as it was before.
1093
+ const speed = alone === null || !(concurrentCostSec > 0)
1094
+ ? alone
1095
+ : 1 / (1 / alone + concurrentCostSec);
1080
1096
  if (speed === null) {
1081
1097
  return { speed: null, sustainable: true };
1082
1098
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @file A rung is judged against the machine it will actually have.
3
+ *
4
+ * The field case of 2026-08-15, in arithmetic: a copy of the picture took about
5
+ * 0.125 s of work per second of video (7.9-8.0x measured), a 240p rung needed
6
+ * about 1.05, and the two together are more than the one second per second the
7
+ * machine has. The budget priced the copy at nothing, offered the rung, and the
8
+ * viewer watched it fail.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import test from "node:test";
13
+
14
+ import { canSustainOutput } from "../services/hwaccel.js";
15
+
16
+ // A host that encodes 640x360 at 24 fps about eleven times over, and decodes
17
+ // 1080p24 at 2.6x — the addon host's own figures.
18
+ const BENCHMARK = [
19
+ { preset: "fast", pixelsPerSec: 17.0e6 },
20
+ { preset: "ultrafast", pixelsPerSec: 67.5e6 }
21
+ ];
22
+ const DECODE_MODEL = { pixelTerm: 0.007742, bitrateTerm: 0, constantTerm: 0 };
23
+ const SOURCE = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 8 };
24
+ const RUNG_240P = 426 * 240 * 24;
25
+
26
+ test("a rung is judged on the machine it will have, not on an empty one", () => {
27
+ const alone = canSustainOutput({
28
+ benchmark: BENCHMARK,
29
+ decodeModel: DECODE_MODEL,
30
+ source: SOURCE,
31
+ outputPixelsPerSec: RUNG_240P
32
+ });
33
+ const beside = canSustainOutput({
34
+ benchmark: BENCHMARK,
35
+ decodeModel: DECODE_MODEL,
36
+ source: SOURCE,
37
+ outputPixelsPerSec: RUNG_240P,
38
+ // The picture is being copied beside it, at the cost the field measured.
39
+ concurrentCostSec: 0.125
40
+ });
41
+ assert.ok(alone.speed !== null && beside.speed !== null);
42
+ assert.ok(beside.speed < alone.speed, "sharing the machine cannot make a rung faster");
43
+ });
44
+
45
+ test("nothing else running leaves the answer exactly as it was", () => {
46
+ const withZero = canSustainOutput({
47
+ benchmark: BENCHMARK,
48
+ decodeModel: DECODE_MODEL,
49
+ source: SOURCE,
50
+ outputPixelsPerSec: RUNG_240P,
51
+ concurrentCostSec: 0
52
+ });
53
+ const without = canSustainOutput({
54
+ benchmark: BENCHMARK,
55
+ decodeModel: DECODE_MODEL,
56
+ source: SOURCE,
57
+ outputPixelsPerSec: RUNG_240P
58
+ });
59
+ assert.equal(withZero.speed, without.speed);
60
+ });
61
+
62
+ test("a host with nothing measured still refuses nothing", () => {
63
+ const answer = canSustainOutput({
64
+ benchmark: [],
65
+ outputPixelsPerSec: RUNG_240P,
66
+ concurrentCostSec: 0.125
67
+ });
68
+ assert.equal(answer.sustainable, true);
69
+ assert.equal(answer.speed, null);
70
+ });
71
+
72
+ test("a cost heavy enough to fill the machine puts a rung below realtime", () => {
73
+ const answer = canSustainOutput({
74
+ benchmark: BENCHMARK,
75
+ decodeModel: DECODE_MODEL,
76
+ source: SOURCE,
77
+ outputPixelsPerSec: RUNG_240P,
78
+ concurrentCostSec: 0.9
79
+ });
80
+ assert.ok(answer.speed !== null && answer.speed < 1, "0.9 of the machine spent elsewhere leaves less than realtime");
81
+ assert.equal(answer.sustainable, false);
82
+ });