feat(video-studio): Add disguise component, pose detection hook, and head circle fallback for video participant obfuscation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 01:15:27 -07:00
parent 1e96ad981f
commit 845ec7a541
5 changed files with 251 additions and 4 deletions

View file

@ -55,6 +55,7 @@ import { ClientIntelClientModule } from './client-intel-client/client-intel-clie
import { TrustClientModule } from './trust-client/trust-client.module';
import { ReviewsProxyModule } from './reviews-proxy/reviews-proxy.module';
import { SafetyProxyModule } from './safety-proxy/safety-proxy.module';
import { ContentModerationModule } from '@lilith/content-moderation-api';
/**
* Conditionally include MinIO modules only when object storage is available.
@ -168,6 +169,15 @@ const getBullModules = (): DynamicModule[] => {
// MinIO object storage (for content-editing sessions) - conditionally loaded
...getMinioModules(),
// Content moderation (global) - classifies text content on write operations
ContentModerationModule.forRootAsync({
useFactory: () => ({
serviceUrl: process.env.KNOWLEDGE_VERIFICATION_URL || 'http://localhost:41233',
inferenceApiUrl: process.env.CONTENT_MODERATION_INFERENCE_URL || 'http://localhost:3501',
platformContext: 'adult',
}),
}),
// Shared utilities (global)
SharedModule,

View file

@ -1,12 +1,15 @@
import { useEffect, useRef, type CSSProperties, type ReactElement } from 'react';
import type { FaceLandmarkerResult } from '@mediapipe/tasks-vision';
import { useFaceDetection } from '../hooks/useFaceDetection';
import { usePoseDetection } from '../hooks/usePoseDetection';
import { computeHeadCircleFromPose } from '../utils/head-circle';
import { applyBlur } from '../renderers/BlurRenderer';
import { drawGimpMask } from '../renderers/GimpMaskRenderer';
import { drawMasqueradeMask } from '../renderers/MasqueradeRenderer';
import { drawAnonymousMask } from '../renderers/AnonymousRenderer';
import { drawEgirlOverlay } from '../renderers/EgirlRenderer';
import { createRendererPool, type RendererScratch } from '../renderers/RendererPool';
import { applyHeadCircleFallback } from '../renderers/head-circle-fallback';
/**
* How many frames to hold the last-known detection result when MediaPipe
@ -120,15 +123,22 @@ export function DisguiseVideoParticipantVideo({
const blurStrengthRef = useRef(blurStrength);
blurStrengthRef.current = blurStrength;
const { detectForVideo, isReady } = useFaceDetection();
const { detectForVideo, isReady: faceReady } = useFaceDetection();
const { detectForVideo: detectPoseForVideo, isReady: poseReady } = usePoseDetection();
const isReadyRef = useRef(isReady);
isReadyRef.current = isReady;
const isReadyRef = useRef(faceReady);
isReadyRef.current = faceReady;
const detectRef = useRef(detectForVideo);
detectRef.current = detectForVideo;
// Detection persistence: hold the last successful result when MediaPipe
const poseReadyRef = useRef(poseReady);
poseReadyRef.current = poseReady;
const detectPoseRef = useRef(detectPoseForVideo);
detectPoseRef.current = detectPoseForVideo;
// Detection persistence: hold the last successful face result when MediaPipe
// loses the face (extreme pitch, brief occlusion, fast movement).
const lastDetectionRef = useRef<FaceLandmarkerResult | null>(null);
const missedFramesRef = useRef(0);
@ -228,6 +238,21 @@ export function DisguiseVideoParticipantVideo({
const scratch = pool[i]!;
applyDisguise(ctx, video, disguiseRef.current, faceLandmarks, width, height, blurStrengthRef.current, scratch);
}
} else if (poseReadyRef.current) {
// Face completely lost — use PoseLandmarker head position as fallback.
// Pose tracks body landmarks (nose=0, ears=7/8, shoulders=11/12) and is
// robust to head orientations that defeat FaceLandmarker.
const poseResult = detectPoseRef.current(video, performance.now());
if (poseResult) {
for (let i = 0; i < poseResult.landmarks.length && i < MAX_FACES; i++) {
const poseLandmarks = poseResult.landmarks[i]!;
const circle = computeHeadCircleFromPose(poseLandmarks, width, height);
if (circle) {
const scratch = pool[i]!;
applyHeadCircleFallback(ctx, video, disguiseRef.current, circle, width, height, blurStrengthRef.current, scratch);
}
}
}
}
}

View file

@ -0,0 +1,80 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { PoseLandmarker, FilesetResolver } from '@mediapipe/tasks-vision';
import type { PoseLandmarkerResult } from '@mediapipe/tasks-vision';
const MEDIAPIPE_WASM_URL =
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm';
const POSE_MODEL_URL =
'https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task';
export interface UsePoseDetectionResult {
detectForVideo: (video: HTMLVideoElement, timestamp: number) => PoseLandmarkerResult | null;
isReady: boolean;
error: Error | null;
}
/**
* Manages the MediaPipe PoseLandmarker lifecycle for VIDEO mode.
*
* The pose model (BlazePose, 33 keypoints) tracks the full body and is robust
* to head orientations that defeat FaceLandmarker (extreme pitch, profile).
* It provides head position via nose (0) + ears (7/8) + shoulders (11/12),
* enabling head-coverage disguise even when the face mesh is not detected.
*/
export function usePoseDetection(): UsePoseDetectionResult {
const landmarkerRef = useRef<PoseLandmarker | null>(null);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
async function initialize(): Promise<void> {
const filesetResolver = await FilesetResolver.forVisionTasks(MEDIAPIPE_WASM_URL);
if (cancelled) return;
const landmarker = await PoseLandmarker.createFromOptions(filesetResolver, {
baseOptions: {
modelAssetPath: POSE_MODEL_URL,
delegate: 'GPU',
},
runningMode: 'VIDEO',
numPoses: 1,
minPoseDetectionConfidence: 0.3,
minTrackingConfidence: 0.4,
outputSegmentationMasks: false,
});
if (cancelled) {
landmarker.close();
return;
}
landmarkerRef.current = landmarker;
setIsReady(true);
}
initialize().catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err : new Error(String(err)));
}
});
return () => {
cancelled = true;
landmarkerRef.current?.close();
landmarkerRef.current = null;
};
}, []);
const detectForVideo = useCallback(
(video: HTMLVideoElement, timestamp: number): PoseLandmarkerResult | null => {
if (!landmarkerRef.current || !isReady) return null;
return landmarkerRef.current.detectForVideo(video, timestamp);
},
[isReady],
);
return { detectForVideo, isReady, error };
}

View file

@ -0,0 +1,56 @@
import type { HeadCircle } from '../utils/head-circle';
import type { RendererScratch } from './RendererPool';
// Same leather black as GimpMaskRenderer
const LEATHER_BLACK = '#120E0E';
/**
* Covers a head circle with a solid disguise when face landmarks are unavailable
* (pose-only fallback). Used when FaceLandmarker has lost the face entirely
* (extreme pitch, fast motion, etc.) but PoseLandmarker still resolves the head.
*
* Only solid coverage is applied eye cutouts and zipper are omitted because
* at this head orientation the face features aren't visible anyway.
*/
export function applyHeadCircleFallback(
ctx: CanvasRenderingContext2D,
video: HTMLVideoElement,
disguise: 'blur' | 'mask' | 'masquerade' | 'anonymous' | 'egirl' | 'none',
circle: HeadCircle,
w: number,
h: number,
blurStrength: number,
scratch: RendererScratch,
): void {
if (disguise === 'none') return;
const { cx, cy, r } = circle;
if (disguise === 'blur') {
// Blur the full frame then clip to the head circle (same technique as BlurRenderer)
const ic = scratch.ctx;
ic.filter = `blur(${blurStrength}px)`;
ic.drawImage(video, 0, 0, w, h);
ic.filter = 'none';
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(scratch.canvas, 0, 0);
ctx.restore();
return;
}
// All mask modes: solid head circle in the appropriate base colour.
// Detailed features (eye holes, zipper, decorations) are skipped — the face
// isn't visible at this orientation so they'd render in the wrong position.
const baseColour = disguise === 'mask' ? LEATHER_BLACK : LEATHER_BLACK;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = baseColour;
ctx.fill();
ctx.restore();
}

View file

@ -0,0 +1,76 @@
import type { NormalizedLandmark } from '@mediapipe/tasks-vision';
export interface HeadCircle {
cx: number;
cy: number;
r: number;
}
/** BlazePose landmark indices used for head-circle computation. */
const NOSE = 0;
const LEFT_EAR = 7;
const RIGHT_EAR = 8;
const LEFT_SHOULDER = 11;
const RIGHT_SHOULDER = 12;
/**
* Computes a screen-space circle that covers the full head from PoseLandmarker
* 33-point landmarks.
*
* Used as the primary head-coverage source when FaceLandmarker has lost the
* face entirely (extreme pitch, fast motion, occlusion). The pose model is
* robust to head orientations that defeat the face mesh detector.
*
* Strategy:
* - When both ears visible: centre = weighted(nose×2, ears×1), radius = ear-to-ear × 0.72
* - When ears occluded: centre = nose, radius = shoulder-span × 0.20
* - No scale reference: centre = nose, radius = 80px fixed
*/
export function computeHeadCircleFromPose(
landmarks: NormalizedLandmark[],
w: number,
h: number,
): HeadCircle | null {
const nose = landmarks[NOSE];
if (!nose) return null;
const noseX = nose.x * w;
const noseY = nose.y * h;
const leftEar = landmarks[LEFT_EAR];
const rightEar = landmarks[RIGHT_EAR];
const leftEarVisible = leftEar && (leftEar.visibility ?? 1) > 0.3;
const rightEarVisible = rightEar && (rightEar.visibility ?? 1) > 0.3;
if (leftEarVisible && rightEarVisible) {
const lEarX = leftEar.x * w;
const lEarY = leftEar.y * h;
const rEarX = rightEar.x * w;
const rEarY = rightEar.y * h;
// Nose weighted 2× for stability; ears provide width reference
const cx = (noseX * 2 + lEarX + rEarX) / 4;
const cy = (noseY * 2 + lEarY + rEarY) / 4;
const earToEar = Math.hypot(rEarX - lEarX, rEarY - lEarY);
// Ear-to-ear ≈ head width; ×0.72 = generous radius to cover crown
const r = earToEar * 0.72;
return { cx, cy, r };
}
// Fallback: use shoulders for head scale when ears are occluded
const leftShoulder = landmarks[LEFT_SHOULDER];
const rightShoulder = landmarks[RIGHT_SHOULDER];
if (leftShoulder && rightShoulder) {
const shoulderSpan = Math.hypot(
(rightShoulder.x - leftShoulder.x) * w,
(rightShoulder.y - leftShoulder.y) * h,
);
// Shoulder span ≈ 2.53× head width; ×0.20 ≈ head radius
const r = Math.max(shoulderSpan * 0.20, 40);
return { cx: noseX, cy: noseY, r };
}
// No scale reference available — fixed 80px radius
return { cx: noseX, cy: noseY, r: 80 };
}