Skip to content

Commit 87b04ed

Browse files
committed
✨(backend) make LiveKit Egress recording encoding configurable
Expose RECORDING_ENCODING_* settings to override the default LiveKit Egress preset (H264_720P_30). When RECORDING_ENCODING_ENABLED is True, the provided width/height/framerate/bitrate/keyframe values are passed as advanced EncodingOptions. Lowering framerate and bitrate reduces recording file size and egress worker CPU load. Disabled by default, preserving current behaviour.
1 parent 597eba6 commit 87b04ed

8 files changed

Lines changed: 269 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
14+
1115
## [1.15.0] - 2026-04-30
1216

1317
### Added

docs/features/recording.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ sequenceDiagram
100100
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
101101
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
102102
| **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. |
103+
| **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). |
104+
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
105+
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
106+
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
107+
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
108+
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
109+
| **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`. |
103110

104111

105112
### Manual Storage Webhook
@@ -141,3 +148,54 @@ Using default project meet
141148

142149
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
143150

151+
## Tuning recording encoding
152+
153+
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.
154+
155+
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.
156+
157+
### How values map to GStreamer
158+
159+
| Setting | GStreamer element | Property |
160+
| ------------------------------------- | ----------------- | ---------------------------------- |
161+
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
162+
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
163+
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
164+
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
165+
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
166+
167+
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.
168+
169+
### Reference profiles
170+
171+
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.
172+
173+
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
174+
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
175+
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
176+
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
177+
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
178+
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
179+
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
180+
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
181+
182+
★ Recommended starting point for typical LaSuite Meet usage.
183+
184+
Environment variables for the **Low CPU / small file** profile:
185+
186+
```bash
187+
RECORDING_ENCODING_ENABLED=True
188+
RECORDING_ENCODING_WIDTH=1280
189+
RECORDING_ENCODING_HEIGHT=720
190+
RECORDING_ENCODING_FRAMERATE=15
191+
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
192+
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
193+
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
194+
```
195+
196+
### Caveats
197+
198+
- **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).
199+
- **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.
200+
- **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.
201+
- **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.

env.d/development/common.dist

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
6868
SUMMARY_SERVICE_API_TOKEN=password
6969
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
7070

71+
# Recording encoding (LiveKit Egress advanced options).
72+
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
73+
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
74+
# file size and CPU load on the egress worker.
75+
# RECORDING_ENCODING_ENABLED=False
76+
# RECORDING_ENCODING_WIDTH=1280
77+
# RECORDING_ENCODING_HEIGHT=720
78+
# RECORDING_ENCODING_FRAMERATE=30
79+
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
80+
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
81+
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
82+
7183
# Telephony
7284
ROOM_TELEPHONY_ENABLED=True
7385

src/backend/core/recording/worker/factories.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,17 @@
88
from django.conf import settings
99
from django.utils.module_loading import import_string
1010

11+
from livekit import api as livekit_api
12+
1113
logger = logging.getLogger(__name__)
1214

15+
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
16+
# Kept fixed because changing them would shift the goal-post away from the
17+
# "safe drop-in replacement for the default preset" contract of this feature.
18+
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
19+
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
20+
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
21+
1322

1423
@dataclass(frozen=True)
1524
class WorkerServiceConfig:
@@ -18,13 +27,32 @@ class WorkerServiceConfig:
1827
output_folder: str
1928
server_configurations: Dict[str, Any]
2029
bucket_args: Optional[dict]
30+
encoding_options: Optional[Dict[str, Any]] = None
2131

2232
@classmethod
2333
@lru_cache
2434
def from_settings(cls) -> "WorkerServiceConfig":
2535
"""Load configuration from Django settings with caching for efficiency."""
2636

2737
logger.debug("Loading WorkerServiceConfig from settings.")
38+
39+
encoding_options: Optional[Dict[str, Any]] = None
40+
if settings.RECORDING_ENCODING_ENABLED:
41+
# Single source of truth for the EncodingOptions kwargs:
42+
# operator-tunable values live in Django settings, codec / frequency
43+
# are pinned constants. The services layer only unpacks this dict.
44+
encoding_options = {
45+
"width": settings.RECORDING_ENCODING_WIDTH,
46+
"height": settings.RECORDING_ENCODING_HEIGHT,
47+
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
48+
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
49+
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
50+
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
51+
"video_codec": _RECORDING_VIDEO_CODEC,
52+
"audio_codec": _RECORDING_AUDIO_CODEC,
53+
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
54+
}
55+
2856
return cls(
2957
output_folder=settings.RECORDING_OUTPUT_FOLDER,
3058
server_configurations=settings.LIVEKIT_CONFIGURATION,
@@ -36,6 +64,7 @@ def from_settings(cls) -> "WorkerServiceConfig":
3664
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
3765
"force_path_style": True,
3866
},
67+
encoding_options=encoding_options,
3968
)
4069

4170

src/backend/core/recording/worker/services.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ def start(self, room_name, recording_id):
8383
"""
8484
raise NotImplementedError("Subclass must implement this method.")
8585

86+
def _build_encoding_options(self):
87+
"""Build a LiveKit EncodingOptions from the service config, or None.
88+
89+
When None is returned, the caller should omit the `advanced` field so
90+
LiveKit Egress falls back to its built-in preset (H264_720P_30).
91+
92+
The full EncodingOptions kwargs (operator-tunable values + pinned
93+
codec / frequency constants) are assembled in `WorkerServiceConfig`,
94+
so this method is a thin protobuf adapter.
95+
"""
96+
opts = self._config.encoding_options
97+
if not opts:
98+
return None
99+
100+
return livekit_api.EncodingOptions(**opts)
101+
86102

87103
class VideoCompositeEgressService(BaseEgressService):
88104
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
@@ -104,9 +120,17 @@ def start(self, room_name, recording_id):
104120
s3=self._s3,
105121
)
106122

107-
request = livekit_api.RoomCompositeEgressRequest(
108-
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
109-
)
123+
request_kwargs = {
124+
"room_name": room_name,
125+
"file_outputs": [file_output],
126+
"layout": "speaker-light",
127+
}
128+
129+
advanced = self._build_encoding_options()
130+
if advanced is not None:
131+
request_kwargs["advanced"] = advanced
132+
133+
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
110134

111135
response = self._handle_request(request, "start_room_composite_egress")
112136

src/backend/core/tests/recording/worker/test_factories.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def test_config_initialization(default_config):
6363
"bucket": "test-bucket",
6464
"force_path_style": True,
6565
}
66+
# Encoding override is opt-in; disabled by default.
67+
assert default_config.encoding_options is None
6668

6769

6870
def test_config_immutability(default_config):
@@ -71,6 +73,46 @@ def test_config_immutability(default_config):
7173
default_config.output_folder = "new/path"
7274

7375

76+
@override_settings(
77+
RECORDING_OUTPUT_FOLDER="/test/output",
78+
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
79+
AWS_S3_ENDPOINT_URL="https://s3.test.com",
80+
AWS_S3_ACCESS_KEY_ID="test_key",
81+
AWS_S3_SECRET_ACCESS_KEY="test_secret",
82+
AWS_S3_REGION_NAME="test-region",
83+
AWS_STORAGE_BUCKET_NAME="test-bucket",
84+
RECORDING_ENCODING_ENABLED=True,
85+
RECORDING_ENCODING_WIDTH=1280,
86+
RECORDING_ENCODING_HEIGHT=720,
87+
RECORDING_ENCODING_FRAMERATE=15,
88+
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
89+
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
90+
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
91+
)
92+
def test_config_encoding_options_enabled():
93+
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
94+
95+
The dict mixes operator-tunable values from settings with pinned codec /
96+
frequency constants, so the services layer can simply unpack it.
97+
"""
98+
from livekit import api as livekit_api # local import: avoid Django import order issues
99+
100+
WorkerServiceConfig.from_settings.cache_clear()
101+
config = WorkerServiceConfig.from_settings()
102+
103+
assert config.encoding_options == {
104+
"width": 1280,
105+
"height": 720,
106+
"framerate": 15,
107+
"video_bitrate": 600,
108+
"audio_bitrate": 64,
109+
"key_frame_interval": 10.0,
110+
"video_codec": livekit_api.VideoCodec.H264_MAIN,
111+
"audio_codec": livekit_api.AudioCodec.AAC,
112+
"audio_frequency": 48000,
113+
}
114+
115+
74116
@override_settings(
75117
RECORDING_OUTPUT_FOLDER="/test/output",
76118
LIVEKIT_CONFIGURATION={"server": "test.example.com"},

0 commit comments

Comments
 (0)