@torrent-tv/proxy 2.12.2 → 2.13.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 +18 -0
- package/assets/calibration/NOTICE.md +30 -0
- package/assets/calibration/cal-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-720.mp4 +0 -0
- package/bin/cli.js +6 -1
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +7 -0
- package/server.js +22 -3
- package/services/ffmpeg-banner.js +42 -0
- package/services/hls-session-manager.js +461 -20
- package/services/hwaccel.js +478 -12
- package/services/playback-planner.js +36 -3
- package/test/decode-cost.test.js +401 -0
package/services/hwaccel.js
CHANGED
|
@@ -24,6 +24,13 @@ import { spawn } from "node:child_process";
|
|
|
24
24
|
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import {
|
|
29
|
+
parseFfmpegBitrateKbps,
|
|
30
|
+
parseFfmpegDurationSeconds,
|
|
31
|
+
parseFfmpegVideoDimensions,
|
|
32
|
+
parseFfmpegVideoFps
|
|
33
|
+
} from "./ffmpeg-banner.js";
|
|
27
34
|
|
|
28
35
|
const SOFTWARE_PRESET = "ultrafast";
|
|
29
36
|
const SOFTWARE_CRF = "24";
|
|
@@ -128,12 +135,21 @@ const BENCHMARK_PRESETS = ["fast", "faster", "veryfast", "superfast", "ultrafast
|
|
|
128
135
|
const BENCHMARK_REF_W = 640;
|
|
129
136
|
const BENCHMARK_REF_H = 360;
|
|
130
137
|
const BENCHMARK_DURATION_SEC = 3;
|
|
131
|
-
// Require the
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
138
|
+
// Require the predicted speed to clear realtime by this much. The benchmarks
|
|
139
|
+
// run at startup with an idle CPU; during playback ffmpeg competes with
|
|
140
|
+
// in-process WebTorrent (download + hashing) and delivery, so real throughput
|
|
141
|
+
// is lower, and the margin covers that plus complex scenes.
|
|
142
|
+
//
|
|
143
|
+
// It was 1.8 while the prediction counted ENCODING only and was therefore
|
|
144
|
+
// several times too optimistic on a re-encode; with the decode term the
|
|
145
|
+
// prediction is within ~13 % of measured, so the margin no longer has to stand
|
|
146
|
+
// in for a missing term as well as for load.
|
|
147
|
+
const PRESET_SPEED_MARGIN = 1.5;
|
|
148
|
+
// The bar for a prediction that has NO decode term — a host whose calibration
|
|
149
|
+
// clips are missing, or whose fit was rejected. That figure is the one the
|
|
150
|
+
// margin was 1.8 for, and lowering it there would make an uncalibrated host
|
|
151
|
+
// more permissive than it was before any of this existed.
|
|
152
|
+
const ENCODE_ONLY_SPEED_MARGIN = 1.8;
|
|
137
153
|
|
|
138
154
|
/**
|
|
139
155
|
* @param {number} targetWidth
|
|
@@ -634,6 +650,422 @@ export async function detectTonemapSupport({ ffmpegBin, logger }) {
|
|
|
634
650
|
return supported;
|
|
635
651
|
}
|
|
636
652
|
|
|
653
|
+
/**
|
|
654
|
+
* Solve a 3×3 linear system by Gaussian elimination with partial pivoting.
|
|
655
|
+
*
|
|
656
|
+
* @param {number[][]} rows - Three rows of [c0, c1, c2, rhs].
|
|
657
|
+
* @returns {number[] | null} The three unknowns, or null when singular.
|
|
658
|
+
*/
|
|
659
|
+
function solveLinear3(rows) {
|
|
660
|
+
const m = rows.map((row) => [...row]);
|
|
661
|
+
for (let col = 0; col < 3; col += 1) {
|
|
662
|
+
let pivot = col;
|
|
663
|
+
for (let row = col + 1; row < 3; row += 1) {
|
|
664
|
+
if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) {
|
|
665
|
+
pivot = row;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (Math.abs(m[pivot][col]) < 1e-12) {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
[m[col], m[pivot]] = [m[pivot], m[col]];
|
|
672
|
+
for (let row = 0; row < 3; row += 1) {
|
|
673
|
+
if (row === col) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const factor = m[row][col] / m[col][col];
|
|
677
|
+
for (let k = col; k < 4; k += 1) {
|
|
678
|
+
m[row][k] -= factor * m[col][k];
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return [m[0][3] / m[0][0], m[1][3] / m[1][1], m[2][3] / m[2][2]];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// The clips the decode cost is solved from. They ship with the package
|
|
686
|
+
// (`assets/calibration/`), cut from Netflix Open Content "Meridian" (CC-BY 4.0)
|
|
687
|
+
// — real, grainy live action, because a generated `testsrc2` clip decodes 158 %
|
|
688
|
+
// away from a real film where these are 11 % away (measured 2026-08-14). Two
|
|
689
|
+
// share a pixel count and differ 11.7× in bitrate, the third has the same
|
|
690
|
+
// bitrate class at fewer pixels: three points, three unknowns.
|
|
691
|
+
const CALIBRATION_CLIPS = ["cal-1080-hi.mp4", "cal-1080-lo.mp4", "cal-720.mp4"];
|
|
692
|
+
const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
|
|
693
|
+
// How wide the measured window must be before the slope is trusted, and how
|
|
694
|
+
// long to wait for it at most. A second of decoding is thousands of frames on a
|
|
695
|
+
// quick host and dozens on a weak one; both give a slope, and neither costs the
|
|
696
|
+
// startup more than a second per clip.
|
|
697
|
+
const DECODE_WINDOW_MIN_SEC = 1;
|
|
698
|
+
const DECODE_WINDOW_MAX_MS = 8000;
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Read what a calibration clip IS from the decode run's own output: the
|
|
702
|
+
* dimensions, the frame rate and the bitrate ffmpeg reports for it. Read rather
|
|
703
|
+
* than declared, so replacing a clip cannot silently invalidate the fit.
|
|
704
|
+
*
|
|
705
|
+
* @param {string} stderr
|
|
706
|
+
* @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, durationSeconds: number } | null}
|
|
707
|
+
*/
|
|
708
|
+
function parseClipCharacteristics(stderr) {
|
|
709
|
+
// The same readers the session manager uses on the same banner — one parser
|
|
710
|
+
// per fact, so a second copy cannot drift from the first.
|
|
711
|
+
const { width, height } = parseFfmpegVideoDimensions(stderr);
|
|
712
|
+
const rate = parseFfmpegVideoFps(stderr);
|
|
713
|
+
const seconds = parseFfmpegDurationSeconds(stderr);
|
|
714
|
+
const kbps = parseFfmpegBitrateKbps(stderr);
|
|
715
|
+
if (!(width > 0) || !(height > 0) || !(rate > 0) || !(seconds > 0) || !(kbps > 0)) {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
megapixelsPerSecond: (width * height * rate) / 1e6,
|
|
720
|
+
megabitsPerSecond: kbps / 1000,
|
|
721
|
+
durationSeconds: seconds
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Measure what DECODING costs on this host, as seconds of work per second of
|
|
727
|
+
* video, and solve it into three host constants:
|
|
728
|
+
*
|
|
729
|
+
* decodeCost = a × Mpixel/s + b × Mbit/s + c
|
|
730
|
+
*
|
|
731
|
+
* Why it exists: the preset benchmark below measures ENCODING only, and a
|
|
732
|
+
* re-encode pays for both halves. Measured 2026-08-14 on the addon host, that
|
|
733
|
+
* omission made the budget offer a 240p rung it then ran at 0.39-0.95× — the
|
|
734
|
+
* benchmark said the host cleared the bar 2.5× over. With the decode term the
|
|
735
|
+
* same file predicts within 4.8 %; without it the error on that rung was 209 %.
|
|
736
|
+
*
|
|
737
|
+
* The constants are properties of the HOST, so this runs once at startup (about
|
|
738
|
+
* 5 s on a CM4) and any source is then priced from figures the probe already
|
|
739
|
+
* has — nothing is added to a session's cold start.
|
|
740
|
+
*
|
|
741
|
+
* They are also properties of the CODEC, and the clips are H.264: HEVC, AV1 and
|
|
742
|
+
* 10-bit decode dearer per pixel on the same machine, and a source that has to
|
|
743
|
+
* be re-encoded is by definition one this browser could not play, which is
|
|
744
|
+
* usually not H.264. So the fit is optimistic exactly there. Closing that needs
|
|
745
|
+
* clips in those codecs, and is its own roadmap item.
|
|
746
|
+
*
|
|
747
|
+
* @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
|
|
748
|
+
* @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
|
|
749
|
+
*/
|
|
750
|
+
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
|
|
751
|
+
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
752
|
+
const startedAllAt = Date.now();
|
|
753
|
+
/** @type {number[][]} */
|
|
754
|
+
const equations = [];
|
|
755
|
+
for (const clip of CALIBRATION_CLIPS) {
|
|
756
|
+
const measured = await measureDecodeSlope(ffmpegBin, path.join(clipsDir, clip));
|
|
757
|
+
if (!measured) {
|
|
758
|
+
log.warn(`hwaccel: decode benchmark "${clip}" failed or said nothing; decode cost unknown`);
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
const cost = 1 / measured.speed;
|
|
762
|
+
equations.push([measured.megapixelsPerSecond, measured.megabitsPerSecond, 1, cost]);
|
|
763
|
+
log.info(
|
|
764
|
+
`hwaccel: decode "${clip}" ${measured.megapixelsPerSecond.toFixed(1)} Mpx/s ` +
|
|
765
|
+
`${measured.megabitsPerSecond.toFixed(2)} Mbit/s -> ${measured.speed.toFixed(1)}x ` +
|
|
766
|
+
`(cost ${cost.toFixed(4)} s/s, over ${measured.windowSec.toFixed(1)}s of decoding)`
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
const fitted = fitDecodeCost(equations);
|
|
770
|
+
if (!fitted) {
|
|
771
|
+
log.warn("hwaccel: decode cost could not be fitted to these measurements; decode cost unknown");
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
log.info(
|
|
775
|
+
`hwaccel: decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
|
|
776
|
+
`+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape}, measured in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
|
|
777
|
+
);
|
|
778
|
+
return { pixelTerm: fitted.pixelTerm, bitrateTerm: fitted.bitrateTerm, constantTerm: fitted.constantTerm };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Measure how fast this host DECODES a clip, from ffmpeg’s own report of how
|
|
783
|
+
* much video it has processed.
|
|
784
|
+
*
|
|
785
|
+
* Wall-clock around the process cannot answer this: starting ffmpeg costs about
|
|
786
|
+
* a second, and on a quick machine a five-second clip decodes in a tenth of
|
|
787
|
+
* that, so the measurement would be of the program starting. Progress lines
|
|
788
|
+
* arrive twice a second AFTER it has started, and the slope between two of them
|
|
789
|
+
* — video processed against time taken — contains no part of the startup by
|
|
790
|
+
* construction.
|
|
791
|
+
*
|
|
792
|
+
* The clip is looped forever and the process killed as soon as the window is
|
|
793
|
+
* wide enough, so the cost is bounded by the clock rather than by the clip:
|
|
794
|
+
* roughly a second of measurement on any host, quick or slow.
|
|
795
|
+
*
|
|
796
|
+
* @param {string} ffmpegBin
|
|
797
|
+
* @param {string} clipPath
|
|
798
|
+
* @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
|
|
799
|
+
*/
|
|
800
|
+
function measureDecodeSlope(ffmpegBin, clipPath) {
|
|
801
|
+
return new Promise((resolve) => {
|
|
802
|
+
const args = [
|
|
803
|
+
"-hide_banner", "-loglevel", "info", "-nostats",
|
|
804
|
+
"-stream_loop", "-1",
|
|
805
|
+
"-i", clipPath,
|
|
806
|
+
"-an", "-f", "null", "-",
|
|
807
|
+
"-progress", "pipe:1"
|
|
808
|
+
];
|
|
809
|
+
/** @type {Array<{ wallSec: number, outSec: number }>} */
|
|
810
|
+
const samples = [];
|
|
811
|
+
let stderr = "";
|
|
812
|
+
let stdout = "";
|
|
813
|
+
let settled = false;
|
|
814
|
+
let child;
|
|
815
|
+
const startedAt = Date.now();
|
|
816
|
+
const finish = () => {
|
|
817
|
+
if (settled) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
settled = true;
|
|
821
|
+
clearTimeout(timer);
|
|
822
|
+
try {
|
|
823
|
+
child?.kill("SIGKILL");
|
|
824
|
+
} catch {
|
|
825
|
+
// already gone
|
|
826
|
+
}
|
|
827
|
+
// The first sample is the one that still carries the startup — it reports
|
|
828
|
+
// whatever was processed while the process was coming up. Everything is
|
|
829
|
+
// measured from the second onwards.
|
|
830
|
+
const first = samples[1];
|
|
831
|
+
const last = samples[samples.length - 1];
|
|
832
|
+
const clipInfo = parseClipCharacteristics(stderr);
|
|
833
|
+
if (!first || !last || !clipInfo) {
|
|
834
|
+
resolve(null);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const windowSec = last.wallSec - first.wallSec;
|
|
838
|
+
const producedSec = last.outSec - first.outSec;
|
|
839
|
+
if (!(windowSec >= DECODE_WINDOW_MIN_SEC) || !(producedSec > 0)) {
|
|
840
|
+
resolve(null);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
resolve({
|
|
844
|
+
speed: producedSec / windowSec,
|
|
845
|
+
windowSec,
|
|
846
|
+
megapixelsPerSecond: clipInfo.megapixelsPerSecond,
|
|
847
|
+
megabitsPerSecond: clipInfo.megabitsPerSecond
|
|
848
|
+
});
|
|
849
|
+
};
|
|
850
|
+
const timer = setTimeout(finish, DECODE_WINDOW_MAX_MS);
|
|
851
|
+
try {
|
|
852
|
+
child = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
853
|
+
} catch {
|
|
854
|
+
// The timer would otherwise hold the event loop for its full wait and
|
|
855
|
+
// then run against a child that was never created.
|
|
856
|
+
clearTimeout(timer);
|
|
857
|
+
settled = true;
|
|
858
|
+
resolve(null);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
child.stderr.on("data", (chunk) => {
|
|
862
|
+
stderr += String(chunk);
|
|
863
|
+
});
|
|
864
|
+
child.stdout.on("data", (chunk) => {
|
|
865
|
+
stdout += String(chunk);
|
|
866
|
+
let newline = stdout.indexOf("\n");
|
|
867
|
+
while (newline >= 0) {
|
|
868
|
+
const line = stdout.slice(0, newline).trim();
|
|
869
|
+
stdout = stdout.slice(newline + 1);
|
|
870
|
+
if (line.startsWith("out_time_ms=")) {
|
|
871
|
+
const microseconds = Number(line.slice("out_time_ms=".length));
|
|
872
|
+
if (Number.isFinite(microseconds)) {
|
|
873
|
+
samples.push({ wallSec: (Date.now() - startedAt) / 1000, outSec: microseconds / 1e6 });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
newline = stdout.indexOf("\n");
|
|
877
|
+
}
|
|
878
|
+
if (samples.length >= 2 && samples[samples.length - 1].wallSec - samples[1].wallSec >= DECODE_WINDOW_MIN_SEC) {
|
|
879
|
+
finish();
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
child.on("error", () => {
|
|
883
|
+
if (settled) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
clearTimeout(timer);
|
|
887
|
+
settled = true;
|
|
888
|
+
resolve(null);
|
|
889
|
+
});
|
|
890
|
+
child.on("close", finish);
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Fit the three measurements, and say which shape the data supported.
|
|
896
|
+
*
|
|
897
|
+
* The three-term fit is exact — three points, three unknowns — and is used
|
|
898
|
+
* whenever every term comes out non-negative. A negative term is not a host
|
|
899
|
+
* being odd; it says the difference it was solved from is smaller than the
|
|
900
|
+
* noise between runs, which is what a fast machine produces: measured on a
|
|
901
|
+
* desktop, the 720p clip took LONGER per second than the low-bitrate 1080p one,
|
|
902
|
+
* because process startup is a large share of a decode that takes a second.
|
|
903
|
+
*
|
|
904
|
+
* When that happens the bitrate term — the weak one, and the one solved from a
|
|
905
|
+
* single difference — is dropped and the remaining two are fitted by least
|
|
906
|
+
* squares over all three points. If even the pixel slope comes out non-positive
|
|
907
|
+
* there is no measurable dependence on the source at all, and inventing one is
|
|
908
|
+
* worse than having none: the caller then prices the encoder alone and refuses
|
|
909
|
+
* nothing.
|
|
910
|
+
*
|
|
911
|
+
* @param {number[][]} equations - Rows of [Mpixel/s, Mbit/s, 1, cost].
|
|
912
|
+
* @returns {{ pixelTerm: number, bitrateTerm: number, constantTerm: number, shape: string } | null}
|
|
913
|
+
*/
|
|
914
|
+
function fitDecodeCost(equations) {
|
|
915
|
+
const exact = solveLinear3(equations);
|
|
916
|
+
if (exact && exact[0] > 0 && exact[1] >= 0 && exact[2] >= 0) {
|
|
917
|
+
return { pixelTerm: exact[0], bitrateTerm: exact[1], constantTerm: exact[2], shape: "pixels+bitrate+constant" };
|
|
918
|
+
}
|
|
919
|
+
const count = equations.length;
|
|
920
|
+
const meanPixels = equations.reduce((sum, row) => sum + row[0], 0) / count;
|
|
921
|
+
const meanCost = equations.reduce((sum, row) => sum + row[3], 0) / count;
|
|
922
|
+
let covariance = 0;
|
|
923
|
+
let variance = 0;
|
|
924
|
+
for (const row of equations) {
|
|
925
|
+
covariance += (row[0] - meanPixels) * (row[3] - meanCost);
|
|
926
|
+
variance += (row[0] - meanPixels) ** 2;
|
|
927
|
+
}
|
|
928
|
+
if (!(variance > 0)) {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
const pixelTerm = covariance / variance;
|
|
932
|
+
const constantTerm = meanCost - pixelTerm * meanPixels;
|
|
933
|
+
if (pixelTerm > 0 && constantTerm >= 0) {
|
|
934
|
+
return { pixelTerm, bitrateTerm: 0, constantTerm, shape: "pixels+constant" };
|
|
935
|
+
}
|
|
936
|
+
// A negative constant is the line crossing below zero where no clip was
|
|
937
|
+
// measured — every clip is 22 Mpixel/s or more, and nothing here says what a
|
|
938
|
+
// tiny picture costs. Rather than carry a term that would price a small
|
|
939
|
+
// source as free work, fit through the origin: cost proportional to pixels,
|
|
940
|
+
// which is the relationship the measurements do support.
|
|
941
|
+
let weighted = 0;
|
|
942
|
+
let squares = 0;
|
|
943
|
+
for (const row of equations) {
|
|
944
|
+
weighted += row[0] * row[3];
|
|
945
|
+
squares += row[0] ** 2;
|
|
946
|
+
}
|
|
947
|
+
const throughOrigin = squares > 0 ? weighted / squares : 0;
|
|
948
|
+
if (!(throughOrigin > 0)) {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
return { pixelTerm: throughOrigin, bitrateTerm: 0, constantTerm: 0, shape: "pixels only" };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* How many times realtime this host can DECODE a source of these
|
|
956
|
+
* characteristics, from the startup fit. `null` when the fit is unavailable or
|
|
957
|
+
* the source figures are not known.
|
|
958
|
+
*
|
|
959
|
+
* @param {{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null} model
|
|
960
|
+
* @param {{ megapixelsPerSecond: number, megabitsPerSecond: number }} source
|
|
961
|
+
* @returns {number | null}
|
|
962
|
+
*/
|
|
963
|
+
export function decodeSpeedFor(model, source) {
|
|
964
|
+
if (!model) {
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
const pixels = Number(source?.megapixelsPerSecond);
|
|
968
|
+
const bits = Number(source?.megabitsPerSecond);
|
|
969
|
+
if (!Number.isFinite(pixels) || pixels <= 0 || !Number.isFinite(bits) || bits < 0) {
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
const cost = model.pixelTerm * pixels + model.bitrateTerm * bits + model.constantTerm;
|
|
973
|
+
if (!(cost > 0)) {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
return 1 / cost;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* How many times realtime a re-encode of this source at this output pixel rate
|
|
981
|
+
* would run: decoding and encoding share the machine, so their costs add and
|
|
982
|
+
* their speeds combine as
|
|
983
|
+
*
|
|
984
|
+
* 1 / (1/decodeSpeed + 1/encodeSpeed)
|
|
985
|
+
*
|
|
986
|
+
* Checked 2026-08-14 on the rung that broke playback: 1/(1/2.31 + 1/5.99) =
|
|
987
|
+
* 1.67× against 1.48× measured. With no decode fit this falls back to the
|
|
988
|
+
* encode speed alone — which is what the budget did before, and which
|
|
989
|
+
* overestimated that rung five to eleven times.
|
|
990
|
+
*
|
|
991
|
+
* @param {{ decodeModel: { pixelTerm: number, bitrateTerm: number, constantTerm: number } | null, encodePixelsPerSec: number, outputPixelsPerSec: number, source: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} params
|
|
992
|
+
* @returns {number | null}
|
|
993
|
+
*/
|
|
994
|
+
export function predictedRealtimeSpeed({
|
|
995
|
+
decodeModel,
|
|
996
|
+
encodePixelsPerSec,
|
|
997
|
+
outputPixelsPerSec,
|
|
998
|
+
source,
|
|
999
|
+
observedDecodeCostSec = null
|
|
1000
|
+
}) {
|
|
1001
|
+
if (!Number.isFinite(encodePixelsPerSec) || encodePixelsPerSec <= 0) {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
if (!Number.isFinite(outputPixelsPerSec) || outputPixelsPerSec <= 0) {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
const encodeSpeed = encodePixelsPerSec / outputPixelsPerSec;
|
|
1008
|
+
// What this very file has been seen to cost, when it has been: the clips are
|
|
1009
|
+
// H.264 and a source that has to be re-encoded usually is not, so a figure
|
|
1010
|
+
// taken from the encoder actually running on THIS source beats any model of
|
|
1011
|
+
// a stand-in. It arrives seconds into playback and replaces the estimate.
|
|
1012
|
+
const decodeSpeed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
|
|
1013
|
+
? 1 / observedDecodeCostSec
|
|
1014
|
+
: (source ? decodeSpeedFor(decodeModel, source) : null);
|
|
1015
|
+
if (decodeSpeed === null) {
|
|
1016
|
+
return encodeSpeed;
|
|
1017
|
+
}
|
|
1018
|
+
return 1 / (1 / decodeSpeed + 1 / encodeSpeed);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Whether this host can hold realtime, with the margin, while re-encoding this
|
|
1023
|
+
* source to this output pixel rate — and the predicted speed either way, so a
|
|
1024
|
+
* refusal can say what it refused on.
|
|
1025
|
+
*
|
|
1026
|
+
* The encoder figure is the FASTEST benchmarked preset: it is the best this
|
|
1027
|
+
* host can do, so a rung it cannot hold cannot be held at any quality setting.
|
|
1028
|
+
*
|
|
1029
|
+
* @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number }} params
|
|
1030
|
+
* @returns {{ speed: number | null, sustainable: boolean }}
|
|
1031
|
+
*/
|
|
1032
|
+
export function canSustainOutput({
|
|
1033
|
+
benchmark,
|
|
1034
|
+
decodeModel = null,
|
|
1035
|
+
source = null,
|
|
1036
|
+
outputPixelsPerSec,
|
|
1037
|
+
observedDecodeCostSec = null
|
|
1038
|
+
}) {
|
|
1039
|
+
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
1040
|
+
// Nothing measured on this host: the budget cannot refuse what it cannot
|
|
1041
|
+
// price, and refusing everything would leave a viewer with no rung at all.
|
|
1042
|
+
return { speed: null, sustainable: true };
|
|
1043
|
+
}
|
|
1044
|
+
const observed = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0
|
|
1045
|
+
? observedDecodeCostSec
|
|
1046
|
+
: null;
|
|
1047
|
+
if (observed === null && !isDecodePriced({ decodeModel, source })) {
|
|
1048
|
+
// An encoder-only figure was several times too optimistic on the rung this
|
|
1049
|
+
// check exists for, so it is not fit to refuse anything. Without the decode
|
|
1050
|
+
// term the ladder is offered whole, exactly as it was before.
|
|
1051
|
+
return { speed: null, sustainable: true };
|
|
1052
|
+
}
|
|
1053
|
+
const speed = predictedRealtimeSpeed({
|
|
1054
|
+
decodeModel,
|
|
1055
|
+
encodePixelsPerSec: benchmark[benchmark.length - 1].pixelsPerSec,
|
|
1056
|
+
outputPixelsPerSec,
|
|
1057
|
+
source,
|
|
1058
|
+
observedDecodeCostSec: observed
|
|
1059
|
+
});
|
|
1060
|
+
if (speed === null) {
|
|
1061
|
+
return { speed: null, sustainable: true };
|
|
1062
|
+
}
|
|
1063
|
+
return { speed, sustainable: speed >= PRESET_SPEED_MARGIN };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** The margin a predicted speed must clear to be offered. */
|
|
1067
|
+
export const REALTIME_SPEED_MARGIN = PRESET_SPEED_MARGIN;
|
|
1068
|
+
|
|
637
1069
|
/**
|
|
638
1070
|
* Benchmark software libx264 presets on this host. Encodes a short synthetic
|
|
639
1071
|
* clip at a fixed reference resolution with each preset and measures encoder
|
|
@@ -683,20 +1115,47 @@ export async function benchmarkSoftwarePresets({ ffmpegBin, logger }) {
|
|
|
683
1115
|
*
|
|
684
1116
|
* @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
|
|
685
1117
|
* @param {number} pixelsPerSecNeeded
|
|
1118
|
+
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
|
|
686
1119
|
* @returns {string}
|
|
687
1120
|
*/
|
|
688
|
-
export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded) {
|
|
1121
|
+
export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
|
|
689
1122
|
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
690
1123
|
return "ultrafast";
|
|
691
1124
|
}
|
|
1125
|
+
const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
|
|
1126
|
+
? cost.observedDecodeCostSec
|
|
1127
|
+
: null;
|
|
1128
|
+
const priced = isDecodePriced(cost);
|
|
1129
|
+
const bar = priced ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
|
|
692
1130
|
for (const entry of benchmark) {
|
|
693
|
-
|
|
1131
|
+
const speed = predictedRealtimeSpeed({
|
|
1132
|
+
decodeModel: cost.decodeModel ?? null,
|
|
1133
|
+
encodePixelsPerSec: entry.pixelsPerSec,
|
|
1134
|
+
outputPixelsPerSec: pixelsPerSecNeeded,
|
|
1135
|
+
source: cost.source ?? null,
|
|
1136
|
+
observedDecodeCostSec: observed
|
|
1137
|
+
});
|
|
1138
|
+
if (speed !== null && speed >= bar) {
|
|
694
1139
|
return entry.preset;
|
|
695
1140
|
}
|
|
696
1141
|
}
|
|
697
1142
|
return benchmark[benchmark.length - 1].preset;
|
|
698
1143
|
}
|
|
699
1144
|
|
|
1145
|
+
/**
|
|
1146
|
+
* Whether a cost description can actually price decoding — a fit AND a source
|
|
1147
|
+
* to apply it to. Without both, every prediction is encoder-only.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {{ decodeModel?: object | null, source?: object | null }} cost
|
|
1150
|
+
* @returns {boolean}
|
|
1151
|
+
*/
|
|
1152
|
+
function isDecodePriced(cost) {
|
|
1153
|
+
if (Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0) {
|
|
1154
|
+
return true; // measured on the source itself, which needs no fit to stand on
|
|
1155
|
+
}
|
|
1156
|
+
return Boolean(cost?.decodeModel) && Boolean(cost?.source);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
700
1159
|
// Resolution-ladder heights (output height rungs), high→low. The ladder is
|
|
701
1160
|
// derived per-stream from the ceiling (the client-requested, source-capped
|
|
702
1161
|
// output box): only rungs at or below the ceiling height are used, so the
|
|
@@ -756,9 +1215,10 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
|
|
|
756
1215
|
* @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
|
|
757
1216
|
* @param {{ width: number, height: number }} ceiling
|
|
758
1217
|
* @param {number} outputFps
|
|
1218
|
+
* @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
|
|
759
1219
|
* @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
|
|
760
1220
|
*/
|
|
761
|
-
export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps) {
|
|
1221
|
+
export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
|
|
762
1222
|
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
763
1223
|
return null;
|
|
764
1224
|
}
|
|
@@ -768,15 +1228,21 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps) {
|
|
|
768
1228
|
return null;
|
|
769
1229
|
}
|
|
770
1230
|
const fastest = benchmark[benchmark.length - 1].pixelsPerSec; // ultrafast throughput
|
|
1231
|
+
const bar = isDecodePriced(cost) ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
|
|
771
1232
|
let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
|
|
772
1233
|
for (let i = 0; i < ladder.length; i += 1) {
|
|
773
|
-
const
|
|
774
|
-
|
|
1234
|
+
const speed = predictedRealtimeSpeed({
|
|
1235
|
+
decodeModel: cost.decodeModel ?? null,
|
|
1236
|
+
encodePixelsPerSec: fastest,
|
|
1237
|
+
outputPixelsPerSec: ladder[i].width * ladder[i].height * fps,
|
|
1238
|
+
source: cost.source ?? null
|
|
1239
|
+
});
|
|
1240
|
+
if (speed !== null && speed >= bar) {
|
|
775
1241
|
chosenIndex = i;
|
|
776
1242
|
break;
|
|
777
1243
|
}
|
|
778
1244
|
}
|
|
779
1245
|
const chosen = ladder[chosenIndex];
|
|
780
|
-
const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps);
|
|
1246
|
+
const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost);
|
|
781
1247
|
return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
|
|
782
1248
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
parseFfmpegDurationSeconds,
|
|
13
13
|
parseFfmpegStartTimeSeconds,
|
|
14
14
|
parseFfmpegVideoDimensions,
|
|
15
|
+
parseFfmpegBitrateKbps,
|
|
15
16
|
parseFfmpegVideoFps,
|
|
16
17
|
parseFfmpegHdr
|
|
17
18
|
} from "./ffmpeg-banner.js";
|
|
@@ -274,7 +275,11 @@ export function createPlaybackPlanner({
|
|
|
274
275
|
// index — which reads the same tail of the file — is fetched alongside the
|
|
275
276
|
// codec probe instead of after it. Late-bound to the HLS session manager,
|
|
276
277
|
// which owns the cache both of them share.
|
|
277
|
-
warmKeyframeIndex
|
|
278
|
+
warmKeyframeIndex,
|
|
279
|
+
// Optional. The heights this host could actually serve this source at, for
|
|
280
|
+
// both playback branches, so the quality menu is right from the moment the
|
|
281
|
+
// file is opened rather than from the moment an encoder exists.
|
|
282
|
+
predictOfferedHeights
|
|
278
283
|
}) {
|
|
279
284
|
/** @type {Map<string, PlaybackPlan>} */
|
|
280
285
|
const cache = new Map();
|
|
@@ -307,7 +312,17 @@ export function createPlaybackPlanner({
|
|
|
307
312
|
return {
|
|
308
313
|
...plan,
|
|
309
314
|
expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
|
|
310
|
-
expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null
|
|
315
|
+
expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
|
|
316
|
+
// Answered here for the same reason as the two above: a plan is cached for
|
|
317
|
+
// the life of the process, and what this host will serve a file at is not.
|
|
318
|
+
// It starts as a prediction from the startup benchmarks and is replaced by
|
|
319
|
+
// what an encoder running on this very source turns out to cost — frozen
|
|
320
|
+
// into the cache, every later open of the file would hand the browser the
|
|
321
|
+
// first guess again and undo that. This is the 2.9.106 defect exactly.
|
|
322
|
+
offeredHeights: plan.mediaInfoForOffer
|
|
323
|
+
? (predictOfferedHeights?.(plan.mediaInfoForOffer) ?? null)
|
|
324
|
+
: null,
|
|
325
|
+
mediaInfoForOffer: undefined
|
|
311
326
|
};
|
|
312
327
|
}
|
|
313
328
|
|
|
@@ -457,7 +472,24 @@ export function createPlaybackPlanner({
|
|
|
457
472
|
// never here: read at build time they would be frozen into the cached
|
|
458
473
|
// plan, which is the bug fixed in 2.9.106.
|
|
459
474
|
expectedFirstSegmentMs: null,
|
|
460
|
-
expectedSessionCreateMs: null
|
|
475
|
+
expectedSessionCreateMs: null,
|
|
476
|
+
offeredHeights: null,
|
|
477
|
+
// What the offer is computed FROM, kept on the cached plan so the offer
|
|
478
|
+
// itself can be recomputed on every response. The figures are the
|
|
479
|
+
// probe's own and never change for a file; the answer derived from them
|
|
480
|
+
// does, as the host learns what this source costs. Stripped on the way
|
|
481
|
+
// out — it is not part of the plan the browser is given.
|
|
482
|
+
mediaInfoForOffer: {
|
|
483
|
+
width: videoWidth,
|
|
484
|
+
height: videoHeight,
|
|
485
|
+
fps: parseFfmpegVideoFps(probe.stderr),
|
|
486
|
+
bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
|
|
487
|
+
// Which file this is, so the offer can be answered from what an
|
|
488
|
+
// encoder has already learned about THIS source rather than from the
|
|
489
|
+
// startup clips — the same correction a live session applies.
|
|
490
|
+
sourceKey,
|
|
491
|
+
fileIndex
|
|
492
|
+
}
|
|
461
493
|
};
|
|
462
494
|
// Only cache a plan whose codecs were actually detected. An empty probe is
|
|
463
495
|
// a "header not downloaded yet" signal, not a valid result — caching it
|
|
@@ -484,6 +516,7 @@ export function createPlaybackPlanner({
|
|
|
484
516
|
durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
|
|
485
517
|
width: dims.width,
|
|
486
518
|
height: dims.height,
|
|
519
|
+
bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
|
|
487
520
|
fps: parseFfmpegVideoFps(probe.stderr),
|
|
488
521
|
startTime: parseFfmpegStartTimeSeconds(probe.stderr),
|
|
489
522
|
isHdr: parseFfmpegHdr(probe.stderr)
|