@torrent-tv/proxy 2.9.32 → 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,7 @@
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
+
1
5
  ## 2.9.32
2
6
 
3
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.
@@ -117,3 +117,23 @@ behaviour.
117
117
  - **WHEN** the viewer selects Auto
118
118
  - **THEN** the proxy applies the realtime budget (startup selection + runtime
119
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,10 +34,16 @@
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
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.
41
47
 
42
48
  ## 4. Manual quality
43
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.32",
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": {
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;
@@ -790,6 +819,15 @@ export class HlsSessionManager {
790
819
  const sourceWidth = mediaInfo.width;
791
820
  const sourceHeight = mediaInfo.height;
792
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";
793
831
  // Output frame rate inherited from the source (integer, capped) so 25/30
794
832
  // fps content is not resampled to 24. Fixed-GOP encoders keep the fps↔GOP
795
833
  // relationship exact; time-based-keyframe encoders just use it as the rate.
@@ -888,6 +926,8 @@ export class HlsSessionManager {
888
926
  // software hosts, else the client target). 0 = keep source.
889
927
  encodeWidth,
890
928
  encodeHeight,
929
+ // Whether to insert the HDR→SDR tone-map chain (software path only).
930
+ applyTonemap,
891
931
  // Realtime-budget runtime state (software encoder only). The ladder is the
892
932
  // resolution rungs from the ceiling down; rungIndex is the current rung.
893
933
  // The monitor steps rungIndex down when the encoder is sustainedly
@@ -947,6 +987,9 @@ export class HlsSessionManager {
947
987
  // ceiling), manual (user-forced, budget off), or unset (keep source).
948
988
  `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
949
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"} ` : ""}` +
950
993
  `${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
951
994
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
952
995
  );
@@ -1282,7 +1325,9 @@ export class HlsSessionManager {
1282
1325
  // use time-based keyframes just apply it as the frame rate.
1283
1326
  fps: session.outputFps,
1284
1327
  // Software-only; hardware descriptors ignore it.
1285
- 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
1286
1331
  })
1287
1332
  : ["-c:v", "copy"];
1288
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