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:
parent
eae274e122
commit
baf94da202
3 changed files with 351 additions and 2 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue