ui(video-studio): 💄 Introduce face selection and disguising UI components for video participants

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 01:37:28 -07:00
parent eae274e122
commit baf94da202
3 changed files with 351 additions and 2 deletions

View file

@ -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);
}
}
}

View file

@ -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<number>,
faceDisguiseModes: ReadonlyMap<number, DisguiseMode>,
) => 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<DetectedFace[]>([]);
const [selectedIndices, setSelectedIndices] = useState<ReadonlySet<number>>(new Set());
const [faceDisguiseModes, setFaceDisguiseModes] = useState<ReadonlyMap<number, DisguiseMode>>(
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<number>();
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<number, DisguiseMode>();
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 (
<div style={{ position: 'relative', display: 'inline-block' }}>
<DisguiseVideoParticipantVideo
{...rest}
disguise={disguise}
width={width}
height={height}
onFacesDetected={handleFacesDetected}
disguisedFaceIndices={selectedIndices}
faceDisguiseModes={faceDisguiseModes}
/>
<FaceSelectionOverlay
faces={faces}
selectedIndices={selectedIndices}
intrinsicWidth={width}
intrinsicHeight={height}
onToggle={handleToggle}
disguiseModes={faceDisguiseModes}
globalDisguise={disguise}
onDisguiseModeChange={showModePicker ? handleModeChange : undefined}
/>
</div>
);
}

View file

@ -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<number>;
/** 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<number, DisguiseMode>;
/** 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<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className={className}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
...style,
}}
>
{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 (
<div
key={index}
onClick={() => { 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 */}
<span
style={{
position: 'absolute',
top: 2,
left: 2,
fontSize: 10,
lineHeight: 1,
padding: '2px 4px',
background: 'rgba(0,0,0,0.55)',
color: selected ? '#22ff88' : 'rgba(255,255,255,0.75)',
borderRadius: 3,
pointerEvents: 'none',
}}
>
{`Face ${index}`}
</span>
{/* Disguise mode badge — bottom-left */}
{onDisguiseModeChange ? (
<span
style={{
position: 'absolute',
bottom: 2,
left: 2,
fontSize: 10,
lineHeight: 1,
padding: '2px 4px',
background: 'rgba(0,0,0,0.65)',
color: '#fff',
borderRadius: 3,
cursor: 'pointer',
pointerEvents: 'all',
textDecoration: 'underline dotted',
}}
onClick={(e) => {
e.stopPropagation();
setOpenPickerIndex(pickerOpen ? -1 : index);
}}
>
{activeMode}
{pickerOpen && (
<select
value={activeMode}
onChange={(e) => {
e.stopPropagation();
onDisguiseModeChange(index, e.target.value as DisguiseMode);
setOpenPickerIndex(-1);
}}
onClick={(e) => { e.stopPropagation(); }}
onBlur={() => { setOpenPickerIndex(-1); }}
autoFocus
style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: 2,
fontSize: 11,
background: 'rgba(20,20,20,0.95)',
color: '#fff',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: 3,
padding: '2px 4px',
cursor: 'pointer',
zIndex: 10,
}}
>
{ALL_MODES.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
)}
</span>
) : (
<span
style={{
position: 'absolute',
bottom: 2,
left: 2,
fontSize: 10,
lineHeight: 1,
padding: '2px 4px',
background: 'rgba(0,0,0,0.55)',
color: 'rgba(255,255,255,0.65)',
borderRadius: 3,
pointerEvents: 'none',
}}
>
{activeMode}
</span>
)}
</div>
);
})}
</div>
);
}