mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a00e3a72e | |||
| efa0d48598 | |||
| 873cc083ab | |||
| 8fc7ccf33e | |||
| d650dbc988 | |||
| c08411d133 | |||
| cb4502354e |
@@ -48,12 +48,12 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
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 }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# 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 }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -93,12 +93,12 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
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 }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# 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 }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -139,12 +139,12 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
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 }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# 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 }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -185,13 +185,13 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
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 }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# continue-on-error: true
|
||||
# with:
|
||||
# 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-context: './src/summary'
|
||||
-
|
||||
name: Build and push
|
||||
@@ -233,14 +233,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
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-context: './src/agents'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# continue-on-error: true
|
||||
# with:
|
||||
# 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-context: './src/agents'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
|
||||
test-back:
|
||||
test-backend:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
permissions:
|
||||
@@ -294,7 +294,7 @@ jobs:
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: uv run python manage.py compilemessages
|
||||
|
||||
- name: Run tests
|
||||
- name: Run backend tests
|
||||
run: uv run pytest -n 2
|
||||
|
||||
test-summary:
|
||||
@@ -305,22 +305,6 @@ jobs:
|
||||
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
|
||||
|
||||
+3
-8
@@ -8,8 +8,6 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.11.0] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) support celery with our Django backend #1124
|
||||
@@ -17,24 +15,20 @@ 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
|
||||
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
||||
- ⚡️(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
|
||||
|
||||
@@ -44,6 +38,7 @@ 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
|
||||
|
||||
@@ -204,7 +204,7 @@ 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
|
||||
test-summary: ## run summary service tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest-summary $${args:-${1}}
|
||||
.PHONY: test-summary
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# NB: this file is used locally only. In CI, it is overwritten by pytest install
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
-e DJANGO_CONFIGURATION=Test \
|
||||
app-dev \
|
||||
pytest "$@"
|
||||
pytest "$@"
|
||||
|
||||
@@ -61,10 +61,11 @@ services:
|
||||
`docker compose up -d`
|
||||
```
|
||||
|
||||
Your keycloak instance is now available on https://id.yourdomain.tld
|
||||
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||
|
||||
> [!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).
|
||||
```
|
||||
|
||||
## Creating an OIDC Client for Meet Application
|
||||
|
||||
@@ -75,7 +76,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
|
||||
3. Enter the name of the realm - `meet`.
|
||||
4. Click "Create".
|
||||
|
||||
### Step 2: Create a New Client
|
||||
#### Step 2: Create a New Client
|
||||
|
||||
1. Navigate to the "Clients" tab.
|
||||
2. Click on the "Create client" button.
|
||||
@@ -85,7 +86,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
|
||||
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
||||
1. Click "Save".
|
||||
|
||||
### Step 3: Get Client Credentials
|
||||
#### Step 3: Get Client Credentials
|
||||
|
||||
1. Go to the "Credentials" tab.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
|
||||
@@ -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:3000
|
||||
MEDIA_BASE_URL=http://localhost:8083
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
# OIDC
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
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/*
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.11.0"
|
||||
version = "1.10.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.3.10",
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.11.0"
|
||||
version = "1.10.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -52,7 +52,7 @@ dependencies = [
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"pydantic==2.12.4",
|
||||
"PyJWT==2.12.0",
|
||||
"PyJWT==2.11.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.5",
|
||||
|
||||
Generated
+5
-5
@@ -1157,7 +1157,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.11.0"
|
||||
version = "1.10.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1251,7 +1251,7 @@ requires-dist = [
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.2" },
|
||||
{ name = "pydantic", specifier = "==2.12.4" },
|
||||
{ name = "pyjwt", specifier = "==2.12.0" },
|
||||
{ name = "pyjwt", specifier = "==2.11.0" },
|
||||
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
||||
{ name = "python-magic", specifier = "==0.4.27" },
|
||||
{ name = "redis", specifier = "==5.2.1" },
|
||||
@@ -1754,11 +1754,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.0"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -15,19 +15,7 @@ export const fetchApi = async <T = Record<string, unknown>>(
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, result)
|
||||
}
|
||||
|
||||
@@ -5,5 +5,4 @@ export const keys = {
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
files: 'files',
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiFileItem } from '@/features/files/api/types.ts'
|
||||
import { keys } from '@/api/queryKeys.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)
|
||||
return await fetchApi<ApiFileItem>(`/files/${res.id}/upload-ended/`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export const useCreateFile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createFile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [keys.files],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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.fromProcessorConfig(
|
||||
userConfig.processorConfig
|
||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
||||
userConfig.processorSerialized
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createLocalAudioTrack,
|
||||
createLocalVideoTrack,
|
||||
createLocalAudioTrack,
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button, Dialog, Form, Text } from '@/primitives'
|
||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
@@ -44,7 +44,8 @@ const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
const Effects = ({
|
||||
videoTrack,
|
||||
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
|
||||
onSubmit,
|
||||
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const openDialog = () => setIsDialogOpen(true)
|
||||
@@ -80,7 +81,7 @@ const Effects = ({
|
||||
>
|
||||
{t('subTitle')}
|
||||
</Text>
|
||||
<EffectsConfiguration videoTrack={videoTrack} />
|
||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="whiteCircle"
|
||||
@@ -110,7 +111,7 @@ export const Join = ({
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
processorSerialized,
|
||||
username,
|
||||
},
|
||||
saveAudioInputEnabled,
|
||||
@@ -119,6 +120,7 @@ export const Join = ({
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
saveUsername,
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||
@@ -130,7 +132,7 @@ export const Join = ({
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
processorSerialized,
|
||||
username,
|
||||
}
|
||||
}
|
||||
@@ -144,8 +146,8 @@ export const Join = ({
|
||||
video: !!initialUserChoices.current &&
|
||||
initialUserChoices.current?.videoEnabled && {
|
||||
deviceId: initialUserChoices.current.videoDeviceId,
|
||||
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||
initialUserChoices.current.processorConfig
|
||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
||||
initialUserChoices.current.processorSerialized
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -184,7 +186,9 @@ export const Join = ({
|
||||
const track = await createLocalVideoTrack({
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
processorSerialized
|
||||
),
|
||||
})
|
||||
setDynamicVideoTrack(track)
|
||||
} catch (error) {
|
||||
@@ -203,7 +207,7 @@ export const Join = ({
|
||||
}, [
|
||||
videoEnabled,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
processorSerialized,
|
||||
previewVideoTrack,
|
||||
dynamicVideoTrack,
|
||||
])
|
||||
@@ -686,7 +690,12 @@ export const Join = ({
|
||||
zIndex: '1',
|
||||
})}
|
||||
>
|
||||
<Effects videoTrack={videoTrack} />
|
||||
<Effects
|
||||
videoTrack={videoTrack}
|
||||
onSubmit={(processor) =>
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+26
-15
@@ -11,7 +11,11 @@ import {
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
BackgroundOptions,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
@@ -29,7 +33,7 @@ const DEFAULT_BLUR = '10'
|
||||
* MediaStreamTrackProcessor.
|
||||
*/
|
||||
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
options: ProcessorConfig
|
||||
options: BackgroundOptions
|
||||
name: string
|
||||
processedTrack?: MediaStreamTrack | undefined
|
||||
|
||||
@@ -60,10 +64,15 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
type: ProcessorType
|
||||
virtualBackgroundImage?: HTMLImageElement
|
||||
|
||||
constructor(opts: ProcessorConfig) {
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.name = 'blur'
|
||||
this.options = opts
|
||||
this.type = opts.type
|
||||
|
||||
if (this.options.blurRadius) {
|
||||
this.type = ProcessorType.BLUR
|
||||
} else {
|
||||
this.type = ProcessorType.VIRTUAL
|
||||
}
|
||||
}
|
||||
|
||||
static get isSupported() {
|
||||
@@ -98,12 +107,6 @@ 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 &&
|
||||
@@ -115,7 +118,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
}
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
async update(opts: BackgroundOptions): Promise<void> {
|
||||
this.options = opts
|
||||
this._initVirtualBackgroundImage()
|
||||
}
|
||||
@@ -209,9 +212,6 @@ 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.type === 'blur') {
|
||||
if (this.options.blurRadius) {
|
||||
await this.blur()
|
||||
} else {
|
||||
await this.drawVirtualBackground()
|
||||
@@ -354,4 +354,15 @@ 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,4 +332,15 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-15
@@ -1,23 +1,28 @@
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import {
|
||||
BackgroundBlur,
|
||||
BackgroundTransformer,
|
||||
ProcessorWrapper,
|
||||
VirtualBackground,
|
||||
} from '@livekit/track-processors'
|
||||
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
|
||||
import {
|
||||
BackgroundOptions,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
|
||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
|
||||
opts: ProcessorConfig
|
||||
processor: ProcessorWrapper<BackgroundOptions>
|
||||
opts: BackgroundOptions
|
||||
processorType: ProcessorType
|
||||
|
||||
constructor(opts: ProcessorConfig) {
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.opts = opts
|
||||
|
||||
if (opts.type === 'virtual') {
|
||||
if (opts.imagePath) {
|
||||
this.processorType = ProcessorType.VIRTUAL
|
||||
this.processor = VirtualBackground(opts.imagePath)
|
||||
} else if (opts.type === 'blur') {
|
||||
} else if (opts.blurRadius !== undefined) {
|
||||
this.processorType = ProcessorType.BLUR
|
||||
this.processor = BackgroundBlur(opts.blurRadius)
|
||||
} else {
|
||||
@@ -39,23 +44,24 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
return this.processor.destroy()
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
this.opts = opts
|
||||
|
||||
const newProcessorType =
|
||||
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
|
||||
async update(opts: BackgroundOptions): Promise<void> {
|
||||
const newProcessorType = opts.imagePath
|
||||
? 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(
|
||||
opts as { imagePath?: string; blurRadius?: number }
|
||||
)
|
||||
await this.processor.updateTransformerOptions(processedOpts)
|
||||
this.opts = processedOpts
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -67,6 +73,17 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.opts
|
||||
return (this.processor.transformer as BackgroundTransformer).options
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.processorType,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,23 @@ import { ProcessorWrapper } from '@livekit/track-processors'
|
||||
import { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
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 {
|
||||
BLUR = 'blur',
|
||||
@@ -10,16 +26,6 @@ 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
|
||||
@@ -30,27 +36,28 @@ export class BackgroundProcessorFactory {
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
config: ProcessorConfig
|
||||
type: ProcessorType,
|
||||
opts: BackgroundOptions
|
||||
): BackgroundProcessorInterface | undefined {
|
||||
const isBlur = config.type === ProcessorType.BLUR
|
||||
const isVirtual = config.type === ProcessorType.VIRTUAL
|
||||
const isBlur = type === ProcessorType.BLUR
|
||||
const isVirtual = type === ProcessorType.VIRTUAL
|
||||
|
||||
if (!isBlur && !isVirtual) return undefined
|
||||
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
return new UnifiedBackgroundTrackProcessor(config)
|
||||
return new UnifiedBackgroundTrackProcessor(opts)
|
||||
}
|
||||
|
||||
if (BackgroundCustomProcessor.isSupported) {
|
||||
return new BackgroundCustomProcessor(config)
|
||||
return new BackgroundCustomProcessor(opts)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
static fromProcessorConfig(data?: ProcessorConfig) {
|
||||
if (data) {
|
||||
return BackgroundProcessorFactory.getProcessor(data)
|
||||
static deserializeProcessor(data?: ProcessorSerialized) {
|
||||
if (data?.type) {
|
||||
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
+2
-2
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
|
||||
*
|
||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||
*/
|
||||
const processor = BackgroundProcessorFactory.fromProcessorConfig(
|
||||
userChoices.processorConfig
|
||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||
userChoices.processorSerialized
|
||||
)
|
||||
|
||||
const toggle = trackProps.toggle as (
|
||||
|
||||
@@ -2,12 +2,14 @@ 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)
|
||||
|
||||
@@ -22,6 +24,9 @@ export const Effects = () => {
|
||||
isDisabled={!canPublishCamera}
|
||||
videoTrack={localCameraTrack}
|
||||
layout="vertical"
|
||||
onSubmit={(processor) =>
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+240
-642
@@ -1,16 +1,16 @@
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
BackgroundOptions,
|
||||
BackgroundProcessorFactory,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorConfig,
|
||||
ProcessorType,
|
||||
} from '../blur'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
||||
import { H, P, Text, ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { BlurOn } from '@/components/icons/BlurOn'
|
||||
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
||||
import { useTrackToggle } from '@livekit/components-react'
|
||||
@@ -19,19 +19,6 @@ import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
import { FunnyEffects } from './FunnyEffects'
|
||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import {
|
||||
ListFilesParams,
|
||||
useListMyFiles,
|
||||
} from '@/features/files/api/listFiles.ts'
|
||||
import { useCreateFile } from '@/features/files/api/createFile.ts'
|
||||
import { FileTrigger } from 'react-aria-components'
|
||||
import { RiDeleteBinLine, RiImageAddFill } from '@remixicon/react'
|
||||
import { useDeleteFile } from '@/features/files/api/deleteFile.ts'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { ApiFileItem } from '@/features/files/api/types.ts'
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices.ts'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
@@ -53,51 +40,14 @@ const Information = styled('div', {
|
||||
export type EffectsConfigurationProps = {
|
||||
isDisabled?: boolean
|
||||
videoTrack: LocalVideoTrack
|
||||
onSubmit?: (processor?: BackgroundProcessorInterface) => void
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
}
|
||||
|
||||
const listFilesQueryParams: ListFilesParams = {
|
||||
filters: {
|
||||
type: 'background_image',
|
||||
upload_state: 'ready',
|
||||
is_creator_me: true,
|
||||
is_deleted: false,
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
},
|
||||
}
|
||||
|
||||
function deriveIdFromProcessorConfig(config: ProcessorConfig) {
|
||||
if (config.type === ProcessorType.BLUR) {
|
||||
return `blur-${config.blurRadius}`
|
||||
} else if (config.type === ProcessorType.VIRTUAL) {
|
||||
// the imagePath is not stable for custom backgrounds
|
||||
// so we try first with the fileId
|
||||
if (config.fileId) {
|
||||
return `virtual-${config.fileId}`
|
||||
}
|
||||
return `virtual-${config.imagePath}`
|
||||
} else if (config.type === ProcessorType.FACE_LANDMARKS) {
|
||||
return 'face-landmarks'
|
||||
}
|
||||
throw new Error(`Unknown config type in config: ${config}`)
|
||||
}
|
||||
|
||||
// We use a valtio store so that the state is persisted between the join room
|
||||
// and the actual room
|
||||
const uploadNotPossibleLocalState = proxy({
|
||||
imageBackgroundConfig: null as null | {
|
||||
type: ProcessorType.VIRTUAL
|
||||
imagePath: string
|
||||
label: string
|
||||
},
|
||||
})
|
||||
|
||||
export const EffectsConfiguration = ({
|
||||
isDisabled,
|
||||
videoTrack,
|
||||
onSubmit,
|
||||
layout = 'horizontal',
|
||||
}: EffectsConfigurationProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
@@ -113,365 +63,6 @@ export const EffectsConfiguration = ({
|
||||
> | null>(null)
|
||||
const effectAnnouncementId = useRef(0)
|
||||
|
||||
const {
|
||||
saveProcessorConfig,
|
||||
userChoices: { processorConfig },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const selectedId = useMemo(
|
||||
() =>
|
||||
processorConfig ? deriveIdFromProcessorConfig(processorConfig) : 'none',
|
||||
[processorConfig]
|
||||
)
|
||||
|
||||
const uploadNotPossibleSnap = useSnapshot(uploadNotPossibleLocalState)
|
||||
|
||||
const announceEffectStatusMessage = useCallback(
|
||||
(message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
announce(message)
|
||||
}, 80)
|
||||
},
|
||||
[announce]
|
||||
)
|
||||
|
||||
const getVirtualBackgroundName = useCallback(
|
||||
(imagePath?: string) => {
|
||||
if (!imagePath) return ''
|
||||
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||
if (!match) return ''
|
||||
const index = Number(match[1]) - 1
|
||||
if (Number.isNaN(index)) return ''
|
||||
return t(`virtual.presets.descriptions.${index}`)
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const updateEffectStatusMessage = useCallback(
|
||||
(config: ProcessorConfig, wasSelectedBeforeToggle: boolean) => {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (config.type === ProcessorType.BLUR) {
|
||||
const message =
|
||||
config.blurRadius === BlurRadius.LIGHT
|
||||
? t('blur.status.light')
|
||||
: t('blur.status.strong')
|
||||
announceEffectStatusMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (config.type === ProcessorType.VIRTUAL) {
|
||||
const backgroundName = getVirtualBackgroundName(config.imagePath)
|
||||
if (backgroundName) {
|
||||
announceEffectStatusMessage(
|
||||
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
[announceEffectStatusMessage, getVirtualBackgroundName, t]
|
||||
)
|
||||
|
||||
const toggleEffect = useCallback(
|
||||
async (config: ProcessorConfig) => {
|
||||
setProcessorPending(true)
|
||||
const wasSelectedBeforeToggle =
|
||||
selectedId === deriveIdFromProcessorConfig(config)
|
||||
|
||||
if (!videoTrack) {
|
||||
/**
|
||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
|
||||
* setProcessor on.
|
||||
*
|
||||
* We arrive in this condition when we enter the room with the camera already off.
|
||||
*/
|
||||
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(config)!
|
||||
await toggle(true, {
|
||||
processor: newProcessorTmp,
|
||||
})
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
return
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
await toggle(true)
|
||||
}
|
||||
|
||||
const processor =
|
||||
videoTrack?.getProcessor() as BackgroundProcessorInterface
|
||||
try {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
// Stop processor.
|
||||
await videoTrack.stopProcessor()
|
||||
saveProcessorConfig(undefined)
|
||||
} else if (
|
||||
!processor ||
|
||||
(processor.options.type !== config.type &&
|
||||
!BackgroundProcessorFactory.hasModernApiSupport())
|
||||
) {
|
||||
// Change processor.
|
||||
const newProcessor = BackgroundProcessorFactory.getProcessor(config)!
|
||||
// IMPORTANT: Must explicitly stop previous processor before setting a new one
|
||||
// in browsers without modern API support to prevent UI crashes.
|
||||
// This workaround is needed until this issue is resolved:
|
||||
// https://github.com/livekit/track-processors-js/issues/85
|
||||
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
|
||||
await videoTrack.stopProcessor()
|
||||
}
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
saveProcessorConfig(config)
|
||||
} else {
|
||||
await processor?.update(config)
|
||||
saveProcessorConfig(config)
|
||||
}
|
||||
|
||||
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
// Without setTimeout the DOM is not refreshing when updating the options.
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
}
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
saveProcessorConfig,
|
||||
selectedId,
|
||||
toggle,
|
||||
updateEffectStatusMessage,
|
||||
videoTrack,
|
||||
]
|
||||
)
|
||||
|
||||
const { data: appConfig } = useConfig()
|
||||
const { isLoggedIn } = useUser()
|
||||
const canUploadBackground =
|
||||
isLoggedIn === true &&
|
||||
appConfig?.background_image?.upload_is_enabled === true
|
||||
// We split the error state in 2 parts so that there are no visual glitches when closing the alert
|
||||
const [personalBackgroundHasError, setPersonalBackgroundHasError] =
|
||||
useState<boolean>(false)
|
||||
const [personalBackgroundError, setPersonalBackgroundError] = useState<
|
||||
'file_too_large' | 'invalid_file_type' | null
|
||||
>(null)
|
||||
const createFileMutation = useCreateFile()
|
||||
const deleteFileMutation = useDeleteFile()
|
||||
const filesQ = useListMyFiles(listFilesQueryParams)
|
||||
const hasReachedMaxNbBackgrounds =
|
||||
(canUploadBackground &&
|
||||
appConfig &&
|
||||
filesQ.data &&
|
||||
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
||||
false
|
||||
|
||||
const getHandleSelectChangeFile = useCallback(
|
||||
(file: ApiFileItem) => {
|
||||
return async () => {
|
||||
await toggleEffect({
|
||||
type: ProcessorType.VIRTUAL,
|
||||
imagePath: file.url!,
|
||||
fileId: file.id,
|
||||
})
|
||||
}
|
||||
},
|
||||
[toggleEffect]
|
||||
)
|
||||
|
||||
const handleNewBackgroundFilePicked = useCallback(
|
||||
async (file: File) => {
|
||||
if (
|
||||
!(
|
||||
appConfig?.background_image?.allowed_mimetypes?.includes(file.type) ??
|
||||
false
|
||||
)
|
||||
) {
|
||||
setPersonalBackgroundError('invalid_file_type')
|
||||
setPersonalBackgroundHasError(true)
|
||||
return
|
||||
}
|
||||
if (file.size > (appConfig?.background_image?.max_size ?? 0)) {
|
||||
setPersonalBackgroundError('file_too_large')
|
||||
setPersonalBackgroundHasError(true)
|
||||
return
|
||||
}
|
||||
|
||||
// When the user is not logged in, we fallback to just loading that image
|
||||
if (!canUploadBackground) {
|
||||
// For the preview to work, we somehow need to create a data URL from the raw file.
|
||||
// revoking is handled with userChoicesStore
|
||||
if (uploadNotPossibleSnap.imageBackgroundConfig) {
|
||||
URL.revokeObjectURL(
|
||||
uploadNotPossibleSnap.imageBackgroundConfig.imagePath
|
||||
)
|
||||
}
|
||||
const imagePath = URL.createObjectURL(file)
|
||||
|
||||
const fileId = `local-image`
|
||||
await toggleEffect({
|
||||
type: ProcessorType.VIRTUAL,
|
||||
imagePath,
|
||||
fileId,
|
||||
})
|
||||
uploadNotPossibleLocalState.imageBackgroundConfig = {
|
||||
type: ProcessorType.VIRTUAL,
|
||||
label: file.name.split('.')[0],
|
||||
imagePath,
|
||||
}
|
||||
} else {
|
||||
// Otherwise we create the file in the backend and automatically select it
|
||||
// when it's uploaded.
|
||||
createFileMutation.mutate(
|
||||
{
|
||||
file,
|
||||
onProgress: (progress) => {
|
||||
console.debug('upload-progress', progress)
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (file) => {
|
||||
// We automatically select that created file
|
||||
getHandleSelectChangeFile(file)()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
[
|
||||
appConfig?.background_image?.allowed_mimetypes,
|
||||
appConfig?.background_image?.max_size,
|
||||
canUploadBackground,
|
||||
createFileMutation,
|
||||
getHandleSelectChangeFile,
|
||||
toggleEffect,
|
||||
uploadNotPossibleSnap.imageBackgroundConfig,
|
||||
]
|
||||
)
|
||||
|
||||
const filePickerErrorContext = useMemo(
|
||||
() => ({
|
||||
allowedExtension: appConfig?.background_image?.allowed_extensions ?? [],
|
||||
maxSize: (appConfig?.background_image?.max_size ?? 0) / (1024 * 1024),
|
||||
}),
|
||||
[appConfig?.background_image]
|
||||
)
|
||||
|
||||
const processorOptions = useMemo<{
|
||||
isDisabled: boolean
|
||||
blurBased: {
|
||||
radius: BlurRadius
|
||||
Icon: React.FC
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
tooltip: string
|
||||
id: string
|
||||
config: ProcessorConfig
|
||||
isSelected: boolean
|
||||
}[]
|
||||
virtualBackgrounds: {
|
||||
id: string
|
||||
config: ProcessorConfig
|
||||
isSelected: boolean
|
||||
tooltip: string
|
||||
ariaLabel: string
|
||||
thumbnailPath: string
|
||||
index: number
|
||||
}[]
|
||||
remoteCustomVirtualBackgrounds: {
|
||||
id: string
|
||||
config: ProcessorConfig
|
||||
isSelected: boolean
|
||||
tooltip: string
|
||||
file: ApiFileItem
|
||||
}[]
|
||||
}>(() => {
|
||||
return {
|
||||
isDisabled: (processorPendingReveal || isDisabled) ?? false,
|
||||
blurBased: [
|
||||
{
|
||||
key: 'light',
|
||||
radius: BlurRadius.LIGHT,
|
||||
icon: BlurOn,
|
||||
ref: blurLightRef,
|
||||
},
|
||||
{
|
||||
key: 'normal',
|
||||
radius: BlurRadius.NORMAL,
|
||||
icon: BlurOnStrong,
|
||||
ref: undefined,
|
||||
},
|
||||
].map((item) => {
|
||||
const config: ProcessorConfig = {
|
||||
type: ProcessorType.BLUR,
|
||||
blurRadius: item.radius,
|
||||
}
|
||||
const id = deriveIdFromProcessorConfig(config)
|
||||
return {
|
||||
id,
|
||||
tooltip: t(`blur.light.${selectedId === id ? 'clear' : 'apply'}`),
|
||||
radius: item.radius,
|
||||
isSelected: selectedId === id,
|
||||
Icon: item.icon,
|
||||
ref: item.ref,
|
||||
config,
|
||||
}
|
||||
}),
|
||||
virtualBackgrounds: [...Array(8).keys()].map((index) => {
|
||||
const imagePath = `/assets/backgrounds/${index + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${index + 1}.jpg`
|
||||
const config: ProcessorConfig = {
|
||||
type: ProcessorType.VIRTUAL,
|
||||
imagePath,
|
||||
}
|
||||
const id = deriveIdFromProcessorConfig(config)
|
||||
const isSelected = selectedId === id
|
||||
const prefix = isSelected ? 'selectedLabel' : 'apply'
|
||||
const backgroundName = t(`virtual.presets.descriptions.${index}`)
|
||||
const ariaLabel = `${t(`virtual.presets.${prefix}`)} ${backgroundName}`
|
||||
|
||||
return {
|
||||
tooltip: backgroundName,
|
||||
id,
|
||||
config,
|
||||
isSelected: selectedId === id,
|
||||
thumbnailPath,
|
||||
ariaLabel,
|
||||
index,
|
||||
}
|
||||
}),
|
||||
remoteCustomVirtualBackgrounds: (filesQ.data?.results ?? [])
|
||||
.filter((file) => file.url)
|
||||
.map((file) => {
|
||||
const config: ProcessorConfig = {
|
||||
type: ProcessorType.VIRTUAL,
|
||||
imagePath: file.url!,
|
||||
fileId: file.id,
|
||||
}
|
||||
|
||||
const id = deriveIdFromProcessorConfig(config)
|
||||
|
||||
return {
|
||||
tooltip: file.title,
|
||||
id,
|
||||
config,
|
||||
isSelected: selectedId === id,
|
||||
file,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}, [processorPendingReveal, isDisabled, filesQ.data?.results, t, selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
if (!videoElement) return
|
||||
@@ -506,6 +97,171 @@ export const EffectsConfiguration = ({
|
||||
[]
|
||||
)
|
||||
|
||||
const announceEffectStatusMessage = (message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
announce(message)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const clearEffect = async () => {
|
||||
await videoTrack.stopProcessor()
|
||||
onSubmit?.(undefined)
|
||||
}
|
||||
|
||||
const getVirtualBackgroundName = (imagePath?: string) => {
|
||||
if (!imagePath) return ''
|
||||
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||
if (!match) return ''
|
||||
const index = Number(match[1]) - 1
|
||||
if (Number.isNaN(index)) return ''
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
const updateEffectStatusMessage = (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions,
|
||||
wasSelectedBeforeToggle: boolean
|
||||
) => {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.BLUR) {
|
||||
const message =
|
||||
options.blurRadius === BlurRadius.LIGHT
|
||||
? t('blur.status.light')
|
||||
: t('blur.status.strong')
|
||||
announceEffectStatusMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.VIRTUAL) {
|
||||
const backgroundName = getVirtualBackgroundName(options.imagePath)
|
||||
if (backgroundName) {
|
||||
announceEffectStatusMessage(
|
||||
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEffect = async (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions
|
||||
) => {
|
||||
setProcessorPending(true)
|
||||
const wasSelectedBeforeToggle = isSelected(type, options)
|
||||
|
||||
if (!videoTrack) {
|
||||
/**
|
||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
|
||||
* setProcessor on.
|
||||
*
|
||||
* We arrive in this condition when we enter the room with the camera already off.
|
||||
*/
|
||||
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(
|
||||
type,
|
||||
options
|
||||
)!
|
||||
await toggle(true, {
|
||||
processor: newProcessorTmp,
|
||||
})
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
return
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
await toggle(true)
|
||||
}
|
||||
|
||||
const processor = getProcessor()
|
||||
try {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
// Stop processor.
|
||||
await clearEffect()
|
||||
} else if (
|
||||
!processor ||
|
||||
(processor.serialize().type !== type &&
|
||||
!BackgroundProcessorFactory.hasModernApiSupport())
|
||||
) {
|
||||
// Change processor.
|
||||
const newProcessor = BackgroundProcessorFactory.getProcessor(
|
||||
type,
|
||||
options
|
||||
)!
|
||||
// IMPORTANT: Must explicitly stop previous processor before setting a new one
|
||||
// in browsers without modern API support to prevent UI crashes.
|
||||
// This workaround is needed until this issue is resolved:
|
||||
// https://github.com/livekit/track-processors-js/issues/85
|
||||
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
|
||||
await videoTrack.stopProcessor()
|
||||
}
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
onSubmit?.(newProcessor)
|
||||
} else {
|
||||
await processor?.update(options)
|
||||
// We want to trigger onSubmit when options changes so the parent component is aware of it.
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
|
||||
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
// Without setTimeout the DOM is not refreshing when updating the options.
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
}
|
||||
}
|
||||
|
||||
const getProcessor = () => {
|
||||
return videoTrack?.getProcessor() as BackgroundProcessorInterface
|
||||
}
|
||||
|
||||
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
const processor = getProcessor()
|
||||
const processorSerialized = processor?.serialize()
|
||||
return (
|
||||
!!processor &&
|
||||
processorSerialized.type === type &&
|
||||
JSON.stringify(processorSerialized.options) === JSON.stringify(options)
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
const strength =
|
||||
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
|
||||
const action = isSelected(type, options) ? 'clear' : 'apply'
|
||||
|
||||
return t(`${type}.${strength}.${action}`)
|
||||
}
|
||||
|
||||
const ariaLabelVirtualBackground = (
|
||||
index: number,
|
||||
imagePath: string
|
||||
): string => {
|
||||
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
|
||||
const backgroundName = t(`virtual.descriptions.${index}`)
|
||||
return `${t(`virtual.${prefix}`)} ${backgroundName}`
|
||||
}
|
||||
|
||||
const tooltipVirtualBackground = (index: number): string => {
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css(
|
||||
@@ -606,7 +362,7 @@ export const EffectsConfiguration = ({
|
||||
<div>
|
||||
<div>
|
||||
<H
|
||||
lvl={2}
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
@@ -621,51 +377,65 @@ export const EffectsConfiguration = ({
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
{processorOptions.blurBased.map(({ Icon, ...option }) => (
|
||||
<ToggleButton
|
||||
key={option.id}
|
||||
ref={option.ref}
|
||||
variant="bigSquare"
|
||||
aria-label={option.tooltip}
|
||||
tooltip={option.tooltip}
|
||||
isDisabled={processorOptions.isDisabled}
|
||||
onChange={() => toggleEffect(option.config)}
|
||||
isSelected={option.isSelected}
|
||||
data-attr={`toggle-${option.id}`}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
))}
|
||||
<ToggleButton
|
||||
ref={blurLightRef}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
data-attr="toggle-blur-light"
|
||||
>
|
||||
<BlurOn />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ marginTop: '1.5rem' })}>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={2}
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '0.6rem',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
variant="bodyXsBold"
|
||||
>
|
||||
{t('virtual.title')}
|
||||
</H>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={2}
|
||||
style={{
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
variant="bodyXsMedium"
|
||||
>
|
||||
{t('virtual.personal.title')}
|
||||
</H>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -674,181 +444,35 @@ export const EffectsConfiguration = ({
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{canUploadBackground &&
|
||||
processorOptions.remoteCustomVirtualBackgrounds.map(
|
||||
(option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={
|
||||
'hoverGroup ' + css({ position: 'relative' })
|
||||
}
|
||||
>
|
||||
<VisualOnlyTooltip tooltip={option.tooltip}>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={option.tooltip}
|
||||
isDisabled={processorOptions.isDisabled}
|
||||
onChange={getHandleSelectChangeFile(option.file)}
|
||||
isSelected={option.isSelected}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${option.file.url!})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${option.file.id}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
<Button
|
||||
className={
|
||||
'hoverGroupChild ' +
|
||||
css({
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
})
|
||||
}
|
||||
size={'xs'}
|
||||
variant={'tertiary'}
|
||||
onClick={() => {
|
||||
if (option.isSelected) {
|
||||
// we remove the current effect
|
||||
toggleEffect(option.config)
|
||||
}
|
||||
deleteFileMutation.mutate({
|
||||
fileId: option.file.id,
|
||||
})
|
||||
}}
|
||||
isDisabled={deleteFileMutation.isPending}
|
||||
>
|
||||
<RiDeleteBinLine size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!canUploadBackground &&
|
||||
uploadNotPossibleSnap.imageBackgroundConfig && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={
|
||||
uploadNotPossibleSnap.imageBackgroundConfig.label
|
||||
}
|
||||
>
|
||||
{[...Array(8).keys()].map((i) => {
|
||||
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
||||
const tooltipText = tooltipVirtualBackground(i)
|
||||
return (
|
||||
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={
|
||||
uploadNotPossibleSnap.imageBackgroundConfig.label
|
||||
}
|
||||
isDisabled={processorOptions.isDisabled}
|
||||
onChange={() => {
|
||||
toggleEffect(
|
||||
uploadNotPossibleSnap.imageBackgroundConfig!
|
||||
)
|
||||
}}
|
||||
isSelected={
|
||||
deriveIdFromProcessorConfig(
|
||||
uploadNotPossibleSnap.imageBackgroundConfig
|
||||
) === selectedId
|
||||
aria-label={ariaLabelVirtualBackground(i, imagePath)}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${uploadNotPossibleSnap.imageBackgroundConfig.imagePath})`,
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-local`}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
)}
|
||||
<FileTrigger
|
||||
acceptedFileTypes={
|
||||
appConfig?.background_image?.allowed_mimetypes ?? [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]
|
||||
}
|
||||
onSelect={(e) => {
|
||||
if (e && e.item(0)) {
|
||||
const file = e.item(0) as File
|
||||
handleNewBackgroundFilePicked(file)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bigSquare"
|
||||
aria-label={t('virtual.personal.selectFileTooltip')}
|
||||
tooltip={t('virtual.personal.selectFileTooltip')}
|
||||
isDisabled={
|
||||
(canUploadBackground &&
|
||||
filesQ.data &&
|
||||
filesQ.data.count >=
|
||||
(appConfig?.background_image?.max_count_by_user ??
|
||||
0)) ||
|
||||
processorOptions.isDisabled ||
|
||||
createFileMutation.isPending
|
||||
}
|
||||
data-attr="input-file-select-personal-background"
|
||||
>
|
||||
<RiImageAddFill />
|
||||
</Button>
|
||||
</FileTrigger>
|
||||
</div>
|
||||
{!isLoggedIn && (
|
||||
<Text variant="xsNote">
|
||||
{t('virtual.personal.notLoggedInWarning')}
|
||||
</Text>
|
||||
)}
|
||||
{!canUploadBackground && isLoggedIn && (
|
||||
<Text variant="xsNote">
|
||||
{t('virtual.personal.warningUploadDisabled')}
|
||||
</Text>
|
||||
)}
|
||||
{hasReachedMaxNbBackgrounds && (
|
||||
<Text variant="xsNote">
|
||||
{t('virtual.personal.uploadLimitReached')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '0.4rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={2}
|
||||
style={{
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
variant="bodyXsMedium"
|
||||
>
|
||||
{t('virtual.presets.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
paddingBottom: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
)
|
||||
})}
|
||||
>
|
||||
{processorOptions.virtualBackgrounds.map((option) => (
|
||||
<VisualOnlyTooltip key={option.id} tooltip={option.tooltip}>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={option.ariaLabel}
|
||||
isDisabled={processorOptions.isDisabled}
|
||||
onChange={() => toggleEffect(option.config)}
|
||||
isSelected={option.isSelected}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${option.thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-preset-${option.index}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -859,32 +483,6 @@ export const EffectsConfiguration = ({
|
||||
</Information>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
isOpen={personalBackgroundHasError}
|
||||
type="alert"
|
||||
title={t(`virtual.personal.errors.${personalBackgroundError}.title`)}
|
||||
aria-label={t(
|
||||
`virtual.personal.errors.${personalBackgroundError}.title`
|
||||
)}
|
||||
onClose={() => setPersonalBackgroundHasError(false)}
|
||||
onOpenChange={() => setPersonalBackgroundHasError(false)}
|
||||
>
|
||||
<P>
|
||||
{t(
|
||||
`virtual.personal.errors.${personalBackgroundError}.description`,
|
||||
filePickerErrorContext
|
||||
)}
|
||||
</P>
|
||||
<HStack justifyContent="end" direction="row">
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setPersonalBackgroundHasError(false)}
|
||||
>
|
||||
{t('virtual.personal.errors.close')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
|
||||
showFrench: false,
|
||||
}
|
||||
}
|
||||
return { ...processor.options }
|
||||
return processor.serialize().options
|
||||
}
|
||||
|
||||
const options = getOptions()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
import type { VideoResolution } from '@/stores/userChoices'
|
||||
import { ProcessorConfig } from '@/features/rooms/livekit/components/blur'
|
||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
||||
import type { VideoQuality } from 'livekit-client'
|
||||
|
||||
export function usePersistentUserChoices() {
|
||||
@@ -36,8 +36,10 @@ export function usePersistentUserChoices() {
|
||||
saveNoiseReductionEnabled: (enabled: boolean) => {
|
||||
userChoicesStore.noiseReductionEnabled = enabled
|
||||
},
|
||||
saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
|
||||
userChoicesStore.processorConfig = processorConfig
|
||||
saveProcessorSerialized: (
|
||||
processorSerialized: ProcessorSerialized | undefined
|
||||
) => {
|
||||
userChoicesStore.processorSerialized = processorSerialized
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const {
|
||||
userChoices: {
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
processorSerialized,
|
||||
videoPublishResolution,
|
||||
videoSubscribeQuality,
|
||||
},
|
||||
@@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
resolution: VideoPresets[key].resolution,
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,8 @@ 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', {
|
||||
@@ -27,27 +24,6 @@ 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
|
||||
@@ -58,44 +34,16 @@ export const CaptionsSettings = () => {
|
||||
>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<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>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('textSize.label')}
|
||||
items={captionTextSizeItems}
|
||||
selectedKey={snap.captionTextSize}
|
||||
onSelectionChange={(key) => {
|
||||
accessibilityStore.captionTextSize = key as CaptionTextSize
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
accessibilityStore,
|
||||
CAPTION_TEXT_SIZE_OPTIONS,
|
||||
CAPTION_FONT_COLOR_VALUES,
|
||||
CAPTION_BACKGROUND_COLOR_VALUES,
|
||||
type CaptionTextSize,
|
||||
} from '@/stores/accessibility'
|
||||
|
||||
@@ -94,14 +92,10 @@ const useTranscriptionState = () => {
|
||||
}
|
||||
|
||||
const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||
const { captionTextSize, captionFontColor, captionBackgroundColor } =
|
||||
useSnapshot(accessibilityStore)
|
||||
const { captionTextSize } = 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
|
||||
@@ -134,9 +128,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
color: 'white',
|
||||
width: '100%',
|
||||
})}
|
||||
style={{ color: fontColor }}
|
||||
>
|
||||
<Text variant="h3" margin={false}>
|
||||
{participantName}
|
||||
@@ -144,10 +138,8 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: '400',
|
||||
borderRadius: '4px',
|
||||
padding: '0.125rem 0.25rem',
|
||||
})}
|
||||
style={{ fontSize, lineHeight, backgroundColor }}
|
||||
style={{ fontSize, lineHeight }}
|
||||
>
|
||||
{displayText}
|
||||
</p>
|
||||
|
||||
@@ -273,39 +273,17 @@
|
||||
"title": "Virtueller Hintergrund",
|
||||
"selectedLabel": "Hintergrund angewendet:",
|
||||
"apply": "Ersetze deinen Hintergrund:",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
"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": {
|
||||
|
||||
@@ -126,34 +126,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -270,39 +270,18 @@
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Virtual backgrounds",
|
||||
"title": "Virtual background",
|
||||
"selectedLabel": "Background applied:",
|
||||
"apply": "Replace your background:",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
"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": {
|
||||
|
||||
@@ -126,34 +126,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -270,39 +270,18 @@
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Arrière-plans virtuels",
|
||||
"title": "Arrière-plan virtuel",
|
||||
"selectedLabel": "Arrière-plan appliqué :",
|
||||
"apply": "Remplacer votre arrière plan :",
|
||||
"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.",
|
||||
"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é"
|
||||
}
|
||||
"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": {
|
||||
|
||||
@@ -126,34 +126,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -273,36 +273,15 @@
|
||||
"title": "Virtuele achtergrond",
|
||||
"selectedLabel": "Achtergrond toegepast:",
|
||||
"apply": "Vervang je achtergrond:",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
"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": {
|
||||
|
||||
@@ -126,34 +126,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,10 +36,6 @@ export const text = cva({
|
||||
textStyle: 'body',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bodyXsMedium: {
|
||||
textStyle: 'body',
|
||||
fontWeight: 'medium',
|
||||
},
|
||||
body: {
|
||||
textStyle: 'body',
|
||||
},
|
||||
|
||||
@@ -10,65 +10,14 @@ 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 {
|
||||
@@ -76,21 +25,10 @@ function getAccessibilityState(): AccessibilityState {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
const captionTextSize = CAPTION_TEXT_SIZE_OPTIONS.includes(
|
||||
parsed.captionTextSize
|
||||
)
|
||||
const validCaptionSizes = CAPTION_TEXT_SIZE_OPTIONS
|
||||
const captionTextSize = validCaptionSizes.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,
|
||||
@@ -99,8 +37,6 @@ function getAccessibilityState(): AccessibilityState {
|
||||
? parsed.announceReactions
|
||||
: DEFAULT_STATE.announceReactions,
|
||||
captionTextSize,
|
||||
captionFontColor,
|
||||
captionBackgroundColor,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import {
|
||||
ProcessorConfig,
|
||||
ProcessorType,
|
||||
} from '@/features/rooms/livekit/components/blur'
|
||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
||||
import {
|
||||
loadUserChoices,
|
||||
LocalUserChoices as LocalUserChoicesLK,
|
||||
saveUserChoices,
|
||||
LocalUserChoices as LocalUserChoicesLK,
|
||||
} from '@livekit/components-core'
|
||||
import { VideoQuality } from 'livekit-client'
|
||||
|
||||
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||
|
||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||
processorConfig?: ProcessorConfig
|
||||
processorSerialized?: ProcessorSerialized
|
||||
noiseReductionEnabled?: boolean
|
||||
audioOutputDeviceId?: string
|
||||
videoPublishResolution?: VideoResolution
|
||||
@@ -31,33 +28,7 @@ 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,14 +95,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -14,14 +14,6 @@ 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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.11.0"
|
||||
version = "1.10.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
@@ -27,7 +27,8 @@ dev = [
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"api: Test the API",
|
||||
"unit: Test individual components",
|
||||
"integration: Test the API",
|
||||
]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -57,12 +58,10 @@ select = [
|
||||
"T20", # flake8-print
|
||||
"W", # pycodestyle warning
|
||||
]
|
||||
ignore= ["PLR2004"]
|
||||
ignore= ["DJ001", "PLR2004"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"S101", # use of assert
|
||||
]
|
||||
"tests/*" = ["S", "SLF"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
# Use Google-style docstrings.
|
||||
|
||||
@@ -12,8 +12,6 @@ from summary.core.celery_worker import (
|
||||
)
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TranscribeSummarizeTaskCreation(BaseModel):
|
||||
"""Transcription and summarization parameters."""
|
||||
@@ -34,10 +32,11 @@ class TranscribeSummarizeTaskCreation(BaseModel):
|
||||
@classmethod
|
||||
def validate_language(cls, v):
|
||||
"""Validate 'language' parameter."""
|
||||
if v is not None and v not in settings.whisperx_allowed_languages:
|
||||
allowed = get_settings().whisperx_allowed_languages
|
||||
if v is not None and v not in allowed:
|
||||
raise ValueError(
|
||||
f"Language '{v}' is not allowed. "
|
||||
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
|
||||
f"Allowed languages: {', '.join(allowed)}"
|
||||
)
|
||||
return v
|
||||
|
||||
@@ -62,7 +61,7 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
|
||||
request.download_link,
|
||||
request.context_language,
|
||||
],
|
||||
queue=settings.transcribe_queue,
|
||||
queue=get_settings().transcribe_queue,
|
||||
)
|
||||
|
||||
return {"id": task.id, "message": "Task created"}
|
||||
|
||||
@@ -12,7 +12,6 @@ from posthog import Posthog
|
||||
from summary.core.config import get_settings
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class AnalyticsException(Exception):
|
||||
@@ -26,6 +25,7 @@ class Analytics:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a client if settings are configure."""
|
||||
settings = get_settings()
|
||||
self._client = None
|
||||
if settings.posthog_api_key and settings.posthog_enabled:
|
||||
logger.info("Initialize analytics client")
|
||||
@@ -71,6 +71,7 @@ class MetadataManager:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the task tracker with analytics client."""
|
||||
settings = get_settings()
|
||||
self._redis = redis.from_url(settings.task_tracker_redis_url)
|
||||
self._key_prefix = settings.task_tracker_prefix
|
||||
self._analytics = get_analytics()
|
||||
@@ -117,7 +118,7 @@ class MetadataManager:
|
||||
start_time = time.time()
|
||||
initial_metadata = {
|
||||
"start_time": start_time,
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"asr_model": get_settings().whisperx_asr_model,
|
||||
"retries": 0,
|
||||
"filename": filename,
|
||||
"email": email,
|
||||
|
||||
@@ -140,7 +140,7 @@ def format_transcript(
|
||||
)
|
||||
|
||||
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
def _format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
fomat:
|
||||
@@ -328,7 +328,7 @@ def summarize_transcription(
|
||||
response_format=FORMAT_NEXT_STEPS,
|
||||
)
|
||||
|
||||
next_steps = format_actions(json.loads(next_steps))
|
||||
next_steps = _format_actions(json.loads(next_steps))
|
||||
|
||||
logger.info("Next steps generated")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Application configuration and settings."""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Annotated, List, Literal, Optional, Set
|
||||
|
||||
@@ -91,9 +92,37 @@ class Settings(BaseSettings):
|
||||
task_tracker_prefix: str = "task_metadata:"
|
||||
|
||||
|
||||
class TestSettings(Settings):
|
||||
"""Settings with safe defaults for testing."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=None)
|
||||
|
||||
app_api_token: SecretStr = SecretStr("test-api-token")
|
||||
aws_storage_bucket_name: str = "test-bucket"
|
||||
aws_s3_endpoint_url: str = "http://localhost:9000"
|
||||
aws_s3_access_key_id: str = "test-access-key"
|
||||
aws_s3_secret_access_key: SecretStr = SecretStr("test-secret-key")
|
||||
aws_s3_secure_access: bool = False
|
||||
whisperx_api_key: SecretStr = SecretStr("test-whisperx-key")
|
||||
whisperx_base_url: str = "http://localhost:8000/v1"
|
||||
llm_base_url: str = "http://localhost:8001/v1"
|
||||
llm_api_key: SecretStr = SecretStr("test-llm-key")
|
||||
llm_model: str = "test-model"
|
||||
webhook_api_token: SecretStr = SecretStr("test-webhook-token")
|
||||
webhook_url: str = "http://localhost:8002/webhook"
|
||||
celery_broker_url: str = "memory://"
|
||||
celery_result_backend: str = "cache+memory://"
|
||||
posthog_enabled: bool = False
|
||||
sentry_is_enabled: bool = False
|
||||
langfuse_enabled: bool = False
|
||||
task_tracker_redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings():
|
||||
"""Load and cache application settings."""
|
||||
if os.environ.get("SUMMARY_ENV") == "test":
|
||||
return TestSettings()
|
||||
return Settings()
|
||||
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ from minio.error import MinioException, S3Error
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -31,23 +28,24 @@ class FileService:
|
||||
def __init__(self):
|
||||
"""Initialize FileService with MinIO client and configuration."""
|
||||
endpoint = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
get_settings()
|
||||
.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
|
||||
self._minio_client = Minio(
|
||||
endpoint,
|
||||
access_key=settings.aws_s3_access_key_id,
|
||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
secure=settings.aws_s3_secure_access,
|
||||
access_key=get_settings().aws_s3_access_key_id,
|
||||
secret_key=get_settings().aws_s3_secret_access_key.get_secret_value(),
|
||||
secure=get_settings().aws_s3_secure_access,
|
||||
)
|
||||
|
||||
self._bucket_name = settings.aws_storage_bucket_name
|
||||
self._bucket_name = get_settings().aws_storage_bucket_name
|
||||
self._stream_chunk_size = 32 * 1024
|
||||
|
||||
self._allowed_extensions = settings.recording_allowed_extensions
|
||||
self._max_duration = settings.recording_max_duration
|
||||
self._allowed_extensions = get_settings().recording_allowed_extensions
|
||||
self._max_duration = get_settings().recording_max_duration
|
||||
|
||||
def _download_from_minio(self, remote_object_key) -> Path:
|
||||
"""Download file from MinIO to local temporary file.
|
||||
@@ -174,7 +172,7 @@ class FileService:
|
||||
|
||||
extension = downloaded_path.suffix.lower()
|
||||
|
||||
if extension in settings.recording_video_extensions:
|
||||
if extension in get_settings().recording_video_extensions:
|
||||
logger.info("Video file detected, extracting audio...")
|
||||
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
|
||||
processed_path = extracted_audio_path
|
||||
|
||||
@@ -8,9 +8,6 @@ from langfuse import Langfuse
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,28 +31,28 @@ class LLMObservability:
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
|
||||
if settings.langfuse_enabled:
|
||||
if get_settings().langfuse_enabled:
|
||||
|
||||
def masking_function(data, **kwargs):
|
||||
if (
|
||||
user_has_tracing_consent
|
||||
or settings.langfuse_environment != "production"
|
||||
or get_settings().langfuse_environment != "production"
|
||||
):
|
||||
return data
|
||||
|
||||
return "[REDACTED]"
|
||||
|
||||
if not settings.langfuse_secret_key:
|
||||
if not get_settings().langfuse_secret_key:
|
||||
raise ValueError(
|
||||
"langfuse_secret_key is not configured. "
|
||||
"Please set the secret key or disable Langfuse."
|
||||
)
|
||||
|
||||
self._observability_client = Langfuse(
|
||||
secret_key=settings.langfuse_secret_key.get_secret_value(),
|
||||
public_key=settings.langfuse_public_key,
|
||||
host=settings.langfuse_host,
|
||||
environment=settings.langfuse_environment,
|
||||
secret_key=get_settings().langfuse_secret_key.get_secret_value(),
|
||||
public_key=get_settings().langfuse_public_key,
|
||||
host=get_settings().langfuse_host,
|
||||
environment=get_settings().langfuse_environment,
|
||||
mask=masking_function,
|
||||
)
|
||||
|
||||
@@ -72,8 +69,8 @@ class LLMObservability:
|
||||
to Langfuse for observability when enabled.
|
||||
"""
|
||||
base_args = {
|
||||
"base_url": settings.llm_base_url,
|
||||
"api_key": settings.llm_api_key.get_secret_value(),
|
||||
"base_url": get_settings().llm_base_url,
|
||||
"api_key": get_settings().llm_api_key.get_secret_value(),
|
||||
}
|
||||
|
||||
if not self.is_enabled:
|
||||
@@ -120,7 +117,7 @@ class LLMService:
|
||||
"""
|
||||
try:
|
||||
params: dict[str, Any] = {
|
||||
"model": settings.llm_model,
|
||||
"model": get_settings().llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
|
||||
@@ -6,8 +6,6 @@ from typing import Optional, Tuple
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.locales import LocaleStrings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -23,7 +21,7 @@ class TranscriptFormatter:
|
||||
|
||||
def __init__(self, locale: LocaleStrings):
|
||||
"""Initialize formatter with settings and locale."""
|
||||
self.hallucination_patterns = settings.hallucination_patterns
|
||||
self.hallucination_patterns = get_settings().hallucination_patterns
|
||||
self._locale = locale
|
||||
|
||||
def _get_segments(self, transcription):
|
||||
|
||||
@@ -9,8 +9,6 @@ from urllib3.util import Retry
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -18,9 +16,9 @@ def _create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
total=get_settings().webhook_max_retries,
|
||||
backoff_factor=get_settings().webhook_backoff_factor,
|
||||
status_forcelist=get_settings().webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
@@ -31,7 +29,9 @@ def _post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = _create_retry_session()
|
||||
session.headers.update(
|
||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
||||
{
|
||||
"Authorization": f"Bearer {get_settings().webhook_api_token.get_secret_value()}" # noqa: E501
|
||||
}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
@@ -53,10 +53,10 @@ def submit_content(content, title, email, sub):
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting to %s", settings.webhook_url)
|
||||
logger.debug("Submitting to %s", get_settings().webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
|
||||
response = _post_with_retries(settings.webhook_url, data)
|
||||
response = _post_with_retries(get_settings().webhook_url, data)
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the API summary service."""
|
||||
@@ -1,88 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,24 +1,7 @@
|
||||
"""Integration test configuration. Provides shared fixtures."""
|
||||
"""Shared test fixtures and environment setup for the summary service tests."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
import os
|
||||
|
||||
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
|
||||
# Activate TestSettings (safe defaults for all required env vars)
|
||||
# before any summary module is imported.
|
||||
os.environ["SUMMARY_ENV"] = "test"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests for the summary service."""
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Integration test configuration. Provides shared fixtures."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from summary.core.celery_worker import celery
|
||||
from summary.main import app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
"""Provide a FastAPI TestClient for integration tests."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def eager_celery():
|
||||
"""Run Celery tasks synchronously in the same process."""
|
||||
celery.conf.task_always_eager = True
|
||||
celery.conf.task_eager_propagates = True
|
||||
yield
|
||||
celery.conf.task_always_eager = False
|
||||
celery.conf.task_eager_propagates = False
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Integration test for the transcribe-and-summarize task flow via the API."""
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import responses
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
API_PREFIX = "/api/v1"
|
||||
AUTH_HEADER = {
|
||||
"Authorization": f"Bearer {get_settings().app_api_token.get_secret_value()}"
|
||||
}
|
||||
WEBHOOK_URL = get_settings().webhook_url
|
||||
|
||||
|
||||
class TestTranscribeSummarizeFlow:
|
||||
"""End-to-end test: POST /tasks/ triggers transcription and summary via webhook."""
|
||||
|
||||
@responses.activate
|
||||
@patch("summary.core.celery_worker.analytics")
|
||||
@patch("summary.core.celery_worker.LLMObservability")
|
||||
@patch("summary.core.celery_worker.LLMService")
|
||||
@patch("summary.core.celery_worker.metadata_manager")
|
||||
@patch("summary.core.celery_worker.openai")
|
||||
@patch("summary.core.celery_worker.file_service")
|
||||
def test_transcription_and_summary_are_submitted( # noqa: PLR0913
|
||||
self,
|
||||
mock_file_service,
|
||||
mock_openai,
|
||||
mock_metadata,
|
||||
mock_llm_cls,
|
||||
mock_observability_cls,
|
||||
mock_analytics,
|
||||
client,
|
||||
eager_celery,
|
||||
):
|
||||
"""Creating a task produces a transcription and summary sent to the webhook."""
|
||||
# Stub file service
|
||||
fake_audio = MagicMock()
|
||||
|
||||
@contextmanager
|
||||
def fake_prepare(filename):
|
||||
yield fake_audio, {"duration": 60.0}
|
||||
|
||||
mock_file_service.prepare_audio_file = fake_prepare
|
||||
|
||||
# Stub WhisperX transcription
|
||||
fake_transcription = SimpleNamespace(
|
||||
segments=[
|
||||
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
|
||||
{"speaker": "SPEAKER_01", "text": "Let's discuss the roadmap."},
|
||||
],
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.audio.transcriptions.create.return_value = fake_transcription
|
||||
mock_openai.OpenAI.return_value = mock_client
|
||||
|
||||
# Stub analytics to enable summary
|
||||
mock_analytics.is_feature_enabled.return_value = True
|
||||
|
||||
# Stub LLM for summarization
|
||||
mock_llm = MagicMock()
|
||||
mock_llm_cls.return_value = mock_llm
|
||||
|
||||
plan_json = json.dumps({"titles": ["Roadmap"]})
|
||||
next_steps_json = json.dumps(
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"title": "Draft roadmap",
|
||||
"assignees": ["Aleb"],
|
||||
"due_date": "2026-03-04",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
mock_llm.call.side_effect = [
|
||||
"### TL;DR\nQuick overview.", # tldr
|
||||
plan_json, # parts plan
|
||||
"### Roadmap\nDetails.", # part content
|
||||
next_steps_json, # next steps
|
||||
"Cleaned summary.", # cleaning
|
||||
]
|
||||
|
||||
mock_observability_cls.return_value = MagicMock()
|
||||
|
||||
# Stub webhook (called twice: transcription + summary)
|
||||
responses.post(WEBHOOK_URL, json={"id": "doc-1"}, status=200)
|
||||
responses.post(WEBHOOK_URL, json={"id": "doc-2"}, status=200)
|
||||
|
||||
payload = {
|
||||
"owner_id": "owner-1",
|
||||
"filename": "recording.webm",
|
||||
"email": "user@example.com",
|
||||
"sub": "user-sub-id",
|
||||
"room": "Visio room",
|
||||
"recording_date": "2026-03-04",
|
||||
"recording_time": "09:00",
|
||||
"language": "en",
|
||||
"download_link": "https://example.com/rec.webm",
|
||||
"context_language": "en",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/tasks/", json=payload, headers=AUTH_HEADER
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "id" in body
|
||||
assert body["message"] == "Task created"
|
||||
|
||||
# Verify the webhook received the transcription
|
||||
assert len(responses.calls) >= 1
|
||||
transcript_payload = json.loads(responses.calls[0].request.body)
|
||||
assert "SPEAKER_00" in transcript_payload["content"]
|
||||
assert "Hello everyone." in transcript_payload["content"]
|
||||
assert "Visio room" in transcript_payload["title"]
|
||||
assert transcript_payload["email"] == "user@example.com"
|
||||
assert transcript_payload["sub"] == "user-sub-id"
|
||||
|
||||
# Verify the webhook received the summary
|
||||
assert len(responses.calls) == 2
|
||||
summary_payload = json.loads(responses.calls[1].request.body)
|
||||
assert "TL;DR" in summary_payload["content"]
|
||||
assert "Cleaned summary." in summary_payload["content"]
|
||||
assert "Draft roadmap" in summary_payload["content"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for the summary service."""
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for the celery_worker module."""
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from summary.core.celery_worker import (
|
||||
format_transcript,
|
||||
summarize_transcription,
|
||||
transcribe_audio,
|
||||
)
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.file_service import FileServiceException
|
||||
|
||||
WEBHOOK_URL = get_settings().webhook_url
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transcribe_audio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTranscribeAudio:
|
||||
"""Tests for the transcribe_audio function."""
|
||||
|
||||
@patch("summary.core.celery_worker.metadata_manager")
|
||||
@patch("summary.core.celery_worker.openai")
|
||||
@patch("summary.core.celery_worker.file_service")
|
||||
def test_success(self, mock_file_service, mock_openai, mock_metadata):
|
||||
"""Transcription succeeds and returns the transcription object."""
|
||||
fake_audio = MagicMock()
|
||||
fake_metadata = {"duration": 120.5}
|
||||
|
||||
@contextmanager
|
||||
def fake_prepare(filename):
|
||||
yield fake_audio, fake_metadata
|
||||
|
||||
mock_file_service.prepare_audio_file = fake_prepare
|
||||
|
||||
fake_transcription = SimpleNamespace(
|
||||
segments=[{"speaker": "SPEAKER_00", "text": "Hello"}],
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.audio.transcriptions.create.return_value = fake_transcription
|
||||
mock_openai.OpenAI.return_value = mock_client
|
||||
|
||||
result = transcribe_audio("task-1", "recording.ogg", "en")
|
||||
|
||||
assert result is fake_transcription
|
||||
mock_client.audio.transcriptions.create.assert_called_once()
|
||||
call_kwargs = mock_client.audio.transcriptions.create.call_args
|
||||
assert call_kwargs.kwargs["language"] == "en"
|
||||
assert call_kwargs.kwargs["file"] is fake_audio
|
||||
|
||||
@patch("summary.core.celery_worker.metadata_manager")
|
||||
@patch("summary.core.celery_worker.openai")
|
||||
@patch("summary.core.celery_worker.file_service")
|
||||
def test_file_service_error_returns_none(
|
||||
self, mock_file_service, mock_openai, mock_metadata
|
||||
):
|
||||
"""Returns None when the file cannot be retrieved."""
|
||||
|
||||
@contextmanager
|
||||
def failing_prepare(filename):
|
||||
raise FileServiceException("download failed")
|
||||
yield # NOSONAR - yield required for contextmanager
|
||||
|
||||
mock_file_service.prepare_audio_file = failing_prepare
|
||||
|
||||
result = transcribe_audio("task-1", "recording.ogg", "en")
|
||||
|
||||
assert result is None
|
||||
mock_openai.OpenAI.return_value.audio.transcriptions.create.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_transcript
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatTranscript:
|
||||
"""Tests for the format_transcript function."""
|
||||
|
||||
def test_with_segments(self):
|
||||
"""Formats a transcription with segments into content and title."""
|
||||
transcription = {
|
||||
"segments": [
|
||||
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
|
||||
{"speaker": "SPEAKER_01", "text": "Good morning."},
|
||||
],
|
||||
}
|
||||
|
||||
content, title = format_transcript(
|
||||
transcription,
|
||||
context_language="en",
|
||||
language="en",
|
||||
room="Daily standup",
|
||||
recording_date="2026-03-04",
|
||||
recording_time="09:00",
|
||||
download_link="https://example.com/rec.ogg",
|
||||
)
|
||||
|
||||
assert "SPEAKER_00" in content
|
||||
assert "Hello everyone." in content
|
||||
assert "SPEAKER_01" in content
|
||||
assert "Good morning." in content
|
||||
assert "Daily standup" in title
|
||||
assert "2026-03-04" in title
|
||||
assert "09:00" in title
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"context_language, expected_string",
|
||||
[
|
||||
("en", "Download your recording"),
|
||||
("fr", "Télécharger votre enregistrement"),
|
||||
("de", "diesem Link folgen"),
|
||||
("nl", "Download uw opname door"),
|
||||
],
|
||||
)
|
||||
def test_context_language(self, context_language, expected_string):
|
||||
"""Context language parameter modifies output."""
|
||||
transcription = {
|
||||
"segments": [
|
||||
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
|
||||
],
|
||||
}
|
||||
|
||||
content, _ = format_transcript(
|
||||
transcription,
|
||||
context_language=context_language,
|
||||
language="en",
|
||||
room="Daily standup",
|
||||
recording_date="2026-03-04",
|
||||
recording_time="09:00",
|
||||
download_link="https://example.com/rec.ogg",
|
||||
)
|
||||
|
||||
assert expected_string in content
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""Returns empty-transcription message when there are no segments."""
|
||||
transcription = {"segments": []}
|
||||
|
||||
content, title = format_transcript(
|
||||
transcription,
|
||||
context_language="en",
|
||||
language="en",
|
||||
room=None,
|
||||
recording_date=None,
|
||||
recording_time=None,
|
||||
download_link=None,
|
||||
)
|
||||
|
||||
assert "No audio content" in content or "Transcription" in title
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# summarize_transcription
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSummarizeTranscription:
|
||||
"""Tests for the summarize_transcription Celery task."""
|
||||
|
||||
@responses.activate
|
||||
@patch("summary.core.celery_worker.LLMService")
|
||||
@patch("summary.core.celery_worker.LLMObservability")
|
||||
@patch("summary.core.celery_worker.analytics")
|
||||
def test_generates_and_submits_summary(
|
||||
self, mock_analytics, mock_observability_cls, mock_llm_cls
|
||||
):
|
||||
"""Assembles TLDR + parts + next steps + cleaning, then submits."""
|
||||
mock_analytics.is_feature_enabled.return_value = False
|
||||
|
||||
# Mock the webhook HTTP endpoint
|
||||
responses.post(
|
||||
WEBHOOK_URL,
|
||||
json={"id": "doc-42"},
|
||||
status=200,
|
||||
)
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_llm_cls.return_value = mock_llm
|
||||
|
||||
plan_json = json.dumps({"titles": ["Topic A", "Topic B"]})
|
||||
next_steps_json = json.dumps(
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"title": "What's nice about Visio",
|
||||
"assignees": ["Aleb"],
|
||||
"due_date": "2026-03-04",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# LLM calls in order: tldr, parts (plan), part A, part B, next-steps, cleaning
|
||||
mock_llm.call.side_effect = [
|
||||
"### TL;DR\nShort summary.", # tldr
|
||||
plan_json, # parts plan
|
||||
"### Topic A\nDetails about A.", # part A
|
||||
"### Topic B\nDetails about B.", # part B
|
||||
next_steps_json, # next steps
|
||||
"Cleaned summary content.", # cleaning
|
||||
]
|
||||
|
||||
mock_observability = MagicMock()
|
||||
mock_observability_cls.return_value = mock_observability
|
||||
|
||||
# Push a fake request context so self.request.id is available
|
||||
summarize_transcription.push_request(id="summary-task-1")
|
||||
try:
|
||||
summarize_transcription.run(
|
||||
"owner-1",
|
||||
"Full transcript text",
|
||||
"user@example.com",
|
||||
"oidc-sub-123",
|
||||
"99.999% uptime. Is it reasonable ?",
|
||||
)
|
||||
finally:
|
||||
summarize_transcription.pop_request()
|
||||
|
||||
# Verify the webhook was called with the assembled summary
|
||||
assert len(responses.calls) == 1
|
||||
webhook_request = responses.calls[0]
|
||||
submitted_payload = json.loads(webhook_request.request.body)
|
||||
|
||||
assert "TL;DR" in submitted_payload["content"]
|
||||
assert "Cleaned summary content." in submitted_payload["content"]
|
||||
assert "What's nice about Visio" in submitted_payload["content"]
|
||||
assert "99.999% uptime. Is it reasonable ?" in submitted_payload["title"]
|
||||
assert submitted_payload["email"] == "user@example.com"
|
||||
assert submitted_payload["sub"] == "oidc-sub-123"
|
||||
|
||||
# Verify auth header was sent
|
||||
assert (
|
||||
webhook_request.request.headers["Authorization"]
|
||||
== f"Bearer {get_settings().webhook_api_token.get_secret_value()}"
|
||||
)
|
||||
|
||||
# LLM was called for: tldr, plan, part A, part B, next-steps, cleaning
|
||||
expected_llm_calls = 6
|
||||
assert mock_llm.call.call_count == expected_llm_calls
|
||||
|
||||
mock_observability.flush.assert_called_once()
|
||||
Reference in New Issue
Block a user