This commit is contained in:
leo
2026-09-02 11:31:58 +02:00
parent 9cb19e1906
commit 653e67de56
8 changed files with 149 additions and 54 deletions
+2 -2
View File
@@ -259,8 +259,8 @@ class EncodingConfig(BaseModel):
Attributes:
resolution: Target video resolution.
profile: Encoding profile to balance quality and CPU usage. When `None`,
LiveKit default framerate/bitrate are used for the resolution.
profile: Encoding profile to fps and kbps. When `None`,
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
"""
resolution: str
@@ -22,7 +22,7 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
def build_encoding_options(resolution, profile):
def build_encoding_options(resolution, profile=None):
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
Single source of truth shared by the default encoding
@@ -32,11 +32,13 @@ def build_encoding_options(resolution, profile):
The profile-independent fields (audio bitrate, keyframe interval and the
pinned codec / frequency constants) are always included.
The resolution-dependent fields are added only when they can be resolved:
width/height require a resolution; framerate/video_bitrate require both a
resolution and a profile (a resolution-only encoding leaves framerate and
bitrate to LiveKit's defaults).
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
Framerate and bitrate are left to LiveKit only when the operator
declared no default profile at all.
"""
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
options: Dict[str, Any] = {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
+8 -22
View File
@@ -83,28 +83,17 @@ class BaseEgressService:
"""
raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
def _resolve_encoding_options(self, encoding_options):
"""Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
``encoding_options`` is the per-recording dict persisted by the API in
``recording.options["encoding"]["resolved"]``; it falls back to the
default encoding carried by the service config.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
opts = self._config.encoding_options
if not opts:
return None
return livekit_api.EncodingOptions(**opts)
def _resolve_encoding_options(self, encoding_options):
"""Build LiveKit EncodingOptions from a resolved per-recording dict, or None.
``encoding_options`` is the dict persisted by the API in
``recording.options["encoding"]["resolved"]``.
"""
encoding_options = encoding_options or self._config.encoding_options
if not encoding_options:
return None
@@ -137,10 +126,7 @@ class VideoCompositeEgressService(BaseEgressService):
"layout": "speaker-light",
}
advanced = (
self._resolve_encoding_options(encoding_options)
or self._build_encoding_options()
)
advanced = self._resolve_encoding_options(encoding_options)
if advanced is not None:
request_kwargs["advanced"] = advanced
@@ -5,6 +5,7 @@
from unittest.mock import Mock
from django.conf import settings
from django.test import override_settings
import pytest
from livekit import api as livekit_api
@@ -41,12 +42,35 @@ def service():
# --- build_encoding_options ---
def test_build_options_without_profile_omits_profile_fields():
"""A resolution-only config should resolve dimensions but no framerate/bitrate.
def test_build_options_without_profile_uses_default_profile():
"""A resolution-only config should fall back to the default profile.
The profile-independent fields (audio bitrate, keyframe interval, codec /
frequency pins) are always present, matching the default encoding.
Left unset, framerate and video_bitrate take LiveKit's own EncodingOptions
defaults. The profile-independent fields (audio bitrate, keyframe interval,
codec/frequency pins) are always present, matching the default encoding.
"""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("540p")
assert resolved == {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 960,
"height": 540,
"framerate": default_profile["fps"],
"video_bitrate": default_profile["kbps"]["540p"],
}
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_build_options_omits_profile_fields_without_default_profile():
"""With no default profile declared, framerate/bitrate are left to LiveKit."""
resolved = build_encoding_options("720p", None)
assert resolved == {
@@ -110,13 +134,32 @@ def test_resolve_profile_resolution_combinations(service, profile, resolution):
assert result.audio_frequency == 48000
def test_resolve_options_none_profile_uses_livekit_defaults(service):
"""Missing profile should pass 0 fps/bitrate (LiveKit protobuf default).
def test_resolve_options_none_profile_uses_default_profile(service):
"""A missing profile should resolve to the default profile's fps/bitrate."""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
The pinned codec / audio fields are still applied even without a profile.
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == default_profile["fps"]
assert result.video_bitrate == default_profile["kbps"]["720p"]
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_resolve_options_passes_zero_when_no_default_profile(service):
"""With no default profile, fps/bitrate reach LiveKit unset (protobuf 0).
The pinned codec / audio fields are still applied.
"""
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == 0
@@ -561,6 +561,36 @@ def test_start_recording_persists_resolved_encoding(
}
def test_start_recording_resolution_only_uses_default_profile(
settings, mock_worker_service_factory, mock_worker_manager
):
"""An encoding without a profile should resolve the default profile."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
settings.RECORDING_ENCODING_DEFAULT_PROFILE = "talking_heads"
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"encoding": {"resolution": "540p"}}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
resolved = recording.options["encoding"]["resolved"]
assert resolved["width"] == 960
assert resolved["height"] == 540
assert resolved["framerate"] == 15
assert resolved["video_bitrate"] == 400
# The requested payload is persisted as sent: no profile was asked for.
assert "profile" not in recording.options["encoding"]
def test_start_recording_forwards_resolved_encoding_to_worker(
settings, mock_worker_service, mock_worker_service_factory
):
+24 -4
View File
@@ -819,7 +819,7 @@ class Base(Configuration):
environ_prefix=None,
)
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
4.0,
0.0,
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
environ_prefix=None,
)
@@ -1218,10 +1218,11 @@ class Base(Configuration):
@classmethod
def _check_recording_encoding_maps(cls):
"""Ensure the per-recording encoding maps are mutually consistent.
"""Ensure the per-recording encoding maps are well-formed and consistent.
Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must define a bitrate for
each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.
Each entry of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS must declare a width and
a height, each entry of RECORDING_ENCODING_AVAILABLE_PROFILES an fps and a kbps
map, and every profile must define a bitrate for each declared resolution.
The default profile / resolution feed the default encoding. When either is
missing, no custom default encoding can be built: a warning is emitted and
@@ -1231,10 +1232,29 @@ class Base(Configuration):
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
# DictValue resolves to a dict at runtime; pylint sees the descriptor.
for (
resolution,
resolution_config,
) in cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.items(): # pylint: disable=no-member
missing_keys = {"width", "height"} - set(resolution_config)
if missing_keys:
raise ValueError(
f"Resolution '{resolution}' in "
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS is missing the key(s): "
f"{sorted(missing_keys)}."
)
for (
profile,
profile_config,
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
missing_keys = {"fps", "kbps"} - set(profile_config)
if missing_keys:
raise ValueError(
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES is "
f"missing the key(s): {sorted(missing_keys)}."
)
profile_resolutions = set(profile_config["kbps"])
if profile_resolutions != resolutions:
raise ValueError(