Skip to content

Commit 1526ccf

Browse files
authored
Fix/video codecs patching (#245)
* Fix publish_codec_preferences() not called with real transceivers In PublisherPeerConnection, publish_codec_preferences() was called on init, when the list of transceivers was still empty, so it was not applied, and vp8 codec was still used. Moved the func call to `addTrack()` to fix that. * Set "ultrafast" preset for h264 codec by default - Updated StreamH264Encoder to pass "preset" to codec options - Added STREAM_PATCH_AIORTC_H264_PRESET env variable with "ultrafast" by default. To reset to default "medium", pass "medium" or empty value * Update StreamH264Encoder._encode_frame to patch the codec preset more robustly in case of resolution change * fix ty check
1 parent 031dc16 commit 1526ccf

3 files changed

Lines changed: 94 additions & 14 deletions

File tree

getstream/video/rtc/encoders_patches.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import fractions
12
import logging
23
import os
34
from typing import Optional
45

6+
import av
57
from aiortc import RTCRtpCodecParameters
8+
from aiortc.codecs.h264 import MAX_FRAME_RATE as H264_MAX_FRAME_RATE
69
from aiortc.codecs.h264 import H264Encoder
710
from aiortc.codecs.vpx import Vp8Encoder
811
from aiortc.rtcrtpsender import RTCEncodedFrame, RTCRtpSender
@@ -26,6 +29,11 @@
2629
"off",
2730
)
2831

32+
# libx264 preset for the StreamH264Encoder. Defaults to "ultrafast" for
33+
# real-time CPU; can be raised (e.g. "veryfast", "medium") for better
34+
# quality-per-bit at the cost of more CPU per frame.
35+
STREAM_H264_PRESET = os.getenv("STREAM_PATCH_AIORTC_H264_PRESET", "ultrafast").strip()
36+
2937

3038
try:
3139
# Verify the name-mangled attributes we depend on still exist.
@@ -51,11 +59,13 @@ def target_bitrate(self, bitrate: int) -> None:
5159
self._Vp8Encoder__target_bitrate = bitrate
5260

5361
class StreamH264Encoder(H264Encoder):
54-
"""H264Encoder subclass with higher bitrate bounds for Stream calls."""
62+
"""H264Encoder subclass with higher bitrate bounds and a real-time
63+
libx264 preset for Stream calls."""
5564

5665
def __init__(self) -> None:
5766
super().__init__()
5867
self._H264Encoder__target_bitrate = STREAM_VIDEO_DEFAULT_BITRATE
68+
self.preset = STREAM_H264_PRESET
5969

6070
@property
6171
def target_bitrate(self) -> int:
@@ -68,10 +78,45 @@ def target_bitrate(self, bitrate: int) -> None:
6878
)
6979
self._H264Encoder__target_bitrate = bitrate
7080

81+
def _encode_frame(self, frame, force_keyframe):
82+
# Mirror parent's invalidation policy so we own codec creation in
83+
# both first-init AND recreation (resolution/bitrate change) paths;
84+
# otherwise parent's `if self.codec is None` branch silently
85+
# reverts our preset to libx264's default `medium`.
86+
codec = self.codec
87+
if (
88+
codec is not None
89+
and codec.bit_rate
90+
and (
91+
frame.width != codec.width
92+
or frame.height != codec.height
93+
or abs(self.target_bitrate - codec.bit_rate) / codec.bit_rate > 0.1
94+
)
95+
):
96+
self.buffer_data = b""
97+
self.buffer_pts = None
98+
self.codec = None
99+
100+
if self.codec is None:
101+
self.codec = av.CodecContext.create("libx264", "w")
102+
self.codec.width = frame.width
103+
self.codec.height = frame.height
104+
self.codec.bit_rate = self.target_bitrate
105+
self.codec.pix_fmt = "yuv420p"
106+
self.codec.framerate = fractions.Fraction(H264_MAX_FRAME_RATE, 1)
107+
self.codec.time_base = fractions.Fraction(1, H264_MAX_FRAME_RATE)
108+
self.codec.options = {
109+
"level": "31",
110+
"tune": "zerolatency",
111+
"preset": self.preset,
112+
}
113+
self.codec.profile = "Baseline"
114+
yield from super()._encode_frame(frame, force_keyframe)
115+
71116
except Exception:
72117
logger.warning(
73-
"Failed to patch aiortc video encoder subclasses with Stream bitrate values (aiortc internals may have changed), "
74-
"falling back to default aiortc bitrates. \n"
118+
"Failed to patch aiortc encoder subclasses with Stream values (aiortc internals may have changed), "
119+
"falling back to default aiortc encoders. \n"
75120
"Set STREAM_PATCH_AIORTC_BITRATES=0 to disable patching.",
76121
exc_info=True,
77122
)
@@ -80,10 +125,11 @@ def target_bitrate(self, bitrate: int) -> None:
80125

81126

82127
def patch_sender_encoder(sender: RTCRtpSender) -> None:
83-
"""Patch a video sender to use Stream's higher-bitrate encoders.
128+
"""Patch a sender to use Stream's tuned encoders for the negotiated codec.
84129
85-
If anything goes wrong (e.g. aiortc internals changed), the sender
86-
is left untouched and will use the stock encoder via get_encoder().
130+
Works for video (VP8/H264) senders. If anything
131+
goes wrong (e.g. aiortc internals changed), the sender is left untouched
132+
and will use the stock encoder via get_encoder().
87133
"""
88134
if StreamVp8Encoder is None or StreamH264Encoder is None:
89135
return
@@ -105,8 +151,8 @@ async def _next_with_stream_encoder(
105151
sender._next_encoded_frame = _next_with_stream_encoder # type: ignore[method-assign]
106152
except Exception:
107153
logger.warning(
108-
"Failed to patch aiortc video encoder subclasses with Stream bitrate values (aiortc internals may have changed), "
109-
"falling back to default aiortc bitrates. \n"
154+
"Failed to patch aiortc encoder subclasses with Stream values (aiortc internals may have changed), "
155+
"falling back to default aiortc encoders. \n"
110156
"Set STREAM_PATCH_AIORTC_BITRATES=0 to disable patching.",
111157
exc_info=True,
112158
)

getstream/video/rtc/pc.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,6 @@ def __init__(
5353
self._closed = False
5454
self._connected_event = asyncio.Event()
5555

56-
for transceiver in self.getTransceivers():
57-
if transceiver.kind == "video":
58-
transceiver.setCodecPreferences(publish_codec_preferences())
59-
6056
@self.on("icegatheringstatechange")
6157
def on_icegatheringstatechange():
6258
logger.info(
@@ -79,8 +75,13 @@ def on_connectionstatechange():
7975

8076
def addTrack(self, track: MediaStreamTrack) -> RTCRtpSender:
8177
sender = super().addTrack(track)
82-
if track.kind == "video" and not BITRATE_PATCH_DISABLED:
83-
patch_sender_encoder(sender)
78+
if track.kind == "video":
79+
for transceiver in self.getTransceivers():
80+
if transceiver.sender is sender:
81+
transceiver.setCodecPreferences(publish_codec_preferences())
82+
break
83+
if not BITRATE_PATCH_DISABLED:
84+
patch_sender_encoder(sender)
8485
return sender
8586

8687
async def handle_answer(self, response):

tests/rtc/test_encoders_patches.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,39 @@ def test_disabled_via_env(self, monkeypatch, env_val):
9393
assert mod.BITRATE_PATCH_DISABLED is True
9494

9595

96+
class TestStreamH264Preset:
97+
def test_default_is_ultrafast(self, monkeypatch):
98+
"""STREAM_H264_PRESET defaults to 'ultrafast' when env var is unset."""
99+
monkeypatch.delenv("STREAM_PATCH_AIORTC_H264_PRESET", raising=False)
100+
101+
import getstream.video.rtc.encoders_patches as mod
102+
103+
importlib.reload(mod)
104+
assert mod.STREAM_H264_PRESET == "ultrafast"
105+
assert mod.StreamH264Encoder().preset == "ultrafast"
106+
107+
@pytest.mark.parametrize("env_val", ["medium", "veryfast", "superfast"])
108+
def test_overridden_via_env(self, monkeypatch, env_val):
109+
"""STREAM_H264_PRESET reflects the env var and is stored on the instance."""
110+
monkeypatch.setenv("STREAM_PATCH_AIORTC_H264_PRESET", env_val)
111+
112+
import getstream.video.rtc.encoders_patches as mod
113+
114+
importlib.reload(mod)
115+
assert mod.STREAM_H264_PRESET == env_val
116+
assert mod.StreamH264Encoder().preset == env_val
117+
118+
def test_whitespace_is_stripped(self, monkeypatch):
119+
"""Leading/trailing whitespace in the env value is stripped."""
120+
monkeypatch.setenv("STREAM_PATCH_AIORTC_H264_PRESET", " medium ")
121+
122+
import getstream.video.rtc.encoders_patches as mod
123+
124+
importlib.reload(mod)
125+
assert mod.STREAM_H264_PRESET == "medium"
126+
assert mod.StreamH264Encoder().preset == "medium"
127+
128+
96129
class TestPatchSenderEncoder:
97130
@pytest.mark.asyncio
98131
async def test_installs_vp8_encoder(self):

0 commit comments

Comments
 (0)