mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 13:48:26 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e0bd7ac5 | |||
| 35d2bae0f0 | |||
| 839cfa4b80 |
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user