@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.
Files changed (33) 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/playback-planner.js +10 -20
  30. package/services/server/SegmentServing.js +3 -3
  31. package/services/server/ViewerRequests.js +1 -10
  32. package/services/server/wire-outputs.js +14 -19
  33. package/services/encode/quality/HostTimings.js +0 -249
@@ -37,6 +37,7 @@ import {
37
37
 
38
38
  import { keyFrameArgs, TRANSCODE_FPS } from "./args.js";
39
39
  import { throughputAt } from "./throughput.js";
40
+ import { freeShareDuring, sampleHost } from "./host-load.js";
40
41
  import { LADDER_HEIGHTS } from "./output/ladder.js";
41
42
  // The five kinds, one class each. Detection and benchmarking stay in this file;
42
43
  // how a kind is driven belongs to the kind.
@@ -481,6 +482,28 @@ const CALIBRATION_SETS = {
481
482
  "cal-hevc10-1080-lo.mp4",
482
483
  "cal-hevc10-480-hi.mp4",
483
484
  "cal-hevc10-480-lo.mp4"
485
+ ],
486
+ // MPEG-4 Part 2 is what XviD and DivX releases carry — about 8 % of the
487
+ // releases surveyed on 2026-07-10 — and it was priced as H.264 until these
488
+ // (torrent-tv/meta#3). MPEG-2 is what DVD rips carry, AV1 a growing share of
489
+ // recent releases. VC-1 has no set: ffmpeg has no VC-1 encoder to make one.
490
+ mpeg4: [
491
+ "cal-mpeg4-1080-hi.mp4",
492
+ "cal-mpeg4-1080-lo.mp4",
493
+ "cal-mpeg4-480-hi.mp4",
494
+ "cal-mpeg4-480-lo.mp4"
495
+ ],
496
+ mpeg2: [
497
+ "cal-mpeg2-1080-hi.mp4",
498
+ "cal-mpeg2-1080-lo.mp4",
499
+ "cal-mpeg2-480-hi.mp4",
500
+ "cal-mpeg2-480-lo.mp4"
501
+ ],
502
+ av1: [
503
+ "cal-av1-1080-hi.mp4",
504
+ "cal-av1-1080-lo.mp4",
505
+ "cal-av1-480-hi.mp4",
506
+ "cal-av1-480-lo.mp4"
484
507
  ]
485
508
  };
486
509
  export const CALIBRATION_REFERENCE_CLIP = "cal-h264-1080-hi.mp4";
@@ -675,6 +698,9 @@ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBR
675
698
  log.info(
676
699
  `hwaccel: decode cost measured for ${Object.keys(families).join(", ")}` +
677
700
  (missing.length > 0 ? `; ${missing.join(" and ")} priced as H.264` : "") +
701
+ // Said, because it is a fact about every film in those codecs: VC-1 has
702
+ // no set (ffmpeg cannot encode one), and neither do DivX 3 or 10-bit AV1.
703
+ "; every other codec is priced as H.264" +
678
704
  ` (in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
679
705
  );
680
706
  return { families, ...families.h264 };
@@ -700,7 +726,7 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
700
726
  );
701
727
  if (!streams) {
702
728
  log.warn(
703
- `hwaccel: ${family} cannot be lifted out of its container — no Annex-B filter is mapped for it, ` +
729
+ `hwaccel: ${family} cannot be lifted out of its container — no elementary-stream form is mapped for it, ` +
704
730
  `so its clips were never measured`
705
731
  );
706
732
  return null;
@@ -715,7 +741,13 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
715
741
  );
716
742
  return null;
717
743
  }
718
- const cost = 1 / measured.speed;
744
+ // On a quiet-machine basis, like every other startup figure the offer
745
+ // multiplies by the share free at the time of the question. Measured on the
746
+ // addon host on 2026-10-07: one clip decoded at 10.74x, 13.29x and 13.24x
747
+ // with other work taking 35 %, 19 % and 19 % of the machine, and at 16.4x,
748
+ // 16.3x and 16.3x once that share was divided out (torrent-tv/meta#3).
749
+ const quietSpeed = Number.isFinite(measured.freeShare) ? measured.speed / measured.freeShare : measured.speed;
750
+ const cost = 1 / quietSpeed;
719
751
  samples.push({
720
752
  megapixelsPerSecond: measured.megapixelsPerSecond,
721
753
  megabitsPerSecond: measured.megabitsPerSecond,
@@ -723,8 +755,11 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
723
755
  });
724
756
  log.info(
725
757
  `hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
726
- `${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
727
- `(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
758
+ `${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${quietSpeed.toFixed(1)}x ` +
759
+ (Number.isFinite(measured.freeShare)
760
+ ? `(read ${measured.speed.toFixed(1)}x with other work taking ${Math.round((1 - measured.freeShare) * 100)}% of the machine; `
761
+ : "(the machine's load was not readable, so as read; ") +
762
+ `cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
728
763
  );
729
764
  }
730
765
  const fitted = fitDecodeCost(samples);
@@ -746,18 +781,27 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
746
781
  }
747
782
 
748
783
  /**
749
- * The bitstream filter and demuxer that turn a clip's video track into a
784
+ * The bitstream filter, muxer and demuxer that turn a clip's video track into a
750
785
  * continuous elementary stream, by codec family.
751
786
  *
752
- * H.264 and HEVC in MP4 keep their parameter sets in the container's `avcC` /
753
- * `hvcC` and their access units length-prefixed; Annex-B carries them inline,
754
- * with start codes, which is what makes plain byte concatenation a valid
755
- * stream. That is the property this whole measurement rests on.
787
+ * The property this whole measurement rests on is that plain byte
788
+ * concatenation of the stream is a valid stream, so every header a decoder
789
+ * needs has to travel inside it. H.264 and HEVC in MP4 keep their parameter
790
+ * sets in the container's `avcC` / `hvcC` and their access units
791
+ * length-prefixed; Annex-B carries them inline, with start codes. MPEG-4 Part 2
792
+ * and MPEG-2 in MP4 keep their headers in the decoder configuration;
793
+ * `dump_extra` puts them back in front of every keyframe. AV1 needs no filter:
794
+ * the low-overhead OBU form carries the sequence header in every keyframe's
795
+ * temporal unit. MPEG-2's raw stream is written as `mpeg2video` and read as
796
+ * `mpegvideo`, which is why the two names are kept apart.
756
797
  */
757
- const ANNEX_B_BY_FAMILY = {
758
- h264: { filter: "h264_mp4toannexb", demuxer: "h264" },
759
- hevc: { filter: "hevc_mp4toannexb", demuxer: "hevc" },
760
- hevc10: { filter: "hevc_mp4toannexb", demuxer: "hevc" }
798
+ const ELEMENTARY_BY_FAMILY = {
799
+ h264: { filter: "h264_mp4toannexb", muxer: "h264", demuxer: "h264" },
800
+ hevc: { filter: "hevc_mp4toannexb", muxer: "hevc", demuxer: "hevc" },
801
+ hevc10: { filter: "hevc_mp4toannexb", muxer: "hevc", demuxer: "hevc" },
802
+ mpeg4: { filter: "dump_extra=freq=keyframe", muxer: "m4v", demuxer: "m4v" },
803
+ mpeg2: { filter: "dump_extra=freq=keyframe", muxer: "mpeg2video", demuxer: "mpegvideo" },
804
+ av1: { filter: null, muxer: "obu", demuxer: "obu" }
761
805
  };
762
806
 
763
807
  /**
@@ -784,8 +828,8 @@ function lastErrorLine(stderr) {
784
828
  const EXTRACT_TIMEOUT_MS = 20_000;
785
829
 
786
830
  /**
787
- * Lift a whole family's clips out of their containers, as Annex-B elementary
788
- * streams, in ONE ffmpeg run.
831
+ * Lift a whole family's clips out of their containers, as elementary streams,
832
+ * in ONE ffmpeg run.
789
833
  *
790
834
  * No re-encoding — the frames are copied — so the work itself is trivial and
791
835
  * the cost is almost entirely the process. Doing one process per clip added
@@ -809,24 +853,22 @@ const EXTRACT_TIMEOUT_MS = 20_000;
809
853
  * One entry per clip, in order; null when the family cannot be lifted at all.
810
854
  */
811
855
  async function extractFamilyStreams(ffmpegBin, clipPaths, family) {
812
- const shape = ANNEX_B_BY_FAMILY[family];
856
+ const shape = ELEMENTARY_BY_FAMILY[family];
813
857
  // A family with no mapping is a hard failure, not a silent fallback to
814
- // H.264's filter. AV1 has no Annex-B form at all (its packaging is OBU), and
815
- // MPEG-2 and VC-1 have no `*_mp4toannexb` filter — so the three families the
816
- // roadmap plans next cannot come through here, and finding that out as
817
- // "the clip failed" would send the reader after the clip.
858
+ // H.264's filter: finding that out as "the clip failed" would send the
859
+ // reader after the clip.
818
860
  if (!shape) {
819
861
  return null;
820
862
  }
821
863
  const workDir = await mkdtemp(path.join(os.tmpdir(), "ttv-calibration-"));
822
- const outputs = clipPaths.map((_, index) => path.join(workDir, `stream-${index}.${shape.demuxer}`));
864
+ const outputs = clipPaths.map((_, index) => path.join(workDir, `stream-${index}.${shape.muxer}`));
823
865
  /** @type {string[]} */
824
866
  const args = ["-hide_banner", "-loglevel", "info", "-nostats", "-y"];
825
867
  for (const clipPath of clipPaths) {
826
868
  args.push("-i", clipPath);
827
869
  }
828
870
  for (const [index, output] of outputs.entries()) {
829
- args.push("-map", `${index}:v:0`, "-c:v", "copy", "-bsf:v", shape.filter, "-f", shape.demuxer, output);
871
+ args.push("-map", `${index}:v:0`, "-c:v", "copy", ...(shape.filter ? ["-bsf:v", shape.filter] : []), "-f", shape.muxer, output);
830
872
  }
831
873
  const stderr = await runCapturingStderr(ffmpegBin, args, EXTRACT_TIMEOUT_MS);
832
874
  try {
@@ -1032,12 +1074,20 @@ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () =
1032
1074
  // reads the same in both cases, only the byte count is kept, for the
1033
1075
  // MB/s figure below — a number to read, not a boolean to trust.
1034
1076
  let bytesWritten = 0;
1035
- const finish = () => {
1077
+ /** The machine at the first kept report, where the window starts. @type {Promise<object | null> | null} */
1078
+ let hostBefore = null;
1079
+ const finish = async () => {
1036
1080
  if (settled) {
1037
1081
  return;
1038
1082
  }
1039
1083
  settled = true;
1040
1084
  clearTimeout(timer);
1085
+ // The machine at the end of the window, read while the decoder still runs
1086
+ // so both readings bound the interval the speed is read over.
1087
+ const hostAfter = hostBefore !== null && child?.pid && child.exitCode === null
1088
+ ? await sampleHost(child.pid).catch(() => null)
1089
+ : null;
1090
+ const freeShare = freeShareDuring(hostBefore === null ? null : await hostBefore, hostAfter);
1041
1091
  try {
1042
1092
  child?.stdin?.destroy();
1043
1093
  } catch {
@@ -1084,13 +1134,18 @@ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () =
1084
1134
  );
1085
1135
  resolve({
1086
1136
  speed,
1137
+ // The share of the machine other work left the decoder over the window,
1138
+ // or null where it could not be read. A caller pricing decoding on a
1139
+ // quiet-machine basis divides by it; a caller measuring what company
1140
+ // costs must not, because there the company IS the other work.
1141
+ freeShare,
1087
1142
  windowSec,
1088
1143
  megapixelsPerSecond: stream.megapixelsPerSecond,
1089
1144
  megabitsPerSecond: stream.megabitsPerSecond,
1090
1145
  pipeThroughputMBps: achievedMBps
1091
1146
  });
1092
1147
  };
1093
- const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
1148
+ const timer = setTimeout(() => void finish(), DECODE_WINDOW_MAX_MS);
1094
1149
  try {
1095
1150
  child = spawn(ffmpegBin, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1096
1151
  } catch (error) {
@@ -1128,12 +1183,16 @@ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () =
1128
1183
  const microseconds = Number(line.slice("out_time_ms=".length));
1129
1184
  if (Number.isFinite(microseconds)) {
1130
1185
  samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6, bytesWritten });
1186
+ if (samples.length === 2 && child?.pid) {
1187
+ // The second report, because the window starts there (below).
1188
+ hostBefore = sampleHost(child.pid).catch(() => null);
1189
+ }
1131
1190
  }
1132
1191
  }
1133
1192
  newline = stdout.indexOf("\n");
1134
1193
  }
1135
1194
  if (samples.length >= 2 && samples[samples.length - 1].wallSec - samples[1].wallSec >= DECODE_WINDOW_MIN_SEC) {
1136
- finish();
1195
+ void finish();
1137
1196
  }
1138
1197
  });
1139
1198
  child.on("error", (error) => {
@@ -1154,7 +1213,7 @@ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () =
1154
1213
  }
1155
1214
  resolve({ error: error instanceof Error ? error.message : String(error) });
1156
1215
  });
1157
- child.on("close", finish);
1216
+ child.on("close", () => void finish());
1158
1217
  feed();
1159
1218
  });
1160
1219
  }
@@ -1342,99 +1401,6 @@ function barFor(cost) {
1342
1401
  return isDecodePriced(cost) ? measured : Math.max(UNPRICED_DECODE_BAR, measured);
1343
1402
  }
1344
1403
 
1345
- /**
1346
- * Benchmark software libx264 presets on this host. Encodes a short synthetic
1347
- * clip at a fixed reference resolution with each preset and measures encoder
1348
- * throughput in pixels/second. The session manager uses this to pick, per
1349
- * stream, the highest-quality preset that still encodes the actual
1350
- * (source-capped) resolution faster than realtime.
1351
- *
1352
- * Runs once at startup; bounded by a per-encode timeout. Presets that fail are
1353
- * omitted from the result.
1354
- *
1355
- * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
1356
- * @returns {Promise<Array<{ preset: string, pixelsPerSec: number }>>} Ordered slowest→fastest.
1357
- */
1358
- export async function benchmarkSoftwarePresets({ ffmpegBin, logger, encoder = null }) {
1359
- const log = logger ?? { info: () => {}, warn: () => {} };
1360
-
1361
- // REAL footage, decoded ONCE into raw frames, and the presets are then timed
1362
- // on those frames.
1363
- //
1364
- // Two reasons, both measured. The pattern this replaced (`testsrc2`) has flat
1365
- // areas and no grain and encodes 1.23x cheaper than film on the same machine
1366
- // and preset — an error that always points at offering a rung the host cannot
1367
- // hold. And feeding a compressed clip to each preset instead would put
1368
- // decoding and scaling inside the measurement: subtracting them afterwards
1369
- // compares a wall clock that includes process startup against a decode figure
1370
- // measured to exclude it, while inside one ffmpeg the two halves overlap. On
1371
- // the fastest preset — the one every ladder decision reads as the ceiling —
1372
- // that subtraction is most of the number being measured, so a small error in
1373
- // it becomes a large error in the answer.
1374
- //
1375
- // Raw frames remove all of it: no decoder, no scaler, nothing to subtract,
1376
- // and no dependence on the decode model. The cost is 25 MB of memory in a
1377
- // pipe for a few seconds.
1378
- const rawFramesPath = await decodeToRawFrames(ffmpegBin, log);
1379
- if (rawFramesPath === null) {
1380
- // Said once more, in the words that matter to whoever reads the log next:
1381
- // with no benchmark, `#sustainableHeights` filters nothing and every rung
1382
- // is offered, which is the failure of 2026-08-14 in full.
1383
- log.warn("hwaccel: the quality ladder is UNFILTERED on this host — nothing measured the encoder");
1384
- return [];
1385
- }
1386
- /** @type {Array<{ preset: string, pixelsPerSec: number }>} */
1387
- const results = [];
1388
- try {
1389
- // THE CHOSEN ENCODER'S OWN LADDER, whatever kind it is. NVENC walks p1…p7,
1390
- // QSV veryfast…veryslow, VAAPI its quality levels. A kind with no ladder is
1391
- // measured once, which is still a reading where there was none at all.
1392
- const ladder = encoder?.speedLadder;
1393
- const rungs = Array.isArray(ladder?.values) && ladder.values.length > 0 ? ladder.values : [null];
1394
- for (const rung of rungs) {
1395
- const speed = await measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath);
1396
- if (speed === null) {
1397
- log.warn(`hwaccel: the benchmark of "${rung ?? encoder?.name}" produced no usable reading; skipping`);
1398
- continue;
1399
- }
1400
- const pixelsPerSec = BENCHMARK_REF_W * BENCHMARK_REF_H * TRANSCODE_FPS * speed;
1401
- results.push({ preset: rung ?? encoder?.name, pixelsPerSec });
1402
- log.info(
1403
- `hwaccel: ${encoder?.name} "${rung ?? "as it comes"}" ~= ${(pixelsPerSec / 1e6).toFixed(1)} Mpx/s ` +
1404
- `(${speed.toFixed(2)}x @ ${BENCHMARK_REF_W}x${BENCHMARK_REF_H}, real footage)`
1405
- );
1406
- }
1407
- } finally {
1408
- // The encoder was killed a moment ago and on Windows the handle outlives
1409
- // the signal, so removal is retried and its failure is not worth a session:
1410
- // this is a temp directory the operating system will clear anyway.
1411
- try {
1412
- rmSync(path.dirname(rawFramesPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
1413
- } catch (error) {
1414
- log.warn(`hwaccel: could not remove the benchmark's raw frames: ${error instanceof Error ? error.message : String(error)}`);
1415
- }
1416
- }
1417
- return results;
1418
- }
1419
-
1420
- /**
1421
- * How fast one preset encodes, from ffmpeg's own reports of how much video it
1422
- * has written — not from the clock around the process.
1423
- *
1424
- * Timing whole runs measures the run STARTING. Measured 2026-08-15 on a desktop
1425
- * that spawns ffmpeg in ~0.4 s: three seconds of raw frames encoded that way
1426
- * put `fast` and `ultrafast` within 1.24x of each other, when libx264's own
1427
- * presets differ by several times — the constant had swallowed the difference.
1428
- * The slope between two progress reports contains no part of the startup.
1429
- *
1430
- * The frames are written repeatedly so there is runway to measure over,
1431
- * whatever the preset's speed.
1432
- *
1433
- * @param {string} ffmpegBin
1434
- * @param {string} preset
1435
- * @param {string} rawFramesPath
1436
- * @returns {Promise<number | null>} Video seconds encoded per second of clock.
1437
- */
1438
1404
  /**
1439
1405
  * Video seconds produced per second of clock, from ffmpeg's own reports.
1440
1406
  *
@@ -1465,7 +1431,37 @@ export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC)
1465
1431
  return slope <= ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED ? slope : null;
1466
1432
  }
1467
1433
 
1434
+ /**
1435
+ * How fast one mode encodes, from ffmpeg's own reports of how much video it
1436
+ * has written — not from the clock around the process.
1437
+ *
1438
+ * Timing whole runs measures the run STARTING. Measured 2026-08-15 on a desktop
1439
+ * that spawns ffmpeg in ~0.4 s: three seconds of raw frames encoded that way
1440
+ * put `fast` and `ultrafast` within 1.24x of each other, when libx264's own
1441
+ * presets differ by several times — the constant had swallowed the difference.
1442
+ * The slope between two progress reports contains no part of the startup.
1443
+ *
1444
+ * The frames are written repeatedly so there is runway to measure over,
1445
+ * whatever the preset's speed.
1446
+ *
1447
+ * THE MACHINE IS NOT QUIET while this runs, and the reading is returned as the
1448
+ * speed this machine gives a software encoder with nothing else running
1449
+ * (torrent-tv/meta#3).
1450
+ *
1451
+ * The figure is used on a quiet-machine basis: the offer later multiplies it by
1452
+ * the share of the machine free at the time of the question
1453
+ * (`available-share.js`). So the share other work took DURING the reading is
1454
+ * divided back out. Measured on the addon host on 2026-10-07: other work took
1455
+ * 17-53 % of the machine during readings, the same mode read 2.08x-3.10x, and
1456
+ * divided by the share left it read 3.60x-3.79x.
1457
+ *
1458
+ * A hardware encoder is limited by its device and not by the cores, so its
1459
+ * reading is kept as read.
1460
+ *
1461
+ * @returns {Promise<{ speed: number, measuredSpeed: number, freeShare: number | null } | null>}
1462
+ */
1468
1463
  export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
1464
+ const cpuBound = encoder?.kind === "software";
1469
1465
  return new Promise((resolve) => {
1470
1466
  const args = [
1471
1467
  "-hide_banner", "-loglevel", "error", "-nostats",
@@ -1491,18 +1487,32 @@ export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, fram
1491
1487
  let buffered = "";
1492
1488
  let child;
1493
1489
  const startedAt = Date.now();
1490
+ /** @type {Promise<object | null> | null} */
1491
+ let hostBefore = null;
1494
1492
  const finish = (value) => {
1495
1493
  if (settled) {
1496
1494
  return;
1497
1495
  }
1498
1496
  settled = true;
1499
1497
  clearTimeout(timer);
1500
- try {
1501
- child?.kill("SIGKILL");
1502
- } catch {
1503
- // already gone
1504
- }
1505
- resolve(value);
1498
+ // The second reading of the machine is taken while the encoder is still
1499
+ // running, so the interval is the one the slope was read over.
1500
+ const hostAfter = value !== null && cpuBound && hostBefore !== null && child?.pid
1501
+ ? sampleHost(child.pid).catch(() => null)
1502
+ : Promise.resolve(null);
1503
+ void Promise.all([hostBefore ?? Promise.resolve(null), hostAfter]).then(([before, after]) => {
1504
+ try {
1505
+ child?.kill("SIGKILL");
1506
+ } catch {
1507
+ // already gone
1508
+ }
1509
+ if (value === null) {
1510
+ resolve(null);
1511
+ return;
1512
+ }
1513
+ const freeShare = freeShareDuring(before, after);
1514
+ resolve({ speed: freeShare === null ? value : value / freeShare, measuredSpeed: value, freeShare });
1515
+ });
1506
1516
  };
1507
1517
  const timer = setTimeout(() => finish(null), ENCODE_BENCHMARK_TIMEOUT_MS);
1508
1518
  try {
@@ -1529,6 +1539,10 @@ export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, fram
1529
1539
  // be taken for a position nine trillion seconds before the start.
1530
1540
  if (Number.isFinite(outSec) && outSec >= 0) {
1531
1541
  samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec });
1542
+ if (samples.length === 1 && cpuBound && child?.pid) {
1543
+ // At the first kept report, which is where the slope starts.
1544
+ hostBefore = sampleHost(child.pid).catch(() => null);
1545
+ }
1532
1546
  }
1533
1547
  }
1534
1548
  newline = buffered.indexOf(NEWLINE);
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @file How long each output took from the request that created it to its
3
+ * first segment being served — measured once per output, said in the log, and
4
+ * kept nowhere else.
5
+ *
6
+ * This is what is left of `HostTimings` (torrent-tv/meta#3). That class also
7
+ * kept the median of recent first-segment and creation times in
8
+ * `host-timings.json`, so a restarted proxy would not quote an assumed figure.
9
+ * It was to go once the time could be computed instead of remembered, and it
10
+ * can: a fresh output's first piece takes what this host was measured to need
11
+ * for a first output (`RunCosts`) or one piece at this output's own measured
12
+ * speed, whichever is longer. The remembered median could never say that —
13
+ * measured on the addon host on 2026-10-06 it ran from 0.4 to 184 times the
14
+ * startup figure, because most of it was the swarm.
15
+ */
16
+
17
+ export class ColdStarts {
18
+ /**
19
+ * Output → when the request that created it arrived, until its first segment
20
+ * is served. Taken out on that first segment, so each output is measured once
21
+ * and an output nobody created in this process (adopted at startup) is never
22
+ * measured at all.
23
+ *
24
+ * @type {WeakMap<object, number>}
25
+ */
26
+ #createdAt = new WeakMap();
27
+
28
+ /**
29
+ * An output was created by a request that arrived at `at`.
30
+ *
31
+ * @param {object} output
32
+ * @param {number} at
33
+ * @returns {void}
34
+ */
35
+ noteOutputCreated(output, at) {
36
+ if (Number.isFinite(at) && at > 0) {
37
+ this.#createdAt.set(output, at);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * A segment of this output has just been served. The first one closes the
43
+ * measurement; every later one is nothing.
44
+ *
45
+ * @param {object} output
46
+ * @param {number} [now]
47
+ * @returns {number | null} The cold-start latency when this was the first.
48
+ */
49
+ noteSegmentServed(output, now = Date.now()) {
50
+ const at = this.#createdAt.get(output);
51
+ if (at === undefined) {
52
+ return null;
53
+ }
54
+ this.#createdAt.delete(output);
55
+ return now - at;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * How long a fresh output of this mode takes to have its first piece on this
61
+ * host, in seconds: the measured wait for a first output, or one piece at the
62
+ * output's own speed, whichever is longer.
63
+ *
64
+ * The same arithmetic the encode plan prices a fresh encoder with
65
+ * (`EncodePlan.js`): the measured wait already contains one piece, so the spawn
66
+ * overhead is the wait less one piece, and overhead plus piece is the larger of
67
+ * the two. Null where the speed is not known.
68
+ *
69
+ * @param {{ firstByteWaitSec: number, segmentDurationSec: number, speed: number }} params
70
+ * @returns {number | null}
71
+ */
72
+ export function secondsToFirstPiece({ firstByteWaitSec, segmentDurationSec, speed }) {
73
+ if (!(speed > 0) || !(segmentDurationSec > 0)) {
74
+ return null;
75
+ }
76
+ const pieceSec = segmentDurationSec / speed;
77
+ return Math.max(Number.isFinite(firstByteWaitSec) ? firstByteWaitSec : 0, pieceSec);
78
+ }