@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.
@@ -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 encoder to be this much faster than realtime for the target
132
- // resolution. The benchmark runs at startup with an idle CPU; during playback
133
- // ffmpeg competes with in-process WebTorrent (download + hashing) and delivery,
134
- // so real throughput is lower. A generous margin keeps playback above under
135
- // that real load and absorbs complex scenes.
136
- const PRESET_SPEED_MARGIN = 1.8;
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
- if (entry.pixelsPerSec >= pixelsPerSecNeeded * PRESET_SPEED_MARGIN) {
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 needed = ladder[i].width * ladder[i].height * fps;
774
- if (fastest >= needed * PRESET_SPEED_MARGIN) {
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)