@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 +4 -0
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +20 -0
- package/openspec/changes/transcode-quality/tasks.md +10 -4
- package/package.json +1 -1
- package/server.js +8 -1
- package/services/hls-session-manager.js +49 -4
- package/services/hwaccel.js +41 -2
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
|
|
38
|
-
|
|
39
|
-
- [
|
|
40
|
-
|
|
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
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
|
package/services/hwaccel.js
CHANGED
|
@@ -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
|