Compare commits

..

1 Commits

Author SHA1 Message Date
Jacques ROUSSEL fd3dfe746d 🐛(ci) use github action for argocd webhook notification
In order to refactor this notification between alls projetcs, we
chooseto use a custom github action
2025-03-28 14:13:12 +01:00
51 changed files with 2262 additions and 2494 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "secrets"]
path = secrets
url = ../secrets
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024-2025 DINUM/Etalab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024-2025 DINUM/Etalab
Copyright (c) 2021-2023 DINUM/Etalab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.8.0
FROM livekit/livekit-server:latest
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
+10 -107
View File
@@ -2,6 +2,7 @@
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
## Prerequisites
- k8s cluster with an nginx-ingress controller
@@ -42,11 +43,11 @@ It will expire on 23 March 2027 🗓
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "visio" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-visio"
You can now use your cluster with:
@@ -94,14 +95,13 @@ ingress-nginx-admission-create-jgnc9 0/1 Completed 0 2m44s
ingress-nginx-admission-patch-wrt47 0/1 Completed 0 2m44s
ingress-nginx-controller-57c548c4cd-9xwt6 1/1 Running 0 2m44s
```
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the \*.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
### What will you use to authenticate your users ?
### What will you use to authenticate your users ?
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
@@ -153,7 +153,6 @@ keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
When the redis is ready we can deploy livekit-server.
```
@@ -195,7 +194,6 @@ livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 15m
postgresql-0 1/1 Running 0 50s
redis-master-0 1/1 Running 0 19
```
From here important information you will need are :
```
@@ -214,7 +212,7 @@ POSTGRES_PASSWORD: pass
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
```
$ helm repo add meet https://suitenumerique.github.io/meet/
$ helm repo add meet https://numerique-gouv.github.io/meet/
$ helm repo update
$ helm install meet meet/meet -f examples/meet.values.yaml
```
@@ -233,98 +231,3 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
```
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
## All options
These are the environmental options available on meet backend.
| Option | Description | default |
| ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| DJANGO_ALLOWED_HOSTS | Host that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | name of the database | meet |
| DB_USER | user used to connect to database | dinum |
| DB_PASSWORD | password used to connect to the database | pass |
| DB_HOST | hostname of the database | localhost |
| DB_PORT | port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | s3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | s3 secret key | |
| AWS_S3_REGION_NAME | s3 region | |
| AWS_STORAGE_BUCKET_NAME | s3 bucket name | |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | redis endpoint | redis://redis:6379/1 |
| REQUEST_ENTRY_THROTTLE_RATES | throttle rates | 150/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | deploy check | False |
| FRONTEND_ANALYTICS | analytics information | {} |
| FRONTEND_SUPPORT | crisp frontend support | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | silence livekit debug | false |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | host of the email server | |
| DJANGO_EMAIL_HOST_USER | user to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | password to connect tto the email server | |
| DJANGO_EMAIL_PORT | por tot connect to the email server | |
| DJANGO_EMAIL_USE_TLS | enable tls on email connection | false |
| DJANGO_EMAIL_USE_SSL | enable tls on email connection | false |
| DJANGO_EMAIL_FROM | email from account | from@example.com |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all cors origins | true |
| DJANGO_CORS_ALLOWED_ORIGINS | origins to allow in string | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | origins to allow in regex | [] |
| SENTRY_DSN | sentry server | |
| DJANGO_CELERY_BROKER_URL | celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker options | {} |
| OIDC_CREATE_USER | create oidc user if not exists | false |
| OIDC_VERIFY_SSL | verify ssl for oidc | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | token verification algoritm used by oidc | RS256 |
| OIDC_RP_CLIENT_ID | oidc client | meet |
| OIDC_RP_CLIENT_SECRET | oidc client secret | |
| OIDC_OP_JWKS_ENDPOINT | oidc endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
| OIDC_RP_SCOPES | oidc scopes | openid email |
| LOGIN_REDIRECT_URL | login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redurect url for failure | |
| LOGOUT_REDIRECT_URL | url to redirect to on logout | |
| OIDC_USE_NONCE | use nonce for oidc | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | require https for oidc | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | allowed redirect hosts for oidc | [] |
| OIDC_STORE_ID_TOKEN | store oidc ID token | true |
| ALLOW_LOGOUT_GET_METHOD | allow logout through get method | true |
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
| LIVEKIT_API_KEY | livekit api key | |
| LIVEKIT_API_SECRET | livekit api secret | |
| LIVEKIT_API_URL | livekit api url | |
| RESOURCE_DEFAULT_ACCESS_LEVEL | default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | record meeting option | false |
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
| RECORDING_VERIFY_SSL | verify ssl for recording storage | true |
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | recording storage event token | |
| SUMMARY_SERVICE_ENDPOINT | summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | api token voor summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | markering class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | breva api key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | brevo api contact list ids | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | brevo contact attributes | {"VISIO_USER": True} |
| BREVO_API_TIMEOUT | brevo timeout | 1 |
| LOBBY_KEY_PREFIX | lobby prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | lobby waiting tumeout | 3 |
| LOBBY_DENIED_TIMEOUT | lobby deny timeout | 5 |
| LOBBY_ACCEPTED_TIMEOUT | lobby accept timeout | 21600 |
| LOBBY_NOTIFICATION_TYPE | lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | lobby cookie name | lobbyParticipantId |
Submodule
+1
Submodule secrets added at 2ba12db71d
+1 -3
View File
@@ -121,9 +121,7 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
marketing_service.create_contact(contact_data, timeout=1)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
+7 -23
View File
@@ -12,12 +12,7 @@ from django.conf import settings
from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
)
from livekit.api import LiveKitAPI, SendDataRequest, TwirpError # pylint: disable=E0611
from core import models, utils
@@ -226,12 +221,6 @@ class LobbyService:
color=color,
)
try:
self.notify_participants(room_id=room_id)
except LobbyNotificationError:
# If room not created yet, there is no participants to notify
pass
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
cache_key,
@@ -239,6 +228,12 @@ class LobbyService:
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
try:
self.notify_participants(room_id=room_id)
except LobbyNotificationError:
# If room not created yet, there is no participants to notify
pass
return participant
def _get_participant(
@@ -348,18 +343,7 @@ class LobbyService:
}
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[str(room_id)],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=str(room_id),
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>
@@ -776,43 +776,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Assert
# Verify the API was initialized with correct configuration
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success(mock_livekit_api, lobby_service):
"""Test successful participant notification."""
@@ -821,14 +784,6 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
@@ -838,9 +793,6 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
@@ -865,14 +817,6 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
@@ -885,9 +829,6 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
+1 -7
View File
@@ -19,7 +19,6 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -315,6 +314,7 @@ class Base(Configuration):
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
# CORS
CORS_ALLOW_CREDENTIALS = True
@@ -498,9 +498,6 @@ class Base(Configuration):
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
)
# Lobby configurations
LOBBY_KEY_PREFIX = values.Value(
@@ -579,9 +576,6 @@ class Base(Configuration):
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
class Build(Base):
"""Settings used when the application is built.
+12 -11
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.17"
version = "0.1.15"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.37.18",
"boto3==1.37.4",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
@@ -48,16 +48,17 @@ dependencies = [
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.6",
"psycopg[binary]==3.2.5",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.24.1",
"sentry-sdk==2.22.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.2",
"aiohttp==3.11.14",
"aiohttp==3.11.13",
]
[project.urls]
@@ -72,18 +73,18 @@ dev = [
"drf-spectacular-sidecar==2025.3.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==9.0.2",
"pyfakefs==5.8.0",
"ipython==9.0.1",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.6",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.2",
"types-requests==2.32.0.20250306",
"responses==0.25.6",
"ruff==0.9.9",
"types-requests==2.32.0.20250301",
]
[tool.setuptools]
+2069 -1274
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.17",
"version": "0.1.15",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -16,45 +16,45 @@
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.53.3",
"@react-aria/toast": "3.0.1",
"@pandacss/preset-panda": "0.53.0",
"@react-aria/toast": "3.0.0-beta.19",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.69.0",
"@tanstack/react-query": "5.67.1",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "24.2.3",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.4",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.9.8",
"posthog-js": "1.232.6",
"livekit-client": "2.9.5",
"posthog-js": "1.225.1",
"react": "18.3.1",
"react-aria-components": "1.7.1",
"react-aria-components": "1.6.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.4",
"valtio": "2.1.3",
"wouter": "3.6.0"
},
"devDependencies": {
"@pandacss/dev": "0.53.3",
"@tanstack/eslint-plugin-query": "5.68.0",
"@tanstack/react-query-devtools": "5.69.0",
"@types/node": "22.13.13",
"@pandacss/dev": "0.53.0",
"@tanstack/eslint-plugin-query": "5.66.1",
"@tanstack/react-query-devtools": "5.67.1",
"@types/node": "22.13.9",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.28.0",
"@typescript-eslint/parser": "8.28.0",
"@typescript-eslint/eslint-plugin": "8.26.0",
"@typescript-eslint/parser": "8.26.0",
"@vitejs/plugin-react": "4.3.4",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.1",
"eslint-config-prettier": "10.0.2",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.19",
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.8.2",
"vite": "6.2.3",
"vite": "6.2.0",
"vite-tsconfig-paths": "5.1.4"
}
}
+34 -2
View File
@@ -35,7 +35,40 @@ const config: Config = {
exclude: [],
jsxFramework: 'react',
outdir: 'src/styled-system',
globalFontface: {},
globalFontface: {
Marianne: [
{
src: 'url(/fonts/Marianne-Regular-subset.woff2) format("woff2")',
fontWeight: 400,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Regular_Italic-subset.woff2) format("woff2")',
fontWeight: 400,
fontStyle: 'italic',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Medium-subset.woff2) format("woff2")',
fontWeight: 500,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-Bold-subset.woff2) format("woff2")',
fontWeight: 700,
fontStyle: 'normal',
fontDisplay: 'swap',
},
{
path: 'url(/fonts/Marianne-ExtraBold-subset.woff2) format("woff2")',
fontWeight: 800,
fontStyle: 'normal',
fontDisplay: 'swap',
},
],
},
theme: {
...pandaPreset.theme,
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
@@ -190,7 +223,6 @@ const config: Config = {
fonts: {
sans: {
value: [
'Marianne',
'Source Sans',
'Source Sans fallback',
'ui-sans-serif',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+3 -10
View File
@@ -1,4 +1,4 @@
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
import { cva, RecipeVariantProps } from '@/styled-system/css'
import React from 'react'
const avatar = cva({
@@ -19,8 +19,7 @@ const avatar = cva({
list: {
width: '32px',
height: '32px',
fontSize: '1.3rem',
lineHeight: '1rem',
fontSize: '1.25rem',
},
placeholder: {
width: '100%',
@@ -61,13 +60,7 @@ export const Avatar = ({
className={avatar({ context, notification })}
{...props}
>
<span
className={css({
marginTop: '-0.3rem',
})}
>
{initial}
</span>
{initial}
</div>
)
}
@@ -25,9 +25,7 @@ export const FeedbackBanner = () => {
})}
>
<RiErrorWarningLine size={20} aria-hidden="true" />
<Text as="p" variant="sm">
{t('feedback.context')}
</Text>
<Text as="p">{t('feedback.context')}</Text>
<div
className={css({
display: 'flex',
@@ -35,7 +33,7 @@ export const FeedbackBanner = () => {
gap: 0.25,
})}
>
<A href={GRIST_FORM} target="_blank" size="sm">
<A href={GRIST_FORM} target="_blank">
{t('feedback.cta')}
</A>
<RiExternalLinkLine size={16} aria-hidden="true" />
@@ -48,7 +48,7 @@ export const useUser = (
const isLoggedOut = isLoggedIn === false
return {
refetch: query.refetch,
...query,
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
isLoggedIn,
logout,
@@ -17,13 +17,12 @@ const Heading = styled('h2', {
base: {
width: 'fit-content',
marginBottom: 0,
fontSize: '1.3rem',
fontWeight: '700',
fontSize: '1.5rem',
marginTop: '0.75rem',
lineHeight: '1.7rem',
lineHeight: '2rem',
maxWidth: '23rem',
textAlign: 'center',
textWrap: 'balance',
textWrap: 'pretty',
},
})
@@ -32,7 +31,7 @@ const Body = styled('p', {
maxWidth: '23rem',
textAlign: 'center',
textWrap: 'pretty',
lineHeight: '1.4rem',
lineHeight: '1.2rem',
fontSize: '1rem',
},
})
@@ -103,7 +102,7 @@ const Slide = styled('div', {
alignItems: 'center',
gap: '0.5rem',
justifyContent: 'start',
minHeight: { base: 'none', xsm: '580px' },
minHeight: { base: 'none', xsm: '550px' },
minWidth: { base: 'none', xsm: '200px' },
width: { base: '100%', xsm: '22.625rem' },
},
@@ -72,8 +72,8 @@ const LeftColumn = ({ children }: { children?: ReactNode }) => {
textAlign: 'left',
alignItems: 'flex-start',
flexShrink: '1',
flexBasis: '40rem',
maxWidth: '40rem',
flexBasis: '36rem',
maxWidth: '36rem',
padding: '1em 3em',
},
})}
@@ -120,13 +120,14 @@ const Separator = styled('div', {
const Heading = styled('h1', {
base: {
fontWeight: '700',
fontWeight: '500',
fontStyle: 'normal',
fontStretch: 'normal',
fontOpticalSizing: 'auto',
paddingBottom: '1.2rem',
marginBottom: 0,
paddingBottom: '0.75rem',
fontSize: '2.3rem',
lineHeight: '2.6rem',
lineHeight: '2.5rem',
letterSpacing: '0',
xsm: {
fontSize: '3rem',
@@ -138,9 +139,9 @@ const Heading = styled('h1', {
const IntroText = styled('div', {
base: {
marginBottom: '3rem',
fontSize: '1.2rem',
lineHeight: '1.5rem',
textWrap: 'balance',
fontSize: '1.5rem',
lineHeight: '1.8rem',
textWrap: 'pretty',
maxWidth: '32rem',
},
})
@@ -298,7 +298,7 @@ export const Join = ({
case ApiLobbyStatus.TIMEOUT:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
<H lvl={1} margin={false}>
{t('timeoutInvite.title')}
</H>
<Text as="p" variant="note">
@@ -310,7 +310,7 @@ export const Join = ({
case ApiLobbyStatus.DENIED:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
<H lvl={1} margin={false}>
{t('denied.title')}
</H>
<Text as="p" variant="note">
@@ -322,7 +322,7 @@ export const Join = ({
case ApiLobbyStatus.WAITING:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
<H lvl={1} margin={false}>
{t('waiting.title')}
</H>
<Text
@@ -346,7 +346,7 @@ export const Join = ({
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin="sm" centered>
<H lvl={1} margin="sm">
{t('heading')}
</H>
<Field
@@ -37,14 +37,7 @@ export const Admin = () => {
flexDirection="column"
alignItems="start"
>
<Text
variant="note"
wrap="pretty"
className={css({
textStyle: 'sm',
})}
margin={'md'}
>
<Text variant="note" wrap="pretty" margin="md">
{t('description')}
</Text>
<RACSeparator
@@ -75,8 +68,8 @@ export const Admin = () => {
</Text>
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
label="Type d'accès à la réunion"
aria-label="Type d'accès à la réunion"
labelProps={{
className: css({
fontSize: '1rem',
@@ -75,7 +75,7 @@ export function FloatingReaction({
>
<span
className={css({
lineHeight: '57px',
lineHeight: '45px',
})}
>
{emoji}
@@ -89,10 +89,9 @@ export function FloatingReaction({
textAlign: 'center',
borderRadius: '20px',
paddingX: '0.5rem',
paddingBottom: '0.3125rem',
paddingTop: '0.15rem',
paddingY: '0.15rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
lineHeight: '16px',
lineHeight: '14px',
})}
>
{name}
@@ -1,66 +0,0 @@
import { A, Button, Dialog, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
// todo - refactor it into a generic system
export const ScreenShareErrorModal = ({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
return (
<Dialog
isOpen={isOpen}
role="alertdialog"
title={t('title')}
aria-label={t('ariaLabel')}
onClose={onClose}
>
{({ close }) => {
return (
<>
<P>
{t('message')}{' '}
{isMac && (
<>
{t('macInstructions')}{' '}
<A
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
color="primary"
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
>
{t('macSystemPreferences')}
</A>
.{' '}
</>
)}
{t('helpLinkText')}{' '}
<A
href="https://lasuite.crisp.help/fr/article/visio-probleme-de-presentation-1xkf799/"
aria-label={t('helpLinkLabel') + '-' + t('newTab')}
target="_blank"
color="primary"
>
{t('helpLinkLabel')}
</A>
.
</P>
<Button
onPress={close}
size="sm"
variant="primary"
className={css({ marginLeft: 'auto', marginTop: '2rem' })}
>
{t('closeButton')}
</Button>
</>
)
}}
</Dialog>
)
}
@@ -13,7 +13,7 @@ import {
/**
* This is simply a wrapper around track-processor-js Processor
* in order to be compatible with a common interface BackgroundBlurProcessorInterface
* used across the project.
* used accross the project.
*/
export class BackgroundBlurTrackProcessorJsWrapper
implements BackgroundProcessorInterface
@@ -1,345 +0,0 @@
import { ProcessorOptions, Track } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
FaceLandmarker,
FaceLandmarkerResult,
} from '@mediapipe/tasks-vision'
import {
CLEAR_TIMEOUT,
SET_TIMEOUT,
TIMEOUT_TICK,
timerWorkerScript,
} from './TimerWorker'
import {
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
const PROCESSING_WIDTH = 256 * 3
const PROCESSING_HEIGHT = 144 * 3
const FACE_LANDMARKS_CANVAS_ID = 'face-landmarks-local'
export class FaceLandmarksProcessor implements BackgroundProcessorInterface {
options: BackgroundOptions
name: string
processedTrack?: MediaStreamTrack | undefined
source?: MediaStreamTrack
sourceSettings?: MediaTrackSettings
videoElement?: HTMLVideoElement
videoElementLoaded?: boolean
// Canvas containing the video processing result
outputCanvas?: HTMLCanvasElement
outputCanvasCtx?: CanvasRenderingContext2D
faceLandmarker?: FaceLandmarker
faceLandmarkerResult?: FaceLandmarkerResult
// The resized image of the video source
sourceImageData?: ImageData
timerWorker?: Worker
type: ProcessorType
// Effect images
glassesImage?: HTMLImageElement
mustacheImage?: HTMLImageElement
beretImage?: HTMLImageElement
constructor(opts: BackgroundOptions) {
this.name = 'face_landmarks'
this.options = opts
this.type = ProcessorType.FACE_LANDMARKS
this._initEffectImages()
}
private _initEffectImages() {
this.glassesImage = new Image()
this.glassesImage.src = '/assets/glasses.png'
this.glassesImage.crossOrigin = 'anonymous'
this.mustacheImage = new Image()
this.mustacheImage.src = '/assets/mustache.png'
this.mustacheImage.crossOrigin = 'anonymous'
this.beretImage = new Image()
this.beretImage.src = '/assets/beret.png'
this.beretImage.crossOrigin = 'anonymous'
}
static get isSupported() {
return true // Face landmarks should work in all modern browsers
}
async init(opts: ProcessorOptions<Track.Kind>) {
if (!opts.element) {
throw new Error('Element is required for processing')
}
this.source = opts.track as MediaStreamTrack
this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement
this._createMainCanvas()
const stream = this.outputCanvas!.captureStream()
const tracks = stream.getVideoTracks()
if (tracks.length == 0) {
throw new Error('No tracks found for processing')
}
this.processedTrack = tracks[0]
await this.initFaceLandmarker()
this._initWorker()
posthog.capture('face-landmarks-init')
}
_initWorker() {
this.timerWorker = new Worker(timerWorkerScript, {
name: 'FaceLandmarks',
})
this.timerWorker.onmessage = (data) => this.onTimerMessage(data)
if (this.videoElementLoaded) {
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
})
} else {
this.videoElement!.onloadeddata = () => {
this.videoElementLoaded = true
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
})
}
}
}
onTimerMessage(response: { data: { id: number } }) {
if (response.data.id === TIMEOUT_TICK) {
this.process()
}
}
async initFaceLandmarker() {
const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
)
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
delegate: 'GPU',
},
runningMode: 'VIDEO',
outputFaceBlendshapes: true,
outputFacialTransformationMatrixes: true,
})
}
async sizeSource() {
this.outputCanvasCtx?.drawImage(
this.videoElement!,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
)
this.sourceImageData = this.outputCanvasCtx?.getImageData(
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
)
}
async detectFaces() {
const startTimeMs = performance.now()
this.faceLandmarkerResult = this.faceLandmarker!.detectForVideo(
this.sourceImageData!,
startTimeMs
)
}
private drawEffect(
leftPoint: { x: number; y: number },
rightPoint: { x: number; y: number },
image: HTMLImageElement,
widthScale: number,
heightScale: number,
yOffset: number = 0
) {
// Calculate distance between points
const distance = Math.sqrt(
Math.pow(rightPoint.x - leftPoint.x, 2) +
Math.pow(rightPoint.y - leftPoint.y, 2)
)
// Scale image based on distance
const width = distance * PROCESSING_WIDTH * widthScale
const height = width * heightScale
// Calculate center position between points
const centerX = (leftPoint.x + rightPoint.x) / 2
const centerY = (leftPoint.y + rightPoint.y) / 2 + yOffset
// Draw image
this.outputCanvasCtx!.save()
this.outputCanvasCtx!.translate(
centerX * PROCESSING_WIDTH,
centerY * PROCESSING_HEIGHT
)
// Calculate rotation angle based on point positions
const angle = Math.atan2(
rightPoint.y - leftPoint.y,
rightPoint.x - leftPoint.x
)
this.outputCanvasCtx!.rotate(angle)
// Draw image centered at the midpoint between points
this.outputCanvasCtx!.drawImage(
image,
-width / 2,
-height / 2,
width,
height
)
this.outputCanvasCtx!.restore()
}
async drawFaceLandmarks() {
// Draw the original video frame at the canvas size
this.outputCanvasCtx!.drawImage(
this.videoElement!,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
)
if (!this.faceLandmarkerResult?.faceLandmarks) {
return
}
// Draw face landmarks (optional, for debugging)
this.outputCanvasCtx!.strokeStyle = '#00FF00'
this.outputCanvasCtx!.lineWidth = 2
for (const face of this.faceLandmarkerResult.faceLandmarks) {
// Find eye landmarks
const leftEye = face[468]
const rightEye = face[473]
// Find mouth landmarks for mustache
const leftMoustache = face[92]
const rightMoustache = face[322]
// Find forehead landmarks for beret
const leftForehead = face[103]
const rightForehead = face[332]
if (leftEye && rightEye && this.options.showGlasses) {
this.drawEffect(leftEye, rightEye, this.glassesImage!, 2.5, 0.7)
}
if (leftMoustache && rightMoustache && this.options.showFrench) {
this.drawEffect(
leftMoustache,
rightMoustache,
this.mustacheImage!,
1.5,
0.5
)
}
if (leftForehead && rightForehead && this.options.showFrench) {
this.drawEffect(
leftForehead,
rightForehead,
this.beretImage!,
2.1,
0.7,
-0.1
)
}
}
}
async process() {
await this.sizeSource()
await this.detectFaces()
await this.drawFaceLandmarks()
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
})
}
_createMainCanvas() {
this.outputCanvas = document.querySelector(
`#${FACE_LANDMARKS_CANVAS_ID}`
) as HTMLCanvasElement
if (!this.outputCanvas) {
this.outputCanvas = this._createCanvas(
FACE_LANDMARKS_CANVAS_ID,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
)
}
this.outputCanvasCtx = this.outputCanvas.getContext('2d')!
}
_createCanvas(id: string, width: number, height: number) {
const element = document.createElement('canvas')
element.setAttribute('id', id)
element.setAttribute('width', '' + width)
element.setAttribute('height', '' + height)
return element
}
update(opts: BackgroundOptions): void {
this.options = opts
}
async restart(opts: ProcessorOptions<Track.Kind>) {
await this.destroy()
return this.init(opts)
}
async destroy() {
this.timerWorker?.postMessage({
id: CLEAR_TIMEOUT,
})
this.timerWorker?.terminate()
this.faceLandmarker?.close()
}
clone() {
return new FaceLandmarksProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
}
@@ -3,13 +3,10 @@ import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { BackgroundVirtualTrackProcessorJsWrapper } from './BackgroundVirtualTrackProcessorJsWrapper'
import { FaceLandmarksProcessor } from './FaceLandmarksProcessor'
export type BackgroundOptions = {
blurRadius?: number
imagePath?: string
showGlasses?: boolean
showFrench?: boolean
}
export interface ProcessorSerialized {
@@ -28,16 +25,11 @@ export interface BackgroundProcessorInterface
export enum ProcessorType {
BLUR = 'blur',
VIRTUAL = 'virtual',
FACE_LANDMARKS = 'faceLandmarks',
}
export class BackgroundProcessorFactory {
static isSupported() {
return (
ProcessorWrapper.isSupported ||
BackgroundCustomProcessor.isSupported ||
FaceLandmarksProcessor.isSupported
)
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
}
static getProcessor(
@@ -58,10 +50,6 @@ export class BackgroundProcessorFactory {
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
}
} else if (type === ProcessorType.FACE_LANDMARKS) {
if (FaceLandmarksProcessor.isSupported) {
return new FaceLandmarksProcessor(opts)
}
}
return undefined
}
@@ -13,7 +13,6 @@ export const Effects = () => {
<div
className={css({
padding: '0 1.5rem',
overflowY: 'scroll',
})}
>
<EffectsConfiguration
@@ -5,22 +5,17 @@ import {
BackgroundProcessorFactory,
BackgroundProcessorInterface,
ProcessorType,
BackgroundOptions,
} from '../blur'
import { css } from '@/styled-system/css'
import { Text, P, ToggleButton, H } from '@/primitives'
import { styled } from '@/styled-system/jsx'
import { BackgroundOptions } from '@livekit/track-processors'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import {
RiProhibited2Line,
RiGlassesLine,
RiGoblet2Fill,
} from '@remixicon/react'
import { useHasFaceLandmarksAccess } from '../../hooks/useHasFaceLandmarksAccess'
import { RiProhibited2Line } from '@remixicon/react'
enum BlurRadius {
NONE = 0,
@@ -55,7 +50,6 @@ export const EffectsConfiguration = ({
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFaceLandmarksAccess = useHasFaceLandmarksAccess()
useEffect(() => {
const videoElement = videoRef.current
@@ -145,42 +139,9 @@ export const EffectsConfiguration = ({
}
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
if (type === ProcessorType.FACE_LANDMARKS) {
const effect = options.showGlasses ? 'glasses' : 'french'
return t(
`faceLandmarks.${effect}.${isSelected(type, options) ? 'clear' : 'apply'}`
)
}
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
const getFaceLandmarksOptions = () => {
const processor = getProcessor()
if (processor?.serialize().type === ProcessorType.FACE_LANDMARKS) {
return processor.serialize().options as {
showGlasses?: boolean
showFrench?: boolean
}
}
return { showGlasses: false, showFrench: false }
}
const toggleFaceLandmarkEffect = async (effect: 'glasses' | 'french') => {
const currentOptions = getFaceLandmarksOptions()
const newOptions = {
...currentOptions,
[effect === 'glasses' ? 'showGlasses' : 'showFrench']:
!currentOptions[effect === 'glasses' ? 'showGlasses' : 'showFrench'],
}
if (!newOptions.showGlasses && !newOptions.showFrench) {
// If both effects are off stop the processor
await clearEffect()
} else {
await toggleEffect(ProcessorType.FACE_LANDMARKS, newOptions)
}
}
return (
<div
className={css(
@@ -341,68 +302,6 @@ export const EffectsConfiguration = ({
</ToggleButton>
</div>
</div>
{hasFaceLandmarksAccess && (
<div
className={css({
marginTop: '1.5rem',
})}
>
<H
lvl={3}
style={{
marginBottom: '1rem',
}}
variant="bodyXsBold"
>
{t('faceLandmarks.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
showGlasses: true,
showFrench: false,
})}
tooltip={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
showGlasses: true,
showFrench: false,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleFaceLandmarkEffect('glasses')
}
isSelected={getFaceLandmarksOptions().showGlasses}
data-attr="toggle-glasses"
>
<RiGlassesLine />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
showGlasses: false,
showFrench: true,
})}
tooltip={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
showGlasses: false,
showFrench: true,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleFaceLandmarkEffect('french')
}
isSelected={getFaceLandmarksOptions().showFrench}
data-attr="toggle-french"
>
<RiGoblet2Fill />
</ToggleButton>
</div>
</div>
)}
<div
className={css({
marginTop: '1.5rem',
@@ -1,9 +0,0 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
export const useHasFaceLandmarksAccess = () => {
const featureEnabled = useFeatureFlagEnabled('face-landmarks')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
return featureEnabled || !isAnalyticsEnabled
}
@@ -7,7 +7,7 @@ import {
} from '@livekit/components-core'
import { RoomEvent, Track } from 'livekit-client'
import * as React from 'react'
import { useState } from 'react'
import {
CarouselLayout,
ConnectionStateToast,
@@ -29,7 +29,6 @@ import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingStateToast } from '../components/RecordingStateToast'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
const LayoutWrapper = styled(
'div',
@@ -150,8 +149,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const { isSidePanelOpen } = useSidePanel()
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
return (
<div
className="lk-video-conference"
@@ -165,10 +162,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
value={layoutContext}
// onPinChange={handleFocusStateChange}
>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<div
// todo - extract these magic values into constant
style={{
@@ -214,18 +207,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
</LayoutWrapper>
<MainNotificationToast />
</div>
<ControlBar
onDeviceError={(e) => {
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<ControlBar />
<SidePanel />
</LayoutContextProvider>
)}
@@ -58,14 +58,14 @@ export class SdkReverseClient {
* To be used in SDK scope.
*/
export function useEnsureAuth() {
const { isLoggedIn, refetch } = useUser({
const { isLoggedIn, ...other } = useUser({
fetchUserOptions: { attemptSilent: false },
})
const startSSO = () => {
return new Promise<void>((resolve) => {
SdkReverseClient.waitForAuthenticationAck().then(async () => {
await refetch()
await other.refetch()
resolve()
})
const params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,
+1 -1
View File
@@ -106,7 +106,7 @@ const Marianne = () => {
className={css({
letterSpacing: '-.01em',
textTransform: 'uppercase',
fontWeight: '700',
fontWeight: '600',
fontFamily: 'Marianne',
fontSize: '1.25rem',
lineHeight: '1.75rem',
+1 -1
View File
@@ -44,7 +44,7 @@ const Marianne = () => {
className={css({
letterSpacing: '-.01em',
textTransform: 'uppercase',
fontWeight: '700',
fontWeight: '600',
fontFamily: 'Marianne',
fontSize: '0.7875rem !important',
})}
+12 -35
View File
@@ -60,17 +60,6 @@
"createRoom": {
"heading": "",
"body": ""
},
"screenShare": {
"title": "",
"ariaLabel": "",
"message": "",
"macInstructions": "",
"macSystemPreferences": "",
"helpLinkText": "",
"helpLinkLabel": "",
"closeButton": "",
"newTab": ""
}
},
"controls": {
@@ -135,33 +124,22 @@
}
},
"effects": {
"activateCamera": "Ihre Kamera ist deaktiviert. Wählen Sie eine Option, um sie zu aktivieren.",
"notAvailable": "Videoeffekte werden in Kürze in Ihrem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit können Sie Google Chrome für beste Leistung oder Firefox verwenden :(",
"heading": "Unschärfe",
"activateCamera": "",
"notAvailable": "",
"heading": "",
"blur": {
"title": "Hintergrundunschärfe",
"light": "Leichte Unschärfe",
"normal": "Unschärfe",
"apply": "Unschärfe aktivieren",
"clear": "Unschärfe deaktivieren"
"title": "",
"light": "",
"normal": "",
"apply": "",
"clear": ""
},
"virtual": {
"title": "Virtueller Hintergrund",
"apply": "Virtuellen Hintergrund aktivieren",
"clear": "Virtuellen Hintergrund deaktivieren"
"title": "",
"apply": "",
"clear": ""
},
"faceLandmarks": {
"title": "Visuelle Effekte",
"glasses": {
"apply": "Brille hinzufügen",
"clear": "Brille entfernen"
},
"french": {
"apply": "Französische Touch hinzufügen",
"clear": "Französische Touch entfernen"
}
},
"experimental": "Experimentelle Funktion. Eine v2 kommt für vollständige Browserunterstützung und verbesserte Qualität."
"experimental": ""
},
"sidePanel": {
"heading": {
@@ -200,7 +178,6 @@
"access": {
"title": "",
"description": "",
"type": "",
"levels": {
"public": {
"label": "",
-23
View File
@@ -61,17 +61,6 @@
"createRoom": {
"heading": "Authentication Required",
"body": "This room has not been created yet. Please authenticate to create it or wait for an authenticated user to do so."
},
"screenShare": {
"title": "Unable to share your screen",
"ariaLabel": "Unable to share your screen",
"message": "Your browser may not be allowed to record the screen on your computer.",
"macInstructions": "Go to your",
"macSystemPreferences": "System Preferences",
"helpLinkText": "To learn more, see",
"helpLinkLabel": "Presentation issue",
"closeButton": "Dismiss",
"newTab": "New window"
}
},
"controls": {
@@ -149,17 +138,6 @@
"apply": "Enable virtual background",
"clear": "Disable virtual background"
},
"faceLandmarks": {
"title": "Visual Effects",
"glasses": {
"apply": "Add Glasses",
"clear": "Remove Glasses"
},
"french": {
"apply": "Add French touch",
"clear": "Remove French touch"
}
},
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
},
"sidePanel": {
@@ -199,7 +177,6 @@
"access": {
"title": "Room access",
"description": "These settings will also apply to future occurrences of this meeting.",
"type": "Meeting access types",
"levels": {
"public": {
"label": "Open",
+9 -32
View File
@@ -61,17 +61,6 @@
"createRoom": {
"heading": "Authentification requise",
"body": "Cette réunion n'a pas encore été créée. Veuillez vous authentifier pour la créer ou attendre qu'un utilisateur authentifié le fasse."
},
"screenShare": {
"title": "Impossible de partager votre écran",
"ariaLabel": "Impossible de partager votre écran",
"message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.",
"macInstructions": "Accèdez à vos",
"macSystemPreferences": "Préférences système",
"helpLinkText": "Pour en savoir plus, consulter",
"helpLinkLabel": "Problème de présentation",
"closeButton": "Ignorer",
"newTab": "Nouvelle fenêtre"
}
},
"controls": {
@@ -134,33 +123,22 @@
}
},
"effects": {
"activateCamera": "Votre caméra est désactivée. Choisissez une option pour l'activer.",
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
"notAvailable": "Les effets vidéo seront bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
"heading": "Flou",
"blur": {
"title": "Flou d'arrière-plan",
"light": "Flou léger",
"light": "Léger flou",
"normal": "Flou",
"apply": "Activer le flou",
"apply": "Appliquer le flou",
"clear": "Désactiver le flou"
},
"virtual": {
"title": "Arrière-plan virtuel",
"apply": "Activer l'arrière-plan virtuel",
"clear": "Désactiver l'arrière-plan virtuel"
"apply": "Appliquer l'arrière plan virtuel",
"clear": "Désactiver l'arrière plan virtuel"
},
"faceLandmarks": {
"title": "Effets visuels",
"glasses": {
"apply": "Ajouter des lunettes",
"clear": "Retirer les lunettes"
},
"french": {
"apply": "Ajouter la touche française",
"clear": "Retirer la touche française"
}
},
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet des navigateurs et une meilleure qualité."
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet sur tous les navigateurs et une meilleur qualité."
},
"sidePanel": {
"heading": {
@@ -198,12 +176,11 @@
"description": "Ces paramètres organisateur vous permettent de garder le contrôle de votre réunion. Seuls les organisateurs peuvent accéder à ces commandes.",
"access": {
"title": "Accès à la réunion",
"description": "Ces paramètres s'appliqueront également aux futures occurrences de cette réunion.",
"type": "Type d'accès à la réunion",
"description": "Ces paramètres s'appliqueront également aux futures occurences de cette réunion.",
"levels": {
"public": {
"label": "Ouvrir",
"description": "Tout le monde peut rejoindre la réunion sans autorisation."
"description": "Persone n'a à demander pour rejoindre la réunion."
},
"trusted": {
"label": "Ouvrir aux personnes de confiance",
+14 -95
View File
@@ -34,19 +34,7 @@
"usernameEmpty": "Uw naam kan niet leeg zijn"
},
"cameraDisabled": "Camera is uitgeschakeld.",
"cameraStarting": "Camera wordt ingeschakeld.",
"waiting": {
"title": "Verzoek tot deelname...",
"body": "U kunt deelnemen aan dit gesprek wanneer iemand u toestemming geeft"
},
"denied": {
"title": "U kunt niet deelnemen aan dit gesprek",
"body": "Uw verzoek tot deelname is geweigerd."
},
"timeoutInvite": {
"title": "U kunt niet deelnemen aan dit gesprek",
"body": "Niemand heeft gereageerd op uw verzoek om deel te nemen aan het gesprek"
}
"cameraStarting": "Camera wordt ingeschakeld."
},
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
"shareDialog": {
@@ -61,17 +49,6 @@
"createRoom": {
"heading": "Verificatie vereist",
"body": "Deze ruimte is nog niet gemaakt. Logt u alstublieft in om hem aan te maken, of wacht tot een ingelogde gebruiker dat doet."
},
"screenShare": {
"title": "Kan uw scherm niet delen",
"ariaLabel": "Kan uw scherm niet delen",
"message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.",
"macInstructions": "Ga naar uw",
"macSystemPreferences": "Systeemvoorkeuren",
"helpLinkText": "Meer informatie, zie",
"helpLinkLabel": "Presentatieprobleem",
"closeButton": "Negeren",
"newTab": "Nieuw venster"
}
},
"controls": {
@@ -107,10 +84,6 @@
"open": "Verberg AI-assistent",
"closed": "Toon AI-assistant"
},
"admin": {
"open": "Verberg beheerder",
"closed": "Toon beheerder"
},
"support": "Ondersteuning",
"moreOptions": "Meer opties",
"reactions": {
@@ -134,48 +107,35 @@
}
},
"effects": {
"activateCamera": "Uw camera is uitgeschakeld. Kies een optie om deze in te schakelen.",
"notAvailable": "Video-effecten zijn binnenkort beschikbaar in uw browser. We werken eraan! In de tussentijd kunt u Google Chrome gebruiken voor de beste prestaties of Firefox :(",
"activateCamera": "Uw camera is uitgeschakeld. Kies een optie om hem in te schakelen.",
"notAvailable": "Video-effecten zijn binnenkort beschikbaar in uw browser. We werken hier aan! Ondertussen kunt u Chrome gebruiken voor de beste prestaties, of Firefox :(",
"heading": "Vervaging",
"blur": {
"title": "Achtergrondvervaging",
"light": "Lichte vervaging",
"normal": "Vervaging",
"apply": "Vervaging inschakelen",
"clear": "Vervaging uitschakelen"
"title": "Achtergrond vervagen",
"light": "Licht vervagen",
"normal": "Vervagen",
"apply": "Schakel vervaging in",
"clear": "Schakel vervaging uit"
},
"virtual": {
"title": "Virtuele achtergrond",
"apply": "Virtuele achtergrond inschakelen",
"clear": "Virtuele achtergrond uitschakelen"
"apply": "Schakel virtuele achtergrond in",
"clear": "Schakel virtuele achtergrond uit"
},
"faceLandmarks": {
"title": "Visuele effecten",
"glasses": {
"apply": "Bril toevoegen",
"clear": "Bril verwijderen"
},
"french": {
"apply": "Franse stijl toevoegen",
"clear": "Franse stijl verwijderen"
}
},
"experimental": "Experimentele functie. Een v2 komt eraan voor volledige browserondersteuning en verbeterde kwaliteit."
"experimental": "Experimentele functionaliteit. Een v2 komt er aan met volledige browser-ondersteuning en verbeterde kwaliteit."
},
"sidePanel": {
"heading": {
"participants": "Deelnemers",
"effects": "Effecten",
"chat": "Berichten in de chat",
"transcript": "AI-assistent",
"admin": "Beheerdersbediening"
"transcript": "AI-assistent"
},
"content": {
"participants": "deelnemers",
"effects": "effecten",
"chat": "berichten",
"transcript": "AI-assistent",
"admin": "Beheerdersbediening"
"transcript": "AI-assistent"
},
"closeButton": "Verberg {{content}}"
},
@@ -194,28 +154,6 @@
"button": "Stop met opname"
}
},
"admin": {
"description": "Deze organisatorinstellingen geven u controle over uw vergadering. Alleen organisatoren hebben toegang tot deze bedieningselementen.",
"access": {
"title": "Toegang tot vergadering",
"description": "Deze instellingen zijn ook van toepassing op toekomstige sessies van deze vergadering.",
"type": "Vergaderings toegangstypen",
"levels": {
"public": {
"label": "Open",
"description": "Niemand hoeft toestemming te vragen om deel te nemen aan de vergadering."
},
"trusted": {
"label": "Open voor vertrouwde personen",
"description": "Geauthenticeerde personen hoeven geen toestemming te vragen om deel te nemen aan de vergadering."
},
"restricted": {
"label": "Beperkt",
"description": "Personen die niet zijn uitgenodigd voor de vergadering moeten vragen om deel te nemen."
}
}
}
},
"rating": {
"submit": "Indienen",
"question": "Wat vindt u van de kwaliteit van het gesprek?",
@@ -234,12 +172,6 @@
"heading": "Bedankt voor uw inzending",
"body": "Ons productteam neemt de tijd om uw feedback zorgvuldig te bekijken. We zullen zo snel mogelijk antwoorden."
},
"authenticationMessage": {
"heading": "Hoe kunnen we contact met u opnemen?",
"placeholder": "Uw e-mail",
"submit": "Verzenden",
"ignore": "Negeren"
},
"participants": {
"subheading": "In de ruimte",
"you": "U",
@@ -258,20 +190,7 @@
},
"raisedHands": "Opgestoken handen",
"lowerParticipantHand": "Laat {{name}}'s hand zakken",
"lowerParticipantsHand": "Laat alle handen zakken",
"waiting": {
"title": "Wachtkamer",
"accept": {
"button": "Accepteren",
"label": "{{name}} toelaten tot de vergadering",
"all": "Alles accepteren"
},
"deny": {
"button": "Weigeren",
"label": "{{name}} weigeren voor de vergadering",
"all": "Alles weigeren"
}
}
"lowerParticipantsHand": "Laat alle handen zakken"
},
"recording": {
"label": "Opnemen"
+1 -7
View File
@@ -75,13 +75,7 @@ export const A = ({
return (
<Link
{...props}
className={link({
size,
externalIcon,
underline,
footer,
color,
})}
className={link({ size, externalIcon, underline, footer, color })}
/>
)
}
+1 -3
View File
@@ -44,7 +44,7 @@ const FieldWrapper = styled('div', {
const StyledLabel = styled(Label, {
base: {
display: 'block',
fontSize: '0.875rem',
fontSize: '0.75rem',
},
variants: {
center: {
@@ -218,10 +218,8 @@ export const Field = <T extends object>({
{item.description && (
<Text
variant="note"
wrap={'pretty'}
className={css({
textStyle: 'sm',
marginBottom: '0.5rem',
})}
>
{item.description}
-40
View File
@@ -30,46 +30,6 @@
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/fonts/Marianne-Regular-subset.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/fonts/Marianne-Regular_Italic-subset.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/fonts/Marianne-Medium-subset.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/fonts/Marianne-Bold-subset.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/fonts/Marianne-ExtraBold-subset.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
/*
* to reduce CLS
* values taken from https://github.com/khempenius/font-fallbacks-dataset/blob/main/font-metric-overrides.csv#L2979
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.17",
"version": "0.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.17",
"version": "0.1.15",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.17",
"version": "0.1.15",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "0.1.17",
"version": "0.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.1.17",
"version": "0.1.15",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.17",
"version": "0.1.15",
"author": "",
"license": "ISC",
"description": "",
+4 -4
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.17"
version = "0.1.15"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -10,14 +10,14 @@ dependencies = [
"celery==5.4.0",
"redis==5.2.1",
"minio==7.2.15",
"openai==1.68.2",
"openai==1.65.2",
"requests==2.32.3",
"sentry-sdk[fastapi, celery]==2.24.1",
"sentry-sdk[fastapi, celery]==2.22.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.11.2",
"ruff==0.9.9",
]
[build-system]
+4 -16
View File
@@ -1,15 +1,10 @@
"""API routes related to application tasks."""
from typing import Optional
from celery.result import AsyncResult
from fastapi import APIRouter
from pydantic import BaseModel
from summary.core.celery_worker import (
process_audio_transcribe_summarize,
process_audio_transcribe_summarize_v2,
)
from summary.core.celery_worker import process_audio_transcribe_summarize
class TaskCreation(BaseModel):
@@ -18,7 +13,6 @@ class TaskCreation(BaseModel):
filename: str
email: str
sub: str
version: Optional[int] = 2
router = APIRouter(prefix="/tasks")
@@ -27,15 +21,9 @@ router = APIRouter(prefix="/tasks")
@router.post("/")
async def create_task(request: TaskCreation):
"""Create a task."""
if request.version == 1:
task = process_audio_transcribe_summarize.delay(
request.filename, request.email, request.sub
)
else:
task = process_audio_transcribe_summarize_v2.delay(
request.filename, request.email, request.sub
)
task = process_audio_transcribe_summarize.delay(
request.filename, request.email, request.sub
)
return {"id": task.id, "message": "Task created"}
-98
View File
@@ -56,35 +56,6 @@ def create_retry_session():
return session
def format_segments(transcription_data):
"""Format transcription segments from WhisperX into a readable conversation format.
Processes transcription data with segments containing speaker information and text,
combining consecutive segments from the same speaker and formatting them as a
conversation with speaker labels.
"""
formatted_output = ""
if not transcription_data or not hasattr(transcription_data, 'segments'):
if isinstance(transcription_data, dict) and 'segments' in transcription_data:
segments = transcription_data['segments']
else:
return "Error: Invalid transcription data format"
else:
segments = transcription_data.segments
previous_speaker = None
for segment in segments:
speaker = segment.get('speaker', 'UNKNOWN_SPEAKER')
text = segment.get('text', '')
if text:
if speaker != previous_speaker:
formatted_output += f"\n\n *{speaker}*: {text}"
else:
formatted_output += f" {text}"
previous_speaker = speaker
return formatted_output
def post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = create_retry_session()
@@ -169,72 +140,3 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text)
@celery.task(max_retries=settings.celery_max_retries)
def process_audio_transcribe_summarize_v2(filename: str, email: str, sub: str):
"""Process an audio file by transcribing it and generating a summary.
This Celery task performs the following operations:
1. Retrieves the audio file from MinIO storage
2. Transcribes the audio using WhisperX model
3. Sends the results via webhook
"""
logger.info("Notification received")
logger.debug("filename: %s", filename)
minio_client = Minio(
settings.aws_s3_endpoint_url,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key,
secure=settings.aws_s3_secure_access,
)
logger.debug("Connection to the Minio bucket successful")
audio_file_stream = minio_client.get_object(
settings.aws_storage_bucket_name, object_name=filename
)
temp_file_path = save_audio_stream(audio_file_stream)
logger.debug("Recording successfully downloaded, filepath: %s", temp_file_path)
logger.debug("Initiating OpenAI client")
openai_client = openai.OpenAI(
api_key=settings.openai_api_key, base_url=settings.openai_base_url
)
try:
logger.debug("Querying transcription …")
with open(temp_file_path, "rb") as audio_file:
transcription = openai_client.audio.transcriptions.create(
model=settings.openai_asr_model, file=audio_file
)
logger.debug("Transcription: \n %s", transcription)
finally:
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
logger.debug("Temporary file removed: %s", temp_file_path)
formatted_transcription = format_segments(transcription)
data = {
"title": "Transcription",
"content": formatted_transcription,
"email": email,
"sub": sub,
}
logger.debug("Submitting webhook to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = post_with_retries(settings.webhook_url, data)
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text)
# TODO - integrate summarize the transcript and create a new document.