fix following fixup + change default behavior + add tests

This commit is contained in:
leo
2026-07-14 18:10:44 +02:00
parent 6379c6f7eb
commit f5d0458f29
9 changed files with 240 additions and 84 deletions
+14
View File
@@ -384,6 +384,20 @@ 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/
+16 -8
View File
@@ -38,16 +38,24 @@ 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.
if resolution and profile:
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
resolution
]
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
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,
"width": resolution_config["width"],
"height": resolution_config["height"],
"framerate": profile_config["fps"],
"video_bitrate": profile_config["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,
@@ -36,16 +36,16 @@ def resolve_encoding_config(encoding_config):
}
if resolution:
width, height = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
resolved["width"] = width
resolved["height"] = height
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
resolution
]
resolved["width"] = resolution_config["width"]
resolved["height"] = resolution_config["height"]
if resolution and profile:
fps, kbps_by_resolution = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
profile
]
resolved["framerate"] = fps
resolved["video_bitrate"] = kbps_by_resolution[resolution]
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
resolved["framerate"] = profile_config["fps"]
resolved["video_bitrate"] = profile_config["kbps"][resolution]
return resolved
@@ -85,13 +85,12 @@ def test_resolve_options_returns_none_when_empty(service, encoding_options):
)
def test_resolve_profile_resolution_combinations(service, profile, resolution):
"""Every (profile, resolution) pair should resolve to the values from settings."""
expected_width, expected_height = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
resolution
]
expected_fps, kbps_by_resolution = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
profile
]
expected_bitrate = kbps_by_resolution[resolution]
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 = resolve_encoding_config(
EncodingConfig(resolution=resolution, profile=profile)
@@ -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,27 @@ 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 +140,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"},
@@ -475,6 +475,7 @@ def test_start_recording_options_encoding_valid(
):
"""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")
@@ -493,12 +494,38 @@ def test_start_recording_options_encoding_valid(
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")
@@ -534,6 +561,7 @@ def test_start_recording_forwards_resolved_encoding_to_worker(
):
"""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")
@@ -566,6 +594,7 @@ def test_start_recording_forwards_resolved_encoding_to_worker(
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")
@@ -584,6 +613,7 @@ def test_start_recording_options_encoding_invalid_resolution(settings):
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")
+65 -14
View File
@@ -721,17 +721,25 @@ 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 encoding parameters are resolved from the default profile
# and resolution below and passed to LiveKit as EncodingOptions (advanced),
# replacing 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
)
# Map resolution string -> (width, height) in pixels.
# Map resolution string -> {"width", "height"} in pixels.
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
{
"540p": {"width": 960, "height": 540},
@@ -742,6 +750,7 @@ class Base(Configuration):
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(
{
@@ -1165,14 +1174,20 @@ class Base(Configuration):
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)
for profile, (
_fps,
kbps_by_resolution,
# DictValue resolves to a dict at runtime; pylint sees the descriptor.
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(kbps_by_resolution)
profile_resolutions = set(profile_config["kbps"])
if profile_resolutions != resolutions:
raise ValueError(
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
@@ -1181,6 +1196,42 @@ class Base(Configuration):
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.