feat(frontend-live): Introduce DisguiseVideoParticipantVideo component for visual participant anonymization with blur/mask effects

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-17 21:48:36 -07:00
parent 02ca33562c
commit d98eced76d

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, type CSSProperties } from 'react';
import { useEffect, useRef, type CSSProperties, type ReactElement } from 'react';
import { useFaceDetection } from '../hooks/useFaceDetection';
import { applyBlur } from '../renderers/BlurRenderer';
import { drawGimpMask } from '../renderers/GimpMaskRenderer';
@ -36,7 +36,8 @@ export interface DisguiseVideoParticipantVideoProps {
* The output canvas can be consumed visually or captured as a MediaStream via
* `onCaptureStream` for WebRTC integration.
*
* Production deployments must set these headers for MediaPipe WASM threading:
* Production deployments must set these response headers for MediaPipe WASM
* thread-pool support (SharedArrayBuffer):
* Cross-Origin-Opener-Policy: same-origin
* Cross-Origin-Embedder-Policy: require-corp
*/
@ -49,13 +50,16 @@ export function DisguiseVideoParticipantVideo({
onCaptureStream,
className,
style,
}: DisguiseVideoParticipantVideoProps): JSX.Element {
}: DisguiseVideoParticipantVideoProps): ReactElement {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Stable ref for the 2D context so the render loop doesn't need to call
// getContext() on every frame.
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
// These refs let the render loop read the latest prop values without being
// re-created on every render cycle.
// Mirror the latest prop values into refs so the render loop closure can
// read current values without being re-created on every render.
const disguiseRef = useRef(disguise);
disguiseRef.current = disguise;
@ -70,6 +74,12 @@ export function DisguiseVideoParticipantVideo({
const detectRef = useRef(detectForVideo);
detectRef.current = detectForVideo;
// ── Context initialisation ─────────────────────────────────────────────────
useEffect(() => {
if (!canvasRef.current) return;
ctxRef.current = canvasRef.current.getContext('2d');
}, []);
// ── Stream setup ───────────────────────────────────────────────────────────
useEffect(() => {
const video = videoRef.current;
@ -99,8 +109,8 @@ export function DisguiseVideoParticipantVideo({
}
setup().catch((_err: unknown) => {
// Stream errors surface via the video element's error event or the
// useFaceDetection hook — no additional handling needed here.
// getUserMedia / play errors are surfaced to the user via the video
// element's error event; no additional handling is needed here.
});
return () => {
@ -113,18 +123,13 @@ export function DisguiseVideoParticipantVideo({
// ── Render loop ────────────────────────────────────────────────────────────
useEffect(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animId: number;
function render(): void {
// Wait until video has decoded enough data to draw.
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
const video = videoRef.current;
const ctx = ctxRef.current;
if (video && ctx && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
ctx.drawImage(video, 0, 0, width, height);
if (isReadyRef.current && disguiseRef.current !== 'none') {
@ -146,7 +151,7 @@ export function DisguiseVideoParticipantVideo({
animId = requestAnimationFrame(render);
return () => cancelAnimationFrame(animId);
}, [width, height]); // Only restart loop when dimensions change.
}, [width, height]); // Only restart when output dimensions change.
// ── Capture stream ─────────────────────────────────────────────────────────
useEffect(() => {