@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 +8 -0
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +43 -0
- package/openspec/changes/transcode-quality/tasks.md +22 -9
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +5 -0
- package/server.js +8 -1
- package/services/hls-session-manager.js +72 -15
- package/services/hwaccel.js +41 -2
- package/services/playback-planner.js +23 -1
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
|
|
38
|
-
|
|
39
|
-
- [
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
-
|
|
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
|
@@ -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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
|
936
|
-
//
|
|
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
|
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
|
|
@@ -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 ?? []
|