@torrent-tv/proxy 2.81.2 → 2.82.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.
@@ -53,6 +53,17 @@ export class EncodeOrchestrator {
53
53
  /** What a stop and a start have cost on this host. */
54
54
  #costs = new RunCosts();
55
55
 
56
+ /**
57
+ * What starting and stopping an encoder was measured to cost on this host,
58
+ * before any viewer existed. Told once; real runs replace it as they end.
59
+ *
60
+ * @param {{ firstByteWaitSec: number, killCostSec: number } | null} measured
61
+ * @returns {void}
62
+ */
63
+ noteStartupCosts(measured) {
64
+ this.#costs.noteStartup(measured);
65
+ }
66
+
56
67
  /** The last reason a budget was cut, so the same one is not said twice. */
57
68
  #lastBudgetReason = new Map();
58
69
 
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @file What this host must have measured before the first viewer arrives.
3
+ *
4
+ * The quality offer, the number of encoders and every decision about moving one
5
+ * are arithmetic over four figures: how fast this host encodes, how fast it
6
+ * decodes, what a second job costs it, and what starting and stopping an
7
+ * encoder cost. A figure nobody measured is reported as zero, and zero does not
8
+ * read as "unknown" — it reads as "free" or "instant", and the arithmetic then
9
+ * answers confidently and wrongly.
10
+ *
11
+ * Two gaps this pins, both found 2026-09-10:
12
+ *
13
+ * 1. three of the four ran only when the chosen encoder was SOFTWARE, and two
14
+ * of those three are not about the encoder at all — a host with a GPU
15
+ * decodes in software just the same, and what a second job costs is a
16
+ * property of the machine. A GPU host measured none of them;
17
+ * 2. starting and stopping were learned only from runs that had already ENDED,
18
+ * so at a cold open both were zero and moving an encoder was free. Field
19
+ * 2026-09-08: an encoder moved between two adjacent numbers every half
20
+ * second and produced nothing.
21
+ */
22
+
23
+ import test from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { readFileSync } from "node:fs";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { SoftwareEncoder } from "../services/encode/SoftwareEncoder.js";
29
+ import { NvencEncoder } from "../services/encode/NvencEncoder.js";
30
+ import { QsvEncoder } from "../services/encode/QsvEncoder.js";
31
+ import { VaapiEncoder } from "../services/encode/VaapiEncoder.js";
32
+ import { V4l2m2mEncoder } from "../services/encode/V4l2m2mEncoder.js";
33
+ import { RunCosts } from "../services/encode/run-costs.js";
34
+
35
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
36
+
37
+ test("every encoder kind can say how to measure itself", () => {
38
+ const kinds = [
39
+ new SoftwareEncoder(),
40
+ new NvencEncoder(),
41
+ new QsvEncoder(),
42
+ new VaapiEncoder("/dev/dri/renderD128"),
43
+ new V4l2m2mEncoder()
44
+ ];
45
+ for (const encoder of kinds) {
46
+ const args = encoder.benchmarkArgs(null);
47
+ assert.ok(Array.isArray(args) && args.length >= 2, `${encoder.name} says nothing`);
48
+ assert.ok(args.includes("-c:v"), `${encoder.name} does not name a codec`);
49
+ assert.ok(
50
+ args.some((arg) => String(arg).includes(encoder.name)),
51
+ `${encoder.name} does not name itself`
52
+ );
53
+ }
54
+ });
55
+
56
+ test("a kind with a ladder is measured at every rung of it", () => {
57
+ for (const encoder of [new SoftwareEncoder(), new NvencEncoder(), new QsvEncoder()]) {
58
+ const ladder = encoder.speedLadder;
59
+ assert.ok(ladder.values.length > 1, `${encoder.name} declares no ladder to walk`);
60
+ const rungs = ladder.values.map((rung) => encoder.benchmarkArgs(rung).join(" "));
61
+ assert.equal(new Set(rungs).size, ladder.values.length, `${encoder.name} gives the same arguments for different rungs`);
62
+ }
63
+ });
64
+
65
+ test("nothing about the machine is asked only of a software encoder", () => {
66
+ // Decoding and contention are properties of the host. Asked only where the
67
+ // encoder was software, a host with a GPU had neither, and the quality offer
68
+ // could not price a re-encode nor a second process.
69
+ const server = readFileSync(path.join(HERE, "..", "server.js"), "utf8");
70
+ for (const call of ["benchmarkDecodeCost", "benchmarkContention"]) {
71
+ const at = server.indexOf(call);
72
+ assert.ok(at > 0, `${call} is not called at startup at all`);
73
+ const before = server.slice(Math.max(0, at - 200), at);
74
+ assert.equal(
75
+ before.includes('kind === "software"'),
76
+ false,
77
+ `${call} is still gated on the encoder being software`
78
+ );
79
+ }
80
+ });
81
+
82
+ test("the throughput benchmark is given the encoder that will actually run", () => {
83
+ const server = readFileSync(path.join(HERE, "..", "server.js"), "utf8");
84
+ assert.match(
85
+ server,
86
+ /benchmarkSoftwarePresets\(\{[^}]*encoder: videoEncoder/,
87
+ "the benchmark is not told which encoder to measure"
88
+ );
89
+ });
90
+
91
+ test("a start and a stop are measured before any viewer, and reach the plan", () => {
92
+ const server = readFileSync(path.join(HERE, "..", "server.js"), "utf8");
93
+ assert.match(server, /await measureStartAndStop\(/, "nothing measures a start at startup");
94
+ assert.match(server, /\n startStopCost,/, "the reading never reaches the session manager");
95
+
96
+ const manager = readFileSync(path.join(HERE, "..", "services", "hls-session-manager.js"), "utf8");
97
+ assert.match(
98
+ manager,
99
+ /noteStartupCosts\(startStopCost\)/,
100
+ "the reading never reaches the encoding orchestrator"
101
+ );
102
+ });
103
+
104
+ test("with a startup reading, a cold plan is not told that starting is free", () => {
105
+ const costs = new RunCosts();
106
+ assert.equal(costs.seconds().firstByteWaitSec, 0, "nothing measured yet is nothing");
107
+
108
+ costs.noteStartup({ firstByteWaitSec: 0.68, killCostSec: 0.02 });
109
+ const cold = costs.seconds();
110
+ assert.equal(cold.firstByteWaitSec, 0.68, "the startup reading is not used");
111
+ assert.equal(cold.killCostSec, 0.02);
112
+
113
+ // A real run replaces it: the startup figure is where the plan starts from,
114
+ // not where it stays.
115
+ costs.note({ firstOutputMs: 4000, dyingMs: 300 });
116
+ const warm = costs.seconds();
117
+ assert.equal(warm.firstByteWaitSec, 4);
118
+ assert.equal(warm.killCostSec, 0.3);
119
+ });