@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.
- package/AGENTS.md +338 -336
- package/CHANGELOG.md +2319 -2303
- package/docs/encode-architecture.md +55 -19
- package/docs/playback-start-forecast.md +13 -5
- package/docs/storage-architecture.md +6 -0
- package/package.json +1 -1
- package/routes/stream/get.js +38 -2
- package/server.js +748 -745
- package/services/encode/EncodeOrchestrator.js +24 -49
- package/services/encode/EncodePlan.js +5 -3
- package/services/encode/EncodeRun.js +80 -17
- package/services/encode/EncodeRuns.js +1339 -1239
- package/services/encode/Renditions.js +2 -1
- package/services/encode/RunClock.js +87 -0
- package/services/encode/RunProgress.js +1 -6
- package/services/encode/encoder-readings.js +26 -28
- package/services/encode/piece-completeness.js +46 -0
- package/services/encode/quality/EncodeCost.js +1043 -1167
- package/services/encode/quality/OutputQualityState.js +1 -5
- package/services/encode/quality/QualityController.js +213 -390
- package/services/encode/quality/link-budget.js +5 -17
- package/services/encode/run-inputs.js +13 -2
- package/services/server/SegmentServing.js +3 -1
- package/services/server/ViewerRequests.js +2 -7
- package/services/server/wire-outputs.js +12 -7
- package/services/storage/segment-store/SegmentStore.js +1294 -1255
- package/services/torrent/supply-margin.js +7 -5
- package/test/auto-quality-step.test.js +75 -29
- package/test/decode-cost.test.js +1 -1
- package/test/encode-cost.test.js +31 -39
- package/test/encode-orchestrator.test.js +15 -8
- package/test/encode-plan-viewers.test.js +34 -27
- package/test/encode-run.test.js +34 -6
- package/test/encoder-readings.test.js +48 -26
- package/test/helpers/encode-run.js +113 -9
- package/test/link-budget.test.js +4 -5
- package/test/manager-surface.test.js +1 -2
- package/test/orchestrator-wired.test.js +5 -4
- package/test/output-speed.test.js +0 -1
- package/test/piece-completeness.test.js +42 -0
- package/test/quality-offer.test.js +1 -3
- package/test/quality-variants.test.js +1 -1
- package/test/run-progress.test.js +2 -3
- package/test/same-height-switch.test.js +1 -2
- package/test/segment-closed-on-both-branches.test.js +114 -96
- package/test/segment-store.test.js +15 -15
- package/test/stream-route.test.js +28 -0
- package/test/supply-margin.test.js +13 -0
- package/test/two-viewers-one-picture.test.js +1 -1
- 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 {
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
97
|
-
// slow
|
|
98
|
-
//
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
*
|
|
157
|
-
*
|
|
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
|
-
* @
|
|
161
|
-
* @returns {Promise<boolean>} True when a step was asked for this tick.
|
|
110
|
+
* @returns {number | null}
|
|
162
111
|
*/
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
431
|
-
// screen is not already served by the rung they are on: a re-encode is
|
|
432
|
-
//
|
|
433
|
-
//
|
|
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
|
|
437
|
-
|
|
438
|
-
(higher === copied ||
|
|
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
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
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 {
|
|
454
|
-
* @returns {
|
|
334
|
+
* @param {string} consumerId
|
|
335
|
+
* @returns {boolean}
|
|
455
336
|
*/
|
|
456
|
-
#roomToSpare(session,
|
|
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.#
|
|
459
|
-
if (speed === null || speed <
|
|
460
|
-
return
|
|
342
|
+
const speed = this.#processingSpeedOf(session);
|
|
343
|
+
if (speed === null || speed < 1) {
|
|
344
|
+
return false;
|
|
461
345
|
}
|
|
462
346
|
}
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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 `#
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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 #
|
|
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:
|
|
485
|
+
secondsToReady: this.#secondsToReady(session)
|
|
584
486
|
});
|
|
585
|
-
|
|
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
|
|
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
|
|
493
|
+
this.#host.cancelSameHeightSwitch(consumerId, "their buffer is draining");
|
|
593
494
|
}
|
|
594
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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,
|
|
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
|
-
* @
|
|
644
|
-
* @returns {void}
|
|
552
|
+
* @returns {boolean} True when a move was asked for.
|
|
645
553
|
*/
|
|
646
|
-
#checkVisiblePicture(session, consumerId
|
|
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)
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
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
|
|