@torrent-tv/proxy 2.76.4 → 2.76.5

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.
@@ -0,0 +1,500 @@
1
+ /**
2
+ * @file What encoding this file costs THIS machine, and which heights follow.
3
+ *
4
+ * One subject: seconds of work per second of video. Everything here is that
5
+ * question asked about something — a picture being re-encoded, a soundtrack, a
6
+ * copy, everything running beside the rung being judged — and the last method
7
+ * turns the answers into the list of heights the machine can hold.
8
+ *
9
+ * It decides what a viewer is offered, so being wrong in either direction costs
10
+ * them: too generous and they are given a rung that runs below realtime, which
11
+ * is a slideshow; too mean and they are refused quality the host could hold.
12
+ *
13
+ * Nothing here is chosen. Every figure is a measurement — the startup
14
+ * benchmarks, what an encoder has since been seen doing on this very file, what
15
+ * a second job costs on this host, what share of the machine is free — and where
16
+ * a term has not been measured it contributes nothing rather than a guess.
17
+ *
18
+ * What it is given, and why each is passed rather than reached for: which
19
+ * sessions belong to one file (`liveOutputs`), the host's own readings, which
20
+ * key names a soundtrack, how many encoders are running, and what the file costs
21
+ * merely by being fetched. The learned costs it holds itself: they are what an
22
+ * encoder taught it, and it is their only reader.
23
+ */
24
+
25
+ import { correctForAvailability } from "../available-share.js";
26
+ import { contentionPenalty } from "../contention.js";
27
+ import { TRANSCODE_FPS } from "../encode/args.js";
28
+ import { processCanBeSignalled, runStateOf } from "../encode/encode-run-state.js";
29
+ import { canSustainOutput, speedBar } from "../hwaccel.js";
30
+ import { logger } from "../../utils/logger.js";
31
+
32
+ export class EncodeCost {
33
+ /**
34
+ * What a soundtrack encoder has been seen to cost, by the key naming that
35
+ * track. Written by whoever learns from a run; read here and nowhere else.
36
+ *
37
+ * Public for now because the three that learn are still methods of the
38
+ * session manager. Moving them here is the next step, and until then two
39
+ * objects must not each keep a copy of the same reading.
40
+ *
41
+ * @type {Map<string, { costSec: number, readings: number[], version: number }>}
42
+ */
43
+ audioCost = new Map();
44
+
45
+ /** What copying a file's picture has been seen to cost. @type {Map<string, { costSec: number, readings: number[], version: number }>} */
46
+ copyCost = new Map();
47
+
48
+ /** What decoding a file has been seen to cost. @type {Map<string, { costSec: number, readings: number[], version: number }>} */
49
+ decodeCost = new Map();
50
+
51
+ /**
52
+ * What each height was last predicted to do, kept so a session started at
53
+ * that height can be compared against the prediction once it runs.
54
+ *
55
+ * @type {Map<number, number | null> | null}
56
+ */
57
+ lastPredictedByHeight = null;
58
+
59
+ // The last refusal printed. The offer is recomputed on the path that serves
60
+ // every playlist, init and segment, and the figures behind it move every few
61
+ // seconds — so the line is written when the ANSWER changes, not when it is
62
+ // asked again.
63
+ #lastOfferLine = "";
64
+
65
+ #liveOutputs;
66
+ #host;
67
+ #audioCostKey;
68
+ #runningEncoders;
69
+ #encodersRunningNow;
70
+ #torrentCostSecFor;
71
+
72
+ /**
73
+ * @param {{
74
+ * liveOutputs: import("../output/LiveOutputs.js").LiveOutputs,
75
+ * host: () => { benchmark: object[] | null, decodeModel: object | null, contentionPenalties: object | null, availability: { known: boolean, share: number } | null },
76
+ * audioCostKey: (session: object) => string,
77
+ * runningEncoders: () => number,
78
+ * encodersRunningNow: () => number,
79
+ * torrentCostSecFor: (session: object) => number
80
+ * }} deps
81
+ */
82
+ constructor({ liveOutputs, host, audioCostKey, runningEncoders, encodersRunningNow, torrentCostSecFor }) {
83
+ this.#liveOutputs = liveOutputs;
84
+ // Asked at the moment of the question, not copied: the share of the machine
85
+ // that is free is re-read every few seconds, and a copy taken when this was
86
+ // built would price every later rung against a machine that has gone.
87
+ this.#host = host;
88
+ this.#audioCostKey = audioCostKey;
89
+ this.#runningEncoders = runningEncoders;
90
+ this.#encodersRunningNow = encodersRunningNow;
91
+ this.#torrentCostSecFor = torrentCostSecFor;
92
+ }
93
+
94
+ /**
95
+ * What a running re-encode of the picture costs, in seconds of work per
96
+ * second of video.
97
+ *
98
+ * Measured first: `lastAloneSpeed` is what this very rung did with the
99
+ * machine to itself. Failing that, the encode model that decides every rung —
100
+ * the same benchmark, the same decode term — applied to this rung's own pixel
101
+ * rate. There is no third answer: a rung whose cost cannot be derived at all
102
+ * contributes nothing rather than a number somebody invented.
103
+ *
104
+ * @param {HlsSession} session
105
+ * @returns {number}
106
+ */
107
+ #pictureCostOf(session) {
108
+ if (Number.isFinite(session.lastAloneSpeed) && session.lastAloneSpeed > 0) {
109
+ return 1 / session.lastAloneSpeed;
110
+ }
111
+ const benchmark = this.#host().benchmark;
112
+ const width = Number(session.output.encodeWidth) || 0;
113
+ const height = Number(session.output.encodeHeight) || 0;
114
+ const fps = Number(session.output.outputFps) || TRANSCODE_FPS;
115
+ if (!Array.isArray(benchmark) || benchmark.length === 0 || width <= 0 || height <= 0) {
116
+ return 0;
117
+ }
118
+ const { speed } = canSustainOutput({
119
+ benchmark,
120
+ decodeModel: this.#host().decodeModel,
121
+ source: session.file.decode ?? null,
122
+ outputPixelsPerSec: width * height * fps,
123
+ observedDecodeCostSec: null,
124
+ concurrentCostSec: 0
125
+ });
126
+ return Number.isFinite(speed) && speed > 0 ? 1 / speed : 0;
127
+ }
128
+
129
+ /**
130
+ * What everything OTHER than this session is costing right now, or null when
131
+ * any of it is unpriced.
132
+ *
133
+ * Used to recover a soundtrack's own share from a reading taken beside the
134
+ * picture — the only kind of reading a rendition ever gives, since it runs
135
+ * exactly as long as the picture does. Refusing to answer when something
136
+ * running has no price is the point: unpriced work would otherwise be
137
+ * attributed to the soundtrack, and an overpriced soundtrack refuses quality
138
+ * steps the host could actually hold.
139
+ *
140
+ * @param {HlsSession} session
141
+ * @returns {number | null}
142
+ */
143
+ pricedConcurrentCost(session) {
144
+ let cost = 0;
145
+ for (const member of this.#liveOutputs.familyOf(session)) {
146
+ if (member === session || !processCanBeSignalled(runStateOf(member))) {
147
+ continue;
148
+ }
149
+ if (member.audioOnly === true) {
150
+ const audio = this.audioCost.get(this.#audioCostKey(member));
151
+ if (!audio || !(audio.costSec > 0)) {
152
+ return null;
153
+ }
154
+ cost += audio.costSec;
155
+ continue;
156
+ }
157
+ if (member.transcodeVideo !== true) {
158
+ const copy = this.copyCost.get(member.file.key);
159
+ if (!copy || !(copy.costSec > 0)) {
160
+ return null;
161
+ }
162
+ cost += copy.costSec;
163
+ continue;
164
+ }
165
+ const picture = this.#pictureCostOf(member);
166
+ if (!(picture > 0)) {
167
+ return null;
168
+ }
169
+ cost += picture;
170
+ }
171
+ // Encoders outside this family are counted by number only — there is no
172
+ // price to look up for another film's session — so a reading taken while
173
+ // one is running cannot be attributed either.
174
+ return this.#runningEncoders() > this.#liveOutputs.familyOf(session).filter(
175
+ (member) => processCanBeSignalled(runStateOf(member))
176
+ ).length
177
+ ? null
178
+ : cost;
179
+ }
180
+
181
+ /**
182
+ * What each height of this family is costing RIGHT NOW, for the heights an
183
+ * encoder is actually running at.
184
+ *
185
+ * Exists so a height can be judged against what the machine spends on
186
+ * everything else — a step being warmed is running while it is judged, and
187
+ * charged its own cost it refuses itself.
188
+ *
189
+ * @param {HlsSession} session
190
+ * @returns {Map<number, number>}
191
+ */
192
+ runningCostByHeight(session) {
193
+ /** @type {Map<number, number>} */
194
+ const byHeight = new Map();
195
+ for (const member of this.#liveOutputs.familyOf(session)) {
196
+ if (member.audioOnly === true || member.transcodeVideo !== true) {
197
+ continue;
198
+ }
199
+ if (!processCanBeSignalled(runStateOf(member))) {
200
+ continue;
201
+ }
202
+ const height = this.#liveOutputs.variantHeightOf(member);
203
+ if (height > 0) {
204
+ byHeight.set(height, (byHeight.get(height) ?? 0) + this.#pictureCostOf(member));
205
+ }
206
+ }
207
+ return byHeight;
208
+ }
209
+
210
+ /**
211
+ * Seconds of work per second of video this family is ALREADY committed to,
212
+ * beside any rung being considered.
213
+ *
214
+ * Every encoder of the family that is actually running: the picture, whether
215
+ * it is copied or re-encoded, and each audio rendition. The rung the viewer
216
+ * is watching and the source's own copied height are never withdrawn by the
217
+ * caller, so charging for the encoder that serves them cannot strand anyone —
218
+ * what it does is stop the NEXT rung being offered as though the machine were
219
+ * idle, which is what the field disproved on 2026-08-15.
220
+ *
221
+ * Anything whose cost is neither measured nor derivable contributes nothing.
222
+ * A guess here would refuse rungs on arithmetic nobody performed.
223
+ *
224
+ * @param {HlsSession} session
225
+ * @returns {number}
226
+ */
227
+ committedCostOf(session) {
228
+ let cost = 0;
229
+ for (const member of this.#liveOutputs.familyOf(session)) {
230
+ // Only what still HAS an encoder. A quality step the viewer left keeps
231
+ // its session and its segments but not a process, and it produces nothing
232
+ // for anybody — charging the machine for it would refuse steps on work
233
+ // nobody is doing.
234
+ //
235
+ // A SUSPENDED encoder is charged, deliberately, and this is not the same
236
+ // question. The unit here is seconds of work per second of VIDEO, not per
237
+ // second of wall clock: a copy running at 8x costs 0.125 s/s whether it
238
+ // is producing right now or parked by the look-ahead cap, because over an
239
+ // hour of watching it still produces an hour of video. Suspension is how
240
+ // that cost is spread, not a discount on it — and pricing a parked
241
+ // encoder at zero would offer a step on the strength of a pause that ends
242
+ // the moment the viewer catches up.
243
+ if (!processCanBeSignalled(runStateOf(member))) {
244
+ continue;
245
+ }
246
+ if (member.audioOnly === true) {
247
+ // A soundtrack encoder, priced from its own measured speed. Nothing is
248
+ // charged for a track nobody has measured: a guess here refuses rungs
249
+ // on arithmetic no one performed.
250
+ const audio = this.audioCost.get(this.#audioCostKey(member));
251
+ cost += audio && audio.costSec > 0 ? audio.costSec : 0;
252
+ continue;
253
+ }
254
+ if (member.transcodeVideo !== true) {
255
+ const observed = this.copyCost.get(member.file.key);
256
+ cost += observed && observed.costSec > 0 ? observed.costSec : 0;
257
+ continue;
258
+ }
259
+ // A picture being RE-ENCODED beside the rung being judged — the warm-up
260
+ // that makes a quality switch seamless is two encoders by design, and
261
+ // that overlap is exactly where the field measured 0.504x on a rung
262
+ // predicted at 1.58x (2026-08-15). Priced by what it has been SEEN doing
263
+ // when it had the machine to itself, and otherwise by the same model that
264
+ // judges every rung — which is a prediction, not a guess.
265
+ cost += this.#pictureCostOf(member);
266
+ }
267
+ // And what the FILE costs simply by being fetched and delivered while it is
268
+ // watched: a viewer consumes it at its own byte rate, and every one of
269
+ // those bytes is downloaded, verified and pushed by this process. Priced
270
+ // per megabyte from readings taken while nothing was encoding, so the two
271
+ // measurements do not contain each other.
272
+ cost += this.#torrentCostSecFor(session);
273
+ return cost;
274
+ }
275
+
276
+ /**
277
+ * The speed each rung of this family was last seen running at, when it was
278
+ * running alone.
279
+ *
280
+ * A rung that has been watched failing is refused on that evidence; a rung
281
+ * nobody has run says nothing about itself and is judged by the startup
282
+ * measurement like any other.
283
+ *
284
+ * @param {HlsSession} base
285
+ * @returns {Map<number, number>}
286
+ */
287
+ measuredRungSpeeds(base) {
288
+ /** @type {Map<number, number>} */
289
+ const speeds = new Map();
290
+ for (const session of this.#liveOutputs.familyOf(base)) {
291
+ if (session.transcodeVideo !== true || !Number.isFinite(session.lastAloneSpeed)) {
292
+ continue;
293
+ }
294
+ const height = this.#liveOutputs.variantHeightOf(session);
295
+ if (height > 0) {
296
+ speeds.set(height, session.lastAloneSpeed);
297
+ }
298
+ }
299
+ return speeds;
300
+ }
301
+
302
+ /**
303
+ * Drop the rungs this host cannot hold at realtime.
304
+ *
305
+ * Every rung below the source height is a full re-encode — decode the whole
306
+ * source, encode a smaller picture — and on a weak host that is dearer than
307
+ * the copy it replaces. Measured 2026-08-14: 1080p was copied at 7.8-8.9x
308
+ * while the offered 240p rung ran at 0.388-0.947x, its first segment took
309
+ * 30 s and later ones were held 22 s, so choosing a LOWER quality is what
310
+ * broke playback. A rung that cannot be produced faster than it is watched
311
+ * must not be offered at all.
312
+ *
313
+ * The session's OWN height always stays: an encoder is already producing it,
314
+ * and removing it would point the player at a rung nobody is encoding.
315
+ *
316
+ * @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
317
+ * @returns {number[]}
318
+ */
319
+ sustainableHeights({
320
+ heights,
321
+ ownHeight,
322
+ // Every height a viewer has on screen, not one: two viewers of one picture
323
+ // can be on two rungs, and a rung is never withdrawn while somebody is
324
+ // watching it — their next segment would 404 on a stream that is playing.
325
+ playingHeights = new Set(),
326
+ sourceWidth,
327
+ sourceHeight,
328
+ fps,
329
+ source,
330
+ transcodeVideo,
331
+ observedDecodeCostSec = null,
332
+ concurrentCostSec = 0,
333
+ runningCostByHeight = null,
334
+ measuredHeights = null,
335
+ requiredSpeed = null
336
+ }) {
337
+ // What this file's own supply demands, measured by its reader — and
338
+ // realtime while it has not been measured. Read once here so the line that
339
+ // reports a refusal names the figure it refused against.
340
+ const bar = speedBar(requiredSpeed);
341
+ const benchmark = this.#host().benchmark;
342
+ if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
343
+ // Nothing to predict WITH, so nothing is predicted. What has been SEEN
344
+ // still counts: a rung measured running below realtime is withdrawn here
345
+ // too, because the evidence for it does not come from the benchmark. This
346
+ // return used to hand back every height including one measured at 0.4x —
347
+ // found by a check written when this moved out of the session manager,
348
+ // 2026-09-05.
349
+ // The rung on screen is not exempt, for the same reason it is not exempt
350
+ // below: keeping one measured at 0.007x stalls the viewer with no path to
351
+ // a faster rung, which is what the field showed on 2026-08-31.
352
+ return heights.filter((height) => {
353
+ const measured = measuredHeights?.get(height) ?? null;
354
+ return measured === null || measured >= 1;
355
+ });
356
+ }
357
+ /** @type {number[]} */
358
+ const kept = [];
359
+ /** @type {string[]} */
360
+ const dropped = [];
361
+ // What each height was predicted to do on THIS machine, kept so a session
362
+ // started at that height can be compared against it once it runs. The
363
+ // manager holds the last answer, because the offer is computed on the path
364
+ // that serves every request while a session is created elsewhere.
365
+ /** @type {Map<number, number | null>} */
366
+ const predictedByHeight = new Map();
367
+ for (const height of heights) {
368
+ // A rung this session has actually been seen running below realtime is
369
+ // withdrawn on that evidence, whatever the prediction says. This is the
370
+ // one thing a live reading is authority on: itself. It is asked before
371
+ // any exemption so a rung measured failing while on screen does not stay
372
+ // offered because it was on screen when measured — otherwise a step
373
+ // would ask for the one rung this machine has been measured failing at,
374
+ // then fail again, then step down, for ever. A copied source height
375
+ // cannot reach this: `#measuredRungSpeeds` records only sessions that
376
+ // re-encode, so a copy has no reading to be withdrawn on, which is right
377
+ // — it costs no encoder.
378
+ const measured = measuredHeights?.get(height) ?? null;
379
+ if (measured !== null && measured < 1) {
380
+ // Even the rung on screen is withdrawn on measured failure: keeping it
381
+ // would 404 the next segment, but keeping a rung measured at 0.007x
382
+ // (field 2026-08-31, 4K HEVC on CM4) stalls the viewer for minutes with
383
+ // 0.04s buffered and no way to downgrade because every other rung is
384
+ // also dropped. Withdrawing it lets the offer become empty, which the
385
+ // caller turns into an error the viewer can act on (try another proxy
386
+ // or a lower source) instead of an endless spinner.
387
+ dropped.push(`${height}p=${measured.toFixed(2)}x measured`);
388
+ continue;
389
+ }
390
+ // The rung ON SCREEN is kept only when it has not been measured failing
391
+ // above. Keeping a rung measured at 0.007x would stall the viewer with
392
+ // no path to a faster rung, which is what the field showed.
393
+ if (playingHeights.has(height)) {
394
+ kept.push(height);
395
+ continue;
396
+ }
397
+ // The height an encoder is ALREADY producing, and the source's own height
398
+ // when the FAMILY serves it by copy — neither has to be predicted,
399
+ // because it is happening. A copied rung costs no encoder at all, so no
400
+ // measurement of this host can ever be a reason to withdraw it, and the
401
+ // whole point of it is that it is where a viewer on a rung the machine
402
+ // cannot hold goes back to. `transcodeVideo` here is the base's, not the
403
+ // asking session's: a 240p rung re-encodes, and reading its own flag is
404
+ // what withdrew a copied 1080p in the field on 2026-08-15.
405
+ //
406
+ // A source height that would have to be RE-ENCODED is a prediction like
407
+ // any other: on a session whose budget stepped down to 480p, the source's
408
+ // 1080p is neither copied nor being produced, and keeping it unpriced
409
+ // would offer exactly the kind of rung this refuses. Likewise, a rung
410
+ // this session is already producing at 0.007x (field 2026-08-31, 4K HEVC
411
+ // on CM4, 0.1x at 23:45 and 0.007x at 06:57) is not sustainable just
412
+ // because it is running — keeping it offered no path to a faster rung
413
+ // and left the viewer at 0.04s buffered with no downgrade.
414
+ if (
415
+ (height === ownHeight && !transcodeVideo) ||
416
+ (height === sourceHeight && !transcodeVideo)
417
+ ) {
418
+ kept.push(height);
419
+ continue;
420
+ }
421
+ const width = Math.round(((sourceWidth / sourceHeight) * height) / 2) * 2;
422
+ // What the machine is spending on everything EXCEPT this height. A step
423
+ // being warmed for a switch is already running while it is judged, so its
424
+ // own cost is inside the committed total — and charged against itself it
425
+ // is counted twice. Measured against the field figures of 2026-08-15
426
+ // that is 1.83x against 1.03x: below the margin, so the step the viewer
427
+ // had just asked for was dropped from the offer by the act of warming it,
428
+ // and its next segment answered 404 on a stream that was playing.
429
+ const concurrentBesideThis = Math.max(
430
+ 0,
431
+ concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
432
+ );
433
+ const { speed } = canSustainOutput({
434
+ benchmark,
435
+ decodeModel: this.#host().decodeModel,
436
+ source,
437
+ outputPixelsPerSec: width * height * fps,
438
+ observedDecodeCostSec,
439
+ concurrentCostSec: concurrentBesideThis
440
+ });
441
+ // The benchmark behind that figure was taken on a QUIET host — one
442
+ // ffmpeg and nothing else. The machine a step will actually run on is
443
+ // also running the kernel, the container and whatever else its owner
444
+ // does, and on the addon host that was measured at 99 % busy with a
445
+ // quarter of it unattributed. Only the unattributed part is charged
446
+ // here: our own encoders are already in `concurrentBesideThis` and the
447
+ // proxy's own work is already priced per megabyte moved.
448
+ // Two corrections, and they are different facts about the machine. The
449
+ // availability share removes work nobody has been charged for; the
450
+ // contention penalty says what OUR OWN second job costs, because the
451
+ // budget adds independent prices and this host does not behave that way
452
+ // — the same work measured 2.6× dearer beside one encoder and 3.7×
453
+ // beside two (2026-08-18). `concurrentBesideThis` already counts what is
454
+ // committed; this multiplies by how badly running at all together goes.
455
+ const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
456
+ const { penalty } = contentionPenalty(othersRunning, this.#host().contentionPenalties);
457
+ const onThisMachine = correctForAvailability(
458
+ speed === null ? null : speed / penalty,
459
+ this.#host().availability
460
+ );
461
+ // Kept against the step's own session, so that when it runs the field
462
+ // says what the prediction was worth. Without this the only comparison
463
+ // available is between two figures written minutes apart in different
464
+ // lines of the log.
465
+ predictedByHeight.set(height, onThisMachine);
466
+ if (onThisMachine !== null && onThisMachine >= bar) {
467
+ kept.push(height);
468
+ continue;
469
+ }
470
+ dropped.push(`${height}p=${onThisMachine === null ? "n/a" : `${onThisMachine.toFixed(2)}x`}`);
471
+ }
472
+ // Written when the ANSWER changes, not when the answer is recomputed. This
473
+ // is asked on the path that serves every playlist, init and segment, and
474
+ // the figures behind it move every five seconds — so an unconditional line
475
+ // here is roughly seven hundred identical lines an hour into a forwarder
476
+ // that holds five hundred, which buries whatever is worth reading.
477
+ if (dropped.length > 0) {
478
+ const line =
479
+ `transcode: not offering ${dropped.join(" ")} — below ${bar.toFixed(2)}x ` +
480
+ (Number.isFinite(requiredSpeed) && requiredSpeed > 1
481
+ ? "(the speed this file's own interruptions demand) "
482
+ : "(realtime, this file's supply not measured yet) ") +
483
+ // Said with the figures, because a step refused on a busy machine and
484
+ // one refused on an idle machine are different facts about the host.
485
+ (this.#host().availability?.known
486
+ ? `on a machine with ${Math.round(this.#host().availability.share * 100)}% to spare `
487
+ : "") +
488
+ `(offering ${kept.map((height) => `${height}p`).join(" ")})`;
489
+ if (line !== this.#lastOfferLine) {
490
+ this.#lastOfferLine = line;
491
+ logger.info(line);
492
+ }
493
+ this.lastPredictedByHeight = predictedByHeight;
494
+ } else {
495
+ this.lastPredictedByHeight = predictedByHeight;
496
+ this.#lastOfferLine = "";
497
+ }
498
+ return kept;
499
+ }
500
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @file What encoding costs this machine, asked of the object that owns it.
3
+ *
4
+ * The arithmetic itself is exercised end to end by `auto-quality-step` and
5
+ * `quality-variants`, which go through the session manager. What is pinned here
6
+ * is the seam the move created: this object is given the host's readings as a
7
+ * QUESTION rather than a copy, and it holds what an encoder taught it.
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { EncodeCost } from "../services/quality/EncodeCost.js";
13
+
14
+ /**
15
+ * @param {object} [readings]
16
+ * @returns {{ cost: EncodeCost, asked: () => number, host: { share: number } }}
17
+ */
18
+ function costOn(readings = {}) {
19
+ let asked = 0;
20
+ const host = { share: 1 };
21
+ const cost = new EncodeCost({
22
+ liveOutputs: { familyOf: () => [], variantHeightOf: () => 0 },
23
+ host: () => {
24
+ asked += 1;
25
+ return {
26
+ benchmark: readings.benchmark ?? null,
27
+ decodeModel: null,
28
+ contentionPenalties: null,
29
+ availability: { known: true, share: host.share }
30
+ };
31
+ },
32
+ audioCostKey: () => "audio",
33
+ runningEncoders: () => 0,
34
+ encodersRunningNow: () => 0,
35
+ torrentCostSecFor: () => 0
36
+ });
37
+ return { cost, asked: () => asked, host };
38
+ }
39
+
40
+ test("the host is asked at the moment of the question, not when this was built", () => {
41
+ // The share of the machine that is free is re-read every few seconds. Copied
42
+ // into this object when it was made, every later rung would be priced against
43
+ // a machine that has gone.
44
+ const { cost, asked, host } = costOn();
45
+ assert.equal(asked(), 0, "nothing is read until something is asked");
46
+
47
+ cost.sustainableHeights({ heights: [1080], sourceWidth: 1920, sourceHeight: 1080, fps: 24, source: null, transcodeVideo: true, ownHeight: 0 });
48
+ const first = asked();
49
+ assert.ok(first > 0);
50
+
51
+ host.share = 0.1;
52
+ cost.sustainableHeights({ heights: [1080], sourceWidth: 1920, sourceHeight: 1080, fps: 24, source: null, transcodeVideo: true, ownHeight: 0 });
53
+ assert.ok(asked() > first, "and read again on the next question");
54
+ });
55
+
56
+ test("with no benchmark to judge by, every height offered is kept", () => {
57
+ // Nothing measured is not the same as nothing possible. Refusing here would
58
+ // hide the whole ladder on a host whose startup measurement failed.
59
+ const { cost } = costOn({ benchmark: null });
60
+ const kept = cost.sustainableHeights({
61
+ heights: [1080, 720, 480],
62
+ sourceWidth: 1920,
63
+ sourceHeight: 1080,
64
+ fps: 24,
65
+ source: null,
66
+ transcodeVideo: true,
67
+ ownHeight: 0
68
+ });
69
+ assert.deepEqual(kept, [1080, 720, 480]);
70
+ });
71
+
72
+ test("a rung measured below realtime is withdrawn, and a copied source height is not", () => {
73
+ const { cost } = costOn({ benchmark: null });
74
+ const kept = cost.sustainableHeights({
75
+ heights: [1080, 480],
76
+ sourceWidth: 1920,
77
+ sourceHeight: 1080,
78
+ fps: 24,
79
+ source: null,
80
+ // The base copies its picture, so the source height costs no encoder.
81
+ transcodeVideo: false,
82
+ ownHeight: 1080,
83
+ measuredHeights: new Map([[480, 0.4]])
84
+ });
85
+ assert.deepEqual(kept, [1080], "the copy stays; the rung seen failing does not");
86
+ });
87
+
88
+ test("what an encoder taught this host is held here, and nowhere else", () => {
89
+ const { cost } = costOn();
90
+ cost.copyCost.set("torrent:abc:0", { costSec: 0.125, readings: [8], version: 1 });
91
+ assert.equal(cost.copyCost.get("torrent:abc:0").costSec, 0.125);
92
+ assert.equal(cost.audioCost.size, 0);
93
+ assert.equal(cost.decodeCost.size, 0);
94
+ });