@torrent-tv/proxy 2.89.3 → 2.89.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.
Files changed (50) hide show
  1. package/AGENTS.md +338 -336
  2. package/CHANGELOG.md +2319 -2303
  3. package/docs/encode-architecture.md +55 -19
  4. package/docs/playback-start-forecast.md +13 -5
  5. package/docs/storage-architecture.md +6 -0
  6. package/package.json +1 -1
  7. package/routes/stream/get.js +38 -2
  8. package/server.js +748 -745
  9. package/services/encode/EncodeOrchestrator.js +24 -49
  10. package/services/encode/EncodePlan.js +5 -3
  11. package/services/encode/EncodeRun.js +80 -17
  12. package/services/encode/EncodeRuns.js +1339 -1239
  13. package/services/encode/Renditions.js +2 -1
  14. package/services/encode/RunClock.js +87 -0
  15. package/services/encode/RunProgress.js +1 -6
  16. package/services/encode/encoder-readings.js +26 -28
  17. package/services/encode/piece-completeness.js +46 -0
  18. package/services/encode/quality/EncodeCost.js +1043 -1167
  19. package/services/encode/quality/OutputQualityState.js +1 -5
  20. package/services/encode/quality/QualityController.js +213 -390
  21. package/services/encode/quality/link-budget.js +5 -17
  22. package/services/encode/run-inputs.js +13 -2
  23. package/services/server/SegmentServing.js +3 -1
  24. package/services/server/ViewerRequests.js +2 -7
  25. package/services/server/wire-outputs.js +12 -7
  26. package/services/storage/segment-store/SegmentStore.js +1294 -1255
  27. package/services/torrent/supply-margin.js +7 -5
  28. package/test/auto-quality-step.test.js +75 -29
  29. package/test/decode-cost.test.js +1 -1
  30. package/test/encode-cost.test.js +31 -39
  31. package/test/encode-orchestrator.test.js +15 -8
  32. package/test/encode-plan-viewers.test.js +34 -27
  33. package/test/encode-run.test.js +34 -6
  34. package/test/encoder-readings.test.js +48 -26
  35. package/test/helpers/encode-run.js +113 -9
  36. package/test/link-budget.test.js +4 -5
  37. package/test/manager-surface.test.js +1 -2
  38. package/test/orchestrator-wired.test.js +5 -4
  39. package/test/output-speed.test.js +0 -1
  40. package/test/piece-completeness.test.js +42 -0
  41. package/test/quality-offer.test.js +1 -3
  42. package/test/quality-variants.test.js +1 -1
  43. package/test/run-progress.test.js +2 -3
  44. package/test/same-height-switch.test.js +1 -2
  45. package/test/segment-closed-on-both-branches.test.js +114 -96
  46. package/test/segment-store.test.js +15 -15
  47. package/test/stream-route.test.js +28 -0
  48. package/test/supply-margin.test.js +13 -0
  49. package/test/two-viewers-one-picture.test.js +1 -1
  50. package/test/viewer-outputs.test.js +1 -1
@@ -7,72 +7,51 @@
7
7
  * rest of the proxy is the host object it is built with, and nothing else.
8
8
  */
9
9
 
10
- import { stat } from "node:fs/promises";
11
10
  import { logger } from "../../../utils/logger.js";
12
- import { ENCODE_RUN_STATE } from "../encode-run-state.js";
13
- import { LINK_SAFETY, linkCouldCarry, loadOf } from "./link-budget.js";
11
+ import { linkCouldCarry, loadOf } from "./link-budget.js";
14
12
  import { qualityStateOf } from "./OutputQualityState.js";
15
13
  import { drainThreat } from "./drain-threat.js";
16
14
  import { visibleHeightCap } from "./visible-rung.js";
17
15
 
18
- // Realtime budget — runtime downswitch (software encoder only). Periodically
19
- // check each active software-transcode session's ffmpeg `speed`; when it stays
20
- // below realtime for a sustained window AND the input is not download-starved
21
- // (so the limit is the encoder, not the torrent), step down one resolution rung
22
- // and restart at the current segment. Conservative so it never thrashes: a long
23
- // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
16
+ // How often the host's load and the torrents' download rates are sampled.
17
+ // A sampling period of the machine, not a quality decision: every decision
18
+ // about a viewer's quality is taken on that viewer's report.
24
19
  export const BUDGET_CHECK_INTERVAL_MS = 5_000;
25
- // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
26
- // realtime resets the slow window (hysteresis).
27
- const BUDGET_SPEED_SLOW = 0.95;
28
- const BUDGET_SPEED_OK = 1.0;
29
- // Slow must persist this long before a downshift (absorbs warm-up + brief
30
- // complex scenes; the cumulative average won't dip this long unless the host
31
- // genuinely can't keep up).
32
- const BUDGET_SUSTAINED_MS = 15_000;
33
- // The step BACK UP has to be slower to fire than the step down, or the two
34
- // take turns: a rung that has just been left is by definition one the arithmetic
35
- // still thinks this machine can hold, so it would be asked for again as soon as
36
- // the cooldown expired. Four times the down window is a statement about how long
37
- // a machine has to look able before it is believed, not a measured quantity, and
38
- // it is written here rather than dressed up as one.
39
- const BUDGET_UP_SUSTAINED_MS = 60_000;
40
- // How long a request to the player to change variant stands before it is
41
- // treated as unanswered. A progress report is polled about every 1.5 s and the
42
- // switch itself needs the rung warmed, which is the cold start this host
43
- // measures; this is long enough for both and short enough that a browser which
44
- // cannot honour the request (no master playlist, a viewer on a manual pick) is
45
- // not chased for the rest of the film.
46
- const QUALITY_ASK_TTL_MS = 45_000;
47
- // The input counts as "keeping up" when the torrent downloads at least this
48
- // multiple of the source's average byte rate. Below it (and not yet fully
49
- // downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
50
- const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
51
- // Viewer-link adaptation. Judged on each report a viewer makes, for that
52
- // viewer alone (roadmap item 98): when their link does not carry the stream
53
- // they are given AND their buffer, on its present trend, would run dry before
54
- // another output could have the piece they need (`drain-threat.js`), a smaller
55
- // output is prepared for them. A chosen window of slowness and a chosen buffer
56
- // threshold stood here, and a chosen wait after every action; all three are
57
- // gone. A manual viewer is never sent an automatic quality request.
58
- // Observed produced bitrate: average over this many recently completed
59
- // segments (the newest file on disk may still be written and is excluded).
60
- const LINK_OBSERVED_SEGMENTS = 5;
20
+ // Every quality step is judged on a report a viewer makes, for that viewer
21
+ // alone (roadmap item 98). DOWN: their buffer, on its present trend, would run
22
+ // dry before another output could have the piece they need (`drain-threat.js`),
23
+ // and the reason is one a smaller output removes — their link carries less
24
+ // than the stream, or this machine makes the picture slower than realtime
25
+ // over its own working time (`RunClock`). UP: their buffer is not draining,
26
+ // holds the time another output takes to be ready, the machine makes the
27
+ // picture at least at realtime, and their link carries the next height. A
28
+ // chosen threshold of slowness, a chosen window to sustain it, a window four
29
+ // times as long before stepping back up, a safety share of the link and a
30
+ // chosen test for a download-starved input stood here; all are gone. A manual
31
+ // viewer is never sent an automatic quality request.
61
32
 
62
- /** @typedef {{ pathOf: (address: string, index: number) => string | null }} SegmentPathLookup */
33
+ /** @typedef {{ pathOf: (address: string, index: number) => string | null, sizesOf: (address: string) => Map<number, number> }} SegmentPathLookup */
63
34
 
64
35
  export class QualityController {
65
36
  /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
66
37
  #host;
67
38
 
68
- /** A budget pass is under way; a timer tick that lands meanwhile is skipped. */
39
+ /** A sampling pass is under way; a timer tick that lands meanwhile is skipped. */
69
40
  #tickRunning = false;
70
41
 
71
42
  /** Viewers whose report is being judged now (`noteViewerReported`). @type {Set<string>} */
72
43
  #judging = new Set();
73
44
 
74
45
  /**
75
- * @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `getSourceStats`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `expectedFirstSegmentMs`
46
+ * Viewers whose standing request the judgement of their current report asked
47
+ * for again. A request stands for as long as the conditions it was asked
48
+ * under hold, and only a judgement can say they do; one it did not repeat is
49
+ * let go. @type {Set<string>}
50
+ */
51
+ #affirmed = new Set();
52
+
53
+ /**
54
+ * @param {object} host - `isLive`, `liveConsumers`, `liveRunsOf`, `producedNumbers`, `reportHostLoad`, `runStateOf`, `sampleDownloadRates`, `encodeCost`, `outputs`, `qualityOffer`, `segmentDurationSec`, `segmentPaths`, `videoEncoder`, `prepareSameHeightSwitch`, `sameHeightSwitchPending`, `sameHeightSwitchDirection`, `cancelSameHeightSwitch`, `heightReadyFor`, `bufferOf`, `visiblePictureOf`, `expectedFirstSegmentMs`
76
55
  * @param {SegmentPathLookup} host.segmentPaths - Lookup for completed
77
56
  * segment paths needed by quality decisions.
78
57
  */
@@ -81,23 +60,20 @@ export class QualityController {
81
60
  }
82
61
 
83
62
  /**
84
- * One pass of the quality budget: learn what this host is doing with each
85
- * running encode, and act on it.
63
+ * One sampling pass: the host's load and the torrents' download rates.
86
64
  *
87
- * Public because it is an operation with a name, not an implementation
88
- * detail of a timer — and because a loop that decides what the viewer sees
89
- * and can only be reached through `setInterval` is a loop nothing can check.
90
- * The timer calls exactly this.
65
+ * Public because it is an operation with a name, not an implementation detail
66
+ * of a timer. It decides nothing about any viewer: an encoder's price is
67
+ * learned when it closes a piece (`EncodeCost.learnFrom`), and a quality step
68
+ * is judged on a viewer's report (`noteViewerReported`).
91
69
  *
92
70
  * @returns {Promise<void>}
93
71
  */
94
72
  async runQualityBudgetOnce() {
95
73
  void this.#host.reportHostLoad();
96
- // One tick at a time. Both halves await torrent statistics per source, so a
97
- // slow or stuck answer would otherwise let the next tick in behind it — two
98
- // passes over the same sessions, taking the same reading twice and acting
99
- // on the same speed twice, and an earlier tick's rates landing on top of a
100
- // later tick's.
74
+ // One tick at a time: the rates await torrent statistics per source, so a
75
+ // slow answer would otherwise let the next tick in behind it and take the
76
+ // same reading twice.
101
77
  if (this.#tickRunning === true) {
102
78
  return;
103
79
  }
@@ -106,113 +82,37 @@ export class QualityController {
106
82
  // Taken whatever the encoder is: the torrent's price is charged against
107
83
  // this rate on every host, not only on the ones that re-encode.
108
84
  await this.#host.sampleDownloadRates();
109
- if (this.#host.videoEncoder?.kind !== "software") {
110
- return;
111
- }
112
- await this.#realtimeBudgetPass();
113
85
  } finally {
114
86
  this.#tickRunning = false;
115
87
  }
116
88
  }
117
89
 
118
- async #realtimeBudgetPass() {
119
- const now = Date.now();
120
- for (const session of this.#host.outputs.values()) {
121
- // What this file costs to decode is learned from EVERY encoding session,
122
- // before any of the budget's own conditions are consulted. Those exist to
123
- // decide whether to step the quality, and they exclude most of what is
124
- // worth measuring: a rung already at the foot of its ladder has nowhere
125
- // to step, and a 240p variant IS its whole ladder — which is exactly the
126
- // rung the field measured at 0.95x on 2026-08-15, learning nothing from
127
- // three minutes of it because the loop had already skipped the session as
128
- // un-actionable.
129
- await this.#host.encodeCost.learnFrom(session);
130
- if (
131
- !session ||
132
- !this.#host.isLive(session) ||
133
- this.#host.runStateOf(session) === ENCODE_RUN_STATE.ENDED_FAILED ||
134
- // Nothing is encoding, so there is no speed to judge. A variant the
135
- // viewer has switched away from is left in exactly this state, and its
136
- // last recorded speed would otherwise buy it a step — which restarts
137
- // the encoder it was just stopped for.
138
- this.#host.liveRunsOf(session).length === 0 ||
139
- // A soundtrack published on its own carries no picture, so no quality
140
- // step is its to make; its price is learned above and that is all.
141
- session.spec.carries === "audio-only"
142
- ) {
143
- continue;
144
- }
145
- // A viewer's link is judged on each of their reports
146
- // (`noteViewerReported`), not here: nothing about it changes between two
147
- // reports, so a timer would only read the same statement again.
148
- if (await this.#checkEncoderBudget(session, now)) {
149
- continue;
150
- }
151
- await this.#checkStepUp(session, now);
152
- }
90
+ /**
91
+ * This output's processing speed now, over its run's own working time, or
92
+ * null when no run has measured one. Copying and re-encoding alike; a copy
93
+ * has no encoder a smaller picture would relieve, which is decided where
94
+ * this is asked.
95
+ *
96
+ * @param {HlsSession} session
97
+ * @returns {number | null}
98
+ */
99
+ #processingSpeedOf(session) {
100
+ return this.#host.encodeCost.latestSpeedReadingOf(session)?.speed ?? null;
153
101
  }
154
102
 
155
103
  /**
156
- * The encoder-speed check for one session: sustained sub-realtime, and the
157
- * encoder — not a download-starved input — is the limit.
104
+ * How long another output of the mode on this viewer's screen takes to be
105
+ * ready here, in seconds: as observed on this host (roadmap item 97, step
106
+ * 14), and otherwise this host's time to a first segment. Null when neither
107
+ * has been measured.
158
108
  *
159
109
  * @param {HlsSession} session
160
- * @param {number} now
161
- * @returns {Promise<boolean>} True when a step was asked for this tick.
110
+ * @returns {number | null}
162
111
  */
163
- async #checkEncoderBudget(session, now) {
164
- if (!session.spec.transcodesVideo) {
165
- // A copy has no encoder to make cheaper. Whatever the machine is short
166
- // of, moving this viewer to a RE-ENCODED rung costs it more, not less —
167
- // so the copy path's only lever is the viewer's link, above.
168
- return false;
169
- }
170
- const speed = this.#host.encodeCost.recentSpeedOf(session, now, BUDGET_CHECK_INTERVAL_MS * 2);
171
- if (speed === null) {
172
- return false; // no measurement yet
173
- }
174
- if (speed >= BUDGET_SPEED_OK) {
175
- qualityStateOf(session).budgetSlowSince = 0; // recovered — reset the slow window
176
- return false;
177
- }
178
- if (speed >= BUDGET_SPEED_SLOW) {
179
- return false; // in the hysteresis band; neither slow nor ok
180
- }
181
- if (qualityStateOf(session).budgetSlowSince === 0) {
182
- qualityStateOf(session).budgetSlowSince = now;
183
- return false;
184
- }
185
- if (now - qualityStateOf(session).budgetSlowSince < BUDGET_SUSTAINED_MS) {
186
- return false; // not sustained yet
187
- }
188
- const bound = await this.classifyTranscodeBound(session);
189
- if (bound === "download") {
190
- logger.info(
191
- `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
192
- `"${session.file.name}"; not stepping down (torrent is the bottleneck)`
193
- );
194
- qualityStateOf(session).budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
195
- return false;
196
- }
197
- qualityStateOf(session).budgetSlowSince = 0;
198
- qualityStateOf(session).budgetUpSince = 0;
199
- const boundLabel = bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
200
- // The machine is the output's reason, so every viewer with it on screen is
201
- // asked — each to the rung their own picture bounds a re-encode by, where
202
- // that is lower than the next rung down (roadmap item 98).
203
- const reasonText = `${boundLabel} speed=${speed.toFixed(2)}x`;
204
- const base = this.#host.outputs.pictureOf(session);
205
- const watching = this.#host.presentOn(session).filter((consumerId) => this.#onScreenHere(session, consumerId));
206
- if (watching.length === 0) {
207
- return this.#askLowerHeight(session, reasonText);
208
- }
209
- let asked = false;
210
- for (const consumerId of watching) {
211
- if (this.#askLowerHeight(session, reasonText, [consumerId], { cap: this.#visibleCapOf(base, consumerId) })) {
212
- asked = true;
213
- }
214
- }
215
- return asked;
112
+ #secondsToReady(session) {
113
+ const observedMs = this.#host.observedPreparationMs?.(session) ?? null;
114
+ const expectedMs = Number.isFinite(observedMs) ? observedMs : (this.#host.expectedFirstSegmentMs?.() ?? null);
115
+ return Number.isFinite(expectedMs) && expectedMs >= 0 ? expectedMs / 1000 : null;
216
116
  }
217
117
 
218
118
  /**
@@ -323,6 +223,7 @@ export class QualityController {
323
223
  }
324
224
  const now = Date.now();
325
225
  const asked = [];
226
+ const affirmed = [];
326
227
  const playing = [];
327
228
  for (const consumerId of this.#host.consumersOn(base)) {
328
229
  if (this.#host.qualityModeOf(base, consumerId) !== "auto") {
@@ -340,15 +241,20 @@ export class QualityController {
340
241
  continue;
341
242
  }
342
243
  const standing = this.#host.standingAskOf(base, consumerId);
343
- if (standing && standing.height === height && now - standing.at < QUALITY_ASK_TTL_MS) {
244
+ if (standing && standing.height === height) {
245
+ this.#affirmed.add(consumerId);
246
+ affirmed.push(consumerId);
344
247
  continue;
345
248
  }
346
249
  if (this.#host.askQualityOf(base, consumerId, height, reasonText, now, urgent)) {
250
+ this.#affirmed.add(consumerId);
347
251
  asked.push(consumerId);
348
252
  }
349
253
  }
350
254
  if (asked.length === 0) {
351
- return false;
255
+ // Asked already and asked again: true, so a caller does not go on to say
256
+ // that nothing could be prepared; nothing new is written.
257
+ return affirmed.length > 0;
352
258
  }
353
259
  logger.info(
354
260
  `[budget] transcode ${base.id} asks the player to move ${playing.join("p/")}p → ${height}p${urgent ? " (urgent)" : ""}: ${reasonText} ` +
@@ -377,99 +283,73 @@ export class QualityController {
377
283
  * @param {number} now
378
284
  * @returns {Promise<void>}
379
285
  */
380
- async #checkStepUp(session, now) {
381
- const base = this.#host.outputs.pictureOf(session);
382
- const current = this.#host.outputs.variantHeightOf(session);
383
- // What the machine and the link would have to look like for a step up, held
384
- // for a window four times the one a step DOWN needs. Anything that fails
385
- // resets it, so the window measures an unbroken stretch.
386
- const spare = this.#roomToSpare(session, now);
387
- // A step up being prepared for a viewer who no longer has room is let go:
388
- // the conditions it was asked under have gone back (roadmap item 98).
389
- for (const consumerId of this.#host.presentOn(session)) {
390
- if (!spare.includes(consumerId)) {
391
- this.#dropUpAsk(session, consumerId, "the room it was asked for has gone");
392
- }
393
- }
394
- if (spare.length === 0) {
395
- qualityStateOf(session).budgetUpSince = 0;
286
+ async #checkStepUp(session, consumerId) {
287
+ if (!this.#roomToSpare(session, consumerId)) {
288
+ // A step up being prepared for a viewer who no longer has room is let go:
289
+ // the conditions it was asked under have gone back (roadmap item 98).
290
+ this.#dropUpAsk(session, consumerId, "the room it was asked for has gone");
396
291
  return;
397
292
  }
398
- if (qualityStateOf(session).budgetUpSince === 0) {
399
- qualityStateOf(session).budgetUpSince = now;
293
+ const base = this.#host.outputs.pictureOf(session);
294
+ const current = this.#host.outputs.variantHeightOf(session);
295
+ const reasonText = `the machine and the link carry ${current}p with room to spare`;
296
+ const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "up", reasonText);
297
+ if (move.started) {
400
298
  return;
401
299
  }
402
- if (now - qualityStateOf(session).budgetUpSince < BUDGET_UP_SUSTAINED_MS) {
300
+ // Refused because the MACHINE holds no more encoders: a higher height costs
301
+ // more than the limit that was just refused, so it is not asked for.
302
+ if (move.noPlace) {
303
+ logger.info(`[budget] transcode ${session.id} no step up for ${consumerId}: ${move.reason}`);
403
304
  return;
404
305
  }
405
- qualityStateOf(session).budgetUpSince = 0;
406
- const reasonText =
407
- `the machine and the link have carried ${current}p for ` +
408
- `${Math.round(BUDGET_UP_SUSTAINED_MS / 1000)}s with room to spare`;
409
- const atTheirHighestLimit = [];
410
- for (const consumerId of spare) {
411
- const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "up", reasonText);
412
- if (move.started) {
413
- continue;
414
- }
415
- // Refused because the MACHINE holds no more encoders: a higher height
416
- // costs more than the limit that was just refused, so it is not asked for.
417
- if (move.noPlace) {
418
- logger.info(`[budget] transcode ${session.id} no step up for ${consumerId}: ${move.reason}`);
419
- continue;
420
- }
421
- atTheirHighestLimit.push(consumerId);
422
- }
423
306
  // One rung at a time: the lowest height above the one on screen, never
424
- // above the source (upscaling invents detail and costs more than the
425
- // source itself). A second step follows a second unbroken window.
307
+ // above the source (upscaling invents detail and costs more than the source
308
+ // itself). A rung this host has been measured failing at is not offered
309
+ // (`QualityOffer`), so the step back up cannot return to it.
426
310
  const higher = this.#host.qualityOffer.nextHeightUp(base, current);
427
311
  if (higher === undefined) {
428
312
  return;
429
313
  }
430
- // Only the viewers whose OWN link admits the next rung, and whose picture on
431
- // screen is not already served by the rung they are on: a re-encode is
432
- // never made taller than the picture they see (roadmap item 98). The copy
433
- // of the source is not a re-encode, and going back to it is not bounded.
434
- // Another viewer's fast link or large screen says nothing about theirs.
314
+ // Only where their OWN link admits the next rung, and the picture on their
315
+ // screen is not already served by the rung they are on: a re-encode is never
316
+ // made taller than the picture they see (roadmap item 98). The copy of the
317
+ // source is not a re-encode, and going back to it is not bounded.
435
318
  const copied = this.#host.qualityOffer.copiedHeightOf(base);
436
- const carriers = atTheirHighestLimit.filter((consumerId) =>
437
- this.#linkAdmitsHeight(base, session, consumerId, higher) &&
438
- (higher === copied || this.#visibleCapOf(base, consumerId) === null || higher <= this.#visibleCapOf(base, consumerId))
439
- );
440
- if (carriers.length > 0) {
441
- this.#askQualityHeight(base, higher, reasonText, carriers);
319
+ const cap = this.#visibleCapOf(base, consumerId);
320
+ if (this.#linkAdmitsHeight(base, session, consumerId, higher) &&
321
+ (higher === copied || cap === null || higher <= cap)) {
322
+ this.#askQualityHeight(base, higher, reasonText, [consumerId]);
442
323
  }
443
324
  }
444
325
 
445
326
  /**
446
- * The viewers who have this session on screen with room to spare: the
447
- * encoder ahead of realtime and the torrent not the limit — which are the
448
- * OUTPUT's and hold for all of them or none — and, for each viewer on their
449
- * own, a buffer that is not draining and no move of theirs already being
450
- * prepared.
327
+ * Whether this viewer has room for more, every term measured: the picture on
328
+ * their screen is made at least at realtime over its run's own working time
329
+ * (a copy is not limited by an encoder), their buffer is not draining, it
330
+ * holds at least the time another output takes to be ready here, and no move
331
+ * of theirs is already being prepared.
451
332
  *
452
333
  * @param {HlsSession} session
453
- * @param {number} now
454
- * @returns {string[]} Empty when nobody has room.
334
+ * @param {string} consumerId
335
+ * @returns {boolean}
455
336
  */
456
- #roomToSpare(session, now) {
337
+ #roomToSpare(session, consumerId) {
338
+ if (!this.#onScreenHere(session, consumerId) || this.#host.sameHeightSwitchPending(consumerId)) {
339
+ return false;
340
+ }
457
341
  if (session.spec.transcodesVideo) {
458
- const speed = this.#host.encodeCost.recentSpeedOf(session, now, BUDGET_CHECK_INTERVAL_MS * 2);
459
- if (speed === null || speed < BUDGET_SPEED_OK) {
460
- return [];
342
+ const speed = this.#processingSpeedOf(session);
343
+ if (speed === null || speed < 1) {
344
+ return false;
461
345
  }
462
346
  }
463
- if (qualityStateOf(session).budgetSlowSince !== 0) {
464
- return [];
347
+ const buffer = this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec);
348
+ if (!buffer || !Number.isFinite(buffer.slope) || buffer.slope < 0) {
349
+ return false;
465
350
  }
466
- // A buffer that drains over a whole segment's period is being spent faster
467
- // than it is filled: there is no room for more.
468
- return this.#host.presentOn(session).filter((consumerId) =>
469
- !((this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec)?.slope ?? 0) < 0) &&
470
- this.#onScreenHere(session, consumerId) &&
471
- !this.#host.sameHeightSwitchPending(consumerId)
472
- );
351
+ const secondsToReady = this.#secondsToReady(session);
352
+ return secondsToReady !== null && (buffer.bufferedSec ?? 0) >= secondsToReady;
473
353
  }
474
354
 
475
355
  /**
@@ -477,7 +357,7 @@ export class QualityController {
477
357
  * may have produced yet.
478
358
  *
479
359
  * Nothing measured their link: no ground to refuse, the same silence that
480
- * stops `#checkViewerLink` from acting on them.
360
+ * stops `#checkSupply` from blaming their link.
481
361
  *
482
362
  * @param {HlsSession} base
483
363
  * @param {HlsSession} session
@@ -523,91 +403,120 @@ export class QualityController {
523
403
  return;
524
404
  }
525
405
  this.#judging.add(consumerId);
406
+ this.#affirmed.delete(consumerId);
407
+ const standingBefore = this.#host.standingAskOf(base, consumerId);
526
408
  try {
527
- const now = Date.now();
528
- if (this.#host.videoEncoder?.kind === "software" &&
529
- (await this.#checkViewerLink(session, consumerId, now))) {
530
- return;
409
+ await this.#judge(session, consumerId);
410
+ // A request this judgement did not ask for again no longer has the
411
+ // conditions it was asked under: their buffer stopped draining, the room
412
+ // for a step up went, or the smaller picture is no longer smaller. It is
413
+ // let go here, and their page drops the move it was preparing.
414
+ const standing = this.#host.standingAskOf(base, consumerId);
415
+ // The same request, not one made meanwhile: compared by what it is, since
416
+ // it is read as a copy.
417
+ const unchanged = standing && standingBefore &&
418
+ standing.height === standingBefore.height && standing.at === standingBefore.at;
419
+ if (unchanged && !this.#affirmed.has(consumerId)) {
420
+ this.#host.dropAskOf(base, consumerId);
421
+ logger.info(
422
+ `[budget] transcode ${base.id} no longer asks ${consumerId} for ${standing.height}p: ` +
423
+ `the conditions it was asked under (${standing.reason}) no longer hold`
424
+ );
531
425
  }
532
- this.#checkVisiblePicture(session, consumerId, now);
533
426
  } finally {
427
+ this.#affirmed.delete(consumerId);
534
428
  this.#judging.delete(consumerId);
535
429
  }
536
430
  }
537
431
 
538
432
  /**
539
- * This viewer's link against the stream they are given.
433
+ * One judgement of one viewer's report, in order: what they are given against
434
+ * what keeps up, the picture they see, and the room for more.
540
435
  *
541
- * THE CONDITION, both halves measured: their link does not carry the stream
542
- * (the reading times the safety margin is below the observed bitrate), and
543
- * their buffer, on its present trend, would run dry before another output
544
- * could close the piece they need (`drain-threat.js`). A buffer that falls
545
- * without that threat moves nothing.
546
- *
547
- * THE LEVERS, in order: another limit of the height on their screen (roadmap
548
- * item 97, step 12), then a lower height, asked of their player as URGENT —
549
- * their page switches as soon as the rung is ready, without waiting for a
550
- * cushion that is shrinking. Where neither can be prepared they stay where
551
- * they are, with no message: what is on screen goes on being delivered, and
552
- * a buffer that runs dry is filled again before the picture moves.
436
+ * @param {HlsSession} session - The output on their screen.
437
+ * @param {string} consumerId
438
+ * @returns {Promise<void>}
439
+ */
440
+ async #judge(session, consumerId) {
441
+ if (this.#host.videoEncoder?.kind === "software" &&
442
+ (await this.#checkSupply(session, consumerId))) {
443
+ return;
444
+ }
445
+ if (this.#checkVisiblePicture(session, consumerId)) {
446
+ return;
447
+ }
448
+ if (this.#host.videoEncoder?.kind === "software") {
449
+ await this.#checkStepUp(session, consumerId);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Whether this viewer's buffer can keep up with what they are given.
455
+ *
456
+ * THE CONDITION, every term measured: their buffer, on its present trend,
457
+ * would run dry before another output could close the piece they need
458
+ * (`drain-threat.js`), AND a smaller output removes the reason — their link
459
+ * carries less than the stream they are given, or this machine makes the
460
+ * picture slower than realtime over its run's own working time. A buffer
461
+ * that falls without that threat moves nothing; a threat neither reason
462
+ * explains (the swarm is short) is not answered with a smaller picture, which
463
+ * reads the same input.
464
+ *
465
+ * THE LEVERS, in order: for the link, another limit of the height on their
466
+ * screen (roadmap item 97, step 12), then a lower height; for the machine, a
467
+ * lower height. A lower height is asked of their player as URGENT — their
468
+ * page switches as soon as the rung is ready, without waiting for a cushion
469
+ * that is shrinking. Where nothing can be prepared they stay where they are,
470
+ * with no message.
553
471
  *
554
472
  * @param {HlsSession} session - The output on their screen.
555
473
  * @param {string} consumerId
556
- * @param {number} now
557
474
  * @returns {Promise<boolean>} True when a move was started or asked for.
558
475
  */
559
- async #checkViewerLink(session, consumerId, now) {
476
+ async #checkSupply(session, consumerId) {
560
477
  if (!this.#onScreenHere(session, consumerId)) {
561
478
  return false;
562
479
  }
563
- const report = this.#host.linkReportOf(session, consumerId);
564
- if (!report) {
565
- return false; // their link has not been measured: no ground to move them
566
- }
567
- const observed = await this.observedStreamMbps(session);
568
- if (observed === null) {
569
- return false; // not enough produced material to compare against
570
- }
571
480
  const buffer = this.#host.bufferOf(session, consumerId, this.#host.segmentDurationSec);
572
- // How long another output of the mode on their screen has been seen taking
573
- // to be ready here (roadmap item 97, step 14), and otherwise this host's
574
- // time to a first segment. The observation refines WHEN a move is started;
575
- // what the move may be — admission, the link, the viewer's mode — is
576
- // decided below exactly as without it.
577
- const observedMs = this.#host.observedPreparationMs?.(session) ?? null;
578
- const expectedMs = Number.isFinite(observedMs) ? observedMs : (this.#host.expectedFirstSegmentMs?.() ?? null);
579
481
  const { threat, secondsToEmpty } = drainThreat({
580
482
  bufferedSec: buffer?.bufferedSec ?? 0,
581
483
  slope: buffer?.slope ?? null,
582
484
  reportGapSec: buffer?.reportGapSec ?? 0,
583
- secondsToReady: Number.isFinite(expectedMs) && expectedMs >= 0 ? expectedMs / 1000 : null
485
+ secondsToReady: this.#secondsToReady(session)
584
486
  });
585
- const carries = report.linkMbps * LINK_SAFETY >= observed;
586
- if (carries && !threat) {
487
+ if (!threat) {
587
488
  return false;
588
489
  }
589
490
  // Whatever else happens, a step UP prepared for them is not what they need.
590
- this.#dropUpAsk(session, consumerId, "their buffer is draining or their link does not carry the stream");
491
+ this.#dropUpAsk(session, consumerId, "their buffer is draining");
591
492
  if (this.#host.sameHeightSwitchDirection(consumerId) === "up") {
592
- this.#host.cancelSameHeightSwitch(consumerId, "their buffer is draining or their link does not carry the stream");
493
+ this.#host.cancelSameHeightSwitch(consumerId, "their buffer is draining");
593
494
  }
594
- if (carries || !threat) {
495
+ const report = this.#host.linkReportOf(session, consumerId);
496
+ const observed = report ? await this.observedStreamMbps(session) : null;
497
+ const linkShort = report !== null && report !== undefined && observed !== null && report.linkMbps < observed;
498
+ const speed = session.spec.transcodesVideo ? this.#processingSpeedOf(session) : null;
499
+ const machineShort = speed !== null && speed < 1;
500
+ if (!linkShort && !machineShort) {
595
501
  return false;
596
502
  }
597
503
  if (this.#host.sameHeightSwitchPending(consumerId)) {
598
504
  return false; // a move down is already being prepared for them
599
505
  }
600
506
  const reasonText =
601
- `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps ` +
507
+ (linkShort ? `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps ` : "") +
508
+ (machineShort ? `CPU-bound speed=${speed.toFixed(2)}x ` : "") +
602
509
  `buffer=${(buffer?.bufferedSec ?? 0).toFixed(1)}s empty in ${secondsToEmpty === null ? "?" : secondsToEmpty.toFixed(1)}s ` +
603
510
  `for ${consumerId}`;
604
- const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "down", `viewer-link-bound ${reasonText}`);
605
- if (move.started) {
606
- return true;
511
+ if (linkShort) {
512
+ const move = await this.#host.prepareSameHeightSwitch(session, consumerId, "down", `viewer-link-bound ${reasonText}`);
513
+ if (move.started) {
514
+ return true;
515
+ }
516
+ logger.info(`[budget] transcode ${session.id} ${reasonText}: no lower limit to move to (${move.reason})`);
607
517
  }
608
- logger.info(`[budget] transcode ${session.id} ${reasonText}: no lower limit to move to (${move.reason})`);
609
518
  const base = this.#host.outputs.pictureOf(session);
610
- if (this.#askLowerHeight(session, `viewer-link-bound ${reasonText}`, [consumerId], {
519
+ if (this.#askLowerHeight(session, `${linkShort ? "viewer-link-bound" : "machine-bound"} ${reasonText}`, [consumerId], {
611
520
  urgent: true,
612
521
  cap: this.#visibleCapOf(base, consumerId)
613
522
  })) {
@@ -640,17 +549,16 @@ export class QualityController {
640
549
  *
641
550
  * @param {HlsSession} session - The output on their screen.
642
551
  * @param {string} consumerId
643
- * @param {number} now
644
- * @returns {void}
552
+ * @returns {boolean} True when a move was asked for.
645
553
  */
646
- #checkVisiblePicture(session, consumerId, now) {
554
+ #checkVisiblePicture(session, consumerId) {
647
555
  if (!session.spec.transcodesVideo || !this.#onScreenHere(session, consumerId)) {
648
- return;
556
+ return false;
649
557
  }
650
558
  const base = this.#host.outputs.pictureOf(session);
651
559
  const cap = this.#visibleCapOf(base, consumerId);
652
560
  if (cap === null || !this.#host.outputs.publishesVariants(base)) {
653
- return;
561
+ return false;
654
562
  }
655
563
  const current = this.#host.outputs.variantHeightOf(session);
656
564
  const offered = this.#host.qualityOffer.offeredHeights(base);
@@ -658,19 +566,12 @@ export class QualityController {
658
566
  const target = this.#host.outputs.splicableHeights(base)
659
567
  .find((height) => height <= cap && offered.includes(height));
660
568
  if (target === undefined || !this.#host.heightReadyFor(base, consumerId, target)) {
661
- return;
569
+ return false;
662
570
  }
663
571
  this.#askQualityHeight(base, target, `the picture they see (${cap}p) is smaller than ${current}p`, [consumerId]);
664
- return;
665
- }
666
- if (current < cap) {
667
- const higher = this.#host.qualityOffer.nextHeightUp(base, current);
668
- const room = this.#roomToSpare(session, now).includes(consumerId);
669
- if (higher === undefined || higher > cap || !room || !this.#linkAdmitsHeight(base, session, consumerId, higher)) {
670
- return;
671
- }
672
- this.#askQualityHeight(base, higher, `the picture they see (${cap}p) is larger than ${current}p`, [consumerId]);
572
+ return true;
673
573
  }
574
+ return false;
674
575
  }
675
576
 
676
577
  /**
@@ -724,112 +625,42 @@ export class QualityController {
724
625
  }
725
626
 
726
627
  /**
727
- * Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
728
- * segment files (the newest file may still be being written and is
729
- * excluded). Transcode sessions only — their segment grid is uniform, so
730
- * bytes / (count × segDur) is exact. Returns null when there is not enough
731
- * material to measure.
628
+ * Observed produced bitrate (Mbit/s): every finished piece of this output,
629
+ * its bytes over its own length on the cut table. A published piece is
630
+ * closed, so none is excluded, and no chosen number of recent pieces stands
631
+ * in for the stream. Null while nothing is finished.
732
632
  *
733
633
  * @param {HlsSession} session
734
634
  * @returns {Promise<number | null>}
735
635
  */
736
636
  async observedStreamMbps(session) {
737
- let names;
738
- try {
739
- names = this.#host.producedNumbers(session).map((index) => session.segmentFormat.segmentFileName(index));
740
- } catch {
741
- return null;
742
- }
743
- const indices = [];
744
- for (const name of names) {
745
- const index = session.segmentFormat.segmentIndexFromName(name);
746
- if (index >= 0) {
747
- indices.push(index);
748
- }
749
- }
750
- if (indices.length < 3) {
751
- return null; // need ≥2 completed segments after dropping the newest
752
- }
753
- indices.sort((a, b) => a - b);
754
- const completed = indices.slice(0, -1).slice(-LINK_OBSERVED_SEGMENTS);
637
+ // Every finished piece, each over its own length on the output's cut
638
+ // table: a copy is cut at the source's keyframes, so its pieces are not
639
+ // one length. The store knows their sizes; nothing is read from the disk.
640
+ const sizes = this.#host.segmentPaths.sizesOf(session.outputKey ?? "");
755
641
  let bytes = 0;
756
- try {
757
- for (const index of completed) {
758
- const segmentPath = this.#host.segmentPaths.pathOf(session.outputKey ?? "", index);
759
- if (!segmentPath) {
760
- break;
761
- }
762
- const st = await stat(segmentPath);
763
- bytes += st.size;
642
+ let seconds = 0;
643
+ for (const [index, size] of sizes) {
644
+ const length = session.timeline
645
+ ? session.timeline.publishedStartOf(index + 1) - session.timeline.publishedStartOf(index)
646
+ : this.#host.segmentDurationSec;
647
+ if (size > 0 && length > 0) {
648
+ bytes += size;
649
+ seconds += length;
764
650
  }
765
- } catch {
766
- return null; // a segment vanished mid-measure (seek-restart cleanup)
767
- }
768
- return (bytes * 8) / (completed.length * this.#host.segmentDurationSec) / 1e6;
769
- }
770
-
771
- /**
772
- * Decide whether a sustained sub-realtime transcode is limited by the encoder
773
- * (CPU) or by a download-starved input. Compares the torrent's download rate
774
- * with the source's average byte rate; a fully-downloaded file can never be
775
- * download-bound. Returns "cpu" | "download" | "unknown" ("unknown" is treated
776
- * as CPU by the caller — the common case, logged as such).
777
- *
778
- * @param {HlsSession} session
779
- * @returns {Promise<"cpu" | "download" | "unknown">}
780
- */
781
- async classifyTranscodeBound(session) {
782
- if (!this.#host.getSourceStats) {
783
- return "unknown";
784
- }
785
- let stats;
786
- try {
787
- stats = await this.#host.getSourceStats(session.file.sourceKey, session.file.fileIndex);
788
- } catch {
789
- return "unknown";
790
- }
791
- if (!stats) {
792
- return "unknown";
793
- }
794
- // A fully (or almost fully) downloaded file cannot be download-bound.
795
- if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
796
- return "cpu";
797
- }
798
- const duration = Number.isFinite(session.file.durationSeconds) ? session.file.durationSeconds : 0;
799
- const length = Number.isFinite(stats.fileLength) && stats.fileLength > 0 ? stats.fileLength : 0;
800
- const downloadSpeed = Number.isFinite(stats.downloadSpeed) ? stats.downloadSpeed : 0;
801
- if (duration <= 0 || length <= 0) {
802
- return "unknown"; // cannot compute the source byte rate
803
651
  }
804
- const sourceByteRate = length / duration;
805
- return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
806
- }
807
-
808
- /**
809
- * An encoder is about to start on this output.
810
- *
811
- * Any start resets the cumulative `speed` ffmpeg reports, so both of this
812
- * budget's windows over it start again: the pair of readings speed is learned
813
- * from, and the slow window — otherwise warm-up right after a seek reads as
814
- * sustained sub-realtime and triggers a premature step down.
815
- *
816
- * @param {object} output
817
- * @returns {void}
818
- */
819
- noteRunStarting(output) {
820
- const state = qualityStateOf(output);
821
- state.learnSample = null;
822
- state.budgetSlowSince = 0;
652
+ return seconds > 0 ? (bytes * 8) / seconds / 1e6 : null;
823
653
  }
824
654
 
825
655
  /**
826
656
  * The height this proxy is asking the player to move to, or 0.
827
657
  *
828
658
  * Cleared the moment the viewer is on it — the request has been answered —
829
- * and dropped when it runs out, which is the only sign this side ever gets
830
- * that a player could not or would not follow it. A browser on a manual pick
831
- * ignores every request by design, so an unanswered one is not an error; it
832
- * is said once and let go, rather than repeated for the rest of the film.
659
+ * and let go by the judgement of their next report that does not ask for it
660
+ * again (`noteViewerReported`): a request stands for as long as the
661
+ * conditions it was asked under hold, not for a chosen time. A player that
662
+ * cannot follow it reads it in every progress answer and does nothing, which
663
+ * costs nothing; it is written to the log once, when it is made.
833
664
  *
834
665
  * @param {HlsSession} named - The session the browser addressed.
835
666
  * @returns {number}
@@ -850,14 +681,6 @@ export class QualityController {
850
681
  this.#host.dropAskOf(base, consumerId); // this viewer is there; nothing left to ask for
851
682
  return 0;
852
683
  }
853
- if (Date.now() - ask.at > QUALITY_ASK_TTL_MS) {
854
- this.#host.dropAskOf(base, consumerId);
855
- logger.info(
856
- `[budget] transcode ${base.id} asked viewer ${consumerId || "unnamed"} for ${ask.height}p and the player stayed where it was ` +
857
- `(${ask.reason}); letting the request go`
858
- );
859
- return 0;
860
- }
861
684
  return ask.height;
862
685
  }
863
686