fix(transcode): make preset libx264-only + restore quality opt-in

Two issues with the 0.9.9 preset retune:

1. applyDefaults was filling Preset="veryfast" before
   ResolveEncoderProfile got to pick the latency-biased default, so the
   "superfast" change never reached users with a freshly-generated
   config.toml — only those who left the field empty saw it.

2. The configured preset was being passed through to every encoder.
   That's only valid for libx264 (ultrafast…veryslow); NVENC uses p1-p7
   and rejects anything else, QSV uses its own subset. A user with NVENC
   + preset="veryfast" would have ffmpeg reject the argv.

Now:
- TranscodeConfig.Preset documented as libx264-only with the full
  range + advice on quality vs first-start latency.
- Default in applyDefaults is empty (was "veryfast") so the engine
  fills in "superfast" on libx264.
- ResolveEncoderProfile ignores configuredPreset for vendor encoders
  (NVENC sticks to p3, QSV to veryfast, VideoToolbox has no preset
  knob). Test cases updated to lock in this behaviour.

Users who want better quality at slower first-play should set
download.transcode.preset = "veryfast" (previous default) / "faster" /
"fast" / "medium" in their config.toml.
This commit is contained in:
Deivid Soto 2026-05-27 10:46:03 +02:00
parent 3b8d77b496
commit 0f4ad67827
5 changed files with 72 additions and 20 deletions

View file

@ -39,6 +39,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`realtime` flag steers VideoToolbox into the low-latency code path.
- Encoder + preset selection moved into `engine.ResolveEncoderProfile` so
the same logic drives both argv construction and the log line.
- **`download.transcode.preset` is now libx264-only**. The configured preset
is honoured on software encode (libx264 vocabulary: ultrafast →
veryslow); HW backends ignore it and use vendor-specific defaults
(NVENC p3, QSV veryfast). Passing a libx264 preset to NVENC / QSV was
previously rejected by ffmpeg; the documentation now reflects what was
always the only correct usage.
- Default `download.transcode.preset` is empty (was `"veryfast"`). The
engine fills in `"superfast"` for libx264 — latency-biased. **Users who
want better quality at slower first-play should set it explicitly in
`config.toml`**: `"veryfast"` (previous default) / `"faster"` / `"fast"`
/ `"medium"`. Range documented in the TranscodeConfig struct.
## [0.9.8] - 2026-05-27

View file

@ -98,7 +98,25 @@ type VPNConfig struct {
type TranscodeConfig struct {
Enabled bool `toml:"enabled"` // master switch
HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
Preset string `toml:"preset"` // libx264 preset; "veryfast" by default
// Preset is the encoder speed/quality dial. Only used on software encode
// (libx264) — HW backends (NVENC/QSV/VAAPI/VideoToolbox) use vendor
// presets that don't share libx264's vocabulary and would be rejected
// by ffmpeg if passed here.
//
// Empty (default) → engine picks "superfast" — latency-biased, ~3 s
// first-play on 1080p source on a modern x86 CPU. Marginal quality loss
// at 5-25 Mbps target bitrates.
//
// For better quality at slower first-play (1-2 s slower per seg):
// "veryfast" — previous default; balanced
// "faster" — slight quality bump
// "fast" — meaningful quality bump
// "medium" — libx264 stock default; CPU-bound on 4K
// "slow" / "slower" / "veryslow" — only for batch encodes, not real-time HLS
//
// Or faster:
// "ultrafast" — lowest quality, fastest encode
Preset string `toml:"preset"`
VideoBitrate string `toml:"video_bitrate"` // e.g. "5M"
AudioBitrate string `toml:"audio_bitrate"` // e.g. "192k"
MaxHeight int `toml:"max_height"` // optional downscale cap (e.g. 720)
@ -176,7 +194,10 @@ func Default() Config {
Transcode: TranscodeConfig{
Enabled: true,
HWAccel: "auto",
Preset: "veryfast",
// Empty preset → engine.ResolveEncoderProfile picks the
// latency-biased default ("superfast" on libx264). Override
// in config.toml when quality > first-start latency matters.
Preset: "",
AudioBitrate: "192k",
MaxConcurrent: 2,
},
@ -280,7 +301,12 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
cfg.Download.Transcode.HWAccel = "auto"
}
if !meta.IsDefined("downloads", "transcode", "preset") {
cfg.Download.Transcode.Preset = "veryfast"
// Empty = let engine.ResolveEncoderProfile pick the latency-biased
// default ("superfast" on libx264). Users wanting better quality at
// slower first-play can override to "veryfast" / "fast" / "medium" in
// config.toml. Ignored when hw_accel picks NVENC/QSV/VAAPI/VideoToolbox
// (those have built-in vendor presets).
cfg.Download.Transcode.Preset = ""
}
if !meta.IsDefined("downloads", "transcode", "audio_bitrate") {
cfg.Download.Transcode.AudioBitrate = "192k"

View file

@ -215,8 +215,11 @@ name = "Test"
if cfg.Download.Transcode.HWAccel != "auto" {
t.Errorf("Transcode.HWAccel = %q, want auto", cfg.Download.Transcode.HWAccel)
}
if cfg.Download.Transcode.Preset != "veryfast" {
t.Errorf("Transcode.Preset = %q, want veryfast", cfg.Download.Transcode.Preset)
if cfg.Download.Transcode.Preset != "" {
// Default is now empty — engine.ResolveEncoderProfile picks
// "superfast" on libx264 for first-start latency. Users
// wanting better quality override in config.toml.
t.Errorf("Transcode.Preset = %q, want empty", cfg.Download.Transcode.Preset)
}
if cfg.Download.Transcode.MaxConcurrent != 2 {
t.Errorf("Transcode.MaxConcurrent = %d, want 2", cfg.Download.Transcode.MaxConcurrent)

View file

@ -987,27 +987,31 @@ type EncoderProfile struct {
// ResolveEncoderProfile mirrors the codec + preset selection inside
// buildHLSFFmpegArgsAt so callers (registry, log lines, diagnostic
// endpoints) can know what ffmpeg will be told to do without parsing argv.
//
// The configured preset is libx264-specific by vocabulary (ultrafast…
// veryslow). Passing it through to NVENC / QSV would have ffmpeg reject
// the argv (NVENC uses p1-p7, QSV uses its own subset). So vendor encoders
// always use their hardcoded vendor preset and ignore configuredPreset.
// VideoToolbox has no preset knob at all.
func ResolveEncoderProfile(hw HWAccel, configuredPreset string) EncoderProfile {
codec := hw.FFmpegVideoCodec("h264")
preset := configuredPreset
switch codec {
case "libx264":
preset := configuredPreset
if preset == "" {
preset = "superfast"
}
return EncoderProfile{Codec: codec, Preset: preset}
case "h264_nvenc":
if preset == "" {
preset = "p3"
}
return EncoderProfile{Codec: codec, Preset: "p3"}
case "h264_qsv":
if preset == "" {
preset = "veryfast"
}
return EncoderProfile{Codec: codec, Preset: "veryfast"}
case "h264_videotoolbox":
// No preset knob for VideoToolbox; the speed/quality dial is `-q:v`.
preset = ""
return EncoderProfile{Codec: codec, Preset: ""}
}
return EncoderProfile{Codec: codec, Preset: preset}
// VAAPI + future codecs: no preset, vendor-specific knobs handled in argv.
return EncoderProfile{Codec: codec, Preset: ""}
}
// buildHLSFFmpegArgsAt returns the argv for an HLS encode that starts at the

View file

@ -63,15 +63,23 @@ func TestResolveEncoderProfileDefaults(t *testing.T) {
}
func TestResolveEncoderProfileHonoursConfiguredPreset(t *testing.T) {
// libx264 / NVENC / QSV all defer to the configured preset when set.
// Only libx264 honours the configured preset — the libx264 vocabulary
// (ultrafast…veryslow) doesn't apply to vendor encoders. NVENC has its
// own p1-p7 scale; QSV uses a different subset; VideoToolbox has no
// preset knob. Passing a libx264 preset to them would have ffmpeg reject
// the argv, so ResolveEncoderProfile always falls back to the hardcoded
// vendor preset for non-libx264 codecs.
cases := []struct {
hw HWAccel
configured string
wantPreset string
}{
{HWAccelNone, "ultrafast", "ultrafast"},
{HWAccelNVENC, "p1", "p1"},
{HWAccelQSV, "veryslow", "veryslow"},
{HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours
{HWAccelNone, "medium", "medium"}, // libx264 honours
{HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3
{HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab
{HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast
{HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset
}
for _, tc := range cases {
got := ResolveEncoderProfile(tc.hw, tc.configured)