This commit is contained in:
leo
2026-09-01 19:07:34 +02:00
parent 35d2bae0f0
commit f1e0bd7ac5
6 changed files with 134 additions and 58 deletions
+22 -8
View File
@@ -69,17 +69,31 @@ SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options). # Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in # Encoding is described by a named resolution (width/height) and a named profile
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce # (framerate + video bitrate per resolution) instead of raw encoder values. The
# file size and CPU load on the egress worker. # start-recording API accepts a pair per recording, e.g.
# RECORDING_ENCODING_ENABLED=False # options.encoding={"resolution": "720p", "profile": "talking_heads"}; only keys
# RECORDING_ENCODING_WIDTH=1280 # declared in the two maps below are accepted, "profile" is optional.
# RECORDING_ENCODING_HEIGHT=720 # Both maps are read as a one-line Python dict literal (ast.literal_eval): double
# RECORDING_ENCODING_FRAMERATE=30 # quoted keys, no outer quotes, no trailing comma. Every profile must define a
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000 # kbps entry for exactly the resolutions of the resolutions map, or startup fails.
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
# RECORDING_ENCODING_AVAILABLE_PROFILES={"talking_heads": {"fps": 15, "kbps": {"540p": 400, "720p": 700, "1080p": 1200}}, "text": {"fps": 15, "kbps": {"540p": 600, "720p": 1000, "1080p": 1800}}, "mixed": {"fps": 20, "kbps": {"540p": 900, "720p": 1500, "1080p": 2500}}, "full": {"fps": 30, "kbps": {"540p": 2000, "720p": 3000, "1080p": 4500}}}
# Defaults for recordings that don't carry an encoding. Must be keys of the maps
# above. Declared but not yet read by the recording code at this commit.
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
# RECORDING_ENCODING_DEFAULT_PROFILE=full
# Applied to every resolved encoding, independent of resolution and profile.
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128 # RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0 # RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Server-wide encoding used when a recording carries no encoding of its own.
# Keep False: when True, the worker factory still reads RECORDING_ENCODING_WIDTH,
# _HEIGHT, _FRAMERATE and _VIDEO_BITRATE_KBPS, which no longer exist.
# RECORDING_ENCODING_ENABLED=False
# Telephony # Telephony
ROOM_TELEPHONY_ENABLED=True ROOM_TELEPHONY_ENABLED=True
+39 -16
View File
@@ -22,6 +22,37 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000 _RECORDING_AUDIO_FREQUENCY_HZ = 48000
def _build_default_encoding_options() -> Optional[Dict[str, Any]]:
"""Build the server-wide EncodingOptions kwargs, or None to keep LiveKit's preset.
Operator-tunable values live in Django settings; the default resolution gives
width / height, the default profile gives framerate and video bitrate, while
codec and frequency are pinned constants. Either default left empty means we
use the livekit defaults.
"""
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
if not resolution or not profile:
return None
dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
return {
"width": dimensions["width"],
"height": dimensions["height"],
"framerate": profile_spec["fps"],
"video_bitrate": profile_spec["kbps"][resolution],
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
@dataclass(frozen=True) @dataclass(frozen=True)
class WorkerServiceConfig: class WorkerServiceConfig:
"""Declare Worker Service common configurations""" """Declare Worker Service common configurations"""
@@ -38,22 +69,14 @@ class WorkerServiceConfig:
logger.debug("Loading WorkerServiceConfig from settings.") logger.debug("Loading WorkerServiceConfig from settings.")
encoding_options: Optional[Dict[str, Any]] = None # Single source of truth for the EncodingOptions kwargs; the services
if settings.RECORDING_ENCODING_ENABLED: # layer only unpacks this dict. Recordings carrying their own encoding
# Single source of truth for the EncodingOptions kwargs: # resolve it per request and bypass this default.
# operator-tunable values live in Django settings, codec / frequency encoding_options: Optional[Dict[str, Any]] = (
# are pinned constants. The services layer only unpacks this dict. _build_default_encoding_options()
encoding_options = { if settings.RECORDING_ENCODING_ENABLED
"width": settings.RECORDING_ENCODING_WIDTH, else None
"height": settings.RECORDING_ENCODING_HEIGHT, )
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
return cls( return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER, output_folder=settings.RECORDING_OUTPUT_FOLDER,
@@ -36,16 +36,14 @@ def resolve_encoding_config(encoding_config):
} }
if resolution: if resolution:
width, height = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution] dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
resolved["width"] = width resolved["width"] = dimensions["width"]
resolved["height"] = height resolved["height"] = dimensions["height"]
if resolution and profile: if resolution and profile:
fps, kbps_by_resolution = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[ profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
profile resolved["framerate"] = profile_spec["fps"]
] resolved["video_bitrate"] = profile_spec["kbps"][resolution]
resolved["framerate"] = fps
resolved["video_bitrate"] = kbps_by_resolution[resolution]
return resolved return resolved
@@ -85,23 +85,18 @@ def test_resolve_options_returns_none_when_empty(service, encoding_options):
) )
def test_resolve_profile_resolution_combinations(service, profile, resolution): def test_resolve_profile_resolution_combinations(service, profile, resolution):
"""Every (profile, resolution) pair should resolve to the values from settings.""" """Every (profile, resolution) pair should resolve to the values from settings."""
expected_width, expected_height = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[ dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
resolution profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
]
expected_fps, kbps_by_resolution = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
profile
]
expected_bitrate = kbps_by_resolution[resolution]
resolved = resolve_encoding_config( resolved = resolve_encoding_config(
EncodingConfig(resolution=resolution, profile=profile) EncodingConfig(resolution=resolution, profile=profile)
) )
result = service._resolve_encoding_options(resolved) result = service._resolve_encoding_options(resolved)
assert result.width == expected_width assert result.width == dimensions["width"]
assert result.height == expected_height assert result.height == dimensions["height"]
assert result.framerate == expected_fps assert result.framerate == profile_spec["fps"]
assert result.video_bitrate == expected_bitrate assert result.video_bitrate == profile_spec["kbps"][resolution]
def test_resolve_options_none_profile_uses_livekit_defaults(service): def test_resolve_options_none_profile_uses_livekit_defaults(service):
@@ -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"},
+30 -9
View File
@@ -755,7 +755,7 @@ class Base(Configuration):
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
) )
# Map resolution string -> (width, height) in pixels. # Map resolution name -> {"width": ..., "height": ...} in pixels.
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue( RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
{ {
"540p": {"width": 960, "height": 540}, "540p": {"width": 960, "height": 540},
@@ -1211,16 +1211,21 @@ class Base(Configuration):
def _check_recording_encoding_maps(cls): def _check_recording_encoding_maps(cls):
"""Ensure the per-recording encoding maps are mutually consistent. """Ensure the per-recording encoding maps are mutually consistent.
Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must define a bitrate for Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must declare an fps and
each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS. 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) resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
for profile, ( for profile, spec in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items():
_fps, if "fps" not in spec or "kbps" not in spec:
kbps_by_resolution, raise ValueError(
# DictValue resolves to a dict at runtime; pylint sees the descriptor. f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member "define both 'fps' and 'kbps'."
profile_resolutions = set(kbps_by_resolution) )
profile_resolutions = set(spec["kbps"])
if profile_resolutions != resolutions: if profile_resolutions != resolutions:
raise ValueError( raise ValueError(
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must " f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
@@ -1229,6 +1234,22 @@ class Base(Configuration):
f"{resolutions ^ profile_resolutions}" 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.