@torrent-tv/proxy 2.80.13 → 2.80.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/docs/download-architecture.md +42 -0
- package/docs/encode-architecture.md +87 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +64 -53
- package/services/download/SwarmSelection.js +20 -4
- package/services/encode/EncodePlan.js +181 -40
- package/services/encode/SegmentStore.js +743 -718
- package/services/encode/run-costs.js +54 -3
- package/services/hls-session-manager.js +13 -14
- package/services/output/LiveOutputs.js +47 -1
- package/services/output/playlists.js +86 -11
- package/services/output/rates.js +125 -0
- package/services/piece-store/piece-lru.js +9 -1
- package/services/priority/PriorityOrchestrator.js +284 -278
- package/services/supply-margin.js +40 -1
- package/services/torrent-worker/piece-reader.js +9 -4
- package/services/torrent-worker/worker.js +5 -0
- package/services/viewer/Viewer.js +345 -297
- package/test/declared-rates.test.js +116 -0
- package/test/encode-plan.test.js +9 -3
- package/test/move-cost.test.js +138 -0
- package/test/piece-lru.test.js +5 -1
- package/test/supply-margin.test.js +25 -8
|
@@ -53,9 +53,22 @@ export class RunCosts {
|
|
|
53
53
|
* that was never told to stop did not die on command, and one that produced
|
|
54
54
|
* nothing has no first output — and an absent reading is not a zero.
|
|
55
55
|
*
|
|
56
|
-
* @param {{ dyingMs?: number | null, firstOutputMs?: number | null
|
|
56
|
+
* @param {{ dyingMs?: number | null, firstOutputMs?: number | null,
|
|
57
|
+
* livedMs?: number | null }} ended - `livedMs` is how long a run that
|
|
58
|
+
* produced NOTHING was alive, which is a lower bound on the first output.
|
|
57
59
|
*/
|
|
58
60
|
note(ended) {
|
|
61
|
+
// A RUN THAT PRODUCED NOTHING IS A MEASUREMENT TOO — of a lower bound. It
|
|
62
|
+
// says the first output takes at least as long as this run lived, which is
|
|
63
|
+
// a fact and not an estimate, and it is the only reading a thrash can
|
|
64
|
+
// supply: every run in one is killed before it finishes anything.
|
|
65
|
+
//
|
|
66
|
+
// Without it the figure that prices a move could only ever be learned from
|
|
67
|
+
// runs that survived, so the state in which moves are ruinous was exactly
|
|
68
|
+
// the state in which their cost stayed unknown.
|
|
69
|
+
if (!Number.isFinite(ended?.firstOutputMs) && Number.isFinite(ended?.livedMs) && ended.livedMs > 0) {
|
|
70
|
+
RunCosts.#keep(this.#firstOutput, /** @type {number} */ (ended.livedMs));
|
|
71
|
+
}
|
|
59
72
|
if (Number.isFinite(ended?.dyingMs)) {
|
|
60
73
|
RunCosts.#keep(this.#dying, /** @type {number} */ (ended.dyingMs));
|
|
61
74
|
}
|
|
@@ -85,9 +98,47 @@ export class RunCosts {
|
|
|
85
98
|
* @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
|
|
86
99
|
*/
|
|
87
100
|
seconds() {
|
|
101
|
+
const dying = middleOf(this.#dying);
|
|
102
|
+
const first = middleOf(this.#firstOutput);
|
|
88
103
|
return {
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
// UNKNOWN IS NOT ZERO, and for a cost it is not a small number either: it
|
|
105
|
+
// is the figure that makes the act it prices never worth doing. Reported
|
|
106
|
+
// as 0, an unmeasured move was FREE in the plan's arithmetic, so any gain
|
|
107
|
+
// however small justified it — and moving an encoder is irreversible,
|
|
108
|
+
// because the process it kills cannot be un-killed.
|
|
109
|
+
//
|
|
110
|
+
// The blindness was self-sustaining: `#firstOutput` only takes a reading
|
|
111
|
+
// from a run that produced something, and a run killed 0.8 s after
|
|
112
|
+
// starting produces nothing. So a thrash prevented the measurement that
|
|
113
|
+
// would have stopped it. Field 2026-09-08: 39 moves in one session, 24 of
|
|
114
|
+
// them between three adjacent numbers — #58 to #59, #59 to #58, #58 to
|
|
115
|
+
// #60, #60 to #58, six times each — while the picture stood still for
|
|
116
|
+
// 116.7 s.
|
|
117
|
+
//
|
|
118
|
+
// TWO QUESTIONS, NOT ONE, and they take the unknown differently.
|
|
119
|
+
//
|
|
120
|
+
// PLACING an encoder where there is none has no alternative: the film gets
|
|
121
|
+
// made or it does not. So an unmeasured cost must not stand in the way,
|
|
122
|
+
// and the honest figure is what has been measured or nothing.
|
|
123
|
+
//
|
|
124
|
+
// MOVING one has an alternative — leave it alone — and it is
|
|
125
|
+
// irreversible, because the process it kills cannot be un-killed. There
|
|
126
|
+
// an unmeasured cost must not license the act, and `Infinity` is the
|
|
127
|
+
// identity of the comparison that consumes it: "nobody has measured what
|
|
128
|
+
// this costs" and "never worth doing" are the same statement about an
|
|
129
|
+
// action whose price is unknown.
|
|
130
|
+
//
|
|
131
|
+
// Reported as 0 for both, an unmeasured move was FREE in the plan's
|
|
132
|
+
// arithmetic, so a gain of a fraction of a second justified it. And the
|
|
133
|
+
// blindness was self-sustaining: `#firstOutput` takes a reading only from
|
|
134
|
+
// a run that produced something, and every run in a thrash is killed
|
|
135
|
+
// before it finishes anything.
|
|
136
|
+
killCostSec: (dying ?? 0) / 1000,
|
|
137
|
+
firstByteWaitSec: (first ?? 0) / 1000,
|
|
138
|
+
moveCostSec:
|
|
139
|
+
first === null
|
|
140
|
+
? Number.POSITIVE_INFINITY
|
|
141
|
+
: ((dying ?? 0) + first) / 1000,
|
|
91
142
|
samples: Math.min(this.#dying.length, this.#firstOutput.length)
|
|
92
143
|
};
|
|
93
144
|
}
|
|
@@ -1510,7 +1510,11 @@ export class HlsSessionManager {
|
|
|
1510
1510
|
// picture a step belongs to, the steps, the soundtracks, the height a
|
|
1511
1511
|
// session is named by. Read-only over the register above, and the layer the
|
|
1512
1512
|
// quality budget and the serving path both stand on.
|
|
1513
|
-
this.liveOutputs = new LiveOutputs({
|
|
1513
|
+
this.liveOutputs = new LiveOutputs({
|
|
1514
|
+
sessionsById: this.sessionsById,
|
|
1515
|
+
fileLengthOf: (session) => this.#fileLengthByKey.get(session.file.key) ?? 0,
|
|
1516
|
+
largestPieceOf: (address) => this.segmentStore.largestPiece(address)
|
|
1517
|
+
});
|
|
1514
1518
|
// What this host learned last time it ran. Without it every restart shows
|
|
1515
1519
|
// the first viewer a figure with no measurement behind it.
|
|
1516
1520
|
this.#loadHostTimings();
|
|
@@ -3178,7 +3182,7 @@ export class HlsSessionManager {
|
|
|
3178
3182
|
* @param {{ linkMbps: number, bufferedAheadSec: number, consumerId?: string, positionSeconds?: number }} report
|
|
3179
3183
|
* @returns {boolean}
|
|
3180
3184
|
*/
|
|
3181
|
-
recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds, playing }) {
|
|
3185
|
+
recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds, playing, onScreen, inPictureInPicture }) {
|
|
3182
3186
|
const named = this.sessionsById.get(sessionId);
|
|
3183
3187
|
if (!named || named.state === "disposed") {
|
|
3184
3188
|
return false;
|
|
@@ -3196,7 +3200,7 @@ export class HlsSessionManager {
|
|
|
3196
3200
|
const now = Date.now();
|
|
3197
3201
|
this.viewers
|
|
3198
3202
|
.of(session, typeof consumerId === "string" && consumerId.length > 0 ? consumerId : "")
|
|
3199
|
-
.report({ linkMbps, bufferedAheadSec, positionSeconds, playing }, now);
|
|
3203
|
+
.report({ linkMbps, bufferedAheadSec, positionSeconds, playing, onScreen, inPictureInPicture }, now);
|
|
3200
3204
|
// A stale reading must not go on deciding for the viewers still here: a
|
|
3201
3205
|
// report describes a link at a moment, and a viewer who seeked since then
|
|
3202
3206
|
// is somewhere else entirely.
|
|
@@ -8295,13 +8299,6 @@ export class HlsSessionManager {
|
|
|
8295
8299
|
if (!this.liveOutputs.publishesVariants(session)) {
|
|
8296
8300
|
return null;
|
|
8297
8301
|
}
|
|
8298
|
-
const sourceHeight = Number(session.file.height) || 0;
|
|
8299
|
-
// What CAN be spliced, not what is worth offering this second. The live
|
|
8300
|
-
// judgement travels in `offeredHeights` and in every progress report, which
|
|
8301
|
-
// is what the viewer's menu follows; letting it decide the master's
|
|
8302
|
-
// existence made a live session answer 404 to its own published address.
|
|
8303
|
-
const rungs = this.liveOutputs.splicableHeights(session);
|
|
8304
|
-
const sourceWidth = Number(session.file.width) || 0;
|
|
8305
8302
|
// The audio tracks, published once for the whole file rather than muxed
|
|
8306
8303
|
// into every rung. Two things follow from that: the same track is not
|
|
8307
8304
|
// encoded once per rung on a host that struggles to encode it once, and
|
|
@@ -8319,10 +8316,12 @@ export class HlsSessionManager {
|
|
|
8319
8316
|
? this.#audioRenditionsOf(session, this.#audioChoiceOf(session, consumerId).trackIndex)
|
|
8320
8317
|
: [];
|
|
8321
8318
|
return masterPlaylistText({
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8319
|
+
// The shape of the film and the rates it carries, asked of the layer that
|
|
8320
|
+
// holds both. What CAN be spliced, not what is worth offering this second:
|
|
8321
|
+
// the live judgement travels in `offeredHeights` and in every progress
|
|
8322
|
+
// report, and letting it decide the master's existence made a live session
|
|
8323
|
+
// answer 404 to its own published address.
|
|
8324
|
+
...this.liveOutputs.masterFactsOf(session),
|
|
8326
8325
|
renditions,
|
|
8327
8326
|
playlistFileName: PLAYLIST_FILE_NAME
|
|
8328
8327
|
});
|
|
@@ -16,14 +16,22 @@
|
|
|
16
16
|
|
|
17
17
|
import { variantHeightsFor } from "./ladder.js";
|
|
18
18
|
|
|
19
|
+
import { masterRateArgs } from "./rates.js";
|
|
20
|
+
|
|
19
21
|
export class LiveOutputs {
|
|
20
22
|
/**
|
|
21
23
|
* @param {object} params
|
|
22
24
|
* @param {Map<string, object>} params.sessionsById - The live sessions. Read,
|
|
23
25
|
* never written.
|
|
24
26
|
*/
|
|
25
|
-
constructor({ sessionsById }) {
|
|
27
|
+
constructor({ sessionsById, fileLengthOf = () => 0, largestPieceOf = () => ({ index: -1, size: 0 }) }) {
|
|
26
28
|
this.sessionsById = sessionsById;
|
|
29
|
+
// Two facts this layer needs and does not own: how many bytes a source file
|
|
30
|
+
// is, which the torrent reports, and the biggest piece an output has made,
|
|
31
|
+
// which the disk knows. Taken as plain functions, so nothing of either layer
|
|
32
|
+
// is held here.
|
|
33
|
+
this.fileLengthOf = fileLengthOf;
|
|
34
|
+
this.largestPieceOf = largestPieceOf;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -264,4 +272,42 @@ export class LiveOutputs {
|
|
|
264
272
|
}
|
|
265
273
|
return this.splicableHeights(owner).length >= 2;
|
|
266
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Everything the master playlist needs of a session except its soundtracks.
|
|
277
|
+
*
|
|
278
|
+
* The shape of the film — which heights can be spliced, how big the source
|
|
279
|
+
* is, how the pieces are packaged — and the rates it carries. All of it is a
|
|
280
|
+
* fact about the OUTPUT, and it was assembled at the call site in the session
|
|
281
|
+
* manager, which is the file that is being taken apart.
|
|
282
|
+
*
|
|
283
|
+
* The soundtracks are not here on purpose: which of them is marked default is
|
|
284
|
+
* the asking VIEWER'S business, and that belongs to whoever holds viewers.
|
|
285
|
+
*
|
|
286
|
+
* @param {object} session
|
|
287
|
+
* @returns {object}
|
|
288
|
+
*/
|
|
289
|
+
masterFactsOf(session) {
|
|
290
|
+
return {
|
|
291
|
+
playlistVersion: session.segmentFormat.playlistVersion,
|
|
292
|
+
heights: this.splicableHeights(session),
|
|
293
|
+
sourceWidth: Number(session.file?.width) || 0,
|
|
294
|
+
sourceHeight: Number(session.file?.height) || 0,
|
|
295
|
+
...masterRateArgs({
|
|
296
|
+
// FROM WHOEVER KNOWS IT. A source file does not carry its own byte
|
|
297
|
+
// length — the torrent reports it — and reading a field that does not
|
|
298
|
+
// exist is what declared every variant at the floor in 2.80.14.
|
|
299
|
+
fileLength: Number(this.fileLengthOf(session)) || 0,
|
|
300
|
+
durationSeconds: Number(session.file?.durationSeconds) || 0,
|
|
301
|
+
// The probe's own reading of the video stream, which is known from the
|
|
302
|
+
// moment the session exists and covers the gap before the torrent has
|
|
303
|
+
// reported a length.
|
|
304
|
+
streamBitsPerSecond: (Number(session.file?.decode?.megabitsPerSecond) || 0) * 1_000_000,
|
|
305
|
+
largest: this.largestPieceOf(session.outputKey ?? ""),
|
|
306
|
+
boundaries: session.timeline?.published ?? session.timeline?.boundaries ?? null,
|
|
307
|
+
producedHeight: this.producedHeightOf(session),
|
|
308
|
+
capKbps: Number(session.rateCapKbps) || 0
|
|
309
|
+
})
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
267
313
|
}
|
|
@@ -58,19 +58,75 @@ export function escapeAttribute(value) {
|
|
|
58
58
|
return String(value ?? "").replace(/"/g, "'").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// The smallest rate the HLS specification tolerates in a `BANDWIDTH` attribute.
|
|
62
|
+
// It is a floor on what may be DECLARED, not a belief about any content: a file
|
|
63
|
+
// whose rate is not yet known is described by it, and so is a variant so small
|
|
64
|
+
// that the arithmetic below would go under it.
|
|
65
|
+
const MINIMUM_DECLARED_BITS_PER_SECOND = 400_000;
|
|
66
|
+
|
|
61
67
|
/**
|
|
62
|
-
*
|
|
68
|
+
* How many bits of film there are per second of playback, for one height.
|
|
69
|
+
*
|
|
70
|
+
* MEASURED, and the measurement is available before anything is encoded. The
|
|
71
|
+
* comment here used to say the opposite — "a measurement we do not have before
|
|
72
|
+
* encoding starts" — and gave `height * height * 3.2` instead, which for 1080
|
|
73
|
+
* is 3 732 480. Field 2026-09-08: that was declared for a file carrying
|
|
74
|
+
* 18.4 Mbit/s, five times more, and the arithmetic that reads it is not
|
|
75
|
+
* cosmetic.
|
|
76
|
+
*
|
|
77
|
+
* **What reads it.** The browser sizes its cushion in BYTES from this figure
|
|
78
|
+
* times the seconds it is asked to hold, so a figure five times low makes the
|
|
79
|
+
* cushion five times shallow: 120 s asked bought 56 MB, which is 26 s of that
|
|
80
|
+
* film, and the deepest the browser ever held was 17.1 s. And hls.js compares
|
|
81
|
+
* it against its own estimate of the link to decide a level is unplayable —
|
|
82
|
+
* which is why an inflated figure is not the answer either: its own recovery
|
|
83
|
+
* then moves level, and that path does not honour our pinning (measured, 2.59.3).
|
|
84
|
+
*
|
|
85
|
+
* **Where the figure comes from.** Two cases, both exact:
|
|
86
|
+
*
|
|
87
|
+
* - a height that is COPIED carries the source's own bits, so it is the file's
|
|
88
|
+
* length over its duration;
|
|
89
|
+
* - a height that is RE-ENCODED carries what the encoder is capped at, which we
|
|
90
|
+
* impose ourselves.
|
|
63
91
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
92
|
+
* The specification wants the PEAK per segment in `BANDWIDTH` and the average
|
|
93
|
+
* in `AVERAGE-BANDWIDTH`; both are emitted, and the peak is scaled from the
|
|
94
|
+
* average by the ratio the largest produced segment has actually shown — never
|
|
95
|
+
* a chosen multiplier, and equal to the average until a segment exists.
|
|
68
96
|
*
|
|
69
|
-
* @param {
|
|
97
|
+
* @param {object} params
|
|
98
|
+
* @param {number} params.averageBitsPerSecond - The film's own rate, measured.
|
|
99
|
+
* @param {number} params.height
|
|
100
|
+
* @param {number} params.sourceHeight
|
|
101
|
+
* @param {number} [params.capKbps] - What a re-encoded height is capped at.
|
|
70
102
|
* @returns {number}
|
|
71
103
|
*/
|
|
72
|
-
export function
|
|
73
|
-
|
|
104
|
+
export function bitrateFor({ averageBitsPerSecond, height, sourceHeight, capKbps = 0 }) {
|
|
105
|
+
// ONE EXPRESSION, and each term is a measured quantity or the absence of one
|
|
106
|
+
// written as the identity of its operation. There is no case analysis here
|
|
107
|
+
// because there are no cases: the rate a variant carries is what the source
|
|
108
|
+
// carries, shrunk by how much less picture there is, and never more than what
|
|
109
|
+
// we cap the encoder at.
|
|
110
|
+
//
|
|
111
|
+
// - what the source carries: `length * 8 / duration`, exact. Unknown is 0,
|
|
112
|
+
// and 0 falls to the floor below, which is what "not measured" means;
|
|
113
|
+
// - how much less picture: the ratio of pixel counts, and never above 1 —
|
|
114
|
+
// a variant at or above the source's height carries the source's bits.
|
|
115
|
+
// The pixel count is the one term of the relation that is a fact rather
|
|
116
|
+
// than an opinion about the encoder, and it errs HIGH for a small height,
|
|
117
|
+
// which is the safe direction: the cushion is sized generously and the
|
|
118
|
+
// player does not conclude the level is beyond its link;
|
|
119
|
+
// - what we cap it at: exact where we impose one, and `Infinity` where we
|
|
120
|
+
// do not, which is the identity of `min` and so states "no cap" without a
|
|
121
|
+
// branch;
|
|
122
|
+
// - the floor: the smallest figure the specification tolerates, and the
|
|
123
|
+
// identity of `max`.
|
|
124
|
+
const measured = Number(averageBitsPerSecond) > 0 ? Number(averageBitsPerSecond) : 0;
|
|
125
|
+
const shrink = height > 0 && sourceHeight > 0
|
|
126
|
+
? Math.min(1, (height * height) / (sourceHeight * sourceHeight))
|
|
127
|
+
: 1;
|
|
128
|
+
const cap = capKbps > 0 ? capKbps * 1000 : Number.POSITIVE_INFINITY;
|
|
129
|
+
return Math.round(Math.max(MINIMUM_DECLARED_BITS_PER_SECOND, Math.min(measured * shrink, cap)));
|
|
74
130
|
}
|
|
75
131
|
|
|
76
132
|
/**
|
|
@@ -134,7 +190,10 @@ export function mediaPlaylistText({ boundaries, segmentFormat }) {
|
|
|
134
190
|
* sourceWidth: number,
|
|
135
191
|
* sourceHeight: number,
|
|
136
192
|
* renditions?: Array<{ trackIndex: number, name: string, language: string, isDefault: boolean }>,
|
|
137
|
-
* playlistFileName: string
|
|
193
|
+
* playlistFileName: string,
|
|
194
|
+
* averageBitsPerSecond?: number,
|
|
195
|
+
* peakOverAverage?: number,
|
|
196
|
+
* capKbpsFor?: (height: number) => number
|
|
138
197
|
* }} params
|
|
139
198
|
* @returns {string}
|
|
140
199
|
*/
|
|
@@ -144,7 +203,10 @@ export function masterPlaylistText({
|
|
|
144
203
|
sourceWidth,
|
|
145
204
|
sourceHeight,
|
|
146
205
|
renditions = [],
|
|
147
|
-
playlistFileName
|
|
206
|
+
playlistFileName,
|
|
207
|
+
averageBitsPerSecond = 0,
|
|
208
|
+
peakOverAverage = 1,
|
|
209
|
+
capKbpsFor = () => 0
|
|
148
210
|
}) {
|
|
149
211
|
const lines = ["#EXTM3U", `#EXT-X-VERSION:${playlistVersion}`];
|
|
150
212
|
const audioGroup = renditions.length > 0 ? AUDIO_GROUP_ID : "";
|
|
@@ -160,8 +222,21 @@ export function masterPlaylistText({
|
|
|
160
222
|
const width = sourceHeight > 0 && sourceWidth > 0
|
|
161
223
|
? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
|
|
162
224
|
: 0;
|
|
225
|
+
const average = bitrateFor({
|
|
226
|
+
averageBitsPerSecond,
|
|
227
|
+
height,
|
|
228
|
+
sourceHeight,
|
|
229
|
+
capKbps: capKbpsFor(height)
|
|
230
|
+
});
|
|
231
|
+
// BOTH, because they answer different questions and the specification has a
|
|
232
|
+
// name for each: the peak is what a link must carry at the worst moment,
|
|
233
|
+
// the average is what the whole variant costs. hls.js sizes its byte budget
|
|
234
|
+
// from BANDWIDTH, so the peak is what stops the cushion being sized for a
|
|
235
|
+
// quiet stretch and running dry on a loud one — measured on the field file,
|
|
236
|
+
// 17.1 Mbit/s median against 73 Mbit/s at its peak.
|
|
237
|
+
const peak = Math.round(average * Math.max(1, peakOverAverage));
|
|
163
238
|
lines.push(
|
|
164
|
-
`#EXT-X-STREAM-INF:BANDWIDTH=${
|
|
239
|
+
`#EXT-X-STREAM-INF:BANDWIDTH=${peak},AVERAGE-BANDWIDTH=${average}` +
|
|
165
240
|
(width > 0 ? `,RESOLUTION=${width}x${height}` : "") +
|
|
166
241
|
(audioGroup ? `,AUDIO="${audioGroup}"` : "")
|
|
167
242
|
);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How many bits of film there are per second of playback.
|
|
3
|
+
*
|
|
4
|
+
* A fact about the OUTPUT, and the two figures the HLS specification asks for:
|
|
5
|
+
* the average over the whole variant, and the peak one segment reaches. Both
|
|
6
|
+
* are measured — the average from the file's own length and duration, the peak
|
|
7
|
+
* from the biggest piece actually produced and the span it covers.
|
|
8
|
+
*
|
|
9
|
+
* It used to be `height * height * 3.2`, a rule of thumb, justified by a comment
|
|
10
|
+
* saying the measurement was not available before encoding starts. It is: the
|
|
11
|
+
* length and the duration are known when the session is created, and the peak
|
|
12
|
+
* refines itself as pieces appear. Field 2026-09-08: 3.73 Mbit/s declared for a
|
|
13
|
+
* file carrying 18.4, and the browser's cushion is sized in BYTES from that
|
|
14
|
+
* figure — 120 s asked bought 26 s of film.
|
|
15
|
+
*
|
|
16
|
+
* Pure: plain numbers in, plain numbers out. Nothing here knows what a session,
|
|
17
|
+
* a store or a torrent is.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The two rates to declare, from what has been measured of this file.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} params
|
|
24
|
+
* @param {number} params.fileLength - Bytes of the source file, 0 until the
|
|
25
|
+
* torrent has said. Counts every track and every byte of container, so it is
|
|
26
|
+
* the better figure of the two.
|
|
27
|
+
* @param {number} params.durationSeconds
|
|
28
|
+
* @param {number} [params.streamBitsPerSecond] - What a probe read off the
|
|
29
|
+
* video stream, known from session creation. Lower than the whole file's rate
|
|
30
|
+
* because it is one track of it, and used while the length is not known.
|
|
31
|
+
* @param {{ index: number, size: number }} [params.largest] - The biggest piece
|
|
32
|
+
* produced so far, with its number. An index of `-1` means none yet.
|
|
33
|
+
* @param {number[]} [params.boundaries] - Where the file is cut, so the biggest
|
|
34
|
+
* piece's own span is known. Bytes alone cannot give a rate.
|
|
35
|
+
* @returns {{ averageBitsPerSecond: number, peakOverAverage: number }}
|
|
36
|
+
*/
|
|
37
|
+
export function declaredRates({
|
|
38
|
+
fileLength,
|
|
39
|
+
durationSeconds,
|
|
40
|
+
streamBitsPerSecond = 0,
|
|
41
|
+
largest = null,
|
|
42
|
+
boundaries = null
|
|
43
|
+
}) {
|
|
44
|
+
const length = Number(fileLength);
|
|
45
|
+
const duration = Number(durationSeconds);
|
|
46
|
+
// TWO SOURCES, AND THE LARGER OF THEM, because each is measured and each can
|
|
47
|
+
// be absent — and absent is 0, so the larger is whichever was measured.
|
|
48
|
+
//
|
|
49
|
+
// - the whole file's length over its duration: the best figure, since it
|
|
50
|
+
// counts every track and every byte of container. Known only once the
|
|
51
|
+
// torrent has reported the file's size;
|
|
52
|
+
// - what the probe read off the video stream: known when the session is
|
|
53
|
+
// created, and lower, because it is one track of several.
|
|
54
|
+
//
|
|
55
|
+
// The first alone was what shipped in 2.80.14, read from a field that does
|
|
56
|
+
// not exist on a source file. It came back 0 on every session, so every
|
|
57
|
+
// variant was declared at the 400 kbit/s floor — nine times WORSE than the
|
|
58
|
+
// rule of thumb it replaced — and the browser, which sizes its cushion in
|
|
59
|
+
// bytes from this figure, held 0.1 s of film against 120 s asked. The picture
|
|
60
|
+
// stood still for 116.7 s of one viewing. Assuming a field instead of
|
|
61
|
+
// checking it is the whole of that fault.
|
|
62
|
+
const fromLength = length > 0 && duration > 0 ? (length * 8) / duration : 0;
|
|
63
|
+
const averageBitsPerSecond = Math.max(fromLength, Number(streamBitsPerSecond) || 0);
|
|
64
|
+
if (!(averageBitsPerSecond > 0)) {
|
|
65
|
+
return { averageBitsPerSecond: 0, peakOverAverage: 1 };
|
|
66
|
+
}
|
|
67
|
+
const index = Number(largest?.index);
|
|
68
|
+
const size = Number(largest?.size);
|
|
69
|
+
if (!Number.isInteger(index) || index < 0 || !(size > 0) || !Array.isArray(boundaries)) {
|
|
70
|
+
// Nothing has been produced, so the peak is not yet a measured quantity and
|
|
71
|
+
// is declared equal to the average. It rises as soon as one piece exists,
|
|
72
|
+
// and a ratio invented meanwhile would be exactly the fabrication this file
|
|
73
|
+
// replaced.
|
|
74
|
+
return { averageBitsPerSecond, peakOverAverage: 1 };
|
|
75
|
+
}
|
|
76
|
+
const span = Number(boundaries[index + 1]) - Number(boundaries[index]);
|
|
77
|
+
if (!(span > 0)) {
|
|
78
|
+
return { averageBitsPerSecond, peakOverAverage: 1 };
|
|
79
|
+
}
|
|
80
|
+
// Never below one: the peak cannot be under the average, and a piece that
|
|
81
|
+
// happens to be the smallest in a short session must not lower the figure the
|
|
82
|
+
// player sizes its cushion from.
|
|
83
|
+
return {
|
|
84
|
+
averageBitsPerSecond,
|
|
85
|
+
peakOverAverage: Math.max(1, ((size * 8) / span) / averageBitsPerSecond)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The three arguments the master playlist needs to declare its rates.
|
|
91
|
+
*
|
|
92
|
+
* Assembled here rather than at the call site, because all three are facts
|
|
93
|
+
* about the OUTPUT and none of them is a fact about a session: the file's own
|
|
94
|
+
* length and duration, the biggest piece made of it, and the cap imposed on the
|
|
95
|
+
* one height being produced. The caller holds them and passes them as numbers.
|
|
96
|
+
*
|
|
97
|
+
* Only the height being produced has a cap — the other heights do not exist
|
|
98
|
+
* yet, and stating one for them would be a guess about a session nobody has
|
|
99
|
+
* made.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} params
|
|
102
|
+
* @param {number} params.fileLength
|
|
103
|
+
* @param {number} params.durationSeconds
|
|
104
|
+
* @param {number} [params.streamBitsPerSecond]
|
|
105
|
+
* @param {{ index: number, size: number } | null} [params.largest]
|
|
106
|
+
* @param {number[] | null} [params.boundaries]
|
|
107
|
+
* @param {number} [params.producedHeight] - The height this output encodes at.
|
|
108
|
+
* @param {number} [params.capKbps] - What that height is capped at, if it is.
|
|
109
|
+
* @returns {{ averageBitsPerSecond: number, peakOverAverage: number,
|
|
110
|
+
* capKbpsFor: (height: number) => number }}
|
|
111
|
+
*/
|
|
112
|
+
export function masterRateArgs({
|
|
113
|
+
fileLength,
|
|
114
|
+
durationSeconds,
|
|
115
|
+
streamBitsPerSecond = 0,
|
|
116
|
+
largest = null,
|
|
117
|
+
boundaries = null,
|
|
118
|
+
producedHeight = 0,
|
|
119
|
+
capKbps = 0
|
|
120
|
+
}) {
|
|
121
|
+
return {
|
|
122
|
+
...declaredRates({ fileLength, durationSeconds, streamBitsPerSecond, largest, boundaries }),
|
|
123
|
+
capKbpsFor: (height) => (height === producedHeight ? capKbps : 0)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -268,9 +268,16 @@ export class PieceLru {
|
|
|
268
268
|
* a union wider than the capacity cannot be held however the eviction is
|
|
269
269
|
* ordered.
|
|
270
270
|
*
|
|
271
|
-
* @returns {{ readers: number, unionPieces: number, widestPieces: number, capacity: number }}
|
|
271
|
+
* @returns {{ readers: number, names: string[], unionPieces: number, widestPieces: number, capacity: number }}
|
|
272
272
|
*/
|
|
273
273
|
demand() {
|
|
274
|
+
// WHO THEY ARE, not only how many. A "reader" here is whoever declared a
|
|
275
|
+
// range, and on 2026-09-08 the field said `5 reader(s) want 24 piece(s) of
|
|
276
|
+
// 25` on a session with two encoders — because the priority map declares one
|
|
277
|
+
// range per zone and four of its zones were arriving as four readers. The
|
|
278
|
+
// count alone could not say that, and choosing between "narrow the windows"
|
|
279
|
+
// and "raise the allowance" was guesswork until the names were printed.
|
|
280
|
+
const names = [...this.#protected.keys()].map(String).sort();
|
|
274
281
|
const ranges = [...this.#protected.values()]
|
|
275
282
|
.map((range) => ({ from: range.from, to: range.to }))
|
|
276
283
|
.sort((left, right) => left.from - right.from);
|
|
@@ -288,6 +295,7 @@ export class PieceLru {
|
|
|
288
295
|
}
|
|
289
296
|
return {
|
|
290
297
|
readers: ranges.length,
|
|
298
|
+
names,
|
|
291
299
|
unionPieces,
|
|
292
300
|
widestPieces,
|
|
293
301
|
capacity: this.#capacity
|