mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aa2b80be9 | |||
| 04be495351 | |||
| 43185605eb | |||
| cf3fb208e2 | |||
| 4ca230eb12 | |||
| 4b5e0cb2a3 | |||
| 45f374610f | |||
| b419a2bfd2 | |||
| ee8d96bee7 | |||
| c65ff2d75d | |||
| ea1c90d8ca | |||
| 3e963e3e6d | |||
| 82769128a1 | |||
| 05f4ce6b2e | |||
| 508984ecfa | |||
| a05507f73d | |||
| a27def119c | |||
| fce94f38ce | |||
| 83bd9c5ce3 | |||
| bfbfade99a | |||
| 16daf7b8d3 | |||
| fcad79d662 | |||
| 2424ce17ec | |||
| 4715905334 | |||
| 7c05aedcfe | |||
| 7347fc7c86 | |||
| ada7d9a666 | |||
| 0cb3fb8e3c | |||
| dcb788b57b | |||
| 73bcb9d598 |
@@ -12,6 +12,9 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: 1001:127
|
DOCKER_USER: 1001:127
|
||||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||||
@@ -20,6 +23,8 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push-backend:
|
build-and-push-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -43,12 +48,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '--target backend-production -f Dockerfile'
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -63,6 +68,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-frontend-generic:
|
build-and-push-frontend-generic:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -86,12 +93,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -107,6 +114,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-frontend-dinum:
|
build-and-push-frontend-dinum:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -130,12 +139,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -151,6 +160,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-summary:
|
build-and-push-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -174,13 +185,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# continue-on-error: true
|
continue-on-error: true
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/summary/Dockerfile --target production'
|
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||||
docker-context: './src/summary'
|
docker-context: './src/summary'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
@@ -197,6 +208,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-agents:
|
build-and-push-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -220,14 +233,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# continue-on-error: true
|
continue-on-error: true
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/agents/Dockerfile --target production'
|
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||||
# docker-context: './src/agents'
|
docker-context: './src/agents'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -242,6 +255,8 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
notify-argocd:
|
notify-argocd:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
needs:
|
needs:
|
||||||
- build-and-push-frontend-generic
|
- build-and-push-frontend-generic
|
||||||
- build-and-push-frontend-dinum
|
- build-and-push-frontend-dinum
|
||||||
|
|||||||
@@ -297,6 +297,46 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run pytest -n 2
|
run: uv run pytest -n 2
|
||||||
|
|
||||||
|
test-summary:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/summary
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_API_TOKEN: "test-api-token"
|
||||||
|
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
|
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||||
|
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||||
|
WHISPERX_ASR_MODEL: "large-v2"
|
||||||
|
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||||
|
WHISPERX_DEFAULT_LANGUAGE: "fr"
|
||||||
|
LLM_BASE_URL: "https://configure-your-url.com"
|
||||||
|
LLM_API_KEY: "test-llm-secret"
|
||||||
|
LLM_MODEL: "test-llm-model"
|
||||||
|
WEBHOOK_API_TOKEN: "test-webhook-secret"
|
||||||
|
WEBHOOK_URL: "https://configure-your-url.com"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install development dependencies
|
||||||
|
run: pip install --user .[dev]
|
||||||
|
|
||||||
|
- name: Run summary tests
|
||||||
|
run: ~/.local/bin/pytest
|
||||||
|
|
||||||
lint-front:
|
lint-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
+23
-2
@@ -8,6 +8,21 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
|
||||||
|
- ♿️(frontend) fix more tools heading hierarchy #1181
|
||||||
|
- ♿️(fronted) improve button descriptions for More tools actions #1184
|
||||||
|
- 💄(spinner) enforce spinner height #1183
|
||||||
|
- 💄(custom-background) add upload indicator with preview #1183
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) disable personal custom background while deleting #1183
|
||||||
|
- 🐛(frontend) auto-select new custom background when not logged in #1183
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-03-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(helm) support celery with our Django backend #1124
|
- ✨(helm) support celery with our Django backend #1124
|
||||||
@@ -15,19 +30,24 @@ and this project adheres to
|
|||||||
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
||||||
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
||||||
- ✨(file-upload) disable by default & limit count by user #1141
|
- ✨(file-upload) disable by default & limit count by user #1141
|
||||||
|
- ✨(frontend) custom background #1067
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ♿️(frontend) Caption text size setting for accessibility #1062
|
- ♿️(frontend) Caption text size setting for accessibility #1062
|
||||||
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
||||||
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
||||||
- ♿(frontend) improve chat toast a11y for screen readers #1109
|
- ♿️(frontend) improve chat toast a11y for screen readers #1109
|
||||||
- ♿(frontend) improve ui and aria labels for help article links #1108
|
- ♿️(frontend) improve ui and aria labels for help article links #1108
|
||||||
- 🌐(frontend) improve German translation #1125
|
- 🌐(frontend) improve German translation #1125
|
||||||
- 🔨(python-env) migrate meet main app to UV #1120
|
- 🔨(python-env) migrate meet main app to UV #1120
|
||||||
- ♻️(backend) align Application model field with `is_active` convention #1133
|
- ♻️(backend) align Application model field with `is_active` convention #1133
|
||||||
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
||||||
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
|
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
|
||||||
|
- 🔒️(backend) avoid information exposure through exception messages #1144
|
||||||
|
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
|
||||||
|
- 📌(agents) unpin OpenSSL and related dependencies #1167
|
||||||
|
- ♿️(frontend) add caption font and background color customization #1122
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -35,6 +55,7 @@ and this project adheres to
|
|||||||
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
||||||
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
||||||
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
||||||
|
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
|
||||||
|
|
||||||
## [1.10.0] - 2026-03-05
|
## [1.10.0] - 2026-03-05
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
|||||||
|
|
||||||
test: ## run project tests
|
test: ## run project tests
|
||||||
@$(MAKE) test-back-parallel
|
@$(MAKE) test-back-parallel
|
||||||
|
@$(MAKE) test-summary
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-back: ## run back-end tests
|
test-back: ## run back-end tests
|
||||||
@@ -203,6 +204,11 @@ test-back-parallel: ## run all back-end tests in parallel
|
|||||||
bin/pytest -n auto $${args:-${1}}
|
bin/pytest -n auto $${args:-${1}}
|
||||||
.PHONY: test-back-parallel
|
.PHONY: test-back-parallel
|
||||||
|
|
||||||
|
test-summary: ## run summary tests
|
||||||
|
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
|
bin/pytest-summary $${args:-${1}}
|
||||||
|
.PHONY: test-summary
|
||||||
|
|
||||||
makemigrations: ## run django makemigrations for the Meet project.
|
makemigrations: ## run django makemigrations for the Meet project.
|
||||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||||
@$(COMPOSE) up -d postgresql
|
@$(COMPOSE) up -d postgresql
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||||
|
|
||||||
|
_dc_run \
|
||||||
|
app-summary-dev \
|
||||||
|
python -m pytest "$@"
|
||||||
@@ -61,11 +61,10 @@ services:
|
|||||||
`docker compose up -d`
|
`docker compose up -d`
|
||||||
```
|
```
|
||||||
|
|
||||||
Your keycloak instance is now available on https://doc.yourdomain.tld
|
Your keycloak instance is now available on https://id.yourdomain.tld
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
||||||
```
|
|
||||||
|
|
||||||
## Creating an OIDC Client for Meet Application
|
## Creating an OIDC Client for Meet Application
|
||||||
|
|
||||||
@@ -76,7 +75,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
|
|||||||
3. Enter the name of the realm - `meet`.
|
3. Enter the name of the realm - `meet`.
|
||||||
4. Click "Create".
|
4. Click "Create".
|
||||||
|
|
||||||
#### Step 2: Create a New Client
|
### Step 2: Create a New Client
|
||||||
|
|
||||||
1. Navigate to the "Clients" tab.
|
1. Navigate to the "Clients" tab.
|
||||||
2. Click on the "Create client" button.
|
2. Click on the "Create client" button.
|
||||||
@@ -86,7 +85,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
|
|||||||
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
||||||
1. Click "Save".
|
1. Click "Save".
|
||||||
|
|
||||||
#### Step 3: Get Client Credentials
|
### Step 3: Get Client Credentials
|
||||||
|
|
||||||
1. Go to the "Credentials" tab.
|
1. Go to the "Credentials" tab.
|
||||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# Recording Delegate — Design Spec
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a meeting organizer is absent, no one can start recording or transcription because only room admins/owners have that permission. This is a common scenario (secretary creates meetings for executives, organizer can't attend, etc.).
|
||||||
|
|
||||||
|
The PR #794 proposed opening recording permissions to all authenticated users, but this doesn't fit multi-tenant deployments where authenticated users may belong to different organizations.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
A lightweight **Recording Delegate** system that lets admins/owners grant recording-specific rights to individual users, with a request/approve flow for live meetings and auto-approval when no admin is present.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `HasPrivilegesOnRoom` permission class is used on `start-recording` and `stop-recording` actions in `RoomViewSet` (viewsets.py:299, 349). This checks `is_administrator_or_owner()`.
|
||||||
|
- `ResourceAccess` model with roles: `owner`, `administrator`, `member`.
|
||||||
|
- No backend-to-client DataChannel messaging exists. The backend uses the LiveKit Server SDK only for webhooks, mute/remove/update participant operations via `ParticipantsManagement` service. There is no `ListParticipants` or `SendData` call.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `NoAccessView` component already includes a `RequestRecording` button and a `handleRequest` prop.
|
||||||
|
- `ScreenRecordingSidePanel` already sends a `ScreenRecordingRequested` notification via DataChannel (client-to-client).
|
||||||
|
- `useHasRecordingAccess` hook checks `useIsAdminOrOwner()` to determine recording access.
|
||||||
|
- All DataChannel notifications are sent client-side via `useNotifyParticipants` → `room.localParticipant.publishData()`.
|
||||||
|
|
||||||
|
### What needs to change
|
||||||
|
|
||||||
|
- Replace `HasPrivilegesOnRoom` with `HasRecordingPermission` on recording endpoints (also check delegate status).
|
||||||
|
- Update `useHasRecordingAccess` to also check delegate status.
|
||||||
|
- Extend the existing `NoAccessView` request flow with backend-backed approval (currently it only sends a client-side notification with no persistence).
|
||||||
|
- Add `ListParticipants` capability to `ParticipantsManagement` service.
|
||||||
|
- Add backend-to-client notification capability via LiveKit Server SDK `RoomService.send_data()`.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **Separate from the role system**: `RecordingDelegate` is a standalone model, not a new `RoleChoices` entry. This avoids polluting the existing owner/admin/member hierarchy and is easy to remove when a more advanced multi-admin system is built.
|
||||||
|
- **Session or permanent**: the admin choosing to grant rights decides whether the delegation is for the current session only or permanent.
|
||||||
|
- **Auto-approve for authenticated users**: when no admin/owner is present in the room, an authenticated user's request is auto-approved after 30s. This covers the "absent organizer" scenario without opening permissions globally.
|
||||||
|
- **Notifications are hybrid**: client-to-client for immediate UX feedback (request/grant/revoke), backend-to-client for auto-approve (only the backend knows when the 30s timer fires).
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### RecordingDelegate
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | UUID (PK) | Primary key |
|
||||||
|
| `room` | FK → Room | The room this delegation applies to |
|
||||||
|
| `user` | FK → User (CASCADE) | The delegated user |
|
||||||
|
| `status` | CharField | `pending` or `approved` |
|
||||||
|
| `is_permanent` | Boolean (default=False) | False = session-only, True = persists across meetings |
|
||||||
|
| `granted_by` | FK → User (nullable, SET_NULL) | Who granted the rights. Null = auto-approved |
|
||||||
|
| `created_at` | DateTime (auto) | Timestamp |
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- Unique together: `(room, user)`
|
||||||
|
|
||||||
|
The `status` field tracks pending requests in the database (not just Redis), so the `/approve/` endpoint can look up what it's approving, and the GET list can show pending requests to admins.
|
||||||
|
|
||||||
|
### Permission check
|
||||||
|
|
||||||
|
`HasRecordingPermission` replaces `HasPrivilegesOnRoom` on `start-recording` and `stop-recording` actions. It checks in order:
|
||||||
|
1. User is admin/owner of the room → allowed
|
||||||
|
2. User has a `RecordingDelegate` entry with `status=approved` for this room → allowed
|
||||||
|
3. Otherwise → denied
|
||||||
|
|
||||||
|
Delegates can both start AND stop recordings (a delegate who starts a recording can stop it).
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints nested under `/api/v1.0/rooms/{room_id}/recording-delegates/`.
|
||||||
|
|
||||||
|
| Method | Path | Permission | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET` | `/` | Admin/Owner | List delegates for this room (includes pending) |
|
||||||
|
| `POST` | `/` | Admin/Owner | Grant recording rights (direct, status=approved) |
|
||||||
|
| `DELETE` | `/{id}/` | Admin/Owner | Revoke a delegate |
|
||||||
|
| `POST` | `/request/` | Authenticated | Request recording rights (creates status=pending) |
|
||||||
|
| `POST` | `/{id}/approve/` | Admin/Owner | Approve a pending request |
|
||||||
|
| `POST` | `/{id}/reject/` | Admin/Owner | Reject a pending request (deletes the entry) |
|
||||||
|
|
||||||
|
### Payloads
|
||||||
|
|
||||||
|
**POST (grant):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": "uuid",
|
||||||
|
"is_permanent": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST approve:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"is_permanent": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST (request):**
|
||||||
|
No body needed — user is derived from `request.user`.
|
||||||
|
|
||||||
|
**GET (list) response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "delegate-uuid",
|
||||||
|
"user": { "id": "user-uuid", "name": "Jean Dupont" },
|
||||||
|
"status": "approved",
|
||||||
|
"is_permanent": true,
|
||||||
|
"granted_by": { "id": "admin-uuid", "name": "Marie Martin" },
|
||||||
|
"created_at": "2026-03-20T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Backend Infrastructure
|
||||||
|
|
||||||
|
### LiveKit ListParticipants
|
||||||
|
|
||||||
|
Add a `list_participants(room_name)` method to `ParticipantsManagement` service using `livekit.api.RoomService.list_participants()`. This returns the list of currently connected participants with their identity (which maps to the user ID set when generating the LiveKit token).
|
||||||
|
|
||||||
|
### LiveKit SendData (backend → client)
|
||||||
|
|
||||||
|
Add a `send_data(room_name, data, participant_identities)` method to `ParticipantsManagement` service using `livekit.api.RoomService.send_data()`. This is needed for the auto-approve flow where the backend must notify the requester after the Celery timer fires.
|
||||||
|
|
||||||
|
### Participant identity mapping
|
||||||
|
|
||||||
|
LiveKit participant `identity` is set to the Django user's UUID string when the LiveKit token is generated. The `list_participants` response provides these identities, which can be directly matched against `ResourceAccess.user_id` to determine which participants are admins/owners.
|
||||||
|
|
||||||
|
## Delegation Flows
|
||||||
|
|
||||||
|
### Flow 1: Push by admin (direct grant)
|
||||||
|
|
||||||
|
1. Admin clicks "Grant recording rights" on a participant
|
||||||
|
2. `POST /recording-delegates/` with `{ user, is_permanent }`
|
||||||
|
3. `RecordingDelegate` created with `status=approved, granted_by=admin`
|
||||||
|
4. Admin's frontend sends DataChannel notification to participant: `RecordingRightsGranted`
|
||||||
|
5. Participant sees recording buttons appear
|
||||||
|
|
||||||
|
### Flow 2: Request by participant
|
||||||
|
|
||||||
|
1. Participant clicks "Request recording rights"
|
||||||
|
2. `POST /recording-delegates/request/`
|
||||||
|
3. Backend creates `RecordingDelegate` with `status=pending`
|
||||||
|
4. Backend calls `list_participants` and cross-references with `ResourceAccess` to check admin presence
|
||||||
|
|
||||||
|
**Case A — Admin present:**
|
||||||
|
5. Requester's frontend sends DataChannel notification to admins: `RecordingRightsRequested` (with delegate ID and user info)
|
||||||
|
6. Admin sees popup: "X requests recording rights" [Session only] [Permanent] [Reject]
|
||||||
|
7. Admin clicks → `POST /recording-delegates/{id}/approve/` or `/{id}/reject/`
|
||||||
|
8. Backend updates `RecordingDelegate` status to `approved` (or deletes on reject)
|
||||||
|
9. Admin's frontend sends DataChannel notification to requester: `RecordingRightsGranted` or `RecordingRightsRejected`
|
||||||
|
|
||||||
|
**Case B — No admin present:**
|
||||||
|
5. Backend schedules a Celery task with `countdown=30` seconds, storing the task ID in cache as `auto_approve:{delegate_id}`
|
||||||
|
6. API response includes `auto_approve_seconds: 30`
|
||||||
|
7. Frontend shows countdown: "No admin present. Auto-approval in 30s..."
|
||||||
|
8. After 30s, Celery task fires:
|
||||||
|
- Re-checks the `RecordingDelegate` still exists and is still `pending` (requester may have left)
|
||||||
|
- Re-checks no admin is present via `list_participants`
|
||||||
|
- If both conditions met: updates to `status=approved, granted_by=null, is_permanent=False`
|
||||||
|
- Sends notification via `RoomService.send_data()`: `RecordingRightsGranted`
|
||||||
|
- If an admin is now present: does nothing (admin will handle via Case A)
|
||||||
|
9. If an admin connects during the 30s:
|
||||||
|
- Admin's frontend fetches pending requests via `GET /recording-delegates/?status=pending`
|
||||||
|
- Admin sees and handles the request (Case A flow)
|
||||||
|
- When admin approves/rejects, the `status` changes and the Celery task's re-check at step 8 will find it's no longer `pending` → no-op
|
||||||
|
|
||||||
|
### Flow 3: Pre-meeting
|
||||||
|
|
||||||
|
1. Owner/admin goes to room management page
|
||||||
|
2. Searches for users and adds them as recording delegates
|
||||||
|
3. `POST /recording-delegates/` with `{ user, is_permanent: true }`
|
||||||
|
|
||||||
|
### Revocation
|
||||||
|
|
||||||
|
1. Admin/owner clicks revoke on a delegate
|
||||||
|
2. `DELETE /recording-delegates/{id}/`
|
||||||
|
3. Admin's frontend sends DataChannel notification to participant: `RecordingRightsRevoked`
|
||||||
|
4. Recording buttons disappear in real-time
|
||||||
|
5. Any active recording started by this delegate continues to completion
|
||||||
|
|
||||||
|
## Real-time Communication
|
||||||
|
|
||||||
|
### Notification types
|
||||||
|
|
||||||
|
| Type | Mechanism | Sent by | Sent to | Trigger |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `RecordingRightsRequested` | DataChannel (client) | Requester's browser | Admins in room | Participant requests rights |
|
||||||
|
| `RecordingRightsGranted` | DataChannel (client) or SendData (backend for auto-approve) | Admin's browser / backend | Requester | Approved or auto-approved |
|
||||||
|
| `RecordingRightsRejected` | DataChannel (client) | Admin's browser | Requester | Rejected |
|
||||||
|
| `RecordingRightsRevoked` | DataChannel (client) | Admin's browser | The delegate | Admin revokes rights |
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
### Participant side (authenticated, non-admin)
|
||||||
|
|
||||||
|
Extend the existing `NoAccessView` component in `ScreenRecordingSidePanel`. The existing `RequestRecording` button and `handleRequest` prop are reused but connected to the new backend API instead of the current client-only notification.
|
||||||
|
|
||||||
|
States: `idle` → `pending` (with 30s countdown if auto-approve) → `granted` / `rejected`
|
||||||
|
|
||||||
|
Once `granted`: standard start/stop recording buttons appear.
|
||||||
|
|
||||||
|
### Admin side
|
||||||
|
|
||||||
|
- **Toast/popup** on incoming `RecordingRightsRequested` notification: "X requests recording rights" with actions [Session only] [Permanent] [Reject]
|
||||||
|
- **Participant context menu**: "Grant recording rights" → sub-menu [Session] [Permanent]
|
||||||
|
- On room join, fetch pending requests via `GET /recording-delegates/?status=pending` to catch requests made before the admin connected
|
||||||
|
|
||||||
|
### Room management page (pre-meeting)
|
||||||
|
|
||||||
|
New "Recording Delegation" section in the Admin panel:
|
||||||
|
- User search field + list of current delegates with revoke button
|
||||||
|
- Only visible to admin/owner
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
**`useRecordingDelegate(roomId)`**
|
||||||
|
```
|
||||||
|
→ { isDelegate, requestRights(), pendingRequest, countdown }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `useHasRecordingAccess`** to also return `true` when the user is a delegate (`status=approved`).
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
### Definition of "session"
|
||||||
|
|
||||||
|
A session corresponds to a LiveKit room lifecycle (first participant joins → last participant leaves). Non-permanent delegates are cleaned up when the room ends, with a **5-minute grace period** to handle brief disconnections (all participants drop and reconnect quickly).
|
||||||
|
|
||||||
|
### Primary: LiveKit webhook `room_finished`
|
||||||
|
|
||||||
|
When `room_finished` fires, schedule a Celery task with `countdown=300` (5 minutes). When the task runs:
|
||||||
|
- Check if the room is still empty via `list_participants`
|
||||||
|
- If empty: delete all `RecordingDelegate` entries with `is_permanent=False` for that room
|
||||||
|
- If participants are back: do nothing (new session started)
|
||||||
|
|
||||||
|
### Safety net: Celery periodic task
|
||||||
|
|
||||||
|
Every 6 hours, delete non-permanent delegates with `created_at` older than 24h. Covers missed webhooks. The 24h window is generous enough to cover multi-hour meetings.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Delegate starts recording then is revoked | Active recording continues. Revocation prevents new recordings. |
|
||||||
|
| Two participants request simultaneously (auto-approve) | Independent requests. Both get rights after 30s. |
|
||||||
|
| Admin arrives during 30s countdown | Admin fetches pending requests on join. Celery task re-checks status before approving — if admin already handled it, task is a no-op. |
|
||||||
|
| Requester leaves room during countdown | Frontend does not cancel the pending — Celery task re-checks the delegate still exists. If the requester deleted their request on leave, task is a no-op. |
|
||||||
|
| Permanent delegate's user deleted | FK CASCADE removes the delegate entry. |
|
||||||
|
| Duplicate request by same user | Unique constraint `(room, user)` prevents duplicates. Returns 200 with existing delegate if already exists. |
|
||||||
|
| Brief room-empty gap (all disconnect/reconnect) | 5-minute grace period on `room_finished` prevents premature cleanup. |
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Authentication required**: `IsAuthenticated` on `/request/` endpoint
|
||||||
|
- **Rate limiting**: `SessionExchangeAnonRateThrottle`-style throttle on `/request/` — 5 requests/min per user per room. Returns HTTP 429 when exceeded.
|
||||||
|
- **Audit trail**: `granted_by` field traces who granted (null = auto-approved), `created_at` for timing
|
||||||
|
- **No anonymous auto-approve**: only authenticated users can trigger auto-approval
|
||||||
|
- **Celery task safety**: auto-approve task re-checks both `status=pending` and admin absence before granting — no race condition
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Backend (pytest)
|
||||||
|
|
||||||
|
**Model:**
|
||||||
|
- CRUD operations on `RecordingDelegate`
|
||||||
|
- Unique constraint `(room, user)` enforced
|
||||||
|
- CASCADE delete on user/room deletion
|
||||||
|
- Status transitions: pending → approved, pending → deleted (reject)
|
||||||
|
|
||||||
|
**Permissions:**
|
||||||
|
- Admin/owner can start-recording (unchanged)
|
||||||
|
- Delegate (status=approved) can start-recording → 201
|
||||||
|
- Delegate (status=pending) cannot start-recording → 403
|
||||||
|
- Authenticated non-delegate → 403
|
||||||
|
- Anonymous → 401
|
||||||
|
- Revoked delegate → 403
|
||||||
|
- Delegate can stop-recording they started → 200
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
- POST delegate: admin → 201, non-admin → 403
|
||||||
|
- DELETE delegate: admin → 204, non-admin → 403
|
||||||
|
- POST request: authenticated → 201 (pending created), anonymous → 401, existing delegate → 200
|
||||||
|
- POST approve: admin → 200 (status updated), non-admin → 403
|
||||||
|
- POST reject: admin → 200 (delegate deleted), non-admin → 403
|
||||||
|
- GET list: admin sees pending + approved, non-admin → 403
|
||||||
|
|
||||||
|
**Auto-approve:**
|
||||||
|
- Request with no admin present → Celery task scheduled
|
||||||
|
- After 30s, task fires → delegate approved with `granted_by=null`
|
||||||
|
- Admin present when task fires → task is no-op
|
||||||
|
- Delegate no longer pending when task fires → task is no-op
|
||||||
|
- Requester deleted request → task is no-op
|
||||||
|
|
||||||
|
**Cleanup:**
|
||||||
|
- Webhook `room_finished` + 5min grace → non-permanent delegates deleted
|
||||||
|
- Room not empty after grace period → delegates preserved
|
||||||
|
- Permanent delegates → preserved
|
||||||
|
- Celery periodic task → delegates older than 24h deleted
|
||||||
|
|
||||||
|
**New infrastructure:**
|
||||||
|
- `list_participants` returns correct participant identities
|
||||||
|
- `send_data` delivers notification to specific participant
|
||||||
|
- Participant identity maps to user UUID
|
||||||
|
|
||||||
|
### Frontend (vitest)
|
||||||
|
|
||||||
|
- `useRecordingDelegate`: states idle/pending/granted/rejected
|
||||||
|
- `useHasRecordingAccess`: returns true for delegates
|
||||||
|
- Request button visible for authenticated non-admin, hidden for anonymous
|
||||||
|
- Recording buttons visible after granted
|
||||||
|
- Admin popup: all 3 actions work (session/permanent/reject)
|
||||||
|
- Admin fetches pending requests on room join
|
||||||
|
- 30s countdown displayed correctly during auto-approve
|
||||||
|
- Revocation removes recording buttons in real-time
|
||||||
@@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
|||||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||||
AWS_S3_ACCESS_KEY_ID=meet
|
AWS_S3_ACCESS_KEY_ID=meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY=password
|
AWS_S3_SECRET_ACCESS_KEY=password
|
||||||
MEDIA_BASE_URL=http://localhost:8083
|
MEDIA_BASE_URL=http://localhost:3000
|
||||||
FILE_UPLOAD_ENABLED=True
|
FILE_UPLOAD_ENABLED=True
|
||||||
|
|
||||||
# OIDC
|
# OIDC
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ FROM python:3.13-slim AS base
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libgobject-2.0-0 \
|
libgobject-2.0-0 \
|
||||||
"openssl=3.5.4-1~deb13u2" \
|
|
||||||
"libssl3t64=3.5.4-1~deb13u2" \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.10.0"
|
version = "1.11.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.3.10",
|
"livekit-agents==1.3.10",
|
||||||
|
|||||||
@@ -488,9 +488,7 @@ class RoomViewSet(
|
|||||||
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
return drf_response.Response(
|
return drf_response.Response({"status": "error"}, status=status_code)
|
||||||
{"status": "error", "message": str(e)}, status=status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=False,
|
detail=False,
|
||||||
@@ -757,10 +755,10 @@ class RecordingViewSet(
|
|||||||
recording_id = parser.get_recording_id(request.data)
|
recording_id = parser.get_recording_id(request.data)
|
||||||
|
|
||||||
except ParsingEventDataError as e:
|
except ParsingEventDataError as e:
|
||||||
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
|
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
|
||||||
|
|
||||||
except InvalidBucketError as e:
|
except InvalidBucketError as e:
|
||||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
|
||||||
|
|
||||||
except InvalidFilepathError:
|
except InvalidFilepathError:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
|||||||
|
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||||
f"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
|
||||||
)
|
)
|
||||||
|
|
||||||
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {"detail": "Invalid request data: Error message"}
|
assert response.json() == {"detail": "Invalid request data."}
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
||||||
@@ -112,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {"detail": "Invalid bucket specified"}
|
assert response.json() == {"detail": "Invalid bucket specified."}
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "Authorization header missing",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +90,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
|
assert response.json() == {"status": "error"}
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_event_type(client, mock_livekit_config):
|
def test_unknown_event_type(client, mock_livekit_config):
|
||||||
@@ -116,7 +115,6 @@ def test_unknown_event_type(client, mock_livekit_config):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "Unknown webhook type: unknown_event_type",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.10.0"
|
version = "1.11.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -52,7 +52,7 @@ dependencies = [
|
|||||||
"nested-multipart-parser==1.6.0",
|
"nested-multipart-parser==1.6.0",
|
||||||
"psycopg[binary]==3.3.2",
|
"psycopg[binary]==3.3.2",
|
||||||
"pydantic==2.12.4",
|
"pydantic==2.12.4",
|
||||||
"PyJWT==2.11.0",
|
"PyJWT==2.12.0",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.32.5",
|
"requests==2.32.5",
|
||||||
|
|||||||
Generated
+5
-5
@@ -1157,7 +1157,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.10.0"
|
version = "1.11.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -1251,7 +1251,7 @@ requires-dist = [
|
|||||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.2" },
|
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.2" },
|
||||||
{ name = "pydantic", specifier = "==2.12.4" },
|
{ name = "pydantic", specifier = "==2.12.4" },
|
||||||
{ name = "pyjwt", specifier = "==2.11.0" },
|
{ name = "pyjwt", specifier = "==2.12.0" },
|
||||||
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
||||||
{ name = "python-magic", specifier = "==0.4.27" },
|
{ name = "python-magic", specifier = "==0.4.27" },
|
||||||
{ name = "redis", specifier = "==5.2.1" },
|
{ name = "redis", specifier = "==5.2.1" },
|
||||||
@@ -1754,11 +1754,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.11.0"
|
version = "2.12.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||||
"@fontsource/material-icons-outlined": "5.2.6",
|
"@fontsource/material-icons-outlined": "5.2.6",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
|
|||||||
@@ -15,7 +15,19 @@ export const fetchApi = async <T = Record<string, unknown>>(
|
|||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const result = await response.json()
|
|
||||||
|
let result: T
|
||||||
|
if (response.status === 204) {
|
||||||
|
result = undefined as T
|
||||||
|
} else {
|
||||||
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
result = undefined as T
|
||||||
|
} else {
|
||||||
|
result = (await response.json()) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(response.status, result)
|
throw new ApiError(response.status, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export const keys = {
|
|||||||
requestEntry: 'requestEntry',
|
requestEntry: 'requestEntry',
|
||||||
waitingParticipants: 'waitingParticipants',
|
waitingParticipants: 'waitingParticipants',
|
||||||
roomCreationCallback: 'roomCreationCallback',
|
roomCreationCallback: 'roomCreationCallback',
|
||||||
|
files: 'files',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export interface ApiConfig {
|
|||||||
expiration_days?: number
|
expiration_days?: number
|
||||||
max_duration?: number
|
max_duration?: number
|
||||||
}
|
}
|
||||||
|
background_image: {
|
||||||
|
upload_is_enabled: boolean
|
||||||
|
max_size: number
|
||||||
|
max_count_by_user: number
|
||||||
|
allowed_extensions: string[]
|
||||||
|
allowed_mimetypes: string[]
|
||||||
|
}
|
||||||
subtitle: {
|
subtitle: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { ApiFileItem } from '@/features/files/api/types.ts'
|
||||||
|
import { keys } from '@/api/queryKeys.ts'
|
||||||
|
import { queryClient } from '@/api/queryClient.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file, using XHR so we can report on progress through a handler.
|
||||||
|
*
|
||||||
|
* @param url The URL to PUT the file to.
|
||||||
|
* @param file The file to upload.
|
||||||
|
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
|
||||||
|
*/
|
||||||
|
export const uploadFile = (
|
||||||
|
url: string,
|
||||||
|
file: File,
|
||||||
|
progressHandler: (progress: number) => void
|
||||||
|
) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open('PUT', url)
|
||||||
|
xhr.setRequestHeader('X-amz-acl', 'private')
|
||||||
|
xhr.setRequestHeader('Content-Type', file.type)
|
||||||
|
|
||||||
|
xhr.addEventListener('error', reject)
|
||||||
|
xhr.addEventListener('abort', reject)
|
||||||
|
|
||||||
|
xhr.addEventListener('readystatechange', () => {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
// Make sure to always set the progress to 100% when the upload is done.
|
||||||
|
// Because 'progress' event listener is not called when the file size is 0.
|
||||||
|
progressHandler(100)
|
||||||
|
return resolve(true)
|
||||||
|
}
|
||||||
|
reject(new Error(`Failed to perform the upload on ${url}.`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (progressEvent) => {
|
||||||
|
if (progressEvent.lengthComputable) {
|
||||||
|
progressHandler(
|
||||||
|
Math.floor((progressEvent.loaded / progressEvent.total) * 100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
xhr.send(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously creates a new file and uploads it to the server.
|
||||||
|
*
|
||||||
|
* @param {object} params - The parameters for the file creation and upload process.
|
||||||
|
* @param {File} params.file - The file object to be uploaded.
|
||||||
|
* @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100).
|
||||||
|
* @returns {Promise<ApiFileItem>} A promise that resolves when the file has been successfully uploaded and the server process is completed.
|
||||||
|
*/
|
||||||
|
export const createFile = async ({
|
||||||
|
file,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
file: File
|
||||||
|
onProgress: (progress: number) => void
|
||||||
|
}): Promise<ApiFileItem> => {
|
||||||
|
const res = await fetchApi<ApiFileItem>(`/files/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ filename: file.name, type: 'background_image' }),
|
||||||
|
})
|
||||||
|
if (res.upload_state !== 'pending') {
|
||||||
|
throw new Error('State should be pending right after creation')
|
||||||
|
}
|
||||||
|
const policy = res.policy
|
||||||
|
await uploadFile(policy, file, onProgress)
|
||||||
|
const createdFile = await fetchApi<ApiFileItem>(
|
||||||
|
`/files/${res.id}/upload-ended/`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// We invalidate the files query to make sure the new file is immediately available.
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [keys.files],
|
||||||
|
})
|
||||||
|
return createdFile
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateFile = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createFile,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { keys } from '@/api/queryKeys.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file specified by its unique identifier.
|
||||||
|
*
|
||||||
|
* @param {Object} params - The parameters required for deleting the file.
|
||||||
|
* @param {string} params.fileId - The unique identifier of the file to be deleted.
|
||||||
|
* @returns {Promise<void>} A promise that resolves when the file is successfully deleted.
|
||||||
|
*/
|
||||||
|
export const deleteFile = async ({
|
||||||
|
fileId,
|
||||||
|
}: {
|
||||||
|
fileId: string
|
||||||
|
}): Promise<void> => {
|
||||||
|
await fetchApi<void>(`/files/${fileId}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteFile = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteFile,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [keys.files],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
import { keys } from '@/api/queryKeys'
|
||||||
|
import {
|
||||||
|
ApiFileItem,
|
||||||
|
ApiFileType,
|
||||||
|
ApiFileUploadState,
|
||||||
|
} from '@/features/files/api/types.ts'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
import { useConfig } from '@/api/useConfig.ts'
|
||||||
|
|
||||||
|
type ListFilesResponse = {
|
||||||
|
count: number
|
||||||
|
next: string | null
|
||||||
|
previous: string | null
|
||||||
|
results: ApiFileItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListFilesFilters = {
|
||||||
|
is_creator_me?: boolean
|
||||||
|
type?: ApiFileType
|
||||||
|
upload_state?: ApiFileUploadState
|
||||||
|
is_deleted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListFilesParams = {
|
||||||
|
filters?: ListFilesFilters
|
||||||
|
pagination: {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listMyFiles = async ({
|
||||||
|
filters = {},
|
||||||
|
pagination: { page, pageSize },
|
||||||
|
}: ListFilesParams): Promise<ListFilesResponse> => {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
query.append('page', page.toString())
|
||||||
|
query.append('page_size', pageSize.toString())
|
||||||
|
if (filters?.is_creator_me ?? true) {
|
||||||
|
query.append('is_creator_me', 'true')
|
||||||
|
}
|
||||||
|
if (filters?.type) {
|
||||||
|
query.append('type', filters.type)
|
||||||
|
}
|
||||||
|
if (filters?.upload_state) {
|
||||||
|
query.append('upload_state', filters.upload_state)
|
||||||
|
}
|
||||||
|
if (typeof filters?.is_deleted === 'boolean') {
|
||||||
|
query.append('is_deleted', filters.is_deleted ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchApi<ListFilesResponse>(`/files?${query.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useListMyFiles = (params: Parameters<typeof listMyFiles>[0]) => {
|
||||||
|
const { isLoggedIn } = useUser()
|
||||||
|
const { data: appConfig } = useConfig()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [keys.files, params],
|
||||||
|
queryFn: () => listMyFiles(params),
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
enabled:
|
||||||
|
isLoggedIn && appConfig?.background_image?.upload_is_enabled === true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type ApiFileCreator = {
|
||||||
|
id: string // UUID
|
||||||
|
full_name: string | null
|
||||||
|
short_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiFileType = 'background_image'
|
||||||
|
export type ApiFileUploadState = 'pending' | 'ready'
|
||||||
|
|
||||||
|
export type ApiFileItem = {
|
||||||
|
id: string // UUID
|
||||||
|
created_at: string // ISO datetime string
|
||||||
|
updated_at: string // ISO datetime string
|
||||||
|
title: string
|
||||||
|
type: ApiFileType
|
||||||
|
creator: ApiFileCreator
|
||||||
|
deleted_at: string | null
|
||||||
|
hard_deleted_at: string | null
|
||||||
|
filename: string
|
||||||
|
upload_state: ApiFileUploadState
|
||||||
|
mimetype: string // e.g. "image/png"
|
||||||
|
size: number // file size in bytes
|
||||||
|
description: string | null
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
upload_state: 'ready'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
upload_state: 'pending'
|
||||||
|
policy: string
|
||||||
|
url: null
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -215,8 +215,8 @@ export const Conference = ({
|
|||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={
|
video={
|
||||||
userConfig.videoEnabled && {
|
userConfig.videoEnabled && {
|
||||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||||
userConfig.processorSerialized
|
userConfig.processorConfig
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
|
|||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
createLocalVideoTrack,
|
|
||||||
createLocalAudioTrack,
|
createLocalAudioTrack,
|
||||||
|
createLocalVideoTrack,
|
||||||
LocalAudioTrack,
|
LocalAudioTrack,
|
||||||
LocalVideoTrack,
|
LocalVideoTrack,
|
||||||
Track,
|
Track,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { H } from '@/primitives/H'
|
import { H } from '@/primitives/H'
|
||||||
import { Field } from '@/primitives/Field'
|
import { Field } from '@/primitives/Field'
|
||||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
import { Button, Dialog, Form, Text } from '@/primitives'
|
||||||
import { VStack } from '@/styled-system/jsx'
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { Heading } from 'react-aria-components'
|
import { Heading } from 'react-aria-components'
|
||||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||||
@@ -44,8 +44,7 @@ const onError = (e: Error) => console.error('ERROR', e)
|
|||||||
|
|
||||||
const Effects = ({
|
const Effects = ({
|
||||||
videoTrack,
|
videoTrack,
|
||||||
onSubmit,
|
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
|
||||||
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
const openDialog = () => setIsDialogOpen(true)
|
const openDialog = () => setIsDialogOpen(true)
|
||||||
@@ -81,7 +80,7 @@ const Effects = ({
|
|||||||
>
|
>
|
||||||
{t('subTitle')}
|
{t('subTitle')}
|
||||||
</Text>
|
</Text>
|
||||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
<EffectsConfiguration videoTrack={videoTrack} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Button
|
<Button
|
||||||
variant="whiteCircle"
|
variant="whiteCircle"
|
||||||
@@ -111,7 +110,7 @@ export const Join = ({
|
|||||||
audioDeviceId,
|
audioDeviceId,
|
||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorSerialized,
|
processorConfig,
|
||||||
username,
|
username,
|
||||||
},
|
},
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
@@ -120,7 +119,6 @@ export const Join = ({
|
|||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveVideoInputDeviceId,
|
saveVideoInputDeviceId,
|
||||||
saveUsername,
|
saveUsername,
|
||||||
saveProcessorSerialized,
|
|
||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||||
@@ -132,7 +130,7 @@ export const Join = ({
|
|||||||
audioDeviceId,
|
audioDeviceId,
|
||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorSerialized,
|
processorConfig,
|
||||||
username,
|
username,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,8 +144,8 @@ export const Join = ({
|
|||||||
video: !!initialUserChoices.current &&
|
video: !!initialUserChoices.current &&
|
||||||
initialUserChoices.current?.videoEnabled && {
|
initialUserChoices.current?.videoEnabled && {
|
||||||
deviceId: initialUserChoices.current.videoDeviceId,
|
deviceId: initialUserChoices.current.videoDeviceId,
|
||||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||||
initialUserChoices.current.processorSerialized
|
initialUserChoices.current.processorConfig
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -186,9 +184,7 @@ export const Join = ({
|
|||||||
const track = await createLocalVideoTrack({
|
const track = await createLocalVideoTrack({
|
||||||
deviceId: { exact: videoDeviceId },
|
deviceId: { exact: videoDeviceId },
|
||||||
processor:
|
processor:
|
||||||
BackgroundProcessorFactory.deserializeProcessor(
|
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||||
processorSerialized
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
setDynamicVideoTrack(track)
|
setDynamicVideoTrack(track)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -207,7 +203,7 @@ export const Join = ({
|
|||||||
}, [
|
}, [
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorSerialized,
|
processorConfig,
|
||||||
previewVideoTrack,
|
previewVideoTrack,
|
||||||
dynamicVideoTrack,
|
dynamicVideoTrack,
|
||||||
])
|
])
|
||||||
@@ -690,12 +686,7 @@ export const Join = ({
|
|||||||
zIndex: '1',
|
zIndex: '1',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Effects
|
<Effects videoTrack={videoTrack} />
|
||||||
videoTrack={videoTrack}
|
|
||||||
onSubmit={(processor) =>
|
|
||||||
saveProcessorSerialized(processor?.serialize())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,11 +149,12 @@ export const SidePanel = () => {
|
|||||||
activeSubPanelId,
|
activeSubPanelId,
|
||||||
} = useSidePanel()
|
} = useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
|
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
title={title}
|
||||||
ariaLabel={t('ariaLabel')}
|
ariaLabel={t('ariaLabel', { title })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
layoutStore.activePanelId = null
|
layoutStore.activePanelId = null
|
||||||
layoutStore.activeSubPanelId = null
|
layoutStore.activeSubPanelId = null
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const ToolButton = ({
|
|||||||
<div>
|
<div>
|
||||||
<Text
|
<Text
|
||||||
margin={false}
|
margin={false}
|
||||||
as="h3"
|
as="h2"
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 0.25,
|
gap: 0.25,
|
||||||
|
|||||||
+16
-27
@@ -11,11 +11,7 @@ import {
|
|||||||
TIMEOUT_TICK,
|
TIMEOUT_TICK,
|
||||||
timerWorkerScript,
|
timerWorkerScript,
|
||||||
} from './TimerWorker'
|
} from './TimerWorker'
|
||||||
import {
|
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
|
||||||
BackgroundProcessorInterface,
|
|
||||||
BackgroundOptions,
|
|
||||||
ProcessorType,
|
|
||||||
} from '.'
|
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256
|
const PROCESSING_WIDTH = 256
|
||||||
const PROCESSING_HEIGHT = 144
|
const PROCESSING_HEIGHT = 144
|
||||||
@@ -33,7 +29,7 @@ const DEFAULT_BLUR = '10'
|
|||||||
* MediaStreamTrackProcessor.
|
* MediaStreamTrackProcessor.
|
||||||
*/
|
*/
|
||||||
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||||
options: BackgroundOptions
|
options: ProcessorConfig
|
||||||
name: string
|
name: string
|
||||||
processedTrack?: MediaStreamTrack | undefined
|
processedTrack?: MediaStreamTrack | undefined
|
||||||
|
|
||||||
@@ -64,15 +60,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
type: ProcessorType
|
type: ProcessorType
|
||||||
virtualBackgroundImage?: HTMLImageElement
|
virtualBackgroundImage?: HTMLImageElement
|
||||||
|
|
||||||
constructor(opts: BackgroundOptions) {
|
constructor(opts: ProcessorConfig) {
|
||||||
this.name = 'blur'
|
this.name = 'blur'
|
||||||
this.options = opts
|
this.options = opts
|
||||||
|
this.type = opts.type
|
||||||
if (this.options.blurRadius) {
|
|
||||||
this.type = ProcessorType.BLUR
|
|
||||||
} else {
|
|
||||||
this.type = ProcessorType.VIRTUAL
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static get isSupported() {
|
static get isSupported() {
|
||||||
@@ -107,6 +98,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
_initVirtualBackgroundImage() {
|
||||||
|
if (this.options.type !== 'virtual') {
|
||||||
|
throw new Error(
|
||||||
|
'Virtual background is only supported for virtual background'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
this.options.imagePath &&
|
this.options.imagePath &&
|
||||||
this.virtualBackgroundImage &&
|
this.virtualBackgroundImage &&
|
||||||
@@ -118,7 +115,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: BackgroundOptions): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
this.options = opts
|
this.options = opts
|
||||||
this._initVirtualBackgroundImage()
|
this._initVirtualBackgroundImage()
|
||||||
}
|
}
|
||||||
@@ -187,7 +184,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
PROCESSING_WIDTH,
|
PROCESSING_WIDTH,
|
||||||
PROCESSING_WIDTH
|
PROCESSING_HEIGHT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +209,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
* TODO: future improvement with WebGL.
|
* TODO: future improvement with WebGL.
|
||||||
*/
|
*/
|
||||||
async blur() {
|
async blur() {
|
||||||
|
if (this.options.type !== 'blur') {
|
||||||
|
throw new Error('Blurring is only supported for blur background')
|
||||||
|
}
|
||||||
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
||||||
for (let i = 0; i < mask.length; ++i) {
|
for (let i = 0; i < mask.length; ++i) {
|
||||||
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
||||||
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
await this.sizeSource()
|
await this.sizeSource()
|
||||||
await this.segment()
|
await this.segment()
|
||||||
|
|
||||||
if (this.options.blurRadius) {
|
if (this.options.type === 'blur') {
|
||||||
await this.blur()
|
await this.blur()
|
||||||
} else {
|
} else {
|
||||||
await this.drawVirtualBackground()
|
await this.drawVirtualBackground()
|
||||||
@@ -354,15 +354,4 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.timerWorker?.terminate()
|
this.timerWorker?.terminate()
|
||||||
this.imageSegmenter?.close()
|
this.imageSegmenter?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
clone() {
|
|
||||||
return new BackgroundCustomProcessor(this.options)
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return {
|
|
||||||
type: this.type,
|
|
||||||
options: this.options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,15 +332,4 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
|||||||
this.timerWorker?.terminate()
|
this.timerWorker?.terminate()
|
||||||
this.faceLandmarker?.close()
|
this.faceLandmarker?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
clone() {
|
|
||||||
return new FaceLandmarksProcessor(this.options)
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return {
|
|
||||||
type: this.type,
|
|
||||||
options: this.options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-32
@@ -1,28 +1,23 @@
|
|||||||
import { ProcessorOptions, Track } from 'livekit-client'
|
import { ProcessorOptions, Track } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
BackgroundBlur,
|
BackgroundBlur,
|
||||||
BackgroundTransformer,
|
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
VirtualBackground,
|
VirtualBackground,
|
||||||
} from '@livekit/track-processors'
|
} from '@livekit/track-processors'
|
||||||
import {
|
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
|
||||||
BackgroundOptions,
|
|
||||||
BackgroundProcessorInterface,
|
|
||||||
ProcessorType,
|
|
||||||
} from '.'
|
|
||||||
|
|
||||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||||
processor: ProcessorWrapper<BackgroundOptions>
|
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
|
||||||
opts: BackgroundOptions
|
opts: ProcessorConfig
|
||||||
processorType: ProcessorType
|
processorType: ProcessorType
|
||||||
|
|
||||||
constructor(opts: BackgroundOptions) {
|
constructor(opts: ProcessorConfig) {
|
||||||
this.opts = opts
|
this.opts = opts
|
||||||
|
|
||||||
if (opts.imagePath) {
|
if (opts.type === 'virtual') {
|
||||||
this.processorType = ProcessorType.VIRTUAL
|
this.processorType = ProcessorType.VIRTUAL
|
||||||
this.processor = VirtualBackground(opts.imagePath)
|
this.processor = VirtualBackground(opts.imagePath)
|
||||||
} else if (opts.blurRadius !== undefined) {
|
} else if (opts.type === 'blur') {
|
||||||
this.processorType = ProcessorType.BLUR
|
this.processorType = ProcessorType.BLUR
|
||||||
this.processor = BackgroundBlur(opts.blurRadius)
|
this.processor = BackgroundBlur(opts.blurRadius)
|
||||||
} else {
|
} else {
|
||||||
@@ -44,24 +39,23 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
return this.processor.destroy()
|
return this.processor.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: BackgroundOptions): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
const newProcessorType = opts.imagePath
|
this.opts = opts
|
||||||
? ProcessorType.VIRTUAL
|
|
||||||
: ProcessorType.BLUR
|
const newProcessorType =
|
||||||
|
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
|
||||||
|
|
||||||
let processedOpts = opts
|
|
||||||
if (newProcessorType !== this.processorType) {
|
if (newProcessorType !== this.processorType) {
|
||||||
this.processorType = newProcessorType
|
this.processorType = newProcessorType
|
||||||
if (newProcessorType === ProcessorType.VIRTUAL) {
|
if (newProcessorType === ProcessorType.VIRTUAL) {
|
||||||
this.processor.name = 'virtual-background'
|
this.processor.name = 'virtual-background'
|
||||||
processedOpts = { ...opts, blurRadius: undefined }
|
|
||||||
} else {
|
} else {
|
||||||
this.processor.name = 'background-blur'
|
this.processor.name = 'background-blur'
|
||||||
processedOpts = { ...opts, imagePath: undefined }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.processor.updateTransformerOptions(processedOpts)
|
await this.processor.updateTransformerOptions(
|
||||||
this.opts = processedOpts
|
opts as { imagePath?: string; blurRadius?: number }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
@@ -73,17 +67,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
get options() {
|
get options() {
|
||||||
return (this.processor.transformer as BackgroundTransformer).options
|
return this.opts
|
||||||
}
|
|
||||||
|
|
||||||
clone() {
|
|
||||||
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return {
|
|
||||||
type: this.processorType,
|
|
||||||
options: this.options,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,7 @@ import { ProcessorWrapper } from '@livekit/track-processors'
|
|||||||
import { Track, TrackProcessor } from 'livekit-client'
|
import { Track, TrackProcessor } from 'livekit-client'
|
||||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||||
|
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||||
export type BackgroundOptions = {
|
|
||||||
blurRadius?: number
|
|
||||||
imagePath?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessorSerialized {
|
|
||||||
type: ProcessorType
|
|
||||||
options: BackgroundOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
|
||||||
update(opts: BackgroundOptions): Promise<void>
|
|
||||||
options: BackgroundOptions
|
|
||||||
clone(): BackgroundProcessorInterface
|
|
||||||
serialize(): ProcessorSerialized
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ProcessorType {
|
export enum ProcessorType {
|
||||||
BLUR = 'blur',
|
BLUR = 'blur',
|
||||||
@@ -26,6 +10,16 @@ export enum ProcessorType {
|
|||||||
FACE_LANDMARKS = 'faceLandmarks',
|
FACE_LANDMARKS = 'faceLandmarks',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProcessorConfig =
|
||||||
|
| { type: ProcessorType.BLUR; blurRadius: number }
|
||||||
|
| { type: ProcessorType.VIRTUAL; imagePath: string; fileId?: string }
|
||||||
|
| ({ type: ProcessorType.FACE_LANDMARKS } & FaceLandmarksOptions)
|
||||||
|
|
||||||
|
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
||||||
|
update(opts: ProcessorConfig): Promise<void>
|
||||||
|
options: ProcessorConfig
|
||||||
|
}
|
||||||
|
|
||||||
export class BackgroundProcessorFactory {
|
export class BackgroundProcessorFactory {
|
||||||
static hasModernApiSupport() {
|
static hasModernApiSupport() {
|
||||||
return ProcessorWrapper.hasModernApiSupport
|
return ProcessorWrapper.hasModernApiSupport
|
||||||
@@ -36,28 +30,27 @@ export class BackgroundProcessorFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getProcessor(
|
static getProcessor(
|
||||||
type: ProcessorType,
|
config: ProcessorConfig
|
||||||
opts: BackgroundOptions
|
|
||||||
): BackgroundProcessorInterface | undefined {
|
): BackgroundProcessorInterface | undefined {
|
||||||
const isBlur = type === ProcessorType.BLUR
|
const isBlur = config.type === ProcessorType.BLUR
|
||||||
const isVirtual = type === ProcessorType.VIRTUAL
|
const isVirtual = config.type === ProcessorType.VIRTUAL
|
||||||
|
|
||||||
if (!isBlur && !isVirtual) return undefined
|
if (!isBlur && !isVirtual) return undefined
|
||||||
|
|
||||||
if (ProcessorWrapper.isSupported) {
|
if (ProcessorWrapper.isSupported) {
|
||||||
return new UnifiedBackgroundTrackProcessor(opts)
|
return new UnifiedBackgroundTrackProcessor(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BackgroundCustomProcessor.isSupported) {
|
if (BackgroundCustomProcessor.isSupported) {
|
||||||
return new BackgroundCustomProcessor(opts)
|
return new BackgroundCustomProcessor(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
static deserializeProcessor(data?: ProcessorSerialized) {
|
static fromProcessorConfig(data?: ProcessorConfig) {
|
||||||
if (data?.type) {
|
if (data) {
|
||||||
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
|
return BackgroundProcessorFactory.getProcessor(data)
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
|
|||||||
*
|
*
|
||||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||||
*/
|
*/
|
||||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
const processor = BackgroundProcessorFactory.fromProcessorConfig(
|
||||||
userChoices.processorSerialized
|
userChoices.processorConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = trackProps.toggle as (
|
const toggle = trackProps.toggle as (
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { useLocalParticipant } from '@livekit/components-react'
|
|||||||
import { LocalVideoTrack } from 'livekit-client'
|
import { LocalVideoTrack } from 'livekit-client'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { EffectsConfiguration } from './EffectsConfiguration'
|
import { EffectsConfiguration } from './EffectsConfiguration'
|
||||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
|
||||||
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
||||||
import { TrackSource } from '@livekit/protocol'
|
import { TrackSource } from '@livekit/protocol'
|
||||||
|
|
||||||
export const Effects = () => {
|
export const Effects = () => {
|
||||||
const { cameraTrack } = useLocalParticipant()
|
const { cameraTrack } = useLocalParticipant()
|
||||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||||
const { saveProcessorSerialized } = usePersistentUserChoices()
|
|
||||||
|
|
||||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
||||||
|
|
||||||
@@ -24,9 +22,6 @@ export const Effects = () => {
|
|||||||
isDisabled={!canPublishCamera}
|
isDisabled={!canPublishCamera}
|
||||||
videoTrack={localCameraTrack}
|
videoTrack={localCameraTrack}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onSubmit={(processor) =>
|
|
||||||
saveProcessorSerialized(processor?.serialize())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+705
-251
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
|
|||||||
showFrench: false,
|
showFrench: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return processor.serialize().options
|
return { ...processor.options }
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = getOptions()
|
const options = getOptions()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
import { userChoicesStore } from '@/stores/userChoices'
|
||||||
import type { VideoResolution } from '@/stores/userChoices'
|
import type { VideoResolution } from '@/stores/userChoices'
|
||||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
import { ProcessorConfig } from '@/features/rooms/livekit/components/blur'
|
||||||
import type { VideoQuality } from 'livekit-client'
|
import type { VideoQuality } from 'livekit-client'
|
||||||
|
|
||||||
export function usePersistentUserChoices() {
|
export function usePersistentUserChoices() {
|
||||||
@@ -36,10 +36,8 @@ export function usePersistentUserChoices() {
|
|||||||
saveNoiseReductionEnabled: (enabled: boolean) => {
|
saveNoiseReductionEnabled: (enabled: boolean) => {
|
||||||
userChoicesStore.noiseReductionEnabled = enabled
|
userChoicesStore.noiseReductionEnabled = enabled
|
||||||
},
|
},
|
||||||
saveProcessorSerialized: (
|
saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
|
||||||
processorSerialized: ProcessorSerialized | undefined
|
userChoicesStore.processorConfig = processorConfig
|
||||||
) => {
|
|
||||||
userChoicesStore.processorSerialized = processorSerialized
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
const {
|
const {
|
||||||
userChoices: {
|
userChoices: {
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorSerialized,
|
processorConfig,
|
||||||
videoPublishResolution,
|
videoPublishResolution,
|
||||||
videoSubscribeQuality,
|
videoSubscribeQuality,
|
||||||
},
|
},
|
||||||
@@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
resolution: VideoPresets[key].resolution,
|
resolution: VideoPresets[key].resolution,
|
||||||
deviceId: { exact: videoDeviceId },
|
deviceId: { exact: videoDeviceId },
|
||||||
processor:
|
processor:
|
||||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
|
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { useSnapshot } from 'valtio'
|
|||||||
import {
|
import {
|
||||||
accessibilityStore,
|
accessibilityStore,
|
||||||
type CaptionTextSize,
|
type CaptionTextSize,
|
||||||
|
type CaptionColor,
|
||||||
CAPTION_TEXT_SIZE_OPTIONS,
|
CAPTION_TEXT_SIZE_OPTIONS,
|
||||||
|
CAPTION_COLOR_OPTIONS,
|
||||||
} from '@/stores/accessibility'
|
} from '@/stores/accessibility'
|
||||||
|
import { useAreSubtitlesAvailable } from '../hooks/useAreSubtitlesAvailable'
|
||||||
|
|
||||||
export const CaptionsSettings = () => {
|
export const CaptionsSettings = () => {
|
||||||
const { t } = useTranslation('settings', {
|
const { t } = useTranslation('settings', {
|
||||||
@@ -24,6 +27,27 @@ export const CaptionsSettings = () => {
|
|||||||
[t]
|
[t]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const captionFontColorItems = useMemo(
|
||||||
|
() =>
|
||||||
|
CAPTION_COLOR_OPTIONS.map((color) => ({
|
||||||
|
value: color,
|
||||||
|
label: t(`fontColor.options.${color}`),
|
||||||
|
})),
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const captionBackgroundColorItems = useMemo(
|
||||||
|
() =>
|
||||||
|
CAPTION_COLOR_OPTIONS.map((color) => ({
|
||||||
|
value: color,
|
||||||
|
label: t(`backgroundColor.options.${color}`),
|
||||||
|
})),
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const areSubtitlesAvailable = useAreSubtitlesAvailable()
|
||||||
|
if (!areSubtitlesAvailable) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<H
|
<H
|
||||||
@@ -34,16 +58,44 @@ export const CaptionsSettings = () => {
|
|||||||
>
|
>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
<Field
|
<div
|
||||||
type="select"
|
className={css({
|
||||||
label={t('textSize.label')}
|
display: 'flex',
|
||||||
items={captionTextSizeItems}
|
flexDirection: 'column',
|
||||||
selectedKey={snap.captionTextSize}
|
gap: '0.75rem',
|
||||||
onSelectionChange={(key) => {
|
})}
|
||||||
accessibilityStore.captionTextSize = key as CaptionTextSize
|
>
|
||||||
}}
|
<Field
|
||||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
type="select"
|
||||||
/>
|
label={t('textSize.label')}
|
||||||
|
items={captionTextSizeItems}
|
||||||
|
selectedKey={snap.captionTextSize}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
accessibilityStore.captionTextSize = key as CaptionTextSize
|
||||||
|
}}
|
||||||
|
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
type="select"
|
||||||
|
label={t('fontColor.label')}
|
||||||
|
items={captionFontColorItems}
|
||||||
|
selectedKey={snap.captionFontColor}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
accessibilityStore.captionFontColor = key as CaptionColor
|
||||||
|
}}
|
||||||
|
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
type="select"
|
||||||
|
label={t('backgroundColor.label')}
|
||||||
|
items={captionBackgroundColorItems}
|
||||||
|
selectedKey={snap.captionBackgroundColor}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
accessibilityStore.captionBackgroundColor = key as CaptionColor
|
||||||
|
}}
|
||||||
|
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useSnapshot } from 'valtio'
|
|||||||
import {
|
import {
|
||||||
accessibilityStore,
|
accessibilityStore,
|
||||||
CAPTION_TEXT_SIZE_OPTIONS,
|
CAPTION_TEXT_SIZE_OPTIONS,
|
||||||
|
CAPTION_FONT_COLOR_VALUES,
|
||||||
|
CAPTION_BACKGROUND_COLOR_VALUES,
|
||||||
type CaptionTextSize,
|
type CaptionTextSize,
|
||||||
} from '@/stores/accessibility'
|
} from '@/stores/accessibility'
|
||||||
|
|
||||||
@@ -92,10 +94,14 @@ const useTranscriptionState = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||||
const { captionTextSize } = useSnapshot(accessibilityStore)
|
const { captionTextSize, captionFontColor, captionBackgroundColor } =
|
||||||
|
useSnapshot(accessibilityStore)
|
||||||
const participantColor = getParticipantColor(row.participant)
|
const participantColor = getParticipantColor(row.participant)
|
||||||
const participantName = getParticipantName(row.participant)
|
const participantName = getParticipantName(row.participant)
|
||||||
const { fontSize, lineHeight } = CAPTION_FONT_SIZES[captionTextSize]
|
const { fontSize, lineHeight } = CAPTION_FONT_SIZES[captionTextSize]
|
||||||
|
const fontColor = CAPTION_FONT_COLOR_VALUES[captionFontColor]
|
||||||
|
const backgroundColor =
|
||||||
|
CAPTION_BACKGROUND_COLOR_VALUES[captionBackgroundColor]
|
||||||
|
|
||||||
const getDisplayText = (row: TranscriptionRow): string => {
|
const getDisplayText = (row: TranscriptionRow): string => {
|
||||||
return row.segments
|
return row.segments
|
||||||
@@ -128,9 +134,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
color: 'white',
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
})}
|
})}
|
||||||
|
style={{ color: fontColor }}
|
||||||
>
|
>
|
||||||
<Text variant="h3" margin={false}>
|
<Text variant="h3" margin={false}>
|
||||||
{participantName}
|
{participantName}
|
||||||
@@ -138,8 +144,10 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
|||||||
<p
|
<p
|
||||||
className={css({
|
className={css({
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '0.125rem 0.25rem',
|
||||||
})}
|
})}
|
||||||
style={{ fontSize, lineHeight }}
|
style={{ fontSize, lineHeight, backgroundColor }}
|
||||||
>
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -273,17 +273,40 @@
|
|||||||
"title": "Virtueller Hintergrund",
|
"title": "Virtueller Hintergrund",
|
||||||
"selectedLabel": "Hintergrund angewendet:",
|
"selectedLabel": "Hintergrund angewendet:",
|
||||||
"apply": "Ersetze deinen Hintergrund:",
|
"apply": "Ersetze deinen Hintergrund:",
|
||||||
"descriptions": {
|
"personal": {
|
||||||
"0": "Gerilltes Holzmöbel",
|
"title": "Meine Hintergründe",
|
||||||
"1": "Besprechungsraum",
|
"selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll",
|
||||||
"2": "Loft mit schwarzer Glaswand",
|
"notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.",
|
||||||
"3": "Esszimmer",
|
"warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.",
|
||||||
"4": "Holzregale",
|
"uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.",
|
||||||
"5": "Holztreppe",
|
"uploadInProgress": "Datei wird hochgeladen…",
|
||||||
"6": "Graue Bibliothek",
|
"errors": {
|
||||||
"7": "Kaffeetheke"
|
"close": "Schließen",
|
||||||
|
"file_too_large": {
|
||||||
|
"title": "Datei zu groß",
|
||||||
|
"description": "Die Datei ist zu groß. Bitte wähle eine Datei mit weniger als {{maxSize, number}} MB."
|
||||||
|
},
|
||||||
|
"invalid_file_type": {
|
||||||
|
"title": "Ungültiger Dateityp",
|
||||||
|
"description": "Der Dateityp wird nicht unterstützt. Bitte wähle eine {{allowedExtension}}-Datei."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"presets": {
|
||||||
|
"title": "Vorschläge",
|
||||||
|
"descriptions": {
|
||||||
|
"0": "Gerilltes Holzmöbel",
|
||||||
|
"1": "Besprechungsraum",
|
||||||
|
"2": "Loft mit schwarzer Glaswand",
|
||||||
|
"3": "Esszimmer",
|
||||||
|
"4": "Holzregale",
|
||||||
|
"5": "Holztreppe",
|
||||||
|
"6": "Graue Bibliothek",
|
||||||
|
"7": "Kaffeetheke"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"faceLandmarks": {
|
"faceLandmarks": {
|
||||||
"title": "Visuelle Effekte",
|
"title": "Visuelle Effekte",
|
||||||
"glasses": {
|
"glasses": {
|
||||||
@@ -297,7 +320,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidePanel": {
|
"sidePanel": {
|
||||||
"ariaLabel": "Seitenleiste",
|
"ariaLabel": "Seitenleiste - {{title}}",
|
||||||
"backToTools": "Zurück zu den Meeting-Tools",
|
"backToTools": "Zurück zu den Meeting-Tools",
|
||||||
"heading": {
|
"heading": {
|
||||||
"participants": "Teilnehmende",
|
"participants": "Teilnehmende",
|
||||||
@@ -332,11 +355,11 @@
|
|||||||
"tools": {
|
"tools": {
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"title": "Transkribieren",
|
"title": "Transkribieren",
|
||||||
"body": "Das Gespräch aufzeichnen."
|
"body": "Wandelt Meetings in Text um."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"screenRecording": {
|
||||||
"title": "Aufzeichnen",
|
"title": "Aufnehmen",
|
||||||
"body": "Das Meeting aufzeichnen."
|
"body": "Speichert Meetings als Video."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,6 +126,34 @@
|
|||||||
"medium": "Mittel",
|
"medium": "Mittel",
|
||||||
"large": "Groß"
|
"large": "Groß"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"fontColor": {
|
||||||
|
"label": "Schriftfarbe",
|
||||||
|
"options": {
|
||||||
|
"default": "Standard",
|
||||||
|
"white": "Weiß",
|
||||||
|
"black": "Schwarz",
|
||||||
|
"blue": "Blau",
|
||||||
|
"green": "Grün",
|
||||||
|
"red": "Rot",
|
||||||
|
"yellow": "Gelb",
|
||||||
|
"cyan": "Cyan",
|
||||||
|
"magenta": "Magenta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backgroundColor": {
|
||||||
|
"label": "Hintergrundfarbe",
|
||||||
|
"options": {
|
||||||
|
"default": "Standard",
|
||||||
|
"white": "Weiß",
|
||||||
|
"black": "Schwarz",
|
||||||
|
"blue": "Blau",
|
||||||
|
"green": "Grün",
|
||||||
|
"red": "Rot",
|
||||||
|
"yellow": "Gelb",
|
||||||
|
"cyan": "Cyan",
|
||||||
|
"magenta": "Magenta"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -270,18 +270,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"title": "Virtual background",
|
"title": "Virtual backgrounds",
|
||||||
"selectedLabel": "Background applied:",
|
"selectedLabel": "Background applied:",
|
||||||
"apply": "Replace your background:",
|
"apply": "Replace your background:",
|
||||||
"descriptions": {
|
"personal": {
|
||||||
"0": "Fluted wooden furniture",
|
"title": "My backgrounds",
|
||||||
"1": "Meeting room",
|
"selectFileTooltip": "Select an image file to use as a personal background",
|
||||||
"2": "Loft with black glass partition",
|
"notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.",
|
||||||
"3": "Dining room",
|
"warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.",
|
||||||
"4": "Wooden shelves",
|
"uploadLimitReached": "You cannot upload more personal backgrounds.",
|
||||||
"5": "Wooden staircase",
|
"uploadInProgress": "Image is being uploaded…",
|
||||||
"6": "Gray library",
|
"errors": {
|
||||||
"7": "Coffee counter"
|
"close": "Close",
|
||||||
|
"file_too_large": {
|
||||||
|
"title": "File too large",
|
||||||
|
"description": "The file is too large. Please choose a file smaller than {{maxSize, number}} MB."
|
||||||
|
},
|
||||||
|
"invalid_file_type": {
|
||||||
|
"title": "Invalid file type",
|
||||||
|
"description": "The file type is not supported. Please choose a {{allowedExtension, list(type: 'disjunction')}} file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"presets": {
|
||||||
|
"title": "Suggestions",
|
||||||
|
"descriptions": {
|
||||||
|
"0": "Fluted wooden furniture",
|
||||||
|
"1": "Meeting room",
|
||||||
|
"2": "Loft with black glass partition",
|
||||||
|
"3": "Dining room",
|
||||||
|
"4": "Wooden shelves",
|
||||||
|
"5": "Wooden staircase",
|
||||||
|
"6": "Gray library",
|
||||||
|
"7": "Coffee counter"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"faceLandmarks": {
|
"faceLandmarks": {
|
||||||
@@ -297,7 +319,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidePanel": {
|
"sidePanel": {
|
||||||
"ariaLabel": "Sidepanel",
|
"ariaLabel": "Sidepanel - {{title}}",
|
||||||
"backToTools": "Back to meeting tools",
|
"backToTools": "Back to meeting tools",
|
||||||
"heading": {
|
"heading": {
|
||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
@@ -332,11 +354,11 @@
|
|||||||
"tools": {
|
"tools": {
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"title": "Transcribe",
|
"title": "Transcribe",
|
||||||
"body": "Record the conversation."
|
"body": "Turn meetings into text."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"screenRecording": {
|
||||||
"title": "Record",
|
"title": "Record",
|
||||||
"body": "Record the meeting."
|
"body": "Save meetings as video."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,6 +126,34 @@
|
|||||||
"medium": "Medium",
|
"medium": "Medium",
|
||||||
"large": "Large"
|
"large": "Large"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"fontColor": {
|
||||||
|
"label": "Font color",
|
||||||
|
"options": {
|
||||||
|
"default": "Default",
|
||||||
|
"white": "White",
|
||||||
|
"black": "Black",
|
||||||
|
"blue": "Blue",
|
||||||
|
"green": "Green",
|
||||||
|
"red": "Red",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"cyan": "Cyan",
|
||||||
|
"magenta": "Magenta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backgroundColor": {
|
||||||
|
"label": "Background color",
|
||||||
|
"options": {
|
||||||
|
"default": "Default",
|
||||||
|
"white": "White",
|
||||||
|
"black": "Black",
|
||||||
|
"blue": "Blue",
|
||||||
|
"green": "Green",
|
||||||
|
"red": "Red",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"cyan": "Cyan",
|
||||||
|
"magenta": "Magenta"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -270,18 +270,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"title": "Arrière-plan virtuel",
|
"title": "Arrière-plans virtuels",
|
||||||
"selectedLabel": "Arrière-plan appliqué :",
|
"selectedLabel": "Arrière-plan appliqué :",
|
||||||
"apply": "Remplacer votre arrière plan :",
|
"apply": "Remplacer votre arrière plan :",
|
||||||
"descriptions": {
|
"personal": {
|
||||||
"0": "Meuble cannelé en bois",
|
"title": "Mes arrière-plans",
|
||||||
"1": "Salle de réunion",
|
"selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan",
|
||||||
"2": "Loft avec verrière noire",
|
"notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.",
|
||||||
"3": "Salle à manger",
|
"warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.",
|
||||||
"4": "Étagères en bois",
|
"uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.",
|
||||||
"5": "Escalier en bois",
|
"uploadInProgress": "Image en cours d'envoi…",
|
||||||
"6": "Bibliothèque grise",
|
"errors": {
|
||||||
"7": "Comptoir de café"
|
"close": "Fermer",
|
||||||
|
"file_too_large": {
|
||||||
|
"title": "Fichier trop volumineux",
|
||||||
|
"description": "Le fichier est trop volumineux. Veuillez choisir un fichier de moins de {{maxSize, number}} Mo."
|
||||||
|
},
|
||||||
|
"invalid_file_type": {
|
||||||
|
"title": "Type de fichier non valide",
|
||||||
|
"description": "Le type de fichier n'est pas pris en charge. Veuillez choisir un fichier {{allowedExtension}}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"presets": {
|
||||||
|
"title": "Suggestions",
|
||||||
|
"descriptions": {
|
||||||
|
"0": "Meuble cannelé en bois",
|
||||||
|
"1": "Salle de réunion",
|
||||||
|
"2": "Loft avec verrière noire",
|
||||||
|
"3": "Salle à manger",
|
||||||
|
"4": "Étagères en bois",
|
||||||
|
"5": "Escalier en bois",
|
||||||
|
"6": "Bibliothèque grise",
|
||||||
|
"7": "Comptoir de café"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"faceLandmarks": {
|
"faceLandmarks": {
|
||||||
@@ -297,7 +319,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidePanel": {
|
"sidePanel": {
|
||||||
"ariaLabel": "Panneau latéral",
|
"ariaLabel": "Panneau latéral - {{title}}",
|
||||||
"backToTools": "Retour aux outils de réunion",
|
"backToTools": "Retour aux outils de réunion",
|
||||||
"heading": {
|
"heading": {
|
||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
@@ -332,11 +354,11 @@
|
|||||||
"tools": {
|
"tools": {
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"title": "Transcrire",
|
"title": "Transcrire",
|
||||||
"body": "Enregistrer la conversation."
|
"body": "Transcrire la réunion."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"screenRecording": {
|
||||||
"title": "Enregistrer",
|
"title": "Enregistrer",
|
||||||
"body": "Enregistrer la réunion."
|
"body": "Enregistrer la réunion en vidéo."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,6 +126,34 @@
|
|||||||
"medium": "Moyen",
|
"medium": "Moyen",
|
||||||
"large": "Grand"
|
"large": "Grand"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"fontColor": {
|
||||||
|
"label": "Couleur du texte",
|
||||||
|
"options": {
|
||||||
|
"default": "Par défaut",
|
||||||
|
"white": "Blanc",
|
||||||
|
"black": "Noir",
|
||||||
|
"blue": "Bleu",
|
||||||
|
"green": "Vert",
|
||||||
|
"red": "Rouge",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"cyan": "Cyan",
|
||||||
|
"magenta": "Magenta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backgroundColor": {
|
||||||
|
"label": "Couleur de fond",
|
||||||
|
"options": {
|
||||||
|
"default": "Par défaut",
|
||||||
|
"white": "Blanc",
|
||||||
|
"black": "Noir",
|
||||||
|
"blue": "Bleu",
|
||||||
|
"green": "Vert",
|
||||||
|
"red": "Rouge",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"cyan": "Cyan",
|
||||||
|
"magenta": "Magenta"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -273,15 +273,37 @@
|
|||||||
"title": "Virtuele achtergrond",
|
"title": "Virtuele achtergrond",
|
||||||
"selectedLabel": "Achtergrond toegepast:",
|
"selectedLabel": "Achtergrond toegepast:",
|
||||||
"apply": "Vervang je achtergrond:",
|
"apply": "Vervang je achtergrond:",
|
||||||
"descriptions": {
|
"personal": {
|
||||||
"0": "Geprofileerd houten meubel",
|
"title": "Mijn achtergronden",
|
||||||
"1": "Vergaderruimte",
|
"selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond",
|
||||||
"2": "Loft met zwarte glaswand",
|
"notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.",
|
||||||
"3": "Eetkamer",
|
"warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.",
|
||||||
"4": "Houten planken",
|
"uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.",
|
||||||
"5": "Houten trap",
|
"uploadInProgress": "Afbeelding wordt geüpload…",
|
||||||
"6": "Grijze bibliotheek",
|
"errors": {
|
||||||
"7": "Koffiebar"
|
"close": "Sluiten",
|
||||||
|
"file_too_large": {
|
||||||
|
"title": "Bestand te groot",
|
||||||
|
"description": "Het bestand is te groot. Kies een bestand kleiner dan {{maxSize, number}} MB."
|
||||||
|
},
|
||||||
|
"invalid_file_type": {
|
||||||
|
"title": "Ongeldig bestandstype",
|
||||||
|
"description": "Het bestandstype wordt niet ondersteund. Kies een {{allowedExtension, list(type: 'disjunction')}} bestand."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"presets": {
|
||||||
|
"title": "Suggesties",
|
||||||
|
"descriptions": {
|
||||||
|
"0": "Geprofileerd houten meubel",
|
||||||
|
"1": "Vergaderruimte",
|
||||||
|
"2": "Loft met zwarte glaswand",
|
||||||
|
"3": "Eetkamer",
|
||||||
|
"4": "Houten planken",
|
||||||
|
"5": "Houten trap",
|
||||||
|
"6": "Grijze bibliotheek",
|
||||||
|
"7": "Koffiebar"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"faceLandmarks": {
|
"faceLandmarks": {
|
||||||
@@ -297,7 +319,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidePanel": {
|
"sidePanel": {
|
||||||
"ariaLabel": "Zijbalk",
|
"ariaLabel": "Zijbalk - {{title}}",
|
||||||
"backToTools": "Terug naar vergadertools",
|
"backToTools": "Terug naar vergadertools",
|
||||||
"heading": {
|
"heading": {
|
||||||
"participants": "Deelnemers",
|
"participants": "Deelnemers",
|
||||||
@@ -332,11 +354,11 @@
|
|||||||
"tools": {
|
"tools": {
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"title": "Transcriberen",
|
"title": "Transcriberen",
|
||||||
"body": "Het gesprek opnemen."
|
"body": "Zet vergaderingen om in tekst."
|
||||||
},
|
},
|
||||||
"screenRecording": {
|
"screenRecording": {
|
||||||
"title": "Opnemen",
|
"title": "Opnemen",
|
||||||
"body": "De vergadering opnemen."
|
"body": "Sla vergaderingen op als video."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,6 +126,34 @@
|
|||||||
"medium": "Gemiddeld",
|
"medium": "Gemiddeld",
|
||||||
"large": "Groot"
|
"large": "Groot"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"fontColor": {
|
||||||
|
"label": "Tekstkleur",
|
||||||
|
"options": {
|
||||||
|
"default": "Standaard",
|
||||||
|
"white": "Wit",
|
||||||
|
"black": "Zwart",
|
||||||
|
"blue": "Blauw",
|
||||||
|
"green": "Groen",
|
||||||
|
"red": "Rood",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"cyan": "Cyaan",
|
||||||
|
"magenta": "Magenta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backgroundColor": {
|
||||||
|
"label": "Achtergrondkleur",
|
||||||
|
"options": {
|
||||||
|
"default": "Standaard",
|
||||||
|
"white": "Wit",
|
||||||
|
"black": "Zwart",
|
||||||
|
"blue": "Blauw",
|
||||||
|
"green": "Groen",
|
||||||
|
"red": "Rood",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"cyan": "Cyaan",
|
||||||
|
"magenta": "Magenta"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ export const Spinner = ({
|
|||||||
const r = 14 - strokeWidth
|
const r = 14 - strokeWidth
|
||||||
const c = 2 * r * Math.PI
|
const c = 2 * r * Math.PI
|
||||||
return (
|
return (
|
||||||
<ProgressBar aria-label="Loading..." value={30}>
|
<ProgressBar
|
||||||
|
aria-label="Loading..."
|
||||||
|
value={30}
|
||||||
|
style={{ height: size, width: size }}
|
||||||
|
>
|
||||||
{({ percentage }) => (
|
{({ percentage }) => (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export const text = cva({
|
|||||||
textStyle: 'body',
|
textStyle: 'body',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
|
bodyXsMedium: {
|
||||||
|
textStyle: 'body',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
},
|
||||||
body: {
|
body: {
|
||||||
textStyle: 'body',
|
textStyle: 'body',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,14 +10,65 @@ export const CAPTION_TEXT_SIZE_OPTIONS: CaptionTextSize[] = [
|
|||||||
'large',
|
'large',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export type CaptionColor =
|
||||||
|
| 'default'
|
||||||
|
| 'white'
|
||||||
|
| 'black'
|
||||||
|
| 'blue'
|
||||||
|
| 'green'
|
||||||
|
| 'red'
|
||||||
|
| 'yellow'
|
||||||
|
| 'cyan'
|
||||||
|
| 'magenta'
|
||||||
|
|
||||||
|
export const CAPTION_COLOR_OPTIONS: CaptionColor[] = [
|
||||||
|
'default',
|
||||||
|
'white',
|
||||||
|
'black',
|
||||||
|
'blue',
|
||||||
|
'green',
|
||||||
|
'red',
|
||||||
|
'yellow',
|
||||||
|
'cyan',
|
||||||
|
'magenta',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
|
||||||
|
default: '#FFFFFF',
|
||||||
|
white: '#FFFFFF',
|
||||||
|
black: '#000000',
|
||||||
|
blue: '#0000FF',
|
||||||
|
green: '#00FF00',
|
||||||
|
red: '#FF0000',
|
||||||
|
yellow: '#FFFF00',
|
||||||
|
cyan: '#00FFFF',
|
||||||
|
magenta: '#FF00FF',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
|
||||||
|
default: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
black: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
white: 'rgba(255, 255, 255, 0.75)',
|
||||||
|
blue: 'rgba(0, 0, 255, 0.75)',
|
||||||
|
green: 'rgba(0, 255, 0, 0.75)',
|
||||||
|
red: 'rgba(255, 0, 0, 0.75)',
|
||||||
|
yellow: 'rgba(255, 255, 0, 0.75)',
|
||||||
|
cyan: 'rgba(0, 255, 255, 0.75)',
|
||||||
|
magenta: 'rgba(255, 0, 255, 0.75)',
|
||||||
|
}
|
||||||
|
|
||||||
type AccessibilityState = {
|
type AccessibilityState = {
|
||||||
announceReactions: boolean
|
announceReactions: boolean
|
||||||
captionTextSize: CaptionTextSize
|
captionTextSize: CaptionTextSize
|
||||||
|
captionFontColor: CaptionColor
|
||||||
|
captionBackgroundColor: CaptionColor
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STATE: AccessibilityState = {
|
const DEFAULT_STATE: AccessibilityState = {
|
||||||
announceReactions: false,
|
announceReactions: false,
|
||||||
captionTextSize: 'medium',
|
captionTextSize: 'medium',
|
||||||
|
captionFontColor: 'default',
|
||||||
|
captionBackgroundColor: 'default',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccessibilityState(): AccessibilityState {
|
function getAccessibilityState(): AccessibilityState {
|
||||||
@@ -25,10 +76,21 @@ function getAccessibilityState(): AccessibilityState {
|
|||||||
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
|
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored)
|
const parsed = JSON.parse(stored)
|
||||||
const validCaptionSizes = CAPTION_TEXT_SIZE_OPTIONS
|
const captionTextSize = CAPTION_TEXT_SIZE_OPTIONS.includes(
|
||||||
const captionTextSize = validCaptionSizes.includes(parsed.captionTextSize)
|
parsed.captionTextSize
|
||||||
|
)
|
||||||
? parsed.captionTextSize
|
? parsed.captionTextSize
|
||||||
: DEFAULT_STATE.captionTextSize
|
: DEFAULT_STATE.captionTextSize
|
||||||
|
const captionFontColor = CAPTION_COLOR_OPTIONS.includes(
|
||||||
|
parsed.captionFontColor
|
||||||
|
)
|
||||||
|
? parsed.captionFontColor
|
||||||
|
: DEFAULT_STATE.captionFontColor
|
||||||
|
const captionBackgroundColor = CAPTION_COLOR_OPTIONS.includes(
|
||||||
|
parsed.captionBackgroundColor
|
||||||
|
)
|
||||||
|
? parsed.captionBackgroundColor
|
||||||
|
: DEFAULT_STATE.captionBackgroundColor
|
||||||
return {
|
return {
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
...parsed,
|
...parsed,
|
||||||
@@ -37,6 +99,8 @@ function getAccessibilityState(): AccessibilityState {
|
|||||||
? parsed.announceReactions
|
? parsed.announceReactions
|
||||||
: DEFAULT_STATE.announceReactions,
|
: DEFAULT_STATE.announceReactions,
|
||||||
captionTextSize,
|
captionTextSize,
|
||||||
|
captionFontColor,
|
||||||
|
captionBackgroundColor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { proxy, subscribe } from 'valtio'
|
import { proxy, subscribe } from 'valtio'
|
||||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
import {
|
||||||
|
ProcessorConfig,
|
||||||
|
ProcessorType,
|
||||||
|
} from '@/features/rooms/livekit/components/blur'
|
||||||
import {
|
import {
|
||||||
loadUserChoices,
|
loadUserChoices,
|
||||||
saveUserChoices,
|
|
||||||
LocalUserChoices as LocalUserChoicesLK,
|
LocalUserChoices as LocalUserChoicesLK,
|
||||||
|
saveUserChoices,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { VideoQuality } from 'livekit-client'
|
import { VideoQuality } from 'livekit-client'
|
||||||
|
|
||||||
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||||
|
|
||||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||||
processorSerialized?: ProcessorSerialized
|
processorConfig?: ProcessorConfig
|
||||||
noiseReductionEnabled?: boolean
|
noiseReductionEnabled?: boolean
|
||||||
audioOutputDeviceId?: string
|
audioOutputDeviceId?: string
|
||||||
videoPublishResolution?: VideoResolution
|
videoPublishResolution?: VideoResolution
|
||||||
@@ -28,7 +31,33 @@ function getUserChoicesState(): LocalUserChoices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
||||||
|
|
||||||
subscribe(userChoicesStore, () => {
|
subscribe(userChoicesStore, () => {
|
||||||
saveUserChoices(userChoicesStore, false)
|
saveUserChoices(userChoicesStore, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// we run some logic on store loading to check if the processor config is still valid
|
||||||
|
if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
|
||||||
|
if (userChoicesStore.processorConfig.imagePath.startsWith('blob:')) {
|
||||||
|
// this happens when a not authenticated user had changed their background image
|
||||||
|
// we restore clear the processor config to avoid displaying a black screen.
|
||||||
|
userChoicesStore.processorConfig = undefined
|
||||||
|
} else if (userChoicesStore.processorConfig.fileId) {
|
||||||
|
// Checking if the image is still available / accessible
|
||||||
|
await fetch(userChoicesStore.processorConfig.imagePath, {
|
||||||
|
// We bypass the cache to ensure we have access
|
||||||
|
cache: 'reload',
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
// if we cannot fetch the image (likely a 401 from the backend because
|
||||||
|
// the user is not logged in anymore, etc.),
|
||||||
|
// we clear the processor config to avoid displaying a black screen.
|
||||||
|
// This can happen when the user logs out for instance, etc.
|
||||||
|
if (!response.ok) {
|
||||||
|
userChoicesStore.processorConfig = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
userChoicesStore.processorConfig = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,3 +95,14 @@ html:has(.lk-video-conference) {
|
|||||||
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
|
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
|
||||||
U+2212, U+2215, U+FEFF, U+FFFD;
|
U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hoverGroup .hoverGroupChild {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hoverGroup:hover .hoverGroupChild,
|
||||||
|
.hoverGroup:focus-within .hoverGroupChild {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export default defineConfig(({ mode }) => {
|
|||||||
port: parseInt(env.VITE_PORT) || 3000,
|
port: parseInt(env.VITE_PORT) || 3000,
|
||||||
host: env.VITE_HOST ?? 'localhost',
|
host: env.VITE_HOST ?? 'localhost',
|
||||||
allowedHosts: ['.nip.io'],
|
allowedHosts: ['.nip.io'],
|
||||||
|
// In a local dev setup, we proxy the media server ourselves to avoid CORS issues
|
||||||
|
proxy: {
|
||||||
|
'/media': {
|
||||||
|
target: 'http://localhost:8083',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.5.4",
|
"@html-to/text-cli": "0.5.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.10.0"
|
version = "1.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
@@ -21,8 +21,16 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.14.4",
|
"ruff==0.14.4",
|
||||||
|
"pytest==9.0.2",
|
||||||
|
"responses>=0.25.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
markers = [
|
||||||
|
"api: Test the API",
|
||||||
|
]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=61.0"]
|
requires = ["setuptools>=61.0"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
@@ -49,6 +57,7 @@ select = [
|
|||||||
"T20", # flake8-print
|
"T20", # flake8-print
|
||||||
"W", # pycodestyle warning
|
"W", # pycodestyle warning
|
||||||
]
|
]
|
||||||
|
ignore= ["PLR2004"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/*" = [
|
"tests/*" = [
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the summary service."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the API summary service."""
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Integration tests for the health check endpoints."""
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeartbeat:
|
||||||
|
"""Tests for the /__heartbeat__ endpoint."""
|
||||||
|
|
||||||
|
def test_returns_200(self, client):
|
||||||
|
"""The heartbeat endpoint responds with 200 OK without a token."""
|
||||||
|
response = client.get("/__heartbeat__")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestLBHeartbeat:
|
||||||
|
"""Tests for the /__lbheartbeat__ endpoint."""
|
||||||
|
|
||||||
|
def test_returns_200(self, client):
|
||||||
|
"""The load-balancer heartbeat endpoint responds with 200 OK without a token."""
|
||||||
|
response = client.get("/__lbheartbeat__")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Integration tests for the API tasks endpoints."""
|
||||||
|
|
||||||
|
# tests/unit/test_api_tasks.py
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
class TestTasks:
|
||||||
|
"""Tests for the /tasks endpoint."""
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"summary.api.route.tasks.process_audio_transcribe_summarize_v2.apply_async",
|
||||||
|
return_value=MagicMock(id="task-id-abc"),
|
||||||
|
)
|
||||||
|
@patch("summary.api.route.tasks.time.time", return_value=1735725600.0)
|
||||||
|
def test_create_task_returns_task_id(self, mock_time, mock_apply_async, client):
|
||||||
|
"""POST /tasks/ with valid payload returns id and dispatches Celery task."""
|
||||||
|
response = client.post(
|
||||||
|
"api/v1/tasks/",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
json={
|
||||||
|
"owner_id": "owner-123",
|
||||||
|
"filename": "recording.mp4",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"sub": "sub-123",
|
||||||
|
"room": "room-abc",
|
||||||
|
"recording_date": "2026-01-01",
|
||||||
|
"recording_time": "10:00:00",
|
||||||
|
"language": None,
|
||||||
|
"download_link": "http://example.com/file.mp4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"id": "task-id-abc", "message": "Task created"}
|
||||||
|
|
||||||
|
args = mock_apply_async.call_args.kwargs["args"]
|
||||||
|
assert args == [
|
||||||
|
"owner-123", # owner_id
|
||||||
|
"recording.mp4", # filename
|
||||||
|
"user@example.com", # email
|
||||||
|
"sub-123", # sub
|
||||||
|
1735725600.0, # frozen time
|
||||||
|
"room-abc", # room
|
||||||
|
"2026-01-01", # recording_date
|
||||||
|
"10:00:00", # recording_time
|
||||||
|
None, # language
|
||||||
|
"http://example.com/file.mp4", # download_link
|
||||||
|
None, # context_language
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_create_task_invalid_language(self, client):
|
||||||
|
"""POST /tasks/ with an unsupported language returns 422."""
|
||||||
|
payload = {"language": "klingon"}
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/tasks/",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"summary.api.route.tasks.AsyncResult",
|
||||||
|
return_value=MagicMock(status="PENDING"),
|
||||||
|
)
|
||||||
|
def test_get_task_status_pending(self, mock_result, client):
|
||||||
|
"""GET /tasks/{id} returns PENDING status when the task has not started yet."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/tasks/task-id-abc",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"id": "task-id-abc", "status": "PENDING"}
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"summary.api.route.tasks.AsyncResult",
|
||||||
|
return_value=MagicMock(status="SUCCESS"),
|
||||||
|
)
|
||||||
|
def test_get_task_status_success(self, mock_result, client):
|
||||||
|
"""GET /tasks/{id} returns SUCCESS status when the task has completed."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/tasks/task-id-abc",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "SUCCESS"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Integration test configuration. Provides shared fixtures."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from summary.core.config import Settings, get_settings
|
||||||
|
from summary.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_override():
|
||||||
|
"""Return settings for tests."""
|
||||||
|
return Settings(
|
||||||
|
app_api_token=SecretStr("test-api-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
"""Provide a FastAPI TestClient for tests."""
|
||||||
|
client = TestClient(app)
|
||||||
|
app.dependency_overrides[get_settings] = get_settings_override
|
||||||
|
|
||||||
|
return client
|
||||||
Reference in New Issue
Block a user