@torrent-tv/proxy 2.9.30 → 2.9.32

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 CHANGED
@@ -1,3 +1,12 @@
1
+ ## 2.9.32
2
+
3
+ - **New**: Manual quality support (OpenSpec change `transcode-quality`, part 4). The playback plan now reports the source coded resolution (`videoWidth`/`videoHeight`, parsed from the ffprobe banner) so the browser can offer a quality menu. `POST /api/transcode-sessions` accepts `manualQuality: true`: the requested target box is then encoded exactly (capped to the source, never upscaled) with the realtime budget disabled for that session — no startup auto-downscale and no runtime downswitch — so a viewer-forced resolution stays constant for the whole session. `manualQuality` is part of the session key (a forced-quality session is distinct from Auto). Logged as `enc=WxH@fps quality=manual`. Auto (no flag) is unchanged: the realtime budget decides. Pairs with the server release that adds the player quality menu.
4
+
5
+ ## 2.9.31
6
+
7
+ - **New**: Realtime transcode budget — startup resolution + preset selection (OpenSpec change `transcode-quality`, part 2.1). For the software encoder the proxy now picks the output RESOLUTION as well as the libx264 preset from the startup benchmark: the client-requested box (capped to the source, never upscaled) is the ceiling, and the proxy chooses the highest resolution rung at or below it that the benchmark predicts encodes faster than realtime (with the existing margin), then the best preset at that resolution. On a weak host this downscales (e.g. a 720p60→30 stream that ran at ~0.9× on a Home Assistant box now encodes at ~480p in realtime) instead of dropping into sub-realtime playback with constant stalls. Capable hosts keep full resolution and spend the headroom on a higher-quality preset; hardware encoders and the no-benchmark case are unchanged. Also fixed the realtime-need calculation to use the session's actual output frame rate instead of the fixed 24 fps constant (it under-counted for 25/30 fps content). The chosen encode resolution is logged (`enc=WxH@fps budget=on`). This scales down from the orientation-independent ceiling the browser now sends (server 0.8.43).
8
+ - **New**: Realtime transcode budget — runtime downswitch (OpenSpec change `transcode-quality`, part 2.2). If a software transcode runs below realtime for a sustained window (ffmpeg `speed` < ~0.95× for ~15 s), the proxy steps the resolution one rung down the ladder and restarts the encode at the segment the viewer is on, so a stream that starts fine but bogs down on a heavy passage recovers instead of stalling. It first checks the bottleneck: if the torrent download can't sustain the source's byte rate (and the file isn't fully downloaded), the limit is the download, not the encoder — the proxy logs that and does NOT degrade quality. Conservative guards prevent thrash: a 30 s post-action cooldown, at most 3 downshifts, a resolution floor, the slow window reset on every (re)start, and no automatic upswitch yet. The switch point uses a hard encoder restart (a brief blip is possible there; a seamless discontinuity/parallel tier is a later refinement). Logged as `[budget] … CPU-bound speed=… → downscale to WxH` or `… download-limited; not downscaling`.
9
+
1
10
  ## 2.9.30
2
11
 
3
12
  - **New**: The proxy owns subtitle conversion and detects the language from content (OpenSpec change `subtitle-language`). `GET /api/subtitles` now also serves EXTERNAL subtitle files (no `trackIndex`): it reads the file, decodes its encoding (UTF-8 or Windows-1251 — common for Russian `.srt`), converts `.srt`/`.ass`/`.ssa` → WebVTT on the proxy (the browser no longer converts), and reports the language in `X-Subtitle-Language`/`X-Subtitle-Language-Name`. Language is detected with `franc` (n-gram, MIT) restricted to a curated language set — it distinguishes Russian from Ukrainian (and Latin languages) and avoids short-text false positives, returning no header when undetermined. Embedded tracks detect from the first chunk of extracted VTT. Pairs with the server release that fetches VTT from here and applies the filename → content → audio-language priority.
@@ -35,3 +35,85 @@ chosen encoder places keyframes:
35
35
  - **WHEN** the source frame rate cannot be probed on the software path
36
36
  - **THEN** the output falls back to the default rate and playback still
37
37
  segments correctly
38
+
39
+ ### Requirement: Software encode fits a realtime budget at startup
40
+
41
+ For the software encoder, the proxy SHALL choose the output resolution and
42
+ libx264 preset a startup benchmark predicts this host can encode faster than
43
+ realtime (with a margin), rather than always encoding at the client-requested
44
+ resolution. The client-requested box, capped to the source resolution (never
45
+ upscaled), is the ceiling; the proxy SHALL pick the highest resolution rung at
46
+ or below that ceiling that clears the realtime margin, then the highest-quality
47
+ preset that still clears it at that resolution. When even the lowest rung
48
+ cannot clear the margin, the proxy SHALL use the lowest rung (best effort). The
49
+ realtime need SHALL be computed from the session's actual output frame rate.
50
+ Hardware encoders and the no-benchmark case SHALL keep the ceiling resolution
51
+ and the default preset.
52
+
53
+ #### Scenario: Weak host, source above realtime capacity
54
+ - **WHEN** the software benchmark shows the host cannot encode the source-capped
55
+ resolution faster than realtime (e.g. 720p60→30 that runs below 1×)
56
+ - **THEN** the proxy downscales to the highest ladder rung that clears the
57
+ realtime margin (e.g. 480p) instead of encoding sub-realtime at full size
58
+
59
+ #### Scenario: Capable host
60
+ - **WHEN** the benchmark shows ample headroom at the ceiling resolution
61
+ - **THEN** the proxy keeps the ceiling resolution and spends the headroom on a
62
+ higher-quality (slower) preset
63
+
64
+ #### Scenario: Hardware encoder
65
+ - **WHEN** a hardware encoder is selected
66
+ - **THEN** no benchmark-based downscale is applied and the ceiling resolution
67
+ is used
68
+
69
+ ### Requirement: Software encode downswitches at runtime when CPU-bound
70
+
71
+ The proxy SHALL, for the software encoder, step the output resolution one rung
72
+ down the ladder and restart the encode at the segment currently being watched
73
+ when a transcode runs below realtime for a sustained window. Before downscaling
74
+ it SHALL determine whether the limit is the encoder or a download-starved
75
+ input — comparing the torrent download rate with the source's average byte rate
76
+ (a fully-downloaded file is never download-bound) — and SHALL NOT downscale when
77
+ the limit is the download (it SHALL log that instead). The downswitch SHALL be
78
+ bounded by a sustained-slow window, a post-action cooldown, a maximum number of
79
+ steps, and a resolution floor, and SHALL reset its slow window on every encode
80
+ (re)start. There SHALL be no automatic upswitch in this version.
81
+
82
+ #### Scenario: Sustained CPU-bound transcode
83
+ - **WHEN** a software transcode's encoder speed stays below realtime for the
84
+ sustained window while the input download keeps up
85
+ - **THEN** the proxy downscales one rung and restarts at the current segment,
86
+ up to the step cap / resolution floor
87
+
88
+ #### Scenario: Download-limited, not CPU-limited
89
+ - **WHEN** the encoder speed is below realtime but the torrent cannot download
90
+ the source's byte rate and the file is not fully downloaded
91
+ - **THEN** the proxy does not downscale and logs that the download is the limit
92
+
93
+ #### Scenario: No thrash after a switch
94
+ - **WHEN** a downswitch (or a viewer seek) has just restarted the encode
95
+ - **THEN** the slow window is reset and no further downswitch occurs until a new
96
+ sustained-slow window elapses after the cooldown
97
+
98
+ ### Requirement: Manual quality forces a constant resolution
99
+
100
+ The viewer SHALL be able to force a specific output resolution instead of Auto.
101
+ When a resolution is forced the proxy SHALL encode exactly that box (capped to
102
+ the source, never upscaled) with the realtime budget disabled — no startup
103
+ auto-downscale and no runtime downswitch — so the resolution stays constant for
104
+ the whole session. The browser SHALL offer Auto plus resolutions at or below the
105
+ source height, built from the source resolution reported in the playback plan.
106
+ Selecting a quality SHALL re-open the stream at the new resolution with the
107
+ playback position preserved. Auto SHALL keep the current realtime-budget
108
+ behaviour.
109
+
110
+ #### Scenario: Forced resolution is constant
111
+ - **WHEN** the viewer forces a resolution (e.g. 480p)
112
+ - **THEN** the proxy encodes at that resolution for the whole session, with no
113
+ budget downscale or runtime downswitch, and playback resumes at the same
114
+ position
115
+
116
+ #### Scenario: Auto
117
+ - **WHEN** the viewer selects Auto
118
+ - **THEN** the proxy applies the realtime budget (startup selection + runtime
119
+ downswitch) as before
@@ -11,11 +11,27 @@
11
11
  - [x] 1.3 Unit-verify fps choice and fps↔GOP consistency (25→100, 24→96,
12
12
  default→96); syntax checks
13
13
 
14
- ## 2. Realtime budget (planned)
15
-
16
- - [ ] 2.1 Benchmark picks encoder/preset/resolution/fps within a realtime
17
- margin; downscale instead of refuse
18
- - [ ] 2.2 Runtime `speed<1` watch → restart with a lighter profile
14
+ ## 2. Realtime budget
15
+
16
+ - [x] 2.1 Startup: benchmark picks resolution + preset within a realtime
17
+ margin; downscale below the client-target ceiling instead of refusing
18
+ (`chooseSoftwareEncodeSettings`/`buildResolutionLadder` in hwaccel;
19
+ `#chooseEncodeBudget` + `encodeWidth`/`encodeHeight` in the session
20
+ manager; fixed the needed-pixels calc to use the session's `outputFps`
21
+ not the fixed `TRANSCODE_FPS`). Verified on the FIFA host profile
22
+ (720p60→480p) + strong/weak/no-benchmark profiles.
23
+ - [x] 2.2 Runtime `speed<1` watch → step down the resolution ladder + restart
24
+ at the current segment (hard-restart tier). Gate on CPU-bound only:
25
+ compare `getFileStats().downloadSpeed` with the source byte-rate so a
26
+ download-starved input is NOT misread as an encoder limit (don't degrade
27
+ quality for a download bottleneck — log it instead). Hysteresis +
28
+ cooldown + floor + max steps; slow window reset on every (re)start; no
29
+ upswitch in v1 (oscillation risk). `#enforceRealtimeBudget` +
30
+ `#classifyTranscodeBound` + `#applyBudgetDownshift` in the session
31
+ manager; `getSourceStats` injected from server.js.
32
+ NOTE (follow-up 2.2b): seamless switch via `EXT-X-DISCONTINUITY` /
33
+ parallel encoder tier keyed by host resources — the hard restart can blip
34
+ at the switch point.
19
35
  - [ ] 2.3 `-maxrate`/`-bufsize`
20
36
 
21
37
  ## 3. HDR tone mapping (planned)
@@ -23,10 +39,17 @@
23
39
  - [ ] 3.1 Detect 10-bit/HDR; insert tonemap chain when re-encoding to 8-bit
24
40
  - [ ] 3.2 Guard on tonemap-filter availability in the ffmpeg build
25
41
 
26
- ## 4. Manual quality (planned)
27
-
28
- - [ ] 4.1 Proxy honours requested target height (already partly there)
29
- - [ ] 4.2 Server Quality menu (Auto + forced resolutions)
42
+ ## 4. Manual quality
43
+
44
+ - [x] 4.1 Proxy honours a forced resolution: `manualQuality` flag on the
45
+ transcode-session request encodes the requested box exactly (capped to
46
+ source, budget + runtime downswitch disabled); `manualQuality` in the
47
+ session key; playback plan reports source `videoWidth`/`videoHeight`.
48
+ - [x] 4.2 Server Quality menu (Auto + forced resolutions <= source). Custom
49
+ media-chrome submenu mirroring the audio menu; selection re-opens the
50
+ stream at the forced resolution with the position preserved (reuses the
51
+ audio-switch machinery); shared settings button shows for audio OR
52
+ quality.
30
53
 
31
54
  ## 5. Release
32
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.30",
3
+ "version": "2.9.32",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -33,6 +33,10 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
33
33
  const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
34
34
  const targetWidth = Number(payload.targetWidth);
35
35
  const targetHeight = Number(payload.targetHeight);
36
+ // Manual quality: the target box is a user-forced resolution, encoded exactly
37
+ // (capped to source), with the realtime budget's auto-downscale + runtime
38
+ // downswitch disabled for the session.
39
+ const manualQuality = payload.manualQuality === true;
36
40
  const startPositionSeconds = Number(payload.startPositionSeconds);
37
41
  const audioTrackIndex = Number(payload.audioTrackIndex);
38
42
 
@@ -50,6 +54,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
50
54
  fileName,
51
55
  targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
52
56
  targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
57
+ manualQuality,
53
58
  startPositionSeconds:
54
59
  Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
55
60
  ? startPositionSeconds
package/server.js CHANGED
@@ -117,7 +117,21 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
117
117
  localBindHost: host,
118
118
  localPort: selectedPort,
119
119
  videoEncoder,
120
- softwarePresetBenchmark
120
+ softwarePresetBenchmark,
121
+ // Live download stats accessor for the realtime budget: lets it tell a
122
+ // CPU-bound transcode from a download-starved input before downscaling.
123
+ getSourceStats: async (sourceKey, fileIndex) => {
124
+ const record = sourceRegistry.get(sourceKey);
125
+ if (!record) {
126
+ return null;
127
+ }
128
+ try {
129
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
130
+ return torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
121
135
  });
122
136
  const playbackPlanner = createPlaybackPlanner({
123
137
  ffmpegBin,
@@ -15,7 +15,13 @@ import path from "node:path";
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { spawn } from "node:child_process";
17
17
  import { logger } from "../utils/logger.js";
18
- import { softwareDescriptor, pickSoftwarePreset, TRANSCODE_FPS, chooseOutputFps } from "./hwaccel.js";
18
+ import {
19
+ softwareDescriptor,
20
+ chooseSoftwareEncodeSettings,
21
+ pickSoftwarePreset,
22
+ TRANSCODE_FPS,
23
+ chooseOutputFps
24
+ } from "./hwaccel.js";
19
25
 
20
26
  const PLAYLIST_FILE_NAME = "index.m3u8";
21
27
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -36,6 +42,30 @@ const RESTART_COOLDOWN_MS = 4_000;
36
42
  // segment fetch, so it never expires mid-watch.
37
43
  const DEFAULT_SESSION_TTL_MS = 120 * 1000;
38
44
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
45
+ // Realtime budget — runtime downswitch (software encoder only). Periodically
46
+ // check each active software-transcode session's ffmpeg `speed`; when it stays
47
+ // below realtime for a sustained window AND the input is not download-starved
48
+ // (so the limit is the encoder, not the torrent), step down one resolution rung
49
+ // and restart at the current segment. Conservative so it never thrashes: a long
50
+ // sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
51
+ const BUDGET_CHECK_INTERVAL_MS = 5_000;
52
+ // Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
53
+ // realtime resets the slow window (hysteresis).
54
+ const BUDGET_SPEED_SLOW = 0.95;
55
+ const BUDGET_SPEED_OK = 1.0;
56
+ // Slow must persist this long before a downshift (absorbs warm-up + brief
57
+ // complex scenes; the cumulative average won't dip this long unless the host
58
+ // genuinely can't keep up).
59
+ const BUDGET_SUSTAINED_MS = 15_000;
60
+ // After a downshift, wait this long before another (lets the new profile settle
61
+ // and a fresh cumulative average build).
62
+ const BUDGET_ACTION_COOLDOWN_MS = 30_000;
63
+ // Never step down more than this many rungs below the startup choice.
64
+ const BUDGET_MAX_DOWNSHIFTS = 3;
65
+ // The input counts as "keeping up" when the torrent downloads at least this
66
+ // multiple of the source's average byte rate. Below it (and not yet fully
67
+ // downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
68
+ const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
39
69
  const MICROSECONDS_PER_SECOND = 1_000_000;
40
70
  const PROGRESS_LOG_INTERVAL_MS = 5_000;
41
71
  // Read segment files in large blocks so the body is delivered to the data
@@ -625,10 +655,15 @@ export class HlsSessionManager {
625
655
  sessionTtlMs = DEFAULT_SESSION_TTL_MS,
626
656
  startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
627
657
  videoEncoder = null,
628
- softwarePresetBenchmark = null
658
+ softwarePresetBenchmark = null,
659
+ getSourceStats = null
629
660
  }) {
630
661
  this.enabled = Boolean(enabled);
631
662
  this.ffmpegBin = ffmpegBin;
663
+ // Optional async accessor for a source's live download stats, used by the
664
+ // realtime budget to tell a CPU limit from a download-starved input:
665
+ // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
666
+ this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
632
667
  // Detected H.264 encoder descriptor (hardware or software). Defaults to
633
668
  // software libx264 when no detection result is supplied. May be downgraded
634
669
  // to software at runtime if a hardware encode fails.
@@ -647,6 +682,13 @@ export class HlsSessionManager {
647
682
  void this.cleanupExpired();
648
683
  }, CLEANUP_INTERVAL_MS);
649
684
  this.cleanupTimer.unref();
685
+ // Realtime-budget monitor: only meaningful for the software encoder with a
686
+ // benchmark (the only path that can pick/step resolution). Cheap no-op scan
687
+ // otherwise.
688
+ this.budgetTimer = setInterval(() => {
689
+ void this.#enforceRealtimeBudget();
690
+ }, BUDGET_CHECK_INTERVAL_MS);
691
+ this.budgetTimer.unref();
650
692
  }
651
693
 
652
694
  /**
@@ -667,6 +709,7 @@ export class HlsSessionManager {
667
709
  * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
668
710
  * @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
669
711
  * @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
712
+ * @param {boolean} [options.manualQuality=false] - User-forced resolution: encode the target box exactly (capped to source), no budget downscale / runtime downswitch.
670
713
  * @returns {Promise<HlsSession>}
671
714
  */
672
715
  async createOrGetSession({
@@ -679,7 +722,8 @@ export class HlsSessionManager {
679
722
  targetWidth = 0,
680
723
  targetHeight = 0,
681
724
  startPositionSeconds = 0,
682
- audioTrackIndex = 0
725
+ audioTrackIndex = 0,
726
+ manualQuality = false
683
727
  }) {
684
728
  if (!this.enabled) {
685
729
  const error = new Error("Audio transcoding is disabled on this proxy.");
@@ -697,6 +741,7 @@ export class HlsSessionManager {
697
741
  : 0;
698
742
  const normalizedAudioTrack =
699
743
  Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
744
+ const forceManualQuality = manualQuality === true && transcodeVideo;
700
745
  const sourceMapKey = [
701
746
  sourceKey,
702
747
  String(fileIndex),
@@ -705,6 +750,7 @@ export class HlsSessionManager {
705
750
  `t${normalizedAudioTrack}`,
706
751
  String(normalizedTargetWidth),
707
752
  String(normalizedTargetHeight),
753
+ forceManualQuality ? "q-manual" : "q-auto",
708
754
  String(normalizedStartPosition)
709
755
  ].join(":");
710
756
  const existingId = this.sessionIdBySource.get(sourceMapKey);
@@ -787,17 +833,32 @@ export class HlsSessionManager {
787
833
  const usingKeyframeBoundaries = hasDuration && !transcodeVideo && Array.isArray(keyframeTimes);
788
834
  const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
789
835
 
790
- // Pick the highest-quality software preset that still encodes the actual
791
- // (source-capped) output resolution faster than realtime. Null for hardware
792
- // encoders or when the source size / benchmark is unavailable — buildVideoArgs
793
- // then uses its static default preset.
794
- const softwarePreset = this.#chooseSoftwarePreset({
795
- transcodeVideo,
796
- targetWidth: normalizedTargetWidth,
797
- targetHeight: normalizedTargetHeight,
798
- sourceWidth,
799
- sourceHeight
800
- });
836
+ // Realtime budget (software encoder): pick the output resolution + libx264
837
+ // preset this host can encode faster than realtime. On a weak host this
838
+ // downscales below the client target (the orientation-independent ceiling)
839
+ // instead of dropping into sub-realtime playback. Null for hardware
840
+ // encoders or when the source size / benchmark is unavailable — the encode
841
+ // then keeps the client target box and buildVideoArgs's default preset.
842
+ //
843
+ // Manual quality bypasses the budget entirely: the user forced a specific
844
+ // resolution, so encode exactly that box (capped to source by the scale
845
+ // filter) with the default preset, and the runtime downswitch is skipped
846
+ // for the session (budgetLadder stays null).
847
+ const encodeBudget = forceManualQuality
848
+ ? null
849
+ : this.#chooseEncodeBudget({
850
+ transcodeVideo,
851
+ targetWidth: normalizedTargetWidth,
852
+ targetHeight: normalizedTargetHeight,
853
+ sourceWidth,
854
+ sourceHeight,
855
+ outputFps
856
+ });
857
+ const softwarePreset = encodeBudget?.preset ?? null;
858
+ // Effective encode box: the budget's downscaled resolution when applied,
859
+ // otherwise the client target (0 = keep source, handled by buildVideoArgs).
860
+ const encodeWidth = encodeBudget?.width ?? normalizedTargetWidth;
861
+ const encodeHeight = encodeBudget?.height ?? normalizedTargetHeight;
801
862
 
802
863
  const session = {
803
864
  id: sessionId,
@@ -818,8 +879,24 @@ export class HlsSessionManager {
818
879
  transcodeAudio,
819
880
  audioTrackIndex: normalizedAudioTrack,
820
881
  outputFps,
882
+ // Client-requested target box (the orientation-independent ceiling). Kept
883
+ // for the session key and reference; the actual encode uses encodeWidth/
884
+ // encodeHeight, which the realtime budget may have downscaled below this.
821
885
  targetWidth: normalizedTargetWidth,
822
886
  targetHeight: normalizedTargetHeight,
887
+ // Effective encode resolution handed to ffmpeg (budget-selected on weak
888
+ // software hosts, else the client target). 0 = keep source.
889
+ encodeWidth,
890
+ encodeHeight,
891
+ // Realtime-budget runtime state (software encoder only). The ladder is the
892
+ // resolution rungs from the ceiling down; rungIndex is the current rung.
893
+ // The monitor steps rungIndex down when the encoder is sustainedly
894
+ // CPU-bound and restarts ffmpeg at the current segment.
895
+ budgetLadder: encodeBudget?.ladder ?? null,
896
+ budgetRungIndex: Number.isInteger(encodeBudget?.rungIndex) ? encodeBudget.rungIndex : 0,
897
+ budgetDownshifts: 0,
898
+ budgetSlowSince: 0,
899
+ budgetLastActionAt: 0,
823
900
  sourceWidth,
824
901
  sourceHeight,
825
902
  // Container start time (seconds); subtracted on the copy path so the
@@ -866,6 +943,10 @@ export class HlsSessionManager {
866
943
  `branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
867
944
  `seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
868
945
  `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
946
+ // Effective encode resolution: budget-on (auto downscale from the
947
+ // ceiling), manual (user-forced, budget off), or unset (keep source).
948
+ `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
949
+ `${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
869
950
  `${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
870
951
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
871
952
  );
@@ -971,25 +1052,190 @@ export class HlsSessionManager {
971
1052
  }
972
1053
 
973
1054
  /**
974
- * Choose the libx264 preset for a software video transcode: the highest
975
- * quality the startup benchmark says this host can encode at the actual
976
- * (source-capped) output resolution faster than realtime. Returns null when
977
- * not applicable (no video transcode, hardware encoder, or missing
978
- * benchmark/source size) buildVideoArgs then uses its default preset.
1055
+ * Realtime budget (software encoder only): choose the output resolution AND
1056
+ * libx264 preset this host can encode faster than realtime, from the startup
1057
+ * benchmark. The ceiling is the client-requested box capped to the source
1058
+ * (never upscaled); the budget picks the highest resolution rung at or below
1059
+ * that ceiling that clears realtime × margin, then the best preset at that
1060
+ * resolution. On a weak host this downscales below the client target instead
1061
+ * of dropping into sub-realtime playback. Returns null when not applicable
1062
+ * (no video transcode, hardware encoder, or missing benchmark/source size) —
1063
+ * the encode then keeps the ceiling resolution and the default preset.
979
1064
  *
980
- * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null }} params
981
- * @returns {string | null}
1065
+ * @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number }} params
1066
+ * @returns {{ width: number, height: number, preset: string } | null}
982
1067
  */
983
- #chooseSoftwarePreset({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight }) {
1068
+ #chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps }) {
984
1069
  if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
985
1070
  return null;
986
1071
  }
987
- const out = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
988
- if (!out) {
1072
+ const ceiling = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
1073
+ if (!ceiling) {
1074
+ return null;
1075
+ }
1076
+ return chooseSoftwareEncodeSettings(this.softwarePresetBenchmark, { width: ceiling.w, height: ceiling.h }, outputFps);
1077
+ }
1078
+
1079
+ /**
1080
+ * Parse ffmpeg's `speed` progress value (e.g. "0.903x", "1.6x", "N/A") into a
1081
+ * number. Returns null when it cannot be parsed (no data yet).
1082
+ *
1083
+ * @param {string} value
1084
+ * @returns {number | null}
1085
+ */
1086
+ #parseSpeed(value) {
1087
+ if (typeof value !== "string" || value.length === 0) {
989
1088
  return null;
990
1089
  }
991
- const pixelsPerSecNeeded = out.w * out.h * TRANSCODE_FPS;
992
- return pickSoftwarePreset(this.softwarePresetBenchmark, pixelsPerSecNeeded);
1090
+ const numeric = Number.parseFloat(value);
1091
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
1092
+ }
1093
+
1094
+ /**
1095
+ * Realtime budget monitor (software encoder only). For each active
1096
+ * software-transcode session, watch the encoder's cumulative `speed`: when it
1097
+ * stays below realtime for a sustained window AND the input is not
1098
+ * download-starved (so the limit is the encoder, not the torrent), step the
1099
+ * resolution one rung down the ladder and restart the encode at the current
1100
+ * segment. Conservative: sustained window, post-action cooldown, a step cap,
1101
+ * and a resolution floor (the last ladder rung). No upswitch in v1.
1102
+ *
1103
+ * @returns {Promise<void>}
1104
+ */
1105
+ async #enforceRealtimeBudget() {
1106
+ if (this.videoEncoder?.kind !== "software") {
1107
+ return;
1108
+ }
1109
+ const now = Date.now();
1110
+ for (const session of this.sessionsById.values()) {
1111
+ if (
1112
+ !session ||
1113
+ session.state === "disposed" ||
1114
+ session.state === "failed" ||
1115
+ !session.transcodeVideo ||
1116
+ !Array.isArray(session.budgetLadder) ||
1117
+ session.budgetLadder.length < 2
1118
+ ) {
1119
+ continue;
1120
+ }
1121
+ // Already at the floor or out of steps — nothing more to give.
1122
+ if (
1123
+ session.budgetRungIndex >= session.budgetLadder.length - 1 ||
1124
+ session.budgetDownshifts >= BUDGET_MAX_DOWNSHIFTS
1125
+ ) {
1126
+ continue;
1127
+ }
1128
+ const speed = this.#parseSpeed(session.progress?.speed);
1129
+ if (speed === null) {
1130
+ continue; // no measurement yet
1131
+ }
1132
+ if (speed >= BUDGET_SPEED_OK) {
1133
+ session.budgetSlowSince = 0; // recovered — reset the slow window
1134
+ continue;
1135
+ }
1136
+ if (speed >= BUDGET_SPEED_SLOW) {
1137
+ continue; // in the hysteresis band; neither slow nor ok
1138
+ }
1139
+ // speed < BUDGET_SPEED_SLOW — track how long it has been slow.
1140
+ if (session.budgetSlowSince === 0) {
1141
+ session.budgetSlowSince = now;
1142
+ continue;
1143
+ }
1144
+ if (now - session.budgetSlowSince < BUDGET_SUSTAINED_MS) {
1145
+ continue; // not sustained yet
1146
+ }
1147
+ if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
1148
+ continue; // let the previous action settle
1149
+ }
1150
+ // Sustained sub-realtime. Only downscale if the encoder — not a
1151
+ // download-starved input — is the limit.
1152
+ const bound = await this.#classifyTranscodeBound(session);
1153
+ if (bound === "download") {
1154
+ logger.info(
1155
+ `[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
1156
+ `"${session.fileName}"; not downscaling (torrent is the bottleneck)`
1157
+ );
1158
+ session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
1159
+ continue;
1160
+ }
1161
+ this.#applyBudgetDownshift(session, speed, bound);
1162
+ }
1163
+ }
1164
+
1165
+ /**
1166
+ * Decide whether a sustained sub-realtime transcode is limited by the encoder
1167
+ * (CPU) or by a download-starved input. Compares the torrent's download rate
1168
+ * with the source's average byte rate; a fully-downloaded file can never be
1169
+ * download-bound. Returns "cpu" | "download" | "unknown" ("unknown" is treated
1170
+ * as CPU by the caller — the common case, logged as such).
1171
+ *
1172
+ * @param {HlsSession} session
1173
+ * @returns {Promise<"cpu" | "download" | "unknown">}
1174
+ */
1175
+ async #classifyTranscodeBound(session) {
1176
+ if (!this.getSourceStats) {
1177
+ return "unknown";
1178
+ }
1179
+ let stats;
1180
+ try {
1181
+ stats = await this.getSourceStats(session.sourceKey, session.fileIndex);
1182
+ } catch {
1183
+ return "unknown";
1184
+ }
1185
+ if (!stats) {
1186
+ return "unknown";
1187
+ }
1188
+ // A fully (or almost fully) downloaded file cannot be download-bound.
1189
+ if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
1190
+ return "cpu";
1191
+ }
1192
+ const duration = Number.isFinite(session.totalDurationSeconds) ? session.totalDurationSeconds : 0;
1193
+ const length = Number.isFinite(stats.fileLength) && stats.fileLength > 0 ? stats.fileLength : 0;
1194
+ const downloadSpeed = Number.isFinite(stats.downloadSpeed) ? stats.downloadSpeed : 0;
1195
+ if (duration <= 0 || length <= 0) {
1196
+ return "unknown"; // cannot compute the source byte rate
1197
+ }
1198
+ const sourceByteRate = length / duration;
1199
+ return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
1200
+ }
1201
+
1202
+ /**
1203
+ * Step a session one resolution rung down the budget ladder and restart the
1204
+ * encode at the current segment with the lighter profile.
1205
+ *
1206
+ * @param {HlsSession} session
1207
+ * @param {number} speed - The measured (sub-realtime) speed, for logging.
1208
+ * @param {"cpu" | "unknown"} bound
1209
+ * @returns {void}
1210
+ */
1211
+ #applyBudgetDownshift(session, speed, bound) {
1212
+ const nextIndex = session.budgetRungIndex + 1;
1213
+ const rung = session.budgetLadder[nextIndex];
1214
+ if (!rung) {
1215
+ return;
1216
+ }
1217
+ const fps = Number.isInteger(session.outputFps) && session.outputFps > 0 ? session.outputFps : TRANSCODE_FPS;
1218
+ session.budgetRungIndex = nextIndex;
1219
+ session.budgetDownshifts += 1;
1220
+ session.budgetLastActionAt = Date.now();
1221
+ session.budgetSlowSince = 0;
1222
+ session.encodeWidth = rung.width;
1223
+ session.encodeHeight = rung.height;
1224
+ session.softwarePreset = pickSoftwarePreset(this.softwarePresetBenchmark, rung.width * rung.height * fps);
1225
+ // Restart at the current live-edge segment so the lighter profile takes over
1226
+ // from where the viewer is watching (hard-restart tier).
1227
+ const head = session.encodeStartIndex;
1228
+ const processed = Number.isFinite(session.progress?.processedSeconds)
1229
+ ? session.progress.processedSeconds
1230
+ : this.#segmentStartTime(session, head);
1231
+ const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
1232
+ logger.info(
1233
+ `[budget] transcode ${session.id} ${bound === "unknown" ? "assuming CPU-bound" : "CPU-bound"} ` +
1234
+ `speed=${speed.toFixed(2)}x → downscale to ${rung.width}x${rung.height}/${session.softwarePreset} ` +
1235
+ `(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
1236
+ `restart at segment #${currentSeg} "${session.fileName}"`
1237
+ );
1238
+ this.#startEncodeRun(session, currentSeg);
993
1239
  }
994
1240
 
995
1241
  /**
@@ -1027,8 +1273,10 @@ export class HlsSessionManager {
1027
1273
  // codec args (including keyframe alignment on segment boundaries).
1028
1274
  const videoCodecArgs = session.transcodeVideo
1029
1275
  ? this.videoEncoder.buildVideoArgs({
1030
- targetWidth: session.targetWidth,
1031
- targetHeight: session.targetHeight,
1276
+ // Budget-selected encode box (may be below the client target on weak
1277
+ // software hosts); falls back to the client target for hardware.
1278
+ targetWidth: session.encodeWidth,
1279
+ targetHeight: session.encodeHeight,
1032
1280
  segmentDurationSec: this.segmentDurationSec,
1033
1281
  // Source-inherited output rate (integer, capped); descriptors that
1034
1282
  // use time-based keyframes just apply it as the frame rate.
@@ -1115,6 +1363,11 @@ export class HlsSessionManager {
1115
1363
  session.progress.processedSeconds = startSeconds;
1116
1364
  session.progress.startPositionSeconds = startSeconds;
1117
1365
  session.progress.updatedAt = Date.now();
1366
+ // Any (re)start resets the cumulative `speed` ffmpeg reports, so reset the
1367
+ // realtime-budget slow window too — otherwise warm-up right after a user
1368
+ // seek could be mis-counted as sustained sub-realtime and trigger a
1369
+ // premature downscale.
1370
+ session.budgetSlowSince = 0;
1118
1371
 
1119
1372
  logger.info(
1120
1373
  `transcode ${session.id} encode-run from segment #${safeIndex} ` +
@@ -1533,6 +1786,7 @@ export class HlsSessionManager {
1533
1786
  */
1534
1787
  async disposeAll() {
1535
1788
  clearInterval(this.cleanupTimer);
1789
+ clearInterval(this.budgetTimer);
1536
1790
  const activeIds = Array.from(this.sessionsById.keys());
1537
1791
  for (const sessionId of activeIds) {
1538
1792
  await this.disposeSession(sessionId);
@@ -558,3 +558,87 @@ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded) {
558
558
  }
559
559
  return benchmark[benchmark.length - 1].preset;
560
560
  }
561
+
562
+ // Resolution-ladder heights (output height rungs), high→low. The ladder is
563
+ // derived per-stream from the ceiling (the client-requested, source-capped
564
+ // output box): only rungs at or below the ceiling height are used, so the
565
+ // budget never upscales past what the client asked for. Standard heights keep
566
+ // the downscaled output at familiar resolutions.
567
+ const RESOLUTION_LADDER_HEIGHTS = [2160, 1440, 1080, 720, 540, 480, 360, 240];
568
+
569
+ /**
570
+ * Build the resolution ladder for a ceiling box. Returns candidate output
571
+ * dimensions from the ceiling downward, preserving the ceiling's aspect ratio,
572
+ * each even-sized. The ceiling itself is always the top rung; ladder heights
573
+ * at or above it are skipped (never upscale). Deduped by height.
574
+ *
575
+ * @param {number} ceilingWidth
576
+ * @param {number} ceilingHeight
577
+ * @returns {Array<{ width: number, height: number }>} high→low
578
+ */
579
+ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
580
+ const cw = Number.isInteger(ceilingWidth) && ceilingWidth > 0 ? ceilingWidth : 0;
581
+ const ch = Number.isInteger(ceilingHeight) && ceilingHeight > 0 ? ceilingHeight : 0;
582
+ if (!cw || !ch) {
583
+ return [];
584
+ }
585
+ const even = (v) => {
586
+ const r = Math.round(v);
587
+ return Math.max(2, r - (r % 2));
588
+ };
589
+ /** @type {Array<{ width: number, height: number }>} */
590
+ const rungs = [{ width: cw, height: ch }];
591
+ for (const h of RESOLUTION_LADDER_HEIGHTS) {
592
+ if (h >= ch) {
593
+ continue; // at/above the ceiling — the ceiling rung already covers it
594
+ }
595
+ rungs.push({ width: even(cw * (h / ch)), height: h });
596
+ }
597
+ const seen = new Set();
598
+ return rungs.filter((rung) => {
599
+ if (seen.has(rung.height)) {
600
+ return false;
601
+ }
602
+ seen.add(rung.height);
603
+ return true;
604
+ });
605
+ }
606
+
607
+ /**
608
+ * Choose the software encode settings (resolution + preset) that fit the
609
+ * realtime budget on this host. From the resolution ladder (ceiling downward),
610
+ * pick the HIGHEST rung whose encode throughput — predicted from the startup
611
+ * benchmark's fastest preset — clears realtime × PRESET_SPEED_MARGIN. Then, at
612
+ * that resolution, pick the highest-quality preset that still clears the
613
+ * margin. When even the lowest rung cannot clear it, use the lowest rung with
614
+ * the fastest preset (best effort — a smaller picture beats sub-realtime
615
+ * playback at full size). Returns null when no benchmark or ceiling is
616
+ * available (the caller keeps the ceiling resolution and the default preset).
617
+ *
618
+ * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
619
+ * @param {{ width: number, height: number }} ceiling
620
+ * @param {number} outputFps
621
+ * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
622
+ */
623
+ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps) {
624
+ if (!Array.isArray(benchmark) || benchmark.length === 0) {
625
+ return null;
626
+ }
627
+ const fps = Number.isFinite(outputFps) && outputFps > 0 ? outputFps : TRANSCODE_FPS;
628
+ const ladder = buildResolutionLadder(ceiling?.width, ceiling?.height);
629
+ if (ladder.length === 0) {
630
+ return null;
631
+ }
632
+ const fastest = benchmark[benchmark.length - 1].pixelsPerSec; // ultrafast throughput
633
+ let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
634
+ for (let i = 0; i < ladder.length; i += 1) {
635
+ const needed = ladder[i].width * ladder[i].height * fps;
636
+ if (fastest >= needed * PRESET_SPEED_MARGIN) {
637
+ chosenIndex = i;
638
+ break;
639
+ }
640
+ }
641
+ const chosen = ladder[chosenIndex];
642
+ const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps);
643
+ return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
644
+ }
@@ -68,6 +68,18 @@ function parseStreams(ffmpegOutput) {
68
68
  function parseStreamCodecs(ffmpegOutput) {
69
69
  const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
70
70
  const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
71
+ // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
72
+ // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
73
+ const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
74
+ let videoWidth = 0;
75
+ let videoHeight = 0;
76
+ if (videoLineMatch) {
77
+ const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
78
+ if (dim) {
79
+ videoWidth = Number(dim[1]);
80
+ videoHeight = Number(dim[2]);
81
+ }
82
+ }
71
83
  const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
72
84
  const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
73
85
  let durationSeconds = 0;
@@ -106,6 +118,8 @@ function parseStreamCodecs(ffmpegOutput) {
106
118
  videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
107
119
  container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
108
120
  durationSeconds,
121
+ videoWidth,
122
+ videoHeight,
109
123
  audioTracks,
110
124
  subtitleTracks
111
125
  };
@@ -208,6 +222,8 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
208
222
  * @property {string} videoCodec
209
223
  * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
210
224
  * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
225
+ * @property {number} videoWidth - Source coded width (0 if unknown).
226
+ * @property {number} videoHeight - Source coded height (0 if unknown).
211
227
  */
212
228
 
213
229
  /**
@@ -289,6 +305,8 @@ export function createPlaybackPlanner({
289
305
  videoCodec: "",
290
306
  container: "",
291
307
  durationSeconds: 0,
308
+ videoWidth: 0,
309
+ videoHeight: 0,
292
310
  audioTracks: [],
293
311
  subtitleTracks: []
294
312
  };
@@ -316,7 +334,7 @@ export function createPlaybackPlanner({
316
334
  await torrentPool.prefetchFileEdges(torrent, fileIndex);
317
335
  probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
318
336
  }
319
- const { audioCodec, videoCodec, container, durationSeconds, audioTracks, subtitleTracks } = probe;
337
+ const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
320
338
  const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
321
339
 
322
340
  // `mode` is advisory only (audio-codec based). The browser makes the
@@ -331,6 +349,10 @@ export function createPlaybackPlanner({
331
349
  videoCodec,
332
350
  container,
333
351
  durationSeconds,
352
+ // Source coded resolution — drives the browser's manual quality menu
353
+ // (list of forced resolutions <= source). 0 when unknown.
354
+ videoWidth,
355
+ videoHeight,
334
356
  // Full track inventory for the browser's audio/subtitle menus.
335
357
  audioTracks: audioTracks ?? [],
336
358
  subtitleTracks: subtitleTracks ?? []