mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 06:08:29 +00:00
wip
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user