@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 +11 -0
- package/package.json +1 -1
- package/server.js +11 -0
- package/services/hls-session-manager.js +238 -9
- package/services/host-load.js +20 -0
- package/services/hwaccel.js +18 -2
- package/test/concurrent-cost.test.js +82 -0
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
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 = {
|
|
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)}
|
|
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 (
|
|
4653
|
-
return;
|
|
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);
|
package/services/host-load.js
CHANGED
|
@@ -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
|
+
}
|
package/services/hwaccel.js
CHANGED
|
@@ -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
|
|
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
|
+
});
|