From d2a4fef89ad56964d39a0afb2acf1833d74e8df7 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 7 Jul 2026 08:02:56 +0200 Subject: [PATCH] feat(media_publish): per-track bitrate + codec-aware encoder options The trickle output path hardcoded x264-only encoder options (preset=superfast, tune=zerolatency, forced-idr, bf) for every codec and had no way to set a target bitrate. That made rendition ladders unusable for ABR (the encoder picked its own bitrate) and made non-x264 encoders (libsvtav1, libvpx-vp9, *_nvenc) fail at avcodec_open2. VideoOutputConfig gains three optional, backward-compatible fields: - bit_rate: target average video bitrate (bits/sec) - profile: H.264/H.265 profile (ignored by codecs without one) - encoder_options: extra ffmpeg options merged over the per-codec defaults Encoder options are now codec-aware: x264/x265 keep the original low-latency set (identical behavior); other codecs start from empty defaults so they open, and callers tune via encoder_options (e.g. {"preset":"p4","tune":"ll"} for av1_nvenc). bit_rate is applied to the stream's codec context after add_stream. Unlocks bitrate control AND AV1/HEVC/VP9 on the live/trickle transcode path. Defaults are unchanged, so existing x264 users see no difference. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CjPDUcYXLR6K2ft4g7TYbt --- src/livepeer_gateway/media_publish.py | 39 +++++++++++++++++---- tests/test_media_publish_encoder.py | 49 +++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 tests/test_media_publish_encoder.py diff --git a/src/livepeer_gateway/media_publish.py b/src/livepeer_gateway/media_publish.py index bf11725..b95ed4b 100644 --- a/src/livepeer_gateway/media_publish.py +++ b/src/livepeer_gateway/media_publish.py @@ -53,6 +53,21 @@ def _normalize_fps(fps: Optional[float]) -> int: return max(1, int(round(fps))) +# Low-latency encoder options for the trickle output path. x264/x265 keep the +# original superfast/zerolatency set (backward compatible); other codecs start +# from empty defaults so they open cleanly -- the previously hardcoded x264-only +# options (preset=superfast, tune=zerolatency, forced-idr) made non-x264 encoders +# (libsvtav1, libvpx-vp9, *_nvenc, ...) fail at avcodec_open2. Callers add or +# override any option via VideoOutputConfig.encoder_options. +_X264_LOWLATENCY = {"bf": "0", "preset": "superfast", "tune": "zerolatency", "forced-idr": "1"} + + +def _encoder_base_options(codec: str) -> dict: + if codec in ("libx264", "libx265"): + return dict(_X264_LOWLATENCY) + return {} + + @dataclass(frozen=True) class VideoOutputConfig: """ @@ -76,6 +91,14 @@ class VideoOutputConfig: codec: str = "libx264" # Output pixel format presented to the encoder. pix_fmt: str = "yuv420p" + # Target average video bitrate in bits/sec. None = encoder default (as before). + bit_rate: Optional[int] = None + # H.264/H.265 profile (e.g. "high", "main", "baseline"); codecs without such a + # profile ignore it. None = encoder default. + profile: Optional[str] = None + # Extra ffmpeg encoder options merged over the per-codec defaults, e.g. + # {"preset": "p4", "tune": "ll"} for av1_nvenc. Lets callers tune any encoder. + encoder_options: Optional[dict] = None @dataclass(frozen=True) @@ -657,12 +680,11 @@ def custom_io_open(url: str, flags: int, options: dict) -> object: assert isinstance(config, VideoOutputConfig) if not isinstance(track._first_frame, av.VideoFrame): continue - video_opts = { - "bf": "0", - "preset": "superfast", - "tune": "zerolatency", - "forced-idr": "1", - } + video_opts = _encoder_base_options(config.codec) + if config.profile: + video_opts["profile"] = config.profile + if config.encoder_options: + video_opts.update({str(k): str(v) for k, v in config.encoder_options.items()}) video_kwargs = { "time_base": _OUT_TIME_BASE, "width": track._first_frame.width, @@ -676,6 +698,11 @@ def custom_io_open(url: str, flags: int, options: dict) -> object: options=video_opts, **video_kwargs, ) + if config.bit_rate: + try: + track._stream.codec_context.bit_rate = int(config.bit_rate) + except Exception: # pragma: no cover - encoder may reject + pass continue config = track.config diff --git a/tests/test_media_publish_encoder.py b/tests/test_media_publish_encoder.py new file mode 100644 index 0000000..c4f13dd --- /dev/null +++ b/tests/test_media_publish_encoder.py @@ -0,0 +1,49 @@ +"""Unit tests for the codec-aware encoder options + VideoOutputConfig fields. + +Pure logic — no ffmpeg/trickle needed: +- x264/x265 keep the original low-latency option set (backward compatible). +- other codecs start from empty base options so they open cleanly (the old + hardcoded x264-only options broke non-x264 encoders at avcodec_open2). +- the new bit_rate / profile / encoder_options fields default to "no change". +""" +from __future__ import annotations + +from livepeer_gateway.media_publish import ( + VideoOutputConfig, + _encoder_base_options, + _X264_LOWLATENCY, +) + + +def test_x264_family_keeps_lowlatency_defaults(): + assert _encoder_base_options("libx264") == _X264_LOWLATENCY + assert _encoder_base_options("libx265") == _X264_LOWLATENCY + # returns a copy, not the shared dict (callers mutate it) + opts = _encoder_base_options("libx264") + opts["preset"] = "veryfast" + assert _X264_LOWLATENCY["preset"] == "superfast" + + +def test_non_x264_codecs_get_empty_base_options(): + for codec in ("libsvtav1", "av1_nvenc", "libvpx-vp9", "h264_nvenc", "hevc_nvenc"): + assert _encoder_base_options(codec) == {} + + +def test_video_output_config_defaults_are_backward_compatible(): + cfg = VideoOutputConfig() + assert cfg.bit_rate is None + assert cfg.profile is None + assert cfg.encoder_options is None + assert cfg.codec == "libx264" + + +def test_video_output_config_accepts_new_fields(): + cfg = VideoOutputConfig( + codec="av1_nvenc", + bit_rate=3_000_000, + profile="high", + encoder_options={"preset": "p4", "tune": "ll"}, + ) + assert cfg.bit_rate == 3_000_000 + assert cfg.profile == "high" + assert cfg.encoder_options == {"preset": "p4", "tune": "ll"}