@torrent-tv/proxy 2.35.0 → 2.36.1
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 +12 -0
- package/package.json +1 -1
- package/scripts/read-container-index.mjs +108 -0
- package/services/container-index/matroska.js +142 -6
- package/services/contention.js +128 -128
- package/services/hls-session-manager.js +326 -116
- package/services/hwaccel.js +59 -28
- package/services/learned-median.js +79 -0
- package/services/torrent-cost.js +88 -0
- package/test/decode-cost.test.js +44 -12
- package/test/learned-median.test.js +37 -0
- package/test/matroska-cues-track.test.js +192 -0
- package/test/torrent-cost.test.js +71 -0
package/services/hwaccel.js
CHANGED
|
@@ -157,21 +157,18 @@ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
|
|
|
157
157
|
const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
|
|
158
158
|
/** Progress reports arrive line by line. */
|
|
159
159
|
const NEWLINE = String.fromCharCode(10);
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
// margin was 1.8 for, and lowering it there would make an uncalibrated host
|
|
173
|
-
// more permissive than it was before any of this existed.
|
|
174
|
-
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;
|
|
175
172
|
|
|
176
173
|
/**
|
|
177
174
|
* @param {number} targetWidth
|
|
@@ -1064,7 +1061,7 @@ export function predictedRealtimeSpeed({
|
|
|
1064
1061
|
* The encoder figure is the FASTEST benchmarked preset: it is the best this
|
|
1065
1062
|
* host can do, so a rung it cannot hold cannot be held at any quality setting.
|
|
1066
1063
|
*
|
|
1067
|
-
* @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
|
|
1068
1065
|
* @returns {{ speed: number | null, sustainable: boolean }}
|
|
1069
1066
|
*/
|
|
1070
1067
|
export function canSustainOutput({
|
|
@@ -1073,7 +1070,8 @@ export function canSustainOutput({
|
|
|
1073
1070
|
source = null,
|
|
1074
1071
|
outputPixelsPerSec,
|
|
1075
1072
|
observedDecodeCostSec = null,
|
|
1076
|
-
concurrentCostSec = 0
|
|
1073
|
+
concurrentCostSec = 0,
|
|
1074
|
+
requiredSpeed = null
|
|
1077
1075
|
}) {
|
|
1078
1076
|
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
1079
1077
|
// Nothing measured on this host: the budget cannot refuse what it cannot
|
|
@@ -1114,11 +1112,45 @@ export function canSustainOutput({
|
|
|
1114
1112
|
if (speed === null) {
|
|
1115
1113
|
return { speed: null, sustainable: true };
|
|
1116
1114
|
}
|
|
1117
|
-
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;
|
|
1118
1141
|
}
|
|
1119
1142
|
|
|
1120
|
-
/**
|
|
1121
|
-
|
|
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
|
+
}
|
|
1122
1154
|
|
|
1123
1155
|
/**
|
|
1124
1156
|
* Benchmark software libx264 presets on this host. Encodes a short synthetic
|
|
@@ -1377,7 +1409,7 @@ async function decodeToRawFrames(ffmpegBin, log) {
|
|
|
1377
1409
|
*
|
|
1378
1410
|
* @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
|
|
1379
1411
|
* @param {number} pixelsPerSecNeeded
|
|
1380
|
-
* @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]
|
|
1381
1413
|
* @returns {string}
|
|
1382
1414
|
*/
|
|
1383
1415
|
/**
|
|
@@ -1405,8 +1437,7 @@ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
|
|
|
1405
1437
|
const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
|
|
1406
1438
|
? cost.observedDecodeCostSec
|
|
1407
1439
|
: null;
|
|
1408
|
-
const
|
|
1409
|
-
const bar = priced ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
|
|
1440
|
+
const bar = barFor(cost);
|
|
1410
1441
|
// The FIRST entry that clears the bar wins — the list is in quality order, so
|
|
1411
1442
|
// that is the best picture this host can hold. Every entry is examined rather
|
|
1412
1443
|
// than the walk stopping at the first miss, because the measurements do not
|
|
@@ -1493,9 +1524,9 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
|
|
|
1493
1524
|
* Choose the software encode settings (resolution + preset) that fit the
|
|
1494
1525
|
* realtime budget on this host. From the resolution ladder (ceiling downward),
|
|
1495
1526
|
* pick the HIGHEST rung whose encode throughput — predicted from the startup
|
|
1496
|
-
* benchmark's fastest preset — clears
|
|
1497
|
-
* that resolution, pick the highest-quality
|
|
1498
|
-
*
|
|
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
|
|
1499
1530
|
* the fastest preset (best effort — a smaller picture beats sub-realtime
|
|
1500
1531
|
* playback at full size). Returns null when no benchmark or ceiling is
|
|
1501
1532
|
* available (the caller keeps the ceiling resolution and the default preset).
|
|
@@ -1503,7 +1534,7 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
|
|
|
1503
1534
|
* @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
|
|
1504
1535
|
* @param {{ width: number, height: number }} ceiling
|
|
1505
1536
|
* @param {number} outputFps
|
|
1506
|
-
* @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]
|
|
1507
1538
|
* @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
|
|
1508
1539
|
*/
|
|
1509
1540
|
export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
|
|
@@ -1516,7 +1547,7 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
|
|
|
1516
1547
|
return null;
|
|
1517
1548
|
}
|
|
1518
1549
|
const fastest = cheapestPresetPixelsPerSec(benchmark); // the cheapest preset's throughput
|
|
1519
|
-
const bar =
|
|
1550
|
+
const bar = barFor(cost);
|
|
1520
1551
|
let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
|
|
1521
1552
|
for (let i = 0; i < ladder.length; i += 1) {
|
|
1522
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
|
+
}
|
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
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The container's table names a track, and only the picture's entries are
|
|
3
|
+
* cut points.
|
|
4
|
+
*
|
|
5
|
+
* Measured 2026-08-18 on the two files the field sessions were recorded from:
|
|
6
|
+
* `Minions.and.Monsters.1080p.mkv` carries 2778 cue entries for its video track
|
|
7
|
+
* — one every 2.002 s — and 4669 more across four subtitle tracks;
|
|
8
|
+
* `Moana.2 … MegaPeer.mkv` carries 1055 for video and 5007 across five subtitle
|
|
9
|
+
* tracks. Read without the track, both sets went into the cut list together,
|
|
10
|
+
* and ffmpeg — which can only cut a copied picture at a real keyframe at or
|
|
11
|
+
* after the time it is asked for — moved every such cut forward to the next
|
|
12
|
+
* keyframe. That is the whole of the disagreement the field reported: 2.002 s
|
|
13
|
+
* on the first file, a median of 6.3 s and a worst case of 21 s on the second,
|
|
14
|
+
* and never once negative.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { readMatroskaKeyframeTimes } from "../services/container-index/matroska.js";
|
|
20
|
+
|
|
21
|
+
const ID_EBML = 0x1a45dfa3;
|
|
22
|
+
const ID_SEGMENT = 0x18538067;
|
|
23
|
+
const ID_SEEK_HEAD = 0x114d9b74;
|
|
24
|
+
const ID_SEEK = 0x4dbb;
|
|
25
|
+
const ID_SEEK_ID = 0x53ab;
|
|
26
|
+
const ID_SEEK_POSITION = 0x53ac;
|
|
27
|
+
const ID_INFO = 0x1549a966;
|
|
28
|
+
const ID_TIMESTAMP_SCALE = 0x2ad7b1;
|
|
29
|
+
const ID_TRACKS = 0x1654ae6b;
|
|
30
|
+
const ID_TRACK_ENTRY = 0xae;
|
|
31
|
+
const ID_TRACK_NUMBER = 0xd7;
|
|
32
|
+
const ID_TRACK_TYPE = 0x83;
|
|
33
|
+
const ID_CUES = 0x1c53bb6b;
|
|
34
|
+
const ID_CUE_POINT = 0xbb;
|
|
35
|
+
const ID_CUE_TIME = 0xb3;
|
|
36
|
+
const ID_CUE_TRACK_POSITIONS = 0xb7;
|
|
37
|
+
const ID_CUE_TRACK = 0xf7;
|
|
38
|
+
const ID_CUE_CLUSTER_POSITION = 0xf1;
|
|
39
|
+
|
|
40
|
+
/** An element id, as the bytes the specification gives it. */
|
|
41
|
+
function idBytes(id) {
|
|
42
|
+
const bytes = [];
|
|
43
|
+
let rest = id;
|
|
44
|
+
while (rest > 0) {
|
|
45
|
+
bytes.unshift(rest & 0xff);
|
|
46
|
+
rest = Math.floor(rest / 256);
|
|
47
|
+
}
|
|
48
|
+
return Buffer.from(bytes);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A size, as a four-byte EBML variable-length integer. */
|
|
52
|
+
function sizeBytes(size) {
|
|
53
|
+
const buffer = Buffer.alloc(4);
|
|
54
|
+
buffer.writeUInt32BE(size, 0);
|
|
55
|
+
buffer[0] |= 0x10;
|
|
56
|
+
return buffer;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function element(id, payload) {
|
|
60
|
+
return Buffer.concat([idBytes(id), sizeBytes(payload.length), payload]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An unsigned value, in as few bytes as it needs. */
|
|
64
|
+
function uintElement(id, value) {
|
|
65
|
+
const bytes = [];
|
|
66
|
+
let rest = value;
|
|
67
|
+
do {
|
|
68
|
+
bytes.unshift(rest & 0xff);
|
|
69
|
+
rest = Math.floor(rest / 256);
|
|
70
|
+
} while (rest > 0);
|
|
71
|
+
return element(id, Buffer.from(bytes));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cuePoint(timeMs, tracks) {
|
|
75
|
+
return element(ID_CUE_POINT, Buffer.concat([
|
|
76
|
+
uintElement(ID_CUE_TIME, timeMs),
|
|
77
|
+
...tracks.map((track) => element(ID_CUE_TRACK_POSITIONS, Buffer.concat([
|
|
78
|
+
uintElement(ID_CUE_TRACK, track),
|
|
79
|
+
uintElement(ID_CUE_CLUSTER_POSITION, 4096)
|
|
80
|
+
])))
|
|
81
|
+
]));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A file with the shape the field files have: one picture, one set of subtitles,
|
|
86
|
+
* and a table that indexes both.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ withTracks?: boolean }} [options]
|
|
89
|
+
* @returns {Buffer}
|
|
90
|
+
*/
|
|
91
|
+
function buildFile({ withTracks = true, cueTrack = null } = {}) {
|
|
92
|
+
const info = element(ID_INFO, uintElement(ID_TIMESTAMP_SCALE, 1_000_000));
|
|
93
|
+
const tracks = element(ID_TRACKS, Buffer.concat([
|
|
94
|
+
element(ID_TRACK_ENTRY, Buffer.concat([
|
|
95
|
+
uintElement(ID_TRACK_NUMBER, 1),
|
|
96
|
+
uintElement(ID_TRACK_TYPE, 1) // video
|
|
97
|
+
])),
|
|
98
|
+
element(ID_TRACK_ENTRY, Buffer.concat([
|
|
99
|
+
uintElement(ID_TRACK_NUMBER, 2),
|
|
100
|
+
uintElement(ID_TRACK_TYPE, 17) // subtitles
|
|
101
|
+
]))
|
|
102
|
+
]));
|
|
103
|
+
const forPicture = cueTrack ?? 1;
|
|
104
|
+
const forSubtitles = cueTrack ?? 2;
|
|
105
|
+
const cues = element(ID_CUES, Buffer.concat([
|
|
106
|
+
cuePoint(0, [forPicture]),
|
|
107
|
+
cuePoint(1070, [forSubtitles]),
|
|
108
|
+
cuePoint(2002, [forPicture]),
|
|
109
|
+
cuePoint(3141, [forSubtitles]),
|
|
110
|
+
cuePoint(4004, [forPicture])
|
|
111
|
+
]));
|
|
112
|
+
|
|
113
|
+
const seekEntry = (targetId, position) => element(ID_SEEK, Buffer.concat([
|
|
114
|
+
element(ID_SEEK_ID, idBytes(targetId)),
|
|
115
|
+
element(ID_SEEK_POSITION, (() => {
|
|
116
|
+
const buffer = Buffer.alloc(4);
|
|
117
|
+
buffer.writeUInt32BE(position, 0);
|
|
118
|
+
return buffer;
|
|
119
|
+
})())
|
|
120
|
+
]));
|
|
121
|
+
// Positions are relative to the Segment's payload, so the SeekHead has to be
|
|
122
|
+
// measured before they can be stated. Its own length does not change when the
|
|
123
|
+
// placeholders become real values: every size and position here is written at
|
|
124
|
+
// a fixed width.
|
|
125
|
+
const draft = element(ID_SEEK_HEAD, Buffer.concat([
|
|
126
|
+
seekEntry(ID_INFO, 0),
|
|
127
|
+
...(withTracks ? [seekEntry(ID_TRACKS, 0)] : []),
|
|
128
|
+
seekEntry(ID_CUES, 0)
|
|
129
|
+
]));
|
|
130
|
+
const infoAt = draft.length;
|
|
131
|
+
const tracksAt = infoAt + info.length;
|
|
132
|
+
const cuesAt = withTracks ? tracksAt + tracks.length : infoAt + info.length;
|
|
133
|
+
const seekHead = element(ID_SEEK_HEAD, Buffer.concat([
|
|
134
|
+
seekEntry(ID_INFO, infoAt),
|
|
135
|
+
...(withTracks ? [seekEntry(ID_TRACKS, tracksAt)] : []),
|
|
136
|
+
seekEntry(ID_CUES, cuesAt)
|
|
137
|
+
]));
|
|
138
|
+
|
|
139
|
+
const segmentPayload = Buffer.concat(
|
|
140
|
+
withTracks ? [seekHead, info, tracks, cues] : [seekHead, info, cues]
|
|
141
|
+
);
|
|
142
|
+
return Buffer.concat([
|
|
143
|
+
element(ID_EBML, Buffer.from([0x42, 0x86, 0x81, 0x01])),
|
|
144
|
+
element(ID_SEGMENT, segmentPayload)
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readerOver(file) {
|
|
149
|
+
return async (start, end) => {
|
|
150
|
+
const last = Math.min(end, file.length - 1);
|
|
151
|
+
return start > last ? null : file.subarray(start, last + 1);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test("only the picture's entries become cut times", async () => {
|
|
156
|
+
const file = buildFile();
|
|
157
|
+
|
|
158
|
+
const times = await readMatroskaKeyframeTimes(readerOver(file), file.length);
|
|
159
|
+
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
times.map((time) => Number(time.toFixed(3))),
|
|
162
|
+
[0, 2.002, 4.004],
|
|
163
|
+
"the subtitle entries at 1.070 and 3.141 are not keyframes, and a cut asked for there lands late"
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a table whose entries name no known track is still used", async () => {
|
|
168
|
+
// The cue points reference track 9, which Tracks never declares. Filtering
|
|
169
|
+
// leaves nothing — and returning nothing would put an even grid on a copied
|
|
170
|
+
// picture, the failure this reader exists to prevent.
|
|
171
|
+
const file = buildFile({ cueTrack: 9 });
|
|
172
|
+
|
|
173
|
+
const times = await readMatroskaKeyframeTimes(readerOver(file), file.length);
|
|
174
|
+
|
|
175
|
+
assert.deepEqual(
|
|
176
|
+
times.map((time) => Number(time.toFixed(3))),
|
|
177
|
+
[0, 1.07, 2.002, 3.141, 4.004],
|
|
178
|
+
"an unrecognised table beats no table at all"
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("a file whose tracks cannot be read keeps every entry", async () => {
|
|
183
|
+
const file = buildFile({ withTracks: false });
|
|
184
|
+
|
|
185
|
+
const times = await readMatroskaKeyframeTimes(readerOver(file), file.length);
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(
|
|
188
|
+
times.map((time) => Number(time.toFixed(3))),
|
|
189
|
+
[0, 1.07, 2.002, 3.141, 4.004],
|
|
190
|
+
"with nothing to tell the tracks apart, the old behaviour is the only one available"
|
|
191
|
+
);
|
|
192
|
+
});
|