@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.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +697 -169
- package/services/hwaccel.js +49 -6
- package/services/segment-formats/fmp4.js +320 -300
- package/services/segment-formats/mp4-boxes.js +59 -0
- package/test/auto-quality-step.test.js +442 -0
- package/test/quality-variants.test.js +6 -5
|
@@ -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
|
|
527
|
-
//
|
|
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
|
-
//
|
|
530
|
-
|
|
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
|
|
544
|
-
//
|
|
545
|
-
//
|
|
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
|
|
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
|
|
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
|
|
2122
|
-
//
|
|
2123
|
-
//
|
|
2124
|
-
//
|
|
2125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
3767
|
-
//
|
|
3768
|
-
//
|
|
3769
|
-
//
|
|
3770
|
-
//
|
|
3771
|
-
//
|
|
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
|
|
3781
|
-
//
|
|
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
|
-
|
|
3784
|
-
|
|
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
|
-
//
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
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.
|
|
3798
|
-
// guards double-firing anyway).
|
|
3848
|
+
// encode.
|
|
3799
3849
|
if (await this.#checkLinkBudget(session, now)) {
|
|
3800
3850
|
continue;
|
|
3801
3851
|
}
|
|
3802
|
-
|
|
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
|
-
|
|
3811
|
-
|
|
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
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
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
|
-
|
|
3819
|
-
|
|
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
|
-
|
|
3822
|
-
|
|
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
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|