@torrent-tv/proxy 2.80.10 → 2.80.11
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 +1685 -1675
- package/package.json +50 -51
- package/services/encode/run-command.js +729 -707
- package/services/hls-session-manager.js +10168 -10388
- package/services/orchestrators/EncodeOrchestrator.js +726 -726
- package/services/output/LiveOutputs.js +49 -0
- package/services/priority/PriorityOrchestrator.js +278 -174
- package/test/decode-cost-fit.test.js +57 -0
- package/{test-measured/decode-cost.measured.js → test/decode-cost.test.js} +13 -36
- package/test/keyframe-table-budget.test.js +7 -1
- package/test/one-authority.test.js +220 -105
- package/test/piece-store-slow-disk.test.js +32 -2
- package/test/priority-map-per-output.test.js +211 -0
- package/test/quality-variants.test.js +1222 -1209
- package/test/tracks-begin-together.test.js +32 -13
- package/test/tunnel-renewal.test.js +34 -5
- package/test/two-viewers-one-picture.test.js +374 -347
- package/test/viewer-outputs.test.js +277 -278
- package/test-measured/decode-measurement.measured.js +0 -83
|
@@ -125,6 +125,55 @@ export class LiveOutputs {
|
|
|
125
125
|
return session;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Whether this output is the one that person is CONSUMING.
|
|
130
|
+
*
|
|
131
|
+
* A person holds a record on more outputs than they are watching, and the two
|
|
132
|
+
* are different facts. The picture is where their record lives — the browser
|
|
133
|
+
* addresses it, their chosen soundtrack is written on it, their position is
|
|
134
|
+
* read from it — so they never stop being known to it; but the moment they
|
|
135
|
+
* step down to 480p, the 1080p output is producing for nobody.
|
|
136
|
+
*
|
|
137
|
+
* Which is why this exists and why it is here. It is asked when the priority
|
|
138
|
+
* map of one output is built, and it decides whether an encoder on that output
|
|
139
|
+
* is wanted at all. Without it every output of a film was handed the film's
|
|
140
|
+
* whole map: the plan wanted an encoder on each, the session manager killed
|
|
141
|
+
* the ones it judged unwatched, and the viewer's own move announced itself and
|
|
142
|
+
* started them again — the two authorities of 2026-09-08.
|
|
143
|
+
*
|
|
144
|
+
* The viewer arrives as PLAIN FIELDS. This layer knows the shape of a film — a
|
|
145
|
+
* picture, its steps, its soundtracks — and the viewer layer knows where a
|
|
146
|
+
* person stands; neither holds the other.
|
|
147
|
+
*
|
|
148
|
+
* STATED AS A REFUSAL, and deliberately only where the refusal is certain.
|
|
149
|
+
* Being known to an output is watching it everywhere except one case, because
|
|
150
|
+
* everywhere else a person who stops watching is let go of: a soundtrack
|
|
151
|
+
* nobody chose, a step nobody is on. The exception is the picture itself,
|
|
152
|
+
* which they are never let go of — and which therefore had no way at all of
|
|
153
|
+
* knowing it was producing for nobody.
|
|
154
|
+
*
|
|
155
|
+
* So a step, a soundtrack, a step being warmed, a step whose init has just
|
|
156
|
+
* been asked for: all watched, as before. The picture: watched unless this
|
|
157
|
+
* person is on a step of it.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} session
|
|
160
|
+
* @param {{ activeVariantId?: string | null, warmingVariantId?: string | null }} viewer
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
watchedBy(session, viewer) {
|
|
164
|
+
if (!session || !viewer) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (session.isStep === true || session.audioOnly === true) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
// The picture. A step of it on their screen is a statement that they are
|
|
171
|
+
// not looking at this; a step merely being made ready for them is not, and
|
|
172
|
+
// during a warm-up both are genuinely being produced.
|
|
173
|
+
const step = viewer.activeVariantId ?? null;
|
|
174
|
+
return step === null || step === session.id;
|
|
175
|
+
}
|
|
176
|
+
|
|
128
177
|
/**
|
|
129
178
|
* Which variant a session IS, as a height. Zero encode height means "keep the
|
|
130
179
|
* source", so the source's own height is the answer.
|
|
@@ -1,174 +1,278 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The priority map, built once and handed to everybody who acts on it.
|
|
3
|
-
*
|
|
4
|
-
* One map per film, in seconds of film against a number. It is built from where
|
|
5
|
-
* the viewers are and nothing else, and both of the things that do work — the
|
|
6
|
-
* encoding and the downloading — read it and decide for themselves. They do not
|
|
7
|
-
* talk to each other, and neither of them tells this class anything.
|
|
8
|
-
*
|
|
9
|
-
* **Why it has to be published rather than asked for.** The downloading lives
|
|
10
|
-
* in another thread. Until now it took its orders from the reads themselves:
|
|
11
|
-
* every read declared a window around its own head, so fifteen reads declared
|
|
12
|
-
* fifteen windows on a piece store that holds sixteen pieces. Half of all
|
|
13
|
-
* evictions then took a piece a reader had said it wanted, two thirds of reads
|
|
14
|
-
* came back from disk, and what `/stream` handed out stopped being the file's
|
|
15
|
-
* bytes — twenty-two source-parse errors, a segment the player could not
|
|
16
|
-
* append, and an empty picture for six minutes (field 2026-09-05).
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { emptyMap, mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
|
|
20
|
-
|
|
21
|
-
export class PriorityOrchestrator {
|
|
22
|
-
/** Where the map goes once it is built. @type {(published: object) => void} */
|
|
23
|
-
#publish;
|
|
24
|
-
|
|
25
|
-
/** The last map published per film and file, so an unchanged one is not resent. */
|
|
26
|
-
#last = new Map();
|
|
27
|
-
|
|
28
|
-
/** The last map BUILT per film and file, for whoever reads instead of being
|
|
29
|
-
* handed it. @type {Map<string, import("./PriorityMap.js").PriorityMap>} */
|
|
30
|
-
#maps = new Map();
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* @
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* @
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file The priority map, built once and handed to everybody who acts on it.
|
|
3
|
+
*
|
|
4
|
+
* One map per film, in seconds of film against a number. It is built from where
|
|
5
|
+
* the viewers are and nothing else, and both of the things that do work — the
|
|
6
|
+
* encoding and the downloading — read it and decide for themselves. They do not
|
|
7
|
+
* talk to each other, and neither of them tells this class anything.
|
|
8
|
+
*
|
|
9
|
+
* **Why it has to be published rather than asked for.** The downloading lives
|
|
10
|
+
* in another thread. Until now it took its orders from the reads themselves:
|
|
11
|
+
* every read declared a window around its own head, so fifteen reads declared
|
|
12
|
+
* fifteen windows on a piece store that holds sixteen pieces. Half of all
|
|
13
|
+
* evictions then took a piece a reader had said it wanted, two thirds of reads
|
|
14
|
+
* came back from disk, and what `/stream` handed out stopped being the file's
|
|
15
|
+
* bytes — twenty-two source-parse errors, a segment the player could not
|
|
16
|
+
* append, and an empty picture for six minutes (field 2026-09-05).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { emptyMap, mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
|
|
20
|
+
|
|
21
|
+
export class PriorityOrchestrator {
|
|
22
|
+
/** Where the map goes once it is built. @type {(published: object) => void} */
|
|
23
|
+
#publish;
|
|
24
|
+
|
|
25
|
+
/** The last map published per film and file, so an unchanged one is not resent. */
|
|
26
|
+
#last = new Map();
|
|
27
|
+
|
|
28
|
+
/** The last map BUILT per film and file, for whoever reads instead of being
|
|
29
|
+
* handed it. @type {Map<string, import("./PriorityMap.js").PriorityMap>} */
|
|
30
|
+
#maps = new Map();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The last map built per OUTPUT, from the viewers of that output alone.
|
|
34
|
+
*
|
|
35
|
+
* The same fact answered at two scopes, because the two things that act on it
|
|
36
|
+
* ask at two scopes and both are right. The swarm is asked for bytes of a
|
|
37
|
+
* FILE, and the picture, a quality step and a soundtrack of one film read the
|
|
38
|
+
* same bytes — so every viewer of any of them wants that file's bytes.
|
|
39
|
+
* Encoders are placed per OUTPUT, and a person watching 480p wants nothing of
|
|
40
|
+
* the 1080p output at all.
|
|
41
|
+
*
|
|
42
|
+
* One map for both was the second authority over encoders. Every output of a
|
|
43
|
+
* film was handed the whole film's map, so the plan wanted an encoder on every
|
|
44
|
+
* one of them; what actually stopped the ones nobody was watching was the
|
|
45
|
+
* session manager killing them by its own judgement — and since a viewer
|
|
46
|
+
* moving between steps also announces itself, the plan started them again on
|
|
47
|
+
* the next pass. Two parties answering "should this encoder exist" by different
|
|
48
|
+
* rules, several times a second.
|
|
49
|
+
*
|
|
50
|
+
* @type {Map<string, import("./PriorityMap.js").PriorityMap>}
|
|
51
|
+
*/
|
|
52
|
+
#byOutput = new Map();
|
|
53
|
+
|
|
54
|
+
/** Who is watching one session. @type {(session: object) => Map<string, object>} */
|
|
55
|
+
#viewersOf;
|
|
56
|
+
|
|
57
|
+
/** How wide the first band of one session's file is. @type {(session: object) => number} */
|
|
58
|
+
#allowanceFor;
|
|
59
|
+
|
|
60
|
+
/** Whether this output is what that person is consuming, as opposed to one
|
|
61
|
+
* they merely hold a record on. @type {(session: object, viewer: object) => boolean} */
|
|
62
|
+
#watchedBy;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* This layer states facts and imports nothing above itself, so what it needs
|
|
66
|
+
* of a session — who is watching it, and how wide an interruption this file
|
|
67
|
+
* has shown on this swarm — is passed in.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} params
|
|
70
|
+
* @param {(published: { sourceKey: string, fileIndex: number, durationSeconds: number,
|
|
71
|
+
* zones: { from: number, to: number, priority: number }[] }) => void} params.publish
|
|
72
|
+
* @param {(session: object) => Map<string, object>} [params.viewersOf]
|
|
73
|
+
* @param {(session: object) => number} [params.allowanceFor]
|
|
74
|
+
* @param {(session: object, viewer: object) => boolean} [params.watchedBy] -
|
|
75
|
+
* Whether this output is the one that person is consuming. Which of a film's
|
|
76
|
+
* outputs a person has on screen is a fact about the film's shape, which
|
|
77
|
+
* this layer does not know; absent, every registered viewer counts, and then
|
|
78
|
+
* the per-output map says the same as the per-file one.
|
|
79
|
+
*/
|
|
80
|
+
constructor({ publish, viewersOf, allowanceFor, watchedBy }) {
|
|
81
|
+
this.#publish = typeof publish === "function" ? publish : () => {};
|
|
82
|
+
this.#viewersOf = typeof viewersOf === "function" ? viewersOf : () => new Map();
|
|
83
|
+
this.#allowanceFor = typeof allowanceFor === "function" ? allowanceFor : () => 0;
|
|
84
|
+
this.#watchedBy = typeof watchedBy === "function" ? watchedBy : () => true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A map from a set of viewers, and the ONE statement of how one is built.
|
|
89
|
+
*
|
|
90
|
+
* Asked at both scopes — once per film for the swarm, once per output for the
|
|
91
|
+
* encoders — and written once, because two copies of how a viewer's map is
|
|
92
|
+
* built is the same two-owners fault this class was split for.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} params
|
|
95
|
+
* @param {number} params.durationSeconds
|
|
96
|
+
* @param {number} params.allowanceSeconds
|
|
97
|
+
* @param {{ atSeconds: number, playing: boolean }[]} params.viewers
|
|
98
|
+
* @returns {import("./PriorityMap.js").PriorityMap} A map of no length where
|
|
99
|
+
* nobody is watching or the film's length is unknown, which says the same as
|
|
100
|
+
* a map with nothing in it.
|
|
101
|
+
*/
|
|
102
|
+
#mapFrom({ durationSeconds, allowanceSeconds, viewers }) {
|
|
103
|
+
if (!(durationSeconds > 0) || !(viewers?.length > 0)) {
|
|
104
|
+
return emptyMap(0);
|
|
105
|
+
}
|
|
106
|
+
return mergeMaps(
|
|
107
|
+
viewers.map((viewer) =>
|
|
108
|
+
mapForViewer({
|
|
109
|
+
atSeconds: viewer.atSeconds,
|
|
110
|
+
durationSeconds,
|
|
111
|
+
allowanceSeconds,
|
|
112
|
+
playing: viewer.playing !== false
|
|
113
|
+
})
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The map for one film, from everyone watching it.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} params
|
|
122
|
+
* @param {string} params.sourceKey
|
|
123
|
+
* @param {number} params.fileIndex
|
|
124
|
+
* @param {number} params.durationSeconds
|
|
125
|
+
* @param {number} params.allowanceSeconds - The measured depth below which an
|
|
126
|
+
* interruption reaches a viewer of this file.
|
|
127
|
+
* @param {{ atSeconds: number, playing: boolean }[]} params.viewers
|
|
128
|
+
* @returns {import("./PriorityMap.js").PriorityMap} One number per second of
|
|
129
|
+
* film, merged over everyone watching it.
|
|
130
|
+
*/
|
|
131
|
+
build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers }) {
|
|
132
|
+
const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
|
|
133
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
134
|
+
this.#maps.set(key, map);
|
|
135
|
+
// Unchanged maps are not republished: the downloading rebuilds what it asks
|
|
136
|
+
// the swarm for on every one, and a viewer sitting still would otherwise
|
|
137
|
+
// make it do that several times a second. Compared as stretches rather than
|
|
138
|
+
// second by second, which is the same comparison over far fewer values.
|
|
139
|
+
const zones = runsOf(map);
|
|
140
|
+
const shape = JSON.stringify(zones);
|
|
141
|
+
if (this.#last.get(key) !== shape) {
|
|
142
|
+
this.#last.set(key, shape);
|
|
143
|
+
this.#publish({ sourceKey, fileIndex, durationSeconds, zones });
|
|
144
|
+
}
|
|
145
|
+
return map;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build and publish the map for every file anybody is watching.
|
|
150
|
+
*
|
|
151
|
+
* One map per FILE, not per output: the picture, a quality step and a
|
|
152
|
+
* soundtrack of one film are three outputs reading the same bytes, and the
|
|
153
|
+
* swarm is asked for bytes. Viewers of all of them merge into one map.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} params
|
|
156
|
+
* @param {Iterable<object[]>} params.sessionGroups - The live sessions, in
|
|
157
|
+
* whatever grouping the caller holds them; they are regrouped by file here.
|
|
158
|
+
* @param {number} params.staleAfterMs - How long a viewer may be silent and
|
|
159
|
+
* still count as watching.
|
|
160
|
+
* @param {number} [params.now]
|
|
161
|
+
* @returns {void}
|
|
162
|
+
*/
|
|
163
|
+
publishFor({ sessionGroups, staleAfterMs, now = Date.now() }) {
|
|
164
|
+
/** @type {Map<string, { sourceKey: string, fileIndex: number, durationSeconds: number, allowanceSeconds: number, viewers: object[] }>} */
|
|
165
|
+
const byFile = new Map();
|
|
166
|
+
/** @type {Map<string, { durationSeconds: number, allowanceSeconds: number, viewers: object[] }>} */
|
|
167
|
+
const byOutput = new Map();
|
|
168
|
+
for (const sessions of sessionGroups) {
|
|
169
|
+
for (const session of sessions) {
|
|
170
|
+
const key = `${session.sourceKey}:${session.fileIndex}`;
|
|
171
|
+
const durationSeconds = Number(session.file?.durationSeconds) || 0;
|
|
172
|
+
// The first band is as wide as an interruption this file has actually
|
|
173
|
+
// shown on this swarm, never a chosen number.
|
|
174
|
+
const allowanceSeconds = this.#allowanceFor(session);
|
|
175
|
+
let held = byFile.get(key);
|
|
176
|
+
if (!held) {
|
|
177
|
+
held = {
|
|
178
|
+
sourceKey: session.sourceKey,
|
|
179
|
+
fileIndex: session.fileIndex,
|
|
180
|
+
durationSeconds,
|
|
181
|
+
allowanceSeconds,
|
|
182
|
+
viewers: []
|
|
183
|
+
};
|
|
184
|
+
byFile.set(key, held);
|
|
185
|
+
}
|
|
186
|
+
// Every output anybody holds a session for, whether or not a viewer is
|
|
187
|
+
// consuming it — an output with nobody on it must get a map with
|
|
188
|
+
// nothing in it, which is how the plan is told to stop its encoders.
|
|
189
|
+
// Left out, it would keep the map it had when somebody was watching.
|
|
190
|
+
const address = session.outputKey ?? "";
|
|
191
|
+
let mine = byOutput.get(address);
|
|
192
|
+
if (!mine) {
|
|
193
|
+
mine = { durationSeconds, allowanceSeconds, viewers: [] };
|
|
194
|
+
byOutput.set(address, mine);
|
|
195
|
+
}
|
|
196
|
+
for (const viewer of this.#viewersOf(session).values()) {
|
|
197
|
+
if (!viewer.isPresent(now, staleAfterMs)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const stated = {
|
|
201
|
+
atSeconds: viewer.positionSeconds() ?? 0,
|
|
202
|
+
playing: viewer.playing !== false
|
|
203
|
+
};
|
|
204
|
+
held.viewers.push(stated);
|
|
205
|
+
if (this.#watchedBy(session, viewer)) {
|
|
206
|
+
mine.viewers.push(stated);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// WHAT THIS PASS SAW IS ALL THERE IS. Everything below is derived from the
|
|
212
|
+
// live sessions, so a file or an output that is not among them is gone —
|
|
213
|
+
// and these maps are the projection of that, never a memory of it. Left to
|
|
214
|
+
// accumulate they were three maps that only grew, which is the shape of half
|
|
215
|
+
// the memory faults recorded in this repository, and `forget` was written
|
|
216
|
+
// for it and called from nowhere.
|
|
217
|
+
for (const key of [...this.#maps.keys()]) {
|
|
218
|
+
if (!byFile.has(key)) {
|
|
219
|
+
this.#maps.delete(key);
|
|
220
|
+
this.#last.delete(key);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const address of [...this.#byOutput.keys()]) {
|
|
224
|
+
if (!byOutput.has(address)) {
|
|
225
|
+
this.#byOutput.delete(address);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const one of byFile.values()) {
|
|
229
|
+
// A file of unknown length cannot be divided into zones, and a file
|
|
230
|
+
// nobody is watching has nothing to be urgent about.
|
|
231
|
+
if (one.durationSeconds > 0 && one.viewers.length > 0) {
|
|
232
|
+
this.build(one);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const [address, one] of byOutput) {
|
|
236
|
+
this.#byOutput.set(address, this.#mapFrom(one));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Nobody is watching this file any more.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} sourceKey
|
|
244
|
+
* @param {number} fileIndex
|
|
245
|
+
*/
|
|
246
|
+
/**
|
|
247
|
+
* The map this class last built for one file.
|
|
248
|
+
*
|
|
249
|
+
* Read by whoever acts on it and cannot be handed it at the moment it is
|
|
250
|
+
* made — the encoding decides per output, and one file has several. It is the
|
|
251
|
+
* SAME map: built once here, from where the viewers are, and neither read
|
|
252
|
+
* changes it.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} sourceKey
|
|
255
|
+
* @param {number} fileIndex
|
|
256
|
+
* @returns {import("./PriorityMap.js").PriorityMap} A map of no length where
|
|
257
|
+
* none was built, which says the same as a map with nothing in it.
|
|
258
|
+
*/
|
|
259
|
+
mapFor(sourceKey, fileIndex) {
|
|
260
|
+
return this.#maps.get(`${sourceKey}:${fileIndex}`) ?? emptyMap(0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The map for ONE output, from the viewers consuming that output.
|
|
265
|
+
*
|
|
266
|
+
* What the encoding reads. A map of no length says nobody is on this output,
|
|
267
|
+
* which is what makes an encoder on it unwanted — and it is a statement, not
|
|
268
|
+
* an absence: the walk above writes one for every output a session exists
|
|
269
|
+
* for, including the ones everybody has left.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} address
|
|
272
|
+
* @returns {import("./PriorityMap.js").PriorityMap}
|
|
273
|
+
*/
|
|
274
|
+
mapForOutput(address) {
|
|
275
|
+
return this.#byOutput.get(address) ?? emptyMap(0);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
}
|
|
@@ -143,3 +143,60 @@ test("what a model prices a film at", () => {
|
|
|
143
143
|
// Which is a decode speed of 1/cost — the figure the quality offer rests on.
|
|
144
144
|
assert.ok(1 / cost > 1 && 1 / cost < 10, `decodes at ${(1 / cost).toFixed(2)}x`);
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* THE ORDERINGS, STATED WHERE THEY ARE DETERMINISTIC.
|
|
149
|
+
*
|
|
150
|
+
* What the quality offer needs of this model is not precision but ORDER: a
|
|
151
|
+
* bigger, richer, dearer-to-decode source must never be priced cheaper, because
|
|
152
|
+
* the model is asked about rungs nobody has decoded. That is a property of the
|
|
153
|
+
* fit, and it is a property of the fit whatever machine runs the test.
|
|
154
|
+
*
|
|
155
|
+
* These three used to be asserted over LIVE readings — decode two clips through
|
|
156
|
+
* real ffmpeg and require the smaller to come back faster. That measures the
|
|
157
|
+
* scheduler, not the code: four failures in one day, and the worst of them read
|
|
158
|
+
* 480p at 8.4x against 1080p at 20.8x, an inversion of two and a half times
|
|
159
|
+
* with nothing wrong. The readings themselves are unavailable to a test at all,
|
|
160
|
+
* since a clip that "said nothing" under load makes the whole benchmark answer
|
|
161
|
+
* with nothing.
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
test("a bigger picture is priced dearer than a smaller one of the same bitrate", () => {
|
|
165
|
+
const model = fitDecodeCost(wellConditionedSet({ pixel: 0.0055, bitrate: 0.0099, constant: 0.057 }));
|
|
166
|
+
const big = decodeCostOf(model, { megapixelsPerSecond: (1920 * 1080 * FPS) / 1e6, megabitsPerSecond: 4 });
|
|
167
|
+
const small = decodeCostOf(model, { megapixelsPerSecond: (854 * 480 * FPS) / 1e6, megabitsPerSecond: 4 });
|
|
168
|
+
|
|
169
|
+
assert.ok(big > small, `1080p at ${big.toFixed(4)} s/s against 480p at ${small.toFixed(4)}`);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a thicker stream is priced dearer than a thin one of the same size", () => {
|
|
173
|
+
const model = fitDecodeCost(wellConditionedSet({ pixel: 0.0055, bitrate: 0.0099, constant: 0.057 }));
|
|
174
|
+
const pixels = (854 * 480 * FPS) / 1e6;
|
|
175
|
+
const thick = decodeCostOf(model, { megapixelsPerSecond: pixels, megabitsPerSecond: 9.5 });
|
|
176
|
+
const thin = decodeCostOf(model, { megapixelsPerSecond: pixels, megabitsPerSecond: 1.1 });
|
|
177
|
+
|
|
178
|
+
assert.ok(thick > thin, `9.5 Mbit/s at ${thick.toFixed(4)} s/s against 1.1 at ${thin.toFixed(4)}`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a family measured to be dearer prices its own sources dearer", () => {
|
|
182
|
+
// Why the model is fitted per codec family at all: HEVC costs more than H.264
|
|
183
|
+
// for the same picture on the same machine, so a source that has to be
|
|
184
|
+
// re-encoded — which is usually one the browser could not decode — must be
|
|
185
|
+
// priced by its own family and not by H.264's.
|
|
186
|
+
const cheap = { pixel: 0.0055, bitrate: 0.0099, constant: 0.057 };
|
|
187
|
+
const dear = { pixel: 0.011, bitrate: 0.0198, constant: 0.114 };
|
|
188
|
+
const model = {
|
|
189
|
+
...fitDecodeCost(wellConditionedSet(cheap)),
|
|
190
|
+
families: {
|
|
191
|
+
h264: fitDecodeCost(wellConditionedSet(cheap)),
|
|
192
|
+
hevc: fitDecodeCost(wellConditionedSet(dear))
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const source = { megapixelsPerSecond: (854 * 480 * FPS) / 1e6, megabitsPerSecond: 1.1 };
|
|
196
|
+
|
|
197
|
+
assert.ok(
|
|
198
|
+
decodeCostOf(model, { ...source, codec: "hevc" }) >
|
|
199
|
+
decodeCostOf(model, { ...source, codec: "h264" }),
|
|
200
|
+
"the family chooses the terms, and the dearer family answers dearer"
|
|
201
|
+
);
|
|
202
|
+
});
|
|
@@ -6,10 +6,19 @@
|
|
|
6
6
|
* claiming the host cleared the bar 2.5 times over — the error on that rung was
|
|
7
7
|
* 209 %. The decode term brings a controlled measurement to within 5 %.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* WHAT IS ASSERTED HERE IS ARITHMETIC, over constants that were measured once
|
|
10
|
+
* on the addon host and written down. The live benchmark used to run here too —
|
|
11
|
+
* on the reasoning that a fit which only ever meets invented numbers can be
|
|
12
|
+
* wrong in every way that matters — and it could not be asserted about: a clip
|
|
13
|
+
* that "said nothing" under load makes the whole benchmark answer with nothing,
|
|
14
|
+
* so the very first line of that test failed by luck. The orderings it was for
|
|
15
|
+
* are held over the fit itself, deterministically, in `decode-cost-fit.test.js`.
|
|
16
|
+
*
|
|
17
|
+
* What no test covers as a result is the live path — lifting a clip out of its
|
|
18
|
+
* container, feeding it through the pipe, reading the slope. That is integration
|
|
19
|
+
* over real ffmpeg, and it belongs to a stand run before a release rather than
|
|
20
|
+
* to a check that measures whatever else the machine was doing (roadmap item
|
|
21
|
+
* 52).
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
import test from "node:test";
|
|
@@ -23,7 +32,6 @@ import { Output } from "../services/output/Output.js";
|
|
|
23
32
|
import os from "node:os";
|
|
24
33
|
import path from "node:path";
|
|
25
34
|
import {
|
|
26
|
-
benchmarkDecodeCost,
|
|
27
35
|
canSustainOutput,
|
|
28
36
|
decodeSpeedFor,
|
|
29
37
|
predictedRealtimeSpeed,
|
|
@@ -45,37 +53,6 @@ const ADDON_HOST_MODEL = { pixelTerm: 0.005555, bitrateTerm: 0.00990, constantTe
|
|
|
45
53
|
// The film measured that day: 1920x1080 at 24 fps, about 8 Mbit/s.
|
|
46
54
|
const MEASURED_FILM = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 8 };
|
|
47
55
|
|
|
48
|
-
test("the fit comes out of the real clips, and predicts one of them back", async () => {
|
|
49
|
-
const model = await benchmarkDecodeCost({ ffmpegBin });
|
|
50
|
-
|
|
51
|
-
assert.ok(model, "the clips ship with the package and this host has ffmpeg");
|
|
52
|
-
assert.ok(model.pixelTerm > 0, "more pixels cannot decode faster");
|
|
53
|
-
assert.ok(Number.isFinite(model.bitrateTerm) && Number.isFinite(model.constantTerm));
|
|
54
|
-
|
|
55
|
-
// What the model must get right is how cost SCALES from one source to
|
|
56
|
-
// another — that is the whole of its job, since it is asked about rungs
|
|
57
|
-
// nobody has decoded. So: a bigger, richer source is never cheaper.
|
|
58
|
-
//
|
|
59
|
-
// Deliberately not a numeric bound. This suite runs its files in parallel and
|
|
60
|
-
// the benchmark is a live measurement, so the fit it produces depends on what
|
|
61
|
-
// else the machine was doing: on this desktop the same clips have solved to
|
|
62
|
-
// pixels+bitrate+constant, to pixels alone, and — under load — to a
|
|
63
|
-
// constant-dominated shape whose 720p/1080p ratio was 1.32 rather than the
|
|
64
|
-
// ~2.4 of a quiet run. That instability is real and is recorded against
|
|
65
|
-
// roadmap item 1; pinning a number here would only pin how busy the machine
|
|
66
|
-
// happened to be. What the FIGURES are worth is checked where it is quiet:
|
|
67
|
-
// against the addon host's recorded constants below, and against the real
|
|
68
|
-
// film in the field.
|
|
69
|
-
const clip720 = { megapixelsPerSecond: (1280 * 720 * 24) / 1e6, megabitsPerSecond: 2.248 };
|
|
70
|
-
const clip1080 = { megapixelsPerSecond: (1920 * 1080 * 24) / 1e6, megabitsPerSecond: 11.375 };
|
|
71
|
-
const ratio = decodeSpeedFor(model, clip720) / decodeSpeedFor(model, clip1080);
|
|
72
|
-
|
|
73
|
-
assert.ok(
|
|
74
|
-
ratio >= 1,
|
|
75
|
-
`720p at a fifth of the bitrate cannot decode slower than 1080p; the fit says ${ratio.toFixed(2)}x`
|
|
76
|
-
);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
56
|
test("decode cost prices the film it was checked against", () => {
|
|
80
57
|
const speed = decodeSpeedFor(ADDON_HOST_MODEL, MEASURED_FILM);
|
|
81
58
|
|