Compare commits

..

10 Commits

Author SHA1 Message Date
leo 11de0bd408 reorder changelog 2026-09-02 14:15:23 +02:00
leo 5632e44a87 reorder changelog 2026-09-02 14:14:05 +02:00
leo 71f9032681 update upgrade.md to reflect options 2026-09-02 14:00:32 +02:00
leo 88dc205ac3 revert change regarding change in worker 2026-09-02 13:49:48 +02:00
leo 4b7e212db8 wip 2026-09-02 12:20:17 +02:00
leo b852f40778 lint 2026-09-02 12:11:14 +02:00
leo 56f9605bd3 address issues 2026-09-02 12:04:03 +02:00
leo 65bd76698f fix tets 2026-09-02 11:39:23 +02:00
leo 653e67de56 wip 2026-09-02 11:31:58 +02:00
leo 9cb19e1906 (backend) add per-recording encoding config to start-recording API
Add new options to query start-start recording API. A resolution
("540p", "720p", "1080p") and a profile ("talking_heads", "text", "mixed")
are resolved to provide a width, height, fps and bitrate which
are passed on to the encoder. Using profiles allows for some flexibility
on quality if necessary without changing front facing user config.

Co-authored-by: sarthakbahal <sarthakbahal.45@gmail.com>
2026-09-02 10:39:12 +02:00
28 changed files with 1077 additions and 1151 deletions
+8 -4
View File
@@ -8,6 +8,14 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- ✨(backend) add per-recording encoding quality presets to start-recording API
### Changed
git
- 💥(backend) replace recording encoding options with a profile model
### Fixed ### Fixed
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667 - 🐛(frontend) keep the sending resolution picked while the camera is off #1667
@@ -222,10 +230,6 @@ and this project adheres to
- ♿️(frontend) focus side panel container on open #1452 - ♿️(frontend) focus side panel container on open #1452
- 🐛(summary) whisper call error handling - 🐛(summary) whisper call error handling
### Added
- ✨(frontend) add screen share zoom controls #1498
## [1.23.0] - 2026-07-08 ## [1.23.0] - 2026-07-08
### Added ### Added
+122
View File
@@ -16,6 +16,128 @@ 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`
keep their names and meaning. The keyframe interval now defaults to `0` (unset,
encoder's choice) instead of `4.0`.
#### If you never set `RECORDING_ENCODING_ENABLED=True`
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.
Video output is therefore unchanged.
Audio and keyframing may not be. 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`. **If you set either of them while the
feature was disabled, they had no effect and now do**; check them before upgrading.
If you never set them, no action is required: 128 kbps AAC is what the preset used,
and the keyframe interval now defaults to `0`, which leaves the field unset so the
encoder keeps picking it as before. Set `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0`
if you want fixed 4-second keyframes (the value the setting defaulted to while it
was gated behind `RECORDING_ENCODING_ENABLED`).
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
```
Both maps are validated at startup and a malformed one raises a `ValueError`:
- every entry of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` must declare `width` and
`height`, and every entry of `RECORDING_ENCODING_AVAILABLE_PROFILES` an `fps` and a
`kbps` map;
- every profile 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.
#### Breaking: custom worker services must accept `encoding_options`
Only concerns deployments pointing `RECORDING_WORKER_CLASSES` at their own worker
class. The shipped `VideoCompositeEgressService` and `AudioCompositeEgressService`
are already updated.
The `WorkerService` protocol's `start()` takes a third argument, and the mediator
now always passes it as a keyword when the recording carries no per-recording encoding:
```python
# before
def start(self, room_id: str, recording_id: str) -> str: ...
# now
def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str: ...
```
#### 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. It
falls back to `RECORDING_ENCODING_DEFAULT_PROFILE`) 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 ## v1.30.0
### Removing S3 storage-event webhooks for recordings ### Removing S3 storage-event webhooks for recordings
+41 -34
View File
@@ -93,13 +93,13 @@ sequenceDiagram
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. | | **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. | | **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. | | **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). | | **RECORDING_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | | **RECORDING_ENCODING_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | | **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | | **RECORDING_ENCODING_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | | **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | | **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. | | **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `0.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `0` leaves the field unset, letting the encoder pick; `4.0` is a standard VOD value. |
> [!NOTE] > [!NOTE]
@@ -130,52 +130,59 @@ This allows you to verify which recordings are in progress, troubleshoot egress
## Tuning recording encoding ## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing. Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives. Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request keeps `RECORDING_ENCODING_DEFAULT_PROFILE` for fps and bitrate, so clients can only pick from pre-defined values. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
The resolved values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what the profile/resolution resolve to is what the encoder receives.
### How values map to GStreamer ### How values map to GStreamer
| Setting | GStreamer element | Property | | Resolved value | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- | | ----------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` | | resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` | | profile `fps` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) | | profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` | | `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) | | `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file. The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles ### Built-in profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware. The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for | | Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- | | --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour | | `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion | | `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ | | `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) | | `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
★ Recommended starting point for typical LaSuite Meet usage. To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
Environment variables for the **Low CPU / small file** profile: ```json
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
}
```
To change the default encoding applied to every recording:
```bash ```bash
RECORDING_ENCODING_ENABLED=True RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_WIDTH=1280 RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64 RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0 RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
``` ```
### Caveats ### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame). - **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **`text`** profile (1000 kbps × 15 fps ≈ 67 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings. - **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings. - **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning. - **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+50 -2
View File
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer from pydantic import (
BaseModel,
Field,
field_serializer,
field_validator,
)
from pydantic import ValidationError as PydanticValidationError from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -244,6 +249,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only") raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class EncodingConfig(BaseModel):
"""Configuration options for recording encoding.
The allowed `resolution` and `profile` values are derived at validation time
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
to those maps is enough to make it accepted here.
Attributes:
resolution: Target video resolution.
profile: Encoding profile to fps and kbps. When `None`,
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
"""
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"}
+24 -1
View File
@@ -55,6 +55,7 @@ from core.recording.worker.exceptions import (
RecordingStopError, RecordingStopError,
) )
from core.recording.worker.factories import ( from core.recording.worker.factories import (
build_encoding_options,
get_worker_service, get_worker_service,
) )
from core.recording.worker.mediator import ( from core.recording.worker.mediator import (
@@ -400,12 +401,34 @@ class RoomViewSet(
options = serializer.validated_data.get("options") options = serializer.validated_data.get("options")
room = self.get_object() 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: 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,
+55 -16
View File
@@ -22,6 +22,46 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000 _RECORDING_AUDIO_FREQUENCY_HZ = 48000
def build_encoding_options(resolution, profile=None):
"""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.
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
Framerate and bitrate are left to LiveKit only when the operator
declared no default profile at all.
"""
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
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) @dataclass(frozen=True)
class WorkerServiceConfig: class WorkerServiceConfig:
"""Declare Worker Service common configurations""" """Declare Worker Service common configurations"""
@@ -38,22 +78,16 @@ class WorkerServiceConfig:
logger.debug("Loading WorkerServiceConfig from settings.") 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 encoding_options: Optional[Dict[str, Any]] = None
if settings.RECORDING_ENCODING_ENABLED: if resolution and profile:
# Single source of truth for the EncodingOptions kwargs: encoding_options = build_encoding_options(resolution, profile)
# 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,
}
return cls( return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER, output_folder=settings.RECORDING_OUTPUT_FOLDER,
@@ -78,7 +112,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
+19 -14
View File
@@ -76,28 +76,28 @@ 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).
""" """
raise NotImplementedError("Subclass must implement this method.") raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self): def _resolve_encoding_options(self, encoding_options):
"""Build a LiveKit EncodingOptions from the service config, or None. """Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
``encoding_options`` is the per-recording dict persisted by the API in
``recording.options["encoding"]["resolved"]``; it falls back to the
default encoding carried by the service config.
When None is returned, the caller should omit the `advanced` field so When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30). LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
""" """
opts = self._config.encoding_options encoding_options = encoding_options or self._config.encoding_options
if not opts: if not encoding_options:
return None return None
return livekit_api.EncodingOptions(**opts) return livekit_api.EncodingOptions(**encoding_options)
class VideoCompositeEgressService(BaseEgressService): class VideoCompositeEgressService(BaseEgressService):
@@ -105,7 +105,7 @@ class VideoCompositeEgressService(BaseEgressService):
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 +126,7 @@ class VideoCompositeEgressService(BaseEgressService):
"layout": "speaker-light", "layout": "speaker-light",
} }
advanced = self._build_encoding_options() advanced = self._resolve_encoding_options(encoding_options)
if advanced is not None: if advanced is not None:
request_kwargs["advanced"] = advanced request_kwargs["advanced"] = advanced
@@ -145,8 +145,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,169 @@
"""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
from django.test import override_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_uses_default_profile():
"""A resolution-only config should fall back to the default profile.
Left unset, framerate and video_bitrate take LiveKit's own EncodingOptions
defaults. The profile-independent fields (audio bitrate, keyframe interval,
codec/frequency pins) are always present, matching the default encoding.
"""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("540p")
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": 960,
"height": 540,
"framerate": default_profile["fps"],
"video_bitrate": default_profile["kbps"]["540p"],
}
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_build_options_omits_profile_fields_without_default_profile():
"""With no default profile declared, framerate/bitrate are left to LiveKit."""
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_default_profile(service):
"""A missing profile should resolve to the default profile's fps/bitrate."""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == default_profile["fps"]
assert result.video_bitrate == default_profile["kbps"]["720p"]
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_resolve_options_passes_zero_when_no_default_profile(service):
"""With no default profile, fps/bitrate reach LiveKit unset (protobuf 0).
The pinned codec / audio fields are still applied.
"""
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_SECRET_ACCESS_KEY": "test_secret",
"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_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 # Use override_settings to properly patch Django settings
@@ -66,8 +76,18 @@ def test_config_initialization(default_config):
"bucket": "test-bucket", "bucket": "test-bucket",
"force_path_style": True, "force_path_style": True,
} }
# Encoding override is opt-in; disabled by default. # The default encoding is always resolved from the default profile/resolution.
assert default_config.encoding_options is None 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): def test_config_immutability(default_config):
@@ -76,6 +96,7 @@ def test_config_immutability(default_config):
default_config.output_folder = "new/path" default_config.output_folder = "new/path"
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
@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"},
@@ -84,23 +105,25 @@ def test_config_immutability(default_config):
AWS_S3_SECRET_ACCESS_KEY="test_secret", AWS_S3_SECRET_ACCESS_KEY="test_secret",
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_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
RECORDING_ENCODING_WIDTH=1280, RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
RECORDING_ENCODING_HEIGHT=720, RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
RECORDING_ENCODING_FRAMERATE=15, RECORDING_ENCODING_DEFAULT_PROFILE="low",
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
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_default(custom_encoding_enabled):
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated. """The default encoding is always resolved from the default profile/resolution.
The dict mixes operator-tunable values from settings with pinned codec / The default fallback resolves the default profile/resolution and mixes those
frequency constants, so the services layer can simply unpack it. 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() with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
config = WorkerServiceConfig.from_settings() WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options == { assert config.encoding_options == {
"width": 1280, "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( @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.services.room_management.RoomManagement.update_metadata")
def test_start_recording_passes_resolved_encoding(
mock_update_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]
) )
@@ -2,11 +2,12 @@
Test rooms API endpoints in the Meet core app: start recording. 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 from unittest import mock
import pytest import pytest
from livekit import api as livekit_api
from rest_framework.test import APIClient from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory from ...factories import RoomFactory, UserFactory
@@ -470,6 +471,224 @@ 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
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_resolution_only_uses_default_profile(
settings, mock_worker_service_factory, mock_worker_manager
):
"""An encoding without a profile should resolve the default profile."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
settings.RECORDING_ENCODING_DEFAULT_PROFILE = "talking_heads"
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": "540p"}}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
resolved = recording.options["encoding"]["resolved"]
assert resolved["width"] == 960
assert resolved["height"] == 540
assert resolved["framerate"] == 15
assert resolved["video_bitrate"] == 400
# The requested payload is persisted as sent: no profile was asked for.
assert "profile" not in recording.options["encoding"]
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.services.room_management.RoomManagement.update_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]) @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."""
+173 -20
View File
@@ -23,6 +23,8 @@ import dj_database_url
import sentry_sdk import sentry_sdk
from configurations import Configuration, values from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue from lasuite.configuration.values import SecretFileValue
from pydantic import BaseModel, PositiveInt, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.integrations.logging import ignore_logger
@@ -53,6 +55,28 @@ def get_release():
return "NA" # Default: not available return "NA" # Default: not available
class Resolution(BaseModel):
"""Shape of a RECORDING_ENCODING_AVAILABLE_RESOLUTIONS entry."""
model_config = {"extra": "forbid"}
width: PositiveInt
height: PositiveInt
class Profile(BaseModel):
"""Shape of a RECORDING_ENCODING_AVAILABLE_PROFILES entry."""
model_config = {"extra": "forbid"}
fps: PositiveInt
kbps: dict[str, PositiveInt]
RESOLUTION_MAP_ADAPTER = TypeAdapter(dict[str, Resolution])
PROFILE_MAP_ADAPTER = TypeAdapter(dict[str, Profile])
class Base(Configuration): class Base(Configuration):
""" """
This is the base configuration every configuration (aka environment) should inherit from. It This is the base configuration every configuration (aka environment) should inherit from. It
@@ -745,35 +769,81 @@ class Base(Configuration):
# These settings affect screen recordings handled by VideoCompositeEgressService; # These settings affect screen recordings handled by VideoCompositeEgressService;
# they are silently ignored by AudioCompositeEgressService (audio-only transcript # they are silently ignored by AudioCompositeEgressService (audio-only transcript
# 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 #
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio). # A default encoding is applied to every recording: it is resolved from the default
# When enabled, the values below are passed to LiveKit as EncodingOptions # profile and resolution below and passed to LiveKit as EncodingOptions (advanced),
# (advanced) and replace the preset. Lowering framerate and bitrate reduces # replacing LiveKit's built-in H264_720P_30 preset. Lowering framerate and bitrate
# output file size and CPU load on the egress worker. # reduces output file size and CPU load on the egress worker. If either
RECORDING_ENCODING_ENABLED = values.BooleanValue( # RECORDING_ENCODING_DEFAULT_RESOLUTION or RECORDING_ENCODING_DEFAULT_PROFILE is
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None # 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 # Map resolution string -> {"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,
) )
# 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( RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
128, 128,
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS", environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
environ_prefix=None, environ_prefix=None,
) )
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue( RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
4.0, 0.0,
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S", environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
environ_prefix=None, environ_prefix=None,
) )
@@ -781,6 +851,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 +1240,86 @@ class Base(Configuration):
}, },
} }
@classmethod
def _check_recording_encoding_maps(cls):
"""Ensure the per-recording encoding maps are well-formed and consistent.
Each entry of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS must declare a width and
a height, each entry of RECORDING_ENCODING_AVAILABLE_PROFILES an fps and a kbps
map, and every profile must define a bitrate for each declared resolution.
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)
for name, adapter in (
("RECORDING_ENCODING_AVAILABLE_RESOLUTIONS", RESOLUTION_MAP_ADAPTER),
("RECORDING_ENCODING_AVAILABLE_PROFILES", PROFILE_MAP_ADAPTER),
):
try:
adapter.validate_python(getattr(cls, name))
except PydanticValidationError as exc:
raise ValueError(f"{name} is malformed: {exc}") from exc
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}"
)
# Check that default resolutions and profiles are actually defined
if (
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION
and 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
and 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)})."
)
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,
)
@classmethod @classmethod
def post_setup(cls): def post_setup(cls):
"""Post setup configuration. """Post setup configuration.
@@ -1182,6 +1333,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
@@ -20,7 +20,6 @@ import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from './ParticipantPlaceholder' import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus' import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning' import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog' import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels' import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
@@ -49,8 +48,6 @@ interface ParticipantTileExtendedProps extends ParticipantTileProps {
disableTileControls?: boolean disableTileControls?: boolean
} }
const MOUSE_IDLE_TIME = 3000
export const ParticipantTile: ( export const ParticipantTile: (
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement> props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef< ) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
@@ -92,8 +89,6 @@ export const ParticipantTile: (
) )
const isScreenShare = trackReference.source != Track.Source.Camera const isScreenShare = trackReference.source != Track.Source.Camera
const isRemoteScreenShare =
isScreenShare && !trackReference.participant.isLocal
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false) const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantColor = getParticipantColor(trackReference.participant) const participantColor = getParticipantColor(trackReference.participant)
@@ -103,38 +98,11 @@ export const ParticipantTile: (
}) })
const participantName = name || identity || 'Unknown' const participantName = name || identity || 'Unknown'
// Hover + idle tracking for the focus overlay (pin, effects, mute buttons).
const [isTileHovered, setIsTileHovered] = React.useState(false)
const [isIdle, setIsIdle] = React.useState(false)
const idleTimerRef = React.useRef<number | null>(null)
const handleTileMouseMove = React.useCallback(() => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = window.setTimeout(
() => setIsIdle(true),
MOUSE_IDLE_TIME
)
setIsIdle(false)
}, [])
const isOverlayVisible = hasKeyboardFocus || (isTileHovered && !isIdle)
// tileRef: fullscreen target. setRefs merges it with the forwarded ref on the same node.
const tileRef = React.useRef<HTMLDivElement>(null)
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
if (typeof ref === 'function') ref(node)
else if (ref)
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
},
[ref]
)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = { const interactiveProps = {
...elementProps, ...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0, tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }), 'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => { onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -152,57 +120,8 @@ export const ParticipantTile: (
}, },
} }
const isVideoTrack =
isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare)
let trackMedia: React.ReactNode = null
if (isVideoTrack) {
if (isRemoteScreenShare) {
trackMedia = (
<ScreenShareZoomableVideo
trackRef={trackReference}
tileRef={tileRef}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
} else {
trackMedia = (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
}
} else if (isTrackReference(trackReference)) {
trackMedia = (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
}
return ( return (
<div <div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
ref={setRefs}
style={{ position: 'relative' }}
{...interactiveProps}
onMouseEnter={() => setIsTileHovered(true)}
onMouseLeave={() => {
setIsTileHovered(false)
setIsIdle(false)
if (idleTimerRef.current) {
window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
}}
onMouseMove={handleTileMouseMove}
>
<TrackRefContextIfNeeded trackRef={trackReference}> <TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}> <ParticipantContextIfNeeded participant={trackReference.participant}>
{trackReference.participant.isLocal && ( {trackReference.participant.isLocal && (
@@ -210,7 +129,23 @@ export const ParticipantTile: (
)} )}
{children ?? ( {children ?? (
<> <>
{trackMedia} {isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
) : (
isTrackReference(trackReference) && (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
)}
<div className="lk-participant-placeholder"> <div className="lk-participant-placeholder">
<ParticipantPlaceholder <ParticipantPlaceholder
color={participantColor} color={participantColor}
@@ -229,7 +164,7 @@ export const ParticipantTile: (
{!disableMetadata && !disableTileControls && ( {!disableMetadata && !disableTileControls && (
<ParticipantTileFocus <ParticipantTileFocus
trackRef={trackReference} trackRef={trackReference}
isVisible={isOverlayVisible} hasKeyboardFocus={hasKeyboardFocus}
/> />
)} )}
</ParticipantContextIfNeeded> </ParticipantContextIfNeeded>
@@ -1,22 +1,44 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core' import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { ReactNode } from 'react' import { ReactNode, useEffect, useRef, useState } from 'react'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton' import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton' import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton' import { MuteButton } from './MuteButton'
import { ZoomButton } from './ZoomButton'
const MOUSE_IDLE_TIME = 3000
type FadeOverlayProps = { type FadeOverlayProps = {
children: ReactNode children: ReactNode
isVisible: boolean hasKeyboardFocus: boolean
} }
// Pointer-events none so this overlay doesn't block the zoom surface below. const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
// Hover and idle tracking therefore lives on the tile, which still receives const [active, setActive] = useState(false)
// the pointer events, and comes back in as isVisible. const idleTimerRef = useRef<number | null>(null)
const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
setActive(false)
}, MOUSE_IDLE_TIME)
}
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
useEffect(() => clearIdleTimer, [])
const isVisible = hasKeyboardFocus || active
return ( return (
<div <div
className={css({ className={css({
@@ -28,10 +50,15 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
alignItems: 'center', alignItems: 'center',
width: '100%', width: '100%',
height: '100%', height: '100%',
pointerEvents: 'none',
})} })}
data-visible={isVisible || undefined} data-visible={isVisible || undefined}
aria-hidden={!isVisible} aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
> >
{isVisible && children} {isVisible && children}
</div> </div>
@@ -40,10 +67,10 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
export const ParticipantTileFocus = ({ export const ParticipantTileFocus = ({
trackRef, trackRef,
isVisible, hasKeyboardFocus,
}: { }: {
trackRef: TrackReferenceOrPlaceholder trackRef: TrackReferenceOrPlaceholder
isVisible: boolean hasKeyboardFocus: boolean
}) => { }) => {
const participant = trackRef.participant const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare const isScreenShare = trackRef.source == Track.Source.ScreenShare
@@ -51,7 +78,7 @@ export const ParticipantTileFocus = ({
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
return ( return (
<FadeOverlay isVisible={isVisible}> <FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<div <div
className={css({ className={css({
backgroundColor: 'primaryDark.50', backgroundColor: 'primaryDark.50',
@@ -60,7 +87,6 @@ export const ParticipantTileFocus = ({
display: 'flex', display: 'flex',
opacity: 0.6, opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards', animation: 'overlayIn 200ms linear 300ms backwards',
pointerEvents: 'auto',
_hover: { _hover: {
opacity: 0.95, opacity: 0.95,
}, },
@@ -68,7 +94,7 @@ export const ParticipantTileFocus = ({
> >
<HStack gap={0.5} padding={0.5}> <HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} /> <FocusButton trackRef={trackRef} />
{!isScreenShare && ( {!isScreenShare ? (
<> <>
{isLocal ? ( {isLocal ? (
<EffectsButton /> <EffectsButton />
@@ -76,6 +102,8 @@ export const ParticipantTileFocus = ({
canMute && <MuteButton participant={participant} /> canMute && <MuteButton participant={participant} />
)} )}
</> </>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)} )}
</HStack> </HStack>
</div> </div>
@@ -0,0 +1,32 @@
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { Button } from '@/primitives'
import { RiFullscreenLine } from '@remixicon/react'
export const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
@@ -1,26 +0,0 @@
import { VideoTrack } from '@livekit/components-react'
import { type TrackReference } from '@livekit/components-core'
import { memo } from 'react'
interface ScreenShareVideoTrackProps {
trackRef: TrackReference
onSubscriptionStatusChanged: (subscribed: boolean) => void
manageSubscription?: boolean
}
// Zoom/pan updates the wrapper transform only; skip VideoTrack re-renders.
export const ScreenShareVideoTrack = memo(
({
trackRef,
onSubscriptionStatusChanged,
manageSubscription,
}: ScreenShareVideoTrackProps) => (
<VideoTrack
trackRef={trackRef}
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
manageSubscription={manageSubscription}
/>
)
)
ScreenShareVideoTrack.displayName = 'ScreenShareVideoTrack'
@@ -1,223 +0,0 @@
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import {
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
RiFullscreenExitLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Toolbar } from 'react-aria-components'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { isMacintosh } from '@/utils/livekit'
import { srOnly } from '@/styles/a11y'
interface ScreenShareZoomControlsProps {
containerRef: React.RefObject<HTMLDivElement | null>
isZoomed: boolean
zoomPercentage: number
canZoomIn: boolean
canZoomOut: boolean
onZoomIn: () => void
onZoomOut: () => void
onResetZoom: () => void
}
export const ScreenShareZoomControls = ({
containerRef,
isZoomed,
zoomPercentage,
canZoomIn,
canZoomOut,
onZoomIn,
onZoomOut,
onResetZoom,
}: ScreenShareZoomControlsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
const hadFocusInCollapsibleRef = useRef(false)
const [isFullscreen, setIsFullscreen] = useState(false)
// Tracks whether this tile's container triggered fullscreen (vs another share's).
const wasThisTileFullscreen = useRef(false)
const isFullscreenAvailable = document.fullscreenEnabled
// Covers Esc and browser UI exits, not just the toolbar button.
// Only this tile's instance announces to avoid duplicates with multiple shares.
useEffect(() => {
const onChange = () => {
const isThisTileFullscreen =
document.fullscreenElement === containerRef.current
setIsFullscreen(isThisTileFullscreen)
if (isThisTileFullscreen) {
wasThisTileFullscreen.current = true
announce(t('fullScreenEntered'), 'assertive')
} else if (wasThisTileFullscreen.current) {
wasThisTileFullscreen.current = false
announce(t('fullScreenExited'), 'assertive')
}
}
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}, [announce, t, containerRef])
// Back at 100 % the collapsible controls are disabled and hidden, which drops
// keyboard focus on the body. Hand it to the zoom in button instead, the only
// control of that group still reachable.
useEffect(() => {
if (isZoomed || !hadFocusInCollapsibleRef.current) return
hadFocusInCollapsibleRef.current = false
zoomInButtonRef.current?.focus()
}, [isZoomed])
const toggleFullScreen = useCallback(async () => {
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen()
} else {
// Tile container so zoom controls stay visible in fullscreen.
await containerRef.current?.requestFullscreen()
}
} catch (error) {
console.error('Error toggling fullscreen:', error)
}
}, [containerRef])
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
return (
<div
className={css({
position: 'absolute',
bottom: '12px',
right: '12px',
zIndex: 2,
pointerEvents: 'auto',
})}
>
{/* react-aria Toolbar: left/right arrows move between the controls and
Tab leaves the group as a whole, as the toolbar role implies. */}
<Toolbar
aria-label={t('toolbarLabel')}
className={css({
display: 'flex',
alignItems: 'center',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
padding: '0.5rem',
opacity: 0.7,
transition: 'opacity 200ms linear',
_hover: {
opacity: 0.95,
},
})}
>
<span className={srOnly}>
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
</span>
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
trick keeps overflow:hidden from clipping focus rings. */}
<div
className={css({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
padding: '3px',
margin: '-3px',
})}
style={{
maxWidth: isZoomed ? '12rem' : '0',
opacity: isZoomed ? 1 : 0,
}}
aria-hidden={!isZoomed}
onFocus={() => {
hadFocusInCollapsibleRef.current = true
}}
onBlur={(e) => {
// Disabling a focused button blurs it with no relatedTarget, so the
// flag must survive that case for the effect above to rescue focus.
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
}}
>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fitToWindow')}
aria-label={t('fitToWindow')}
isDisabled={!isZoomed}
onPress={onResetZoom}
>
<RiFullscreenExitLine size={20} />
</Button>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomOutWithShortcut', {
shortcut: wheelShortcutVisual,
})}
aria-label={t('zoomOut')}
isDisabled={!isZoomed || !canZoomOut}
onPress={onZoomOut}
>
<RiZoomOutLine size={20} />
</Button>
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
<span
aria-hidden="true"
className={css({
color: 'white',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.25rem',
textAlign: 'center',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 0.25rem',
whiteSpace: 'nowrap',
})}
>
{zoomPercentage} %
</span>
</div>
<Button
ref={zoomInButtonRef}
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
aria-label={t('zoomIn')}
isDisabled={!canZoomIn}
onPress={onZoomIn}
>
<RiZoomInLine size={20} />
</Button>
{isFullscreenAvailable && (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
onPress={toggleFullScreen}
>
{isFullscreen ? (
<RiCollapseDiagonalLine size={20} />
) : (
<RiExpandDiagonalLine size={20} />
)}
</Button>
)}
</Toolbar>
</div>
)
}
@@ -1,106 +0,0 @@
import { css } from '@/styled-system/css'
import { type TrackReference } from '@livekit/components-core'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
import { ScreenShareVideoTrack } from './ScreenShareVideoTrack'
interface ScreenShareZoomableVideoProps {
trackRef: TrackReference
tileRef: React.RefObject<HTMLDivElement | null>
onSubscriptionStatusChanged: (subscribed: boolean) => void
manageSubscription?: boolean
}
export const ScreenShareZoomableVideo = ({
trackRef,
tileRef,
onSubscriptionStatusChanged,
manageSubscription,
}: ScreenShareZoomableVideoProps) => {
const zoom = useScreenShareZoom()
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
// SR announcement: announce zoom level on change, with a one-time pan hint
// on the first zoom above 100 % per session.
const prevZoomRef = useRef(zoom.zoomPercentage)
const hasAnnouncedPanHint = useRef(false)
useEffect(() => {
if (prevZoomRef.current === zoom.zoomPercentage) return
const wasAtDefault = prevZoomRef.current <= 100
prevZoomRef.current = zoom.zoomPercentage
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
hasAnnouncedPanHint.current = true
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
} else {
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
}
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
// Attach keyboard listener on the tile container (has tabIndex=0).
useEffect(() => {
const el = tileRef.current
if (!el) return
el.addEventListener('keydown', zoom.handleKeyDown)
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
}, [tileRef, zoom.handleKeyDown])
// Native wheel listener with { passive: false } so preventDefault works.
useEffect(() => {
const el = zoom.surfaceElRef.current
if (!el) return
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
return () => el.removeEventListener('wheel', zoom.handleWheel)
}, [zoom.handleWheel, zoom.surfaceElRef])
return (
<>
<div
ref={zoom.surfaceElRef}
className={css({
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
userSelect: 'none',
// Leaves the browser's native pinch-zoom available on touch devices
// while still routing single-pointer drags to useMove for panning.
touchAction: 'pinch-zoom',
})}
{...zoom.moveProps}
>
<div
ref={zoom.transformElRef}
style={{
width: '100%',
height: '100%',
pointerEvents: 'none',
transformOrigin: 'center center',
}}
>
<ScreenShareVideoTrack
trackRef={trackRef}
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
manageSubscription={manageSubscription}
/>
</div>
</div>
<ScreenShareZoomControls
containerRef={tileRef}
isZoomed={zoom.isZoomed}
zoomPercentage={zoom.zoomPercentage}
canZoomIn={zoom.canZoomIn}
canZoomOut={zoom.canZoomOut}
onZoomIn={zoom.zoomIn}
onZoomOut={zoom.zoomOut}
onResetZoom={zoom.resetZoom}
/>
</>
)
}
@@ -1,284 +0,0 @@
import { useCallback, useRef, useSyncExternalStore } from 'react'
import { useMove } from 'react-aria'
import type { MoveMoveEvent } from '@react-types/shared'
import {
FULL_PICTURE_RATIO,
MIN_ZOOM,
PAN_STEP,
WHEEL_ZOOM_SPEED,
ZOOM_STEP,
type PanOffset,
type ZoomSnapshot,
buildZoomSnapshot,
clampPan,
clampZoom,
getCursorFromZoomState,
getCursorPercentsFromWheelEvent,
getPanDeltaPercentsFromMove,
getPictureRatio,
getWheelPanOffset,
getZoomTransform,
} from '../utils/screenShareZoom'
/**
* Manages zoom and pan state for a remote screen share.
*
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
* (via transformElRef / surfaceElRef) so the hot path (drag, wheel) never
* triggers a React re-render. A useSyncExternalStore snapshot is flushed only
* when the toolbar UI needs to update (zoom level change, drag end).
*
* Drag/touch panning is handled by react-aria's useMove (moveProps).
* Ctrl/Cmd + wheel zoom is a native listener (must be non-passive to
* preventDefault and block browser page zoom).
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
* tile container (which has tabIndex=0 and focus).
*/
export const useScreenShareZoom = () => {
const zoomRef = useRef(MIN_ZOOM)
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
const draggingRef = useRef(false)
// The consumer binds these to the inner transform div and the outer drag surface.
const transformElRef = useRef<HTMLDivElement | null>(null)
const surfaceElRef = useRef<HTMLDivElement | null>(null)
// Snapshot store: subscribers are notified only on explicit flush() calls.
const snapshotRef = useRef<ZoomSnapshot>(
buildZoomSnapshot(MIN_ZOOM, { x: 0, y: 0 }, false)
)
const listenersRef = useRef(new Set<() => void>())
const subscribe = useCallback((cb: () => void) => {
listenersRef.current.add(cb)
return () => {
listenersRef.current.delete(cb)
}
}, [])
const getSnapshot = useCallback(() => snapshotRef.current, [])
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const flush = useCallback(() => {
snapshotRef.current = buildZoomSnapshot(
zoomRef.current,
panRef.current,
draggingRef.current
)
listenersRef.current.forEach((cb) => cb())
}, [])
const applyTransform = useCallback(() => {
const el = transformElRef.current
if (!el) return
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
}, [])
// The video is letterboxed inside the surface by object-fit: contain, so the
// pan bounds depend on how much of the surface the picture actually covers.
// Read live rather than cached: both the tile and the shared resolution can
// change at any time.
const readPictureRatio = useCallback(() => {
const surface = surfaceElRef.current
const video = transformElRef.current?.querySelector('video')
if (!surface || !video) return FULL_PICTURE_RATIO
return getPictureRatio(
surface.clientWidth,
surface.clientHeight,
video.videoWidth,
video.videoHeight
)
}, [])
const applyCursor = useCallback(() => {
const el = surfaceElRef.current
if (!el) return
el.style.cursor = getCursorFromZoomState(
zoomRef.current,
draggingRef.current
)
}, [])
const zoomIn = useCallback(() => {
const next = clampZoom(zoomRef.current + ZOOM_STEP)
zoomRef.current = next
panRef.current = clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush, readPictureRatio])
const zoomOut = useCallback(() => {
const next = clampZoom(zoomRef.current - ZOOM_STEP)
zoomRef.current = next
panRef.current =
next <= MIN_ZOOM
? { x: 0, y: 0 }
: clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush, readPictureRatio])
const resetZoom = useCallback(() => {
zoomRef.current = MIN_ZOOM
panRef.current = { x: 0, y: 0 }
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush])
// Must be attached with { passive: false } so preventDefault() blocks
// the browser's native Ctrl+scroll page zoom.
const handleWheel = useCallback(
(e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
e.stopPropagation()
const target = e.currentTarget as HTMLElement
const prev = zoomRef.current
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
const next = clampZoom(prev + delta)
if (next <= MIN_ZOOM) {
zoomRef.current = MIN_ZOOM
panRef.current = { x: 0, y: 0 }
} else {
const { cursorXPercent, cursorYPercent } =
getCursorPercentsFromWheelEvent(e, target)
zoomRef.current = next
panRef.current = getWheelPanOffset({
pan: panRef.current,
prevZoom: prev,
nextZoom: next,
cursorXPercent,
cursorYPercent,
ratio: readPictureRatio(),
})
}
applyTransform()
applyCursor()
flush()
},
[applyTransform, applyCursor, flush, readPictureRatio]
)
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
// here because moveProps is on the zoom surface, while focus is on the tile
// container, see handleKeyDown below.
const { moveProps } = useMove({
onMoveStart() {
if (zoomRef.current <= MIN_ZOOM) return
draggingRef.current = true
applyCursor()
flush()
},
onMove(e: MoveMoveEvent) {
if (zoomRef.current <= MIN_ZOOM) return
const el = surfaceElRef.current
if (!el) return
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
e.deltaX,
e.deltaY,
el
)
panRef.current = clampPan(
{
x: panRef.current.x + deltaXPercent,
y: panRef.current.y + deltaYPercent,
},
zoomRef.current,
readPictureRatio()
)
applyTransform()
// Mouse drag: skip flush (imperative-only) to avoid re-renders per frame.
// Keyboard: flush so the toolbar reflects the updated position.
if (e.pointerType === 'keyboard') {
flush()
}
},
onMoveEnd() {
draggingRef.current = false
applyTransform()
applyCursor()
flush()
},
})
const panBy = useCallback(
(dx: number, dy: number) => {
panRef.current = clampPan(
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
zoomRef.current,
readPictureRatio()
)
applyTransform()
flush()
},
[applyTransform, flush, readPictureRatio]
)
// Attached to the tile container (not the zoom surface) where keyboard
// focus lives. Arrows pan, +/-/0 zoom.
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const isZoomed = zoomRef.current > MIN_ZOOM
if (!isZoomed && e.key !== '+' && e.key !== '=') return
if (e.key.startsWith('Arrow') && e.target !== e.currentTarget) return
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
panBy(PAN_STEP, 0)
break
case 'ArrowRight':
e.preventDefault()
panBy(-PAN_STEP, 0)
break
case 'ArrowUp':
e.preventDefault()
panBy(0, PAN_STEP)
break
case 'ArrowDown':
e.preventDefault()
panBy(0, -PAN_STEP)
break
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
e.preventDefault()
zoomOut()
break
case '0':
e.preventDefault()
resetZoom()
break
}
},
[panBy, zoomIn, zoomOut, resetZoom]
)
return {
...snapshot,
transformElRef,
surfaceElRef,
moveProps,
zoomIn,
zoomOut,
resetZoom,
handleWheel,
handleKeyDown,
}
}
@@ -1,154 +0,0 @@
export const MIN_ZOOM = 1
export const MAX_ZOOM = 4
export const ZOOM_STEP = 0.1
export const WHEEL_ZOOM_SPEED = 0.002
export const PAN_STEP = 5
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
// cursor offset is measured from and the half extent the pan is clamped
// against, so the two stay consistent by construction.
export const HALF_EXTENT_PERCENT = 50
export interface PanOffset {
x: number
y: number
}
// Fraction of the surface each axis of the picture covers, in [0, 1].
export interface PictureRatio {
x: number
y: number
}
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
export interface ZoomSnapshot {
zoomLevel: number
zoomPercentage: number
panOffset: PanOffset
isZoomed: boolean
isDragging: boolean
canZoomIn: boolean
canZoomOut: boolean
}
export const clampZoom = (value: number) => {
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
}
// Restrict pan so the picture always covers the view. Pan is a % of the
// surface, in which object-fit: contain letterboxes the picture: its half
// extent is `ratio * 50` against a view half extent of 50, and scaling by
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
export const clampPan = (
pan: PanOffset,
zoom: number,
ratio: PictureRatio
): PanOffset => {
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * HALF_EXTENT_PERCENT)
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
return {
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
}
}
// Per-axis fraction of the surface covered by an object-fit: contain picture.
export const getPictureRatio = (
surfaceWidth: number,
surfaceHeight: number,
videoWidth: number,
videoHeight: number
): PictureRatio => {
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
return FULL_PICTURE_RATIO
}
const surfaceRatio = surfaceWidth / surfaceHeight
const videoRatio = videoWidth / videoHeight
return surfaceRatio > videoRatio
? { x: videoRatio / surfaceRatio, y: 1 }
: { x: 1, y: surfaceRatio / videoRatio }
}
export const buildZoomSnapshot = (
zoom: number,
pan: PanOffset,
dragging: boolean
): ZoomSnapshot => {
return {
zoomLevel: zoom,
zoomPercentage: Math.round(zoom * 100),
panOffset: pan,
isZoomed: zoom > MIN_ZOOM,
isDragging: dragging,
canZoomIn: zoom < MAX_ZOOM,
canZoomOut: zoom > MIN_ZOOM,
}
}
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
}
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
if (zoom <= MIN_ZOOM) return 'default'
return dragging ? 'grabbing' : 'grab'
}
// Keep the content point under the cursor anchored while zooming. With
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
// `z * (offset + pan)`, so holding it still gives:
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
export const getWheelPanOffset = ({
pan,
prevZoom,
nextZoom,
cursorXPercent,
cursorYPercent,
ratio,
}: {
pan: PanOffset
prevZoom: number
nextZoom: number
cursorXPercent: number
cursorYPercent: number
ratio: PictureRatio
}): PanOffset => {
const panShift = 1 / nextZoom - 1 / prevZoom
return clampPan(
{
x: pan.x + cursorXPercent * panShift,
y: pan.y + cursorYPercent * panShift,
},
nextZoom,
ratio
)
}
// Convert cursor pixel position to a % offset from the surface center.
export const getCursorPercentsFromWheelEvent = (
e: WheelEvent,
target: HTMLElement
) => {
const rect = target.getBoundingClientRect()
return {
cursorXPercent:
((e.clientX - rect.left) / rect.width) * 100 - HALF_EXTENT_PERCENT,
cursorYPercent:
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
}
}
// Convert useMove pixel deltas to % of the surface dimensions.
export const getPanDeltaPercentsFromMove = (
deltaX: number,
deltaY: number,
surface: HTMLElement
) => {
const rect = surface.getBoundingClientRect()
return {
deltaXPercent: (deltaX / rect.width) * 100,
deltaYPercent: (deltaY / rect.height) * 100,
}
}
+1 -28
View File
@@ -16,10 +16,6 @@ export type ShortcutId =
| 'recording' | 'recording'
| 'reaction' | 'reaction'
| 'fullscreen' | 'fullscreen'
| 'zoom-in'
| 'zoom-out'
| 'zoom-reset'
| 'zoom-pan'
export const getShortcutDescriptorById = (id: ShortcutId) => export const getShortcutDescriptorById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id) shortcutCatalog.find((item) => item.id === id)
@@ -28,7 +24,7 @@ export type ShortcutDescriptor = {
id: ShortcutId id: ShortcutId
category: ShortcutCategory category: ShortcutCategory
shortcut?: Shortcut shortcut?: Shortcut
kind?: 'press' | 'longPress' | 'arrows' kind?: 'press' | 'longPress'
code?: string // used when kind === 'longPress' (KeyboardEvent.code) code?: string // used when kind === 'longPress' (KeyboardEvent.code)
description?: string description?: string
} }
@@ -90,27 +86,4 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'interaction', category: 'interaction',
shortcut: { key: 'P', ctrlKey: true, shiftKey: true }, shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
}, },
// Screen share zoom keys are unmodified, so they are bound on the focused
// tile instead of being registered globally. They are listed here so the
// shortcuts panel stays exhaustive.
{
id: 'zoom-in',
category: 'interaction',
shortcut: { key: '+' },
},
{
id: 'zoom-out',
category: 'interaction',
shortcut: { key: '-' },
},
{
id: 'zoom-reset',
category: 'interaction',
shortcut: { key: '0' },
},
{
id: 'zoom-pan',
category: 'interaction',
kind: 'arrows',
},
] ]
@@ -25,7 +25,6 @@ export const formatShortcutLabelForSR = (
shiftLabel, shiftLabel,
plusLabel, plusLabel,
noShortcutLabel, noShortcutLabel,
keyLabels,
}: { }: {
controlLabel: string controlLabel: string
commandLabel: string commandLabel: string
@@ -34,12 +33,10 @@ export const formatShortcutLabelForSR = (
shiftLabel: string shiftLabel: string
plusLabel: string plusLabel: string
noShortcutLabel: string noShortcutLabel: string
// Spelled-out names for keys screen readers may skip or mispronounce.
keyLabels?: Record<string, string>
} }
) => { ) => {
if (!shortcut) return noShortcutLabel if (!shortcut) return noShortcutLabel
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase() const key = shortcut.key?.toUpperCase()
if (!key) return noShortcutLabel if (!key) return noShortcutLabel
const ctrlWord = isMacintosh() ? commandLabel : controlLabel const ctrlWord = isMacintosh() ? commandLabel : controlLabel
const altWord = isMacintosh() ? optionLabel : altLabel const altWord = isMacintosh() ? optionLabel : altLabel
@@ -12,9 +12,6 @@ export const useShortcutFormatting = () => {
const formatVisual = useCallback( const formatVisual = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => { (shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.visual.arrows')
}
if (code && kind === 'longPress') { if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code) const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.visual.hold', { key: label || '?' }) return t('shortcutsPanel.visual.hold', { key: label || '?' })
@@ -26,9 +23,6 @@ export const useShortcutFormatting = () => {
const formatForSR = useCallback( const formatForSR = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => { (shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.sr.arrows')
}
if (code && kind === 'longPress') { if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code) const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.sr.hold', { key: label || '?' }) return t('shortcutsPanel.sr.hold', { key: label || '?' })
@@ -41,10 +35,6 @@ export const useShortcutFormatting = () => {
shiftLabel: t('shortcutsPanel.sr.shift'), shiftLabel: t('shortcutsPanel.sr.shift'),
plusLabel: t('shortcutsPanel.sr.plus'), plusLabel: t('shortcutsPanel.sr.plus'),
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'), noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
keyLabels: {
'+': t('shortcutsPanel.sr.plusKey'),
'-': t('shortcutsPanel.sr.minusKey'),
},
}) })
}, },
[t] [t]
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "{{name}} stummschalten", "muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild" "fullScreen": "Vollbild"
}, },
"screenShareZoom": {
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
"zoomIn": "Vergrößern",
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
"zoomOut": "Verkleinern",
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
"wheelShortcut": "Steuerung plus Mausrad",
"wheelShortcutMac": "Befehl plus Mausrad",
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
"fitToWindow": "An Fenster anpassen",
"fullScreen": "Vollbild",
"exitFullScreen": "Vollbild beenden",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
"fullScreenEntered": "Vollbild aktiviert",
"fullScreenExited": "Vollbild deaktiviert"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Tastenkürzel", "title": "Tastenkürzel",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Hand heben oder senken", "raise-hand": "Hand heben oder senken",
"toggle-chat": "Chat anzeigen/ausblenden", "toggle-chat": "Chat anzeigen/ausblenden",
"toggle-participants": "Teilnehmende anzeigen/ausblenden", "toggle-participants": "Teilnehmende anzeigen/ausblenden",
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen", "open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
}, },
"sr": { "sr": {
"control": "Steuerung", "control": "Steuerung",
@@ -807,14 +785,10 @@
"shift": "Umschalt", "shift": "Umschalt",
"plus": "plus", "plus": "plus",
"hold": "Halte {{key}} gedrückt", "hold": "Halte {{key}} gedrückt",
"arrows": "Pfeiltasten",
"plusKey": "Plus-Taste",
"minusKey": "Minus-Taste",
"noShortcut": "Kein Tastenkürzel" "noShortcut": "Kein Tastenkürzel"
}, },
"visual": { "visual": {
"hold": "Halte {{key}} gedrückt", "hold": "Halte {{key}} gedrückt"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Mute {{name}}", "muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen" "fullScreen": "Full screen"
}, },
"screenShareZoom": {
"toolbarLabel": "Screen share zoom controls",
"zoomIn": "Zoom in",
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
"zoomOut": "Zoom out",
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
"wheelShortcut": "Control plus scroll wheel",
"wheelShortcutMac": "Command plus scroll wheel",
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
"fitToWindow": "Fit to window",
"fullScreen": "Full screen",
"exitFullScreen": "Exit full screen",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
"fullScreenEntered": "Full screen enabled",
"fullScreenExited": "Full screen disabled"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Keyboard shortcuts", "title": "Keyboard shortcuts",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Raise or lower hand", "raise-hand": "Raise or lower hand",
"toggle-chat": "Toggle chat", "toggle-chat": "Toggle chat",
"toggle-participants": "Toggle participants", "toggle-participants": "Toggle participants",
"open-shortcuts-settings": "Open shortcuts settings", "open-shortcuts-settings": "Open shortcuts settings"
"zoom-in": "Zoom in on a shared screen",
"zoom-out": "Zoom out on a shared screen",
"zoom-reset": "Reset the shared screen zoom",
"zoom-pan": "Move around a zoomed shared screen"
}, },
"sr": { "sr": {
"control": "Control", "control": "Control",
@@ -807,14 +785,10 @@
"shift": "Shift", "shift": "Shift",
"plus": "plus", "plus": "plus",
"hold": "Hold {{key}}", "hold": "Hold {{key}}",
"arrows": "Arrow keys",
"plusKey": "Plus key",
"minusKey": "Minus key",
"noShortcut": "No shortcut" "noShortcut": "No shortcut"
}, },
"visual": { "visual": {
"hold": "Hold {{key}}", "hold": "Hold {{key}}"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Couper le micro de {{name}}", "muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran" "fullScreen": "Plein écran"
}, },
"screenShareZoom": {
"toolbarLabel": "Contrôles de zoom du partage d'écran",
"zoomIn": "Zoomer",
"zoomInWithShortcut": "Zoomer ({{shortcut}})",
"zoomOut": "Dézoomer",
"zoomOutWithShortcut": "Dézoomer ({{shortcut}})",
"wheelShortcut": "Contrôle plus molette",
"wheelShortcutMac": "Commande plus molette",
"wheelShortcutHint": "Raccourci : Contrôle plus molette pour zoomer ou dézoomer.",
"wheelShortcutHintMac": "Raccourci : Commande plus molette pour zoomer ou dézoomer.",
"fitToWindow": "Ajuster à la fenêtre",
"fullScreen": "Plein écran",
"exitFullScreen": "Quitter le plein écran",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Glissez avec la souris, ou revenez sur le partage d'écran et utilisez les touches fléchées pour naviguer dans l'image.",
"fullScreenEntered": "Plein écran activé",
"fullScreenExited": "Plein écran désactivé"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Raccourcis clavier", "title": "Raccourcis clavier",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Lever ou baisser la main", "raise-hand": "Lever ou baisser la main",
"toggle-chat": "Afficher/Masquer le chat", "toggle-chat": "Afficher/Masquer le chat",
"toggle-participants": "Afficher/Masquer les participants", "toggle-participants": "Afficher/Masquer les participants",
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis", "open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
"zoom-in": "Zoomer sur un écran partagé",
"zoom-out": "Dézoomer sur un écran partagé",
"zoom-reset": "Réinitialiser le zoom de l’écran partagé",
"zoom-pan": "Se déplacer dans un écran partagé zoomé"
}, },
"sr": { "sr": {
"control": "Contrôle", "control": "Contrôle",
@@ -807,14 +785,10 @@
"shift": "Majuscule", "shift": "Majuscule",
"plus": "plus", "plus": "plus",
"hold": "Maintenir {{key}}", "hold": "Maintenir {{key}}",
"arrows": "Touches fléchées",
"plusKey": "Touche plus",
"minusKey": "Touche moins",
"noShortcut": "Aucun raccourci" "noShortcut": "Aucun raccourci"
}, },
"visual": { "visual": {
"hold": "Maintenir {{key}}", "hold": "Maintenir {{key}}"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Demp {{name}}", "muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm" "fullScreen": "Volledig scherm"
}, },
"screenShareZoom": {
"toolbarLabel": "Zoombediening voor schermdeling",
"zoomIn": "Inzoomen",
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
"zoomOut": "Uitzoomen",
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
"wheelShortcut": "Control plus scrollwiel",
"wheelShortcutMac": "Command plus scrollwiel",
"wheelShortcutHint": "Sneltoets: Control plus scrollwiel om in of uit te zoomen.",
"wheelShortcutHintMac": "Sneltoets: Command plus scrollwiel om in of uit te zoomen.",
"fitToWindow": "Aanpassen aan venster",
"fullScreen": "Volledig scherm",
"exitFullScreen": "Volledig scherm verlaten",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Sleep met de muis, of zet de focus terug op de schermdeling en gebruik de pijltjestoetsen om door het beeld te navigeren.",
"fullScreenEntered": "Volledig scherm ingeschakeld",
"fullScreenExited": "Volledig scherm uitgeschakeld"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Sneltoetsen", "title": "Sneltoetsen",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Hand opsteken of laten zakken", "raise-hand": "Hand opsteken of laten zakken",
"toggle-chat": "Chat tonen/verbergen", "toggle-chat": "Chat tonen/verbergen",
"toggle-participants": "Deelnemers tonen/verbergen", "toggle-participants": "Deelnemers tonen/verbergen",
"open-shortcuts-settings": "Sneltoets-instellingen openen", "open-shortcuts-settings": "Sneltoets-instellingen openen"
"zoom-in": "Inzoomen op een gedeeld scherm",
"zoom-out": "Uitzoomen op een gedeeld scherm",
"zoom-reset": "Zoom van het gedeelde scherm herstellen",
"zoom-pan": "Navigeren in een ingezoomd gedeeld scherm"
}, },
"sr": { "sr": {
"control": "Control", "control": "Control",
@@ -807,14 +785,10 @@
"shift": "Shift", "shift": "Shift",
"plus": "plus", "plus": "plus",
"hold": "Houd {{key}} ingedrukt", "hold": "Houd {{key}} ingedrukt",
"arrows": "Pijltoetsen",
"plusKey": "Plus-toets",
"minusKey": "Min-toets",
"noShortcut": "Geen sneltoets" "noShortcut": "Geen sneltoets"
}, },
"visual": { "visual": {
"hold": "Houd {{key}} ingedrukt", "hold": "Houd {{key}} ingedrukt"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {