Compare commits

..

3 Commits

Author SHA1 Message Date
leo f1e0bd7ac5 wip temp 2026-09-01 19:07:34 +02:00
leo 35d2bae0f0 (backend) add per-recording encoding config to start-recording API
Add new options to query start-start recording API. A resolution
("540p", "720p", "1080p") and a profile ("talking_heads", "text", "mixed")
are resolved to provide a width, height, fps and bitrate which
are passed on to the encoder. Using profiles allows for some flexibility
on quality if necessary without changing front facing user config.

Co-authored-by: sarthakbahal <sarthakbahal.45@gmail.com>
2026-09-01 17:35:42 +02:00
lebaudantoine 839cfa4b80 📝(docs) document v1.30.0 in UPGRADE.md
Add the missing v1.30.0 entry in `UPGRADE.md`, which was overlooked
when the release was published.
2026-09-01 16:22:57 +02:00
13 changed files with 709 additions and 56 deletions
+4
View File
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- ✨(backend) add per-recording encoding quality presets to start-recording API
## [1.30.0] - 2026-09-01 ## [1.30.0] - 2026-09-01
### Changed ### Changed
+88
View File
@@ -16,6 +16,94 @@ the following command inside your docker container:
## [Unreleased] ## [Unreleased]
### Recording encoding settings replaced by a resolution/profile model
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
fps and video bitrate.
**The following environment variables are no longer read. If they are still set in
your deployment they are silently ignored, and your recordings will be encoded with
the new defaults instead of your tuned values.**
| Removed variable | Replaced by |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
are unchanged and keep their values.
#### If you never set `RECORDING_ENCODING_ENABLED=True`
No action is required. The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
Note that these values are now sent explicitly as advanced `EncodingOptions`
rather than relying on LiveKit's preset, so `RECORDING_ENCODING_AUDIO_BITRATE_KBPS`
and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` now apply to every recording. They
previously applied only when `RECORDING_ENCODING_ENABLED` was `True`.
To keep letting LiveKit pick the encoding instead, set either default to an empty
value:
```
RECORDING_ENCODING_DEFAULT_RESOLUTION=
RECORDING_ENCODING_DEFAULT_PROFILE=
```
#### If you had tuned `RECORDING_ENCODING_*` values
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
environment as a single-line Python/JSON dict literal (parsed with
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
add outer quotes in `.env`-style files):
```bash
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
```
Two constraints are validated at startup and may raise a `ValueError`:
- every profile in `RECORDING_ENCODING_AVAILABLE_PROFILES` must define a `kbps`
entry for **exactly** the keys of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`;
overriding one of the two maps usually means overriding both;
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
when non-empty, must be keys of their respective map.
#### Optional: per-recording encoding
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
start-recording API accepts an `encoding` object
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional) that
overrides the default for a single recording. It does not enable or disable the
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
settings either way. Leaving it at `False` preserves the previous behaviour, where
every recording uses the server-side encoding: requests carrying
`options.encoding` are rejected with a `400` before the recording is created, so
nothing is persisted and no egress is started.
Before enabling it:
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
- as of this implementation, the frontend never sends `encoding`
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
egress has no video encoding to configure.
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
for the full setting reference, the shipped profile table and the tuning caveats.
## v1.30.0
### Removing S3 storage-event webhooks for recordings ### Removing S3 storage-event webhooks for recordings
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0. Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
+22 -8
View File
@@ -69,17 +69,31 @@ SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options). # Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in # Encoding is described by a named resolution (width/height) and a named profile
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce # (framerate + video bitrate per resolution) instead of raw encoder values. The
# file size and CPU load on the egress worker. # start-recording API accepts a pair per recording, e.g.
# RECORDING_ENCODING_ENABLED=False # options.encoding={"resolution": "720p", "profile": "talking_heads"}; only keys
# RECORDING_ENCODING_WIDTH=1280 # declared in the two maps below are accepted, "profile" is optional.
# RECORDING_ENCODING_HEIGHT=720 # Both maps are read as a one-line Python dict literal (ast.literal_eval): double
# RECORDING_ENCODING_FRAMERATE=30 # quoted keys, no outer quotes, no trailing comma. Every profile must define a
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000 # 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_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0 # 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 # Telephony
ROOM_TELEPHONY_ENABLED=True ROOM_TELEPHONY_ENABLED=True
+50 -2
View File
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer from pydantic import (
BaseModel,
Field,
field_serializer,
field_validator,
)
from pydantic import ValidationError as PydanticValidationError from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -244,6 +249,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only") raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class EncodingConfig(BaseModel):
"""Configuration options for recording encoding.
The allowed `resolution` and `profile` values are derived at validation time
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
to those maps is enough to make it accepted here.
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.
"""
resolution: str
profile: str | None = None
model_config = {"extra": "forbid"}
@field_validator("resolution")
@classmethod
def _validate_resolution(cls, value):
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
if value not in allowed:
raise ValueError(
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
)
return value
@field_validator("profile")
@classmethod
def _validate_profile(cls, value):
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
if value is None:
return None
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
if value not in allowed:
raise ValueError(
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
)
return value
class RecordingOptions(BaseModel): class RecordingOptions(BaseModel):
"""Configuration options for recording. """Configuration options for recording.
@@ -264,7 +312,7 @@ class RecordingOptions(BaseModel):
transcribe: bool | None = None transcribe: bool | None = None
collect_metadata: bool | None = None collect_metadata: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None original_mode: Literal["screen_recording", "transcript"] | None = None
encoding: EncodingConfig | None = None
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
+10 -1
View File
@@ -60,6 +60,7 @@ from core.recording.worker.factories import (
from core.recording.worker.mediator import ( from core.recording.worker.mediator import (
WorkerServiceMediator, WorkerServiceMediator,
) )
from core.recording.worker.services import resolve_encoding_config
from core.services.invitation import InvitationService from core.services.invitation import InvitationService
from core.services.livekit_events import ( from core.services.livekit_events import (
LiveKitEventsService, LiveKitEventsService,
@@ -400,12 +401,20 @@ class RoomViewSet(
options = serializer.validated_data.get("options") options = serializer.validated_data.get("options")
room = self.get_object() room = self.get_object()
options_data = options.model_dump(exclude_none=True) if options else {}
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
)
try: try:
with transaction.atomic(): with transaction.atomic():
recording = models.Recording.objects.create( recording = models.Recording.objects.create(
room=room, room=room,
mode=mode, mode=mode,
options=options.model_dump(exclude_none=True) if options else {}, options=options_data,
) )
models.RecordingAccess.objects.create( models.RecordingAccess.objects.create(
user=self.request.user, user=self.request.user,
+45 -17
View File
@@ -22,6 +22,37 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000 _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) @dataclass(frozen=True)
class WorkerServiceConfig: class WorkerServiceConfig:
"""Declare Worker Service common configurations""" """Declare Worker Service common configurations"""
@@ -38,22 +69,14 @@ class WorkerServiceConfig:
logger.debug("Loading WorkerServiceConfig from settings.") logger.debug("Loading WorkerServiceConfig from settings.")
encoding_options: Optional[Dict[str, Any]] = None # Single source of truth for the EncodingOptions kwargs; the services
if settings.RECORDING_ENCODING_ENABLED: # layer only unpacks this dict. Recordings carrying their own encoding
# Single source of truth for the EncodingOptions kwargs: # resolve it per request and bypass this default.
# operator-tunable values live in Django settings, codec / frequency encoding_options: Optional[Dict[str, Any]] = (
# are pinned constants. The services layer only unpacks this dict. _build_default_encoding_options()
encoding_options = { if settings.RECORDING_ENCODING_ENABLED
"width": settings.RECORDING_ENCODING_WIDTH, else None
"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( return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER, output_folder=settings.RECORDING_OUTPUT_FOLDER,
@@ -78,7 +101,12 @@ class WorkerService(Protocol):
def __init__(self, config: WorkerServiceConfig): def __init__(self, config: WorkerServiceConfig):
"""Initialize the service with the given configuration.""" """Initialize the service with the given configuration."""
def start(self, room_id: str, recording_id: str) -> str: def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str:
"""Start a recording for a specified room.""" """Start a recording for a specified room."""
def stop(self, worker_id: str) -> str: def stop(self, worker_id: str) -> str:
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
raise RecordingStartError() raise RecordingStartError()
room_name = str(recording.room.id) room_name = str(recording.room.id)
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
try: try:
worker_id = self._worker_service.start(room_name, recording.id) worker_id = self._worker_service.start(
room_name, recording.id, encoding_options=encoding_options
)
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e: except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
logger.exception( logger.exception(
"Failed to start recording for room %s: %s", recording.room.slug, e "Failed to start recording for room %s: %s", recording.room.slug, e
+61 -5
View File
@@ -2,6 +2,8 @@
# pylint: disable=no-member # pylint: disable=no-member
from django.conf import settings
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from livekit import api as livekit_api from livekit import api as livekit_api
@@ -11,6 +13,41 @@ from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig 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:
dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
resolved["width"] = dimensions["width"]
resolved["height"] = dimensions["height"]
if resolution and profile:
profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
resolved["framerate"] = profile_spec["fps"]
resolved["video_bitrate"] = profile_spec["kbps"][resolution]
return resolved
class BaseEgressService: class BaseEgressService:
"""Base egress defining common methods to manage and interact with LiveKit egress processes.""" """Base egress defining common methods to manage and interact with LiveKit egress processes."""
@@ -76,7 +113,7 @@ class BaseEgressService:
return "FAILED_TO_STOP" return "FAILED_TO_STOP"
def start(self, room_name, recording_id): def start(self, room_name, recording_id, encoding_options=None):
"""Start the egress process for a recording (not implemented in the base class). """Start the egress process for a recording (not implemented in the base class).
Each derived class must implement this method, providing the necessary parameters for Each derived class must implement this method, providing the necessary parameters for
its specific egress type (e.g. audio_only, streaming output). its specific egress type (e.g. audio_only, streaming output).
@@ -99,13 +136,24 @@ class BaseEgressService:
return livekit_api.EncodingOptions(**opts) 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"]``.
"""
if not encoding_options:
return None
return livekit_api.EncodingOptions(**encoding_options)
class VideoCompositeEgressService(BaseEgressService): class VideoCompositeEgressService(BaseEgressService):
"""Record multiple participant video and audio tracks into a single output '.mp4' file.""" """Record multiple participant video and audio tracks into a single output '.mp4' file."""
hrid = "video-recording-composite-livekit-egress" hrid = "video-recording-composite-livekit-egress"
def start(self, room_name, recording_id): def start(self, room_name, recording_id, encoding_options=None):
"""Start the video composite egress process for a recording.""" """Start the video composite egress process for a recording."""
# Save room's recording as a mp4 video file. # Save room's recording as a mp4 video file.
@@ -126,7 +174,10 @@ class VideoCompositeEgressService(BaseEgressService):
"layout": "speaker-light", "layout": "speaker-light",
} }
advanced = self._build_encoding_options() advanced = (
self._resolve_encoding_options(encoding_options)
or self._build_encoding_options()
)
if advanced is not None: if advanced is not None:
request_kwargs["advanced"] = advanced request_kwargs["advanced"] = advanced
@@ -145,8 +196,13 @@ class AudioCompositeEgressService(BaseEgressService):
hrid = "audio-recording-composite-livekit-egress" hrid = "audio-recording-composite-livekit-egress"
def start(self, room_name, recording_id): def start(self, room_name, recording_id, encoding_options=None):
"""Start the audio composite egress process for a recording.""" """Start the audio composite egress process for a recording.
``encoding_options`` is accepted for signature compatibility with the
WorkerService protocol but ignored: audio-only egress has no
encoding to configure.
"""
# Save room's recording as an ogg audio file. # Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG file_type = livekit_api.EncodedFileType.OGG
@@ -0,0 +1,109 @@
"""Tests for the per-recording encoding resolution in BaseEgressService."""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from unittest.mock import Mock
from django.conf import settings
import pytest
from pydantic import ValidationError as PydanticValidationError
from core.api.serializers import EncodingConfig
from core.recording.worker.services import (
VideoCompositeEgressService,
resolve_encoding_config,
)
def make_config():
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
config = Mock()
config.bucket_args = {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
}
config.encoding_options = None
return config
@pytest.fixture
def service():
"""Return a VideoCompositeEgressService with mocked handle_request."""
svc = VideoCompositeEgressService(make_config())
svc._handle_request = Mock()
return svc
# --- resolve_encoding_config ---
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_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"))
assert resolved == {
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"width": 1280,
"height": 720,
}
def test_encoding_config_requires_resolution():
"""A profile-only or empty encoding config should be rejected at validation."""
with pytest.raises(PydanticValidationError):
EncodingConfig(profile="mixed")
with pytest.raises(PydanticValidationError):
EncodingConfig()
# --- _resolve_encoding_options ---
@pytest.mark.parametrize("encoding_options", [None, {}])
def test_resolve_options_returns_none_when_empty(service, encoding_options):
"""Resolver should return None when the resolved dict is empty or missing."""
assert service._resolve_encoding_options(encoding_options) is None
@pytest.mark.parametrize(
"resolution",
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
)
@pytest.mark.parametrize(
"profile",
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
)
def test_resolve_profile_resolution_combinations(service, profile, resolution):
"""Every (profile, resolution) pair should resolve to the values from settings."""
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 == 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):
"""Missing profile should pass 0 fps/bitrate (LiveKit protobuf default)."""
resolved = resolve_encoding_config(EncodingConfig(resolution="720p"))
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == 0
assert result.video_bitrate == 0
@@ -85,18 +85,22 @@ def test_config_immutability(default_config):
AWS_S3_REGION_NAME="test-region", AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket", AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_ENABLED=True, RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_WIDTH=1280, RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={
RECORDING_ENCODING_HEIGHT=720, "720p": {"width": 1280, "height": 720},
RECORDING_ENCODING_FRAMERATE=15, },
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600, 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_AUDIO_BITRATE_KBPS=64,
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0, RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
) )
def test_config_encoding_options_enabled(): def test_config_encoding_options_enabled():
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated. """When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
The dict mixes operator-tunable values from settings with pinned codec / The dict mixes values resolved from the default resolution / profile with
frequency constants, so the services layer can simply unpack it. pinned codec / frequency constants, so the services layer can simply unpack it.
""" """
WorkerServiceConfig.from_settings.cache_clear() 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( @override_settings(
RECORDING_OUTPUT_FOLDER="/test/output", RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"}, LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
# Verify worker service call # Verify worker service call
expected_room_name = str(mock_recording.room.id) expected_room_name = str(mock_recording.room.id)
mock_worker_service.start.assert_called_once_with( mock_worker_service.start.assert_called_once_with(
expected_room_name, mock_recording.id expected_room_name, mock_recording.id, encoding_options=None
) )
# Verify recording updates # Verify recording updates
@@ -64,6 +64,38 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
) )
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_passes_resolved_encoding(
mock_update_room_metadata, mediator, mock_worker_service
):
"""The resolved encoding persisted in recording.options reaches the worker."""
mock_worker_service.start.return_value = "test-worker-123"
resolved = {
"key_frame_interval": 4.0,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
}
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED,
worker_id=None,
options={
"encoding": {
"resolution": "720p",
"profile": "talking_heads",
"resolved": resolved,
}
},
)
mediator.start(mock_recording)
mock_worker_service.start.assert_called_once_with(
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError] "error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
) )
@@ -470,6 +470,160 @@ def test_start_recording_options_unknown_field_rejected(settings):
assert response.status_code == 400 assert response.status_code == 400
def test_start_recording_options_encoding_valid(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a valid encoding configuration."""
settings.RECORDING_ENABLE = True
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": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
def test_start_recording_persists_resolved_encoding(
settings, mock_worker_service_factory, mock_worker_manager
):
"""The resolved encoding should be persisted in recording.options alongside
the requested resolution/profile for traceability."""
settings.RECORDING_ENABLE = True
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": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options["encoding"] == {
"resolution": "720p",
"profile": "talking_heads",
"resolved": {
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
},
}
def test_start_recording_forwards_resolved_encoding_to_worker(
settings, mock_worker_service, mock_worker_service_factory
):
"""The resolved encoding should passed on to the worker."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
mock_worker_service.start.return_value = "egress-123"
with mock.patch("core.utils.update_room_metadata"):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {
"encoding": {"resolution": "720p", "profile": "talking_heads"}
},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
mock_worker_service.start.assert_called_once_with(
str(room.id),
recording.id,
encoding_options=recording.options["encoding"]["resolved"],
)
def test_start_recording_options_encoding_invalid_resolution(settings):
"""Should reject invalid encoding resolution values."""
settings.RECORDING_ENABLE = True
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": "4K"}}},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_encoding_unknown_key_rejected(settings):
"""Should reject unknown keys in encoding configuration."""
settings.RECORDING_ENABLE = True
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": {"bitrate": 9000}},
},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_without_encoding_unchanged(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Requests without encoding should keep existing options behavior."""
settings.RECORDING_ENABLE = True
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": {"language": "fr"}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": "fr"}
@pytest.mark.parametrize("value", ["foo", 12]) @pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value): def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values.""" """Should reject non-boolean transcribe values."""
+98 -15
View File
@@ -747,26 +747,63 @@ class Base(Configuration):
# recordings), whose request never carries advanced EncodingOptions. # recordings), whose request never carries advanced EncodingOptions.
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset # 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). # (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
# When enabled, the values below are passed to LiveKit as EncodingOptions # When enabled, the encoding parameters are resolved from the default profile
# (advanced) and replace the preset. Lowering framerate and bitrate reduces # and resolution below and passed to LiveKit as EncodingOptions (advanced),
# output file size and CPU load on the egress worker. # replacing the preset. Lowering framerate and bitrate reduces output file
# size and CPU load on the egress worker.
RECORDING_ENCODING_ENABLED = values.BooleanValue( RECORDING_ENCODING_ENABLED = values.BooleanValue(
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
) )
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None # Map resolution name -> {"width": ..., "height": ...} in pixels.
) RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue( {
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None "540p": {"width": 960, "height": 540},
) "720p": {"width": 1280, "height": 720},
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue( "1080p": {"width": 1920, "height": 1080},
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None },
) environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
3000,
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
environ_prefix=None, environ_prefix=None,
) )
# Bitrate scales with resolution so quality stays consistent across sizes.
RECORDING_ENCODING_AVAILABLE_PROFILES = values.DictValue(
{
"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},
},
},
environ_name="RECORDING_ENCODING_AVAILABLE_PROFILES",
environ_prefix=None,
)
# Defaults used when no profile/resolution is specified per recording.
# Must be keys of the two dicts above (validated at startup).
RECORDING_ENCODING_DEFAULT_PROFILE = values.Value(
"full",
environ_name="RECORDING_ENCODING_DEFAULT_PROFILE",
environ_prefix=None,
)
RECORDING_ENCODING_DEFAULT_RESOLUTION = values.Value(
"720p",
environ_name="RECORDING_ENCODING_DEFAULT_RESOLUTION",
environ_prefix=None,
)
# Settings independent of profile/resolution.
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue( RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
128, 128,
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS", environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
@@ -781,6 +818,7 @@ class Base(Configuration):
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue( SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None 1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
) )
SUMMARY_SERVICE_ENDPOINT = values.Value( SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
) )
@@ -1169,6 +1207,49 @@ class Base(Configuration):
}, },
} }
@classmethod
def _check_recording_encoding_maps(cls):
"""Ensure the per-recording encoding maps are mutually consistent.
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, 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 "
"define a bitrate for exactly the resolutions in "
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
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 @classmethod
def post_setup(cls): def post_setup(cls):
"""Post setup configuration. """Post setup configuration.
@@ -1182,6 +1263,8 @@ class Base(Configuration):
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH" "FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
) )
cls._check_recording_encoding_maps()
if ( if (
cls.SUMMARY_SERVICE_VERSION == 1 cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None and cls.SUMMARY_SERVICE_ENDPOINT is not None