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
@@ -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>
)
}