@torrent-tv/proxy 2.34.0 → 2.36.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 +8 -1
- package/services/contention.js +128 -0
- package/services/hls-session-manager.js +365 -117
- package/services/hwaccel.js +142 -28
- package/services/learned-median.js +79 -0
- package/services/torrent-cost.js +88 -0
- package/test/contention.test.js +76 -0
- package/test/decode-cost.test.js +44 -12
- package/test/learned-median.test.js +37 -0
- package/test/torrent-cost.test.js +71 -0
package/services/hwaccel.js
CHANGED
|
@@ -25,6 +25,7 @@ import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import { fitDecodeCost } from "./decode-cost-fit.js";
|
|
28
|
+
import { penaltiesFrom } from "./contention.js";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
import {
|
|
30
31
|
parseFfmpegBitrateKbps,
|
|
@@ -156,21 +157,18 @@ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
|
|
|
156
157
|
const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
|
|
157
158
|
/** Progress reports arrive line by line. */
|
|
158
159
|
const NEWLINE = String.fromCharCode(10);
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
// margin was 1.8 for, and lowering it there would make an uncalibrated host
|
|
172
|
-
// more permissive than it was before any of this existed.
|
|
173
|
-
const ENCODE_ONLY_SPEED_MARGIN = 1.8;
|
|
160
|
+
// Producing one second of video per second of clock. Not a margin and not a
|
|
161
|
+
// choice — the definition of keeping up, and the bar when nothing better is
|
|
162
|
+
// known about the supply this step will meet.
|
|
163
|
+
const REALTIME = 1;
|
|
164
|
+
// The bar where decoding CANNOT be priced — no calibration fit, or a source the
|
|
165
|
+
// probe said too little about. This one is not measured and cannot be: the
|
|
166
|
+
// prediction it guards counts encoding only, which on the field host was
|
|
167
|
+
// several times optimistic, and there is no reading on such a host to correct
|
|
168
|
+
// it with. It is left at the figure it has had since before decoding was
|
|
169
|
+
// priced, because lowering it to realtime would make the least-measured hosts
|
|
170
|
+
// the most permissive. Where decoding IS priced, nothing chosen remains.
|
|
171
|
+
const UNPRICED_DECODE_BAR = 1.8;
|
|
174
172
|
|
|
175
173
|
/**
|
|
176
174
|
* @param {number} targetWidth
|
|
@@ -751,6 +749,88 @@ function parseClipCharacteristics(stderr) {
|
|
|
751
749
|
* @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
|
|
752
750
|
* @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
|
|
753
751
|
*/
|
|
752
|
+
/**
|
|
753
|
+
* What a second job costs on this host, measured rather than assumed.
|
|
754
|
+
*
|
|
755
|
+
* The budget adds seconds of work per second of content — this encode, plus
|
|
756
|
+
* that decode, plus what is already committed — and the addon host contradicted
|
|
757
|
+
* that directly on 2026-08-18: decoding ran at 2.10-2.25x alone, 0.79-0.90x
|
|
758
|
+
* with one encoder beside it and 0.56-0.64x with two. The same work costs 2.6×
|
|
759
|
+
* more for having company. Heat is not the cause (the hot idle machine was the
|
|
760
|
+
* fastest reading of all); four cores sharing one path to memory is.
|
|
761
|
+
*
|
|
762
|
+
* So it is measured the way everything else here is: the same clip decoded
|
|
763
|
+
* alone, then decoded again while an encoder of the same clip runs beside it.
|
|
764
|
+
* The ratio is the penalty. The encoder is stopped as soon as the reading is
|
|
765
|
+
* taken, and the whole thing costs one decode plus one short encode.
|
|
766
|
+
*
|
|
767
|
+
* @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string, upTo?: number }} options
|
|
768
|
+
* @returns {Promise<Map<number, number> | null>} Penalties by how many other
|
|
769
|
+
* jobs were running, or null when the readings could not be taken.
|
|
770
|
+
*/
|
|
771
|
+
export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, upTo = 2 }) {
|
|
772
|
+
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
773
|
+
// The cheapest clip in the set: this measures the MACHINE's behaviour under
|
|
774
|
+
// company, not the clip's own cost, so the smallest one says it soonest.
|
|
775
|
+
const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
|
|
776
|
+
const startedAt = Date.now();
|
|
777
|
+
const alone = await measureDecodeSlope(ffmpegBin, clip);
|
|
778
|
+
if (!alone) {
|
|
779
|
+
log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
/** @type {Array<{ others: number, speed: number }>} */
|
|
783
|
+
const beside = [];
|
|
784
|
+
/** @type {import("node:child_process").ChildProcess[]} */
|
|
785
|
+
const load = [];
|
|
786
|
+
try {
|
|
787
|
+
for (let others = 1; others <= Math.max(1, upTo); others += 1) {
|
|
788
|
+
load.push(
|
|
789
|
+
spawn(
|
|
790
|
+
ffmpegBin,
|
|
791
|
+
[
|
|
792
|
+
"-hide_banner", "-loglevel", "error", "-nostats",
|
|
793
|
+
"-stream_loop", "-1", "-i", clip,
|
|
794
|
+
"-an", "-c:v", "libx264", "-preset", "fast", "-f", "null", "-"
|
|
795
|
+
],
|
|
796
|
+
{ stdio: ["ignore", "ignore", "ignore"], windowsHide: true }
|
|
797
|
+
)
|
|
798
|
+
);
|
|
799
|
+
// Let the encoder reach its own speed before reading anything: an encode
|
|
800
|
+
// measured in its first moments is measuring the process starting.
|
|
801
|
+
await new Promise((resolve) => {
|
|
802
|
+
setTimeout(resolve, 2_000);
|
|
803
|
+
});
|
|
804
|
+
const withCompany = await measureDecodeSlope(ffmpegBin, clip);
|
|
805
|
+
if (withCompany) {
|
|
806
|
+
beside.push({ others, speed: withCompany.speed });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
} finally {
|
|
810
|
+
for (const child of load) {
|
|
811
|
+
try {
|
|
812
|
+
child.kill("SIGKILL");
|
|
813
|
+
} catch {
|
|
814
|
+
// Already gone: the reading is what mattered, and nothing else uses it.
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const penalties = penaltiesFrom(alone.speed, beside);
|
|
819
|
+
if (!penalties) {
|
|
820
|
+
log.warn("hwaccel: contention readings said nothing; costs will be added as though jobs were independent");
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
log.info(
|
|
824
|
+
`hwaccel: a second job costs ${[...penalties.entries()]
|
|
825
|
+
.map(([others, penalty]) => `${penalty.toFixed(2)}x beside ${others}`)
|
|
826
|
+
.join(", ")} ` +
|
|
827
|
+
`(decode alone ${alone.speed.toFixed(2)}x, ` +
|
|
828
|
+
`${beside.map((reading) => `${reading.speed.toFixed(2)}x beside ${reading.others}`).join(", ")}, ` +
|
|
829
|
+
`measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
|
|
830
|
+
);
|
|
831
|
+
return penalties;
|
|
832
|
+
}
|
|
833
|
+
|
|
754
834
|
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
|
|
755
835
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
756
836
|
const startedAllAt = Date.now();
|
|
@@ -981,7 +1061,7 @@ export function predictedRealtimeSpeed({
|
|
|
981
1061
|
* The encoder figure is the FASTEST benchmarked preset: it is the best this
|
|
982
1062
|
* host can do, so a rung it cannot hold cannot be held at any quality setting.
|
|
983
1063
|
*
|
|
984
|
-
* @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number }} params
|
|
1064
|
+
* @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number, requiredSpeed?: number | null }} params
|
|
985
1065
|
* @returns {{ speed: number | null, sustainable: boolean }}
|
|
986
1066
|
*/
|
|
987
1067
|
export function canSustainOutput({
|
|
@@ -990,7 +1070,8 @@ export function canSustainOutput({
|
|
|
990
1070
|
source = null,
|
|
991
1071
|
outputPixelsPerSec,
|
|
992
1072
|
observedDecodeCostSec = null,
|
|
993
|
-
concurrentCostSec = 0
|
|
1073
|
+
concurrentCostSec = 0,
|
|
1074
|
+
requiredSpeed = null
|
|
994
1075
|
}) {
|
|
995
1076
|
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
996
1077
|
// Nothing measured on this host: the budget cannot refuse what it cannot
|
|
@@ -1031,11 +1112,45 @@ export function canSustainOutput({
|
|
|
1031
1112
|
if (speed === null) {
|
|
1032
1113
|
return { speed: null, sustainable: true };
|
|
1033
1114
|
}
|
|
1034
|
-
return { speed, sustainable: speed >=
|
|
1115
|
+
return { speed, sustainable: speed >= speedBar(requiredSpeed) };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* The speed a step has to reach to be worth offering.
|
|
1120
|
+
*
|
|
1121
|
+
* Realtime is not enough on its own: a step that produces exactly one second
|
|
1122
|
+
* per second never recovers the seconds lost while its reader waits for the
|
|
1123
|
+
* swarm, so it survives its own supply only if what it gains between
|
|
1124
|
+
* interruptions covers what one interruption costs. That is measured per file
|
|
1125
|
+
* and per swarm by the reader — `1 + worst wait / median interval`, in
|
|
1126
|
+
* `supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
|
|
1127
|
+
* against the 1.5 that used to stand here, and to 4.04-8.14 on a torrent whose
|
|
1128
|
+
* swarm no encoder could have kept up with.
|
|
1129
|
+
*
|
|
1130
|
+
* Where that figure does not exist yet — fewer than two interruptions measured
|
|
1131
|
+
* — the bar is realtime. It is the one thing that can be said without
|
|
1132
|
+
* measuring the swarm, and the offer is restated as soon as the reader has
|
|
1133
|
+
* something to say.
|
|
1134
|
+
*
|
|
1135
|
+
* @param {number | null | undefined} requiredSpeed - What this file's own
|
|
1136
|
+
* interruptions demand, when they have been measured.
|
|
1137
|
+
* @returns {number}
|
|
1138
|
+
*/
|
|
1139
|
+
export function speedBar(requiredSpeed) {
|
|
1140
|
+
return Number.isFinite(requiredSpeed) && requiredSpeed > REALTIME ? requiredSpeed : REALTIME;
|
|
1035
1141
|
}
|
|
1036
1142
|
|
|
1037
|
-
/**
|
|
1038
|
-
|
|
1143
|
+
/**
|
|
1144
|
+
* The bar for a cost description — the supply's demand where decoding is
|
|
1145
|
+
* priced, and never below the unpriced-decode bar where it is not.
|
|
1146
|
+
*
|
|
1147
|
+
* @param {{ decodeModel?: object | null, source?: object | null, observedDecodeCostSec?: number | null, requiredSpeed?: number | null }} cost
|
|
1148
|
+
* @returns {number}
|
|
1149
|
+
*/
|
|
1150
|
+
function barFor(cost) {
|
|
1151
|
+
const measured = speedBar(cost?.requiredSpeed);
|
|
1152
|
+
return isDecodePriced(cost) ? measured : Math.max(UNPRICED_DECODE_BAR, measured);
|
|
1153
|
+
}
|
|
1039
1154
|
|
|
1040
1155
|
/**
|
|
1041
1156
|
* Benchmark software libx264 presets on this host. Encodes a short synthetic
|
|
@@ -1294,7 +1409,7 @@ async function decodeToRawFrames(ffmpegBin, log) {
|
|
|
1294
1409
|
*
|
|
1295
1410
|
* @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
|
|
1296
1411
|
* @param {number} pixelsPerSecNeeded
|
|
1297
|
-
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
|
|
1412
|
+
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
|
|
1298
1413
|
* @returns {string}
|
|
1299
1414
|
*/
|
|
1300
1415
|
/**
|
|
@@ -1322,8 +1437,7 @@ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
|
|
|
1322
1437
|
const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
|
|
1323
1438
|
? cost.observedDecodeCostSec
|
|
1324
1439
|
: null;
|
|
1325
|
-
const
|
|
1326
|
-
const bar = priced ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
|
|
1440
|
+
const bar = barFor(cost);
|
|
1327
1441
|
// The FIRST entry that clears the bar wins — the list is in quality order, so
|
|
1328
1442
|
// that is the best picture this host can hold. Every entry is examined rather
|
|
1329
1443
|
// than the walk stopping at the first miss, because the measurements do not
|
|
@@ -1410,9 +1524,9 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
|
|
|
1410
1524
|
* Choose the software encode settings (resolution + preset) that fit the
|
|
1411
1525
|
* realtime budget on this host. From the resolution ladder (ceiling downward),
|
|
1412
1526
|
* pick the HIGHEST rung whose encode throughput — predicted from the startup
|
|
1413
|
-
* benchmark's fastest preset — clears
|
|
1414
|
-
* that resolution, pick the highest-quality
|
|
1415
|
-
*
|
|
1527
|
+
* benchmark's fastest preset — clears the speed this file's own supply
|
|
1528
|
+
* demands (`speedBar`). Then, at that resolution, pick the highest-quality
|
|
1529
|
+
* preset that still clears it. When even the lowest rung cannot clear it, use the lowest rung with
|
|
1416
1530
|
* the fastest preset (best effort — a smaller picture beats sub-realtime
|
|
1417
1531
|
* playback at full size). Returns null when no benchmark or ceiling is
|
|
1418
1532
|
* available (the caller keeps the ceiling resolution and the default preset).
|
|
@@ -1420,7 +1534,7 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
|
|
|
1420
1534
|
* @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
|
|
1421
1535
|
* @param {{ width: number, height: number }} ceiling
|
|
1422
1536
|
* @param {number} outputFps
|
|
1423
|
-
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
|
|
1537
|
+
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
|
|
1424
1538
|
* @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
|
|
1425
1539
|
*/
|
|
1426
1540
|
export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
|
|
@@ -1433,7 +1547,7 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
|
|
|
1433
1547
|
return null;
|
|
1434
1548
|
}
|
|
1435
1549
|
const fastest = cheapestPresetPixelsPerSec(benchmark); // the cheapest preset's throughput
|
|
1436
|
-
const bar =
|
|
1550
|
+
const bar = barFor(cost);
|
|
1437
1551
|
let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
|
|
1438
1552
|
for (let i = 0; i < ladder.length; i += 1) {
|
|
1439
1553
|
const speed = predictedRealtimeSpeed({
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Publishing a value that is learned from repeated readings.
|
|
3
|
+
*
|
|
4
|
+
* Four costs are learned this way — what a file costs to decode, what copying
|
|
5
|
+
* it costs, what a soundtrack costs, what the torrent costs per megabyte — and
|
|
6
|
+
* each has the same two decisions to make: which figure the readings amount to,
|
|
7
|
+
* and when a new figure replaces the published one.
|
|
8
|
+
*
|
|
9
|
+
* The first is the median, because one starved moment must not drag the answer.
|
|
10
|
+
*
|
|
11
|
+
* The second used to be "more than five per cent", a figure nobody measured.
|
|
12
|
+
* It is now the scatter of the readings themselves: a median that has moved
|
|
13
|
+
* further than its own readings disagree with each other has moved for a
|
|
14
|
+
* reason, and one that has not is the same answer read again. Republishing
|
|
15
|
+
* costs something real — every session recomputes its offer, on the path that
|
|
16
|
+
* serves every playlist, init and segment — so the question is worth asking,
|
|
17
|
+
* but it is answered from the measurements rather than from a constant.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The middle of a set of readings, or null when there are none.
|
|
22
|
+
*
|
|
23
|
+
* @param {number[]} values
|
|
24
|
+
* @returns {number | null}
|
|
25
|
+
*/
|
|
26
|
+
export function medianOf(values) {
|
|
27
|
+
const usable = (Array.isArray(values) ? values : []).filter((value) => Number.isFinite(value));
|
|
28
|
+
if (usable.length === 0) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const sorted = [...usable].sort((left, right) => left - right);
|
|
32
|
+
const middle = Math.floor(sorted.length / 2);
|
|
33
|
+
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How far the readings sit from their own middle — the median of their
|
|
38
|
+
* distances to it.
|
|
39
|
+
*
|
|
40
|
+
* The median distance rather than the mean one, for the same reason the value
|
|
41
|
+
* itself is a median: a single disturbed reading describes the disturbance, not
|
|
42
|
+
* the host.
|
|
43
|
+
*
|
|
44
|
+
* @param {number[]} values
|
|
45
|
+
* @returns {number | null} Null when there is nothing to measure. Zero is a
|
|
46
|
+
* real answer: readings that all agree disagree by nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function scatterOf(values) {
|
|
49
|
+
const middle = medianOf(values);
|
|
50
|
+
if (middle === null) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const distances = (Array.isArray(values) ? values : [])
|
|
54
|
+
.filter((value) => Number.isFinite(value))
|
|
55
|
+
.map((value) => Math.abs(value - middle));
|
|
56
|
+
return medianOf(distances);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a newly computed median should replace the published one.
|
|
61
|
+
*
|
|
62
|
+
* @param {number | null} published - What every session is answering with now.
|
|
63
|
+
* @param {number} median - What the readings say today.
|
|
64
|
+
* @param {number[]} readings - The readings that median came from.
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function movedBeyondScatter(published, median, readings) {
|
|
68
|
+
if (!Number.isFinite(median)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isFinite(published)) {
|
|
72
|
+
return true; // nothing published yet; the first answer is always news
|
|
73
|
+
}
|
|
74
|
+
const scatter = scatterOf(readings);
|
|
75
|
+
if (scatter === null) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return Math.abs(median - published) > scatter;
|
|
79
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the torrent itself costs this machine, per megabyte it moves.
|
|
3
|
+
*
|
|
4
|
+
* Downloading, verifying every piece and pushing segments down a data channel
|
|
5
|
+
* are work on the same box as the encoder, and they scale with what is moved.
|
|
6
|
+
* The reading is taken only while nothing is encoding, because that is the one
|
|
7
|
+
* moment the spending needs no arithmetic to attribute: what the process uses
|
|
8
|
+
* then is the torrent's.
|
|
9
|
+
*
|
|
10
|
+
* Except that it is not all the torrent's, and that is what this file exists
|
|
11
|
+
* for. Measured on the addon host 2026-08-18, one session reported **145.4 ms
|
|
12
|
+
* per megabyte over 8.7 MB** and **23.1 ms per megabyte over 54 MB** — the same
|
|
13
|
+
* host, the same minute, a sixfold disagreement that follows the size of the
|
|
14
|
+
* interval rather than anything about the torrent. A process with nothing to do
|
|
15
|
+
* still runs its timers, its tunnel and its session sweeps, and that draw does
|
|
16
|
+
* not shrink when fewer megabytes move: divided by a small number it swamps the
|
|
17
|
+
* answer. A minimum-megabytes threshold stood against exactly this and did not
|
|
18
|
+
* hold, because a chosen number was standing in for a measured one.
|
|
19
|
+
*
|
|
20
|
+
* The draw is measurable. An interval in which nothing encodes and the torrents
|
|
21
|
+
* move no bytes at all costs whatever this process spends anyway; subtract that
|
|
22
|
+
* from an interval which did move bytes, and what remains is the torrent's.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The share of one core this process draws while it has nothing to do.
|
|
27
|
+
*
|
|
28
|
+
* @param {{ cpuSeconds: number, elapsedSeconds: number }} interval - CPU seconds
|
|
29
|
+
* already divided by the number of cores, so the figure is comparable with
|
|
30
|
+
* the wall seconds every other cost in the budget is stated in.
|
|
31
|
+
* @returns {number | null} Null when the interval cannot be divided.
|
|
32
|
+
*/
|
|
33
|
+
export function baseDrawFrom({ cpuSeconds, elapsedSeconds }) {
|
|
34
|
+
if (!Number.isFinite(cpuSeconds) || !Number.isFinite(elapsedSeconds) || !(elapsedSeconds > 0)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (!(cpuSeconds >= 0)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return cpuSeconds / elapsedSeconds;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What one megabyte cost, once the draw that would have been spent anyway is
|
|
45
|
+
* taken off.
|
|
46
|
+
*
|
|
47
|
+
* The draw is subtracted, so what is left is only as certain as the draw is.
|
|
48
|
+
* The draw's own readings disagree with each other by a measured amount, and
|
|
49
|
+
* over an interval that disagreement is worth `scatter × elapsed` seconds of
|
|
50
|
+
* CPU: a reading whose remainder is smaller than that measures the wobble in
|
|
51
|
+
* the subtraction rather than the torrent. This is what the removed
|
|
52
|
+
* minimum-megabytes threshold was reaching for, arrived at from the readings
|
|
53
|
+
* instead of from a chosen size — a small interval fails it because little was
|
|
54
|
+
* moved in it, and a large one passes on the same arithmetic.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ cpuSeconds: number, elapsedSeconds: number, megabytes: number, baseDraw: number | null, drawScatter?: number | null }} interval
|
|
57
|
+
* @returns {number | null} Seconds of work per megabyte, or null when this
|
|
58
|
+
* interval cannot say — no base draw measured yet, nothing moved, or the
|
|
59
|
+
* interval spent no more than the draw and its own uncertainty account for.
|
|
60
|
+
*/
|
|
61
|
+
export function costPerMegabyteFrom({ cpuSeconds, elapsedSeconds, megabytes, baseDraw, drawScatter = null }) {
|
|
62
|
+
if (!Number.isFinite(cpuSeconds) || !Number.isFinite(elapsedSeconds) || !(elapsedSeconds > 0)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isFinite(megabytes) || !(megabytes > 0)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isFinite(baseDraw) || !(baseDraw >= 0)) {
|
|
69
|
+
// Nothing measured to subtract. Publishing the whole spending as the
|
|
70
|
+
// torrent's is what produced the 145 ms/MB reading, so this interval says
|
|
71
|
+
// nothing instead.
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const attributable = cpuSeconds - baseDraw * elapsedSeconds;
|
|
75
|
+
if (!(attributable > 0)) {
|
|
76
|
+
// The draw accounts for everything this interval spent. Not a discovery
|
|
77
|
+
// that the torrent is free — a reading with nothing left in it.
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const uncertainty = Number.isFinite(drawScatter) && drawScatter > 0 ? drawScatter * elapsedSeconds : 0;
|
|
81
|
+
if (!(attributable > uncertainty)) {
|
|
82
|
+
// What is left is inside the disagreement between the draw's own readings.
|
|
83
|
+
// Dividing it by a small number of megabytes is how 145 ms/MB was reported
|
|
84
|
+
// on a host that costs 23.
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return attributable / megabytes;
|
|
88
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The price of a second job, with the readings it was derived from.
|
|
3
|
+
*
|
|
4
|
+
* Measured on the addon host 2026-08-18: decoding the same clip ran at 2.20x
|
|
5
|
+
* alone, 0.85x with one encoder beside it and 0.60x with two. The budget has
|
|
6
|
+
* been adding independent prices, and these say two jobs that each fit alone do
|
|
7
|
+
* not fit together.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
|
|
13
|
+
import { contentionPenalty, costWithContention, penaltiesFrom } from "../services/contention.js";
|
|
14
|
+
|
|
15
|
+
/** The addon host's own readings. */
|
|
16
|
+
const ALONE = 2.2;
|
|
17
|
+
const BESIDE = [
|
|
18
|
+
{ others: 1, speed: 0.85 },
|
|
19
|
+
{ others: 2, speed: 0.6 }
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
test("the penalties are the ratios of the measured speeds", () => {
|
|
23
|
+
const penalties = penaltiesFrom(ALONE, BESIDE);
|
|
24
|
+
|
|
25
|
+
assert.ok(penalties);
|
|
26
|
+
// 2.2 / 0.85 = 2.59: the same work costs two and a half times more with one
|
|
27
|
+
// encoder beside it.
|
|
28
|
+
assert.ok(Math.abs(penalties.get(1) - 2.588) < 0.01, `${penalties.get(1)}`);
|
|
29
|
+
assert.ok(Math.abs(penalties.get(2) - 3.667) < 0.01, `${penalties.get(2)}`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("alone on the machine is what the benchmarks measure, so nothing is corrected", () => {
|
|
33
|
+
const penalties = penaltiesFrom(ALONE, BESIDE);
|
|
34
|
+
const answer = contentionPenalty(0, penalties);
|
|
35
|
+
assert.equal(answer.penalty, 1);
|
|
36
|
+
assert.equal(answer.measured, true);
|
|
37
|
+
assert.equal(costWithContention(0.45, 0, penalties), 0.45);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a cost beside one other job is the measured multiple, not the sum of two prices", () => {
|
|
41
|
+
const penalties = penaltiesFrom(ALONE, BESIDE);
|
|
42
|
+
// Decoding priced at 0.45 s/s alone becomes 1.17 s/s beside an encoder —
|
|
43
|
+
// which is what the field measured (1.18), and what additivity cannot say.
|
|
44
|
+
const corrected = costWithContention(0.45, 1, penalties);
|
|
45
|
+
assert.ok(Math.abs(corrected - 1.165) < 0.01, `${corrected}`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("beyond the readings it holds the largest instead of extrapolating", () => {
|
|
49
|
+
const penalties = penaltiesFrom(ALONE, BESIDE);
|
|
50
|
+
const four = contentionPenalty(4, penalties);
|
|
51
|
+
assert.equal(four.penalty, penalties.get(2), "two readings say nothing about a fourth job");
|
|
52
|
+
assert.equal(four.from, 2, "and the answer says which reading it came from");
|
|
53
|
+
assert.equal(four.measured, true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("with nothing measured the prediction is left alone, not guessed at", () => {
|
|
57
|
+
const answer = contentionPenalty(1, null);
|
|
58
|
+
assert.equal(answer.penalty, 1);
|
|
59
|
+
assert.equal(answer.measured, false, "the caller must be able to say the figure is uncorrected");
|
|
60
|
+
assert.equal(costWithContention(0.45, 1, null), 0.45);
|
|
61
|
+
assert.equal(contentionPenalty(1, new Map()).measured, false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a machine that got faster for being busier has no penalty to measure", () => {
|
|
65
|
+
// Noise on a fast host, not a discovery. Measured on the developer's desktop,
|
|
66
|
+
// the difference between one and two jobs is inside the scatter.
|
|
67
|
+
const penalties = penaltiesFrom(20, [{ others: 1, speed: 21 }]);
|
|
68
|
+
assert.equal(penalties.get(1), 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("readings that are not measurements are left out", () => {
|
|
72
|
+
assert.equal(penaltiesFrom(0, BESIDE), null, "every penalty is relative to the alone reading");
|
|
73
|
+
assert.equal(penaltiesFrom(ALONE, []), null);
|
|
74
|
+
assert.equal(penaltiesFrom(ALONE, [{ others: 0, speed: 2 }]), null, "zero others is not a penalty");
|
|
75
|
+
assert.equal(penaltiesFrom(ALONE, [{ others: 1, speed: Number.NaN }]), null);
|
|
76
|
+
});
|
package/test/decode-cost.test.js
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
canSustainOutput,
|
|
25
25
|
decodeSpeedFor,
|
|
26
26
|
predictedRealtimeSpeed,
|
|
27
|
-
|
|
27
|
+
speedBar
|
|
28
28
|
} from "../services/hwaccel.js";
|
|
29
29
|
import { HlsSessionManager, sourceDecodeCharacteristics } from "../services/hls-session-manager.js";
|
|
30
30
|
import { parseFfmpegBitrateKbps, parseFfmpegVideoDimensions, parseFfmpegVideoFps } from "../services/ffmpeg-banner.js";
|
|
@@ -111,7 +111,7 @@ test("with no decode fit the prediction is the encoder alone — what it was bef
|
|
|
111
111
|
assert.equal(speed, 5.99, "the old figure, four times the truth on that rung");
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
test("a rung is refused when the combined speed is under the
|
|
114
|
+
test("a rung is refused when the combined speed is under the bar", () => {
|
|
115
115
|
// The addon host's fastest preset, read from its own log: 11.2 Mpx/s.
|
|
116
116
|
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
117
117
|
const source = MEASURED_FILM;
|
|
@@ -123,14 +123,10 @@ test("a rung is refused when the combined speed is under the margin", () => {
|
|
|
123
123
|
outputPixelsPerSec: 1280 * 720 * 24
|
|
124
124
|
});
|
|
125
125
|
assert.equal(heavy.sustainable, false, "720p needs 22 Mpx/s from an 11.2 Mpx/s host");
|
|
126
|
-
assert.ok(heavy.speed <
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// 0.388-0.947x under real load — a host simultaneously copying 1080p,
|
|
131
|
-
// downloading the torrent and pushing segments. The prediction is honest for
|
|
132
|
-
// an idle machine; the margin is what has to carry the load, and 1.5 does not
|
|
133
|
-
// carry it. Pinned here so the arithmetic is not rediscovered from a log.
|
|
126
|
+
assert.ok(heavy.speed < 1, `predicted ${heavy.speed.toFixed(2)}x`);
|
|
127
|
+
|
|
128
|
+
// The 240p rung of that film predicts 1.58x on an idle machine, and with
|
|
129
|
+
// nothing known about the swarm the bar is realtime, so it is offered.
|
|
134
130
|
const light = canSustainOutput({
|
|
135
131
|
benchmark,
|
|
136
132
|
decodeModel: ADDON_HOST_MODEL,
|
|
@@ -141,6 +137,26 @@ test("a rung is refused when the combined speed is under the margin", () => {
|
|
|
141
137
|
assert.equal(light.sustainable, true);
|
|
142
138
|
});
|
|
143
139
|
|
|
140
|
+
test("the bar is what this file's own supply demands, when it has been measured", () => {
|
|
141
|
+
// The field torrent of 2026-08-17: waits of 1.49 s median arriving every
|
|
142
|
+
// 2.22 s demand 1.67x of any step that is to survive them. The same 240p rung
|
|
143
|
+
// predicted at 1.58x clears realtime and does not clear that.
|
|
144
|
+
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
145
|
+
const rung = {
|
|
146
|
+
benchmark,
|
|
147
|
+
decodeModel: ADDON_HOST_MODEL,
|
|
148
|
+
source: MEASURED_FILM,
|
|
149
|
+
outputPixelsPerSec: 426 * 240 * 24
|
|
150
|
+
};
|
|
151
|
+
assert.equal(canSustainOutput({ ...rung, requiredSpeed: 1.67 }).sustainable, false);
|
|
152
|
+
assert.equal(canSustainOutput({ ...rung, requiredSpeed: 1.2 }).sustainable, true);
|
|
153
|
+
// A swarm that has not been measured cannot raise the bar, and cannot lower
|
|
154
|
+
// it below realtime either.
|
|
155
|
+
assert.equal(speedBar(null), 1);
|
|
156
|
+
assert.equal(speedBar(0.4), 1);
|
|
157
|
+
assert.equal(speedBar(1.67), 1.67);
|
|
158
|
+
});
|
|
159
|
+
|
|
144
160
|
test("a reading from the running encoder outranks the model of the clips", () => {
|
|
145
161
|
// The field case, 2026-08-14: the 240p rung of that film ran at 0.95x at its
|
|
146
162
|
// best on a host whose fastest preset benchmarked at 11.2 Mpx/s. Subtracting
|
|
@@ -340,8 +356,24 @@ test("the master playlist drops the rungs the host cannot hold", async (t) => {
|
|
|
340
356
|
const stronger = { ...session, id: "dddddddd-eeee-ffff-0000-111111111111", offeredHeightsCache: undefined };
|
|
341
357
|
manager.sessionsById.set(stronger.id, stronger);
|
|
342
358
|
const master = manager.buildMasterPlaylist(stronger.id);
|
|
343
|
-
assert.ok(master, "1080p copied plus the
|
|
359
|
+
assert.ok(master, "1080p copied plus the rungs this host can produce");
|
|
344
360
|
const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
|
|
345
|
-
assert.deepEqual(heights, [1080, 240]);
|
|
361
|
+
assert.deepEqual(heights, [1080, 360, 240], "nothing is known about this swarm, so the bar is realtime");
|
|
346
362
|
assert.deepEqual(manager.offeredHeights(stronger), heights);
|
|
363
|
+
|
|
364
|
+
// The same host, once the reader has measured what this file's supply
|
|
365
|
+
// demands: waits arriving as they did on the field torrent of 2026-08-17 ask
|
|
366
|
+
// 1.67x of any step, and the rungs that only just cleared realtime go.
|
|
367
|
+
const onAThinSwarm = {
|
|
368
|
+
...stronger,
|
|
369
|
+
id: "eeeeeeee-ffff-0000-1111-222222222222",
|
|
370
|
+
offeredHeightsCache: undefined,
|
|
371
|
+
supplyFigures: { requiredSpeed: 1.67, worstWaitSec: 1.49, medianIntervalSec: 2.22, samples: 12 }
|
|
372
|
+
};
|
|
373
|
+
manager.sessionsById.set(onAThinSwarm.id, onAThinSwarm);
|
|
374
|
+
assert.deepEqual(
|
|
375
|
+
manager.offeredHeights(onAThinSwarm),
|
|
376
|
+
[1080],
|
|
377
|
+
"the copied height costs no encoder and stays; nothing re-encoded survives that supply"
|
|
378
|
+
);
|
|
347
379
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Adopting a new median only when it has moved further than the readings
|
|
3
|
+
* behind it disagree with each other.
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { medianOf, movedBeyondScatter, scatterOf } from "../services/learned-median.js";
|
|
8
|
+
|
|
9
|
+
test("the median is the middle reading, and the mean of the two middle ones", () => {
|
|
10
|
+
assert.equal(medianOf([3, 1, 2]), 2);
|
|
11
|
+
assert.equal(medianOf([1, 2, 3, 4]), 2.5);
|
|
12
|
+
assert.equal(medianOf([]), null);
|
|
13
|
+
assert.equal(medianOf([Number.NaN]), null);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("the scatter is the median distance from the middle", () => {
|
|
17
|
+
assert.equal(scatterOf([10, 10, 10]), 0);
|
|
18
|
+
assert.equal(scatterOf([8, 10, 12]), 2);
|
|
19
|
+
assert.equal(scatterOf([]), null);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the first answer is always adopted", () => {
|
|
23
|
+
assert.equal(movedBeyondScatter(null, 5, [5]), true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a move smaller than the readings' own disagreement is the same answer", () => {
|
|
27
|
+
assert.equal(movedBeyondScatter(10, 11, [8, 10, 12]), false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a move larger than that disagreement is adopted", () => {
|
|
31
|
+
assert.equal(movedBeyondScatter(10, 13, [8, 10, 12]), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("readings that all agree let any change through", () => {
|
|
35
|
+
assert.equal(movedBeyondScatter(10, 10.4, [10, 10, 10]), true);
|
|
36
|
+
assert.equal(movedBeyondScatter(10, 10, [10, 10, 10]), false);
|
|
37
|
+
});
|