mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 06:08:29 +00:00
✨(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>
This commit is contained in:
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
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 rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@@ -244,6 +249,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
||||
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):
|
||||
"""Configuration options for recording.
|
||||
|
||||
@@ -264,7 +312,7 @@ class RecordingOptions(BaseModel):
|
||||
transcribe: bool | None = None
|
||||
collect_metadata: bool | None = None
|
||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||
|
||||
encoding: EncodingConfig | None = None
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ from core.recording.worker.exceptions import (
|
||||
RecordingStopError,
|
||||
)
|
||||
from core.recording.worker.factories import (
|
||||
build_encoding_options,
|
||||
get_worker_service,
|
||||
)
|
||||
from core.recording.worker.mediator import (
|
||||
@@ -400,12 +401,34 @@ class RoomViewSet(
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
if (
|
||||
options is not None
|
||||
and options.encoding is not None
|
||||
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
|
||||
):
|
||||
# Per-recording encoding selection is gated by
|
||||
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
|
||||
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||
# and RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||
return drf_response.Response(
|
||||
{"detail": "Per-recording encoding selection is disabled."},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
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"] = build_encoding_options(
|
||||
options.encoding.resolution, options.encoding.profile
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
options=options_data,
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
|
||||
@@ -22,6 +22,44 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||
|
||||
|
||||
def build_encoding_options(resolution, profile):
|
||||
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
|
||||
|
||||
Single source of truth shared by the default encoding
|
||||
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
|
||||
persisted by the start-recording API, so both paths always produce the
|
||||
same shape.
|
||||
|
||||
The profile-independent fields (audio bitrate, keyframe interval and the
|
||||
pinned codec / frequency constants) are always included.
|
||||
The resolution-dependent fields are added only when they can be resolved:
|
||||
width/height require a resolution; framerate/video_bitrate require both a
|
||||
resolution and a profile (a resolution-only encoding leaves framerate and
|
||||
bitrate to LiveKit's defaults).
|
||||
"""
|
||||
options: Dict[str, Any] = {
|
||||
"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,
|
||||
}
|
||||
|
||||
if resolution:
|
||||
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
|
||||
resolution
|
||||
]
|
||||
options["width"] = resolution_config["width"]
|
||||
options["height"] = resolution_config["height"]
|
||||
|
||||
if resolution and profile:
|
||||
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||
options["framerate"] = profile_config["fps"]
|
||||
options["video_bitrate"] = profile_config["kbps"][resolution]
|
||||
|
||||
return options
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerServiceConfig:
|
||||
"""Declare Worker Service common configurations"""
|
||||
@@ -38,22 +76,16 @@ class WorkerServiceConfig:
|
||||
|
||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||
|
||||
# The default encoding is resolved from the default profile/resolution and
|
||||
# applied to every recording that carries no per-recording encoding.
|
||||
# When either default is missing, we leave this as None so LiveKit falls
|
||||
# back to its built-in preset.
|
||||
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
if settings.RECORDING_ENCODING_ENABLED:
|
||||
# Single source of truth for the EncodingOptions kwargs:
|
||||
# operator-tunable values live in Django settings, codec / frequency
|
||||
# are pinned constants. The services layer only unpacks this dict.
|
||||
encoding_options = {
|
||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
||||
"height": settings.RECORDING_ENCODING_HEIGHT,
|
||||
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
|
||||
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||
}
|
||||
if resolution and profile:
|
||||
encoding_options = build_encoding_options(resolution, profile)
|
||||
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
@@ -78,7 +110,12 @@ class WorkerService(Protocol):
|
||||
def __init__(self, config: WorkerServiceConfig):
|
||||
"""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."""
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
|
||||
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
|
||||
raise RecordingStartError()
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
|
||||
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:
|
||||
logger.exception(
|
||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||
|
||||
@@ -76,7 +76,7 @@ class BaseEgressService:
|
||||
|
||||
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).
|
||||
Each derived class must implement this method, providing the necessary parameters for
|
||||
its specific egress type (e.g. audio_only, streaming output).
|
||||
@@ -99,13 +99,24 @@ class BaseEgressService:
|
||||
|
||||
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):
|
||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||
|
||||
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."""
|
||||
|
||||
# Save room's recording as a mp4 video file.
|
||||
@@ -126,7 +137,10 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
"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:
|
||||
request_kwargs["advanced"] = advanced
|
||||
|
||||
@@ -145,8 +159,13 @@ class AudioCompositeEgressService(BaseEgressService):
|
||||
|
||||
hrid = "audio-recording-composite-livekit-egress"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
"""Start the audio composite egress process for a recording."""
|
||||
def start(self, room_name, recording_id, encoding_options=None):
|
||||
"""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.
|
||||
file_type = livekit_api.EncodedFileType.OGG
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for the per-recording encoding resolution in BaseEgressService."""
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import pytest
|
||||
from livekit import api as livekit_api
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from core.api.serializers import EncodingConfig
|
||||
from core.recording.worker.factories import build_encoding_options
|
||||
from core.recording.worker.services import VideoCompositeEgressService
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- build_encoding_options ---
|
||||
|
||||
|
||||
def test_build_options_without_profile_omits_profile_fields():
|
||||
"""A resolution-only config should resolve dimensions but no framerate/bitrate.
|
||||
|
||||
The profile-independent fields (audio bitrate, keyframe interval, codec /
|
||||
frequency pins) are always present, matching the default encoding.
|
||||
"""
|
||||
resolved = build_encoding_options("720p", None)
|
||||
|
||||
assert resolved == {
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
}
|
||||
assert "framerate" not in resolved
|
||||
assert "video_bitrate" not in resolved
|
||||
|
||||
|
||||
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."""
|
||||
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||
expected_width = resolution_config["width"]
|
||||
expected_height = resolution_config["height"]
|
||||
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||
expected_fps = profile_config["fps"]
|
||||
expected_bitrate = profile_config["kbps"][resolution]
|
||||
|
||||
resolved = build_encoding_options(resolution, profile)
|
||||
result = service._resolve_encoding_options(resolved)
|
||||
|
||||
assert result.width == expected_width
|
||||
assert result.height == expected_height
|
||||
assert result.framerate == expected_fps
|
||||
assert result.video_bitrate == expected_bitrate
|
||||
# Profile-independent fields match the default encoding, never dropped.
|
||||
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||
assert result.audio_frequency == 48000
|
||||
|
||||
|
||||
def test_resolve_options_none_profile_uses_livekit_defaults(service):
|
||||
"""Missing profile should pass 0 fps/bitrate (LiveKit protobuf default).
|
||||
|
||||
The pinned codec / audio fields are still applied even without a profile.
|
||||
"""
|
||||
resolved = build_encoding_options("720p", None)
|
||||
result = service._resolve_encoding_options(resolved)
|
||||
assert result.width == 1280
|
||||
assert result.height == 720
|
||||
assert result.framerate == 0
|
||||
assert result.video_bitrate == 0
|
||||
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||
@@ -40,6 +40,16 @@ def test_settings():
|
||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||
"AWS_S3_REGION_NAME": "test-region",
|
||||
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
||||
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
|
||||
"720p": {"width": 1280, "height": 720}
|
||||
},
|
||||
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
|
||||
"full": {"fps": 30, "kbps": {"720p": 3000}}
|
||||
},
|
||||
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
|
||||
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
|
||||
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
|
||||
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
|
||||
}
|
||||
|
||||
# Use override_settings to properly patch Django settings
|
||||
@@ -66,8 +76,18 @@ def test_config_initialization(default_config):
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
}
|
||||
# Encoding override is opt-in; disabled by default.
|
||||
assert default_config.encoding_options is None
|
||||
# The default encoding is always resolved from the default profile/resolution.
|
||||
assert default_config.encoding_options == {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 30,
|
||||
"video_bitrate": 3000,
|
||||
"audio_bitrate": 128,
|
||||
"key_frame_interval": 4.0,
|
||||
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
}
|
||||
|
||||
|
||||
def test_config_immutability(default_config):
|
||||
@@ -76,6 +96,7 @@ def test_config_immutability(default_config):
|
||||
default_config.output_folder = "new/path"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
@@ -84,23 +105,25 @@ def test_config_immutability(default_config):
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
AWS_S3_REGION_NAME="test-region",
|
||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||
RECORDING_ENCODING_ENABLED=True,
|
||||
RECORDING_ENCODING_WIDTH=1280,
|
||||
RECORDING_ENCODING_HEIGHT=720,
|
||||
RECORDING_ENCODING_FRAMERATE=15,
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
||||
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
|
||||
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
|
||||
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
|
||||
RECORDING_ENCODING_DEFAULT_PROFILE="low",
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||
)
|
||||
def test_config_encoding_options_enabled():
|
||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
||||
def test_config_encoding_options_default(custom_encoding_enabled):
|
||||
"""The default encoding is always resolved from the default profile/resolution.
|
||||
|
||||
The dict mixes operator-tunable values from settings with pinned codec /
|
||||
frequency constants, so the services layer can simply unpack it.
|
||||
The default fallback resolves the default profile/resolution and mixes those
|
||||
operator-tunable values with pinned codec / frequency constants. This works
|
||||
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
|
||||
per-recording API, so both toggle states produce the same default.
|
||||
"""
|
||||
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
|
||||
assert config.encoding_options == {
|
||||
"width": 1280,
|
||||
@@ -115,6 +138,27 @@ def test_config_encoding_options_enabled():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("default_resolution", "default_profile"),
|
||||
[("", "full"), ("720p", ""), ("", "")],
|
||||
)
|
||||
def test_config_encoding_options_none_when_default_missing(
|
||||
test_settings, default_resolution, default_profile
|
||||
):
|
||||
"""A missing default resolution/profile leaves encoding_options None.
|
||||
|
||||
The service then omits the `advanced` field so LiveKit uses its built-in preset.
|
||||
"""
|
||||
with override_settings(
|
||||
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
|
||||
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
|
||||
):
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
|
||||
assert config.encoding_options is None
|
||||
|
||||
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
||||
# Verify worker service call
|
||||
expected_room_name = str(mock_recording.room.id)
|
||||
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
|
||||
@@ -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(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
Test rooms API endpoints in the Meet core app: start recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit import api as livekit_api
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
@@ -470,6 +471,194 @@ def test_start_recording_options_unknown_field_rejected(settings):
|
||||
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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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_options_encoding_rejected_when_custom_encoding_disabled(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
|
||||
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 == 400
|
||||
assert not Recording.objects.filter(room=room).exists()
|
||||
|
||||
|
||||
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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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": {
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
"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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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
|
||||
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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])
|
||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||
"""Should reject non-boolean transcribe values."""
|
||||
|
||||
+132
-19
@@ -745,28 +745,74 @@ class Base(Configuration):
|
||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||
# recordings), whose request never carries advanced EncodingOptions.
|
||||
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
|
||||
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
|
||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
||||
# output file size and CPU load on the egress worker.
|
||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
||||
#
|
||||
# A default encoding is applied to every recording: it is resolved from the default
|
||||
# profile and resolution below and passed to LiveKit as EncodingOptions (advanced),
|
||||
# replacing LiveKit's built-in H264_720P_30 preset. Lowering framerate and bitrate
|
||||
# reduces output file size and CPU load on the egress worker. If either
|
||||
# RECORDING_ENCODING_DEFAULT_RESOLUTION or RECORDING_ENCODING_DEFAULT_PROFILE is
|
||||
# unset, no default encoding is built (a startup warning is emitted) and LiveKit's
|
||||
# built-in preset is used instead.
|
||||
#
|
||||
# RECORDING_CUSTOM_ENCODING_ENABLED gates whether the start-recording API lets a
|
||||
# client override that default per recording (via an `encoding` object selecting a
|
||||
# resolution/profile). When False, the API rejects per-recording `encoding` and
|
||||
# every recording uses the default; when True, clients may pick from the
|
||||
# available resolutions/profiles below.
|
||||
RECORDING_CUSTOM_ENCODING_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_CUSTOM_ENCODING_ENABLED", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||
3000,
|
||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
||||
|
||||
# Map resolution string -> {"width", "height"} in pixels.
|
||||
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
|
||||
{
|
||||
"540p": {"width": 960, "height": 540},
|
||||
"720p": {"width": 1280, "height": 720},
|
||||
"1080p": {"width": 1920, "height": 1080},
|
||||
},
|
||||
environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Map profile string -> {"fps", "kbps": {resolution: video_bitrate_kbps}}.
|
||||
# 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(
|
||||
128,
|
||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||
@@ -781,6 +827,7 @@ class Base(Configuration):
|
||||
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||
)
|
||||
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -1169,6 +1216,70 @@ 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 define a bitrate for
|
||||
each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.
|
||||
|
||||
The default profile / resolution feed the default encoding. When either is
|
||||
missing, no custom default encoding can be built: a warning is emitted and
|
||||
recordings fall back to LiveKit's built-in preset. When both are set, they
|
||||
must reference keys that actually exist in the maps above.
|
||||
"""
|
||||
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||
# DictValue resolves to a dict at runtime; pylint sees the descriptor.
|
||||
for (
|
||||
profile,
|
||||
profile_config,
|
||||
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
|
||||
profile_resolutions = set(profile_config["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}"
|
||||
)
|
||||
|
||||
missing = [
|
||||
name
|
||||
for name, value in (
|
||||
(
|
||||
"RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||
),
|
||||
(
|
||||
"RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||
cls.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||
),
|
||||
)
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
warnings.warn(
|
||||
f"{' and '.join(missing)} not set; recordings will use LiveKit's "
|
||||
"built-in encoding preset instead of a custom default encoding.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return
|
||||
|
||||
if cls.RECORDING_ENCODING_DEFAULT_RESOLUTION not in resolutions:
|
||||
raise ValueError(
|
||||
"RECORDING_ENCODING_DEFAULT_RESOLUTION "
|
||||
f"'{cls.RECORDING_ENCODING_DEFAULT_RESOLUTION}' is not a key of "
|
||||
f"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS ({sorted(resolutions)})."
|
||||
)
|
||||
if cls.RECORDING_ENCODING_DEFAULT_PROFILE not in profiles:
|
||||
raise ValueError(
|
||||
"RECORDING_ENCODING_DEFAULT_PROFILE "
|
||||
f"'{cls.RECORDING_ENCODING_DEFAULT_PROFILE}' is not a key of "
|
||||
f"RECORDING_ENCODING_AVAILABLE_PROFILES ({sorted(profiles)})."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def post_setup(cls):
|
||||
"""Post setup configuration.
|
||||
@@ -1182,6 +1293,8 @@ class Base(Configuration):
|
||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||
)
|
||||
|
||||
cls._check_recording_encoding_maps()
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||
|
||||
Reference in New Issue
Block a user