diff --git a/CHANGELOG.md b/CHANGELOG.md index 7354489f..54a35e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - ๐Ÿ”’๏ธ(backend) add validation of Room.configuration - โœจ(helm) add support multiple transcribe worker / endpoint #1247 +- โœจ(backend) make LiveKit Egress recording encoding configurable #1288 ### Fixed diff --git a/docs/features/recording.md b/docs/features/recording.md index c75f1e12..ec566475 100644 --- a/docs/features/recording.md +++ b/docs/features/recording.md @@ -100,6 +100,13 @@ sequenceDiagram | **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. | | **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. | | **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. | +| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). | +| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | +| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | +| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | +| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | +| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | +| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | ### Manual Storage Webhook @@ -141,3 +148,54 @@ Using default project meet This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly. +## Tuning recording encoding + +By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280ร—720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing. + +The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions โ€” what you set is what the encoder receives. + +### How values map to GStreamer + +| Setting | GStreamer element | Property | +| ------------------------------------- | ----------------- | ---------------------------------- | +| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` | +| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` | +| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) | +| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval ร— fps` | +| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps ร— 1000` (bits) | + +The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) โ€” lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file. + +### Reference profiles + +Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate ร— bitrate` but the absolute numbers depend on the host hardware. + +| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for | +| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- | +| Default (preset) | 1280ร—720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour | +| Balanced | 1280ร—720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion | +| **Low CPU / small file** | 1280ร—720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides โ˜… | +| Slide-heavy | 1280ร—720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) | +| Minimum CPU | 960ร—540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required | +| Audio-heavy fallback | 1280ร—720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion | + +โ˜… Recommended starting point for typical LaSuite Meet usage. + +Environment variables for the **Low CPU / small file** profile: + +```bash +RECORDING_ENCODING_ENABLED=True +RECORDING_ENCODING_WIDTH=1280 +RECORDING_ENCODING_HEIGHT=720 +RECORDING_ENCODING_FRAMERATE=15 +RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600 +RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64 +RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0 +``` + +### Caveats + +- **Screen-share readability โ€” think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate รท framerate`). The recommended preset (600 kbps ร— 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides โ€” which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps ร— 15 fps โ‰ˆ 60 kbits/frame). +- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings. +- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings. +- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2ร—โ€“5ร—, defeating the goal of this tuning. diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 12ef54f4..862f226b 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -68,6 +68,18 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/ SUMMARY_SERVICE_API_TOKEN=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 +# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128 +# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0 + # Telephony ROOM_TELEPHONY_ENABLED=True diff --git a/src/backend/core/recording/worker/factories.py b/src/backend/core/recording/worker/factories.py index 33f0957c..c8014785 100644 --- a/src/backend/core/recording/worker/factories.py +++ b/src/backend/core/recording/worker/factories.py @@ -1,5 +1,7 @@ """Factory, configurations and Protocol to create worker services""" +# pylint: disable=no-member + import logging from dataclasses import dataclass from functools import lru_cache @@ -8,8 +10,17 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type from django.conf import settings from django.utils.module_loading import import_string +from livekit import api as livekit_api + logger = logging.getLogger(__name__) +# Codec / frequency constants matching LiveKit's H264_720P_30 preset. +# Kept fixed because changing them would shift the goal-post away from the +# "safe drop-in replacement for the default preset" contract of this feature. +_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN +_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC +_RECORDING_AUDIO_FREQUENCY_HZ = 48000 + @dataclass(frozen=True) class WorkerServiceConfig: @@ -18,6 +29,7 @@ class WorkerServiceConfig: output_folder: str server_configurations: Dict[str, Any] bucket_args: Optional[dict] + encoding_options: Optional[Dict[str, Any]] = None @classmethod @lru_cache @@ -25,6 +37,24 @@ class WorkerServiceConfig: """Load configuration from Django settings with caching for efficiency.""" 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, + } + return cls( output_folder=settings.RECORDING_OUTPUT_FOLDER, server_configurations=settings.LIVEKIT_CONFIGURATION, @@ -36,6 +66,7 @@ class WorkerServiceConfig: "bucket": settings.AWS_STORAGE_BUCKET_NAME, "force_path_style": True, }, + encoding_options=encoding_options, ) diff --git a/src/backend/core/recording/worker/services.py b/src/backend/core/recording/worker/services.py index 166dbb4f..227e3bb0 100644 --- a/src/backend/core/recording/worker/services.py +++ b/src/backend/core/recording/worker/services.py @@ -83,6 +83,22 @@ class BaseEgressService: """ raise NotImplementedError("Subclass must implement this method.") + def _build_encoding_options(self): + """Build a LiveKit EncodingOptions from the service config, or None. + + 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) + class VideoCompositeEgressService(BaseEgressService): """Record multiple participant video and audio tracks into a single output '.mp4' file.""" @@ -104,9 +120,17 @@ class VideoCompositeEgressService(BaseEgressService): s3=self._s3, ) - request = livekit_api.RoomCompositeEgressRequest( - room_name=room_name, file_outputs=[file_output], layout="speaker-light" - ) + request_kwargs = { + "room_name": room_name, + "file_outputs": [file_output], + "layout": "speaker-light", + } + + advanced = self._build_encoding_options() + if advanced is not None: + request_kwargs["advanced"] = advanced + + request = livekit_api.RoomCompositeEgressRequest(**request_kwargs) response = self._handle_request(request, "start_room_composite_egress") diff --git a/src/backend/core/tests/recording/worker/test_factories.py b/src/backend/core/tests/recording/worker/test_factories.py index 12952ffa..c83f6516 100644 --- a/src/backend/core/tests/recording/worker/test_factories.py +++ b/src/backend/core/tests/recording/worker/test_factories.py @@ -2,7 +2,7 @@ Test worker service factories. """ -# pylint: disable=protected-access,redefined-outer-name,unused-argument +# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member from dataclasses import FrozenInstanceError from unittest.mock import Mock @@ -10,6 +10,9 @@ from unittest.mock import Mock from django.test import override_settings import pytest +from livekit import ( + api as livekit_api_codec, +) from core.recording.worker.factories import ( WorkerService, @@ -63,6 +66,8 @@ def test_config_initialization(default_config): "bucket": "test-bucket", "force_path_style": True, } + # Encoding override is opt-in; disabled by default. + assert default_config.encoding_options is None def test_config_immutability(default_config): @@ -71,6 +76,45 @@ def test_config_immutability(default_config): default_config.output_folder = "new/path" +@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_WIDTH=1280, + RECORDING_ENCODING_HEIGHT=720, + RECORDING_ENCODING_FRAMERATE=15, + RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600, + 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. + """ + + WorkerServiceConfig.from_settings.cache_clear() + config = WorkerServiceConfig.from_settings() + + assert config.encoding_options == { + "width": 1280, + "height": 720, + "framerate": 15, + "video_bitrate": 600, + "audio_bitrate": 64, + "key_frame_interval": 10.0, + "video_codec": livekit_api_codec.VideoCodec.H264_MAIN, + "audio_codec": livekit_api_codec.AudioCodec.AAC, + "audio_frequency": 48000, + } + + @override_settings( RECORDING_OUTPUT_FOLDER="/test/output", LIVEKIT_CONFIGURATION={"server": "test.example.com"}, diff --git a/src/backend/core/tests/recording/worker/test_services.py b/src/backend/core/tests/recording/worker/test_services.py index 1591590d..1c0b2c9d 100644 --- a/src/backend/core/tests/recording/worker/test_services.py +++ b/src/backend/core/tests/recording/worker/test_services.py @@ -39,6 +39,31 @@ def config(): ) +@pytest.fixture +def config_with_encoding(config): + """Fixture for a config carrying custom encoding options. + + Mirrors the dict shape produced by `WorkerServiceConfig.from_settings()` + (operator-tunable values + pinned codec / frequency constants). + """ + return WorkerServiceConfig( + output_folder=config.output_folder, + server_configurations=config.server_configurations, + bucket_args=config.bucket_args, + encoding_options={ + "width": 1280, + "height": 720, + "framerate": 15, + "video_bitrate": 600, + "audio_bitrate": 64, + "key_frame_interval": 10.0, + "video_codec": livekit_api.VideoCodec.H264_MAIN, + "audio_codec": livekit_api.AudioCodec.AAC, + "audio_frequency": 48000, + }, + ) + + @pytest.fixture def mock_s3_upload(): """Fixture for mocked S3Upload""" @@ -224,6 +249,41 @@ def test_video_composite_egress_start_missing_egress_id(video_service): assert "Egress ID not found" in str(exc_info.value) +def test_video_composite_egress_start_without_encoding_options(video_service): + """When no encoding options are configured, no `advanced` field is set. + + LiveKit then falls back to its built-in preset (H264_720P_30). + """ + video_service._handle_request.return_value = Mock(egress_id="eg-1") + video_service.start("test-room", "rec-1") + + request = video_service._handle_request.call_args[0][0] + # Proto oneof `options` must be unset when no advanced encoding is provided. + assert request.WhichOneof("options") is None + + +def test_video_composite_egress_start_with_encoding_options(config_with_encoding): + """Custom encoding options are forwarded as `advanced` EncodingOptions.""" + service = VideoCompositeEgressService(config_with_encoding) + service._handle_request = Mock(return_value=Mock(egress_id="eg-2")) + + service.start("test-room", "rec-2") + + request = service._handle_request.call_args[0][0] + assert request.WhichOneof("options") == "advanced" + + advanced = request.advanced + assert advanced.width == 1280 + assert advanced.height == 720 + assert advanced.framerate == 15 + assert advanced.video_bitrate == 600 + assert advanced.audio_bitrate == 64 + assert advanced.key_frame_interval == pytest.approx(10.0) + assert advanced.video_codec == livekit_api.VideoCodec.H264_MAIN + assert advanced.audio_codec == livekit_api.AudioCodec.AAC + assert advanced.audio_frequency == 48000 + + def test_audio_composite_egress_hrid(audio_service): """Test HRID is correct""" assert audio_service.hrid == "audio-recording-composite-livekit-egress" diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 1f5e980b..5a594347 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -700,6 +700,44 @@ class Base(Configuration): RECORDING_MAX_DURATION = values.IntegerValue( None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None ) + + # Recording encoding options for LiveKit Egress (video composite egress only). + # These settings affect screen recordings handled by VideoCompositeEgressService; + # they are silently ignored by AudioCompositeEgressService (audio-only transcript + # recordings), whose request never carries advanced EncodingOptions. + # When disabled, LiveKit falls back to its built-in H264_720P_30 preset + # (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio). + # When enabled, the values below are passed to LiveKit as EncodingOptions + # (advanced) and replace the preset. Lowering framerate and bitrate reduces + # output file size and CPU load on the egress worker. + RECORDING_ENCODING_ENABLED = values.BooleanValue( + False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None + ) + RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue( + 1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None + ) + RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue( + 720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None + ) + RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue( + 30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None + ) + RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue( + 3000, + environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS", + environ_prefix=None, + ) + RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue( + 128, + environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS", + environ_prefix=None, + ) + RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue( + 4.0, + environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S", + environ_prefix=None, + ) + SUMMARY_SERVICE_ENDPOINT = values.Value( None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None )