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 une outputTrack traité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

  • sourceTrack doit déjà être une caméra publiée (publishCamera() appelé au préalable) : createVirtualBackgroundProcessor ne fait jamais de getUserMedia lui-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 via getImageData() pour la composition, pas seulement un rendu visuel — un fond cross-origin sans CORS fait lever une SecurityError au 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>.
  • onPerformanceDegraded signale un arrêt déjà survenu, pas une demande : au moment de l’appel, outputTrack est 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 jamais sourceTrack : 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 nouveau createVirtualBackgroundProcessor() 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.