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.