Compare commits

..

22 Commits

Author SHA1 Message Date
Michel-Marie MAUDET 0aa2b80be9 📝(docs) add recording delegate design spec
Design spec for a lightweight recording delegation system as an
alternative approach to PR #794's global permission model.
Instead of opening recording rights to all authenticated users,
this proposes granular per-user delegation with request/approve
flow and auto-approval when no admin is present.
2026-03-20 06:19:36 +01:00
Florent Chehab 04be495351 💄(custom-background) add upload indicator with preview
When uploading an image, depending on the available network
there might be a bit of wait while the image is being uploaded.
In this commit we add a preview (grayscale + spinner) to have
UI feedback that the upload is in progress.
2026-03-19 17:27:51 +01:00
Florent Chehab 43185605eb 💄(spinner) enforce spinner height
For some reason the ProgressBar adds a bit of height to the spinner
which makes it hard to center.
2026-03-19 17:27:21 +01:00
Florent Chehab cf3fb208e2 🐛(frontend) auto-select new custom background when not logged in
When not logged in and selecting a new custom personal background
the constant id was causing the new custom background not to be auto
selected.
2026-03-19 17:27:21 +01:00
Florent Chehab 4ca230eb12 🐛(frontend) disable personal custom background while deleting
Prevent users selecting a personal custom background while deleting one.
2026-03-19 17:27:20 +01:00
lebaudantoine 4b5e0cb2a3 ️(frontend) improve button descriptions for More tools actions #1184
The "Transcribe" and "Record" buttons had unclear and misleading
descriptions, both using the verb "record," which caused confusion,
especially for screen reader users.

Update descriptions to clearly reflect each action:
- Transcribe: generate a written transcript of the conversation
- Record: save the meeting as a video

This improves accessibility (RGAA 11.9) and reduces the risk of
users triggering the wrong action.

Closes #1173
2026-03-19 14:48:02 +01:00
lebaudantoine 45f374610f ️(frontend) fix more tools heading hierarchy
Side panel title is an H1, but the hierarchy skips directly to H3.
Fix the heading structure. It closes #1178.
2026-03-19 11:56:16 +01:00
lebaudantoine b419a2bfd2 ️(frontend) fix sidepanel accessibility aria-label
The aria-label only announced the presence of a sidepanel without
including its title.

Append the sidepanel title to improve accessibility and context
for screen readers.

Closes #1176.
2026-03-19 11:21:40 +01:00
lebaudantoine ee8d96bee7 🔖(minor) bump release to 1.11.0 2026-03-19 00:31:32 +01:00
lebaudantoine c65ff2d75d 🩹(frontend) disable subtitle settings when feature is unavailable
Hide or disable settings related to the subtitle feature when
the feature flag is not enabled.
2026-03-18 23:25:20 +01:00
Cyril ea1c90d8ca (feat) add default color option for captions
Add "Default" option (white text on black background) for font and background.
2026-03-18 20:44:25 +01:00
Cyril 3e963e3e6d ♻️(refactor) apply caption color customization to subtitles
Use captionFontColor and captionBackgroundColor in Transcription component.
2026-03-18 20:44:25 +01:00
Cyril 82769128a1 (feat) add caption font color and background settings
Select font color and background color in Accessibility > Captions.
2026-03-18 20:44:25 +01:00
Cyril 05f4ce6b2e 🌐(i18n) add captions color settings translations
EN, FR, DE, NL for Accessibility > Captions > Font color / Background color.
2026-03-18 20:44:25 +01:00
Cyril 508984ecfa ️(frontend) add caption font color and background to access store
Persist captionFontColor and captionBackgroundColor in user preferences.
2026-03-18 20:44:25 +01:00
lebaudantoine a05507f73d 👷(ci) run summary test suite
Configure the CI to run the summary tests' suite.
2026-03-18 20:19:52 +01:00
lebaudantoine a27def119c (summary) add tests for the task endpoint
Ensure the API correctly validates and accepts the parameters
required to register a task in the queue.
2026-03-18 20:19:52 +01:00
lebaudantoine fce94f38ce 🧑‍💻(summary) add make command to run summary test suite
Introduce a dedicated make target to execute tests for the
summary component of the stack.
2026-03-18 20:19:52 +01:00
lebaudantoine 83bd9c5ce3 (summary) add minimal test suite for health endpoint
Based on cameldev's work, introduce basic tests to ensure the
health endpoint responds correctly.
2026-03-18 20:19:52 +01:00
Florent Chehab bfbfade99a 🐛(frontend) prevent black background image
Prevent showing a black background when the image is not
accessible anymore.

This happens after the user logs out or logs in.
Or the auth is revoked.
2026-03-18 18:49:59 +01:00
Florent Chehab 16daf7b8d3 (frontend) add custom virtual background feature
Add a custom virtual background feature.

If the backend supports uploading files, backgrounds are stored
in the backend for the user.
Otherwise, only one background image can be selected.
2026-03-18 18:49:59 +01:00
lebaudantoine fcad79d662 📌(agents) unpin OpenSSL and related dependencies
The base image now includes OpenSSL 3.5.5, which resolves
CVE-2025-15467.

Remove explicit pinning of OpenSSL and its dependencies.
2026-03-18 11:34:44 +01:00
59 changed files with 2036 additions and 483 deletions
+40
View File
@@ -297,6 +297,46 @@ jobs:
- name: Run tests
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:
runs-on: ubuntu-latest
permissions:
+20 -3
View File
@@ -8,6 +8,21 @@ and this project adheres to
## [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
- ✨(helm) support celery with our Django backend #1124
@@ -15,14 +30,15 @@ and this project adheres to
- ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
- ✨(frontend) custom background #1067
### Changed
- ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
- ♿(frontend) improve chat toast a11y for screen readers #1109
- ♿(frontend) improve ui and aria labels for help article links #1108
-(frontend) improve chat toast a11y for screen readers #1109
-(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125
- 🔨(python-env) migrate meet main app to UV #1120
- ♻️(backend) align Application model field with `is_active` convention #1133
@@ -30,6 +46,8 @@ and this project adheres to
- ⚡️(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
@@ -39,7 +57,6 @@ and this project adheres to
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
### Changed
+6
View File
@@ -191,6 +191,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
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}}
.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.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
@@ -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
+1 -1
View File
@@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
# OIDC
+5 -8
View File
@@ -1,14 +1,11 @@
FROM python:3.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgobject-2.0-0 \
&& apt-get upgrade -y openssl libssl3t64 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
WORKDIR /builder
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.10.0"
version = "1.11.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.10.0"
version = "1.11.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1157,7 +1157,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.10.0"
version = "1.11.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.10.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.10.0",
"version": "1.11.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.10.0",
"version": "1.11.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+13 -1
View File
@@ -15,7 +15,19 @@ export const fetchApi = async <T = Record<string, unknown>>(
...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) {
throw new ApiError(response.status, result)
}
+1
View File
@@ -5,4 +5,5 @@ export const keys = {
requestEntry: 'requestEntry',
waitingParticipants: 'waitingParticipants',
roomCreationCallback: 'roomCreationCallback',
files: 'files',
}
+7
View File
@@ -30,6 +30,13 @@ export interface ApiConfig {
expiration_days?: 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: {
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}
video={
userConfig.videoEnabled && {
processor: BackgroundProcessorFactory.deserializeProcessor(
userConfig.processorSerialized
processor: BackgroundProcessorFactory.fromProcessorConfig(
userConfig.processorConfig
),
}
}
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
createLocalVideoTrack,
createLocalAudioTrack,
createLocalVideoTrack,
LocalAudioTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { H } from '@/primitives/H'
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 { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
@@ -44,8 +44,7 @@ const onError = (e: Error) => console.error('ERROR', e)
const Effects = ({
videoTrack,
onSubmit,
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
const [isDialogOpen, setIsDialogOpen] = useState(false)
const openDialog = () => setIsDialogOpen(true)
@@ -81,7 +80,7 @@ const Effects = ({
>
{t('subTitle')}
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
<EffectsConfiguration videoTrack={videoTrack} />
</Dialog>
<Button
variant="whiteCircle"
@@ -111,7 +110,7 @@ export const Join = ({
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
processorConfig,
username,
},
saveAudioInputEnabled,
@@ -120,7 +119,6 @@ export const Join = ({
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null)
@@ -132,7 +130,7 @@ export const Join = ({
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
processorConfig,
username,
}
}
@@ -146,8 +144,8 @@ export const Join = ({
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor: BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.current.processorSerialized
processor: BackgroundProcessorFactory.fromProcessorConfig(
initialUserChoices.current.processorConfig
),
},
},
@@ -186,9 +184,7 @@ export const Join = ({
const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
setDynamicVideoTrack(track)
} catch (error) {
@@ -207,7 +203,7 @@ export const Join = ({
}, [
videoEnabled,
videoDeviceId,
processorSerialized,
processorConfig,
previewVideoTrack,
dynamicVideoTrack,
])
@@ -690,12 +686,7 @@ export const Join = ({
zIndex: '1',
})}
>
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
<Effects videoTrack={videoTrack} />
</div>
</div>
</div>
@@ -149,11 +149,12 @@ export const SidePanel = () => {
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const title = t(`heading.${activeSubPanelId || activePanelId}`)
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
title={title}
ariaLabel={t('ariaLabel', { title })}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -65,7 +65,7 @@ const ToolButton = ({
<div>
<Text
margin={false}
as="h3"
as="h2"
className={css({
display: 'flex',
gap: 0.25,
@@ -11,11 +11,7 @@ import {
TIMEOUT_TICK,
timerWorkerScript,
} from './TimerWorker'
import {
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -33,7 +29,7 @@ const DEFAULT_BLUR = '10'
* MediaStreamTrackProcessor.
*/
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
options: BackgroundOptions
options: ProcessorConfig
name: string
processedTrack?: MediaStreamTrack | undefined
@@ -64,15 +60,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
type: ProcessorType
virtualBackgroundImage?: HTMLImageElement
constructor(opts: BackgroundOptions) {
constructor(opts: ProcessorConfig) {
this.name = 'blur'
this.options = opts
if (this.options.blurRadius) {
this.type = ProcessorType.BLUR
} else {
this.type = ProcessorType.VIRTUAL
}
this.type = opts.type
}
static get isSupported() {
@@ -107,6 +98,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
}
_initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') {
throw new Error(
'Virtual background is only supported for virtual background'
)
}
const needsUpdate =
this.options.imagePath &&
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._initVirtualBackgroundImage()
}
@@ -212,6 +209,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
* TODO: future improvement with WebGL.
*/
async blur() {
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
}
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.sizeSource()
await this.segment()
if (this.options.blurRadius) {
if (this.options.type === 'blur') {
await this.blur()
} else {
await this.drawVirtualBackground()
@@ -354,15 +354,4 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.timerWorker?.terminate()
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.faceLandmarker?.close()
}
clone() {
return new FaceLandmarksProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
}
@@ -1,28 +1,23 @@
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundBlur,
BackgroundTransformer,
ProcessorWrapper,
VirtualBackground,
} from '@livekit/track-processors'
import {
BackgroundOptions,
BackgroundProcessorInterface,
ProcessorType,
} from '.'
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
opts: ProcessorConfig
processorType: ProcessorType
constructor(opts: BackgroundOptions) {
constructor(opts: ProcessorConfig) {
this.opts = opts
if (opts.imagePath) {
if (opts.type === 'virtual') {
this.processorType = ProcessorType.VIRTUAL
this.processor = VirtualBackground(opts.imagePath)
} else if (opts.blurRadius !== undefined) {
} else if (opts.type === 'blur') {
this.processorType = ProcessorType.BLUR
this.processor = BackgroundBlur(opts.blurRadius)
} else {
@@ -44,24 +39,23 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
return this.processor.destroy()
}
async update(opts: BackgroundOptions): Promise<void> {
const newProcessorType = opts.imagePath
? ProcessorType.VIRTUAL
: ProcessorType.BLUR
async update(opts: ProcessorConfig): Promise<void> {
this.opts = opts
const newProcessorType =
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
let processedOpts = opts
if (newProcessorType !== this.processorType) {
this.processorType = newProcessorType
if (newProcessorType === ProcessorType.VIRTUAL) {
this.processor.name = 'virtual-background'
processedOpts = { ...opts, blurRadius: undefined }
} else {
this.processor.name = 'background-blur'
processedOpts = { ...opts, imagePath: undefined }
}
}
await this.processor.updateTransformerOptions(processedOpts)
this.opts = processedOpts
await this.processor.updateTransformerOptions(
opts as { imagePath?: string; blurRadius?: number }
)
}
get name() {
@@ -73,17 +67,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
}
get options() {
return (this.processor.transformer as BackgroundTransformer).options
}
clone() {
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
}
serialize() {
return {
type: this.processorType,
options: this.options,
}
return this.opts
}
}
@@ -2,23 +2,7 @@ import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
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
}
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
export enum ProcessorType {
BLUR = 'blur',
@@ -26,6 +10,16 @@ export enum ProcessorType {
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 {
static hasModernApiSupport() {
return ProcessorWrapper.hasModernApiSupport
@@ -36,28 +30,27 @@ export class BackgroundProcessorFactory {
}
static getProcessor(
type: ProcessorType,
opts: BackgroundOptions
config: ProcessorConfig
): BackgroundProcessorInterface | undefined {
const isBlur = type === ProcessorType.BLUR
const isVirtual = type === ProcessorType.VIRTUAL
const isBlur = config.type === ProcessorType.BLUR
const isVirtual = config.type === ProcessorType.VIRTUAL
if (!isBlur && !isVirtual) return undefined
if (ProcessorWrapper.isSupported) {
return new UnifiedBackgroundTrackProcessor(opts)
return new UnifiedBackgroundTrackProcessor(config)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
return new BackgroundCustomProcessor(config)
}
return undefined
}
static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type) {
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
static fromProcessorConfig(data?: ProcessorConfig) {
if (data) {
return BackgroundProcessorFactory.getProcessor(data)
}
return undefined
}
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
const processor = BackgroundProcessorFactory.fromProcessorConfig(
userChoices.processorConfig
)
const toggle = trackProps.toggle as (
@@ -2,14 +2,12 @@ import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { css } from '@/styled-system/css'
import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
@@ -24,9 +22,6 @@ export const Effects = () => {
isDisabled={!canPublishCamera}
videoTrack={localCameraTrack}
layout="vertical"
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
)
File diff suppressed because it is too large Load Diff
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
showFrench: false,
}
}
return processor.serialize().options
return { ...processor.options }
}
const options = getOptions()
@@ -1,7 +1,7 @@
import { useSnapshot } from 'valtio'
import { userChoicesStore } 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'
export function usePersistentUserChoices() {
@@ -36,10 +36,8 @@ export function usePersistentUserChoices() {
saveNoiseReductionEnabled: (enabled: boolean) => {
userChoicesStore.noiseReductionEnabled = enabled
},
saveProcessorSerialized: (
processorSerialized: ProcessorSerialized | undefined
) => {
userChoicesStore.processorSerialized = processorSerialized
saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
userChoicesStore.processorConfig = processorConfig
},
}
}
@@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const {
userChoices: {
videoDeviceId,
processorSerialized,
processorConfig,
videoPublishResolution,
videoSubscribeQuality,
},
@@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
})
}
}
@@ -6,8 +6,11 @@ import { useSnapshot } from 'valtio'
import {
accessibilityStore,
type CaptionTextSize,
type CaptionColor,
CAPTION_TEXT_SIZE_OPTIONS,
CAPTION_COLOR_OPTIONS,
} from '@/stores/accessibility'
import { useAreSubtitlesAvailable } from '../hooks/useAreSubtitlesAvailable'
export const CaptionsSettings = () => {
const { t } = useTranslation('settings', {
@@ -24,6 +27,27 @@ export const CaptionsSettings = () => {
[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 (
<li>
<H
@@ -34,16 +58,44 @@ export const CaptionsSettings = () => {
>
{t('heading')}
</H>
<Field
type="select"
label={t('textSize.label')}
items={captionTextSizeItems}
selectedKey={snap.captionTextSize}
onSelectionChange={(key) => {
accessibilityStore.captionTextSize = key as CaptionTextSize
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<Field
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>
)
}
@@ -12,6 +12,8 @@ import { useSnapshot } from 'valtio'
import {
accessibilityStore,
CAPTION_TEXT_SIZE_OPTIONS,
CAPTION_FONT_COLOR_VALUES,
CAPTION_BACKGROUND_COLOR_VALUES,
type CaptionTextSize,
} from '@/stores/accessibility'
@@ -92,10 +94,14 @@ const useTranscriptionState = () => {
}
const Transcription = ({ row }: { row: TranscriptionRow }) => {
const { captionTextSize } = useSnapshot(accessibilityStore)
const { captionTextSize, captionFontColor, captionBackgroundColor } =
useSnapshot(accessibilityStore)
const participantColor = getParticipantColor(row.participant)
const participantName = getParticipantName(row.participant)
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 => {
return row.segments
@@ -128,9 +134,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
/>
<div
className={css({
color: 'white',
width: '100%',
})}
style={{ color: fontColor }}
>
<Text variant="h3" margin={false}>
{participantName}
@@ -138,8 +144,10 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
<p
className={css({
fontWeight: '400',
borderRadius: '4px',
padding: '0.125rem 0.25rem',
})}
style={{ fontSize, lineHeight }}
style={{ fontSize, lineHeight, backgroundColor }}
>
{displayText}
</p>
+36 -13
View File
@@ -273,17 +273,40 @@
"title": "Virtueller Hintergrund",
"selectedLabel": "Hintergrund angewendet:",
"apply": "Ersetze deinen Hintergrund:",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
"personal": {
"title": "Meine Hintergründe",
"selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll",
"notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.",
"warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.",
"uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.",
"uploadInProgress": "Datei wird hochgeladen…",
"errors": {
"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": {
"title": "Visuelle Effekte",
"glasses": {
@@ -297,7 +320,7 @@
}
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"ariaLabel": "Seitenleiste - {{title}}",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmende",
@@ -332,11 +355,11 @@
"tools": {
"transcript": {
"title": "Transkribieren",
"body": "Das Gespräch aufzeichnen."
"body": "Wandelt Meetings in Text um."
},
"screenRecording": {
"title": "Aufzeichnen",
"body": "Das Meeting aufzeichnen."
"title": "Aufnehmen",
"body": "Speichert Meetings als Video."
}
}
},
+28
View File
@@ -126,6 +126,34 @@
"medium": "Mittel",
"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"
}
}
}
},
+35 -13
View File
@@ -270,18 +270,40 @@
}
},
"virtual": {
"title": "Virtual background",
"title": "Virtual backgrounds",
"selectedLabel": "Background applied:",
"apply": "Replace your background:",
"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"
"personal": {
"title": "My backgrounds",
"selectFileTooltip": "Select an image file to use as a personal background",
"notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.",
"warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.",
"uploadLimitReached": "You cannot upload more personal backgrounds.",
"uploadInProgress": "Image is being uploaded…",
"errors": {
"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": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"ariaLabel": "Sidepanel - {{title}}",
"backToTools": "Back to meeting tools",
"heading": {
"participants": "Participants",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcribe",
"body": "Record the conversation."
"body": "Turn meetings into text."
},
"screenRecording": {
"title": "Record",
"body": "Record the meeting."
"body": "Save meetings as video."
}
}
},
+28
View File
@@ -126,6 +126,34 @@
"medium": "Medium",
"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"
}
}
}
},
+35 -13
View File
@@ -270,18 +270,40 @@
}
},
"virtual": {
"title": "Arrière-plan virtuel",
"title": "Arrière-plans virtuels",
"selectedLabel": "Arrière-plan appliqué :",
"apply": "Remplacer votre arrière plan :",
"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é"
"personal": {
"title": "Mes arrière-plans",
"selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan",
"notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.",
"warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.",
"uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.",
"uploadInProgress": "Image en cours d'envoi…",
"errors": {
"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": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Panneau latéral",
"ariaLabel": "Panneau latéral - {{title}}",
"backToTools": "Retour aux outils de réunion",
"heading": {
"participants": "Participants",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcrire",
"body": "Enregistrer la conversation."
"body": "Transcrire la réunion."
},
"screenRecording": {
"title": "Enregistrer",
"body": "Enregistrer la réunion."
"body": "Enregistrer la réunion en vidéo."
}
}
},
+28
View File
@@ -126,6 +126,34 @@
"medium": "Moyen",
"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"
}
}
}
},
+34 -12
View File
@@ -273,15 +273,37 @@
"title": "Virtuele achtergrond",
"selectedLabel": "Achtergrond toegepast:",
"apply": "Vervang je achtergrond:",
"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"
"personal": {
"title": "Mijn achtergronden",
"selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond",
"notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.",
"warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.",
"uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.",
"uploadInProgress": "Afbeelding wordt geüpload…",
"errors": {
"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": {
@@ -297,7 +319,7 @@
}
},
"sidePanel": {
"ariaLabel": "Zijbalk",
"ariaLabel": "Zijbalk - {{title}}",
"backToTools": "Terug naar vergadertools",
"heading": {
"participants": "Deelnemers",
@@ -332,11 +354,11 @@
"tools": {
"transcript": {
"title": "Transcriberen",
"body": "Het gesprek opnemen."
"body": "Zet vergaderingen om in tekst."
},
"screenRecording": {
"title": "Opnemen",
"body": "De vergadering opnemen."
"body": "Sla vergaderingen op als video."
}
}
},
+28
View File
@@ -126,6 +126,34 @@
"medium": "Gemiddeld",
"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"
}
}
}
},
+5 -1
View File
@@ -20,7 +20,11 @@ export const Spinner = ({
const r = 14 - strokeWidth
const c = 2 * r * Math.PI
return (
<ProgressBar aria-label="Loading..." value={30}>
<ProgressBar
aria-label="Loading..."
value={30}
style={{ height: size, width: size }}
>
{({ percentage }) => (
<div
className={css({
+4
View File
@@ -36,6 +36,10 @@ export const text = cva({
textStyle: 'body',
fontWeight: 'bold',
},
bodyXsMedium: {
textStyle: 'body',
fontWeight: 'medium',
},
body: {
textStyle: 'body',
},
+66 -2
View File
@@ -10,14 +10,65 @@ export const CAPTION_TEXT_SIZE_OPTIONS: CaptionTextSize[] = [
'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 = {
announceReactions: boolean
captionTextSize: CaptionTextSize
captionFontColor: CaptionColor
captionBackgroundColor: CaptionColor
}
const DEFAULT_STATE: AccessibilityState = {
announceReactions: false,
captionTextSize: 'medium',
captionFontColor: 'default',
captionBackgroundColor: 'default',
}
function getAccessibilityState(): AccessibilityState {
@@ -25,10 +76,21 @@ function getAccessibilityState(): AccessibilityState {
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
if (stored) {
const parsed = JSON.parse(stored)
const validCaptionSizes = CAPTION_TEXT_SIZE_OPTIONS
const captionTextSize = validCaptionSizes.includes(parsed.captionTextSize)
const captionTextSize = CAPTION_TEXT_SIZE_OPTIONS.includes(
parsed.captionTextSize
)
? parsed.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 {
...DEFAULT_STATE,
...parsed,
@@ -37,6 +99,8 @@ function getAccessibilityState(): AccessibilityState {
? parsed.announceReactions
: DEFAULT_STATE.announceReactions,
captionTextSize,
captionFontColor,
captionBackgroundColor,
}
}
+33 -4
View File
@@ -1,16 +1,19 @@
import { proxy, subscribe } from 'valtio'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import {
ProcessorConfig,
ProcessorType,
} from '@/features/rooms/livekit/components/blur'
import {
loadUserChoices,
saveUserChoices,
LocalUserChoices as LocalUserChoicesLK,
saveUserChoices,
} from '@livekit/components-core'
import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
processorConfig?: ProcessorConfig
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
videoPublishResolution?: VideoResolution
@@ -28,7 +31,33 @@ function getUserChoicesState(): LocalUserChoices {
}
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
subscribe(userChoicesStore, () => {
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
})
}
}
+11
View File
@@ -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+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;
}
+8
View File
@@ -14,6 +14,14 @@ export default defineConfig(({ mode }) => {
port: parseInt(env.VITE_PORT) || 3000,
host: env.VITE_HOST ?? 'localhost',
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
}
}
},
}
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.10.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.10.0",
"version": "1.11.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.10.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.10.0",
"version": "1.11.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.10.0",
"version": "1.11.0",
"author": "",
"license": "ISC",
"description": "",
+10 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.10.0"
version = "1.11.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -21,8 +21,16 @@ dependencies = [
[project.optional-dependencies]
dev = [
"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]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
@@ -49,6 +57,7 @@ select = [
"T20", # flake8-print
"W", # pycodestyle warning
]
ignore= ["PLR2004"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
+1
View File
@@ -0,0 +1 @@
"""Tests for the summary service."""
+1
View File
@@ -0,0 +1 @@
"""Tests for the API summary service."""
+21
View File
@@ -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
+88
View File
@@ -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"
+24
View File
@@ -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