diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 63d3ce16..f0aed6cd 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -69,17 +69,31 @@ SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording # Recording encoding (LiveKit Egress advanced options). -# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in -# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce -# file size and CPU load on the egress worker. -# RECORDING_ENCODING_ENABLED=False -# RECORDING_ENCODING_WIDTH=1280 -# RECORDING_ENCODING_HEIGHT=720 -# RECORDING_ENCODING_FRAMERATE=30 -# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000 +# Encoding is described by a named resolution (width/height) and a named profile +# (framerate + video bitrate per resolution) instead of raw encoder values. The +# start-recording API accepts a pair per recording, e.g. +# options.encoding={"resolution": "720p", "profile": "talking_heads"}; only keys +# declared in the two maps below are accepted, "profile" is optional. +# Both maps are read as a one-line Python dict literal (ast.literal_eval): double +# quoted keys, no outer quotes, no trailing comma. Every profile must define a +# kbps entry for exactly the resolutions of the resolutions map, or startup fails. +# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}} +# RECORDING_ENCODING_AVAILABLE_PROFILES={"talking_heads": {"fps": 15, "kbps": {"540p": 400, "720p": 700, "1080p": 1200}}, "text": {"fps": 15, "kbps": {"540p": 600, "720p": 1000, "1080p": 1800}}, "mixed": {"fps": 20, "kbps": {"540p": 900, "720p": 1500, "1080p": 2500}}, "full": {"fps": 30, "kbps": {"540p": 2000, "720p": 3000, "1080p": 4500}}} + +# Defaults for recordings that don't carry an encoding. Must be keys of the maps +# above. Declared but not yet read by the recording code at this commit. +# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p +# RECORDING_ENCODING_DEFAULT_PROFILE=full + +# Applied to every resolved encoding, independent of resolution and profile. # RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128 # RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0 +# Server-wide encoding used when a recording carries no encoding of its own. +# Keep False: when True, the worker factory still reads RECORDING_ENCODING_WIDTH, +# _HEIGHT, _FRAMERATE and _VIDEO_BITRATE_KBPS, which no longer exist. +# RECORDING_ENCODING_ENABLED=False + # Telephony ROOM_TELEPHONY_ENABLED=True diff --git a/src/backend/core/recording/worker/factories.py b/src/backend/core/recording/worker/factories.py index dfe97b5f..2923eb6c 100644 --- a/src/backend/core/recording/worker/factories.py +++ b/src/backend/core/recording/worker/factories.py @@ -22,6 +22,37 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC _RECORDING_AUDIO_FREQUENCY_HZ = 48000 +def _build_default_encoding_options() -> Optional[Dict[str, Any]]: + """Build the server-wide EncodingOptions kwargs, or None to keep LiveKit's preset. + + Operator-tunable values live in Django settings; the default resolution gives + width / height, the default profile gives framerate and video bitrate, while + codec and frequency are pinned constants. Either default left empty means we + use the livekit defaults. + """ + + resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION + profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE + + if not resolution or not profile: + return None + + dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution] + profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile] + + return { + "width": dimensions["width"], + "height": dimensions["height"], + "framerate": profile_spec["fps"], + "video_bitrate": profile_spec["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, + } + + @dataclass(frozen=True) class WorkerServiceConfig: """Declare Worker Service common configurations""" @@ -38,22 +69,14 @@ class WorkerServiceConfig: logger.debug("Loading WorkerServiceConfig from settings.") - encoding_options: Optional[Dict[str, Any]] = None - if settings.RECORDING_ENCODING_ENABLED: - # Single source of truth for the EncodingOptions kwargs: - # operator-tunable values live in Django settings, codec / frequency - # are pinned constants. The services layer only unpacks this dict. - encoding_options = { - "width": settings.RECORDING_ENCODING_WIDTH, - "height": settings.RECORDING_ENCODING_HEIGHT, - "framerate": settings.RECORDING_ENCODING_FRAMERATE, - "video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS, - "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, - } + # Single source of truth for the EncodingOptions kwargs; the services + # layer only unpacks this dict. Recordings carrying their own encoding + # resolve it per request and bypass this default. + encoding_options: Optional[Dict[str, Any]] = ( + _build_default_encoding_options() + if settings.RECORDING_ENCODING_ENABLED + else None + ) 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 9284a808..251770b3 100644 --- a/src/backend/core/recording/worker/services.py +++ b/src/backend/core/recording/worker/services.py @@ -36,16 +36,14 @@ def resolve_encoding_config(encoding_config): } if resolution: - width, height = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution] - resolved["width"] = width - resolved["height"] = height + dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution] + resolved["width"] = dimensions["width"] + resolved["height"] = dimensions["height"] if resolution and profile: - fps, kbps_by_resolution = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[ - profile - ] - resolved["framerate"] = fps - resolved["video_bitrate"] = kbps_by_resolution[resolution] + profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile] + resolved["framerate"] = profile_spec["fps"] + resolved["video_bitrate"] = profile_spec["kbps"][resolution] return resolved 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 333b89ee..e781667a 100644 --- a/src/backend/core/tests/recording/worker/test_encoding_resolver.py +++ b/src/backend/core/tests/recording/worker/test_encoding_resolver.py @@ -85,23 +85,18 @@ def test_resolve_options_returns_none_when_empty(service, encoding_options): ) def test_resolve_profile_resolution_combinations(service, profile, resolution): """Every (profile, resolution) pair should resolve to the values from settings.""" - expected_width, expected_height = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[ - resolution - ] - expected_fps, kbps_by_resolution = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[ - profile - ] - expected_bitrate = kbps_by_resolution[resolution] + dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution] + profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile] resolved = resolve_encoding_config( EncodingConfig(resolution=resolution, profile=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 + assert result.width == dimensions["width"] + assert result.height == dimensions["height"] + assert result.framerate == profile_spec["fps"] + assert result.video_bitrate == profile_spec["kbps"][resolution] def test_resolve_options_none_profile_uses_livekit_defaults(service): diff --git a/src/backend/core/tests/recording/worker/test_factories.py b/src/backend/core/tests/recording/worker/test_factories.py index c83f6516..59cf1720 100644 --- a/src/backend/core/tests/recording/worker/test_factories.py +++ b/src/backend/core/tests/recording/worker/test_factories.py @@ -85,18 +85,22 @@ def test_config_immutability(default_config): AWS_S3_REGION_NAME="test-region", AWS_STORAGE_BUCKET_NAME="test-bucket", RECORDING_ENCODING_ENABLED=True, - RECORDING_ENCODING_WIDTH=1280, - RECORDING_ENCODING_HEIGHT=720, - RECORDING_ENCODING_FRAMERATE=15, - RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600, + RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={ + "720p": {"width": 1280, "height": 720}, + }, + RECORDING_ENCODING_AVAILABLE_PROFILES={ + "talking_heads": {"fps": 15, "kbps": {"720p": 600}}, + }, + RECORDING_ENCODING_DEFAULT_RESOLUTION="720p", + RECORDING_ENCODING_DEFAULT_PROFILE="talking_heads", RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64, RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0, ) def test_config_encoding_options_enabled(): """When RECORDING_ENCODING_ENABLED is True, encoding options are populated. - The dict mixes operator-tunable values from settings with pinned codec / - frequency constants, so the services layer can simply unpack it. + The dict mixes values resolved from the default resolution / profile with + pinned codec / frequency constants, so the services layer can simply unpack it. """ WorkerServiceConfig.from_settings.cache_clear() @@ -115,6 +119,27 @@ def test_config_encoding_options_enabled(): } +@override_settings( + RECORDING_OUTPUT_FOLDER="/test/output", + LIVEKIT_CONFIGURATION={"server": "test.example.com"}, + AWS_S3_ENDPOINT_URL="https://s3.test.com", + AWS_S3_ACCESS_KEY_ID="test_key", + AWS_S3_SECRET_ACCESS_KEY="test_secret", + AWS_S3_REGION_NAME="test-region", + AWS_STORAGE_BUCKET_NAME="test-bucket", + RECORDING_ENCODING_ENABLED=True, + RECORDING_ENCODING_DEFAULT_RESOLUTION="", + RECORDING_ENCODING_DEFAULT_PROFILE="", +) +def test_config_encoding_options_without_defaults(): + """An empty default resolution or profile leaves the encoding to LiveKit.""" + + WorkerServiceConfig.from_settings.cache_clear() + config = WorkerServiceConfig.from_settings() + + assert config.encoding_options is None + + @override_settings( RECORDING_OUTPUT_FOLDER="/test/output", LIVEKIT_CONFIGURATION={"server": "test.example.com"}, diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index fc71f888..506044b4 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -755,7 +755,7 @@ class Base(Configuration): False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None ) - # Map resolution string -> (width, height) in pixels. + # Map resolution name -> {"width": ..., "height": ...} in pixels. RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue( { "540p": {"width": 960, "height": 540}, @@ -1211,16 +1211,21 @@ class Base(Configuration): def _check_recording_encoding_maps(cls): """Ensure the per-recording encoding maps are mutually consistent. - Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must define a bitrate for - each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS. + Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must declare an fps and + a bitrate for each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, + and each non-empty default must name an entry of its map. """ + # DictValue resolves to a dict at runtime; pylint sees the descriptor. + # pylint: disable=no-member resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS) - for profile, ( - _fps, - kbps_by_resolution, - # DictValue resolves to a dict at runtime; pylint sees the descriptor. - ) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member - profile_resolutions = set(kbps_by_resolution) + for profile, spec in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): + if "fps" not in spec or "kbps" not in spec: + raise ValueError( + f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must " + "define both 'fps' and 'kbps'." + ) + + profile_resolutions = set(spec["kbps"]) if profile_resolutions != resolutions: raise ValueError( f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must " @@ -1229,6 +1234,22 @@ class Base(Configuration): f"{resolutions ^ profile_resolutions}" ) + default_resolution = cls.RECORDING_ENCODING_DEFAULT_RESOLUTION + if default_resolution and default_resolution not in resolutions: + raise ValueError( + f"RECORDING_ENCODING_DEFAULT_RESOLUTION '{default_resolution}' is not a " + "key of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, choose from " + f"{sorted(resolutions)}." + ) + + profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES) + default_profile = cls.RECORDING_ENCODING_DEFAULT_PROFILE + if default_profile and default_profile not in profiles: + raise ValueError( + f"RECORDING_ENCODING_DEFAULT_PROFILE '{default_profile}' is not a key of " + f"RECORDING_ENCODING_AVAILABLE_PROFILES, choose from {sorted(profiles)}." + ) + @classmethod def post_setup(cls): """Post setup configuration.