@torrent-tv/proxy 2.74.1 → 2.76.0
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 +1504 -1453
- package/CLAUDE.md +19 -6
- package/biome.json +182 -1
- package/docs/container-architecture.md +27 -6
- package/docs/encode-run-state.md +1 -1
- package/knip.json +14 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +1 -1
- package/routes/api/transcode-sessions/post.js +185 -185
- package/routes/api/transcode-sessions/progress/get.js +5 -1
- package/routes/transcode/audio-file/get.js +11 -1
- package/routes/transcode/audio-warm/get.js +11 -1
- package/routes/transcode/session-file/get.js +1 -1
- package/routes/transcode/variant-file/get.js +10 -1
- package/scripts/render-run-graph.js +2 -2
- package/server.js +25 -0
- package/services/audio-inventory.js +9 -214
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +281 -80
- package/services/container/ContainerFactory.js +67 -0
- package/services/container/MatroskaContainer.js +327 -8
- package/services/container/Mp4Container.js +373 -27
- package/services/container/SubtitleFileContainer.js +0 -1
- package/services/controllers/SubtitleController.js +128 -128
- package/services/demand/index.js +7 -10
- package/services/download/registry.js +0 -14
- package/services/encode/CoverageMap.js +281 -0
- package/services/encode/EncodePlan.js +255 -0
- package/services/encode/EncodeRun.js +587 -0
- package/services/encode/Encoder.js +84 -0
- package/services/encode/NvencEncoder.js +45 -0
- package/services/encode/QsvEncoder.js +47 -0
- package/services/encode/SegmentDemand.js +0 -0
- package/services/encode/SegmentStore.js +529 -0
- package/services/encode/SoftwareEncoder.js +111 -0
- package/services/encode/V4l2m2mEncoder.js +53 -0
- package/services/encode/VaapiEncoder.js +53 -0
- package/services/encode/args.js +200 -0
- package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
- package/services/encode/index.js +9 -0
- package/services/encode/run-command.js +647 -0
- package/services/hls-session-manager.js +11073 -10711
- package/services/hwaccel.js +1688 -1992
- package/services/orchestrators/EncodeOrchestrator.js +359 -0
- package/services/output/LiveOutputs.js +213 -0
- package/services/output/Output.js +94 -0
- package/services/output/OutputSpec.js +195 -0
- package/services/output/Timeline.js +220 -0
- package/services/output/index.js +1 -0
- package/services/output/ladder.js +26 -0
- package/services/playback-planner.js +806 -747
- package/services/produced-index.js +222 -300
- package/services/source/SourceFile.js +346 -0
- package/services/{sidecar-files.js → torrent/files.js} +107 -11
- package/services/torrent/naming.js +619 -0
- package/services/torrent-worker/client.js +10 -0
- package/services/torrent-worker/container-tracks.js +71 -43
- package/services/torrent-worker/pool-adapter.js +370 -333
- package/services/torrent-worker/protocol.js +7 -0
- package/services/torrent-worker/subtitle-cues.js +549 -549
- package/services/torrent-worker/worker.js +18 -0
- package/services/tracks/AudioTrack.js +131 -40
- package/services/tracks/TextSubtitleTrack.js +287 -287
- package/services/tracks/index.js +15 -14
- package/services/viewer/Viewer.js +145 -0
- package/services/viewer/Viewers.js +124 -0
- package/test/audio-inventory.test.js +176 -177
- package/test/auto-quality-step.test.js +508 -506
- package/test/behind-head-repair.test.js +17 -7
- package/test/coverage-map.test.js +153 -0
- package/test/cut-times-timeline.test.js +6 -5
- package/test/cuts-follow-published-grid.test.js +4 -4
- package/test/decode-cost.test.js +31 -12
- package/test/encode-exit.test.js +1 -1
- package/test/encode-orchestrator.test.js +196 -0
- package/test/encode-plan.test.js +245 -0
- package/test/encode-run-state.test.js +2 -2
- package/test/encode-run.test.js +168 -0
- package/test/encoder-kinds.test.js +122 -0
- package/test/held-request-width.test.js +9 -3
- package/test/helpers/encode-run.js +128 -0
- package/test/keyframe-index-accuracy.test.js +19 -12
- package/test/keyframes-belong-to-the-file.test.js +132 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/orchestrator-wired.test.js +164 -0
- package/test/output-shape.test.js +68 -0
- package/test/output-spec.test.js +157 -0
- package/test/produced-copy-choice.test.js +58 -92
- package/test/produced-index.test.js +142 -188
- package/test/quality-variants.test.js +1079 -1075
- package/test/run-graph-drift.test.js +1 -1
- package/test/run-intervals.test.js +329 -0
- package/test/run-position-follows-published-grid.test.js +4 -4
- package/test/seek-landing.test.js +8 -8
- package/test/seek-target-not-superseded.test.js +21 -9
- package/test/segment-demand.test.js +82 -0
- package/test/segment-serve-wiring.test.js +47 -52
- package/test/segment-store.test.js +187 -0
- package/test/segments-are-shared.test.js +175 -0
- package/test/sidecar-naming.test.js +142 -0
- package/test/source-file.test.js +133 -0
- package/test/stale-request-after-seek.test.js +18 -12
- package/test/subtitle-language.test.js +252 -252
- package/test/timeline.test.js +95 -0
- package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
- package/test/torrent-naming.test.js +255 -0
- package/test/tracks-begin-together.test.js +44 -32
- package/test/two-viewers-one-picture.test.js +347 -0
- package/test/video-facts.test.js +102 -0
- package/test/viewer-outputs.test.js +273 -0
- package/test/viewer.test.js +91 -0
- package/utils/perf.js +1 -63
- package/services/container/index.js +0 -6
- package/services/container-index/avi.js +0 -167
- package/services/container-index/index.js +0 -118
- package/services/container-index/matroska.js +0 -336
- package/services/container-index/mp4.js +0 -358
- package/services/controllers/index.js +0 -2
- package/services/download/index.js +0 -8
- package/services/orchestrators/index.js +0 -2
- /package/services/{container-index → container}/ebml-reader.js +0 -0
- /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
- /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How many encoders run on this proxy, on which outputs, and over which
|
|
3
|
+
* stretches.
|
|
4
|
+
*
|
|
5
|
+
* The application layer over `encode/`. It holds four things and owns none of
|
|
6
|
+
* their rules:
|
|
7
|
+
*
|
|
8
|
+
* 1. what viewers want (`SegmentDemand`), stated once each and read as a union;
|
|
9
|
+
* 2. what has been made and what is being made, one `CoverageMap` per output;
|
|
10
|
+
* 3. the encoders that are running (`EncodeRun`), each over a stretch;
|
|
11
|
+
* 4. a tally of how every run ended, which is what makes "abnormal endings do
|
|
12
|
+
* not happen" a number rather than an impression.
|
|
13
|
+
*
|
|
14
|
+
* The decision itself is `EncodePlan.planEncoders`, from numbers alone. This
|
|
15
|
+
* carries it out, and everything it cannot know is injected: how many encoders
|
|
16
|
+
* this machine can afford, how a run is built for a given stretch, and which
|
|
17
|
+
* segments already exist.
|
|
18
|
+
*
|
|
19
|
+
* **No viewer reaches the decision.** A viewer states a window and is forgotten
|
|
20
|
+
* as a name; what the plan sees is a union of windows. That is the rule the
|
|
21
|
+
* layer exists for, stated by the user 2026-09-04: requests come from any
|
|
22
|
+
* viewers in any number, encoders are managed to suit them, and viewers get the
|
|
23
|
+
* result when it is ready.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { CoverageMap } from "../encode/CoverageMap.js";
|
|
27
|
+
import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
|
|
28
|
+
import { ENCODE_EXIT } from "../encode/encode-exit.js";
|
|
29
|
+
import { SegmentDemand } from "../encode/SegmentDemand.js";
|
|
30
|
+
|
|
31
|
+
export class EncodeOrchestrator {
|
|
32
|
+
/** Output address to what has been made of it. @type {Map<string, CoverageMap>} */
|
|
33
|
+
#coverage = new Map();
|
|
34
|
+
|
|
35
|
+
/** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
|
|
36
|
+
#runs = new Map();
|
|
37
|
+
|
|
38
|
+
/** How runs have ended, by cause. @type {Map<string, number>} */
|
|
39
|
+
#endings = new Map();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {(address: string) => number} params.maxRunsFor - How many encoders
|
|
44
|
+
* this machine can afford on one output. The same arithmetic that decides
|
|
45
|
+
* the quality offer; measured per host, never chosen here.
|
|
46
|
+
* @param {(params: { address: string, from: number, to: number }) =>
|
|
47
|
+
* import("../encode/EncodeRun.js").EncodeRun} params.makeRun - Build a run
|
|
48
|
+
* for a stretch. What to read, what to map and how to cut belong to whoever
|
|
49
|
+
* knows the source.
|
|
50
|
+
* @param {number} params.segmentSeconds
|
|
51
|
+
* @param {number} params.restartCostSec - Measured: 0.12 s on the addon host.
|
|
52
|
+
* @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
|
|
53
|
+
* @param {() => number} [params.now]
|
|
54
|
+
*/
|
|
55
|
+
constructor({ maxRunsFor, makeRun, segmentSeconds, restartCostSec, logger, now }) {
|
|
56
|
+
this.demand = new SegmentDemand();
|
|
57
|
+
this.maxRunsFor = maxRunsFor;
|
|
58
|
+
this.makeRun = makeRun;
|
|
59
|
+
this.segmentSeconds = segmentSeconds;
|
|
60
|
+
this.restartCostSec = restartCostSec;
|
|
61
|
+
this.logger = logger;
|
|
62
|
+
this.now = typeof now === "function" ? now : Date.now;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The map of one output, made on first mention.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} address
|
|
69
|
+
* @returns {CoverageMap}
|
|
70
|
+
*/
|
|
71
|
+
coverageOf(address) {
|
|
72
|
+
let map = this.#coverage.get(address);
|
|
73
|
+
if (!map) {
|
|
74
|
+
map = new CoverageMap();
|
|
75
|
+
this.#coverage.set(address, map);
|
|
76
|
+
}
|
|
77
|
+
return map;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} address
|
|
82
|
+
* @returns {import("../encode/EncodeRun.js").EncodeRun[]}
|
|
83
|
+
*/
|
|
84
|
+
runsOn(address) {
|
|
85
|
+
return this.#runs.get(address) ?? [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How long an output is, once its playlist is known.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} address
|
|
92
|
+
* @param {number} segmentCount
|
|
93
|
+
*/
|
|
94
|
+
setSegmentCount(address, segmentCount) {
|
|
95
|
+
this.coverageOf(address).setSegmentCount(segmentCount);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Segments that already exist — from a previous life of this process, or
|
|
100
|
+
* because somebody else made them. Told to the map, which is what stops an
|
|
101
|
+
* encoder being started to make them again.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} address
|
|
104
|
+
* @param {Iterable<number>} indexes
|
|
105
|
+
*/
|
|
106
|
+
noteAlreadyMade(address, indexes) {
|
|
107
|
+
this.coverageOf(address).markReadyAll(indexes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A viewer states what it is waiting for. Replaces whatever it said before.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} params
|
|
114
|
+
* @param {string} params.claimant
|
|
115
|
+
* @param {string} params.address
|
|
116
|
+
* @param {number} params.from
|
|
117
|
+
* @param {number} params.to
|
|
118
|
+
*/
|
|
119
|
+
want({ claimant, address, from, to }) {
|
|
120
|
+
this.demand.state({ claimant, address, from, to, statedAt: this.now() });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A viewer has gone.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} claimant
|
|
127
|
+
*/
|
|
128
|
+
release(claimant) {
|
|
129
|
+
this.demand.forget(claimant);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A segment has been finished, by whichever run made it.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} address
|
|
136
|
+
* @param {number} index
|
|
137
|
+
*/
|
|
138
|
+
noteProduced(address, index) {
|
|
139
|
+
this.coverageOf(address).markReady(index);
|
|
140
|
+
for (const run of this.runsOn(address)) {
|
|
141
|
+
run.noteProduced(index);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} address
|
|
147
|
+
* @param {object} run
|
|
148
|
+
* @param {number} speedX
|
|
149
|
+
*/
|
|
150
|
+
noteSpeed(address, wanted, speedX) {
|
|
151
|
+
for (const run of this.runsOn(address)) {
|
|
152
|
+
if (run === wanted) {
|
|
153
|
+
run.noteSpeed(speedX);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Decide and act, for every output anybody wants anything of and every output
|
|
160
|
+
* that still has an encoder on it.
|
|
161
|
+
*
|
|
162
|
+
* Safe to call as often as anything changes: the plan is a function of the
|
|
163
|
+
* state, so a pass that finds nothing to change does nothing.
|
|
164
|
+
*/
|
|
165
|
+
reconcile() {
|
|
166
|
+
const addresses = new Set([...this.demand.addresses(), ...this.#runs.keys()]);
|
|
167
|
+
for (const address of addresses) {
|
|
168
|
+
this.#reconcileOne(address);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {string} address
|
|
174
|
+
*/
|
|
175
|
+
#reconcileOne(address) {
|
|
176
|
+
const coverage = this.coverageOf(address);
|
|
177
|
+
// A run that has ended and said nothing. One built here reports its own
|
|
178
|
+
// ending and is released by `noteEnded`; one ADOPTED from elsewhere — a
|
|
179
|
+
// session whose encoder stopped — has no such promise, and its claim would
|
|
180
|
+
// otherwise sit in the map for the life of the process, telling the plan
|
|
181
|
+
// that a stretch nobody is making is being made. Nothing would ever be
|
|
182
|
+
// started there again.
|
|
183
|
+
for (const run of this.runsOn(address)) {
|
|
184
|
+
if (!run.isAlive && !run.isStopping) {
|
|
185
|
+
this.noteEnded({
|
|
186
|
+
address,
|
|
187
|
+
run,
|
|
188
|
+
ending: ENCODE_EXIT.GONE,
|
|
189
|
+
because: "it is no longer running, and it did not say so"
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const windows = this.demand.windowsOn(address).map((window) => ({
|
|
194
|
+
from: window.from,
|
|
195
|
+
to: window.to
|
|
196
|
+
}));
|
|
197
|
+
const live = this.runsOn(address).filter((run) => run.isAlive);
|
|
198
|
+
const actions = planEncoders({
|
|
199
|
+
coverage,
|
|
200
|
+
windows,
|
|
201
|
+
// The runs themselves. The plan is arithmetic and reads four numbers off
|
|
202
|
+
// each; what it hands back names the run by BEING it, so nothing has to
|
|
203
|
+
// invent a token to refer to one by.
|
|
204
|
+
runs: live,
|
|
205
|
+
maxRuns: Math.max(0, this.maxRunsFor(address)),
|
|
206
|
+
segmentSeconds: this.segmentSeconds,
|
|
207
|
+
restartCostSec: this.restartCostSec
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
for (const action of actions) {
|
|
211
|
+
if (action.type === "stop") {
|
|
212
|
+
this.#stop(action.run, action.because);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (action.type === "move") {
|
|
216
|
+
// A running encoder's position cannot be changed — it is fixed when the
|
|
217
|
+
// process starts — so a move is this one ending and another beginning
|
|
218
|
+
// where the material is missing. Both halves are recorded as what they
|
|
219
|
+
// are, which is why the ending of a moved run is not called normal.
|
|
220
|
+
this.#stop(action.run, action.because);
|
|
221
|
+
this.#start(address, action.from, action.to, action.because);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (action.type === "start") {
|
|
225
|
+
this.#start(address, action.from, action.to, action.because);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// A run that stays keeps its claim current: the free stretch ahead of it
|
|
229
|
+
// may have shrunk since it was given one.
|
|
230
|
+
coverage.claim(action.run, action.from, action.to);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {string} address
|
|
236
|
+
* @param {number} from
|
|
237
|
+
* @param {number} to
|
|
238
|
+
* @param {string} because
|
|
239
|
+
*/
|
|
240
|
+
#start(address, from, to, because) {
|
|
241
|
+
// The run names itself: identity is a property of the thing, and two
|
|
242
|
+
// places minting names is how one stops being unique.
|
|
243
|
+
const run = this.makeRun({ address, from, to });
|
|
244
|
+
if (!run) {
|
|
245
|
+
// Not necessarily a failure: making an encoder can take a probe and a
|
|
246
|
+
// keyframe read, and this call is arithmetic that must not wait on
|
|
247
|
+
// either. Whoever builds one answers with nothing while it is on its way,
|
|
248
|
+
// and the next pass sees it.
|
|
249
|
+
this.logger.info(`encode: an encoder for #${from}..#${to} of ${address} is not there yet`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const onThisOutput = this.#runs.get(address) ?? [];
|
|
253
|
+
onThisOutput.push(run);
|
|
254
|
+
this.#runs.set(address, onThisOutput);
|
|
255
|
+
this.coverageOf(address).claim(run, from, to);
|
|
256
|
+
run.start(because);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Take charge of a run this class did not start.
|
|
261
|
+
*
|
|
262
|
+
* The browser asks for a stream and a run begins for it, long before this
|
|
263
|
+
* class has an opinion. Left unknown, that run would be invisible to the plan
|
|
264
|
+
* — which would then start a second encoder over the same numbers, believing
|
|
265
|
+
* nothing was being made there. So whoever starts one hands it over, and from
|
|
266
|
+
* then on it is planned like any other.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} address
|
|
269
|
+
* @param {{ id: string, from: number, to: number, head: number, speedX: number, isAlive: boolean, stop: (because: string) => void }} run
|
|
270
|
+
*/
|
|
271
|
+
adopt(address, run) {
|
|
272
|
+
if (!run) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const onThisOutput = this.#runs.get(address) ?? [];
|
|
276
|
+
if (onThisOutput.includes(run)) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
onThisOutput.push(run);
|
|
280
|
+
this.#runs.set(address, onThisOutput);
|
|
281
|
+
this.coverageOf(address).claim(run, run.from, run.to);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param {object} run
|
|
286
|
+
* @param {string} because
|
|
287
|
+
*/
|
|
288
|
+
#stop(run, because) {
|
|
289
|
+
run.stop(because);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A run has ended, however it ended. Its stretch goes back to the map — what
|
|
294
|
+
* it finished stays made — and the ending is counted.
|
|
295
|
+
*
|
|
296
|
+
* Wired by whoever builds the run, so that a run built outside this class is
|
|
297
|
+
* still accounted for.
|
|
298
|
+
*
|
|
299
|
+
* @param {import("../encode/EncodeRun.js").RunEnded} ended
|
|
300
|
+
*/
|
|
301
|
+
noteEnded(ended) {
|
|
302
|
+
this.coverageOf(ended.address).release(ended.run);
|
|
303
|
+
const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
|
|
304
|
+
if (remaining.length === 0) {
|
|
305
|
+
this.#runs.delete(ended.address);
|
|
306
|
+
} else {
|
|
307
|
+
this.#runs.set(ended.address, remaining);
|
|
308
|
+
}
|
|
309
|
+
this.#endings.set(ended.ending, (this.#endings.get(ended.ending) ?? 0) + 1);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* How runs have ended over the life of this process, by cause.
|
|
314
|
+
*
|
|
315
|
+
* The abnormal classes are meant to stand at zero. Without the count,
|
|
316
|
+
* "we understand why it ended" is indistinguishable from "we noticed it once".
|
|
317
|
+
*
|
|
318
|
+
* @returns {Record<string, number>}
|
|
319
|
+
*/
|
|
320
|
+
endings() {
|
|
321
|
+
/** @type {Record<string, number>} */
|
|
322
|
+
const tally = {};
|
|
323
|
+
for (const ending of Object.values(ENCODE_EXIT)) {
|
|
324
|
+
tally[ending] = this.#endings.get(ending) ?? 0;
|
|
325
|
+
}
|
|
326
|
+
return tally;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* One line saying what this proxy is encoding and whether anybody is waiting.
|
|
331
|
+
*
|
|
332
|
+
* `waiting` is the point of it: a proxy with encoders running and a viewer
|
|
333
|
+
* still stopped at a segment nobody is making is the failure this layer was
|
|
334
|
+
* built to remove, and it is visible here rather than inferred from a log.
|
|
335
|
+
*
|
|
336
|
+
* @returns {string}
|
|
337
|
+
*/
|
|
338
|
+
describe() {
|
|
339
|
+
const parts = [];
|
|
340
|
+
for (const address of new Set([...this.demand.addresses(), ...this.#runs.keys()])) {
|
|
341
|
+
const coverage = this.coverageOf(address);
|
|
342
|
+
const windows = this.demand.windowsOn(address).map((w) => ({ from: w.from, to: w.to }));
|
|
343
|
+
const waiting = firstUnmetWant(coverage, windows);
|
|
344
|
+
const runs = this.runsOn(address)
|
|
345
|
+
.map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
|
|
346
|
+
.join(" ");
|
|
347
|
+
parts.push(
|
|
348
|
+
`${address.slice(0, 60)} ready=${coverage.stats().ready} ` +
|
|
349
|
+
`viewers=${windows.length} runs=[${runs}] ` +
|
|
350
|
+
`waiting=${waiting === null ? "nobody" : `#${waiting}`}`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const tally = this.endings();
|
|
354
|
+
const endings = Object.entries(tally)
|
|
355
|
+
.map(([cause, count]) => `${cause}=${count}`)
|
|
356
|
+
.join(" ");
|
|
357
|
+
return `encode: ${parts.length === 0 ? "nothing wanted" : parts.join(" | ")} :: endings ${endings}`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Which outputs of one file exist right now, and what each of them is.
|
|
3
|
+
*
|
|
4
|
+
* Every question here is answered by walking the live sessions and looking at
|
|
5
|
+
* what they ARE — the same file, a step, a soundtrack, this height — and not by
|
|
6
|
+
* following a list of ids anybody keeps. A list of ids is a link between
|
|
7
|
+
* sessions: it ties their lifetimes together, it goes stale when one of them is
|
|
8
|
+
* disposed, and it has to be cleaned from the other side. What an output is is
|
|
9
|
+
* enough to find it, which is the rule `OutputSpec` exists for.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here writes anything except the two answers a session memoizes about
|
|
12
|
+
* itself, and nothing here knows about encoders, viewers, the torrent or the
|
|
13
|
+
* disk. It is the layer the quality budget and the serving path both stand on,
|
|
14
|
+
* and it is separated first for that reason.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { variantHeightsFor } from "./ladder.js";
|
|
18
|
+
|
|
19
|
+
export class LiveOutputs {
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} params
|
|
22
|
+
* @param {Map<string, object>} params.sessionsById - The live sessions. Read,
|
|
23
|
+
* never written.
|
|
24
|
+
*/
|
|
25
|
+
constructor({ sessionsById }) {
|
|
26
|
+
this.sessionsById = sessionsById;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every live session of one file: the picture, its quality steps, and the
|
|
31
|
+
* soundtracks published separately.
|
|
32
|
+
*
|
|
33
|
+
* The file is what they share, so the file is what this asks about. Where the
|
|
34
|
+
* sums this feeds are concerned that is also the right question: two pictures
|
|
35
|
+
* of one file are two encoders on one machine whether or not anybody thinks
|
|
36
|
+
* of them as one film.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} session
|
|
39
|
+
* @returns {object[]}
|
|
40
|
+
*/
|
|
41
|
+
familyOf(session) {
|
|
42
|
+
const family = [session];
|
|
43
|
+
const key = session?.file?.key;
|
|
44
|
+
for (const other of this.sessionsById.values()) {
|
|
45
|
+
if (other === session || other.state === "disposed" || other.file?.key !== key) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
family.push(other);
|
|
49
|
+
}
|
|
50
|
+
return family;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The soundtracks published separately for this picture, live ones only.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} base
|
|
57
|
+
* @returns {object[]}
|
|
58
|
+
*/
|
|
59
|
+
renditionsOf(base) {
|
|
60
|
+
return this.familyOf(base).filter((session) => session !== base && session.audioOnly === true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The quality steps of this picture, live ones only.
|
|
65
|
+
*
|
|
66
|
+
* A step is a session made as one — `isStep`, written where it is created —
|
|
67
|
+
* and not merely "another session of this file that carries a picture". The
|
|
68
|
+
* difference is the second picture: one file can hold two — a browser that
|
|
69
|
+
* understands rendition groups and one that needs the sound muxed in produce
|
|
70
|
+
* two — and calling one of them a step of the other would let a switch away
|
|
71
|
+
* from a step stop the encoder of somebody else's picture.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} base
|
|
74
|
+
* @returns {object[]}
|
|
75
|
+
*/
|
|
76
|
+
stepsOf(base) {
|
|
77
|
+
return this.familyOf(base).filter((session) => session !== base && session.isStep === true);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The picture a step belongs to, or the session itself when it is not a step.
|
|
82
|
+
*
|
|
83
|
+
* How a session came to be is a fact about it, not a link to another session
|
|
84
|
+
* — so a step whose picture has gone answers for itself rather than following
|
|
85
|
+
* a dead reference, and nothing has to be cleaned from the other side when
|
|
86
|
+
* one of them ends.
|
|
87
|
+
*
|
|
88
|
+
* One file can carry two pictures at once, and then this returns whichever
|
|
89
|
+
* was made first. Everything asked of the answer is a fact of the FILE and of
|
|
90
|
+
* this host: what heights can be offered, what a step costs, when the budget
|
|
91
|
+
* last acted. Two pictures of one file answer all of those alike.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} session
|
|
94
|
+
* @returns {object}
|
|
95
|
+
*/
|
|
96
|
+
pictureOf(session) {
|
|
97
|
+
if (!session || session.isStep !== true) {
|
|
98
|
+
return session;
|
|
99
|
+
}
|
|
100
|
+
for (const other of this.familyOf(session)) {
|
|
101
|
+
if (other.isStep !== true && other.audioOnly !== true) {
|
|
102
|
+
return other;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return session;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Which variant a session IS, as a height. Zero encode height means "keep the
|
|
110
|
+
* source", so the source's own height is the answer.
|
|
111
|
+
*
|
|
112
|
+
* Settled once and then kept, because it is a NAME — the player addresses the
|
|
113
|
+
* variant by it for the whole session, having fetched the master exactly
|
|
114
|
+
* once. The height a session encodes at is not stable: the realtime budget
|
|
115
|
+
* steps it down when the host cannot keep up. Deriving the name afresh each
|
|
116
|
+
* time would mean a downshift silently renames the variant the viewer is
|
|
117
|
+
* watching, and the next segment request under the old name would build a
|
|
118
|
+
* SECOND session at the height the host had just proved it could not manage.
|
|
119
|
+
* A downshift changes the picture inside the variant instead, which is what
|
|
120
|
+
* it has always done.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} session
|
|
123
|
+
* @returns {number}
|
|
124
|
+
*/
|
|
125
|
+
variantHeightOf(session) {
|
|
126
|
+
if (Number.isInteger(session.variantHeight) && session.variantHeight > 0) {
|
|
127
|
+
return session.variantHeight;
|
|
128
|
+
}
|
|
129
|
+
const encodeHeight = Number(session.output.encodeHeight) || 0;
|
|
130
|
+
session.variantHeight = encodeHeight > 0
|
|
131
|
+
? encodeHeight
|
|
132
|
+
: Math.round(Number(session.file.height) || 0);
|
|
133
|
+
return session.variantHeight;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The height a session's encoder is actually producing, or 0 when it produces
|
|
138
|
+
* no encoded picture of its own (a copy, or a soundtrack).
|
|
139
|
+
*
|
|
140
|
+
* A COPY must never be adopted: it costs no encoder at all, so handing it to
|
|
141
|
+
* a request for a re-encoded rung would give away the one thing this host can
|
|
142
|
+
* always serve.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} session
|
|
145
|
+
* @returns {number}
|
|
146
|
+
*/
|
|
147
|
+
producedHeightOf(session) {
|
|
148
|
+
if (!session || session.transcodeVideo !== true || session.audioOnly === true) {
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
return Math.round(Number(session.output.encodeHeight) || 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The heights this file's variants CAN be spliced at — a fact about the
|
|
156
|
+
* source and the cut grid, settled once and never moved.
|
|
157
|
+
*
|
|
158
|
+
* Separate from which of them are worth OFFERING to the viewer right now, on
|
|
159
|
+
* a machine whose load moves every five seconds. Both were the same list
|
|
160
|
+
* until 2026-08-18, and that is what broke playback outright: the browser is
|
|
161
|
+
* told at session creation that a master playlist exists, and 192 ms later —
|
|
162
|
+
* after the session's own encoder had started and the first supply reading
|
|
163
|
+
* had arrived — the live list had fallen from five rungs to one,
|
|
164
|
+
* `buildMasterPlaylist` returned null for having fewer than two, and the
|
|
165
|
+
* master answered 404 to the very session that had just published it. hls.js
|
|
166
|
+
* treats that as fatal and unrecoverable, so nothing played at all (session
|
|
167
|
+
* `4ef731d8`, "Moana (2016).mkv", 17:43:01).
|
|
168
|
+
*
|
|
169
|
+
* A live figure may decide what to offer. It may not decide whether a
|
|
170
|
+
* published document exists.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} session
|
|
173
|
+
* @returns {number[]} Largest first.
|
|
174
|
+
*/
|
|
175
|
+
splicableHeights(session) {
|
|
176
|
+
const owner = this.pictureOf(session);
|
|
177
|
+
if (Array.isArray(owner.splicableHeights)) {
|
|
178
|
+
return owner.splicableHeights;
|
|
179
|
+
}
|
|
180
|
+
const heights = new Set(variantHeightsFor(Number(owner.file.height) || 0));
|
|
181
|
+
const own = this.variantHeightOf(owner);
|
|
182
|
+
if (own > 0) {
|
|
183
|
+
heights.add(own);
|
|
184
|
+
}
|
|
185
|
+
owner.splicableHeights = [...heights].sort((left, right) => right - left);
|
|
186
|
+
return owner.splicableHeights;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Whether this stream publishes a master playlist at all — that is, whether
|
|
191
|
+
* there is anything for a player to move BETWEEN.
|
|
192
|
+
*
|
|
193
|
+
* Asked in one place because two callers depend on the same answer and used
|
|
194
|
+
* to compute it differently: the builder refused a copied stream whose cut
|
|
195
|
+
* grid is a fiction, while the budget looked only at how many heights could
|
|
196
|
+
* in principle be spliced. A copy with no readable keyframe index therefore
|
|
197
|
+
* had requests recorded against it — asking a player with no variants to
|
|
198
|
+
* change variant, once every window, for the whole film.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} session
|
|
201
|
+
* @returns {boolean}
|
|
202
|
+
*/
|
|
203
|
+
publishesVariants(session) {
|
|
204
|
+
const owner = this.pictureOf(session);
|
|
205
|
+
// A copy can only be cut where the source already has a keyframe, so a rung
|
|
206
|
+
// meant to splice into it has to be cut at exactly those times. A copy that
|
|
207
|
+
// fell back to an even grid ffmpeg does not cut on has nothing to align to.
|
|
208
|
+
if (!owner.transcodeVideo && owner.timeline.cutGrid !== "keyframe") {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return this.splicableHeights(owner).length >= 2;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What one output is encoded AS, decided once.
|
|
3
|
+
*
|
|
4
|
+
* `OutputSpec` says what an output is — which tracks, in what form, cut how.
|
|
5
|
+
* This is the other half: the shape the encoder is actually given for it. The
|
|
6
|
+
* box in pixels, the frame rate, the speed setting, whether the picture is tone
|
|
7
|
+
* mapped down from HDR.
|
|
8
|
+
*
|
|
9
|
+
* **Why it is not a fact of a session.** It is decided by the realtime budget
|
|
10
|
+
* at the moment a session is created — what this machine could hold just then —
|
|
11
|
+
* so two sessions of one output, made minutes apart, could be given different
|
|
12
|
+
* shapes while claiming the same identity. Everything downstream assumes
|
|
13
|
+
* otherwise: a segment of one is supposed to be interchangeable with a segment
|
|
14
|
+
* of the other, and the master playlist names one `RESOLUTION` for both.
|
|
15
|
+
*
|
|
16
|
+
* Decided once per output and held here, that cannot happen. What the budget
|
|
17
|
+
* learns afterwards moves the RATE cap, which is deliberately not here: rate
|
|
18
|
+
* control appears in neither the SPS nor the PPS, so it can move under a player
|
|
19
|
+
* that has already cached the init. The size cannot, which is exactly why the
|
|
20
|
+
* size belongs to the output and the cap belongs to the run.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export class Output {
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} params
|
|
26
|
+
* @param {number} params.encodeWidth - 0 means the source's own width.
|
|
27
|
+
* @param {number} params.encodeHeight - 0 means the source's own height.
|
|
28
|
+
* @param {number} params.outputFps
|
|
29
|
+
* @param {string | null} params.softwarePreset - The speed setting, where the
|
|
30
|
+
* encoder has a ladder and one was chosen from it.
|
|
31
|
+
* @param {boolean} params.applyTonemap
|
|
32
|
+
*/
|
|
33
|
+
constructor({ encodeWidth, encodeHeight, outputFps, softwarePreset, applyTonemap }) {
|
|
34
|
+
this.encodeWidth = Number.isFinite(encodeWidth) ? encodeWidth : 0;
|
|
35
|
+
this.encodeHeight = Number.isFinite(encodeHeight) ? encodeHeight : 0;
|
|
36
|
+
this.outputFps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : 0;
|
|
37
|
+
this.softwarePreset = typeof softwarePreset === "string" ? softwarePreset : null;
|
|
38
|
+
this.applyTonemap = applyTonemap === true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The shapes this proxy has decided, one per output.
|
|
44
|
+
*
|
|
45
|
+
* Keyed by `OutputSpec.toKey()` and by nothing else: the shape is a property of
|
|
46
|
+
* what is being produced, and two requests that produce the same thing must be
|
|
47
|
+
* given the same one however far apart they arrive.
|
|
48
|
+
*/
|
|
49
|
+
export class Outputs {
|
|
50
|
+
/** @type {Map<string, Output>} */
|
|
51
|
+
#byKey = new Map();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The shape for this output, decided by `decide` the first time it is asked
|
|
55
|
+
* for and never again.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} key
|
|
58
|
+
* @param {() => Output} decide
|
|
59
|
+
* @returns {Output}
|
|
60
|
+
*/
|
|
61
|
+
get(key, decide) {
|
|
62
|
+
let output = this.#byKey.get(key);
|
|
63
|
+
if (!output) {
|
|
64
|
+
output = decide();
|
|
65
|
+
this.#byKey.set(key, output);
|
|
66
|
+
}
|
|
67
|
+
return output;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drop every shape nobody is holding.
|
|
72
|
+
*
|
|
73
|
+
* Same reason the timelines are swept: a map that only grows is the shape of
|
|
74
|
+
* half the memory faults recorded in this project.
|
|
75
|
+
*
|
|
76
|
+
* @param {Set<Output>} inUse
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
forgetUnused(inUse) {
|
|
80
|
+
let dropped = 0;
|
|
81
|
+
for (const [key, output] of [...this.#byKey]) {
|
|
82
|
+
if (!inUse.has(output)) {
|
|
83
|
+
this.#byKey.delete(key);
|
|
84
|
+
dropped += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return dropped;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @returns {number} */
|
|
91
|
+
get size() {
|
|
92
|
+
return this.#byKey.size;
|
|
93
|
+
}
|
|
94
|
+
}
|