From 9176e877eb82d86cf26e4a95aaded4e595a58bb5 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Tue, 26 May 2026 16:00:18 +0200 Subject: [PATCH] fix(hls): clamp ffmpeg bitrate to the level we derive from outputHeight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking for 2160p quality on a 720p source kept the daemon's qcap.VideoBitrate at 25 Mbps even after outputHeight was clamped to the source. The level H264LevelForHeight picks for the 720p output is 3.1 / 4.0, which rejects any VBV >20 Mbps — libx264 then exited with "VBV bitrate (25000) > level limit" on every restart, ffmpeg auto-restarted 3 times, master.m3u8 never appeared, and the player got stuck at "Preparando sesión". Re-derive the (height, bitrate) cap from the EFFECTIVE outputHeight via the new capForHeight helper. Result: 720p source asked for 2160p → outputs 720p with the 3500 kbps bitrate the level actually accepts. ffmpeg runs cleanly, master.m3u8 appears, playback starts. The web also clamps effectiveQuality to source resolution before the session row is written, so the daemon mostly receives sane labels. This change keeps the daemon defensive against (a) older web clients that still ask for upscaled qualities, and (b) future quality="original" requests where qcap is empty and Transcode.VideoBitrate could overshoot the level too. --- internal/cmd/version.go | 2 +- internal/engine/hls.go | 12 +++++++++++- internal/engine/webrtc_stream.go | 23 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 18dac17..e03063b 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "0.9.2" +var Version = "0.9.3" diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 03a9948..cc0b442 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -863,7 +863,17 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin } args = append(args, "-profile:v", "main", "-level:v", H264LevelForHeight(outputHeight)) - bitrate := qcap.VideoBitrate + // Bitrate must match the level libx264 actually picks for outputHeight, + // not the qcap target for the user's requested label. If a user asks for + // "2160p" on a 1080p source, qcap.VideoBitrate is 25 Mbps but the level + // (derived from outputHeight=1080) is 4.0, which rejects bitrates >20 Mbps + // with "VBV bitrate (25000) > level limit (20000)". Re-derive the cap + // from the effective height so the (level, bitrate) pair stays coherent. + effectiveCap := capForHeight(outputHeight) + bitrate := effectiveCap.VideoBitrate + if bitrate == "" { + bitrate = qcap.VideoBitrate + } if bitrate == "" { bitrate = cfg.Transcode.VideoBitrate } diff --git a/internal/engine/webrtc_stream.go b/internal/engine/webrtc_stream.go index fa4016c..1b4905a 100644 --- a/internal/engine/webrtc_stream.go +++ b/internal/engine/webrtc_stream.go @@ -130,6 +130,29 @@ func resolveQualityCap(label string) qualityCap { } } +// capForHeight returns the bitrate-cap pair appropriate for an effective +// output height. Used after clamping outputHeight to the source's resolution: +// asking ffmpeg for "2160p" bitrate (25 Mbps) on a 1080p source overshoots +// the H.264 level we derived from the EFFECTIVE height (4.0, max 20 Mbps) and +// makes libx264 refuse with "VBV bitrate > level limit". This helper picks +// the bitrate that matches the level libx264 will actually accept. +func capForHeight(height int) qualityCap { + switch { + case height <= 0: + return qualityCap{} + case height <= 480: + return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"} + case height <= 720: + return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"} + case height <= 1080: + return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"} + case height <= 1440: + return qualityCap{MaxHeight: 1440, VideoBitrate: "12000k"} + default: + return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"} + } +} + // buildStreamSource picks between passthrough and transcoded source. ffprobe // failure or missing ffmpeg falls back to passthrough — the browser surfaces // a clearer codec error than us refusing to start.