@torrent-tv/proxy 2.94.2 → 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.
Files changed (34) hide show
  1. package/assets/calibration/NOTICE.md +25 -0
  2. package/assets/calibration/cal-av1-1080-hi.mp4 +0 -0
  3. package/assets/calibration/cal-av1-1080-lo.mp4 +0 -0
  4. package/assets/calibration/cal-av1-480-hi.mp4 +0 -0
  5. package/assets/calibration/cal-av1-480-lo.mp4 +0 -0
  6. package/assets/calibration/cal-mpeg2-1080-hi.mp4 +0 -0
  7. package/assets/calibration/cal-mpeg2-1080-lo.mp4 +0 -0
  8. package/assets/calibration/cal-mpeg2-480-hi.mp4 +0 -0
  9. package/assets/calibration/cal-mpeg2-480-lo.mp4 +0 -0
  10. package/assets/calibration/cal-mpeg4-1080-hi.mp4 +0 -0
  11. package/assets/calibration/cal-mpeg4-1080-lo.mp4 +0 -0
  12. package/assets/calibration/cal-mpeg4-480-hi.mp4 +0 -0
  13. package/assets/calibration/cal-mpeg4-480-lo.mp4 +0 -0
  14. package/package.json +1 -1
  15. package/server.js +0 -2
  16. package/services/encode/EncodeOrchestrator.js +10 -0
  17. package/services/encode/LocalObservations.js +11 -2
  18. package/services/encode/OutputOpening.js +3 -6
  19. package/services/encode/SoftwareEncoder.js +2 -2
  20. package/services/encode/available-share.js +5 -1
  21. package/services/encode/calibration.js +15 -2
  22. package/services/encode/decode-cost-fit.js +12 -0
  23. package/services/encode/host-load.js +34 -0
  24. package/services/encode/hwaccel.js +139 -125
  25. package/services/encode/quality/ColdStarts.js +78 -0
  26. package/services/encode/quality/EncodeCost.js +119 -47
  27. package/services/encode/quality/OutputQualityState.js +2 -0
  28. package/services/encode/quality/QualityController.js +9 -5
  29. package/services/media/container/h264-configuration.js +7 -14
  30. package/services/media/playback-planner.js +10 -20
  31. package/services/server/SegmentServing.js +3 -3
  32. package/services/server/ViewerRequests.js +1 -10
  33. package/services/server/wire-outputs.js +14 -19
  34. 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.94.2",
3
+ "version": "2.95.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
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. The same length the host timings keep
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`, `hostTimings`, `returns`, `encodeRuns`
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.hostTimings.noteOutputCreated(session, createEntryMs);
678
- this.#host.encodeCost.notePredictionFor(session, output.encodeHeight);
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 `benchmarkSoftwarePresets` measures and `pickSoftwarePreset`
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 `benchmarkSoftwarePresets`, in " +
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 encoder benchmark measures a QUIET host: one ffmpeg, nothing else. A real
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 realtimes = await speed(rung, rawPath, frame);
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
  *