@torrent-tv/proxy 2.94.3 → 2.95.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/assets/calibration/NOTICE.md +25 -0
- package/assets/calibration/cal-av1-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-av1-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-av1-480-hi.mp4 +0 -0
- package/assets/calibration/cal-av1-480-lo.mp4 +0 -0
- package/assets/calibration/cal-mpeg2-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-mpeg2-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-mpeg2-480-hi.mp4 +0 -0
- package/assets/calibration/cal-mpeg2-480-lo.mp4 +0 -0
- package/assets/calibration/cal-mpeg4-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-mpeg4-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-mpeg4-480-hi.mp4 +0 -0
- package/assets/calibration/cal-mpeg4-480-lo.mp4 +0 -0
- package/package.json +1 -1
- package/server.js +0 -2
- package/services/encode/EncodeOrchestrator.js +10 -0
- package/services/encode/LocalObservations.js +11 -2
- package/services/encode/OutputOpening.js +3 -6
- package/services/encode/SoftwareEncoder.js +2 -2
- package/services/encode/available-share.js +5 -1
- package/services/encode/calibration.js +15 -2
- package/services/encode/decode-cost-fit.js +12 -0
- package/services/encode/host-load.js +34 -0
- package/services/encode/hwaccel.js +139 -125
- package/services/encode/quality/ColdStarts.js +78 -0
- package/services/encode/quality/EncodeCost.js +119 -47
- package/services/encode/quality/OutputQualityState.js +2 -0
- package/services/encode/quality/QualityController.js +9 -5
- package/services/media/playback-planner.js +10 -20
- package/services/server/SegmentServing.js +3 -3
- package/services/server/ViewerRequests.js +1 -10
- package/services/server/wire-outputs.js +14 -19
- package/services/encode/quality/HostTimings.js +0 -249
|
@@ -17,6 +17,31 @@ H.264 High, 24 fps, 2 s each, no audio:
|
|
|
17
17
|
| `cal-h264-480-hi.mp4` | 854×480 | 9.48 Mbit/s |
|
|
18
18
|
| `cal-h264-480-lo.mp4` | 854×480 | 1.25 Mbit/s |
|
|
19
19
|
|
|
20
|
+
The sets for the other codec families are made from `cal-h264-1080-hi.mp4`
|
|
21
|
+
above, scaled and re-encoded with ffmpeg 8.1, 2 s, 24 fps, no audio
|
|
22
|
+
(torrent-tv/meta#3). The bitrates are what the encoders produced; libxvid and
|
|
23
|
+
the MPEG-2 encoder at a 9 Mbit/s target stop at their best quantizer on this
|
|
24
|
+
short clip, so the high clips use a fixed quantizer instead:
|
|
25
|
+
|
|
26
|
+
| file | encoder and settings | resolution | bitrate |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| `cal-mpeg4-1080-hi.mp4` | `mpeg4 -q:v 2 -bf 2 -g 48 -mbd 2` | 1920×1080 | 5.98 Mbit/s |
|
|
29
|
+
| `cal-mpeg4-1080-lo.mp4` | `mpeg4 -b:v 1M -maxrate 1M -bufsize 2M -qmin 1 -bf 2 -g 48 -mbd 2` | 1920×1080 | 1.25 Mbit/s |
|
|
30
|
+
| `cal-mpeg4-480-hi.mp4` | `mpeg4 -q:v 1 -qmin 1 -bf 2 -g 48 -mbd 2` | 854×480 | 3.93 Mbit/s |
|
|
31
|
+
| `cal-mpeg4-480-lo.mp4` | `mpeg4 -b:v 400k -maxrate 400k -bufsize 1M -bf 2 -g 48 -mbd 2` | 854×480 | 0.54 Mbit/s |
|
|
32
|
+
| `cal-mpeg2-1080-hi.mp4` | `mpeg2video -b:v 9M -maxrate 9M -bufsize 18M -qmin 1 -bf 2 -g 12` | 1920×1080 | 7.45 Mbit/s |
|
|
33
|
+
| `cal-mpeg2-1080-lo.mp4` | `mpeg2video -b:v 1M -maxrate 1M -bufsize 4M -qmin 1 -bf 2 -g 12` | 1920×1080 | 2.80 Mbit/s |
|
|
34
|
+
| `cal-mpeg2-480-hi.mp4` | `mpeg2video -q:v 1 -qmin 1 -bf 2 -g 12` | 854×480 | 5.95 Mbit/s |
|
|
35
|
+
| `cal-mpeg2-480-lo.mp4` | `mpeg2video -b:v 500k -maxrate 500k -bufsize 2M -bf 2 -g 12` | 854×480 | 0.97 Mbit/s |
|
|
36
|
+
| `cal-av1-1080-hi.mp4` | `libsvtav1 -preset 8 -b:v 9M -g 48` | 1920×1080 | 5.07 Mbit/s |
|
|
37
|
+
| `cal-av1-1080-lo.mp4` | `libsvtav1 -preset 8 -b:v 1M -g 48` | 1920×1080 | 0.52 Mbit/s |
|
|
38
|
+
| `cal-av1-480-hi.mp4` | `libsvtav1 -preset 8 -b:v 9M -g 48` | 854×480 | 4.38 Mbit/s |
|
|
39
|
+
| `cal-av1-480-lo.mp4` | `libsvtav1 -preset 8 -b:v 1M -g 48` | 854×480 | 0.56 Mbit/s |
|
|
40
|
+
|
|
41
|
+
The MPEG-4 Part 2 set is decoded by the same decoder (`mpeg4`) as an XviD or
|
|
42
|
+
DivX 4+ release. VC-1 has no set because ffmpeg has no VC-1 encoder, and
|
|
43
|
+
10-bit AV1 has none yet; both are priced as H.264, and the startup log says so.
|
|
44
|
+
|
|
20
45
|
## Why six, and why this grid
|
|
21
46
|
|
|
22
47
|
`services/encode/hwaccel.js` decodes them at startup and fits the host's decode cost,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -943,8 +943,6 @@ export async function startProxyServer({
|
|
|
943
943
|
return params ? await containerOrchestrator.getTracks(params) : [];
|
|
944
944
|
},
|
|
945
945
|
warmKeyframeIndex: (params) => keyframeTables.warm(params),
|
|
946
|
-
expectedFirstSegmentMs: () => outputParts.hostTimings.expectedFirstSegmentMs(),
|
|
947
|
-
expectedSessionCreateMs: () => outputParts.hostTimings.expectedSessionCreateMs(),
|
|
948
946
|
// The quality menu is on screen from the moment a file is opened, so the
|
|
949
947
|
// heights this host can actually serve have to be answerable before any
|
|
950
948
|
// encoder exists — from the probe and the startup benchmarks alone.
|
|
@@ -119,6 +119,16 @@ export class EncodeOrchestrator {
|
|
|
119
119
|
this.#costs.noteStartup(measured);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* What a stop and a start cost on this host, in seconds, as the plan reads
|
|
124
|
+
* them — for anyone else pricing a fresh encoder by the same figures.
|
|
125
|
+
*
|
|
126
|
+
* @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
|
|
127
|
+
*/
|
|
128
|
+
runCostSeconds() {
|
|
129
|
+
return this.#costs.seconds();
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
/** Derived segment demand for one output, copied from the discardable cache. */
|
|
123
133
|
wantedSegmentsOn(address) {
|
|
124
134
|
return [...this.#demand.mapOn(address)];
|
|
@@ -41,13 +41,22 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
44
46
|
import { configurationKeyOf, kindOfEncoderName } from "./fingerprint.js";
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The directory of the installed proxy, where these observations are kept when
|
|
50
|
+
* the deployment names no state directory. Deliberately not the working
|
|
51
|
+
* directory: for an ordinary `npm i -g` install that is wherever the operator
|
|
52
|
+
* launched from, which would split the history between runs.
|
|
53
|
+
*/
|
|
54
|
+
export const PROXY_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
55
|
+
|
|
46
56
|
/**
|
|
47
57
|
* How many readings are kept per key. A history window, not a figure about the
|
|
48
58
|
* machine: long enough that one run cannot decide, short enough that the
|
|
49
|
-
* figure follows the host as it changes.
|
|
50
|
-
* (`quality/HostTimings.js`).
|
|
59
|
+
* figure follows the host as it changes.
|
|
51
60
|
*/
|
|
52
61
|
const READINGS_KEPT = 20;
|
|
53
62
|
|
|
@@ -44,7 +44,7 @@ export class OutputOpening {
|
|
|
44
44
|
#host;
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* @param {object} host - `logger`, `readSourceMedia`, `enabled`, `segmentFormat`, `renditions`, `sourceFiles`, `getCachedMediaInfo`, `localBaseUrl`, `tonemapSupported`, `videoEncoder`, `hostLoad`, `keyframeTables`, `timelines`, `segmentDurationSec`, `softwarePresetBenchmark`, `decodeCostModel`, `encodeCost`, `admission`, `outputs`, `segmentOutputFiles`, `
|
|
47
|
+
* @param {object} host - `logger`, `readSourceMedia`, `enabled`, `segmentFormat`, `renditions`, `sourceFiles`, `getCachedMediaInfo`, `localBaseUrl`, `tonemapSupported`, `videoEncoder`, `hostLoad`, `keyframeTables`, `timelines`, `segmentDurationSec`, `softwarePresetBenchmark`, `decodeCostModel`, `encodeCost`, `admission`, `outputs`, `segmentOutputFiles`, `coldStarts`, `returns`, `encodeRuns`
|
|
48
48
|
* @param {SegmentOutputFiles} host.segmentOutputFiles - The storage
|
|
49
49
|
* operations needed while selecting or opening an output.
|
|
50
50
|
*/
|
|
@@ -627,9 +627,6 @@ export class OutputOpening {
|
|
|
627
627
|
}
|
|
628
628
|
}
|
|
629
629
|
|
|
630
|
-
// Only a session actually made is timed: a viewer joining one costs none
|
|
631
|
-
// of what this figure predicts for the next viewer who has to wait.
|
|
632
|
-
this.#host.hostTimings.rememberSessionCreateLatency(Date.now() - createEntryMs);
|
|
633
630
|
const sessionId = spec.toName();
|
|
634
631
|
const output = new Output({
|
|
635
632
|
encodeWidth: width,
|
|
@@ -674,8 +671,8 @@ export class OutputOpening {
|
|
|
674
671
|
this.#host.returns.note({ lastReadAt: this.#host.segmentOutputFiles.lastReadAt(spec.toKey()), now: Date.now() });
|
|
675
672
|
this.#host.segmentOutputFiles.directoryFor(spec.toKey());
|
|
676
673
|
this.#host.segmentOutputFiles.useFormat(spec.toKey(), segmentFormat);
|
|
677
|
-
this.#host.
|
|
678
|
-
this.#host.encodeCost.notePredictionFor(session
|
|
674
|
+
this.#host.coldStarts.noteOutputCreated(session, createEntryMs);
|
|
675
|
+
this.#host.encodeCost.notePredictionFor(session);
|
|
679
676
|
// Decided before the key was built and only recorded here. Whether the audio
|
|
680
677
|
// travels separately decides the ffmpeg arguments, what the master says,
|
|
681
678
|
// whether the rendition route answers at all AND what the session is keyed
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
/**
|
|
20
20
|
* The presets the startup benchmark walks, slowest first.
|
|
21
21
|
*
|
|
22
|
-
* The same list `
|
|
22
|
+
* The same list `calibrateEncoder` measures and `pickSoftwarePreset`
|
|
23
23
|
* chooses from; it is stated here because it is a property of this kind, and
|
|
24
24
|
* the four hardware kinds beside it show what its absence looks like.
|
|
25
25
|
*/
|
|
@@ -37,7 +37,7 @@ export class SoftwareEncoder extends Encoder {
|
|
|
37
37
|
values: PRESETS,
|
|
38
38
|
measured: true,
|
|
39
39
|
note:
|
|
40
|
-
"Measured on every host at startup by `
|
|
40
|
+
"Measured on every host at startup by `calibrateEncoder`, in " +
|
|
41
41
|
"pixels per second per preset, and chosen from by `pickSoftwarePreset`."
|
|
42
42
|
};
|
|
43
43
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file How much of the machine a new encoder can actually have.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The startup measurements are filed for a QUIET host: one ffmpeg, nothing
|
|
5
|
+
* else. The host is not quiet while they are taken, so each reading is divided
|
|
6
|
+
* by the share of the machine other work left it (`freeShareDuring`,
|
|
7
|
+
* torrent-tv/meta#3) — otherwise a reading taken while other work ran would be
|
|
8
|
+
* lowered once there and again here. A real
|
|
5
9
|
* encode runs on a machine that is also downloading, hashing and serving, and
|
|
6
10
|
* on the addon host that machine was measured 99 % busy — `ffmpeg=52-60%
|
|
7
11
|
* proxy=17-24% system=99%` — while a step predicted at 1.83x ran at 1.01-1.12x
|
|
@@ -196,7 +196,8 @@ export async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, run
|
|
|
196
196
|
* @param {number} [params.segmentDurationSec]
|
|
197
197
|
* @param {Array<{ width: number, height: number }>} [params.frames]
|
|
198
198
|
* @param {object} [params.measure] - Replaces the three readings, for a check
|
|
199
|
-
* that must not run ffmpeg: `check`, `rawFrames`, `speed`.
|
|
199
|
+
* that must not run ffmpeg: `check`, `rawFrames`, `speed`. `speed` answers a
|
|
200
|
+
* number, or `{ speed, freeShare }` as `measureEncodeSlope` does.
|
|
200
201
|
* @returns {Promise<{ modes: CalibratedMode[], refused: Array<{ preset: string, reason: string }> }>}
|
|
201
202
|
*/
|
|
202
203
|
export async function calibrateEncoder({
|
|
@@ -236,6 +237,8 @@ export async function calibrateEncoder({
|
|
|
236
237
|
|
|
237
238
|
/** @type {Map<string | null, SizeReading[]>} */
|
|
238
239
|
const readings = new Map(qualified.map((rung) => [rung, []]));
|
|
240
|
+
/** The share of the machine other work left each reading. @type {number[]} */
|
|
241
|
+
const freeShares = [];
|
|
239
242
|
const sortedFrames = [...frames].sort((left, right) => left.width * left.height - right.width * right.height);
|
|
240
243
|
for (const frame of sortedFrames) {
|
|
241
244
|
if (qualified.length === 0) {
|
|
@@ -251,7 +254,13 @@ export async function calibrateEncoder({
|
|
|
251
254
|
// Fastest first: the ladder is slowest first, so walk it backwards.
|
|
252
255
|
for (let index = qualified.length - 1; index >= 0; index -= 1) {
|
|
253
256
|
const rung = qualified[index];
|
|
254
|
-
const
|
|
257
|
+
const reading = await speed(rung, rawPath, frame);
|
|
258
|
+
// A number from a check that replaces the reading; the reading itself
|
|
259
|
+
// carries the share of the machine other work left it.
|
|
260
|
+
const realtimes = typeof reading === "number" ? reading : (reading?.speed ?? null);
|
|
261
|
+
if (Number.isFinite(reading?.freeShare)) {
|
|
262
|
+
freeShares.push(reading.freeShare);
|
|
263
|
+
}
|
|
255
264
|
if (realtimes === null) {
|
|
256
265
|
log.warn(`calibration: ${encoder.name} "${rung ?? "as it comes"}" gave no reading at ${frame.width}x${frame.height}`);
|
|
257
266
|
break;
|
|
@@ -314,6 +323,10 @@ export async function calibrateEncoder({
|
|
|
314
323
|
log.info(
|
|
315
324
|
`calibration: ${encoder.name} ${modes.length} of ${rungs.length} mode(s) usable ` +
|
|
316
325
|
`(${((Date.now() - startedAt) / 1000).toFixed(1)}s)` +
|
|
326
|
+
(freeShares.length > 0
|
|
327
|
+
? `; other work took ${Math.round((1 - Math.max(...freeShares)) * 100)}-${Math.round((1 - Math.min(...freeShares)) * 100)}% ` +
|
|
328
|
+
"of the machine during the readings, and the figures are for a machine with nothing else running"
|
|
329
|
+
: encoder.kind === "software" ? "; the machine's load during the readings was not readable, so the figures are as read" : "") +
|
|
317
330
|
(refused.length > 0 ? `; not usable: ${refused.map((one) => `${one.preset} (${one.reason})`).join(", ")}` : "")
|
|
318
331
|
);
|
|
319
332
|
return { modes, refused };
|
|
@@ -337,6 +337,18 @@ export function decodeFamilyOf(source) {
|
|
|
337
337
|
if (codec === "hevc" || codec === "h265" || codec === "x265") {
|
|
338
338
|
return tenBit ? "hevc10" : "hevc";
|
|
339
339
|
}
|
|
340
|
+
// ffmpeg's own names. Only what has a clip set: `msmpeg4v3` (DivX 3) is a
|
|
341
|
+
// different decoder from `mpeg4`, and 10-bit AV1 was not measured, so both
|
|
342
|
+
// fall through to H.264 like every other unmeasured codec.
|
|
343
|
+
if (codec === "mpeg4") {
|
|
344
|
+
return "mpeg4";
|
|
345
|
+
}
|
|
346
|
+
if (codec === "mpeg2video") {
|
|
347
|
+
return "mpeg2";
|
|
348
|
+
}
|
|
349
|
+
if (codec === "av1" && !tenBit) {
|
|
350
|
+
return "av1";
|
|
351
|
+
}
|
|
340
352
|
return "h264";
|
|
341
353
|
}
|
|
342
354
|
|
|
@@ -171,6 +171,40 @@ export async function sampleHost(pid) {
|
|
|
171
171
|
return { takenAt: Date.now(), processCpuSeconds, system };
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The share of the machine a measured process was LEFT by everything else,
|
|
176
|
+
* between two readings taken with `sampleHost(pid)` (torrent-tv/meta#3).
|
|
177
|
+
*
|
|
178
|
+
* A startup measurement reads how fast one ffmpeg runs, and the offer later
|
|
179
|
+
* multiplies that figure by the share of the machine free at the time of the
|
|
180
|
+
* question (`available-share.js`). That is right only if the measurement itself
|
|
181
|
+
* was taken on a machine with nothing else running, which it is not: measured on
|
|
182
|
+
* the addon host on 2026-10-07, other work took 17-53 % of the four cores during
|
|
183
|
+
* the readings, and the same mode read 2.08x, 3.10x and 3.08x while ffmpeg's own
|
|
184
|
+
* processor time per second of video stayed within 3 %. Divided by this share,
|
|
185
|
+
* the three readings come to 3.60x, 3.77x and 3.79x.
|
|
186
|
+
*
|
|
187
|
+
* Null when the host does not report its load (no `/proc`), or a reading was
|
|
188
|
+
* refused; the measurement is then used as it was read, and says so.
|
|
189
|
+
*
|
|
190
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: object | null } | null} before
|
|
191
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: object | null } | null} after
|
|
192
|
+
* @param {number} [cores]
|
|
193
|
+
* @returns {number | null} Between 0 and 1.
|
|
194
|
+
*/
|
|
195
|
+
export function freeShareDuring(before, after, cores = os.cpus().length) {
|
|
196
|
+
if (!before || !after) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const shares = shareOfMachine(before, after, cores);
|
|
200
|
+
if (shares === null || shares.systemShare === null || shares.processShare === null) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const others = Math.min(1, Math.max(0, shares.systemShare - shares.processShare));
|
|
204
|
+
const free = 1 - others;
|
|
205
|
+
return free > 0 ? free : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
174
208
|
/**
|
|
175
209
|
* How much CPU THIS process has used, across every thread it owns.
|
|
176
210
|
*
|