From baf94da2029d33caea9b2164ade6ce6103d6378c Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 18 Mar 2026 01:37:28 -0700 Subject: [PATCH] =?UTF-8?q?ui(video-studio):=20=F0=9F=92=84=20Introduce=20?= =?UTF-8?q?face=20selection=20and=20disguising=20UI=20components=20for=20v?= =?UTF-8?q?ideo=20participants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../DisguiseVideoParticipantVideo.tsx | 10 +- .../DisguiseVideoWithFaceSelector.tsx | 156 +++++++++++++++ .../src/components/FaceSelectionOverlay.tsx | 187 ++++++++++++++++++ 3 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx create mode 100644 features/video-studio/frontend-live/src/components/FaceSelectionOverlay.tsx diff --git a/features/video-studio/frontend-live/src/components/DisguiseVideoParticipantVideo.tsx b/features/video-studio/frontend-live/src/components/DisguiseVideoParticipantVideo.tsx index 6a1e2b8f2..8fc6ece72 100644 --- a/features/video-studio/frontend-live/src/components/DisguiseVideoParticipantVideo.tsx +++ b/features/video-studio/frontend-live/src/components/DisguiseVideoParticipantVideo.tsx @@ -153,6 +153,7 @@ export function DisguiseVideoParticipantVideo({ onCaptureStream, onFacesDetected, disguisedFaceIndices = null, + faceDisguiseModes = null, className, style, }: DisguiseVideoParticipantVideoProps): ReactElement { @@ -178,6 +179,9 @@ export function DisguiseVideoParticipantVideo({ const disguisedFaceIndicesRef = useRef(disguisedFaceIndices); disguisedFaceIndicesRef.current = disguisedFaceIndices; + const faceDisguiseModesRef = useRef(faceDisguiseModes); + faceDisguiseModesRef.current = faceDisguiseModes; + // Throttle state for onFacesDetected (~5 fps). const lastFacesNotifyRef = useRef(0); const lastFaceCountRef = useRef(-1); @@ -318,7 +322,8 @@ export function DisguiseVideoParticipantVideo({ if (selectedIndices !== null && !selectedIndices.has(i)) continue; const faceLandmarks = activeResult.faceLandmarks[i]!; const scratch = pool[i]!; - applyDisguise(ctx, video, disguiseRef.current, faceLandmarks, width, height, blurStrengthRef.current, scratch); + const mode = faceDisguiseModesRef.current?.get(i) ?? disguiseRef.current; + applyDisguise(ctx, video, mode, faceLandmarks, width, height, blurStrengthRef.current, scratch); } } else { // Face completely lost — notify with empty array (throttled). @@ -347,7 +352,8 @@ export function DisguiseVideoParticipantVideo({ const circle = computeHeadCircleFromPose(poseLandmarks, width, height); if (circle) { const scratch = pool[i]!; - applyHeadCircleFallback(ctx, video, disguiseRef.current, circle, width, height, blurStrengthRef.current, scratch); + const mode = faceDisguiseModesRef.current?.get(i) ?? disguiseRef.current; + applyHeadCircleFallback(ctx, video, mode, circle, width, height, blurStrengthRef.current, scratch); } } } diff --git a/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx b/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx new file mode 100644 index 000000000..69f2b9a42 --- /dev/null +++ b/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx @@ -0,0 +1,156 @@ +import { useCallback, useRef, useState, type ReactElement } from 'react'; +import { + DisguiseVideoParticipantVideo, + type DetectedFace, + type DisguiseMode, + type DisguiseVideoParticipantVideoProps, +} from './DisguiseVideoParticipantVideo'; +import { FaceSelectionOverlay } from './FaceSelectionOverlay'; + +export interface DisguiseVideoWithFaceSelectorProps + extends Omit< + DisguiseVideoParticipantVideoProps, + 'onFacesDetected' | 'disguisedFaceIndices' | 'faceDisguiseModes' + > { + /** + * Whether to start with all detected faces selected (disguised). + * Default: true — privacy-first; disguise until explicitly deselected. + */ + defaultSelectAll?: boolean; + /** + * Called when selection or per-face modes change — for external state sync. + */ + onSelectionChange?: ( + selectedIndices: ReadonlySet, + faceDisguiseModes: ReadonlyMap, + ) => void; + /** + * Whether to show the per-face mode picker badge. + * Default: false — simple toggle only. + */ + showModePicker?: boolean; +} + +export function DisguiseVideoWithFaceSelector({ + defaultSelectAll = true, + onSelectionChange, + showModePicker = false, + disguise, + width = 640, + height = 480, + ...rest +}: DisguiseVideoWithFaceSelectorProps): ReactElement { + const [faces, setFaces] = useState([]); + const [selectedIndices, setSelectedIndices] = useState>(new Set()); + const [faceDisguiseModes, setFaceDisguiseModes] = useState>( + new Map(), + ); + + // Use a ref for selectedIndices so the faces-detected callback can read + // the current value without stale closure captures. + const selectedIndicesRef = useRef(selectedIndices); + selectedIndicesRef.current = selectedIndices; + + const faceDisguiseModesRef = useRef(faceDisguiseModes); + faceDisguiseModesRef.current = faceDisguiseModes; + + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; + + const handleFacesDetected = useCallback( + (detected: DetectedFace[]) => { + setFaces(detected); + + const incomingIndices = new Set(detected.map((f) => f.index)); + const currentSelected = selectedIndicesRef.current; + const currentModes = faceDisguiseModesRef.current; + + // Prune stale indices that are no longer detected. + const prunedSelected = new Set(); + for (const idx of currentSelected) { + if (incomingIndices.has(idx)) prunedSelected.add(idx); + } + + // Auto-select newly arrived faces when defaultSelectAll is true. + let changed = prunedSelected.size !== currentSelected.size; + if (defaultSelectAll) { + for (const idx of incomingIndices) { + if (!currentSelected.has(idx)) { + prunedSelected.add(idx); + changed = true; + } + } + } + + // Prune mode overrides for vanished faces. + const prunedModes = new Map(); + for (const [idx, mode] of currentModes) { + if (incomingIndices.has(idx)) prunedModes.set(idx, mode); + } + const modesChanged = prunedModes.size !== currentModes.size; + + if (changed) setSelectedIndices(prunedSelected); + if (modesChanged) setFaceDisguiseModes(prunedModes); + + if ((changed || modesChanged) && onSelectionChangeRef.current) { + onSelectionChangeRef.current( + changed ? prunedSelected : currentSelected, + modesChanged ? prunedModes : currentModes, + ); + } + }, + [defaultSelectAll], + ); + + const handleToggle = useCallback( + (index: number) => { + setSelectedIndices((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + onSelectionChangeRef.current?.(next, faceDisguiseModesRef.current); + return next; + }); + }, + [], + ); + + const handleModeChange = useCallback( + (index: number, mode: DisguiseMode) => { + setFaceDisguiseModes((prev) => { + const next = new Map(prev); + next.set(index, mode); + onSelectionChangeRef.current?.(selectedIndicesRef.current, next); + return next; + }); + }, + [], + ); + + return ( +
+ + +
+ ); +} diff --git a/features/video-studio/frontend-live/src/components/FaceSelectionOverlay.tsx b/features/video-studio/frontend-live/src/components/FaceSelectionOverlay.tsx new file mode 100644 index 000000000..6958eeb14 --- /dev/null +++ b/features/video-studio/frontend-live/src/components/FaceSelectionOverlay.tsx @@ -0,0 +1,187 @@ +import { useRef, useState, type CSSProperties, type ReactElement } from 'react'; +import type { DetectedFace, DisguiseMode } from './DisguiseVideoParticipantVideo'; + +export interface FaceSelectionOverlayProps { + /** Detected faces from onFacesDetected — bounding boxes in canvas pixels. */ + faces: DetectedFace[]; + /** Which face indices are selected (will receive a disguise). */ + selectedIndices: ReadonlySet; + /** Canvas intrinsic width (matches DisguiseVideoParticipantVideo width prop). */ + intrinsicWidth: number; + /** Canvas intrinsic height (matches DisguiseVideoParticipantVideo height prop). */ + intrinsicHeight: number; + /** Called when a face box is clicked to toggle selection. */ + onToggle: (index: number) => void; + /** Per-face disguise mode overrides. */ + disguiseModes?: ReadonlyMap; + /** Global disguise mode shown as fallback label. */ + globalDisguise?: DisguiseMode; + /** + * Called when per-face disguise mode is changed via the mode picker. + * When undefined the mode badge is not interactive. + */ + onDisguiseModeChange?: (index: number, mode: DisguiseMode) => void; + className?: string; + style?: CSSProperties; +} + +const ALL_MODES: DisguiseMode[] = ['blur', 'mask', 'masquerade', 'anonymous', 'egirl', 'none']; + +const UNSELECTED_BORDER = '2px dashed rgba(255,255,255,0.5)'; +const SELECTED_BORDER = '2px solid #22ff88'; +const SELECTED_SHADOW = 'inset 0 0 0 1px rgba(34,255,136,0.3)'; + +export function FaceSelectionOverlay({ + faces, + selectedIndices, + intrinsicWidth, + intrinsicHeight, + onToggle, + disguiseModes, + globalDisguise = 'none', + onDisguiseModeChange, + className, + style, +}: FaceSelectionOverlayProps): ReactElement { + // Track which face has the mode picker open (by index, or -1 for none). + const [openPickerIndex, setOpenPickerIndex] = useState(-1); + const containerRef = useRef(null); + + return ( +
+ {faces.map((face) => { + const { index, bounds } = face; + const selected = selectedIndices.has(index); + const activeMode = disguiseModes?.get(index) ?? globalDisguise; + const pickerOpen = openPickerIndex === index; + + const left = `${(bounds.x / intrinsicWidth) * 100}%`; + const top = `${(bounds.y / intrinsicHeight) * 100}%`; + const width = `${(bounds.w / intrinsicWidth) * 100}%`; + const height = `${(bounds.h / intrinsicHeight) * 100}%`; + + return ( +
{ onToggle(index); }} + style={{ + position: 'absolute', + left, + top, + width, + height, + border: selected ? SELECTED_BORDER : UNSELECTED_BORDER, + boxShadow: selected ? SELECTED_SHADOW : 'none', + boxSizing: 'border-box', + pointerEvents: 'all', + cursor: 'pointer', + userSelect: 'none', + transition: 'border-color 0.15s, box-shadow 0.15s', + }} + > + {/* Face index badge — top-left */} + + {`Face ${index}`} + + + {/* Disguise mode badge — bottom-left */} + {onDisguiseModeChange ? ( + { + e.stopPropagation(); + setOpenPickerIndex(pickerOpen ? -1 : index); + }} + > + {activeMode} + {pickerOpen && ( + + )} + + ) : ( + + {activeMode} + + )} +
+ ); + })} +
+ ); +}