@torrent-tv/proxy 2.53.0 → 2.54.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.
@@ -43,6 +43,9 @@ import {
43
43
  pickSoftwarePreset,
44
44
  canSustainOutput,
45
45
  speedBar,
46
+ maxrateKbpsFor,
47
+ nominalKbpsForHeight,
48
+ nominalKbpsForMaxrate,
46
49
  TRANSCODE_FPS,
47
50
  chooseOutputFps
48
51
  } from "./hwaccel.js";
@@ -523,11 +526,23 @@ const BUDGET_SPEED_OK = 1.0;
523
526
  // complex scenes; the cumulative average won't dip this long unless the host
524
527
  // genuinely can't keep up).
525
528
  const BUDGET_SUSTAINED_MS = 15_000;
526
- // After a downshift, wait this long before another (lets the new profile settle
527
- // and a fresh cumulative average build).
529
+ // After a step, wait this long before another (lets the new picture settle and
530
+ // a fresh slope build).
528
531
  const BUDGET_ACTION_COOLDOWN_MS = 30_000;
529
- // Never step down more than this many rungs below the startup choice.
530
- const BUDGET_MAX_DOWNSHIFTS = 3;
532
+ // The step BACK UP has to be slower to fire than the step down, or the two
533
+ // take turns: a rung that has just been left is by definition one the arithmetic
534
+ // still thinks this machine can hold, so it would be asked for again as soon as
535
+ // the cooldown expired. Four times the down window is a statement about how long
536
+ // a machine has to look able before it is believed, not a measured quantity, and
537
+ // it is written here rather than dressed up as one.
538
+ const BUDGET_UP_SUSTAINED_MS = 60_000;
539
+ // How long a request to the player to change variant stands before it is
540
+ // treated as unanswered. A progress report is polled about every 1.5 s and the
541
+ // switch itself needs the rung warmed, which is the cold start this host
542
+ // measures; this is long enough for both and short enough that a browser which
543
+ // cannot honour the request (no master playlist, a viewer on a manual pick) is
544
+ // not chased for the rest of the film.
545
+ const QUALITY_ASK_TTL_MS = 45_000;
531
546
  // How many readings the median is taken over. This one is a statement about
532
547
  // how much of the past still describes the host, not a measured quantity, and
533
548
  // it is written here rather than dressed up as one.
@@ -540,9 +555,12 @@ const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
540
555
  // measured data-channel throughput + buffered seconds every ~10 s; when a
541
556
  // FRESH report shows the usable link (reported × safety margin) sustainedly
542
557
  // below the observed produced bitrate AND the viewer's buffer is low, the
543
- // budget loop steps the encode one rung down — same machinery, cooldown and
544
- // floor as the CPU trigger. Manual-quality sessions are inherently exempt
545
- // (their budgetLadder is null).
558
+ // budget loop bounds the encode's bitrate by that measured link — same
559
+ // machinery and cooldown as the CPU trigger. On a COPIED picture there is no
560
+ // encoder to bound, so the same finding asks the player for a re-encoded rung
561
+ // instead. Which of the two, and whether a viewer's own pick may be moved at
562
+ // all, is decided where the viewer's choice lives: in the browser, which
563
+ // honours the request only in automatic mode.
546
564
  const LINK_REPORT_FRESH_MS = 30_000;
547
565
  // Usable share of the reported link (protocol overhead + measurement noise).
548
566
  const LINK_SAFETY = 0.8;
@@ -1611,7 +1629,7 @@ export class HlsSessionManager {
1611
1629
  // otherwise.
1612
1630
  this.budgetTimer = setInterval(() => {
1613
1631
  this.#enforceLookAhead();
1614
- void this.#enforceRealtimeBudget();
1632
+ void this.runQualityBudgetOnce();
1615
1633
  }, BUDGET_CHECK_INTERVAL_MS);
1616
1634
  this.budgetTimer.unref();
1617
1635
  }
@@ -2004,8 +2022,7 @@ export class HlsSessionManager {
2004
2022
  //
2005
2023
  // Manual quality bypasses the budget entirely: the user forced a specific
2006
2024
  // resolution, so encode exactly that box (capped to source by the scale
2007
- // filter) with the default preset, and the runtime downswitch is skipped
2008
- // for the session (budgetLadder stays null).
2025
+ // filter) with the default preset.
2009
2026
  // What decoding this source costs, which every re-encode pays on top of
2010
2027
  // the encoder. Read from the probe; null when it did not say enough.
2011
2028
  const sourceDecode = sourceDecodeCharacteristics(mediaInfo);
@@ -2118,15 +2135,38 @@ export class HlsSessionManager {
2118
2135
  : undefined,
2119
2136
  // Whether to insert the HDR→SDR tone-map chain (software path only).
2120
2137
  applyTonemap,
2121
- // Realtime-budget runtime state (software encoder only). The ladder is the
2122
- // resolution rungs from the ceiling down; rungIndex is the current rung.
2123
- // The monitor steps rungIndex down when the encoder is sustainedly
2124
- // CPU-bound and restarts ffmpeg at the current segment.
2125
- budgetLadder: encodeBudget?.ladder ?? null,
2126
- budgetRungIndex: Number.isInteger(encodeBudget?.rungIndex) ? encodeBudget.rungIndex : 0,
2127
- budgetDownshifts: 0,
2138
+ // Realtime-budget runtime state. The ladder that chose the STARTING rung
2139
+ // is not kept: a step is a change of VARIANT now, and a variant is a
2140
+ // session with its own init segment, so there is no per-session rung
2141
+ // index to walk. What is kept is when this session last looked slow, when
2142
+ // it last looked able, and when the family last acted.
2128
2143
  budgetSlowSince: 0,
2129
2144
  budgetLastActionAt: 0,
2145
+ // A standing request to the player to move to another variant, or null.
2146
+ // Kept on the family's BASE — it is the base's id the browser polls
2147
+ // progress with, and the request outlives the rung that raised it.
2148
+ qualityAsk: null,
2149
+ // The last disagreement between the size a run encodes and the size the
2150
+ // served init describes, so the same one is not repeated every run.
2151
+ initSizeSaid: "",
2152
+ // The window in which the machine has looked able to carry a HIGHER rung.
2153
+ // The way back up, which for most of this project's life did not exist:
2154
+ // `budgetRungIndex` was written in exactly one place, `+ 1`.
2155
+ budgetUpSince: 0,
2156
+ // The last speed read as a SLOPE between two progress reports, with the
2157
+ // moment it was read. ffmpeg's own `speed=` is cumulative — output time
2158
+ // over wall time since the run began — so a run starved early carries
2159
+ // that average for the rest of its life, and a decision taken on it is
2160
+ // taken on a figure that stopped being true. Measured 2026-08-21: a
2161
+ // cumulative 0.39x bought a downshift on a run whose own progress lines
2162
+ // showed 1.30x at that moment.
2163
+ recentSpeed: null,
2164
+ // A peak this encode must not exceed, in kbit/s of nominal rate, when the
2165
+ // VIEWER's measured link is what cannot carry the stream. Null while
2166
+ // nothing has measured a limit. It moves `-maxrate`/`-bufsize` and
2167
+ // nothing else: they do not appear in the SPS, so one init segment goes
2168
+ // on describing every fragment — which the picture's SIZE cannot do.
2169
+ rateCapKbps: null,
2130
2170
  // Latest viewer link report ({ linkMbps, bufferedAheadSec, at }) and the
2131
2171
  // link-deficit slow window (mirrors budgetSlowSince for the CPU path).
2132
2172
  netReport: null,
@@ -2777,21 +2817,6 @@ export class HlsSessionManager {
2777
2817
  );
2778
2818
  }
2779
2819
 
2780
- /**
2781
- * Parse ffmpeg's `speed` progress value (e.g. "0.903x", "1.6x", "N/A") into a
2782
- * number. Returns null when it cannot be parsed (no data yet).
2783
- *
2784
- * @param {string} value
2785
- * @returns {number | null}
2786
- */
2787
- #parseSpeed(value) {
2788
- if (typeof value !== "string" || value.length === 0) {
2789
- return null;
2790
- }
2791
- const numeric = Number.parseFloat(value);
2792
- return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
2793
- }
2794
-
2795
2820
  /**
2796
2821
  * Realtime budget monitor (software encoder only). For each active
2797
2822
  * software-transcode session, watch the encoder's cumulative `speed`: when it
@@ -2816,6 +2841,11 @@ export class HlsSessionManager {
2816
2841
  if (!named || named.state === "disposed") {
2817
2842
  return false;
2818
2843
  }
2844
+ // A throughput that is not a positive finite number is not a measurement,
2845
+ // and it reaches an encoder's `-maxrate` from here.
2846
+ if (!Number.isFinite(linkMbps) || !(linkMbps > 0) || !Number.isFinite(bufferedAheadSec)) {
2847
+ return false;
2848
+ }
2819
2849
  // The link carries the stream on screen, so the report belongs to the
2820
2850
  // variant producing it — that is the encoder whose bitrate it can bound.
2821
2851
  this.#activeVariant(named).netReport = { linkMbps, bufferedAheadSec, at: Date.now() };
@@ -2966,16 +2996,26 @@ export class HlsSessionManager {
2966
2996
  if (now - session.linkSlowSince < LINK_SLOW_WINDOW_MS) {
2967
2997
  return false; // not sustained yet
2968
2998
  }
2969
- if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
2970
- return false; // let the previous action settle
2971
- }
2972
- await this.#applyBudgetDownshift(
2973
- session,
2974
- `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
2975
- "link"
2976
- );
2977
2999
  session.linkSlowSince = 0;
2978
- return true;
3000
+ const reasonText =
3001
+ `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps ` +
3002
+ `buffer=${report.bufferedAheadSec.toFixed(1)}s`;
3003
+ // Which lever this branch HAS, which is not the same on both paths.
3004
+ //
3005
+ // A re-encoded picture can simply be told to make fewer bits at the size it
3006
+ // is already making, and the target is not chosen — it is the link the
3007
+ // browser just measured. Nothing about the picture's size moves, so the one
3008
+ // init segment the player holds goes on describing every fragment.
3009
+ //
3010
+ // A COPIED picture is not being encoded at all, so it has no rate to lower:
3011
+ // its bitrate is the source's. The only way to send fewer bits is to send
3012
+ // another rendering of the film, which is a re-encoded rung — a change of
3013
+ // variant, and the player's own switch. This is the whole of what "a change
3014
+ // of resolution must exist on the copy path too" asks for.
3015
+ if (session.transcodeVideo === true && this.videoEncoder?.kind === "software") {
3016
+ return await this.#applyRateCap(session, report.linkMbps, reasonText);
3017
+ }
3018
+ return this.#askLowerHeight(session, `viewer-link-bound ${reasonText}`);
2979
3019
  }
2980
3020
 
2981
3021
  /**
@@ -3733,7 +3773,18 @@ export class HlsSessionManager {
3733
3773
  );
3734
3774
  }
3735
3775
 
3736
- async #enforceRealtimeBudget() {
3776
+ /**
3777
+ * One pass of the quality budget: learn what this host is doing with each
3778
+ * running encode, and act on it.
3779
+ *
3780
+ * Public because it is an operation with a name, not an implementation
3781
+ * detail of a timer — and because a loop that decides what the viewer sees
3782
+ * and can only be reached through `setInterval` is a loop nothing can check.
3783
+ * The timer calls exactly this.
3784
+ *
3785
+ * @returns {Promise<void>}
3786
+ */
3787
+ async runQualityBudgetOnce() {
3737
3788
  void this.#reportHostLoad();
3738
3789
  // One tick at a time. Both halves await torrent statistics per source, so a
3739
3790
  // slow or stuck answer would otherwise let the next tick in behind it — two
@@ -3757,83 +3808,529 @@ export class HlsSessionManager {
3757
3808
  }
3758
3809
  }
3759
3810
 
3760
- /** One pass over the sessions. See `#enforceRealtimeBudget`. */
3811
+ /** One pass over the sessions. See {@link runQualityBudgetOnce}. */
3761
3812
  async #realtimeBudgetPass() {
3762
3813
  const now = Date.now();
3763
3814
  for (const session of this.sessionsById.values()) {
3764
3815
  // What this file costs to decode is learned from EVERY encoding session,
3765
3816
  // before any of the budget's own conditions are consulted. Those exist to
3766
- // decide whether to step the quality down, and they exclude most of what
3767
- // is worth measuring: a rung already at the foot of its ladder has
3768
- // nowhere to step, and a 240p variant IS its whole ladder — which is
3769
- // exactly the rung the field measured at 0.95x on 2026-08-15, learning
3770
- // nothing from three minutes of it because the loop had already skipped
3771
- // the session as un-actionable.
3817
+ // decide whether to step the quality, and they exclude most of what is
3818
+ // worth measuring: a rung already at the foot of its ladder has nowhere
3819
+ // to step, and a 240p variant IS its whole ladder — which is exactly the
3820
+ // rung the field measured at 0.95x on 2026-08-15, learning nothing from
3821
+ // three minutes of it because the loop had already skipped the session as
3822
+ // un-actionable.
3772
3823
  await this.#learnFromEncoder(session);
3773
3824
  if (
3774
3825
  !session ||
3775
3826
  session.state === "disposed" ||
3776
3827
  session.runState === ENCODE_RUN_STATE.ENDED_FAILED ||
3777
- !session.transcodeVideo ||
3778
3828
  // Nothing is encoding, so there is no speed to judge. A variant the
3779
3829
  // viewer has switched away from is left in exactly this state, and its
3780
- // last recorded speed would otherwise buy it a downshift — which
3781
- // restarts the encoder it was just stopped for.
3830
+ // last recorded speed would otherwise buy it a step — which restarts
3831
+ // the encoder it was just stopped for.
3782
3832
  !session.ffmpeg ||
3783
- !Array.isArray(session.budgetLadder) ||
3784
- session.budgetLadder.length < 2
3833
+ // A soundtrack published on its own carries no picture, so no quality
3834
+ // step is its to make; its price is learned above and that is all.
3835
+ session.audioOnly === true
3785
3836
  ) {
3786
3837
  continue;
3787
3838
  }
3788
- // Already at the floor or out of steps nothing more to give.
3789
- if (
3790
- session.budgetRungIndex >= session.budgetLadder.length - 1 ||
3791
- session.budgetDownshifts >= BUDGET_MAX_DOWNSHIFTS
3792
- ) {
3839
+ // The cooldown belongs to the FAMILY, not to one rung of it. A step asks
3840
+ // the player to move to another session, so the rung that acted and the
3841
+ // rung that then runs are different objects, and a cooldown kept on each
3842
+ // separately would let the new one act again immediately.
3843
+ if (now - this.#baseOf(session).budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
3793
3844
  continue;
3794
3845
  }
3795
3846
  // Viewer-link deficit first (adaptive bitrate): independent of encoder
3796
3847
  // speed — a thin cellular link starves even a faster-than-realtime
3797
- // encode. When it acts, skip the CPU check this tick (shared cooldown
3798
- // guards double-firing anyway).
3848
+ // encode.
3799
3849
  if (await this.#checkLinkBudget(session, now)) {
3800
3850
  continue;
3801
3851
  }
3802
- const speed = this.#parseSpeed(session.progress?.speed);
3803
- if (speed === null) {
3804
- continue; // no measurement yet
3805
- }
3806
- if (speed >= BUDGET_SPEED_OK) {
3807
- session.budgetSlowSince = 0; // recovered — reset the slow window
3852
+ if (await this.#checkEncoderBudget(session, now)) {
3808
3853
  continue;
3809
3854
  }
3810
- if (speed >= BUDGET_SPEED_SLOW) {
3811
- continue; // in the hysteresis band; neither slow nor ok
3855
+ await this.#checkStepUp(session, now);
3856
+ }
3857
+ }
3858
+
3859
+ /**
3860
+ * The speed this run is making RIGHT NOW, or null when nothing recent enough
3861
+ * says.
3862
+ *
3863
+ * Read as the slope between two progress reports, never as ffmpeg's own
3864
+ * `speed=`. That figure is cumulative — output time over wall time since the
3865
+ * run began — so a run starved of torrent data early carries the average of
3866
+ * that starvation for the rest of its life. Measured 2026-08-21: a run whose
3867
+ * progress lines showed 1.30x at that moment (13 s of video in 10.02 s of
3868
+ * clock) still reported a cumulative 0.39x from four minutes on a ~100 KB/s
3869
+ * swarm, and the budget stepped the picture down on it. The same mistake was
3870
+ * found and solved once already — the startup decode benchmark reads the
3871
+ * slope between two progress reports for exactly this reason.
3872
+ *
3873
+ * @param {HlsSession} session
3874
+ * @param {number} now
3875
+ * @returns {number | null}
3876
+ */
3877
+ #recentSpeedOf(session, now) {
3878
+ const reading = session.recentSpeed;
3879
+ if (!reading || reading.runSerial !== (session.runSerial ?? 0)) {
3880
+ return null; // nothing from THIS run
3881
+ }
3882
+ // Two budget ticks. A reading older than that is not about the machine as
3883
+ // it stands, and the loop takes a fresh one every pass anyway.
3884
+ if (now - reading.at > BUDGET_CHECK_INTERVAL_MS * 2) {
3885
+ return null;
3886
+ }
3887
+ return reading.speed;
3888
+ }
3889
+
3890
+ /**
3891
+ * The encoder-speed check for one session: sustained sub-realtime, and the
3892
+ * encoder — not a download-starved input — is the limit.
3893
+ *
3894
+ * @param {HlsSession} session
3895
+ * @param {number} now
3896
+ * @returns {Promise<boolean>} True when a step was asked for this tick.
3897
+ */
3898
+ async #checkEncoderBudget(session, now) {
3899
+ if (session.transcodeVideo !== true) {
3900
+ // A copy has no encoder to make cheaper. Whatever the machine is short
3901
+ // of, moving this viewer to a RE-ENCODED rung costs it more, not less —
3902
+ // so the copy path's only lever is the viewer's link, above.
3903
+ return false;
3904
+ }
3905
+ const speed = this.#recentSpeedOf(session, now);
3906
+ if (speed === null) {
3907
+ return false; // no measurement yet
3908
+ }
3909
+ if (speed >= BUDGET_SPEED_OK) {
3910
+ session.budgetSlowSince = 0; // recovered — reset the slow window
3911
+ return false;
3912
+ }
3913
+ if (speed >= BUDGET_SPEED_SLOW) {
3914
+ return false; // in the hysteresis band; neither slow nor ok
3915
+ }
3916
+ if (session.budgetSlowSince === 0) {
3917
+ session.budgetSlowSince = now;
3918
+ return false;
3919
+ }
3920
+ if (now - session.budgetSlowSince < BUDGET_SUSTAINED_MS) {
3921
+ return false; // not sustained yet
3922
+ }
3923
+ const bound = await this.#classifyTranscodeBound(session);
3924
+ if (bound === "download") {
3925
+ logger.info(
3926
+ `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
3927
+ `"${session.fileName}"; not stepping down (torrent is the bottleneck)`
3928
+ );
3929
+ session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
3930
+ return false;
3931
+ }
3932
+ session.budgetSlowSince = 0;
3933
+ session.budgetUpSince = 0;
3934
+ const boundLabel = bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
3935
+ return this.#askLowerHeight(session, `${boundLabel} speed=${speed.toFixed(2)}x`);
3936
+ }
3937
+
3938
+ /**
3939
+ * Ask the player to move down one offered rung.
3940
+ *
3941
+ * THE SIZE OF THE PICTURE IS NEVER REWRITTEN UNDERNEATH A RUNNING SESSION.
3942
+ * The fMP4 init segment is fetched once — a player reads `#EXT-X-MAP` and
3943
+ * never asks again — and `avc1` keeps SPS and PPS in it rather than in the
3944
+ * fragments, so every fragment produced after a size change is decoded
3945
+ * against parameter sets describing a picture that is no longer being made.
3946
+ * Measured 2026-08-21 on two files: one browser went on reporting
3947
+ * `size=1280x720` for three and a half minutes over a band of macroblock
3948
+ * garbage after the encoder had left for 960x540; the other errored on the
3949
+ * first mismatched fragment, closed the MediaSource and sat at `size=0x0`
3950
+ * for four and a half minutes. Which of the two happens is the decoder's
3951
+ * choice, not ours, and no layer reported an error either time.
3952
+ *
3953
+ * A change of resolution is a change of VARIANT, as the standard has it.
3954
+ * Every height is already published in the master with its own init, so the
3955
+ * step is made by ASKING the browser to move — the same act the manual menu
3956
+ * performs, which has never had this fault.
3957
+ *
3958
+ * @param {HlsSession} session
3959
+ * @param {string} reasonText
3960
+ * @returns {boolean} True when an ask was recorded.
3961
+ */
3962
+ #askLowerHeight(session, reasonText) {
3963
+ const base = this.#baseOf(session);
3964
+ const current = this.variantHeightOf(session);
3965
+ const offered = this.offeredHeights(base);
3966
+ // The highest rung strictly below the one on screen that this host is still
3967
+ // willing to serve. `offeredHeights` has already refused everything the
3968
+ // machine cannot hold, so a rung that survives it is one worth moving to.
3969
+ const next = this.#splicableHeights(base)
3970
+ .find((height) => height < current && offered.includes(height));
3971
+ if (next === undefined) {
3972
+ logger.info(
3973
+ `[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
3974
+ `for "${session.fileName}"; leaving the picture alone`
3975
+ );
3976
+ return false;
3977
+ }
3978
+ return this.#askQualityHeight(base, next, reasonText);
3979
+ }
3980
+
3981
+ /**
3982
+ * Record a request to the viewer's player to move to another variant.
3983
+ *
3984
+ * The proxy cannot move a player between variants; it can only say which one
3985
+ * it would rather serve. The request travels in every progress report, and
3986
+ * the browser honours it ONLY in automatic mode — a height the viewer picked
3987
+ * by hand is theirs, and nothing here may take it away.
3988
+ *
3989
+ * @param {HlsSession} base
3990
+ * @param {number} height
3991
+ * @param {string} reasonText
3992
+ * @returns {boolean}
3993
+ */
3994
+ #askQualityHeight(base, height, reasonText) {
3995
+ if (!this.#publishesVariants(base)) {
3996
+ // Said once for the session. Repeating it is not information: the answer
3997
+ // is a property of the stream and cannot change while it plays.
3998
+ if (base.saidNoVariants !== true) {
3999
+ base.saidNoVariants = true;
4000
+ logger.info(
4001
+ `[budget] transcode ${base.id} would ask for ${height}p, but this stream publishes no ` +
4002
+ `variants to move between; leaving the picture alone for the rest of the session`
4003
+ );
3812
4004
  }
3813
- // speed < BUDGET_SPEED_SLOW — track how long it has been slow.
3814
- if (session.budgetSlowSince === 0) {
3815
- session.budgetSlowSince = now;
3816
- continue;
4005
+ return false;
4006
+ }
4007
+ const playing = this.variantHeightOf(this.#activeVariant(base));
4008
+ if (height === playing) {
4009
+ return false;
4010
+ }
4011
+ const now = Date.now();
4012
+ const standing = base.qualityAsk;
4013
+ if (standing && standing.height === height && now - standing.at < QUALITY_ASK_TTL_MS) {
4014
+ return false; // already asked, and the request has not run out
4015
+ }
4016
+ base.qualityAsk = { height, at: now, reason: reasonText };
4017
+ base.budgetLastActionAt = now;
4018
+ logger.info(
4019
+ `[budget] transcode ${base.id} asks the player to move ${playing}p → ${height}p: ${reasonText} ` +
4020
+ `"${base.fileName}" (a change of size is a change of variant — its own init describes it)`
4021
+ );
4022
+ return true;
4023
+ }
4024
+
4025
+ /**
4026
+ * The step BACK UP, in two stages: first give this picture its own bitrate
4027
+ * back, then give it its own size back.
4028
+ *
4029
+ * The order matters. A rate cap was imposed because the viewer's link could
4030
+ * not carry the stream; lifting it is cheaper than enlarging the picture and
4031
+ * is what the viewer notices first. Only a session under no cap is considered
4032
+ * for a higher rung.
4033
+ *
4034
+ * @param {HlsSession} session
4035
+ * @param {number} now
4036
+ * @returns {Promise<void>}
4037
+ */
4038
+ async #checkStepUp(session, now) {
4039
+ const base = this.#baseOf(session);
4040
+ const current = this.variantHeightOf(session);
4041
+ // What the machine and the link would have to look like for a step up, held
4042
+ // for a window four times the one a step DOWN needs. Anything that fails
4043
+ // resets it, so the window measures an unbroken stretch.
4044
+ if (!(await this.#couldCarryMore(session, now, current))) {
4045
+ session.budgetUpSince = 0;
4046
+ return;
4047
+ }
4048
+ if (session.budgetUpSince === 0) {
4049
+ session.budgetUpSince = now;
4050
+ return;
4051
+ }
4052
+ if (now - session.budgetUpSince < BUDGET_UP_SUSTAINED_MS) {
4053
+ return;
4054
+ }
4055
+ if (Number.isFinite(session.rateCapKbps) && session.rateCapKbps > 0) {
4056
+ // A different question from the one above: can the link carry THIS
4057
+ // picture with no cap on it. Asked separately because a session at the
4058
+ // top offered height has no next rung at all, and answering "nothing to
4059
+ // step to, so yes" is how a cap came off a link measured at a fifth of
4060
+ // what the picture needs.
4061
+ if (!this.#linkCouldCarry(session, this.#peakMbpsForHeight(this.#baseOf(session), current), now)) {
4062
+ return;
3817
4063
  }
3818
- if (now - session.budgetSlowSince < BUDGET_SUSTAINED_MS) {
3819
- continue; // not sustained yet
4064
+ session.budgetUpSince = 0;
4065
+ await this.#liftRateCap(session);
4066
+ return;
4067
+ }
4068
+ session.budgetUpSince = 0;
4069
+ // One rung at a time: the lowest height above the one on screen, never
4070
+ // above the source (upscaling invents detail and costs more than the
4071
+ // source itself). A second step follows a second unbroken window.
4072
+ const higher = this.#nextHeightUp(base, current);
4073
+ if (higher === undefined) {
4074
+ return;
4075
+ }
4076
+ this.#askQualityHeight(
4077
+ base,
4078
+ higher,
4079
+ `the machine and the link have carried ${current}p for ` +
4080
+ `${Math.round(BUDGET_UP_SUSTAINED_MS / 1000)}s with room to spare`
4081
+ );
4082
+ }
4083
+
4084
+ /**
4085
+ * Whether this session has room to spare — the encoder ahead of realtime, the
4086
+ * torrent not the limit, and the viewer's link able to carry what the next
4087
+ * rung is allowed to peak at.
4088
+ *
4089
+ * @param {HlsSession} session
4090
+ * @param {number} now
4091
+ * @param {number} current - The height on screen.
4092
+ * @returns {Promise<boolean>}
4093
+ */
4094
+ async #couldCarryMore(session, now, current) {
4095
+ if (session.transcodeVideo === true) {
4096
+ const speed = this.#recentSpeedOf(session, now);
4097
+ if (speed === null || speed < BUDGET_SPEED_OK) {
4098
+ return false;
3820
4099
  }
3821
- if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
3822
- continue; // let the previous action settle
4100
+ }
4101
+ // A run in either slow window is one the budget is already unhappy with.
4102
+ if (session.linkSlowSince !== 0 || session.budgetSlowSince !== 0) {
4103
+ return false;
4104
+ }
4105
+ const report = session.netReport;
4106
+ if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
4107
+ // Nothing fresh measures the link, so it has no opinion either way — the
4108
+ // same silence that stops #checkLinkBudget from acting.
4109
+ return true;
4110
+ }
4111
+ const base = this.#baseOf(session);
4112
+ const next = this.#nextHeightUp(base, current);
4113
+ if (next === undefined) {
4114
+ return true; // nothing to step to; only the cap decision is left
4115
+ }
4116
+ return this.#linkCouldCarry(session, this.#peakMbpsForHeight(base, next), now);
4117
+ }
4118
+
4119
+ /**
4120
+ * Whether the viewer's measured link can carry a given number of Mbit/s.
4121
+ *
4122
+ * A link nobody has measured recently has no opinion either way — the same
4123
+ * silence that stops `#checkLinkBudget` from acting — so it answers yes.
4124
+ *
4125
+ * @param {HlsSession} session
4126
+ * @param {number} wantedMbps
4127
+ * @param {number} now
4128
+ * @returns {boolean}
4129
+ */
4130
+ #linkCouldCarry(session, wantedMbps, now) {
4131
+ const report = session.netReport;
4132
+ if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
4133
+ return true;
4134
+ }
4135
+ return report.linkMbps * LINK_SAFETY >= wantedMbps;
4136
+ }
4137
+
4138
+ /**
4139
+ * The next offered height above `current`, never above the source.
4140
+ *
4141
+ * @param {HlsSession} base
4142
+ * @param {number} current
4143
+ * @returns {number | undefined}
4144
+ */
4145
+ #nextHeightUp(base, current) {
4146
+ const ceiling = Math.round(Number(base.sourceHeight) || 0);
4147
+ return this.offeredHeights(base)
4148
+ .filter((height) => height > current && height <= ceiling)
4149
+ .sort((left, right) => left - right)[0];
4150
+ }
4151
+
4152
+ /**
4153
+ * What a rung is ALLOWED to peak at, in Mbit/s.
4154
+ *
4155
+ * For a re-encoded rung that is the constrained-CRF cap this proxy imposes on
4156
+ * it, which is a figure we set rather than one we hope for. For the height
4157
+ * the family serves by COPY there is no encoder and no cap, so the source's
4158
+ * own bitrate is what will be sent.
4159
+ *
4160
+ * @param {HlsSession} base
4161
+ * @param {number} height
4162
+ * @returns {number}
4163
+ */
4164
+ #peakMbpsForHeight(base, height) {
4165
+ const sourceHeight = Math.round(Number(base.sourceHeight) || 0);
4166
+ if (height === sourceHeight && base.transcodeVideo !== true) {
4167
+ const sourceMbps = Number(base.sourceDecode?.megabitsPerSecond);
4168
+ if (Number.isFinite(sourceMbps) && sourceMbps > 0) {
4169
+ return sourceMbps;
3823
4170
  }
3824
- // Sustained sub-realtime. Only downscale if the encoder — not a
3825
- // download-starved input — is the limit.
3826
- const bound = await this.#classifyTranscodeBound(session);
3827
- if (bound === "download") {
3828
- logger.info(
3829
- `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
3830
- `"${session.fileName}"; not downscaling (torrent is the bottleneck)`
3831
- );
3832
- session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
3833
- continue;
4171
+ }
4172
+ return maxrateKbpsFor(nominalKbpsForHeight(height)) / 1000;
4173
+ }
4174
+
4175
+ /**
4176
+ * Bound this encode's bitrate by the viewer's MEASURED link.
4177
+ *
4178
+ * The one lever that reduces what is sent without touching the picture's
4179
+ * size: `-maxrate`, `-bufsize` and CRF do not appear in the SPS (x264 writes
4180
+ * no HRD parameters unless asked), so the init segment already in the
4181
+ * player's hands goes on describing every fragment. The target is not chosen
4182
+ * — it is the link the browser reported, less the share protocol overhead and
4183
+ * measurement noise take out of it.
4184
+ *
4185
+ * @param {HlsSession} session
4186
+ * @param {number} linkMbps
4187
+ * @param {string} reasonText
4188
+ * @returns {Promise<boolean>}
4189
+ */
4190
+ async #applyRateCap(session, linkMbps, reasonText) {
4191
+ const usableKbps = Math.round(linkMbps * LINK_SAFETY * 1000);
4192
+ const wanted = nominalKbpsForMaxrate(usableKbps);
4193
+ if (!(wanted > 0)) {
4194
+ return false;
4195
+ }
4196
+ // The floor: what the SMALLEST picture this file is offered at is sized to
4197
+ // carry. Below that, the link is not short of bitrate at this size — it is
4198
+ // short of the size, and the answer is a smaller variant rather than a
4199
+ // number that would make this one unwatchable.
4200
+ const base = this.#baseOf(session);
4201
+ const offered = this.offeredHeights(base);
4202
+ const smallest = offered.length > 0 ? Math.min(...offered) : this.variantHeightOf(session);
4203
+ const floor = nominalKbpsForHeight(smallest);
4204
+ if (wanted < floor) {
4205
+ if (this.#askLowerHeight(session, `viewer-link-bound ${reasonText}`)) {
4206
+ return true;
3834
4207
  }
3835
- await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
4208
+ logger.info(
4209
+ `[budget] transcode ${session.id} the link carries ${maxrateKbpsFor(wanted)}kbps and the ` +
4210
+ `smallest picture on offer (${smallest}p) is sized for ${maxrateKbpsFor(floor)}kbps; ` +
4211
+ `capping at the floor rather than below it "${session.fileName}"`
4212
+ );
4213
+ }
4214
+ const nominal = Math.max(wanted, floor);
4215
+ const standing = Number.isFinite(session.rateCapKbps) ? session.rateCapKbps : null;
4216
+ if (standing !== null && nominal >= standing) {
4217
+ return false; // this would loosen a cap, which is the step UP's business
4218
+ }
4219
+ session.rateCapKbps = nominal;
4220
+ session.budgetSlowSince = 0;
4221
+ session.budgetUpSince = 0;
4222
+ base.budgetLastActionAt = Date.now();
4223
+ // What this run was last seen doing described an encode at another bitrate.
4224
+ // A cheaper one encodes faster, so keeping the figure would price the new
4225
+ // picture at the old one's cost.
4226
+ session.lastAloneSpeed = null;
4227
+ session.recentSpeed = null;
4228
+ logger.info(
4229
+ `[budget] transcode ${session.id} viewer-link-bound ${reasonText} → capping the picture at ` +
4230
+ `${maxrateKbpsFor(nominal)}kbps peak, size unchanged at ${session.encodeWidth}x${session.encodeHeight} ` +
4231
+ `"${session.fileName}"`
4232
+ );
4233
+ await this.#restartAtViewer(session);
4234
+ return true;
4235
+ }
4236
+
4237
+ /**
4238
+ * Give a capped picture its own bitrate back.
4239
+ *
4240
+ * @param {HlsSession} session
4241
+ * @returns {Promise<void>}
4242
+ */
4243
+ async #liftRateCap(session) {
4244
+ const lifted = session.rateCapKbps;
4245
+ session.rateCapKbps = null;
4246
+ session.lastAloneSpeed = null;
4247
+ session.recentSpeed = null;
4248
+ this.#baseOf(session).budgetLastActionAt = Date.now();
4249
+ logger.info(
4250
+ `[budget] transcode ${session.id} the link has carried this picture with room to spare; ` +
4251
+ `lifting the ${maxrateKbpsFor(lifted)}kbps cap "${session.fileName}"`
4252
+ );
4253
+ await this.#restartAtViewer(session);
4254
+ }
4255
+
4256
+ /**
4257
+ * Restart this session's encode run at the segment the viewer is on, so a
4258
+ * changed setting takes over from where they are watching.
4259
+ *
4260
+ * @param {HlsSession} session
4261
+ * @returns {Promise<void>}
4262
+ */
4263
+ async #restartAtViewer(session) {
4264
+ const head = session.encodeStartIndex;
4265
+ const processed = Number.isFinite(session.progress?.processedSeconds)
4266
+ ? session.progress.processedSeconds
4267
+ : this.runStartTimeFor(session, head);
4268
+ const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
4269
+ await this.#startEncodeRun(session, currentSeg);
4270
+ }
4271
+
4272
+ /**
4273
+ * Say so when a run is about to encode a picture the init segment already in
4274
+ * the player's hands does not describe.
4275
+ *
4276
+ * This is the whole class of fault named in one line, and it exists because
4277
+ * the fault is otherwise SILENT: no layer reports an error, the encoder is
4278
+ * healthy, segments are served in milliseconds, and what the viewer gets is
4279
+ * either a band of macroblock garbage or a picture that never appears. The
4280
+ * shape is the one 2.48.0 uses for the TIME a run begins at — a run states
4281
+ * where it really landed, and a disagreement with what was published is
4282
+ * named rather than left to be inferred from a browser's own reading.
4283
+ *
4284
+ * The size the init describes is read from the init's own bytes, not taken
4285
+ * from our record of what the encoder was told, because those two disagreeing
4286
+ * IS the fault. The size the run will produce is computed by the same
4287
+ * arithmetic the scale filter performs, so a source smaller than the target
4288
+ * box is not reported as a disagreement.
4289
+ *
4290
+ * Said once per distinct pair of sizes: a run repeated at the same wrong size
4291
+ * has nothing further to say.
4292
+ *
4293
+ * @param {HlsSession} session
4294
+ * @returns {void}
4295
+ */
4296
+ #warnIfRunLeavesTheInitBehind(session) {
4297
+ if (session.transcodeVideo !== true || !session.initBytes || session.initBytes.length === 0) {
4298
+ return; // nothing served yet, or nothing being encoded
4299
+ }
4300
+ const format = session.segmentFormat;
4301
+ if (typeof format?.initVideoSize !== "function") {
4302
+ return; // a self-describing container (MPEG-TS) cannot have this fault
4303
+ }
4304
+ const described = format.initVideoSize(session.initBytes);
4305
+ if (!described) {
4306
+ return;
4307
+ }
4308
+ if (!(session.encodeWidth > 0) || !(session.encodeHeight > 0)) {
4309
+ return; // the run keeps the encoder's own default box; nothing was told
4310
+ }
4311
+ const producing = computeOutputDimensions(
4312
+ session.encodeWidth,
4313
+ session.encodeHeight,
4314
+ session.sourceWidth,
4315
+ session.sourceHeight
4316
+ );
4317
+ if (!producing) {
4318
+ return; // the source size is unknown, so nothing can be predicted
4319
+ }
4320
+ if (described.width === producing.w && described.height === producing.h) {
4321
+ return;
4322
+ }
4323
+ const said = `${described.width}x${described.height}->${producing.w}x${producing.h}`;
4324
+ if (session.initSizeSaid === said) {
4325
+ return;
3836
4326
  }
4327
+ session.initSizeSaid = said;
4328
+ logger.warn(
4329
+ `transcode ${session.id} is about to encode ${producing.w}x${producing.h} while the init segment ` +
4330
+ `the player holds describes ${described.width}x${described.height} "${session.fileName}" — ` +
4331
+ `every fragment of this run will be decoded against parameter sets for a picture that is not ` +
4332
+ `being made. A change of size is a change of variant; nothing here should have moved it.`
4333
+ );
3837
4334
  }
3838
4335
 
3839
4336
  /**
@@ -3879,72 +4376,6 @@ export class HlsSessionManager {
3879
4376
  return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
3880
4377
  }
3881
4378
 
3882
- /**
3883
- * Step a session one resolution rung down the budget ladder and restart the
3884
- * encode at the current segment with the lighter profile.
3885
- *
3886
- * @param {HlsSession} session
3887
- * @param {string} reasonText - Measurement summary for the log line.
3888
- * @param {"cpu" | "unknown" | "link"} bound
3889
- * @returns {Promise<void>}
3890
- */
3891
- async #applyBudgetDownshift(session, reasonText, bound) {
3892
- const nextIndex = session.budgetRungIndex + 1;
3893
- const rung = session.budgetLadder[nextIndex];
3894
- if (!rung) {
3895
- return;
3896
- }
3897
- const fps = Number.isInteger(session.outputFps) && session.outputFps > 0 ? session.outputFps : TRANSCODE_FPS;
3898
- session.budgetRungIndex = nextIndex;
3899
- session.budgetDownshifts += 1;
3900
- session.budgetLastActionAt = Date.now();
3901
- session.budgetSlowSince = 0;
3902
- session.encodeWidth = rung.width;
3903
- session.encodeHeight = rung.height;
3904
- // What this session was last seen doing described the encode it is no
3905
- // longer running. Kept, it prices the new, cheaper picture at the old one's
3906
- // cost, and it keeps the step it was measured on withdrawn from the offer
3907
- // although nothing is producing that step any more. The next reading of the
3908
- // new encode replaces it.
3909
- session.lastAloneSpeed = null;
3910
- // And so is the prediction it was compared against: it described the step
3911
- // this session has just left.
3912
- session.predictedSpeedWhenOffered = this.lastPredictedByHeight?.get(rung.height) ?? null;
3913
- session.lastPredictionRatio = null;
3914
- // Priced the same way the offer and the starting rung are. Choosing the
3915
- // preset on the encoder alone treats decoding as free, which is how the
3916
- // check and the encode came to disagree in the first place — and here it
3917
- // matters most, because this runs on a host that has already failed to keep
3918
- // up and is spending one of its few downshifts.
3919
- session.softwarePreset = pickSoftwarePreset(
3920
- this.softwarePresetBenchmark,
3921
- rung.width * rung.height * fps,
3922
- {
3923
- decodeModel: this.decodeCostModel,
3924
- source: session.sourceDecode ?? null,
3925
- observedDecodeCostSec: this.#observedDecodeCostFor(session),
3926
- requiredSpeed: session.supplyFigures?.requiredSpeed
3927
- ?? this.#requiredSpeedFor(session.sourceKey, session.fileIndex)
3928
- }
3929
- );
3930
- // Restart at the current live-edge segment so the lighter profile takes over
3931
- // from where the viewer is watching (hard-restart tier).
3932
- const head = session.encodeStartIndex;
3933
- const processed = Number.isFinite(session.progress?.processedSeconds)
3934
- ? session.progress.processedSeconds
3935
- : this.runStartTimeFor(session, head);
3936
- const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
3937
- const boundLabel =
3938
- bound === "link" ? "viewer-link-bound" : bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
3939
- logger.info(
3940
- `[budget] transcode ${session.id} ${boundLabel} ` +
3941
- `${reasonText} → downscale to ${rung.width}x${rung.height}/${session.softwarePreset} ` +
3942
- `(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
3943
- `restart at segment #${currentSeg} "${session.fileName}"`
3944
- );
3945
- await this.#startEncodeRun(session, currentSeg);
3946
- }
3947
-
3948
4379
  /**
3949
4380
  * (Re)start the ffmpeg encode run beginning at segment `startIndex`.
3950
4381
  *
@@ -4171,6 +4602,7 @@ export class HlsSessionManager {
4171
4602
  }
4172
4603
  }
4173
4604
 
4605
+ this.#warnIfRunLeavesTheInitBehind(session);
4174
4606
  // Video: re-encode only when required, using the detected encoder
4175
4607
  // (hardware-accelerated or software). The descriptor builds the filter +
4176
4608
  // codec args (including keyframe alignment on segment boundaries).
@@ -4191,7 +4623,10 @@ export class HlsSessionManager {
4191
4623
  // On the source's grid the cuts are not evenly spaced, so no frame
4192
4624
  // count can describe them: the encoder is told the times outright,
4193
4625
  // the same ones the muxer will cut at.
4194
- forcedKeyframeTimes: cutTimes
4626
+ forcedKeyframeTimes: cutTimes,
4627
+ // A ceiling the VIEWER's measured link put on this picture, when one
4628
+ // has been measured. Null means the rung's own nominal rate stands.
4629
+ nominalKbps: session.rateCapKbps ?? null
4195
4630
  })
4196
4631
  : ["-c:v", "copy"];
4197
4632
  const audioCodecArgs = session.transcodeAudio
@@ -5887,6 +6322,31 @@ export class HlsSessionManager {
5887
6322
  * @param {HlsSession} session
5888
6323
  * @returns {number[]} Largest first.
5889
6324
  */
6325
+ /**
6326
+ * Whether this stream publishes a master playlist at all — that is, whether
6327
+ * there is anything for a player to move BETWEEN.
6328
+ *
6329
+ * Asked in one place because two callers depend on the same answer and used
6330
+ * to compute it differently: the builder refused a copied stream whose cut
6331
+ * grid is a fiction, while the budget looked only at how many heights could
6332
+ * in principle be spliced. A copy with no readable keyframe index therefore
6333
+ * had requests recorded against it — asking a player with no variants to
6334
+ * change variant, once every window, for the whole film.
6335
+ *
6336
+ * @param {HlsSession} session
6337
+ * @returns {boolean}
6338
+ */
6339
+ #publishesVariants(session) {
6340
+ const owner = this.#baseOf(session);
6341
+ // A copy can only be cut where the source already has a keyframe, so a rung
6342
+ // meant to splice into it has to be cut at exactly those times. A copy that
6343
+ // fell back to an even grid ffmpeg does not cut on has nothing to align to.
6344
+ if (!owner.transcodeVideo && owner.cutGrid !== "keyframe") {
6345
+ return false;
6346
+ }
6347
+ return this.#splicableHeights(owner).length >= 2;
6348
+ }
6349
+
5890
6350
  #splicableHeights(session) {
5891
6351
  const owner = this.#baseOf(session);
5892
6352
  if (Array.isArray(owner.splicableHeights)) {
@@ -6182,6 +6642,14 @@ export class HlsSessionManager {
6182
6642
  if (speed === null) {
6183
6643
  return;
6184
6644
  }
6645
+ // Recorded HERE, before any of the conditions below can discard the
6646
+ // reading, because the budget and the learning ask different questions of
6647
+ // it. Learning refuses a reading taken beside another encoder, since it
6648
+ // would file that encoder's work as this file's price; the budget wants
6649
+ // exactly what this run is doing right now, whatever else the machine is
6650
+ // doing beside it. Sharing the figure and not the conditions is what lets
6651
+ // the budget stop reading ffmpeg's cumulative average.
6652
+ session.recentSpeed = { speed, at: takenAt, runSerial };
6185
6653
  const kind = costKindForSession(session);
6186
6654
  // A reading taken beside another encoder contains that other encoder's
6187
6655
  // work, and the budget ADDS the same work again when it predicts — so filed
@@ -6773,6 +7241,32 @@ export class HlsSessionManager {
6773
7241
  /** @type {Map<number, number | null>} */
6774
7242
  const predictedByHeight = new Map();
6775
7243
  for (const height of heights) {
7244
+ // The rung ON SCREEN is never withdrawn, whatever anything says about it:
7245
+ // every route guard reads this list, so dropping it would 404 the next
7246
+ // segment of a stream that is playing.
7247
+ if (height === playingHeight) {
7248
+ kept.push(height);
7249
+ continue;
7250
+ }
7251
+ // A rung this session has actually been seen running below realtime is
7252
+ // withdrawn on that evidence, whatever the prediction says. This is the
7253
+ // one thing a live reading is authority on: itself — and it is asked
7254
+ // BEFORE the exemptions below, which it did not used to be.
7255
+ //
7256
+ // That order was harmless while a step rewrote the encode inside the base
7257
+ // and the viewer never left it: `ownHeight` then always WAS the rung on
7258
+ // screen. Now a step moves the viewer to another variant, and the height
7259
+ // they left goes on being exempt although nothing is producing it — so
7260
+ // the step back up would ask for the one rung this machine has been
7261
+ // measured failing at, then fail again, then step down, for ever. The
7262
+ // copied source height cannot reach this: `#measuredRungSpeeds` records
7263
+ // only sessions that re-encode, so a copy has no reading to be withdrawn
7264
+ // on, which is right — it costs no encoder.
7265
+ const measured = measuredHeights?.get(height) ?? null;
7266
+ if (measured !== null && measured < 1) {
7267
+ dropped.push(`${height}p=${measured.toFixed(2)}x measured`);
7268
+ continue;
7269
+ }
6776
7270
  // The height an encoder is ALREADY producing, and the source's own height
6777
7271
  // when the FAMILY serves it by copy — neither has to be predicted,
6778
7272
  // because it is happening. A copied rung costs no encoder at all, so no
@@ -6783,25 +7277,16 @@ export class HlsSessionManager {
6783
7277
  // what withdrew a copied 1080p in the field on 2026-08-15.
6784
7278
  //
6785
7279
  // A source height that would have to be RE-ENCODED is a prediction like
6786
- // any other: on a session whose budget downshifted to 480p, the source's
7280
+ // any other: on a session whose budget stepped down to 480p, the source's
6787
7281
  // 1080p is neither copied nor being produced, and keeping it unpriced
6788
7282
  // would offer exactly the kind of rung this refuses.
6789
7283
  if (
6790
7284
  height === ownHeight ||
6791
- height === playingHeight ||
6792
7285
  (height === sourceHeight && !transcodeVideo)
6793
7286
  ) {
6794
7287
  kept.push(height);
6795
7288
  continue;
6796
7289
  }
6797
- // A rung this session has actually been seen running below realtime is
6798
- // withdrawn on that evidence, whatever the prediction says. This is the
6799
- // one thing a live reading is authority on: itself.
6800
- const measured = measuredHeights?.get(height) ?? null;
6801
- if (measured !== null && measured < 1) {
6802
- dropped.push(`${height}p=${measured.toFixed(2)}x measured`);
6803
- continue;
6804
- }
6805
7290
  const width = Math.round(((sourceWidth / sourceHeight) * height) / 2) * 2;
6806
7291
  // What the machine is spending on everything EXCEPT this height. A step
6807
7292
  // being warmed for a switch is already running while it is judged, so its
@@ -6882,6 +7367,39 @@ export class HlsSessionManager {
6882
7367
  return kept;
6883
7368
  }
6884
7369
 
7370
+ /**
7371
+ * The height this proxy is asking the player to move to, or 0.
7372
+ *
7373
+ * Cleared the moment the viewer is on it — the request has been answered —
7374
+ * and dropped when it runs out, which is the only sign this side ever gets
7375
+ * that a player could not or would not follow it. A browser on a manual pick
7376
+ * ignores every request by design, so an unanswered one is not an error; it
7377
+ * is said once and let go, rather than repeated for the rest of the film.
7378
+ *
7379
+ * @param {HlsSession} named - The session the browser addressed.
7380
+ * @returns {number}
7381
+ */
7382
+ #standingAskFor(named) {
7383
+ const base = this.#baseOf(named);
7384
+ const ask = base.qualityAsk;
7385
+ if (!ask) {
7386
+ return 0;
7387
+ }
7388
+ if (ask.height === this.variantHeightOf(this.#activeVariant(base))) {
7389
+ base.qualityAsk = null; // the viewer is there; nothing left to ask for
7390
+ return 0;
7391
+ }
7392
+ if (Date.now() - ask.at > QUALITY_ASK_TTL_MS) {
7393
+ base.qualityAsk = null;
7394
+ logger.info(
7395
+ `[budget] transcode ${base.id} asked for ${ask.height}p and the player stayed where it was ` +
7396
+ `(${ask.reason}); letting the request go`
7397
+ );
7398
+ return 0;
7399
+ }
7400
+ return ask.height;
7401
+ }
7402
+
6885
7403
  /**
6886
7404
  * The variant of a session that the viewer is watching right now.
6887
7405
  *
@@ -7468,7 +7986,7 @@ export class HlsSessionManager {
7468
7986
  if (!session || session.state === "disposed") {
7469
7987
  return null;
7470
7988
  }
7471
- if (!session.transcodeVideo && session.cutGrid !== "keyframe") {
7989
+ if (!this.#publishesVariants(session)) {
7472
7990
  return null;
7473
7991
  }
7474
7992
  const sourceHeight = Number(session.sourceHeight) || 0;
@@ -7477,9 +7995,6 @@ export class HlsSessionManager {
7477
7995
  // is what the viewer's menu follows; letting it decide the master's
7478
7996
  // existence made a live session answer 404 to its own published address.
7479
7997
  const rungs = this.#splicableHeights(session);
7480
- if (rungs.length < 2) {
7481
- return null;
7482
- }
7483
7998
  const sourceWidth = Number(session.sourceWidth) || 0;
7484
7999
  const lines = ["#EXTM3U", `#EXT-X-VERSION:${session.segmentFormat.playlistVersion}`];
7485
8000
  // The audio tracks, published once for the whole file rather than muxed
@@ -8476,6 +8991,19 @@ export class HlsSessionManager {
8476
8991
  // this very source, so a rung that turns out to be beyond the host
8477
8992
  // disappears from the menu instead of being discovered by switching to it.
8478
8993
  offeredHeights: this.offeredHeights(session),
8994
+ // The variant this proxy would rather serve, or 0 when it is content.
8995
+ //
8996
+ // A REQUEST, not an instruction — this side cannot move a player between
8997
+ // variants and must not pretend to. The browser honours it only in
8998
+ // automatic mode: a height the viewer picked by hand is theirs, and the
8999
+ // rule that automatic quality changes belong to automatic mode alone is
9000
+ // enforced where the viewer's choice actually lives.
9001
+ //
9002
+ // This is what replaced rewriting the picture's size underneath a running
9003
+ // session. Every height is published in the master with its own init, so
9004
+ // asking the player to move is the only form of the act that a decoder
9005
+ // can follow.
9006
+ requestedHeight: this.#standingAskFor(named),
8479
9007
  // What this host takes to create a session and to make a first segment.
8480
9008
  // Also on the playback plan, but the browser reads that once per file:
8481
9009
  // measured 2026-08-06 across four seeks, a proxy that had just restarted