diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index db2f5f9f..1ff87e27 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -63,12 +63,12 @@ from core.recording.worker.exceptions import ( RecordingStopError, ) from core.recording.worker.factories import ( + build_encoding_options, get_worker_service, ) from core.recording.worker.mediator import ( WorkerServiceMediator, ) -from core.recording.worker.services import resolve_encoding_config from core.services.invitation import InvitationService from core.services.livekit_events import ( LiveKitEventsService, @@ -402,8 +402,8 @@ class RoomViewSet( if options is not None and options.encoding is not None: # Persist the resolved encoding (concrete width/height/framerate/ # bitrate) alongside the requested resolution/profile for traceability. - options_data["encoding"]["resolved"] = resolve_encoding_config( - options.encoding + options_data["encoding"]["resolved"] = build_encoding_options( + options.encoding.resolution, options.encoding.profile ) try: diff --git a/src/backend/core/recording/worker/factories.py b/src/backend/core/recording/worker/factories.py index 2987a404..04df3ea4 100644 --- a/src/backend/core/recording/worker/factories.py +++ b/src/backend/core/recording/worker/factories.py @@ -22,6 +22,44 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC _RECORDING_AUDIO_FREQUENCY_HZ = 48000 +def build_encoding_options(resolution, profile): + """Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile. + + Single source of truth shared by the default encoding + (``WorkerServiceConfig.from_settings``) and the per-recording encoding + persisted by the start-recording API, so both paths always produce the + same shape. + + 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). + """ + options: Dict[str, Any] = { + "audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS, + "key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S, + "video_codec": _RECORDING_VIDEO_CODEC, + "audio_codec": _RECORDING_AUDIO_CODEC, + "audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ, + } + + if resolution: + resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[ + resolution + ] + options["width"] = resolution_config["width"] + options["height"] = resolution_config["height"] + + if resolution and profile: + profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile] + options["framerate"] = profile_config["fps"] + options["video_bitrate"] = profile_config["kbps"][resolution] + + return options + + @dataclass(frozen=True) class WorkerServiceConfig: """Declare Worker Service common configurations""" @@ -47,21 +85,7 @@ class WorkerServiceConfig: encoding_options: Optional[Dict[str, Any]] = None if resolution and profile: - resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[ - resolution - ] - profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile] - encoding_options = { - "width": resolution_config["width"], - "height": resolution_config["height"], - "framerate": profile_config["fps"], - "video_bitrate": profile_config["kbps"][resolution], - "audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS, - "key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S, - "video_codec": _RECORDING_VIDEO_CODEC, - "audio_codec": _RECORDING_AUDIO_CODEC, - "audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ, - } + encoding_options = build_encoding_options(resolution, profile) return cls( output_folder=settings.RECORDING_OUTPUT_FOLDER, diff --git a/src/backend/core/recording/worker/services.py b/src/backend/core/recording/worker/services.py index b37a50b2..9e739748 100644 --- a/src/backend/core/recording/worker/services.py +++ b/src/backend/core/recording/worker/services.py @@ -2,8 +2,6 @@ # pylint: disable=no-member -from django.conf import settings - from asgiref.sync import async_to_sync from livekit import api as livekit_api @@ -13,43 +11,6 @@ from .exceptions import WorkerConnectionError, WorkerResponseError from .factories import WorkerServiceConfig -def resolve_encoding_config(encoding_config): - """Resolve a per-recording EncodingConfig to concrete encoding fields. - - Returns a JSON-serializable dict of the LiveKit ``EncodingOptions`` kwargs - derived from the request's resolution / profile, or None when no - encoding_config is provided. This allows to derive width, height, fps, and - bitrate from (resolution, profile). - - Only the fields that can actually be resolved are included: width/height - require a resolution, framerate/video_bitrate require both a resolution and a - profile. - """ - if encoding_config is None: - return None - - resolution = encoding_config.resolution - profile = encoding_config.profile - - resolved = { - "key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S, - } - - if resolution: - resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[ - resolution - ] - resolved["width"] = resolution_config["width"] - resolved["height"] = resolution_config["height"] - - if resolution and profile: - profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile] - resolved["framerate"] = profile_config["fps"] - resolved["video_bitrate"] = profile_config["kbps"][resolution] - - return resolved - - class BaseEgressService: """Base egress defining common methods to manage and interact with LiveKit egress processes.""" diff --git a/src/backend/core/tests/recording/worker/test_encoding_resolver.py b/src/backend/core/tests/recording/worker/test_encoding_resolver.py index e38704ab..b3dd5c9e 100644 --- a/src/backend/core/tests/recording/worker/test_encoding_resolver.py +++ b/src/backend/core/tests/recording/worker/test_encoding_resolver.py @@ -7,13 +7,12 @@ from unittest.mock import Mock from django.conf import settings import pytest +from livekit import api as livekit_api from pydantic import ValidationError as PydanticValidationError from core.api.serializers import EncodingConfig -from core.recording.worker.services import ( - VideoCompositeEgressService, - resolve_encoding_config, -) +from core.recording.worker.factories import build_encoding_options +from core.recording.worker.services import VideoCompositeEgressService def make_config(): @@ -39,23 +38,28 @@ def service(): return svc -# --- resolve_encoding_config --- +# --- build_encoding_options --- -def test_resolve_config_returns_none_without_config(): - """Resolver should return None when no encoding config is provided.""" - assert resolve_encoding_config(None) is None +def test_build_options_without_profile_omits_profile_fields(): + """A resolution-only config should resolve dimensions but no framerate/bitrate. - -def test_resolve_config_without_profile_omits_profile_fields(): - """A resolution-only config should resolve dimensions but no framerate/bitrate.""" - resolved = resolve_encoding_config(EncodingConfig(resolution="720p")) + The profile-independent fields (audio bitrate, keyframe interval, codec / + frequency pins) are always present, matching the default encoding. + """ + resolved = build_encoding_options("720p", None) 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": 1280, "height": 720, } + assert "framerate" not in resolved + assert "video_bitrate" not in resolved def test_encoding_config_requires_resolution(): @@ -92,22 +96,31 @@ def test_resolve_profile_resolution_combinations(service, profile, resolution): expected_fps = profile_config["fps"] expected_bitrate = profile_config["kbps"][resolution] - resolved = resolve_encoding_config( - EncodingConfig(resolution=resolution, profile=profile) - ) + resolved = build_encoding_options(resolution, profile) result = service._resolve_encoding_options(resolved) assert result.width == expected_width assert result.height == expected_height assert result.framerate == expected_fps assert result.video_bitrate == expected_bitrate + # Profile-independent fields match the default encoding, never dropped. + assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS + assert result.video_codec == livekit_api.VideoCodec.H264_MAIN + assert result.audio_codec == livekit_api.AudioCodec.AAC + 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).""" - resolved = resolve_encoding_config(EncodingConfig(resolution="720p")) + """Missing profile should pass 0 fps/bitrate (LiveKit protobuf default). + + 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 == 0 assert result.video_bitrate == 0 + assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS + assert result.video_codec == livekit_api.VideoCodec.H264_MAIN + assert result.audio_codec == livekit_api.AudioCodec.AAC diff --git a/src/backend/core/tests/rooms/test_api_rooms_start_recording.py b/src/backend/core/tests/rooms/test_api_rooms_start_recording.py index ee89aa4d..7f272420 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_start_recording.py +++ b/src/backend/core/tests/rooms/test_api_rooms_start_recording.py @@ -7,6 +7,7 @@ Test rooms API endpoints in the Meet core app: start recording. from unittest import mock import pytest +from livekit import api as livekit_api from rest_framework.test import APIClient from ...factories import RoomFactory, UserFactory @@ -547,7 +548,11 @@ def test_start_recording_persists_resolved_encoding( "resolution": "720p", "profile": "talking_heads", "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": 1280, "height": 720, "framerate": 15,