Rozmycie tla wlasnej kamery
Deploy PROD / deploy (push) Successful in 4m32s

Przycisk w sali, ktory kazdy z wlasna kamera wlacza i wylacza sam,
w trakcie spotkania. Pokazuje sie wylacznie osobie z wlaczona kamera,
wiec uczestnik bez prawa publikacji obrazu nigdy go nie zobaczy.

`@livekit/track-processors` wchodzi jako nowa zaleznosc, poza lista
z CLAUDE.md: uzgodnione. Ladowany leniwie, bo ciagnie za soba MediaPipe:
import statyczny doliczalby kilka MB do pakietu KAZDEGO wejscia na sale,
takze uczestnikom bez kamery. Sprawdzone na zbudowanym pakiecie: MediaPipe
siedzi w osobnym chunku, glowny pakiet nie urosl.

Pliki modelu ida z WLASNEJ domeny, nie z CDN: instancja w sieci zamknietej
ma miec rozmycie tak samo, a CSP nie musi dopuszczac obcych hostow.
Model (244 kB) lezy w repozytorium, a zestaw WASM kopiuje sie przy budowaniu
obrazu z `node_modules`: wazy kilkanascie MB i musi odpowiadac wersji
pakietu, wiec w repozytorium puchlby w historii i po cichu rozjezdzal sie
przy kazdej aktualizacji.

Podmiany tla na obraz swiadomie NIE ma. Segmentacja obcina wlosy i dlonie
przy slabym swietle, a kafelek wideo jest za maly, zeby cokolwiek na tle
bylo czytelne. Reklama ma wlasne miejsce w banerach, ktore sa przy tym
mierzone.

`VideoConference` zostaje nietkniety: przycisk jest obok, wiec aktualizacja
biblioteki nie wymaga odtwarzania niczego u nas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 09:45:07 +02:00
parent 5cd1380286
commit a80fdd9053
5 changed files with 188 additions and 0 deletions
+18
View File
@@ -13,6 +13,24 @@ ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
RUN npm run build 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 --- # --- etap 2: runtime ---
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
+36
View File
@@ -13,6 +13,7 @@
"@jitsi/react-sdk": "^1.4.4", "@jitsi/react-sdk": "^1.4.4",
"@livekit/components-react": "^2.9.24", "@livekit/components-react": "^2.9.24",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@mui/x-charts": "^9.12.0", "@mui/x-charts": "^9.12.0",
"@stomp/stompjs": "^7.3.0", "@stomp/stompjs": "^7.3.0",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
@@ -31,6 +32,7 @@
"react-router-dom": "^6.28.1" "react-router-dom": "^6.28.1"
}, },
"devDependencies": { "devDependencies": {
"@types/dom-mediacapture-transform": "^0.1.9",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
@@ -1108,6 +1110,25 @@
"@bufbuild/protobuf": "^1.10.0" "@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": { "node_modules/@mui/core-downloads-tracker": {
"version": "9.4.0", "version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz",
@@ -2464,6 +2485,21 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+2
View File
@@ -14,6 +14,7 @@
"@jitsi/react-sdk": "^1.4.4", "@jitsi/react-sdk": "^1.4.4",
"@livekit/components-react": "^2.9.24", "@livekit/components-react": "^2.9.24",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@mui/x-charts": "^9.12.0", "@mui/x-charts": "^9.12.0",
"@stomp/stompjs": "^7.3.0", "@stomp/stompjs": "^7.3.0",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
@@ -39,6 +40,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@types/dom-mediacapture-transform": "^0.1.9",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
Binary file not shown.
@@ -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<string | null>(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<void> } | 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 (
<div className={className}>
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={enabled}
className={`inline-flex min-h-[44px] items-center gap-2 rounded-md border px-3 text-sm transition-colors duration-fast ease-standard disabled:opacity-60 ${
enabled
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-50'
}`}
>
<Icon name={busy ? 'loader' : 'droplet'} size={16} />
{busy ? 'Chwileczkę...' : enabled ? 'Rozmycie tła włączone' : 'Rozmyj tło'}
</button>
{error && <p className="mt-1 text-xs text-alarm-600">{error}</p>}
</div>
)
}