@torrent-tv/proxy 2.31.0 → 2.33.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 +10 -0
- package/assets/calibration/NOTICE.md +49 -16
- package/assets/calibration/cal-h264-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-h264-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-h264-480-hi.mp4 +0 -0
- package/assets/calibration/cal-h264-480-lo.mp4 +0 -0
- package/assets/calibration/cal-h264-720-hi.mp4 +0 -0
- package/assets/calibration/cal-h264-720-lo.mp4 +0 -0
- package/package.json +1 -1
- package/services/available-share.js +89 -0
- package/services/decode-cost-fit.js +331 -0
- package/services/hls-session-manager.js +72 -4
- package/services/hwaccel.js +35 -100
- package/test/available-share.test.js +76 -0
- package/test/decode-cost-fit.test.js +145 -0
- package/test/decode-cost.test.js +347 -347
- package/assets/calibration/cal-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-720.mp4 +0 -0
package/test/decode-cost.test.js
CHANGED
|
@@ -1,347 +1,347 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What a re-encode costs on this host, decoding included.
|
|
3
|
-
*
|
|
4
|
-
* The budget used to price the encoder alone. Measured on the addon host
|
|
5
|
-
* 2026-08-14, that made it offer a 240p rung it then ran at 0.388-0.947x while
|
|
6
|
-
* claiming the host cleared the bar 2.5 times over — the error on that rung was
|
|
7
|
-
* 209 %. The decode term brings a controlled measurement to within 5 %.
|
|
8
|
-
*
|
|
9
|
-
* The first test runs the real benchmark against the shipped clips with the
|
|
10
|
-
* real ffmpeg, because a fit that only ever runs against invented numbers can
|
|
11
|
-
* be wrong in every way that matters (2.9.124: a module tested only through its
|
|
12
|
-
* own exports missed the caller that never called it).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import test from "node:test";
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
import { createRequire } from "node:module";
|
|
18
|
-
import { spawn } from "node:child_process";
|
|
19
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
20
|
-
import os from "node:os";
|
|
21
|
-
import path from "node:path";
|
|
22
|
-
import {
|
|
23
|
-
benchmarkDecodeCost,
|
|
24
|
-
canSustainOutput,
|
|
25
|
-
decodeSpeedFor,
|
|
26
|
-
predictedRealtimeSpeed,
|
|
27
|
-
REALTIME_SPEED_MARGIN
|
|
28
|
-
} from "../services/hwaccel.js";
|
|
29
|
-
import { HlsSessionManager, sourceDecodeCharacteristics } from "../services/hls-session-manager.js";
|
|
30
|
-
import { parseFfmpegBitrateKbps, parseFfmpegVideoDimensions, parseFfmpegVideoFps } from "../services/ffmpeg-banner.js";
|
|
31
|
-
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
32
|
-
|
|
33
|
-
const require = createRequire(import.meta.url);
|
|
34
|
-
const ffmpegBin = require("ffmpeg-static");
|
|
35
|
-
const CALIBRATION_DIR = path.join(import.meta.dirname, "..", "assets", "calibration");
|
|
36
|
-
|
|
37
|
-
// The constants measured on the addon host (CM4) on 2026-08-14. Used here as
|
|
38
|
-
// a fixed host, so the arithmetic can be checked against figures that were
|
|
39
|
-
// measured rather than against figures this test invented.
|
|
40
|
-
const ADDON_HOST_MODEL = { pixelTerm: 0.005555, bitrateTerm: 0.00990, constantTerm: 0.0572 };
|
|
41
|
-
// The film measured that day: 1920x1080 at 24 fps, about 8 Mbit/s.
|
|
42
|
-
const MEASURED_FILM = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 8 };
|
|
43
|
-
|
|
44
|
-
test("the fit comes out of the real clips, and predicts one of them back", async () => {
|
|
45
|
-
const model = await benchmarkDecodeCost({ ffmpegBin });
|
|
46
|
-
|
|
47
|
-
assert.ok(model, "the clips ship with the package and this host has ffmpeg");
|
|
48
|
-
assert.ok(model.pixelTerm > 0, "more pixels cannot decode faster");
|
|
49
|
-
assert.ok(Number.isFinite(model.bitrateTerm) && Number.isFinite(model.constantTerm));
|
|
50
|
-
|
|
51
|
-
// What the model must get right is how cost SCALES from one source to
|
|
52
|
-
// another — that is the whole of its job, since it is asked about rungs
|
|
53
|
-
// nobody has decoded. So: a bigger, richer source is never cheaper.
|
|
54
|
-
//
|
|
55
|
-
// Deliberately not a numeric bound. This suite runs its files in parallel and
|
|
56
|
-
// the benchmark is a live measurement, so the fit it produces depends on what
|
|
57
|
-
// else the machine was doing: on this desktop the same clips have solved to
|
|
58
|
-
// pixels+bitrate+constant, to pixels alone, and — under load — to a
|
|
59
|
-
// constant-dominated shape whose 720p/1080p ratio was 1.32 rather than the
|
|
60
|
-
// ~2.4 of a quiet run. That instability is real and is recorded against
|
|
61
|
-
// roadmap item 1; pinning a number here would only pin how busy the machine
|
|
62
|
-
// happened to be. What the FIGURES are worth is checked where it is quiet:
|
|
63
|
-
// against the addon host's recorded constants below, and against the real
|
|
64
|
-
// film in the field.
|
|
65
|
-
const clip720 = { megapixelsPerSecond: (1280 * 720 * 24) / 1e6, megabitsPerSecond: 2.248 };
|
|
66
|
-
const clip1080 = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 11.375 };
|
|
67
|
-
const ratio = decodeSpeedFor(model, clip720) / decodeSpeedFor(model, clip1080);
|
|
68
|
-
|
|
69
|
-
assert.ok(
|
|
70
|
-
ratio >= 1,
|
|
71
|
-
`720p at a fifth of the bitrate cannot decode slower than 1080p; the fit says ${ratio.toFixed(2)}x`
|
|
72
|
-
);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("decode cost prices the film it was checked against", () => {
|
|
76
|
-
const speed = decodeSpeedFor(ADDON_HOST_MODEL, MEASURED_FILM);
|
|
77
|
-
|
|
78
|
-
// 0.005555 x 49.77 + 0.00990 x 8 + 0.0572 = 0.4129 s per second of video.
|
|
79
|
-
assert.ok(Math.abs(1 / speed - 0.4129) < 0.001, `cost was ${(1 / speed).toFixed(4)} s/s`);
|
|
80
|
-
// Measured that day: 0.434 s/s. The claim is 5 %, not exactness.
|
|
81
|
-
assert.ok(Math.abs(1 / speed - 0.434) / 0.434 < 0.05);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("decoding and encoding share the machine, so their speeds combine", () => {
|
|
85
|
-
// The rung that broke playback: 240p at 24 fps, encoded at 5.99x by the
|
|
86
|
-
// measurement, decoded at 2.31x. Measured combined speed was 1.48x.
|
|
87
|
-
const outputPixelsPerSec = 426 * 240 * 24;
|
|
88
|
-
const speed = predictedRealtimeSpeed({
|
|
89
|
-
decodeModel: { pixelTerm: 0, bitrateTerm: 0, constantTerm: 1 / 2.31 },
|
|
90
|
-
encodePixelsPerSec: outputPixelsPerSec * 5.99,
|
|
91
|
-
outputPixelsPerSec,
|
|
92
|
-
source: MEASURED_FILM
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
assert.ok(Math.abs(speed - 1.67) < 0.01, `predicted ${speed.toFixed(2)}x`);
|
|
96
|
-
// 1.67 against the 1.48x that rung measured under a controlled encode: 12.8 %
|
|
97
|
-
// out. The figure is pinned so a change to the combination has to state what
|
|
98
|
-
// it does to it.
|
|
99
|
-
assert.ok(Math.abs(speed - 1.48) / 1.48 < 0.13);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("with no decode fit the prediction is the encoder alone — what it was before", () => {
|
|
103
|
-
const outputPixelsPerSec = 426 * 240 * 24;
|
|
104
|
-
const speed = predictedRealtimeSpeed({
|
|
105
|
-
decodeModel: null,
|
|
106
|
-
encodePixelsPerSec: outputPixelsPerSec * 5.99,
|
|
107
|
-
outputPixelsPerSec,
|
|
108
|
-
source: MEASURED_FILM
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
assert.equal(speed, 5.99, "the old figure, four times the truth on that rung");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("a rung is refused when the combined speed is under the margin", () => {
|
|
115
|
-
// The addon host's fastest preset, read from its own log: 11.2 Mpx/s.
|
|
116
|
-
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
117
|
-
const source = MEASURED_FILM;
|
|
118
|
-
|
|
119
|
-
const heavy = canSustainOutput({
|
|
120
|
-
benchmark,
|
|
121
|
-
decodeModel: ADDON_HOST_MODEL,
|
|
122
|
-
source,
|
|
123
|
-
outputPixelsPerSec: 1280 * 720 * 24
|
|
124
|
-
});
|
|
125
|
-
assert.equal(heavy.sustainable, false, "720p needs 22 Mpx/s from an 11.2 Mpx/s host");
|
|
126
|
-
assert.ok(heavy.speed < REALTIME_SPEED_MARGIN);
|
|
127
|
-
|
|
128
|
-
// And this is the gap the model does NOT close: the 240p rung predicts 1.58x
|
|
129
|
-
// and clears a margin of 1.5, while the field measured that same rung at
|
|
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.
|
|
134
|
-
const light = canSustainOutput({
|
|
135
|
-
benchmark,
|
|
136
|
-
decodeModel: ADDON_HOST_MODEL,
|
|
137
|
-
source,
|
|
138
|
-
outputPixelsPerSec: 426 * 240 * 24
|
|
139
|
-
});
|
|
140
|
-
assert.ok(Math.abs(light.speed - 1.58) < 0.01, `predicted ${light.speed.toFixed(2)}x`);
|
|
141
|
-
assert.equal(light.sustainable, true);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("a reading from the running encoder outranks the model of the clips", () => {
|
|
145
|
-
// The field case, 2026-08-14: the 240p rung of that film ran at 0.95x at its
|
|
146
|
-
// best on a host whose fastest preset benchmarked at 11.2 Mpx/s. Subtracting
|
|
147
|
-
// the encode half of that reading leaves what the SOURCE costs to decode
|
|
148
|
-
// there — 0.83 s per second of video, i.e. 1.20x — which is four times what
|
|
149
|
-
// the H.264 clips predicted for it, and is the truth about this file.
|
|
150
|
-
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
151
|
-
const observedDecodeCostSec = 1 / 0.95 - (426 * 240 * 24) / 11.2e6;
|
|
152
|
-
assert.ok(Math.abs(observedDecodeCostSec - 0.8335) < 0.001);
|
|
153
|
-
|
|
154
|
-
const withObservation = canSustainOutput({
|
|
155
|
-
benchmark,
|
|
156
|
-
decodeModel: ADDON_HOST_MODEL,
|
|
157
|
-
source: MEASURED_FILM,
|
|
158
|
-
outputPixelsPerSec: 426 * 240 * 24,
|
|
159
|
-
observedDecodeCostSec
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
// The same rung the clip model called 1.58x and admitted is now priced at
|
|
163
|
-
// 0.95x — the speed it was actually seen to run at — and refused.
|
|
164
|
-
assert.ok(Math.abs(withObservation.speed - 0.95) < 0.01, `priced ${withObservation.speed.toFixed(2)}x`);
|
|
165
|
-
assert.equal(withObservation.sustainable, false);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test("an observation prices a host whose clips were never fitted", () => {
|
|
169
|
-
// No model at all — clips missing, or a fit rejected. Before an observation
|
|
170
|
-
// nothing can be refused; after one, the same rung is priced and refused.
|
|
171
|
-
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
172
|
-
const outputPixelsPerSec = 426 * 240 * 24;
|
|
173
|
-
|
|
174
|
-
const unpriced = canSustainOutput({
|
|
175
|
-
benchmark,
|
|
176
|
-
decodeModel: null,
|
|
177
|
-
source: MEASURED_FILM,
|
|
178
|
-
outputPixelsPerSec
|
|
179
|
-
});
|
|
180
|
-
assert.deepEqual(unpriced, { speed: null, sustainable: true });
|
|
181
|
-
|
|
182
|
-
const priced = canSustainOutput({
|
|
183
|
-
benchmark,
|
|
184
|
-
decodeModel: null,
|
|
185
|
-
source: MEASURED_FILM,
|
|
186
|
-
outputPixelsPerSec,
|
|
187
|
-
observedDecodeCostSec: 0.8335
|
|
188
|
-
});
|
|
189
|
-
assert.equal(priced.sustainable, false);
|
|
190
|
-
assert.ok(priced.speed < 1, `priced ${priced.speed?.toFixed(2)}x`);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("nothing measured means nothing refused", () => {
|
|
194
|
-
const verdict = canSustainOutput({
|
|
195
|
-
benchmark: [],
|
|
196
|
-
decodeModel: null,
|
|
197
|
-
source: null,
|
|
198
|
-
outputPixelsPerSec: 1920 * 1080 * 24
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
assert.deepEqual(verdict, { speed: null, sustainable: true });
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("a real ffmpeg banner reads into the figures the budget prices", async () => {
|
|
205
|
-
// The chain a session actually runs: ffmpeg prints its banner, the four
|
|
206
|
-
// readers take the four facts out of it, and `sourceDecodeCharacteristics`
|
|
207
|
-
// turns them into the two the fit uses. Run against a real file, because the
|
|
208
|
-
// bitrate reader is new and a banner is the one input nobody can invent
|
|
209
|
-
// faithfully.
|
|
210
|
-
const stderr = await new Promise((resolve) => {
|
|
211
|
-
let text = "";
|
|
212
|
-
const child = spawn(
|
|
213
|
-
ffmpegBin,
|
|
214
|
-
["-hide_banner", "-loglevel", "info", "-i", path.join(CALIBRATION_DIR, "cal-720.mp4"), "-t", "0.1", "-f", "null", "-"],
|
|
215
|
-
{ stdio: ["ignore", "ignore", "pipe"], windowsHide: true }
|
|
216
|
-
);
|
|
217
|
-
child.stderr.on("data", (chunk) => {
|
|
218
|
-
text += String(chunk);
|
|
219
|
-
});
|
|
220
|
-
child.on("close", () => resolve(text));
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// The VIDEO stream's rate (
|
|
224
|
-
// carries nothing else the two differ only by container overhead; on a film
|
|
225
|
-
// with two AC-3 tracks they differ by the whole of the audio, and the fit
|
|
226
|
-
// these figures feed was made from clips decoded with `-an`.
|
|
227
|
-
assert.equal(parseFfmpegBitrateKbps(stderr),
|
|
228
|
-
const dimensions = parseFfmpegVideoDimensions(stderr);
|
|
229
|
-
const figures = sourceDecodeCharacteristics({
|
|
230
|
-
width: dimensions.width,
|
|
231
|
-
height: dimensions.height,
|
|
232
|
-
fps: parseFfmpegVideoFps(stderr),
|
|
233
|
-
bitrateKbps: parseFfmpegBitrateKbps(stderr)
|
|
234
|
-
});
|
|
235
|
-
assert.ok(Math.abs(figures.megapixelsPerSecond - (1280 * 720 * 24) / 1e6) < 0.01);
|
|
236
|
-
assert.ok(Math.abs(figures.megabitsPerSecond -
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test("the video stream's bitrate is preferred, and the output's is never read", () => {
|
|
240
|
-
// A real banner states three rates: the container's, the input video
|
|
241
|
-
// stream's, and — below "Stream mapping:" — the one ffmpeg is about to
|
|
242
|
-
// produce. Only the middle one describes the source.
|
|
243
|
-
const banner = [
|
|
244
|
-
" Duration: 01:31:19.00, start: 0.000000, bitrate: 9500 kb/s",
|
|
245
|
-
" Stream #0:0: Video: h264 (High), yuv420p, 1920x1080, 7800 kb/s, 23.98 fps",
|
|
246
|
-
" Stream #0:1: Audio: ac3, 48000 Hz, 5.1, fltp, 640 kb/s",
|
|
247
|
-
"Stream mapping:",
|
|
248
|
-
" Stream #0:0(und): Video: wrapped_avframe, yuv420p, 1920x1080, q=2-31, 200 kb/s, 24 fps"
|
|
249
|
-
].join("\n");
|
|
250
|
-
assert.equal(parseFfmpegBitrateKbps(banner), 7800);
|
|
251
|
-
|
|
252
|
-
// No per-stream rate: the container's stands in, as it always did.
|
|
253
|
-
const noStreamRate = [
|
|
254
|
-
" Duration: 00:10:00.00, bitrate: 4200 kb/s",
|
|
255
|
-
" Stream #0:0: Video: hevc (Main 10), yuv420p10le, 1920x1080, 23.98 fps"
|
|
256
|
-
].join("\n");
|
|
257
|
-
assert.equal(parseFfmpegBitrateKbps(noStreamRate), 4200);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test("a banner with no bitrate reads as no bitrate, not as zero", () => {
|
|
261
|
-
assert.equal(parseFfmpegBitrateKbps("Duration: 00:01:00.00, start: 0.000000\n"), null);
|
|
262
|
-
assert.equal(parseFfmpegBitrateKbps(""), null);
|
|
263
|
-
assert.equal(parseFfmpegBitrateKbps("Duration: 00:01:00.00, start: 0.000, bitrate: 8000 kb/s"), 8000);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
test("the source's decode figures come off the probe, or not at all", () => {
|
|
267
|
-
assert.deepEqual(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 }), {
|
|
268
|
-
megapixelsPerSecond: (1920 * 1080 * 24) / 1e6,
|
|
269
|
-
megabitsPerSecond: 8
|
|
270
|
-
});
|
|
271
|
-
assert.equal(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: null }), null);
|
|
272
|
-
assert.equal(sourceDecodeCharacteristics(null), null);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
test("the master playlist drops the rungs the host cannot hold", async (t) => {
|
|
276
|
-
const dirPath = await mkdtemp(path.join(os.tmpdir(), "decode-cost-"));
|
|
277
|
-
const manager = new HlsSessionManager({
|
|
278
|
-
enabled: true,
|
|
279
|
-
ffmpegBin: "ffmpeg",
|
|
280
|
-
localBindHost: "127.0.0.1",
|
|
281
|
-
localPort: 9090,
|
|
282
|
-
// A host that can encode a little: 3 Mpx/s, so only the smallest rungs of a
|
|
283
|
-
// 1080p source can be produced faster than they are watched.
|
|
284
|
-
softwarePresetBenchmark: [{ preset: "ultrafast", pixelsPerSec: 3e6 }],
|
|
285
|
-
decodeCostModel: ADDON_HOST_MODEL
|
|
286
|
-
});
|
|
287
|
-
t.after(async () => {
|
|
288
|
-
await manager.disposeAll();
|
|
289
|
-
await rm(dirPath, { recursive: true, force: true });
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
const session = {
|
|
293
|
-
id: "cccccccc-dddd-eeee-ffff-000000000000",
|
|
294
|
-
dirPath,
|
|
295
|
-
state: "ready",
|
|
296
|
-
fileName: "video.mkv",
|
|
297
|
-
startedAt: Date.now(),
|
|
298
|
-
lastAccessedAt: Date.now(),
|
|
299
|
-
ffmpeg: null,
|
|
300
|
-
lastError: "",
|
|
301
|
-
consumers: new Set(),
|
|
302
|
-
segmentFormat: fmp4Format,
|
|
303
|
-
transcodeVideo: false,
|
|
304
|
-
transcodeAudio: true,
|
|
305
|
-
audioTrackIndex: 0,
|
|
306
|
-
sourceKey: "source-1",
|
|
307
|
-
fileIndex: 0,
|
|
308
|
-
sourceWidth: 1920,
|
|
309
|
-
sourceHeight: 1080,
|
|
310
|
-
sourceDecode: MEASURED_FILM,
|
|
311
|
-
outputFps: 24,
|
|
312
|
-
// A copy can only be cut where the source already has a keyframe, and a
|
|
313
|
-
// master is offered only when that grid is real.
|
|
314
|
-
cutGrid: "keyframe",
|
|
315
|
-
encodeWidth: 0,
|
|
316
|
-
encodeHeight: 0,
|
|
317
|
-
usesExplicitCuts: true,
|
|
318
|
-
useSyntheticPlaylist: true,
|
|
319
|
-
playlistText: "#EXTM3U\n",
|
|
320
|
-
segmentBoundaries: Array.from({ length: 101 }, (_, index) => index * 4),
|
|
321
|
-
segmentCount: 100,
|
|
322
|
-
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
|
|
323
|
-
};
|
|
324
|
-
manager.sessionsById.set(session.id, session);
|
|
325
|
-
|
|
326
|
-
assert.equal(
|
|
327
|
-
manager.buildMasterPlaylist(session.id),
|
|
328
|
-
null,
|
|
329
|
-
"every rung under the copy runs below realtime here, so there is nothing to switch to"
|
|
330
|
-
);
|
|
331
|
-
assert.deepEqual(
|
|
332
|
-
manager.offeredHeights(session),
|
|
333
|
-
[1080],
|
|
334
|
-
"and the list the browser is given says the same, since both come from one answer"
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
// A host with a little more encoder keeps the rungs it can actually hold. A
|
|
338
|
-
// second session, because the answer is settled once per session.
|
|
339
|
-
manager.softwarePresetBenchmark = [{ preset: "ultrafast", pixelsPerSec: 12e6 }];
|
|
340
|
-
const stronger = { ...session, id: "dddddddd-eeee-ffff-0000-111111111111", offeredHeightsCache: undefined };
|
|
341
|
-
manager.sessionsById.set(stronger.id, stronger);
|
|
342
|
-
const master = manager.buildMasterPlaylist(stronger.id);
|
|
343
|
-
assert.ok(master, "1080p copied plus the one rung this host can produce");
|
|
344
|
-
const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
|
|
345
|
-
assert.deepEqual(heights, [1080, 240]);
|
|
346
|
-
assert.deepEqual(manager.offeredHeights(stronger), heights);
|
|
347
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file What a re-encode costs on this host, decoding included.
|
|
3
|
+
*
|
|
4
|
+
* The budget used to price the encoder alone. Measured on the addon host
|
|
5
|
+
* 2026-08-14, that made it offer a 240p rung it then ran at 0.388-0.947x while
|
|
6
|
+
* claiming the host cleared the bar 2.5 times over — the error on that rung was
|
|
7
|
+
* 209 %. The decode term brings a controlled measurement to within 5 %.
|
|
8
|
+
*
|
|
9
|
+
* The first test runs the real benchmark against the shipped clips with the
|
|
10
|
+
* real ffmpeg, because a fit that only ever runs against invented numbers can
|
|
11
|
+
* be wrong in every way that matters (2.9.124: a module tested only through its
|
|
12
|
+
* own exports missed the caller that never called it).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import {
|
|
23
|
+
benchmarkDecodeCost,
|
|
24
|
+
canSustainOutput,
|
|
25
|
+
decodeSpeedFor,
|
|
26
|
+
predictedRealtimeSpeed,
|
|
27
|
+
REALTIME_SPEED_MARGIN
|
|
28
|
+
} from "../services/hwaccel.js";
|
|
29
|
+
import { HlsSessionManager, sourceDecodeCharacteristics } from "../services/hls-session-manager.js";
|
|
30
|
+
import { parseFfmpegBitrateKbps, parseFfmpegVideoDimensions, parseFfmpegVideoFps } from "../services/ffmpeg-banner.js";
|
|
31
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const ffmpegBin = require("ffmpeg-static");
|
|
35
|
+
const CALIBRATION_DIR = path.join(import.meta.dirname, "..", "assets", "calibration");
|
|
36
|
+
|
|
37
|
+
// The constants measured on the addon host (CM4) on 2026-08-14. Used here as
|
|
38
|
+
// a fixed host, so the arithmetic can be checked against figures that were
|
|
39
|
+
// measured rather than against figures this test invented.
|
|
40
|
+
const ADDON_HOST_MODEL = { pixelTerm: 0.005555, bitrateTerm: 0.00990, constantTerm: 0.0572 };
|
|
41
|
+
// The film measured that day: 1920x1080 at 24 fps, about 8 Mbit/s.
|
|
42
|
+
const MEASURED_FILM = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 8 };
|
|
43
|
+
|
|
44
|
+
test("the fit comes out of the real clips, and predicts one of them back", async () => {
|
|
45
|
+
const model = await benchmarkDecodeCost({ ffmpegBin });
|
|
46
|
+
|
|
47
|
+
assert.ok(model, "the clips ship with the package and this host has ffmpeg");
|
|
48
|
+
assert.ok(model.pixelTerm > 0, "more pixels cannot decode faster");
|
|
49
|
+
assert.ok(Number.isFinite(model.bitrateTerm) && Number.isFinite(model.constantTerm));
|
|
50
|
+
|
|
51
|
+
// What the model must get right is how cost SCALES from one source to
|
|
52
|
+
// another — that is the whole of its job, since it is asked about rungs
|
|
53
|
+
// nobody has decoded. So: a bigger, richer source is never cheaper.
|
|
54
|
+
//
|
|
55
|
+
// Deliberately not a numeric bound. This suite runs its files in parallel and
|
|
56
|
+
// the benchmark is a live measurement, so the fit it produces depends on what
|
|
57
|
+
// else the machine was doing: on this desktop the same clips have solved to
|
|
58
|
+
// pixels+bitrate+constant, to pixels alone, and — under load — to a
|
|
59
|
+
// constant-dominated shape whose 720p/1080p ratio was 1.32 rather than the
|
|
60
|
+
// ~2.4 of a quiet run. That instability is real and is recorded against
|
|
61
|
+
// roadmap item 1; pinning a number here would only pin how busy the machine
|
|
62
|
+
// happened to be. What the FIGURES are worth is checked where it is quiet:
|
|
63
|
+
// against the addon host's recorded constants below, and against the real
|
|
64
|
+
// film in the field.
|
|
65
|
+
const clip720 = { megapixelsPerSecond: (1280 * 720 * 24) / 1e6, megabitsPerSecond: 2.248 };
|
|
66
|
+
const clip1080 = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 11.375 };
|
|
67
|
+
const ratio = decodeSpeedFor(model, clip720) / decodeSpeedFor(model, clip1080);
|
|
68
|
+
|
|
69
|
+
assert.ok(
|
|
70
|
+
ratio >= 1,
|
|
71
|
+
`720p at a fifth of the bitrate cannot decode slower than 1080p; the fit says ${ratio.toFixed(2)}x`
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("decode cost prices the film it was checked against", () => {
|
|
76
|
+
const speed = decodeSpeedFor(ADDON_HOST_MODEL, MEASURED_FILM);
|
|
77
|
+
|
|
78
|
+
// 0.005555 x 49.77 + 0.00990 x 8 + 0.0572 = 0.4129 s per second of video.
|
|
79
|
+
assert.ok(Math.abs(1 / speed - 0.4129) < 0.001, `cost was ${(1 / speed).toFixed(4)} s/s`);
|
|
80
|
+
// Measured that day: 0.434 s/s. The claim is 5 %, not exactness.
|
|
81
|
+
assert.ok(Math.abs(1 / speed - 0.434) / 0.434 < 0.05);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("decoding and encoding share the machine, so their speeds combine", () => {
|
|
85
|
+
// The rung that broke playback: 240p at 24 fps, encoded at 5.99x by the
|
|
86
|
+
// measurement, decoded at 2.31x. Measured combined speed was 1.48x.
|
|
87
|
+
const outputPixelsPerSec = 426 * 240 * 24;
|
|
88
|
+
const speed = predictedRealtimeSpeed({
|
|
89
|
+
decodeModel: { pixelTerm: 0, bitrateTerm: 0, constantTerm: 1 / 2.31 },
|
|
90
|
+
encodePixelsPerSec: outputPixelsPerSec * 5.99,
|
|
91
|
+
outputPixelsPerSec,
|
|
92
|
+
source: MEASURED_FILM
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
assert.ok(Math.abs(speed - 1.67) < 0.01, `predicted ${speed.toFixed(2)}x`);
|
|
96
|
+
// 1.67 against the 1.48x that rung measured under a controlled encode: 12.8 %
|
|
97
|
+
// out. The figure is pinned so a change to the combination has to state what
|
|
98
|
+
// it does to it.
|
|
99
|
+
assert.ok(Math.abs(speed - 1.48) / 1.48 < 0.13);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("with no decode fit the prediction is the encoder alone — what it was before", () => {
|
|
103
|
+
const outputPixelsPerSec = 426 * 240 * 24;
|
|
104
|
+
const speed = predictedRealtimeSpeed({
|
|
105
|
+
decodeModel: null,
|
|
106
|
+
encodePixelsPerSec: outputPixelsPerSec * 5.99,
|
|
107
|
+
outputPixelsPerSec,
|
|
108
|
+
source: MEASURED_FILM
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.equal(speed, 5.99, "the old figure, four times the truth on that rung");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a rung is refused when the combined speed is under the margin", () => {
|
|
115
|
+
// The addon host's fastest preset, read from its own log: 11.2 Mpx/s.
|
|
116
|
+
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
117
|
+
const source = MEASURED_FILM;
|
|
118
|
+
|
|
119
|
+
const heavy = canSustainOutput({
|
|
120
|
+
benchmark,
|
|
121
|
+
decodeModel: ADDON_HOST_MODEL,
|
|
122
|
+
source,
|
|
123
|
+
outputPixelsPerSec: 1280 * 720 * 24
|
|
124
|
+
});
|
|
125
|
+
assert.equal(heavy.sustainable, false, "720p needs 22 Mpx/s from an 11.2 Mpx/s host");
|
|
126
|
+
assert.ok(heavy.speed < REALTIME_SPEED_MARGIN);
|
|
127
|
+
|
|
128
|
+
// And this is the gap the model does NOT close: the 240p rung predicts 1.58x
|
|
129
|
+
// and clears a margin of 1.5, while the field measured that same rung at
|
|
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.
|
|
134
|
+
const light = canSustainOutput({
|
|
135
|
+
benchmark,
|
|
136
|
+
decodeModel: ADDON_HOST_MODEL,
|
|
137
|
+
source,
|
|
138
|
+
outputPixelsPerSec: 426 * 240 * 24
|
|
139
|
+
});
|
|
140
|
+
assert.ok(Math.abs(light.speed - 1.58) < 0.01, `predicted ${light.speed.toFixed(2)}x`);
|
|
141
|
+
assert.equal(light.sustainable, true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a reading from the running encoder outranks the model of the clips", () => {
|
|
145
|
+
// The field case, 2026-08-14: the 240p rung of that film ran at 0.95x at its
|
|
146
|
+
// best on a host whose fastest preset benchmarked at 11.2 Mpx/s. Subtracting
|
|
147
|
+
// the encode half of that reading leaves what the SOURCE costs to decode
|
|
148
|
+
// there — 0.83 s per second of video, i.e. 1.20x — which is four times what
|
|
149
|
+
// the H.264 clips predicted for it, and is the truth about this file.
|
|
150
|
+
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
151
|
+
const observedDecodeCostSec = 1 / 0.95 - (426 * 240 * 24) / 11.2e6;
|
|
152
|
+
assert.ok(Math.abs(observedDecodeCostSec - 0.8335) < 0.001);
|
|
153
|
+
|
|
154
|
+
const withObservation = canSustainOutput({
|
|
155
|
+
benchmark,
|
|
156
|
+
decodeModel: ADDON_HOST_MODEL,
|
|
157
|
+
source: MEASURED_FILM,
|
|
158
|
+
outputPixelsPerSec: 426 * 240 * 24,
|
|
159
|
+
observedDecodeCostSec
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// The same rung the clip model called 1.58x and admitted is now priced at
|
|
163
|
+
// 0.95x — the speed it was actually seen to run at — and refused.
|
|
164
|
+
assert.ok(Math.abs(withObservation.speed - 0.95) < 0.01, `priced ${withObservation.speed.toFixed(2)}x`);
|
|
165
|
+
assert.equal(withObservation.sustainable, false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("an observation prices a host whose clips were never fitted", () => {
|
|
169
|
+
// No model at all — clips missing, or a fit rejected. Before an observation
|
|
170
|
+
// nothing can be refused; after one, the same rung is priced and refused.
|
|
171
|
+
const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
|
|
172
|
+
const outputPixelsPerSec = 426 * 240 * 24;
|
|
173
|
+
|
|
174
|
+
const unpriced = canSustainOutput({
|
|
175
|
+
benchmark,
|
|
176
|
+
decodeModel: null,
|
|
177
|
+
source: MEASURED_FILM,
|
|
178
|
+
outputPixelsPerSec
|
|
179
|
+
});
|
|
180
|
+
assert.deepEqual(unpriced, { speed: null, sustainable: true });
|
|
181
|
+
|
|
182
|
+
const priced = canSustainOutput({
|
|
183
|
+
benchmark,
|
|
184
|
+
decodeModel: null,
|
|
185
|
+
source: MEASURED_FILM,
|
|
186
|
+
outputPixelsPerSec,
|
|
187
|
+
observedDecodeCostSec: 0.8335
|
|
188
|
+
});
|
|
189
|
+
assert.equal(priced.sustainable, false);
|
|
190
|
+
assert.ok(priced.speed < 1, `priced ${priced.speed?.toFixed(2)}x`);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("nothing measured means nothing refused", () => {
|
|
194
|
+
const verdict = canSustainOutput({
|
|
195
|
+
benchmark: [],
|
|
196
|
+
decodeModel: null,
|
|
197
|
+
source: null,
|
|
198
|
+
outputPixelsPerSec: 1920 * 1080 * 24
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
assert.deepEqual(verdict, { speed: null, sustainable: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("a real ffmpeg banner reads into the figures the budget prices", async () => {
|
|
205
|
+
// The chain a session actually runs: ffmpeg prints its banner, the four
|
|
206
|
+
// readers take the four facts out of it, and `sourceDecodeCharacteristics`
|
|
207
|
+
// turns them into the two the fit uses. Run against a real file, because the
|
|
208
|
+
// bitrate reader is new and a banner is the one input nobody can invent
|
|
209
|
+
// faithfully.
|
|
210
|
+
const stderr = await new Promise((resolve) => {
|
|
211
|
+
let text = "";
|
|
212
|
+
const child = spawn(
|
|
213
|
+
ffmpegBin,
|
|
214
|
+
["-hide_banner", "-loglevel", "info", "-i", path.join(CALIBRATION_DIR, "cal-h264-720-hi.mp4"), "-t", "0.1", "-f", "null", "-"],
|
|
215
|
+
{ stdio: ["ignore", "ignore", "pipe"], windowsHide: true }
|
|
216
|
+
);
|
|
217
|
+
child.stderr.on("data", (chunk) => {
|
|
218
|
+
text += String(chunk);
|
|
219
|
+
});
|
|
220
|
+
child.on("close", () => resolve(text));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// The VIDEO stream's rate (9940), not the container's (9946). On a clip that
|
|
224
|
+
// carries nothing else the two differ only by container overhead; on a film
|
|
225
|
+
// with two AC-3 tracks they differ by the whole of the audio, and the fit
|
|
226
|
+
// these figures feed was made from clips decoded with `-an`.
|
|
227
|
+
assert.equal(parseFfmpegBitrateKbps(stderr), 9940, "the video stream's own bitrate, in kb/s");
|
|
228
|
+
const dimensions = parseFfmpegVideoDimensions(stderr);
|
|
229
|
+
const figures = sourceDecodeCharacteristics({
|
|
230
|
+
width: dimensions.width,
|
|
231
|
+
height: dimensions.height,
|
|
232
|
+
fps: parseFfmpegVideoFps(stderr),
|
|
233
|
+
bitrateKbps: parseFfmpegBitrateKbps(stderr)
|
|
234
|
+
});
|
|
235
|
+
assert.ok(Math.abs(figures.megapixelsPerSecond - (1280 * 720 * 24) / 1e6) < 0.01);
|
|
236
|
+
assert.ok(Math.abs(figures.megabitsPerSecond - 9.94) < 0.001);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the video stream's bitrate is preferred, and the output's is never read", () => {
|
|
240
|
+
// A real banner states three rates: the container's, the input video
|
|
241
|
+
// stream's, and — below "Stream mapping:" — the one ffmpeg is about to
|
|
242
|
+
// produce. Only the middle one describes the source.
|
|
243
|
+
const banner = [
|
|
244
|
+
" Duration: 01:31:19.00, start: 0.000000, bitrate: 9500 kb/s",
|
|
245
|
+
" Stream #0:0: Video: h264 (High), yuv420p, 1920x1080, 7800 kb/s, 23.98 fps",
|
|
246
|
+
" Stream #0:1: Audio: ac3, 48000 Hz, 5.1, fltp, 640 kb/s",
|
|
247
|
+
"Stream mapping:",
|
|
248
|
+
" Stream #0:0(und): Video: wrapped_avframe, yuv420p, 1920x1080, q=2-31, 200 kb/s, 24 fps"
|
|
249
|
+
].join("\n");
|
|
250
|
+
assert.equal(parseFfmpegBitrateKbps(banner), 7800);
|
|
251
|
+
|
|
252
|
+
// No per-stream rate: the container's stands in, as it always did.
|
|
253
|
+
const noStreamRate = [
|
|
254
|
+
" Duration: 00:10:00.00, bitrate: 4200 kb/s",
|
|
255
|
+
" Stream #0:0: Video: hevc (Main 10), yuv420p10le, 1920x1080, 23.98 fps"
|
|
256
|
+
].join("\n");
|
|
257
|
+
assert.equal(parseFfmpegBitrateKbps(noStreamRate), 4200);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a banner with no bitrate reads as no bitrate, not as zero", () => {
|
|
261
|
+
assert.equal(parseFfmpegBitrateKbps("Duration: 00:01:00.00, start: 0.000000\n"), null);
|
|
262
|
+
assert.equal(parseFfmpegBitrateKbps(""), null);
|
|
263
|
+
assert.equal(parseFfmpegBitrateKbps("Duration: 00:01:00.00, start: 0.000, bitrate: 8000 kb/s"), 8000);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("the source's decode figures come off the probe, or not at all", () => {
|
|
267
|
+
assert.deepEqual(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 }), {
|
|
268
|
+
megapixelsPerSecond: (1920 * 1080 * 24) / 1e6,
|
|
269
|
+
megabitsPerSecond: 8
|
|
270
|
+
});
|
|
271
|
+
assert.equal(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: null }), null);
|
|
272
|
+
assert.equal(sourceDecodeCharacteristics(null), null);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("the master playlist drops the rungs the host cannot hold", async (t) => {
|
|
276
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "decode-cost-"));
|
|
277
|
+
const manager = new HlsSessionManager({
|
|
278
|
+
enabled: true,
|
|
279
|
+
ffmpegBin: "ffmpeg",
|
|
280
|
+
localBindHost: "127.0.0.1",
|
|
281
|
+
localPort: 9090,
|
|
282
|
+
// A host that can encode a little: 3 Mpx/s, so only the smallest rungs of a
|
|
283
|
+
// 1080p source can be produced faster than they are watched.
|
|
284
|
+
softwarePresetBenchmark: [{ preset: "ultrafast", pixelsPerSec: 3e6 }],
|
|
285
|
+
decodeCostModel: ADDON_HOST_MODEL
|
|
286
|
+
});
|
|
287
|
+
t.after(async () => {
|
|
288
|
+
await manager.disposeAll();
|
|
289
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const session = {
|
|
293
|
+
id: "cccccccc-dddd-eeee-ffff-000000000000",
|
|
294
|
+
dirPath,
|
|
295
|
+
state: "ready",
|
|
296
|
+
fileName: "video.mkv",
|
|
297
|
+
startedAt: Date.now(),
|
|
298
|
+
lastAccessedAt: Date.now(),
|
|
299
|
+
ffmpeg: null,
|
|
300
|
+
lastError: "",
|
|
301
|
+
consumers: new Set(),
|
|
302
|
+
segmentFormat: fmp4Format,
|
|
303
|
+
transcodeVideo: false,
|
|
304
|
+
transcodeAudio: true,
|
|
305
|
+
audioTrackIndex: 0,
|
|
306
|
+
sourceKey: "source-1",
|
|
307
|
+
fileIndex: 0,
|
|
308
|
+
sourceWidth: 1920,
|
|
309
|
+
sourceHeight: 1080,
|
|
310
|
+
sourceDecode: MEASURED_FILM,
|
|
311
|
+
outputFps: 24,
|
|
312
|
+
// A copy can only be cut where the source already has a keyframe, and a
|
|
313
|
+
// master is offered only when that grid is real.
|
|
314
|
+
cutGrid: "keyframe",
|
|
315
|
+
encodeWidth: 0,
|
|
316
|
+
encodeHeight: 0,
|
|
317
|
+
usesExplicitCuts: true,
|
|
318
|
+
useSyntheticPlaylist: true,
|
|
319
|
+
playlistText: "#EXTM3U\n",
|
|
320
|
+
segmentBoundaries: Array.from({ length: 101 }, (_, index) => index * 4),
|
|
321
|
+
segmentCount: 100,
|
|
322
|
+
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0, speed: "1.0x" }
|
|
323
|
+
};
|
|
324
|
+
manager.sessionsById.set(session.id, session);
|
|
325
|
+
|
|
326
|
+
assert.equal(
|
|
327
|
+
manager.buildMasterPlaylist(session.id),
|
|
328
|
+
null,
|
|
329
|
+
"every rung under the copy runs below realtime here, so there is nothing to switch to"
|
|
330
|
+
);
|
|
331
|
+
assert.deepEqual(
|
|
332
|
+
manager.offeredHeights(session),
|
|
333
|
+
[1080],
|
|
334
|
+
"and the list the browser is given says the same, since both come from one answer"
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// A host with a little more encoder keeps the rungs it can actually hold. A
|
|
338
|
+
// second session, because the answer is settled once per session.
|
|
339
|
+
manager.softwarePresetBenchmark = [{ preset: "ultrafast", pixelsPerSec: 12e6 }];
|
|
340
|
+
const stronger = { ...session, id: "dddddddd-eeee-ffff-0000-111111111111", offeredHeightsCache: undefined };
|
|
341
|
+
manager.sessionsById.set(stronger.id, stronger);
|
|
342
|
+
const master = manager.buildMasterPlaylist(stronger.id);
|
|
343
|
+
assert.ok(master, "1080p copied plus the one rung this host can produce");
|
|
344
|
+
const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
|
|
345
|
+
assert.deepEqual(heights, [1080, 240]);
|
|
346
|
+
assert.deepEqual(manager.offeredHeights(stronger), heights);
|
|
347
|
+
});
|