diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a9bb056..18d1cc9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,6 +13,24 @@ ARG VITE_API_URL=/api ENV VITE_API_URL=$VITE_API_URL RUN npm run build +# Pliki MediaPipe do rozmycia tla, kopiowane Z ZAINSTALOWANEGO PAKIETU. +# +# DLACZEGO NIE Z REPOZYTORIUM +# --------------------------------------------------------------------------- +# Zestaw WASM wazy kilkanascie MB i musi odpowiadac wersji +# `@mediapipe/tasks-vision` wciagnietej przez `@livekit/track-processors`. +# Wrzucony do repozytorium puchlby w historii na zawsze, a przy aktualizacji +# pakietu rozjezdzalby sie po cichu: rozmycie przestaje dzialac, a nic tego +# nie sygnalizuje. Kopiowanie przy budowaniu wiaze pliki z wersja pakietu. +# +# DLACZEGO NIE Z CDN +# --------------------------------------------------------------------------- +# Domyslnie biblioteka pobiera je z jsdelivr i storage.googleapis.com. +# Instancja w sieci zamknietej nie miałaby wtedy rozmycia wcale, a CSP +# musialoby dopuszczac dwa obce hosty. Serwowanie z wlasnej domeny miesci sie +# w istniejacym `connect-src 'self'`. +RUN mkdir -p /build/dist/mediapipe && cp -r node_modules/@mediapipe/tasks-vision/wasm /build/dist/mediapipe/wasm + # --- etap 2: runtime --- FROM nginx:1.27-alpine diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d9d7e0..fce503d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@jitsi/react-sdk": "^1.4.4", "@livekit/components-react": "^2.9.24", "@livekit/components-styles": "^1.2.0", + "@livekit/track-processors": "^0.7.2", "@mui/x-charts": "^9.12.0", "@stomp/stompjs": "^7.3.0", "@tailwindcss/typography": "^0.5.20", @@ -31,6 +32,7 @@ "react-router-dom": "^6.28.1" }, "devDependencies": { + "@types/dom-mediacapture-transform": "^0.1.9", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", @@ -1108,6 +1110,25 @@ "@bufbuild/protobuf": "^1.10.0" } }, + "node_modules/@livekit/track-processors": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz", + "integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==", + "license": "Apache-2.0", + "dependencies": { + "@mediapipe/tasks-vision": "0.10.14" + }, + "peerDependencies": { + "@types/dom-mediacapture-transform": "^0.1.9", + "livekit-client": "^1.12.0 || ^2.1.0" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz", + "integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==", + "license": "Apache-2.0" + }, "node_modules/@mui/core-downloads-tracker": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz", @@ -2464,6 +2485,21 @@ "license": "MIT", "peer": true }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.19.tgz", + "integrity": "sha512-+yFUIDUaASFz0vQpWBErPxun/Lb+F0TdrlTsj50gm7K797YzEx2TmV6fEiwciHyFNghlZYPgQXH5qRcwMHcJFQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6413355..61de00d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@jitsi/react-sdk": "^1.4.4", "@livekit/components-react": "^2.9.24", "@livekit/components-styles": "^1.2.0", + "@livekit/track-processors": "^0.7.2", "@mui/x-charts": "^9.12.0", "@stomp/stompjs": "^7.3.0", "@tailwindcss/typography": "^0.5.20", @@ -39,6 +40,7 @@ } }, "devDependencies": { + "@types/dom-mediacapture-transform": "^0.1.9", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", diff --git a/frontend/public/mediapipe/selfie_segmenter.tflite b/frontend/public/mediapipe/selfie_segmenter.tflite new file mode 100644 index 0000000..a4ebd47 Binary files /dev/null and b/frontend/public/mediapipe/selfie_segmenter.tflite differ diff --git a/frontend/src/pages/public/BackgroundBlurToggle.tsx b/frontend/src/pages/public/BackgroundBlurToggle.tsx new file mode 100644 index 0000000..900a76e --- /dev/null +++ b/frontend/src/pages/public/BackgroundBlurToggle.tsx @@ -0,0 +1,132 @@ +import { useLocalParticipant } from '@livekit/components-react' +import { LocalVideoTrack, Track } from 'livekit-client' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Icon } from '../../components/Icon' + +/** + * Przelacznik rozmycia tla wlasnej kamery. + * + * DLACZEGO OSOBNY PRZYCISK, A NIE WLASNY PASEK NARZEDZI + * --------------------------------------------------------------------------- + * `VideoConference` zostaje nietkniety, zgodnie z zasada, ze nie budujemy + * wlasnego interfejsu wideo. Ten przycisk dokladamy obok, wiec aktualizacja + * biblioteki nie wymaga odtwarzania niczego u nas. + * + * DLACZEGO LADOWANIE JEST LENIWE + * --------------------------------------------------------------------------- + * `@livekit/track-processors` ciagnie za soba MediaPipe, czyli kilka MB kodu + * i WASM. Import statyczny doliczalby to do pakietu KAZDEGO wejscia na sale, + * takze uczestnikom, ktorzy kamery nie maja wcale. Modul wczytuje sie dopiero + * przy pierwszym klinieciu. + * + * DLACZEGO PLIKI IDA Z WLASNEJ DOMENY + * --------------------------------------------------------------------------- + * Domyslnie biblioteka pobiera WASM z jsdelivr, a model ze storage.googleapis. + * Instancja w sieci zamknietej nie miałaby wtedy rozmycia, a CSP musialoby + * dopuszczac dwa obce hosty. Pliki leza u nas: model w `public/mediapipe`, + * zestaw WASM kopiowany przy budowaniu obrazu z `node_modules`. + * + * DLACZEGO WYLACZAMY PRZEZ `switchTo`, A NIE `stopProcessor` + * --------------------------------------------------------------------------- + * Zatrzymanie procesora potrafi zostawic zamrozona klatke, bo sciezka jest + * przepinana w locie. Tryb `disabled` przepuszcza obraz bez zmian i nie + * przerywa publikacji. + */ +export function BackgroundBlurToggle({ className = '' }: { className?: string }) { + const { cameraTrack } = useLocalParticipant() + const [enabled, setEnabled] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + /* + * Procesor przezywa wylaczenie rozmycia, bo `switchTo` jest tansze niz + * budowanie go od nowa: MediaPipe wczytuje wtedy model ponownie i obraz + * zacina sie na chwile przy kazdym przelaczeniu. + */ + const processorRef = useRef<{ switchTo: (options: { mode: string }) => Promise } | null>(null) + + const track = cameraTrack?.track + const isCamera = cameraTrack?.source === Track.Source.Camera + const available = track instanceof LocalVideoTrack && isCamera && !cameraTrack?.isMuted + + /* + * Wylaczona kamera zabiera sciezke, wiec procesor przestaje istniec razem + * z nia. Bez tego przycisk pokazywalby rozmycie jako wlaczone po ponownym + * wlaczeniu kamery, kiedy obraz jest juz zwykly. + */ + useEffect(() => { + if (!available) { + processorRef.current = null + setEnabled(false) + } + }, [available]) + + const toggle = useCallback(async () => { + if (!(track instanceof LocalVideoTrack) || busy) return + + setBusy(true) + setError(null) + try { + if (!processorRef.current) { + const { BackgroundProcessor, supportsBackgroundProcessors } = await import( + '@livekit/track-processors' + ) + + if (!supportsBackgroundProcessors()) { + setError('Ta przeglądarka nie obsługuje rozmycia tła.') + return + } + + const processor = BackgroundProcessor({ + mode: 'background-blur', + blurRadius: 10, + assetPaths: { + tasksVisionFileSet: '/mediapipe/wasm', + modelAssetPath: '/mediapipe/selfie_segmenter.tflite', + }, + }) + + await track.setProcessor(processor) + processorRef.current = processor as unknown as typeof processorRef.current + setEnabled(true) + return + } + + const next = !enabled + await processorRef.current.switchTo({ mode: next ? 'background-blur' : 'disabled' }) + setEnabled(next) + } catch (e) { + // Najczestsza przyczyna to slaby sprzet albo brak WebGL: mowimy o skutku, + // a nie o nazwie wyjatku, ktora uzytkownikowi nic nie mowi. + setError('Nie udało się włączyć rozmycia tła.') + console.error('Rozmycie tla:', e) + } finally { + setBusy(false) + } + }, [track, busy, enabled]) + + // Bez wlasnej kamery nie ma czego rozmywac: uczestnik bez prawa publikacji + // obrazu nie zobaczy tego przycisku wcale. + if (!available) return null + + return ( +
+ + + {error &&

{error}

} +
+ ) +}