@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.
- 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/container/h264-configuration.js +7 -14
- 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
|
@@ -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
|
|
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
|
-
|
|
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 -> ${
|
|
727
|
-
|
|
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
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
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
|
|
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
|
|
788
|
-
*
|
|
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 =
|
|
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
|
|
815
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
resolve(
|
|
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
|
+
}
|