@torrent-tv/proxy 2.9.31 → 2.9.33

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,11 @@
1
+ ## 2.9.33
2
+
3
+ - **New**: HDR / 10-bit tone mapping (OpenSpec change `transcode-quality`, part 3). An HDR source (BT.2020 with a PQ `smpte2084` or HLG `arib-std-b67` transfer) re-encoded to 8-bit SDR without tone mapping looks washed-out and desaturated. The proxy now detects HDR from the probe and, when re-encoding video on the software path, inserts a `zscale`+`tonemap` (hable) chain to convert HDR→BT.709 SDR properly. It is **gated on filter availability**: at startup the proxy checks this ffmpeg build for the `zscale` (libzimg) and `tonemap` filters (`hwaccel: HDR tone mapping available/unavailable …`); when either is missing it falls back to the previous plain 8-bit convert (still plays, just washed-out). The tone map runs after the downscale (cheaper on ARM). Logged per session as `hdr=1 tonemap=on|off`. Hardware encoders keep their current path for now (tone mapping there is a follow-up). No client change — the browser plays the resulting SDR HLS.
4
+
5
+ ## 2.9.32
6
+
7
+ - **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.
8
+
1
9
  ## 2.9.31
2
10
 
3
11
  - **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).
@@ -94,3 +94,46 @@ steps, and a resolution floor, and SHALL reset its slow window on every encode
94
94
  - **WHEN** a downswitch (or a viewer seek) has just restarted the encode
95
95
  - **THEN** the slow window is reset and no further downswitch occurs until a new
96
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
120
+
121
+ ### Requirement: HDR sources are tone-mapped when re-encoded to SDR
122
+
123
+ The proxy SHALL apply an HDR→BT.709 SDR tone-map chain when re-encoding an HDR
124
+ source (a BT.2020 PQ `smpte2084` or HLG `arib-std-b67` transfer) to 8-bit SDR on
125
+ the software path, so the output is not washed-out — provided this ffmpeg build
126
+ has the required filters. The proxy SHALL detect filter availability (`zscale`
127
+ and `tonemap`) at startup and, when either is missing, SHALL fall back to a
128
+ plain 8-bit convert without failing playback.
129
+
130
+ #### Scenario: HDR source, filters available
131
+ - **WHEN** an HDR source is re-encoded on the software path and the build has
132
+ `zscale` + `tonemap`
133
+ - **THEN** the tone-map chain is inserted and the output is BT.709 SDR (not
134
+ washed-out)
135
+
136
+ #### Scenario: HDR source, filters missing
137
+ - **WHEN** an HDR source is re-encoded but the build lacks `zscale`/`tonemap`
138
+ - **THEN** playback still proceeds with a plain 8-bit convert (no tone map) and
139
+ the limitation is logged
@@ -34,15 +34,28 @@
34
34
  at the switch point.
35
35
  - [ ] 2.3 `-maxrate`/`-bufsize`
36
36
 
37
- ## 3. HDR tone mapping (planned)
38
-
39
- - [ ] 3.1 Detect 10-bit/HDR; insert tonemap chain when re-encoding to 8-bit
40
- - [ ] 3.2 Guard on tonemap-filter availability in the ffmpeg build
41
-
42
- ## 4. Manual quality (planned)
43
-
44
- - [ ] 4.1 Proxy honours requested target height (already partly there)
45
- - [ ] 4.2 Server Quality menu (Auto + forced resolutions)
37
+ ## 3. HDR tone mapping
38
+
39
+ - [x] 3.1 Detect HDR from the probe (PQ `smpte2084` / HLG `arib-std-b67`
40
+ transfer); insert a `zscale`+`tonemap` (hable) BT.2020→BT.709 SDR chain
41
+ when re-encoding video on the software path (after the downscale).
42
+ - [x] 3.2 Guard on tonemap-filter availability: startup `detectTonemapSupport`
43
+ checks `ffmpeg -filters` for `zscale` + `tonemap`; when missing, HDR
44
+ falls back to the plain 8-bit convert (washed-out but plays). Logged.
45
+ NOTE (follow-up 3.3): hardware-encoder tone mapping (tonemap_vaapi / npp /
46
+ opencl) — software path only for now.
47
+
48
+ ## 4. Manual quality
49
+
50
+ - [x] 4.1 Proxy honours a forced resolution: `manualQuality` flag on the
51
+ transcode-session request encodes the requested box exactly (capped to
52
+ source, budget + runtime downswitch disabled); `manualQuality` in the
53
+ session key; playback plan reports source `videoWidth`/`videoHeight`.
54
+ - [x] 4.2 Server Quality menu (Auto + forced resolutions <= source). Custom
55
+ media-chrome submenu mirroring the audio menu; selection re-opens the
56
+ stream at the forced resolution with the position preserved (reuses the
57
+ audio-switch machinery); shared settings button shows for audio OR
58
+ quality.
46
59
 
47
60
  ## 5. Release
48
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.31",
3
+ "version": "2.9.33",
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
@@ -30,7 +30,7 @@ import { createSourceRegistry } from "./store/source-registry.js";
30
30
  import { TorrentPool } from "./services/torrent-pool.js";
31
31
  import { HlsSessionManager } from "./services/hls-session-manager.js";
32
32
  import { createPlaybackPlanner } from "./services/playback-planner.js";
33
- import { detectVideoEncoder, benchmarkSoftwarePresets } from "./services/hwaccel.js";
33
+ import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
34
34
  import { logger } from "./utils/logger.js";
35
35
 
36
36
  const __filename = fileURLToPath(import.meta.url);
@@ -111,6 +111,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
111
111
  const softwarePresetBenchmark = videoEncoder?.kind === "software"
112
112
  ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
113
113
  : null;
114
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
115
+ // Detected once; the session manager applies the tonemap chain only for HDR
116
+ // sources on the software path when available.
117
+ const tonemapSupported = transcodeAudio
118
+ ? await detectTonemapSupport({ ffmpegBin, logger })
119
+ : false;
114
120
  const hlsSessionManager = new HlsSessionManager({
115
121
  enabled: transcodeAudio,
116
122
  ffmpegBin,
@@ -118,6 +124,7 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
118
124
  localPort: selectedPort,
119
125
  videoEncoder,
120
126
  softwarePresetBenchmark,
127
+ tonemapSupported,
121
128
  // Live download stats accessor for the realtime budget: lets it tell a
122
129
  // CPU-bound transcode from a download-starved input before downscaling.
123
130
  getSourceStats: async (sourceKey, fileIndex) => {
@@ -348,6 +348,30 @@ function parseFfmpegVideoFps(stderrText) {
348
348
  return Number.isFinite(value) && value > 0 ? value : null;
349
349
  }
350
350
 
351
+ /**
352
+ * Detect an HDR / wide-gamut source from the ffmpeg "Video:" line's colour
353
+ * metadata. HDR is identified by the transfer function — `smpte2084` (PQ /
354
+ * HDR10) or `arib-std-b67` (HLG). Re-encoding such a source to 8-bit SDR
355
+ * without tone mapping produces a washed-out, desaturated picture, so this
356
+ * gates the tonemap filter chain.
357
+ *
358
+ * @param {string} stderrText
359
+ * @returns {boolean}
360
+ */
361
+ function parseFfmpegHdr(stderrText) {
362
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
363
+ return false;
364
+ }
365
+ const videoLine = stderrText.match(/Video:[^\n]*/i);
366
+ if (!videoLine) {
367
+ return false;
368
+ }
369
+ // ffmpeg prints the colour info in parentheses, e.g.
370
+ // "yuv420p10le(tv, bt2020nc/bt2020/smpte2084)". The transfer (last token) is
371
+ // the reliable HDR signal.
372
+ return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
373
+ }
374
+
351
375
  /**
352
376
  * Run a short ffmpeg probe to extract the total duration AND video resolution
353
377
  * of a stream from the container header. Both are printed almost immediately
@@ -356,7 +380,7 @@ function parseFfmpegVideoFps(stderrText) {
356
380
  *
357
381
  * @param {string} ffmpegBin - Path to the ffmpeg executable.
358
382
  * @param {string | URL} inputUrl - URL of the stream to probe.
359
- * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null }>}
383
+ * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
360
384
  */
361
385
  async function probeInputMediaInfo(ffmpegBin, inputUrl) {
362
386
  return new Promise((resolve) => {
@@ -377,7 +401,8 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
377
401
  width: dims.width,
378
402
  height: dims.height,
379
403
  fps: parseFfmpegVideoFps(stderr),
380
- startTime: parseFfmpegStartTimeSeconds(stderr)
404
+ startTime: parseFfmpegStartTimeSeconds(stderr),
405
+ isHdr: parseFfmpegHdr(stderr)
381
406
  });
382
407
  };
383
408
  const timeoutId = setTimeout(() => {
@@ -656,7 +681,8 @@ export class HlsSessionManager {
656
681
  startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
657
682
  videoEncoder = null,
658
683
  softwarePresetBenchmark = null,
659
- getSourceStats = null
684
+ getSourceStats = null,
685
+ tonemapSupported = false
660
686
  }) {
661
687
  this.enabled = Boolean(enabled);
662
688
  this.ffmpegBin = ffmpegBin;
@@ -672,6 +698,9 @@ export class HlsSessionManager {
672
698
  // used to pick the best preset per stream. Null when unavailable (hardware
673
699
  // encoder, or benchmark skipped/failed).
674
700
  this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
701
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
702
+ // Gates the tonemap chain for HDR sources on the software path.
703
+ this.tonemapSupported = Boolean(tonemapSupported);
675
704
  this.segmentDurationSec = segmentDurationSec;
676
705
  this.sessionTtlMs = sessionTtlMs;
677
706
  this.startupWaitMs = startupWaitMs;
@@ -709,6 +738,7 @@ export class HlsSessionManager {
709
738
  * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
710
739
  * @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
711
740
  * @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
741
+ * @param {boolean} [options.manualQuality=false] - User-forced resolution: encode the target box exactly (capped to source), no budget downscale / runtime downswitch.
712
742
  * @returns {Promise<HlsSession>}
713
743
  */
714
744
  async createOrGetSession({
@@ -721,7 +751,8 @@ export class HlsSessionManager {
721
751
  targetWidth = 0,
722
752
  targetHeight = 0,
723
753
  startPositionSeconds = 0,
724
- audioTrackIndex = 0
754
+ audioTrackIndex = 0,
755
+ manualQuality = false
725
756
  }) {
726
757
  if (!this.enabled) {
727
758
  const error = new Error("Audio transcoding is disabled on this proxy.");
@@ -739,6 +770,7 @@ export class HlsSessionManager {
739
770
  : 0;
740
771
  const normalizedAudioTrack =
741
772
  Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
773
+ const forceManualQuality = manualQuality === true && transcodeVideo;
742
774
  const sourceMapKey = [
743
775
  sourceKey,
744
776
  String(fileIndex),
@@ -747,6 +779,7 @@ export class HlsSessionManager {
747
779
  `t${normalizedAudioTrack}`,
748
780
  String(normalizedTargetWidth),
749
781
  String(normalizedTargetHeight),
782
+ forceManualQuality ? "q-manual" : "q-auto",
750
783
  String(normalizedStartPosition)
751
784
  ].join(":");
752
785
  const existingId = this.sessionIdBySource.get(sourceMapKey);
@@ -786,6 +819,15 @@ export class HlsSessionManager {
786
819
  const sourceWidth = mediaInfo.width;
787
820
  const sourceHeight = mediaInfo.height;
788
821
  const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
822
+ // Tone-map an HDR source to SDR only when re-encoding video on the software
823
+ // path and this ffmpeg has the filters. Hardware encoders keep their own
824
+ // (untone-mapped) path for now; when unavailable, HDR falls back to a plain
825
+ // 8-bit convert (washed-out but playable).
826
+ const applyTonemap =
827
+ transcodeVideo === true &&
828
+ mediaInfo.isHdr === true &&
829
+ this.tonemapSupported &&
830
+ this.videoEncoder?.kind === "software";
789
831
  // Output frame rate inherited from the source (integer, capped) so 25/30
790
832
  // fps content is not resampled to 24. Fixed-GOP encoders keep the fps↔GOP
791
833
  // relationship exact; time-based-keyframe encoders just use it as the rate.
@@ -835,14 +877,21 @@ export class HlsSessionManager {
835
877
  // instead of dropping into sub-realtime playback. Null for hardware
836
878
  // encoders or when the source size / benchmark is unavailable — the encode
837
879
  // then keeps the client target box and buildVideoArgs's default preset.
838
- const encodeBudget = this.#chooseEncodeBudget({
839
- transcodeVideo,
840
- targetWidth: normalizedTargetWidth,
841
- targetHeight: normalizedTargetHeight,
842
- sourceWidth,
843
- sourceHeight,
844
- outputFps
845
- });
880
+ //
881
+ // Manual quality bypasses the budget entirely: the user forced a specific
882
+ // resolution, so encode exactly that box (capped to source by the scale
883
+ // filter) with the default preset, and the runtime downswitch is skipped
884
+ // for the session (budgetLadder stays null).
885
+ const encodeBudget = forceManualQuality
886
+ ? null
887
+ : this.#chooseEncodeBudget({
888
+ transcodeVideo,
889
+ targetWidth: normalizedTargetWidth,
890
+ targetHeight: normalizedTargetHeight,
891
+ sourceWidth,
892
+ sourceHeight,
893
+ outputFps
894
+ });
846
895
  const softwarePreset = encodeBudget?.preset ?? null;
847
896
  // Effective encode box: the budget's downscaled resolution when applied,
848
897
  // otherwise the client target (0 = keep source, handled by buildVideoArgs).
@@ -877,6 +926,8 @@ export class HlsSessionManager {
877
926
  // software hosts, else the client target). 0 = keep source.
878
927
  encodeWidth,
879
928
  encodeHeight,
929
+ // Whether to insert the HDR→SDR tone-map chain (software path only).
930
+ applyTonemap,
880
931
  // Realtime-budget runtime state (software encoder only). The ladder is the
881
932
  // resolution rungs from the ceiling down; rungIndex is the current rung.
882
933
  // The monitor steps rungIndex down when the encoder is sustainedly
@@ -932,9 +983,13 @@ export class HlsSessionManager {
932
983
  `branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
933
984
  `seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
934
985
  `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
935
- // Effective encode resolution and whether the realtime budget downscaled
936
- // it below the client target (the orientation-independent ceiling).
986
+ // Effective encode resolution: budget-on (auto downscale from the
987
+ // ceiling), manual (user-forced, budget off), or unset (keep source).
937
988
  `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
989
+ `${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
990
+ // HDR source and whether the tone-map chain was applied (vs washed-out
991
+ // fallback when the filters are missing or on a hardware encoder).
992
+ `${transcodeVideo && mediaInfo.isHdr ? `hdr=1 tonemap=${applyTonemap ? "on" : "off"} ` : ""}` +
938
993
  `${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
939
994
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
940
995
  );
@@ -1270,7 +1325,9 @@ export class HlsSessionManager {
1270
1325
  // use time-based keyframes just apply it as the frame rate.
1271
1326
  fps: session.outputFps,
1272
1327
  // Software-only; hardware descriptors ignore it.
1273
- preset: session.softwarePreset ?? undefined
1328
+ preset: session.softwarePreset ?? undefined,
1329
+ // HDR→SDR tone map (software path only; gated on filter availability).
1330
+ tonemap: session.applyTonemap === true
1274
1331
  })
1275
1332
  : ["-c:v", "copy"];
1276
1333
  const audioCodecArgs = session.transcodeAudio
@@ -27,6 +27,14 @@ import path from "node:path";
27
27
 
28
28
  const SOFTWARE_PRESET = "ultrafast";
29
29
  const SOFTWARE_CRF = "24";
30
+ // HDR→SDR tone-map chain (software). Converts a BT.2020 PQ/HLG source to BT.709
31
+ // 8-bit SDR so the re-encode is not washed-out/desaturated. Requires the
32
+ // `zscale` (libzimg) and `tonemap` filters — gated by detectTonemapSupport;
33
+ // when unavailable the encode falls back to a plain 8-bit convert (no tonemap).
34
+ // npl=100 targets ~100-nit SDR; hable is a well-behaved tone-mapping operator.
35
+ const TONEMAP_FILTER_CHAIN =
36
+ "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709," +
37
+ "tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p";
30
38
  // Default output frame rate when the source rate is unknown, and the rate used
31
39
  // by the synthetic startup test-encode / preset benchmark. The real encode
32
40
  // inherits the source rate (rounded to an integer, capped) — see
@@ -108,20 +116,24 @@ export function softwareDescriptor() {
108
116
  kind: "software",
109
117
  device: null,
110
118
  inputArgs: [],
111
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps }) {
119
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap }) {
112
120
  const { w, h } = safeDimensions(targetWidth, targetHeight);
113
121
  const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
114
122
  // Output frame rate: inherited from the source (rounded/capped) by the
115
123
  // session manager, TRANSCODE_FPS by default. MUST be an integer and MUST
116
124
  // equal the value used in the GOP below, or keyframes drift off the grid.
117
125
  const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
126
+ // HDR→SDR tone-map, inserted AFTER the downscale so it runs on the smaller
127
+ // frame (cheaper on ARM); only when the source is HDR and the filters are
128
+ // present (session manager gates on both).
129
+ const tonemapPart = tonemap === true ? `,${TONEMAP_FILTER_CHAIN}` : "";
118
130
  return [
119
131
  // Never upscale: cap the target box to the source size (min with
120
132
  // iw/ih), so a small source (e.g. 720x400) is encoded at its own
121
133
  // resolution instead of being scaled up to the viewport — far fewer
122
134
  // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
123
135
  "-vf",
124
- `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps}`,
136
+ `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${tonemapPart},fps=${outFps}`,
125
137
  "-c:v", "libx264",
126
138
  // Preset is chosen per stream by the session manager from the startup
127
139
  // benchmark (highest quality that still encodes the source resolution
@@ -496,6 +508,33 @@ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec
496
508
  return software;
497
509
  }
498
510
 
511
+ /**
512
+ * Detect whether this ffmpeg build has the filters needed for the HDR→SDR
513
+ * tone-map chain (`zscale`, from libzimg, and `tonemap`). Both are required;
514
+ * when either is missing, HDR sources are re-encoded without tone mapping
515
+ * (washed-out but playable). Always resolves.
516
+ *
517
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
518
+ * @returns {Promise<boolean>}
519
+ */
520
+ export async function detectTonemapSupport({ ffmpegBin, logger }) {
521
+ const log = logger ?? { info: () => {}, warn: () => {} };
522
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-filters"], 10000);
523
+ if (code !== 0) {
524
+ log.warn("hwaccel: could not list ffmpeg filters; HDR tone mapping disabled");
525
+ return false;
526
+ }
527
+ // `-filters` prints one filter per line: "... zscale ...", "... tonemap ...".
528
+ const hasZscale = /\bzscale\b/.test(stdout);
529
+ const hasTonemap = /\btonemap\b/.test(stdout);
530
+ const supported = hasZscale && hasTonemap;
531
+ log.info(
532
+ `hwaccel: HDR tone mapping ${supported ? "available" : "unavailable"} ` +
533
+ `(zscale=${hasZscale} tonemap=${hasTonemap})`
534
+ );
535
+ return supported;
536
+ }
537
+
499
538
  /**
500
539
  * Benchmark software libx264 presets on this host. Encodes a short synthetic
501
540
  * clip at a fixed reference resolution with each preset and measures encoder
@@ -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 ?? []