virtual-background
Arrière-plan virtuel : câbler <legba-background-select> + createVirtualBackgroundProcessor
Ce que ça fait
@legba-core/ui fournit deux pièces distinctes pour l’arrière-plan virtuel
(LGB-062) :
<legba-background-select>— un sélecteur purement présentatif (liste de vignettes + « Aucun »), entièrement contrôlé, comme<legba-mic-button>.createVirtualBackgroundProcessor(sourceTrack, options)— le pipeline réel (segmentation MediaPipe, composition canevas) : prend une piste caméra déjà acquise, rend uneoutputTracktraitée.
Aucune des deux ne publie quoi que ce soit sur LiveKit, et
@legba-core/realtime ne sait rien de cette fonctionnalité (décision de
l’étape 0, docs/decisions/virtual-background-segmentation.md) : c’est
l’application qui relie choix d’arrière-plan → pipeline → publication, via
l’échappatoire .raw déjà établie (voir
raw-escape-hatch.md).
Exemple complet
import { Track } from "livekit-client";
import type { LegbaCall, BackgroundOption } from "@legba-core/ui";
import { createVirtualBackgroundProcessor, type VirtualBackgroundProcessor } from "@legba-core/ui";
const BACKGROUNDS: BackgroundOption[] = [
{ id: "plage", label: "Plage", url: "/backgrounds/plage.jpg" },
{ id: "bureau", label: "Bureau", url: "/backgrounds/bureau.jpg" },
];
async function wireVirtualBackground(call: LegbaCall, select: HTMLElementTagNameMap["legba-background-select"]) {
select.backgrounds = BACKGROUNDS;
let processor: VirtualBackgroundProcessor | null = null;
select.addEventListener("legba-background-change", async (event) => {
const { id } = (event as CustomEvent<{ id: string | null }>).detail;
const room = call.clientRoom;
if (!room) return;
select.pending = true;
select.errorMessage = null;
try {
// Retire l'effet précédent avant tout changement — jamais deux
// processeurs actifs sur la même piste caméra à la fois.
if (processor) {
processor.stop();
processor = null;
}
const cameraPublication = room.raw.localParticipant.getTrackPublication("camera" as never);
const rawCameraTrack = cameraPublication?.track?.mediaStreamTrack;
if (!rawCameraTrack) throw new Error("aucune caméra publiée : active la caméra avant de choisir un arrière-plan.");
if (id === null) {
// "Aucun" : republie la caméra brute, sans traitement.
await room.raw.localParticipant.publishTrack(rawCameraTrack, { source: Track.Source.Camera });
} else {
const background = BACKGROUNDS.find((b) => b.id === id)!;
processor = await createVirtualBackgroundProcessor(rawCameraTrack, {
backgroundImageUrl: background.url,
onPerformanceDegraded: () => {
// Scénario 8 : l'effet s'est déjà désactivé proprement côté
// pipeline — ici, on ne fait que refléter l'état et revenir à
// la caméra brute, jamais planter l'appel.
select.selected = null;
select.errorMessage = "Arrière-plan désactivé : performance insuffisante sur cet appareil.";
processor = null;
void room.raw.localParticipant.publishTrack(rawCameraTrack, { source: Track.Source.Camera });
},
});
await room.raw.localParticipant.publishTrack(processor.outputTrack, { source: Track.Source.Camera });
}
select.selected = id;
} catch (error) {
select.errorMessage = error instanceof Error ? error.message : "L'arrière-plan virtuel a échoué.";
} finally {
select.pending = false;
}
});
}
Pièges
sourceTrackdoit déjà être une caméra publiée (publishCamera()appelé au préalable) :createVirtualBackgroundProcessorne fait jamais degetUserMedialui-même — c’est un principe retenu à l’étape 0, pas un oubli.- Le fond doit être servi avec les bons en-têtes CORS
(
crossOrigin="anonymous"posé en interne) : le pipeline lit les pixels du fond viagetImageData()pour la composition, pas seulement un rendu visuel — un fond cross-origin sans CORS fait lever uneSecurityErrorau moment de la composition, jamais silencieusement ignoré. - Un GIF animé fonctionne tel quel, sans décodeur dédié : l’image de
fond est un
<img>interne, et un navigateur anime nativement un GIF déjà décodé — chaque trame composée capture simplement l’image actuellement affichée par cet<img>. onPerformanceDegradedsignale un arrêt déjà survenu, pas une demande : au moment de l’appel,outputTrackest déjà à l’état"ended"— l’application doit republier la caméra brute elle-même (voir l’exemple), le pipeline ne le fait jamais à sa place (il ne connaît rien de LiveKit).stop()/changer d’arrière-plan ne touche jamaissourceTrack: la piste caméra fournie en entrée reste sous la responsabilité de l’application du début à la fin, jamais arrêtée par ce module.setBackground(url)change le fond sans reconstruire le pipeline (scénario 9) — préférer ça àstop()+ un nouveaucreateVirtualBackgroundProcessor()pour un simple changement de fond en cours d’appel.- La qualité du masque sur un vrai visage n’est vérifiée qu’en usage réel — l’étape 0 n’a mesuré que le débit d’inférence (canevas synthétique, aucun accès webcam en environnement de recherche), pas la qualité de la découpe. Un smoke test réel (navigateur + vrai MediaPipe + piste synthétique) a confirmé le pipeline stable de bout en bout pendant ce billet, mais la qualité du masque contre un vrai visage reste à vérifier par un usage réel.
- Aucune mesure sur appareil bas de gamme — le budget de trame en
direct (
frameBudgetMs, 33ms par défaut) désactive proprement l’effet si dépassé, mais ce plan de dégradation n’a été vérifié que sur une machine de développement, jamais un vieux Chromebook/tablette d’entrée de gamme.