mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 06:08:29 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc5ef39eee | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 |
@@ -12,15 +12,29 @@ and this project adheres to
|
|||||||
|
|
||||||
- ✨(backend) allow searching the recording admin table by owner email
|
- ✨(backend) allow searching the recording admin table by owner email
|
||||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||||
|
- ✨(all) allow forcing SSO display name for authenticated users
|
||||||
|
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||||
|
- ✨(backend) add per-recording encoding quality presets to start-recording API
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||||
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||||
|
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||||
|
- ♻️(frontend) refactor background processors to use the new API
|
||||||
|
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||||
|
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
|
||||||
|
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
|
||||||
|
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
|
||||||
|
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 🩹(backend) identify externally provisioned users to PostHog
|
- 🩹(backend) identify externally provisioned users to PostHog
|
||||||
|
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||||
|
- ♿️(frontend) focus side panel container on open #1452
|
||||||
|
- 💥(backend) replace recording encoding options with a profile model
|
||||||
|
|
||||||
## [1.23.0] - 2026-07-08
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --locked --no-dev
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:22 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
|
|||||||
+86
@@ -16,6 +16,92 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Recording encoding settings replaced by a resolution/profile model
|
||||||
|
|
||||||
|
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||||
|
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
|
||||||
|
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
|
||||||
|
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
|
||||||
|
fps and video bitrate.
|
||||||
|
|
||||||
|
**The following environment variables are no longer read. If they are still set in
|
||||||
|
your deployment they are silently ignored, and your recordings will be encoded with
|
||||||
|
the new defaults instead of your tuned values.**
|
||||||
|
|
||||||
|
| Removed variable | Replaced by |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
|
||||||
|
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
|
||||||
|
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
|
||||||
|
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
|
||||||
|
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
|
||||||
|
|
||||||
|
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||||
|
are unchanged and keep their values.
|
||||||
|
|
||||||
|
#### If you never set `RECORDING_ENCODING_ENABLED=True`
|
||||||
|
|
||||||
|
No action is required. The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
|
||||||
|
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
|
||||||
|
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
|
||||||
|
|
||||||
|
Note that these values are now sent explicitly as advanced `EncodingOptions`
|
||||||
|
rather than relying on LiveKit's preset, so `RECORDING_ENCODING_AUDIO_BITRATE_KBPS`
|
||||||
|
and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` now apply to every recording. They
|
||||||
|
previously applied only when `RECORDING_ENCODING_ENABLED` was `True`.
|
||||||
|
|
||||||
|
To keep letting LiveKit pick the encoding instead, set either default to an empty
|
||||||
|
value:
|
||||||
|
|
||||||
|
```
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If you had tuned `RECORDING_ENCODING_*` values
|
||||||
|
|
||||||
|
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
|
||||||
|
environment as a single-line Python/JSON dict literal (parsed with
|
||||||
|
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
|
||||||
|
add outer quotes in `.env`-style files):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
|
||||||
|
```
|
||||||
|
|
||||||
|
Two constraints are validated at startup and may raise a `ValueError`:
|
||||||
|
|
||||||
|
- every profile in `RECORDING_ENCODING_AVAILABLE_PROFILES` must define a `kbps`
|
||||||
|
entry for **exactly** the keys of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`;
|
||||||
|
overriding one of the two maps usually means overriding both;
|
||||||
|
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
|
||||||
|
when non-empty, must be keys of their respective map.
|
||||||
|
|
||||||
|
#### Optional: per-recording encoding
|
||||||
|
|
||||||
|
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
|
||||||
|
start-recording API accepts an `encoding` object
|
||||||
|
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional) that
|
||||||
|
overrides the default for a single recording. It does not enable or disable the
|
||||||
|
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
|
||||||
|
settings either way. Leaving it at `False` preserves the previous behaviour, where
|
||||||
|
every recording uses the server-side encoding: requests carrying
|
||||||
|
`options.encoding` are rejected with a `400` before the recording is created, so
|
||||||
|
nothing is persisted and no egress is started.
|
||||||
|
|
||||||
|
Before enabling it:
|
||||||
|
|
||||||
|
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
|
||||||
|
- as of this implementation, the frontend never sends `encoding`
|
||||||
|
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
|
||||||
|
egress has no video encoding to configure.
|
||||||
|
|
||||||
|
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||||
|
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||||
|
|
||||||
## v1.23.0
|
## v1.23.0
|
||||||
|
|
||||||
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||||
|
|||||||
+41
-34
@@ -100,13 +100,13 @@ sequenceDiagram
|
|||||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||||
| **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 | `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. |
|
||||||
|
|
||||||
|
|
||||||
### Manual Storage Webhook
|
### Manual Storage Webhook
|
||||||
@@ -150,52 +150,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 sets the frame size but leaves LiveKit's default framerate/bitrate; adding a profile pins fps and bitrate too. 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.
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
|||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
|
"authenticated_users_can_edit_display_name": (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
),
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -227,6 +232,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
|
class EncodingConfig(BaseModel):
|
||||||
|
"""Configuration options for recording encoding.
|
||||||
|
|
||||||
|
The allowed `resolution` and `profile` values are derived at validation time
|
||||||
|
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
|
||||||
|
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
|
||||||
|
to those maps is enough to make it accepted here.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
resolution: Target video resolution.
|
||||||
|
profile: Encoding profile to balance quality and CPU usage. When `None`,
|
||||||
|
LiveKit default framerate/bitrate are used for the resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolution: str
|
||||||
|
profile: str | None = None
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
@field_validator("resolution")
|
||||||
|
@classmethod
|
||||||
|
def _validate_resolution(cls, value):
|
||||||
|
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("profile")
|
||||||
|
@classmethod
|
||||||
|
def _validate_profile(cls, value):
|
||||||
|
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RecordingOptions(BaseModel):
|
class RecordingOptions(BaseModel):
|
||||||
"""Configuration options for recording.
|
"""Configuration options for recording.
|
||||||
|
|
||||||
@@ -247,7 +295,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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,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 (
|
||||||
@@ -92,6 +93,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
|||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
|
from ..models import RoomAccessLevel
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers, throttling
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
@@ -267,6 +269,9 @@ class RoomViewSet(
|
|||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
data = {
|
data = {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": slug,
|
||||||
|
"is_administrable": False,
|
||||||
|
"access_level": RoomAccessLevel.PUBLIC,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": slug,
|
||||||
@@ -379,12 +384,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,
|
||||||
|
|||||||
@@ -22,6 +22,44 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
|||||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||||
|
|
||||||
|
|
||||||
|
def build_encoding_options(resolution, profile):
|
||||||
|
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
|
||||||
|
|
||||||
|
Single source of truth shared by the default encoding
|
||||||
|
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
|
||||||
|
persisted by the start-recording API, so both paths always produce the
|
||||||
|
same shape.
|
||||||
|
|
||||||
|
The profile-independent fields (audio bitrate, keyframe interval and the
|
||||||
|
pinned codec / frequency constants) are always included.
|
||||||
|
The resolution-dependent fields are added only when they can be resolved:
|
||||||
|
width/height require a resolution; framerate/video_bitrate require both a
|
||||||
|
resolution and a profile (a resolution-only encoding leaves framerate and
|
||||||
|
bitrate to LiveKit's defaults).
|
||||||
|
"""
|
||||||
|
options: Dict[str, Any] = {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||||
|
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||||
|
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolution:
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
|
||||||
|
resolution
|
||||||
|
]
|
||||||
|
options["width"] = resolution_config["width"]
|
||||||
|
options["height"] = resolution_config["height"]
|
||||||
|
|
||||||
|
if resolution and profile:
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
options["framerate"] = profile_config["fps"]
|
||||||
|
options["video_bitrate"] = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class WorkerServiceConfig:
|
class WorkerServiceConfig:
|
||||||
"""Declare Worker Service common configurations"""
|
"""Declare Worker Service common configurations"""
|
||||||
@@ -38,22 +76,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 +110,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:
|
||||||
|
|||||||
@@ -47,8 +47,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
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return "FAILED_TO_STOP"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the egress process for a recording (not implemented in the base class).
|
"""Start the egress process for a recording (not implemented in the base class).
|
||||||
Each derived class must implement this method, providing the necessary parameters for
|
Each derived class must implement this method, providing the necessary parameters for
|
||||||
its specific egress type (e.g. audio_only, streaming output).
|
its specific egress type (e.g. audio_only, streaming output).
|
||||||
@@ -99,13 +99,24 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return livekit_api.EncodingOptions(**opts)
|
return livekit_api.EncodingOptions(**opts)
|
||||||
|
|
||||||
|
def _resolve_encoding_options(self, encoding_options):
|
||||||
|
"""Build LiveKit EncodingOptions from a resolved per-recording dict, or None.
|
||||||
|
|
||||||
|
``encoding_options`` is the dict persisted by the API in
|
||||||
|
``recording.options["encoding"]["resolved"]``.
|
||||||
|
"""
|
||||||
|
if not encoding_options:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return livekit_api.EncodingOptions(**encoding_options)
|
||||||
|
|
||||||
|
|
||||||
class VideoCompositeEgressService(BaseEgressService):
|
class VideoCompositeEgressService(BaseEgressService):
|
||||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||||
|
|
||||||
hrid = "video-recording-composite-livekit-egress"
|
hrid = "video-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the video composite egress process for a recording."""
|
"""Start the video composite egress process for a recording."""
|
||||||
|
|
||||||
# Save room's recording as a mp4 video file.
|
# Save room's recording as a mp4 video file.
|
||||||
@@ -126,7 +137,10 @@ class VideoCompositeEgressService(BaseEgressService):
|
|||||||
"layout": "speaker-light",
|
"layout": "speaker-light",
|
||||||
}
|
}
|
||||||
|
|
||||||
advanced = self._build_encoding_options()
|
advanced = (
|
||||||
|
self._resolve_encoding_options(encoding_options)
|
||||||
|
or self._build_encoding_options()
|
||||||
|
)
|
||||||
if advanced is not None:
|
if advanced is not None:
|
||||||
request_kwargs["advanced"] = advanced
|
request_kwargs["advanced"] = advanced
|
||||||
|
|
||||||
@@ -145,8 +159,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,126 @@
|
|||||||
|
"""Tests for the per-recording encoding resolution in BaseEgressService."""
|
||||||
|
|
||||||
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
|
|
||||||
|
from core.api.serializers import EncodingConfig
|
||||||
|
from core.recording.worker.factories import build_encoding_options
|
||||||
|
from core.recording.worker.services import VideoCompositeEgressService
|
||||||
|
|
||||||
|
|
||||||
|
def make_config():
|
||||||
|
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
|
||||||
|
config = Mock()
|
||||||
|
config.bucket_args = {
|
||||||
|
"endpoint": "https://s3.test.com",
|
||||||
|
"access_key": "test_key",
|
||||||
|
"secret": "test_secret",
|
||||||
|
"region": "test-region",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"force_path_style": True,
|
||||||
|
}
|
||||||
|
config.encoding_options = None
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
"""Return a VideoCompositeEgressService with mocked handle_request."""
|
||||||
|
svc = VideoCompositeEgressService(make_config())
|
||||||
|
svc._handle_request = Mock()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_options_without_profile_omits_profile_fields():
|
||||||
|
"""A resolution-only config should resolve dimensions but no framerate/bitrate.
|
||||||
|
|
||||||
|
The profile-independent fields (audio bitrate, keyframe interval, codec /
|
||||||
|
frequency pins) are always present, matching the default encoding.
|
||||||
|
"""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
|
||||||
|
assert resolved == {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
}
|
||||||
|
assert "framerate" not in resolved
|
||||||
|
assert "video_bitrate" not in resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoding_config_requires_resolution():
|
||||||
|
"""A profile-only or empty encoding config should be rejected at validation."""
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig(profile="mixed")
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# --- _resolve_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encoding_options", [None, {}])
|
||||||
|
def test_resolve_options_returns_none_when_empty(service, encoding_options):
|
||||||
|
"""Resolver should return None when the resolved dict is empty or missing."""
|
||||||
|
assert service._resolve_encoding_options(encoding_options) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resolution",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"profile",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
|
||||||
|
)
|
||||||
|
def test_resolve_profile_resolution_combinations(service, profile, resolution):
|
||||||
|
"""Every (profile, resolution) pair should resolve to the values from settings."""
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||||
|
expected_width = resolution_config["width"]
|
||||||
|
expected_height = resolution_config["height"]
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
expected_fps = profile_config["fps"]
|
||||||
|
expected_bitrate = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
resolved = build_encoding_options(resolution, profile)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == expected_width
|
||||||
|
assert result.height == expected_height
|
||||||
|
assert result.framerate == expected_fps
|
||||||
|
assert result.video_bitrate == expected_bitrate
|
||||||
|
# Profile-independent fields match the default encoding, never dropped.
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
|
assert result.audio_frequency == 48000
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_options_none_profile_uses_livekit_defaults(service):
|
||||||
|
"""Missing profile should pass 0 fps/bitrate (LiveKit protobuf default).
|
||||||
|
|
||||||
|
The pinned codec / audio fields are still applied even without a profile.
|
||||||
|
"""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
assert result.width == 1280
|
||||||
|
assert result.height == 720
|
||||||
|
assert result.framerate == 0
|
||||||
|
assert result.video_bitrate == 0
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
@@ -40,6 +40,16 @@ def test_settings():
|
|||||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
"AWS_S3_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"},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def test_start_recording_success(
|
|||||||
# 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
|
||||||
@@ -66,6 +66,38 @@ def test_start_recording_success(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
def test_start_recording_passes_resolved_encoding(
|
||||||
|
mock_update_room_metadata, mediator, mock_worker_service
|
||||||
|
):
|
||||||
|
"""The resolved encoding persisted in recording.options reaches the worker."""
|
||||||
|
mock_worker_service.start.return_value = "test-worker-123"
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
}
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.INITIATED,
|
||||||
|
worker_id=None,
|
||||||
|
options={
|
||||||
|
"encoding": {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": resolved,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "unregistered-room",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "unregistered-room",
|
"room": "unregistered-room",
|
||||||
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "reunion",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "reunion",
|
"room": "reunion",
|
||||||
|
|||||||
@@ -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,194 @@ 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_forwards_resolved_encoding_to_worker(
|
||||||
|
settings, mock_worker_service, mock_worker_service_factory
|
||||||
|
):
|
||||||
|
"""The resolved encoding should passed on to the worker."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
mock_worker_service.start.return_value = "egress-123"
|
||||||
|
|
||||||
|
with mock.patch("core.utils.update_room_metadata"):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {
|
||||||
|
"encoding": {"resolution": "720p", "profile": "talking_heads"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(room.id),
|
||||||
|
recording.id,
|
||||||
|
encoding_options=recording.options["encoding"]["resolved"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_invalid_resolution(settings):
|
||||||
|
"""Should reject invalid encoding resolution values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_unknown_key_rejected(settings):
|
||||||
|
"""Should reject unknown keys in encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"bitrate": 9000}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_without_encoding_unchanged(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Requests without encoding should keep existing options behavior."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": "fr"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"language": "fr"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["foo", 12])
|
@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."""
|
||||||
|
|||||||
@@ -2,13 +2,109 @@
|
|||||||
Test utils functions
|
Test utils functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
from core.factories import UserFactory
|
||||||
|
from core.utils import (
|
||||||
|
NotificationError,
|
||||||
|
create_livekit_client,
|
||||||
|
generate_token,
|
||||||
|
notify_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode a LiveKit JWT access token for inspection."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_uses_full_name():
|
||||||
|
"""The token's display name should default to the user's full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
assert claims["sub"] == str(user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_fallback_user_representation():
|
||||||
|
"""
|
||||||
|
When the user has no full name, the token's display name should fall back
|
||||||
|
to the user's string representation.
|
||||||
|
"""
|
||||||
|
user = UserFactory(full_name=None)
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == str(user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_explicit_username_overrides_default():
|
||||||
|
"""An explicitly provided username should take precedence over the full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||||
|
"""With editing disabled, an authenticated user's username is ignored."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||||
|
"""Disabling editing doesn't disturb the default full-name path."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_uses_username_when_provided():
|
||||||
|
"""An anonymous user's provided username is used as the display name."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||||
|
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_falls_back_to_anonymous_label():
|
||||||
|
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser())
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Anonymous"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("asyncio.get_running_loop")
|
@mock.patch("asyncio.get_running_loop")
|
||||||
|
|||||||
@@ -109,11 +109,16 @@ def generate_token(
|
|||||||
default_username = "Anonymous"
|
default_username = "Anonymous"
|
||||||
else:
|
else:
|
||||||
identity = str(user.sub)
|
identity = str(user.sub)
|
||||||
default_username = str(user)
|
default_username = user.full_name or str(user)
|
||||||
|
|
||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
color = generate_color(identity)
|
||||||
|
|
||||||
|
can_edit = (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||||
|
)
|
||||||
|
display_name = (username or default_username) if can_edit else default_username
|
||||||
|
|
||||||
token = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -121,7 +126,7 @@ def generate_token(
|
|||||||
)
|
)
|
||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(display_name)
|
||||||
.with_attributes(
|
.with_attributes(
|
||||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||||
)
|
)
|
||||||
|
|||||||
+137
-19
@@ -671,6 +671,11 @@ class Base(Configuration):
|
|||||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||||
|
True,
|
||||||
|
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Recording settings
|
# Recording settings
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
@@ -716,28 +721,74 @@ 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",
|
||||||
@@ -752,6 +803,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
|
||||||
)
|
)
|
||||||
@@ -1116,6 +1168,70 @@ class Base(Configuration):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _check_recording_encoding_maps(cls):
|
||||||
|
"""Ensure the per-recording encoding maps are mutually consistent.
|
||||||
|
|
||||||
|
Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must define a bitrate for
|
||||||
|
each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.
|
||||||
|
|
||||||
|
The default profile / resolution feed the default encoding. When either is
|
||||||
|
missing, no custom default encoding can be built: a warning is emitted and
|
||||||
|
recordings fall back to LiveKit's built-in preset. When both are set, they
|
||||||
|
must reference keys that actually exist in the maps above.
|
||||||
|
"""
|
||||||
|
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
# DictValue resolves to a dict at runtime; pylint sees the descriptor.
|
||||||
|
for (
|
||||||
|
profile,
|
||||||
|
profile_config,
|
||||||
|
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
|
||||||
|
profile_resolutions = set(profile_config["kbps"])
|
||||||
|
if profile_resolutions != resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
|
||||||
|
"define a bitrate for exactly the resolutions in "
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
|
||||||
|
f"{resolutions ^ profile_resolutions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
warnings.warn(
|
||||||
|
f"{' and '.join(missing)} not set; recordings will use LiveKit's "
|
||||||
|
"built-in encoding preset instead of a custom default encoding.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if cls.RECORDING_ENCODING_DEFAULT_RESOLUTION not in resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION "
|
||||||
|
f"'{cls.RECORDING_ENCODING_DEFAULT_RESOLUTION}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS ({sorted(resolutions)})."
|
||||||
|
)
|
||||||
|
if cls.RECORDING_ENCODING_DEFAULT_PROFILE not in profiles:
|
||||||
|
raise ValueError(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE "
|
||||||
|
f"'{cls.RECORDING_ENCODING_DEFAULT_PROFILE}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_PROFILES ({sorted(profiles)})."
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def post_setup(cls):
|
def post_setup(cls):
|
||||||
"""Post setup configuration.
|
"""Post setup configuration.
|
||||||
@@ -1129,6 +1245,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
|
||||||
|
|||||||
Generated
+214
-35
@@ -15,6 +15,7 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
@@ -29,13 +30,13 @@
|
|||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.2",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.387.0",
|
"posthog-js": "1.391.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -110,6 +112,62 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-aria": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@internationalized/number": "^3.6.7",
|
||||||
|
"@internationalized/string": "^3.2.9",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"aria-hidden": "^1.2.3",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"react-stately": "3.47.0",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-aria-components": {
|
||||||
|
"version": "1.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
||||||
|
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"client-only": "^0.0.1",
|
||||||
|
"react-aria": "3.49.0",
|
||||||
|
"react-stately": "3.47.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-stately": {
|
||||||
|
"version": "3.47.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
||||||
|
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@internationalized/number": "^3.6.7",
|
||||||
|
"@internationalized/string": "^3.2.9",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1752,9 +1810,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.35.0",
|
"version": "3.36.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
|
||||||
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
|
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -3192,6 +3250,19 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/binary-extensions": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
||||||
@@ -7094,6 +7165,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-binary-path": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"binary-extensions": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-boolean-object": {
|
"node_modules/is-boolean-object": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
||||||
@@ -8675,6 +8759,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-map": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||||
@@ -9033,19 +9130,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.387.0",
|
"version": "1.391.2",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.387.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
|
||||||
"integrity": "sha512-Pv1jUMySMN62zoAxdJBJPV8n62lkHdjuWhpeU7izczc5Dqbx3hhqO2hkrNTI8Yx1ezmWk2qUHZs03FuOBubdFQ==",
|
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.33.0",
|
"@posthog/core": "^1.35.3",
|
||||||
"@posthog/types": "^1.387.0",
|
"@posthog/types": "^1.390.2",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.28.2",
|
"preact": "^10.29.2",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.1.0"
|
"web-vitals": "^5.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
@@ -9062,13 +9159,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.28.3",
|
"version": "10.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact-render-to-string": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
@@ -9275,19 +9380,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria": {
|
"node_modules/react-aria": {
|
||||||
"version": "3.49.0",
|
"version": "3.50.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
|
||||||
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@internationalized/number": "^3.6.7",
|
"@internationalized/number": "^3.6.7",
|
||||||
"@internationalized/string": "^3.2.9",
|
"@internationalized/string": "^3.2.9",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"aria-hidden": "^1.2.3",
|
"aria-hidden": "^1.2.3",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -9296,17 +9401,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria-components": {
|
"node_modules/react-aria-components": {
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
|
||||||
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-stately": "3.47.0"
|
"react-stately": "3.48.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
@@ -9360,15 +9465,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-stately": {
|
"node_modules/react-stately": {
|
||||||
"version": "3.47.0",
|
"version": "3.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
|
||||||
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@internationalized/number": "^3.6.7",
|
"@internationalized/number": "^3.6.7",
|
||||||
"@internationalized/string": "^3.2.9",
|
"@internationalized/string": "^3.2.9",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
@@ -11137,6 +11242,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sapphi-red"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anymatch": "~3.1.2",
|
||||||
|
"braces": "~3.0.2",
|
||||||
|
"glob-parent": "~5.1.2",
|
||||||
|
"is-binary-path": "~2.1.0",
|
||||||
|
"is-glob": "~4.0.1",
|
||||||
|
"normalize-path": "~3.0.0",
|
||||||
|
"readdirp": "~3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-plugin-svgr": {
|
"node_modules/vite-plugin-svgr": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
||||||
@@ -11470,9 +11649,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "5.1.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
|
||||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
|
|||||||
@@ -17,11 +17,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
@@ -36,13 +37,13 @@
|
|||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.2",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.387.0",
|
"posthog-js": "1.391.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -58,6 +58,7 @@ export interface ApiConfig {
|
|||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
max_participants_for_sound: number
|
max_participants_for_sound: number
|
||||||
auto_mute_on_join_threshold: number
|
auto_mute_on_join_threshold: number
|
||||||
|
authenticated_users_can_edit_display_name: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { loadUserChoices } from '@livekit/components-core'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const CreateMeetingMenu = () => {
|
export const CreateMeetingMenu = () => {
|
||||||
const { username } = loadUserChoices()
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { FocusLayout } from '@/features/rooms/livekit/components/FocusLayout'
|
|
||||||
import { SecondaryScreenShareStrip } from './SecondaryScreenShareStrip'
|
|
||||||
|
|
||||||
export interface FocusAreaProps {
|
|
||||||
focusTrack: TrackReferenceOrPlaceholder
|
|
||||||
secondaryScreenShareTracks: TrackReferenceOrPlaceholder[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main focus track with an optional strip of secondary screen shares below.
|
|
||||||
*/
|
|
||||||
export function FocusArea({
|
|
||||||
focusTrack,
|
|
||||||
secondaryScreenShareTracks,
|
|
||||||
}: FocusAreaProps) {
|
|
||||||
return (
|
|
||||||
<FocusColumn>
|
|
||||||
<MainFocusSlot>
|
|
||||||
<FocusLayout trackRef={focusTrack} />
|
|
||||||
</MainFocusSlot>
|
|
||||||
<SecondaryScreenShareStrip tracks={secondaryScreenShareTracks} />
|
|
||||||
</FocusColumn>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FocusColumn = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const MainFocusSlot = styled('div', {
|
|
||||||
base: {
|
|
||||||
position: 'relative',
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: 'hidden',
|
|
||||||
'& .lk-participant-tile': {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
'& .lk-participant-media-video': {
|
|
||||||
objectFit: 'contain',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { memo } from 'react'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
|
||||||
import { getTrackKey } from '@/features/pip/utils/pipTrackSelection'
|
|
||||||
|
|
||||||
export interface SecondaryScreenShareStripProps {
|
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact horizontal strip for non-focused screen shares.
|
|
||||||
* Each tile keeps pin controls so users can switch focus to any share.
|
|
||||||
*/
|
|
||||||
export const SecondaryScreenShareStrip = memo(
|
|
||||||
({ tracks }: SecondaryScreenShareStripProps) => {
|
|
||||||
if (tracks.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StripContainer>
|
|
||||||
{tracks.map((track) => (
|
|
||||||
<ShareTile key={getTrackKey(track)}>
|
|
||||||
<ParticipantTile trackRef={track} />
|
|
||||||
</ShareTile>
|
|
||||||
))}
|
|
||||||
</StripContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SecondaryScreenShareStrip.displayName = 'SecondaryScreenShareStrip'
|
|
||||||
|
|
||||||
const StripContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: '0.5rem',
|
|
||||||
flexShrink: 0,
|
|
||||||
height: '120px',
|
|
||||||
minHeight: '80px',
|
|
||||||
maxHeight: '140px',
|
|
||||||
padding: '0.5rem 0 0',
|
|
||||||
overflowX: 'auto',
|
|
||||||
overflowY: 'hidden',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ShareTile = styled('div', {
|
|
||||||
base: {
|
|
||||||
position: 'relative',
|
|
||||||
flex: '0 0 auto',
|
|
||||||
height: '100%',
|
|
||||||
aspectRatio: '16 / 9',
|
|
||||||
minWidth: 0,
|
|
||||||
borderRadius: '8px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: 'primaryDark.100',
|
|
||||||
'& .lk-participant-tile': {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
'& .lk-participant-media-video': {
|
|
||||||
objectFit: 'contain',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import {
|
|
||||||
isEqualTrackRef,
|
|
||||||
isTrackReference,
|
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
|
|
||||||
export function getSubscribedScreenShareTracks(
|
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
|
||||||
): TrackReferenceOrPlaceholder[] {
|
|
||||||
return tracks
|
|
||||||
.filter(isTrackReference)
|
|
||||||
.filter(
|
|
||||||
(track) =>
|
|
||||||
track.publication.source === Track.Source.ScreenShare &&
|
|
||||||
track.publication.isSubscribed
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNewScreenShareTracks(
|
|
||||||
screenShareTracks: TrackReferenceOrPlaceholder[],
|
|
||||||
knownTrackSids: Set<string>
|
|
||||||
): TrackReferenceOrPlaceholder[] {
|
|
||||||
return screenShareTracks.filter(
|
|
||||||
(track) =>
|
|
||||||
track.publication.trackSid &&
|
|
||||||
!knownTrackSids.has(track.publication.trackSid)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncKnownScreenShareSids(
|
|
||||||
screenShareTracks: TrackReferenceOrPlaceholder[],
|
|
||||||
knownTrackSids: Set<string>
|
|
||||||
): void {
|
|
||||||
const currentSids = new Set(
|
|
||||||
screenShareTracks
|
|
||||||
.map((track) => track.publication.trackSid)
|
|
||||||
.filter((sid): sid is string => !!sid)
|
|
||||||
)
|
|
||||||
|
|
||||||
screenShareTracks.forEach((track) => {
|
|
||||||
if (track.publication.trackSid) {
|
|
||||||
knownTrackSids.add(track.publication.trackSid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
knownTrackSids.forEach((sid) => {
|
|
||||||
if (!currentSids.has(sid)) {
|
|
||||||
knownTrackSids.delete(sid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNewestScreenShareTrack(
|
|
||||||
screenShareTracks: TrackReferenceOrPlaceholder[]
|
|
||||||
): TrackReferenceOrPlaceholder | undefined {
|
|
||||||
if (screenShareTracks.length === 0) return undefined
|
|
||||||
return screenShareTracks[screenShareTracks.length - 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitTracksForFocusLayout(
|
|
||||||
tracks: TrackReferenceOrPlaceholder[],
|
|
||||||
focusTrack: TrackReferenceOrPlaceholder | undefined,
|
|
||||||
screenShareTracks: TrackReferenceOrPlaceholder[]
|
|
||||||
): {
|
|
||||||
carouselTracks: TrackReferenceOrPlaceholder[]
|
|
||||||
secondaryScreenShareTracks: TrackReferenceOrPlaceholder[]
|
|
||||||
} {
|
|
||||||
const hasActiveScreenShares = screenShareTracks.length > 0
|
|
||||||
|
|
||||||
const secondaryScreenShareTracks = screenShareTracks.filter(
|
|
||||||
(track) => !focusTrack || !isEqualTrackRef(track, focusTrack)
|
|
||||||
)
|
|
||||||
|
|
||||||
const carouselTracks = tracks.filter((track) => {
|
|
||||||
if (focusTrack && isEqualTrackRef(track, focusTrack)) return false
|
|
||||||
if (hasActiveScreenShares && track.source === Track.Source.ScreenShare) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return { carouselTracks, secondaryScreenShareTracks }
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ export type ApiRoom = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
pin_code: string
|
pin_code?: string
|
||||||
is_administrable: boolean
|
is_administrable: boolean
|
||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
|
|||||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -53,6 +54,8 @@ export const Conference = ({
|
|||||||
userChoices: LocalUserChoices
|
userChoices: LocalUserChoices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posthog.capture('visit-room', { slug: roomId })
|
posthog.capture('visit-room', { slug: roomId })
|
||||||
}, [roomId, posthog])
|
}, [roomId, posthog])
|
||||||
@@ -83,10 +86,10 @@ export const Conference = ({
|
|||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchRoom({
|
fetchRoom({
|
||||||
roomId: roomId as string,
|
roomId: roomId as string,
|
||||||
username: userConfig.username,
|
username: username,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (error.statusCode == '404') {
|
if (error.statusCode == '404') {
|
||||||
createRoom({ slug: roomId, username: userConfig.username })
|
createRoom({ slug: roomId, username })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -42,14 +42,17 @@ import {
|
|||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
saveAudioOutputDeviceId,
|
saveAudioOutputDeviceId,
|
||||||
saveUsername,
|
|
||||||
saveVideoInputDeviceId,
|
saveVideoInputDeviceId,
|
||||||
saveVideoInputEnabled,
|
saveVideoInputEnabled,
|
||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
|
|
||||||
|
import { saveUsername, userStore } from '@/stores/user'
|
||||||
|
|
||||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
const onError = (e: Error) => console.error('ERROR', e)
|
const onError = (e: Error) => console.error('ERROR', e)
|
||||||
|
|
||||||
@@ -114,6 +117,9 @@ export const Join = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
|
|
||||||
|
const { data: configData } = useConfig()
|
||||||
|
const { isLoggedIn, user } = useUser()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
@@ -121,9 +127,10 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
username,
|
|
||||||
} = useSnapshot(userChoicesStore)
|
} = useSnapshot(userChoicesStore)
|
||||||
|
|
||||||
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||||
|
|
||||||
if (initialUserChoices.current === null) {
|
if (initialUserChoices.current === null) {
|
||||||
@@ -134,7 +141,6 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
username,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,20 +454,23 @@ export const Join = ({
|
|||||||
<H lvl={1} margin="sm" centered>
|
<H lvl={1} margin="sm" centered>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
<Field
|
{(!isLoggedIn ||
|
||||||
type="text"
|
configData?.authenticated_users_can_edit_display_name) && (
|
||||||
onChange={saveUsername}
|
<Field
|
||||||
label={t('usernameLabel')}
|
type="text"
|
||||||
id="input-name"
|
onChange={saveUsername}
|
||||||
defaultValue={username}
|
label={t('usernameLabel')}
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
id="input-name"
|
||||||
wrapperProps={{
|
defaultValue={username || user?.full_name}
|
||||||
noMargin: true,
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
fullWidth: true,
|
wrapperProps={{
|
||||||
}}
|
noMargin: true,
|
||||||
autoComplete="name"
|
fullWidth: true,
|
||||||
maxLength={50}
|
}}
|
||||||
/>
|
autoComplete="name"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { Button, Div } from '@/primitives'
|
|||||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { ReactNode } from 'react'
|
import React, { ReactNode, useCallback, useRef } from 'react'
|
||||||
import { Chat } from '../prefabs/Chat'
|
import { Chat } from '../prefabs/Chat'
|
||||||
import { Effects } from './effects/Effects'
|
import { Effects } from './effects/Effects'
|
||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
@@ -15,6 +15,7 @@ import { Tools } from './Tools'
|
|||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||||
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -29,103 +30,115 @@ type StyledSidePanelProps = {
|
|||||||
isReactionToolbarOpen?: boolean
|
isReactionToolbarOpen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
||||||
title,
|
(
|
||||||
ariaLabel,
|
{
|
||||||
children,
|
title,
|
||||||
onClose,
|
ariaLabel,
|
||||||
isClosed,
|
children,
|
||||||
isReactionToolbarOpen,
|
onClose,
|
||||||
closeButtonTooltip,
|
isClosed,
|
||||||
isSubmenu = false,
|
isReactionToolbarOpen,
|
||||||
onBack,
|
closeButtonTooltip,
|
||||||
backButtonLabel,
|
isSubmenu = false,
|
||||||
}: StyledSidePanelProps) => (
|
onBack,
|
||||||
<aside
|
backButtonLabel,
|
||||||
className={css({
|
},
|
||||||
borderWidth: '1px',
|
ref
|
||||||
borderStyle: 'solid',
|
) => (
|
||||||
borderColor: 'box.border',
|
<aside
|
||||||
backgroundColor: 'box.bg',
|
ref={ref}
|
||||||
color: 'box.text',
|
tabIndex={-1}
|
||||||
borderRadius: 8,
|
className={css({
|
||||||
flex: 1,
|
borderWidth: '1px',
|
||||||
position: 'absolute',
|
borderStyle: 'solid',
|
||||||
overflow: 'hidden',
|
borderColor: 'box.border',
|
||||||
display: 'flex',
|
backgroundColor: 'box.bg',
|
||||||
flexDirection: 'column',
|
color: 'box.text',
|
||||||
margin: 'var(--sizes-room-side-panel-margin)',
|
borderRadius: 8,
|
||||||
marginLeft: 0,
|
flex: 1,
|
||||||
marginBottom: 0,
|
position: 'absolute',
|
||||||
padding: 0,
|
overflow: 'hidden',
|
||||||
gap: 0,
|
display: 'flex',
|
||||||
right: 0,
|
flexDirection: 'column',
|
||||||
top: 0,
|
margin: 'var(--sizes-room-side-panel-margin)',
|
||||||
width: 'var(--sizes-room-side-panel)',
|
marginLeft: 0,
|
||||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
marginBottom: 0,
|
||||||
})}
|
padding: 0,
|
||||||
style={{
|
gap: 0,
|
||||||
transform: isClosed
|
right: 0,
|
||||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
top: 0,
|
||||||
: 'none',
|
width: 'var(--sizes-room-side-panel)',
|
||||||
bottom: isReactionToolbarOpen
|
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||||
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
'&:focus': {
|
||||||
: 'var(--sizes-room-control-bar)',
|
outline: 'none',
|
||||||
}}
|
},
|
||||||
aria-hidden={isClosed}
|
})}
|
||||||
aria-label={ariaLabel}
|
style={{
|
||||||
>
|
transform: isClosed
|
||||||
<HStack alignItems="center">
|
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
||||||
{isSubmenu && (
|
: 'none',
|
||||||
<Button
|
bottom: isReactionToolbarOpen
|
||||||
variant="secondaryText"
|
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
||||||
size="sm"
|
: 'var(--sizes-room-control-bar)',
|
||||||
square
|
}}
|
||||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
aria-hidden={isClosed}
|
||||||
aria-label={backButtonLabel}
|
aria-label={ariaLabel}
|
||||||
onPress={onBack}
|
>
|
||||||
|
<HStack alignItems="center">
|
||||||
|
{isSubmenu && (
|
||||||
|
<Button
|
||||||
|
variant="secondaryText"
|
||||||
|
size="sm"
|
||||||
|
square
|
||||||
|
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||||
|
aria-label={backButtonLabel}
|
||||||
|
onPress={onBack}
|
||||||
|
>
|
||||||
|
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Heading
|
||||||
|
slot="title"
|
||||||
|
level={1}
|
||||||
|
className={text({ variant: 'h2' })}
|
||||||
|
style={{
|
||||||
|
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
display: isClosed ? 'none' : 'flex',
|
||||||
|
justifyContent: 'start',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
{title}
|
||||||
</Button>
|
</Heading>
|
||||||
)}
|
</HStack>
|
||||||
<Heading
|
<Div
|
||||||
slot="title"
|
position="absolute"
|
||||||
level={1}
|
top="5"
|
||||||
className={text({ variant: 'h2' })}
|
right="5"
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
display: isClosed ? 'none' : undefined,
|
||||||
paddingTop: '1rem',
|
|
||||||
display: isClosed ? 'none' : 'flex',
|
|
||||||
justifyContent: 'start',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
<Button
|
||||||
</Heading>
|
invisible
|
||||||
</HStack>
|
variant="tertiaryText"
|
||||||
<Div
|
size="xs"
|
||||||
position="absolute"
|
onPress={onClose}
|
||||||
top="5"
|
aria-label={closeButtonTooltip}
|
||||||
right="5"
|
tooltip={closeButtonTooltip}
|
||||||
style={{
|
>
|
||||||
display: isClosed ? 'none' : undefined,
|
<RiCloseLine />
|
||||||
}}
|
</Button>
|
||||||
>
|
</Div>
|
||||||
<Button
|
{children}
|
||||||
invisible
|
</aside>
|
||||||
variant="tertiaryText"
|
)
|
||||||
size="xs"
|
|
||||||
onPress={onClose}
|
|
||||||
aria-label={closeButtonTooltip}
|
|
||||||
tooltip={closeButtonTooltip}
|
|
||||||
>
|
|
||||||
<RiCloseLine />
|
|
||||||
</Button>
|
|
||||||
</Div>
|
|
||||||
{children}
|
|
||||||
</aside>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StyledSidePanel.displayName = 'StyledSidePanel'
|
||||||
|
|
||||||
type PanelProps = {
|
type PanelProps = {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -162,8 +175,28 @@ export const SidePanel = () => {
|
|||||||
|
|
||||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||||
|
|
||||||
|
const asideRef = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
|
const focusAside = useCallback(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
asideRef.current?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePanelOpened = useCallback(() => {
|
||||||
|
if (activePanelId === PanelId.CHAT) return
|
||||||
|
focusAside()
|
||||||
|
}, [activePanelId, focusAside])
|
||||||
|
|
||||||
|
useRestoreFocus(isSidePanelOpen, {
|
||||||
|
onOpened: handlePanelOpened,
|
||||||
|
preventScroll: true,
|
||||||
|
activeKey: activePanelId,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
|
ref={asideRef}
|
||||||
title={title}
|
title={title}
|
||||||
ariaLabel={t('ariaLabel', { title })}
|
ariaLabel={t('ariaLabel', { title })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -95,8 +95,13 @@ const ToolButton = ({
|
|||||||
|
|
||||||
export const Tools = () => {
|
export const Tools = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
const {
|
||||||
useSidePanel()
|
openTranscript,
|
||||||
|
openScreenRecording,
|
||||||
|
activeSubPanelId,
|
||||||
|
isToolsOpen,
|
||||||
|
isSidePanelOpen,
|
||||||
|
} = useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||||
|
|
||||||
// Restore focus to the element that opened the Tools panel
|
// Restore focus to the element that opened the Tools panel
|
||||||
@@ -113,6 +118,7 @@ export const Tools = () => {
|
|||||||
},
|
},
|
||||||
restoreFocusRaf: true,
|
restoreFocusRaf: true,
|
||||||
preventScroll: true,
|
preventScroll: true,
|
||||||
|
shouldRestoreOnClose: () => !isSidePanelOpen,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||||
|
|||||||
+4
-5
@@ -13,8 +13,10 @@ import {
|
|||||||
} from './TimerWorker'
|
} from './TimerWorker'
|
||||||
import {
|
import {
|
||||||
BackgroundProcessorInterface,
|
BackgroundProcessorInterface,
|
||||||
|
SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
type ProcessorConfig,
|
type ProcessorConfig,
|
||||||
type ProcessorType,
|
type ProcessorType,
|
||||||
|
MEDIAPIPE_PATH_WASM,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256
|
const PROCESSING_WIDTH = 256
|
||||||
@@ -153,13 +155,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initSegmenter() {
|
async initSegmenter() {
|
||||||
const vision = await FilesetResolver.forVisionTasks(
|
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
||||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
|
||||||
)
|
|
||||||
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
||||||
baseOptions: {
|
baseOptions: {
|
||||||
modelAssetPath:
|
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
|
|
||||||
delegate: 'CPU', // Use CPU for Firefox.
|
delegate: 'CPU', // Use CPU for Firefox.
|
||||||
},
|
},
|
||||||
runningMode: 'VIDEO',
|
runningMode: 'VIDEO',
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
TIMEOUT_TICK,
|
TIMEOUT_TICK,
|
||||||
timerWorkerScript,
|
timerWorkerScript,
|
||||||
} from './TimerWorker'
|
} from './TimerWorker'
|
||||||
import { ProcessorType } from '.'
|
import {
|
||||||
|
FACE_LANDMARKS_MODEL_PATH,
|
||||||
|
ProcessorType,
|
||||||
|
MEDIAPIPE_PATH_WASM,
|
||||||
|
} from '.'
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256 * 3
|
const PROCESSING_WIDTH = 256 * 3
|
||||||
const PROCESSING_HEIGHT = 144 * 3
|
const PROCESSING_HEIGHT = 144 * 3
|
||||||
@@ -128,13 +132,10 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initFaceLandmarker() {
|
async initFaceLandmarker() {
|
||||||
const vision = await FilesetResolver.forVisionTasks(
|
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
||||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
|
||||||
)
|
|
||||||
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
||||||
baseOptions: {
|
baseOptions: {
|
||||||
modelAssetPath:
|
modelAssetPath: FACE_LANDMARKS_MODEL_PATH,
|
||||||
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
|
|
||||||
delegate: 'GPU',
|
delegate: 'GPU',
|
||||||
},
|
},
|
||||||
runningMode: 'VIDEO',
|
runningMode: 'VIDEO',
|
||||||
|
|||||||
+19
-4
@@ -1,13 +1,14 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
BackgroundBlur,
|
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
VirtualBackground,
|
BackgroundProcessor,
|
||||||
} from '@livekit/track-processors'
|
} from '@livekit/track-processors'
|
||||||
import {
|
import {
|
||||||
type ProcessorConfig,
|
type ProcessorConfig,
|
||||||
BackgroundProcessorInterface,
|
BackgroundProcessorInterface,
|
||||||
ProcessorType,
|
ProcessorType,
|
||||||
|
MEDIAPIPE_PATH_WASM,
|
||||||
|
SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||||
@@ -20,10 +21,24 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
|
|
||||||
if (opts.type === 'virtual') {
|
if (opts.type === 'virtual') {
|
||||||
this.processorType = ProcessorType.VIRTUAL
|
this.processorType = ProcessorType.VIRTUAL
|
||||||
this.processor = VirtualBackground(opts.imagePath)
|
this.processor = BackgroundProcessor({
|
||||||
|
mode: 'virtual-background',
|
||||||
|
imagePath: opts.imagePath,
|
||||||
|
assetPaths: {
|
||||||
|
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
||||||
|
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
|
},
|
||||||
|
})
|
||||||
} else if (opts.type === 'blur') {
|
} else if (opts.type === 'blur') {
|
||||||
this.processorType = ProcessorType.BLUR
|
this.processorType = ProcessorType.BLUR
|
||||||
this.processor = BackgroundBlur(opts.blurRadius)
|
this.processor = BackgroundProcessor({
|
||||||
|
mode: 'background-blur',
|
||||||
|
blurRadius: opts.blurRadius,
|
||||||
|
assetPaths: {
|
||||||
|
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
||||||
|
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||||
|
},
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Must provide either imagePath for virtual background or blurRadius for blur'
|
'Must provide either imagePath for virtual background or blurRadius for blur'
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
|||||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||||
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||||
|
|
||||||
|
export const SELFIE_SEGMENTER_MODEL_PATH =
|
||||||
|
'/assets/mediapipe/models/selfie_segmenter_landscape.tflite'
|
||||||
|
|
||||||
|
export const FACE_LANDMARKS_MODEL_PATH =
|
||||||
|
'/assets/mediapipe/models/face_landmarker.task'
|
||||||
|
|
||||||
|
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||||
|
|
||||||
export enum ProcessorType {
|
export enum ProcessorType {
|
||||||
BLUR = 'blur',
|
BLUR = 'blur',
|
||||||
VIRTUAL = 'virtual',
|
VIRTUAL = 'virtual',
|
||||||
|
|||||||
@@ -1,52 +1,21 @@
|
|||||||
import {
|
import { useFocusToggle } from '@livekit/components-react'
|
||||||
isTrackReferencePinned,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import { useFocusToggle, useMaybeLayoutContext } from '@livekit/components-react'
|
|
||||||
import { type Participant, Track } from 'livekit-client'
|
import { type Participant, Track } from 'livekit-client'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import type { MouseEvent } from 'react'
|
import type { MouseEvent } from 'react'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
|
|
||||||
const getParticipantPinTrackRef = (participant: Participant) => {
|
|
||||||
const screenSharePublication = participant.getTrackPublication(
|
|
||||||
Source.ScreenShare
|
|
||||||
)
|
|
||||||
const source =
|
|
||||||
participant.isScreenShareEnabled && screenSharePublication
|
|
||||||
? Source.ScreenShare
|
|
||||||
: Source.Camera
|
|
||||||
|
|
||||||
return {
|
|
||||||
participant,
|
|
||||||
publication: participant.getTrackPublication(source),
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFocusToggleParticipant = (participant: Participant) => {
|
export const useFocusToggleParticipant = (participant: Participant) => {
|
||||||
const layoutContext = useMaybeLayoutContext()
|
const trackRef = {
|
||||||
const trackRef = getParticipantPinTrackRef(participant)
|
participant: participant,
|
||||||
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
source: Source.Camera,
|
||||||
|
}
|
||||||
|
|
||||||
const { mergedProps, inFocus: isFocusedTrack } = useFocusToggle({
|
const { mergedProps, inFocus } = useFocusToggle({
|
||||||
trackRef,
|
trackRef,
|
||||||
props: {},
|
props: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const inFocus =
|
|
||||||
isFocusedTrack ||
|
|
||||||
(!!layoutContext?.pin.state &&
|
|
||||||
[Source.Camera, Source.ScreenShare].some((source) => {
|
|
||||||
const ref = {
|
|
||||||
participant,
|
|
||||||
publication: participant.getTrackPublication(source),
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
!!ref.publication &&
|
|
||||||
isTrackReferencePinned(ref, layoutContext.pin.state)
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
const syntheticEvent = {
|
const syntheticEvent = {
|
||||||
preventDefault: () => {},
|
preventDefault: () => {},
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function Chat({ ...props }: ChatProps) {
|
|||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { send, chatMessages, isSending } = useChat()
|
const { send, chatMessages, isSending } = useChat()
|
||||||
|
|
||||||
const { isChatOpen } = useSidePanel()
|
const { isChatOpen, isSidePanelOpen } = useSidePanel()
|
||||||
const chatSnap = useSnapshot(chatStore)
|
const chatSnap = useSnapshot(chatStore)
|
||||||
|
|
||||||
// Keep track of the element that opened the chat so we can restore focus
|
// Keep track of the element that opened the chat so we can restore focus
|
||||||
@@ -51,6 +51,7 @@ export function Chat({ ...props }: ChatProps) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
preventScroll: true,
|
preventScroll: true,
|
||||||
|
shouldRestoreOnClose: () => !isSidePanelOpen,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { ControlBar } from './ControlBar/ControlBar'
|
import { ControlBar } from './ControlBar/ControlBar'
|
||||||
|
import { FocusLayout } from '../components/FocusLayout'
|
||||||
import { ParticipantTile } from '../components/ParticipantTile'
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
import { SidePanel } from '../components/SidePanel'
|
import { SidePanel } from '../components/SidePanel'
|
||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
@@ -38,16 +39,8 @@ import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||||
import { FocusArea } from '@/features/layout/components/FocusArea'
|
|
||||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||||
import {
|
|
||||||
getNewScreenShareTracks,
|
|
||||||
getNewestScreenShareTrack,
|
|
||||||
getSubscribedScreenShareTracks,
|
|
||||||
splitTracksForFocusLayout,
|
|
||||||
syncKnownScreenShareSids,
|
|
||||||
} from '@/features/layout/utils/screenShareLayout'
|
|
||||||
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
|
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
|
||||||
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
|
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
|
||||||
|
|
||||||
@@ -80,7 +73,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
|||||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||||
const lastAutoFocusedScreenShareTrack =
|
const lastAutoFocusedScreenShareTrack =
|
||||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||||
const knownScreenShareTrackSids = useRef<Set<string>>(new Set())
|
|
||||||
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||||
const { t: tRooms } = useTranslation('rooms')
|
const { t: tRooms } = useTranslation('rooms')
|
||||||
@@ -122,11 +114,14 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
|
|
||||||
useNoiseReduction()
|
useNoiseReduction()
|
||||||
|
|
||||||
const screenShareTracks = getSubscribedScreenShareTracks(tracks)
|
const screenShareTracks = tracks
|
||||||
|
.filter(isTrackReference)
|
||||||
|
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||||
|
|
||||||
const focusTrack = usePinnedTracks(layoutContext)?.[0]
|
const focusTrack = usePinnedTracks(layoutContext)?.[0]
|
||||||
const { carouselTracks, secondaryScreenShareTracks } =
|
const carouselTracks = tracks.filter(
|
||||||
splitTracksForFocusLayout(tracks, focusTrack, screenShareTracks)
|
(track) => !isEqualTrackRef(track, focusTrack)
|
||||||
|
)
|
||||||
|
|
||||||
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
||||||
|
|
||||||
@@ -186,48 +181,31 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newScreenShares = getNewScreenShareTracks(
|
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||||
screenShareTracks,
|
if (
|
||||||
knownScreenShareTrackSids.current
|
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||||
)
|
lastAutoFocusedScreenShareTrack.current === null
|
||||||
syncKnownScreenShareSids(
|
|
||||||
screenShareTracks,
|
|
||||||
knownScreenShareTrackSids.current
|
|
||||||
)
|
|
||||||
|
|
||||||
if (newScreenShares.length > 0) {
|
|
||||||
const newestScreenShare = getNewestScreenShareTrack(newScreenShares)
|
|
||||||
if (newestScreenShare) {
|
|
||||||
log.debug('Auto set screen share focus:', {
|
|
||||||
newScreenShareTrack: newestScreenShare,
|
|
||||||
})
|
|
||||||
layoutContext.pin.dispatch?.({
|
|
||||||
msg: 'set_pin',
|
|
||||||
trackReference: newestScreenShare,
|
|
||||||
})
|
|
||||||
lastAutoFocusedScreenShareTrack.current = newestScreenShare
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
focusTrack?.source === Track.Source.ScreenShare &&
|
|
||||||
!screenShareTracks.some((track) => isEqualTrackRef(track, focusTrack))
|
|
||||||
) {
|
) {
|
||||||
const newestRemaining = getNewestScreenShareTrack(screenShareTracks)
|
log.debug('Auto set screen share focus:', {
|
||||||
if (newestRemaining) {
|
newScreenShareTrack: screenShareTracks[0],
|
||||||
log.debug('Auto switching to remaining screen share:', {
|
})
|
||||||
screenShareTrack: newestRemaining,
|
layoutContext.pin.dispatch?.({
|
||||||
})
|
msg: 'set_pin',
|
||||||
layoutContext.pin.dispatch?.({
|
trackReference: screenShareTracks[0],
|
||||||
msg: 'set_pin',
|
})
|
||||||
trackReference: newestRemaining,
|
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
||||||
})
|
} else if (
|
||||||
lastAutoFocusedScreenShareTrack.current = newestRemaining
|
lastAutoFocusedScreenShareTrack.current &&
|
||||||
} else {
|
!screenShareTracks.some(
|
||||||
log.debug('Auto clearing screen share focus.')
|
(track) =>
|
||||||
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
|
track.publication.trackSid ===
|
||||||
lastAutoFocusedScreenShareTrack.current = null
|
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||||
}
|
)
|
||||||
|
) {
|
||||||
|
log.debug('Auto clearing screen share focus.')
|
||||||
|
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
|
||||||
|
lastAutoFocusedScreenShareTrack.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (focusTrack && !isTrackReference(focusTrack)) {
|
if (focusTrack && !isTrackReference(focusTrack)) {
|
||||||
const updatedFocusTrack = tracks.find(
|
const updatedFocusTrack = tracks.find(
|
||||||
(tr) =>
|
(tr) =>
|
||||||
@@ -303,14 +281,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
>
|
>
|
||||||
<ParticipantTile />
|
<ParticipantTile />
|
||||||
</CarouselLayout>
|
</CarouselLayout>
|
||||||
{focusTrack && (
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||||
<FocusArea
|
|
||||||
focusTrack={focusTrack}
|
|
||||||
secondaryScreenShareTracks={
|
|
||||||
secondaryScreenShareTracks
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FocusLayoutContainer>
|
</FocusLayoutContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
|
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
|
||||||
import { saveUsername } from '@/stores/userChoices'
|
import { saveUsername } from '@/stores/user'
|
||||||
import { logout } from '@/features/auth/utils/logout'
|
import { logout } from '@/features/auth/utils/logout'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
Pick<TabPanelProps, 'id'>
|
Pick<TabPanelProps, 'id'>
|
||||||
@@ -17,6 +18,7 @@ export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
|||||||
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
const { data } = useConfig()
|
||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
|
|
||||||
const { renameParticipant } = useRenameParticipant()
|
const { renameParticipant } = useRenameParticipant()
|
||||||
@@ -45,15 +47,17 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
|||||||
return (
|
return (
|
||||||
<TabPanel padding={'md'} flex id={id}>
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
<H lvl={2}>{t('account.heading')}</H>
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
<Field
|
{(!isLoggedIn || data?.authenticated_users_can_edit_display_name) && (
|
||||||
type="text"
|
<Field
|
||||||
label={t('account.nameLabel')}
|
type="text"
|
||||||
value={name}
|
label={t('account.nameLabel')}
|
||||||
onChange={setName}
|
value={name}
|
||||||
validate={(value) => {
|
onChange={setName}
|
||||||
return !value ? <p>{t('account.nameError')}</p> : null
|
validate={(value) => {
|
||||||
}}
|
return !value ? <p>{t('account.nameError')}</p> : null
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<H lvl={2}>{t('account.authentication')}</H>
|
<H lvl={2}>{t('account.authentication')}</H>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export type RestoreFocusOptions = {
|
|||||||
onClosed?: () => void
|
onClosed?: () => void
|
||||||
restoreFocusRaf?: boolean
|
restoreFocusRaf?: boolean
|
||||||
preventScroll?: boolean
|
preventScroll?: boolean
|
||||||
|
/** When the panel stays open but its content changes, update the restore target. */
|
||||||
|
activeKey?: string | null
|
||||||
|
/** Return false to skip restoring focus on close (e.g. when switching to another panel). */
|
||||||
|
shouldRestoreOnClose?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,9 +26,12 @@ export function useRestoreFocus(
|
|||||||
onClosed,
|
onClosed,
|
||||||
restoreFocusRaf = false,
|
restoreFocusRaf = false,
|
||||||
preventScroll = true,
|
preventScroll = true,
|
||||||
|
activeKey,
|
||||||
|
shouldRestoreOnClose,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const prevIsOpenRef = useRef(false)
|
const prevIsOpenRef = useRef(false)
|
||||||
|
const prevActiveKeyRef = useRef(activeKey)
|
||||||
const triggerRef = useRef<HTMLElement | null>(null)
|
const triggerRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,25 +44,41 @@ export function useRestoreFocus(
|
|||||||
onOpened?.()
|
onOpened?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panel switched while staying open
|
||||||
|
if (wasOpen && isOpen && activeKey !== prevActiveKeyRef.current) {
|
||||||
|
const activeEl = document.activeElement as HTMLElement | null
|
||||||
|
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
|
||||||
|
onOpened?.()
|
||||||
|
}
|
||||||
|
|
||||||
// Just closed
|
// Just closed
|
||||||
if (wasOpen && !isOpen) {
|
if (wasOpen && !isOpen) {
|
||||||
const trigger = triggerRef.current
|
const trigger = triggerRef.current
|
||||||
if (trigger && document.contains(trigger)) {
|
const shouldRestore =
|
||||||
|
(shouldRestoreOnClose?.() ?? true) &&
|
||||||
|
trigger &&
|
||||||
|
document.contains(trigger)
|
||||||
|
|
||||||
|
if (shouldRestore) {
|
||||||
const focus = () => trigger.focus({ preventScroll })
|
const focus = () => trigger.focus({ preventScroll })
|
||||||
if (restoreFocusRaf) requestAnimationFrame(focus)
|
if (restoreFocusRaf) requestAnimationFrame(focus)
|
||||||
else focus()
|
else focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerRef.current = null
|
triggerRef.current = null
|
||||||
onClosed?.()
|
onClosed?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
prevIsOpenRef.current = isOpen
|
prevIsOpenRef.current = isOpen
|
||||||
|
prevActiveKeyRef.current = activeKey
|
||||||
}, [
|
}, [
|
||||||
isOpen,
|
isOpen,
|
||||||
|
activeKey,
|
||||||
onClosed,
|
onClosed,
|
||||||
onOpened,
|
onOpened,
|
||||||
preventScroll,
|
preventScroll,
|
||||||
resolveTrigger,
|
resolveTrigger,
|
||||||
restoreFocusRaf,
|
restoreFocusRaf,
|
||||||
|
shouldRestoreOnClose,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { proxy, subscribe } from 'valtio'
|
||||||
|
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE = {
|
||||||
|
username: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserState(): State {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.USER)
|
||||||
|
if (!stored) return DEFAULT_STATE
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(
|
||||||
|
'[UserPreferencesStore] Failed to parse stored settings:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return DEFAULT_STATE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userStore = proxy<State>(getUserState())
|
||||||
|
|
||||||
|
subscribe(userStore, () => {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userStore))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const saveUsername = (username: string) => {
|
||||||
|
userStore.username = username
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { VideoQuality } from 'livekit-client'
|
|||||||
|
|
||||||
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||||
|
|
||||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
||||||
processorConfig?: ProcessorConfig
|
processorConfig?: ProcessorConfig
|
||||||
noiseReductionEnabled?: boolean
|
noiseReductionEnabled?: boolean
|
||||||
audioOutputDeviceId?: string
|
audioOutputDeviceId?: string
|
||||||
@@ -32,7 +32,13 @@ function getUserChoicesState(): LocalUserChoices {
|
|||||||
|
|
||||||
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
||||||
subscribe(userChoicesStore, () => {
|
subscribe(userChoicesStore, () => {
|
||||||
saveUserChoices(userChoicesStore, false)
|
// TEMPORARY: cast needed because our store omits `username`, which we no
|
||||||
|
// longer persis in this store, while LiveKit's `saveUserChoices` still expects the full
|
||||||
|
// `LocalUserChoices` shape. `username` ends up `undefined` in the saved
|
||||||
|
// object, which `saveUserChoices` tolerates at runtime.
|
||||||
|
// We are migrating away from LiveKit's persistence logic to our own store
|
||||||
|
// handling for more control — this cast can be removed once that lands.
|
||||||
|
saveUserChoices(userChoicesStore as LocalUserChoicesLK, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// we run some logic on store loading to check if the processor config is still valid
|
// we run some logic on store loading to check if the processor config is still valid
|
||||||
@@ -90,10 +96,6 @@ export const saveVideoSubscribeQuality = (quality: VideoQuality) => {
|
|||||||
userChoicesStore.videoSubscribeQuality = quality
|
userChoicesStore.videoSubscribeQuality = quality
|
||||||
}
|
}
|
||||||
|
|
||||||
export const saveUsername = (username: string) => {
|
|
||||||
userChoicesStore.username = username
|
|
||||||
}
|
|
||||||
|
|
||||||
export const saveNoiseReductionEnabled = (enabled: boolean) => {
|
export const saveNoiseReductionEnabled = (enabled: boolean) => {
|
||||||
userChoicesStore.noiseReductionEnabled = enabled
|
userChoicesStore.noiseReductionEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
NOTIFICATIONS: 'app_notification_settings',
|
NOTIFICATIONS: 'app_notification_settings',
|
||||||
USER_PREFERENCES: 'app_user_preferences',
|
USER_PREFERENCES: 'app_user_preferences',
|
||||||
|
USER: 'app_user',
|
||||||
ACCESSIBILITY: 'app_accessibility_settings',
|
ACCESSIBILITY: 'app_accessibility_settings',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig, loadEnv } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { visualizer } from 'rollup-plugin-visualizer'
|
import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
import svgr from 'vite-plugin-svgr'
|
import svgr from 'vite-plugin-svgr'
|
||||||
|
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
@@ -11,18 +12,32 @@ export default defineConfig(({ mode }) => {
|
|||||||
react(),
|
react(),
|
||||||
svgr({
|
svgr({
|
||||||
svgrOptions: {
|
svgrOptions: {
|
||||||
replaceAttrValues: { '#000': 'currentColor', '#000000': 'currentColor', '#1f1f1f': 'currentColor' },
|
replaceAttrValues: {
|
||||||
|
'#000': 'currentColor',
|
||||||
|
'#000000': 'currentColor',
|
||||||
|
'#1f1f1f': 'currentColor',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
env.VITE_ANALYZE === 'true' && visualizer({
|
viteStaticCopy({
|
||||||
open: true,
|
targets: [
|
||||||
filename: 'rollup-plugin-visualizer/stats.html',
|
{
|
||||||
gzipSize: true,
|
src: 'node_modules/@mediapipe/tasks-vision/wasm/*',
|
||||||
brotliSize: true,
|
dest: 'assets/mediapipe/wasm',
|
||||||
|
rename: { stripBase: 4 },
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
|
env.VITE_ANALYZE === 'true' &&
|
||||||
|
visualizer({
|
||||||
|
open: true,
|
||||||
|
filename: 'rollup-plugin-visualizer/stats.html',
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
tsconfigPaths: true
|
tsconfigPaths: true,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ backend:
|
|||||||
SUMMARY_SERVICE_API_TOKEN: password
|
SUMMARY_SERVICE_API_TOKEN: password
|
||||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||||
|
OIDC_USERINFO_FULLNAME_FIELDS: first_name, last_name
|
||||||
|
OIDC_USERINFO_SHORTNAME_FIELD: first_name
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
command:
|
command:
|
||||||
|
|||||||
Reference in New Issue
Block a user