@torrent-tv/proxy 2.80.5 → 2.80.6

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.
@@ -1,500 +1,555 @@
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
- }
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, copySpeedX: number | 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
+ * How fast this machine produces ONE output, in seconds of film per second.
131
+ *
132
+ * THERE IS ALWAYS AN ANSWER, and that is the point of this method. Every
133
+ * decision in the encoding layer is made from arrivals when would this
134
+ * encoder reach that piece and an arrival cannot be computed without a
135
+ * speed. A speed that is missing is therefore not a smaller answer, it is no
136
+ * answer at all: the plan then cannot tell a viewer who will be served from
137
+ * one who will be left waiting, and the moment it happens is the cold start,
138
+ * which is when the question matters most.
139
+ *
140
+ * Three sources, most specific first, and every one of them measured:
141
+ *
142
+ * 1. what a run on THIS output has been seen doing. It is this machine, this
143
+ * material and these settings, so nothing beats it;
144
+ * 2. the startup benchmark, for an output whose picture is re-encoded: the
145
+ * preset readings and the decode model, applied to this output's own pixel
146
+ * rate. It exists before any viewer;
147
+ * 3. the startup copy measurement, for an output whose picture is copied.
148
+ * Copying neither decodes nor encodes, so neither of the above describes
149
+ * it, and until it was measured this branch had no figure at all.
150
+ *
151
+ * @param {string} address - The output, as the encoding layer names it.
152
+ * @returns {number} Seconds of film per second. Zero only where the host
153
+ * measured nothing at all, which is a broken startup rather than a state to
154
+ * plan around.
155
+ */
156
+ speedForOutput(address) {
157
+ const sessions = this.#liveOutputs.sessionsOn(address);
158
+ let measured = 0;
159
+ for (const session of sessions) {
160
+ const speed = Number(session.lastAloneSpeed);
161
+ if (Number.isFinite(speed) && speed > measured) {
162
+ measured = speed;
163
+ }
164
+ }
165
+ if (measured > 0) {
166
+ return measured;
167
+ }
168
+ for (const session of sessions) {
169
+ if (session.transcodeVideo === true) {
170
+ const cost = this.#pictureCostOf(session);
171
+ if (cost > 0) {
172
+ return 1 / cost;
173
+ }
174
+ continue;
175
+ }
176
+ const copying = Number(this.#host().copySpeedX);
177
+ if (Number.isFinite(copying) && copying > 0) {
178
+ return copying;
179
+ }
180
+ }
181
+ return 0;
182
+ }
183
+
184
+ /**
185
+ * What everything OTHER than this session is costing right now, or null when
186
+ * any of it is unpriced.
187
+ *
188
+ * Used to recover a soundtrack's own share from a reading taken beside the
189
+ * picture the only kind of reading a rendition ever gives, since it runs
190
+ * exactly as long as the picture does. Refusing to answer when something
191
+ * running has no price is the point: unpriced work would otherwise be
192
+ * attributed to the soundtrack, and an overpriced soundtrack refuses quality
193
+ * steps the host could actually hold.
194
+ *
195
+ * @param {HlsSession} session
196
+ * @returns {number | null}
197
+ */
198
+ pricedConcurrentCost(session) {
199
+ let cost = 0;
200
+ for (const member of this.#liveOutputs.familyOf(session)) {
201
+ if (member === session || !processCanBeSignalled(runStateOf(member))) {
202
+ continue;
203
+ }
204
+ if (member.audioOnly === true) {
205
+ const audio = this.audioCost.get(this.#audioCostKey(member));
206
+ if (!audio || !(audio.costSec > 0)) {
207
+ return null;
208
+ }
209
+ cost += audio.costSec;
210
+ continue;
211
+ }
212
+ if (member.transcodeVideo !== true) {
213
+ const copy = this.copyCost.get(member.file.key);
214
+ if (!copy || !(copy.costSec > 0)) {
215
+ return null;
216
+ }
217
+ cost += copy.costSec;
218
+ continue;
219
+ }
220
+ const picture = this.#pictureCostOf(member);
221
+ if (!(picture > 0)) {
222
+ return null;
223
+ }
224
+ cost += picture;
225
+ }
226
+ // Encoders outside this family are counted by number only — there is no
227
+ // price to look up for another film's session — so a reading taken while
228
+ // one is running cannot be attributed either.
229
+ return this.#runningEncoders() > this.#liveOutputs.familyOf(session).filter(
230
+ (member) => processCanBeSignalled(runStateOf(member))
231
+ ).length
232
+ ? null
233
+ : cost;
234
+ }
235
+
236
+ /**
237
+ * What each height of this family is costing RIGHT NOW, for the heights an
238
+ * encoder is actually running at.
239
+ *
240
+ * Exists so a height can be judged against what the machine spends on
241
+ * everything else a step being warmed is running while it is judged, and
242
+ * charged its own cost it refuses itself.
243
+ *
244
+ * @param {HlsSession} session
245
+ * @returns {Map<number, number>}
246
+ */
247
+ runningCostByHeight(session) {
248
+ /** @type {Map<number, number>} */
249
+ const byHeight = new Map();
250
+ for (const member of this.#liveOutputs.familyOf(session)) {
251
+ if (member.audioOnly === true || member.transcodeVideo !== true) {
252
+ continue;
253
+ }
254
+ if (!processCanBeSignalled(runStateOf(member))) {
255
+ continue;
256
+ }
257
+ const height = this.#liveOutputs.variantHeightOf(member);
258
+ if (height > 0) {
259
+ byHeight.set(height, (byHeight.get(height) ?? 0) + this.#pictureCostOf(member));
260
+ }
261
+ }
262
+ return byHeight;
263
+ }
264
+
265
+ /**
266
+ * Seconds of work per second of video this family is ALREADY committed to,
267
+ * beside any rung being considered.
268
+ *
269
+ * Every encoder of the family that is actually running: the picture, whether
270
+ * it is copied or re-encoded, and each audio rendition. The rung the viewer
271
+ * is watching and the source's own copied height are never withdrawn by the
272
+ * caller, so charging for the encoder that serves them cannot strand anyone —
273
+ * what it does is stop the NEXT rung being offered as though the machine were
274
+ * idle, which is what the field disproved on 2026-08-15.
275
+ *
276
+ * Anything whose cost is neither measured nor derivable contributes nothing.
277
+ * A guess here would refuse rungs on arithmetic nobody performed.
278
+ *
279
+ * @param {HlsSession} session
280
+ * @returns {number}
281
+ */
282
+ committedCostOf(session) {
283
+ let cost = 0;
284
+ for (const member of this.#liveOutputs.familyOf(session)) {
285
+ // Only what still HAS an encoder. A quality step the viewer left keeps
286
+ // its session and its segments but not a process, and it produces nothing
287
+ // for anybody — charging the machine for it would refuse steps on work
288
+ // nobody is doing.
289
+ //
290
+ // A SUSPENDED encoder is charged, deliberately, and this is not the same
291
+ // question. The unit here is seconds of work per second of VIDEO, not per
292
+ // second of wall clock: a copy running at 8x costs 0.125 s/s whether it
293
+ // is producing right now or parked by the look-ahead cap, because over an
294
+ // hour of watching it still produces an hour of video. Suspension is how
295
+ // that cost is spread, not a discount on it — and pricing a parked
296
+ // encoder at zero would offer a step on the strength of a pause that ends
297
+ // the moment the viewer catches up.
298
+ if (!processCanBeSignalled(runStateOf(member))) {
299
+ continue;
300
+ }
301
+ if (member.audioOnly === true) {
302
+ // A soundtrack encoder, priced from its own measured speed. Nothing is
303
+ // charged for a track nobody has measured: a guess here refuses rungs
304
+ // on arithmetic no one performed.
305
+ const audio = this.audioCost.get(this.#audioCostKey(member));
306
+ cost += audio && audio.costSec > 0 ? audio.costSec : 0;
307
+ continue;
308
+ }
309
+ if (member.transcodeVideo !== true) {
310
+ const observed = this.copyCost.get(member.file.key);
311
+ cost += observed && observed.costSec > 0 ? observed.costSec : 0;
312
+ continue;
313
+ }
314
+ // A picture being RE-ENCODED beside the rung being judged the warm-up
315
+ // that makes a quality switch seamless is two encoders by design, and
316
+ // that overlap is exactly where the field measured 0.504x on a rung
317
+ // predicted at 1.58x (2026-08-15). Priced by what it has been SEEN doing
318
+ // when it had the machine to itself, and otherwise by the same model that
319
+ // judges every rung — which is a prediction, not a guess.
320
+ cost += this.#pictureCostOf(member);
321
+ }
322
+ // And what the FILE costs simply by being fetched and delivered while it is
323
+ // watched: a viewer consumes it at its own byte rate, and every one of
324
+ // those bytes is downloaded, verified and pushed by this process. Priced
325
+ // per megabyte from readings taken while nothing was encoding, so the two
326
+ // measurements do not contain each other.
327
+ cost += this.#torrentCostSecFor(session);
328
+ return cost;
329
+ }
330
+
331
+ /**
332
+ * The speed each rung of this family was last seen running at, when it was
333
+ * running alone.
334
+ *
335
+ * A rung that has been watched failing is refused on that evidence; a rung
336
+ * nobody has run says nothing about itself and is judged by the startup
337
+ * measurement like any other.
338
+ *
339
+ * @param {HlsSession} base
340
+ * @returns {Map<number, number>}
341
+ */
342
+ measuredRungSpeeds(base) {
343
+ /** @type {Map<number, number>} */
344
+ const speeds = new Map();
345
+ for (const session of this.#liveOutputs.familyOf(base)) {
346
+ if (session.transcodeVideo !== true || !Number.isFinite(session.lastAloneSpeed)) {
347
+ continue;
348
+ }
349
+ const height = this.#liveOutputs.variantHeightOf(session);
350
+ if (height > 0) {
351
+ speeds.set(height, session.lastAloneSpeed);
352
+ }
353
+ }
354
+ return speeds;
355
+ }
356
+
357
+ /**
358
+ * Drop the rungs this host cannot hold at realtime.
359
+ *
360
+ * Every rung below the source height is a full re-encode — decode the whole
361
+ * source, encode a smaller picture and on a weak host that is dearer than
362
+ * the copy it replaces. Measured 2026-08-14: 1080p was copied at 7.8-8.9x
363
+ * while the offered 240p rung ran at 0.388-0.947x, its first segment took
364
+ * 30 s and later ones were held 22 s, so choosing a LOWER quality is what
365
+ * broke playback. A rung that cannot be produced faster than it is watched
366
+ * must not be offered at all.
367
+ *
368
+ * The session's OWN height always stays: an encoder is already producing it,
369
+ * and removing it would point the player at a rung nobody is encoding.
370
+ *
371
+ * @param {{ heights: number[], ownHeight: number, sourceWidth: number, sourceHeight: number, fps: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, transcodeVideo: boolean }} params
372
+ * @returns {number[]}
373
+ */
374
+ sustainableHeights({
375
+ heights,
376
+ ownHeight,
377
+ // Every height a viewer has on screen, not one: two viewers of one picture
378
+ // can be on two rungs, and a rung is never withdrawn while somebody is
379
+ // watching it their next segment would 404 on a stream that is playing.
380
+ playingHeights = new Set(),
381
+ sourceWidth,
382
+ sourceHeight,
383
+ fps,
384
+ source,
385
+ transcodeVideo,
386
+ observedDecodeCostSec = null,
387
+ concurrentCostSec = 0,
388
+ runningCostByHeight = null,
389
+ measuredHeights = null,
390
+ requiredSpeed = null
391
+ }) {
392
+ // What this file's own supply demands, measured by its reader and
393
+ // realtime while it has not been measured. Read once here so the line that
394
+ // reports a refusal names the figure it refused against.
395
+ const bar = speedBar(requiredSpeed);
396
+ const benchmark = this.#host().benchmark;
397
+ if (!Array.isArray(benchmark) || benchmark.length === 0 || sourceHeight <= 0 || sourceWidth <= 0) {
398
+ // Nothing to predict WITH, so nothing is predicted. What has been SEEN
399
+ // still counts: a rung measured running below realtime is withdrawn here
400
+ // too, because the evidence for it does not come from the benchmark. This
401
+ // return used to hand back every height including one measured at 0.4x
402
+ // found by a check written when this moved out of the session manager,
403
+ // 2026-09-05.
404
+ // The rung on screen is not exempt, for the same reason it is not exempt
405
+ // below: keeping one measured at 0.007x stalls the viewer with no path to
406
+ // a faster rung, which is what the field showed on 2026-08-31.
407
+ return heights.filter((height) => {
408
+ const measured = measuredHeights?.get(height) ?? null;
409
+ return measured === null || measured >= 1;
410
+ });
411
+ }
412
+ /** @type {number[]} */
413
+ const kept = [];
414
+ /** @type {string[]} */
415
+ const dropped = [];
416
+ // What each height was predicted to do on THIS machine, kept so a session
417
+ // started at that height can be compared against it once it runs. The
418
+ // manager holds the last answer, because the offer is computed on the path
419
+ // that serves every request while a session is created elsewhere.
420
+ /** @type {Map<number, number | null>} */
421
+ const predictedByHeight = new Map();
422
+ for (const height of heights) {
423
+ // A rung this session has actually been seen running below realtime is
424
+ // withdrawn on that evidence, whatever the prediction says. This is the
425
+ // one thing a live reading is authority on: itself. It is asked before
426
+ // any exemption so a rung measured failing while on screen does not stay
427
+ // offered because it was on screen when measured otherwise a step
428
+ // would ask for the one rung this machine has been measured failing at,
429
+ // then fail again, then step down, for ever. A copied source height
430
+ // cannot reach this: `#measuredRungSpeeds` records only sessions that
431
+ // re-encode, so a copy has no reading to be withdrawn on, which is right
432
+ // — it costs no encoder.
433
+ const measured = measuredHeights?.get(height) ?? null;
434
+ if (measured !== null && measured < 1) {
435
+ // Even the rung on screen is withdrawn on measured failure: keeping it
436
+ // would 404 the next segment, but keeping a rung measured at 0.007x
437
+ // (field 2026-08-31, 4K HEVC on CM4) stalls the viewer for minutes with
438
+ // 0.04s buffered and no way to downgrade because every other rung is
439
+ // also dropped. Withdrawing it lets the offer become empty, which the
440
+ // caller turns into an error the viewer can act on (try another proxy
441
+ // or a lower source) instead of an endless spinner.
442
+ dropped.push(`${height}p=${measured.toFixed(2)}x measured`);
443
+ continue;
444
+ }
445
+ // The rung ON SCREEN is kept only when it has not been measured failing
446
+ // above. Keeping a rung measured at 0.007x would stall the viewer with
447
+ // no path to a faster rung, which is what the field showed.
448
+ if (playingHeights.has(height)) {
449
+ kept.push(height);
450
+ continue;
451
+ }
452
+ // The height an encoder is ALREADY producing, and the source's own height
453
+ // when the FAMILY serves it by copy — neither has to be predicted,
454
+ // because it is happening. A copied rung costs no encoder at all, so no
455
+ // measurement of this host can ever be a reason to withdraw it, and the
456
+ // whole point of it is that it is where a viewer on a rung the machine
457
+ // cannot hold goes back to. `transcodeVideo` here is the base's, not the
458
+ // asking session's: a 240p rung re-encodes, and reading its own flag is
459
+ // what withdrew a copied 1080p in the field on 2026-08-15.
460
+ //
461
+ // A source height that would have to be RE-ENCODED is a prediction like
462
+ // any other: on a session whose budget stepped down to 480p, the source's
463
+ // 1080p is neither copied nor being produced, and keeping it unpriced
464
+ // would offer exactly the kind of rung this refuses. Likewise, a rung
465
+ // this session is already producing at 0.007x (field 2026-08-31, 4K HEVC
466
+ // on CM4, 0.1x at 23:45 and 0.007x at 06:57) is not sustainable just
467
+ // because it is running — keeping it offered no path to a faster rung
468
+ // and left the viewer at 0.04s buffered with no downgrade.
469
+ if (
470
+ (height === ownHeight && !transcodeVideo) ||
471
+ (height === sourceHeight && !transcodeVideo)
472
+ ) {
473
+ kept.push(height);
474
+ continue;
475
+ }
476
+ const width = Math.round(((sourceWidth / sourceHeight) * height) / 2) * 2;
477
+ // What the machine is spending on everything EXCEPT this height. A step
478
+ // being warmed for a switch is already running while it is judged, so its
479
+ // own cost is inside the committed total and charged against itself it
480
+ // is counted twice. Measured against the field figures of 2026-08-15
481
+ // that is 1.83x against 1.03x: below the margin, so the step the viewer
482
+ // had just asked for was dropped from the offer by the act of warming it,
483
+ // and its next segment answered 404 on a stream that was playing.
484
+ const concurrentBesideThis = Math.max(
485
+ 0,
486
+ concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
487
+ );
488
+ const { speed } = canSustainOutput({
489
+ benchmark,
490
+ decodeModel: this.#host().decodeModel,
491
+ source,
492
+ outputPixelsPerSec: width * height * fps,
493
+ observedDecodeCostSec,
494
+ concurrentCostSec: concurrentBesideThis
495
+ });
496
+ // The benchmark behind that figure was taken on a QUIET host — one
497
+ // ffmpeg and nothing else. The machine a step will actually run on is
498
+ // also running the kernel, the container and whatever else its owner
499
+ // does, and on the addon host that was measured at 99 % busy with a
500
+ // quarter of it unattributed. Only the unattributed part is charged
501
+ // here: our own encoders are already in `concurrentBesideThis` and the
502
+ // proxy's own work is already priced per megabyte moved.
503
+ // Two corrections, and they are different facts about the machine. The
504
+ // availability share removes work nobody has been charged for; the
505
+ // contention penalty says what OUR OWN second job costs, because the
506
+ // budget adds independent prices and this host does not behave that way
507
+ // — the same work measured 2.6× dearer beside one encoder and 3.7×
508
+ // beside two (2026-08-18). `concurrentBesideThis` already counts what is
509
+ // committed; this multiplies by how badly running at all together goes.
510
+ const othersRunning = concurrentBesideThis > 0 ? this.#encodersRunningNow() : 0;
511
+ const { penalty } = contentionPenalty(othersRunning, this.#host().contentionPenalties);
512
+ const onThisMachine = correctForAvailability(
513
+ speed === null ? null : speed / penalty,
514
+ this.#host().availability
515
+ );
516
+ // Kept against the step's own session, so that when it runs the field
517
+ // says what the prediction was worth. Without this the only comparison
518
+ // available is between two figures written minutes apart in different
519
+ // lines of the log.
520
+ predictedByHeight.set(height, onThisMachine);
521
+ if (onThisMachine !== null && onThisMachine >= bar) {
522
+ kept.push(height);
523
+ continue;
524
+ }
525
+ dropped.push(`${height}p=${onThisMachine === null ? "n/a" : `${onThisMachine.toFixed(2)}x`}`);
526
+ }
527
+ // Written when the ANSWER changes, not when the answer is recomputed. This
528
+ // is asked on the path that serves every playlist, init and segment, and
529
+ // the figures behind it move every five seconds — so an unconditional line
530
+ // here is roughly seven hundred identical lines an hour into a forwarder
531
+ // that holds five hundred, which buries whatever is worth reading.
532
+ if (dropped.length > 0) {
533
+ const line =
534
+ `transcode: not offering ${dropped.join(" ")} — below ${bar.toFixed(2)}x ` +
535
+ (Number.isFinite(requiredSpeed) && requiredSpeed > 1
536
+ ? "(the speed this file's own interruptions demand) "
537
+ : "(realtime, this file's supply not measured yet) ") +
538
+ // Said with the figures, because a step refused on a busy machine and
539
+ // one refused on an idle machine are different facts about the host.
540
+ (this.#host().availability?.known
541
+ ? `on a machine with ${Math.round(this.#host().availability.share * 100)}% to spare `
542
+ : "") +
543
+ `(offering ${kept.map((height) => `${height}p`).join(" ")})`;
544
+ if (line !== this.#lastOfferLine) {
545
+ this.#lastOfferLine = line;
546
+ logger.info(line);
547
+ }
548
+ this.lastPredictedByHeight = predictedByHeight;
549
+ } else {
550
+ this.lastPredictedByHeight = predictedByHeight;
551
+ this.#lastOfferLine = "";
552
+ }
553
+ return kept;
554
+ }
555
+ }