feat(video-studio): Add disguise panel components and video rendering logic for face selection and effects

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 02:25:23 -07:00
parent 9770813805
commit c043295751
7 changed files with 69 additions and 48 deletions

View file

@ -25,34 +25,46 @@ export function App(): ReactElement {
const [identityFrameCounts, setIdentityFrameCounts] = useState<ReadonlyMap<string, number>>(new Map());
// Called when 📷 is clicked on a face box.
// Creates a new identity from the portrait, or updates an existing one's portrait.
// Returns the identity ID so the wrapper can auto-assign the face.
// Creates a new identity (portrait optional — null if capture failed), or
// updates an existing one's portrait. Returns the identity ID for auto-assignment.
const handleCapturePortrait = useCallback(
(
_faceIndex: number,
portraitDataUrl: string,
portraitDataUrl: string | null,
existingIdentityId: string | null,
): string => {
if (existingIdentityId) {
// Update portrait of existing identity.
setIdentities((prev) =>
prev.map((id) =>
id.id === existingIdentityId ? { ...id, thumbnailUrl: portraitDataUrl } : id,
),
);
// Update portrait of existing identity if we have one.
if (portraitDataUrl) {
setIdentities((prev) =>
prev.map((id) =>
id.id === existingIdentityId ? { ...id, thumbnailUrl: portraitDataUrl } : id,
),
);
}
return existingIdentityId;
}
// Create new identity.
const id = generateId();
const name = `Person ${nextPersonNum++}`;
setIdentities((prev) => [...prev, { id, name, thumbnailUrl: portraitDataUrl }]);
setIdentities((prev) => [
...prev,
{ id, name, ...(portraitDataUrl ? { thumbnailUrl: portraitDataUrl } : {}) },
]);
setIdentityModes((prev) => new Map([...prev, [id, 'mask' as DisguiseMode]]));
return id;
},
[],
);
const handleAddIdentity = useCallback(() => {
const id = generateId();
const name = `Person ${nextPersonNum++}`;
setIdentities((prev) => [...prev, { id, name }]);
setIdentityModes((prev) => new Map([...prev, [id, 'mask' as DisguiseMode]]));
}, []);
const handleDeleteIdentity = useCallback((id: string) => {
setIdentities((prev) => prev.filter((i) => i.id !== id));
setIdentityModes((prev) => { const n = new Map(prev); n.delete(id); return n; });
@ -130,6 +142,7 @@ export function App(): ReactElement {
identities={identities}
identityModes={identityModes}
identityFrameCounts={identityFrameCounts}
onAdd={handleAddIdentity}
onDelete={handleDeleteIdentity}
onModeChange={handleIdentityModeChange}
onRename={handleRename}

View file

@ -124,7 +124,7 @@ export function DisguiseParamsPanel({
<span style={styles.title}>Succubus params</span>
<ResetButton onClick={() => onSuccubusChange(resolveSuccubusParams())} />
</div>
<SliderRow label="Horn height" value={succubusParams.hornHeight} min={0.4} max={2.0} step={0.05} onChange={(v) => set('hornHeight', v)} />
<SliderRow label="Horn height" value={succubusParams.hornHeight} min={0.4} max={4.0} step={0.05} onChange={(v) => set('hornHeight', v)} />
<SliderRow label="Horn sweep" value={succubusParams.hornSweep} min={0.4} max={2.0} step={0.05} onChange={(v) => set('hornSweep', v)} />
<SliderRow label="Eye glow" value={succubusParams.eyeGlow} min={0.3} max={2.0} step={0.05} onChange={(v) => set('eyeGlow', v)} />
<SliderRow label="Sparkles / eye" value={succubusParams.sparkleCount} min={0} max={9} step={1} format={(v) => String(v)} onChange={(v) => set('sparkleCount', Math.round(v))} />

View file

@ -17,7 +17,7 @@ const MAX_HEIGHT = 720;
export interface FileVideoViewProps {
identities: FaceIdentity[];
resolveIdentityMode: (identityId: string) => DisguiseMode | undefined;
onCapturePortrait: (faceIndex: number, portraitDataUrl: string, existingIdentityId: string | null) => string | null;
onCapturePortrait: (faceIndex: number, portraitDataUrl: string | null, existingIdentityId: string | null) => string | null;
onFrameAccounting: (faceIdentities: ReadonlyMap<number, string>) => void;
}

View file

@ -11,6 +11,7 @@ export interface IdentityPanelProps {
identityModes: ReadonlyMap<string, DisguiseMode>;
/** Frame counts per identity ID — drives the donut chart. */
identityFrameCounts: ReadonlyMap<string, number>;
onAdd: () => void;
onDelete: (id: string) => void;
onModeChange: (id: string, mode: DisguiseMode) => void;
onRename: (id: string, name: string) => void;
@ -130,6 +131,7 @@ export function IdentityPanel({
identities,
identityModes,
identityFrameCounts,
onAdd,
onDelete,
onModeChange,
onRename,
@ -162,15 +164,15 @@ export function IdentityPanel({
<div style={styles.panel}>
<div style={styles.header}>
<span style={styles.title}>Identities</span>
{identities.length > 0 && (
<span style={styles.hint}>📷 click face to capture</span>
)}
<button type="button" style={styles.addBtn} onClick={onAdd} title="Add new identity">
+ Add
</button>
</div>
{identities.length === 0 ? (
<p style={styles.empty}>
No identities yet.<br />
Click <strong style={{ color: '#888' }}>📷</strong> on a detected face to capture a portrait and create one.
Use <strong style={{ color: '#888' }}>+ Add</strong> or click <strong style={{ color: '#888' }}>📷</strong> on a detected face.
</p>
) : (
<div style={styles.list}>
@ -263,10 +265,15 @@ const styles = {
letterSpacing: '0.08em',
color: '#555',
},
hint: {
fontSize: '10px',
color: '#444',
fontStyle: 'italic' as const,
addBtn: {
fontSize: '11px',
padding: '2px 8px',
background: '#1e0f2e',
color: '#9b59b6',
border: '1px solid #3a1a5a',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600,
},
empty: {
margin: 0,

View file

@ -10,7 +10,7 @@ import type { FaceIdentity } from '@vs-live/components/FaceSelectionOverlay';
export interface LiveCameraViewProps {
identities: FaceIdentity[];
resolveIdentityMode: (identityId: string) => DisguiseMode | undefined;
onCapturePortrait: (faceIndex: number, portraitDataUrl: string, existingIdentityId: string | null) => string | null;
onCapturePortrait: (faceIndex: number, portraitDataUrl: string | null, existingIdentityId: string | null) => string | null;
onFrameAccounting: (faceIdentities: ReadonlyMap<number, string>) => void;
}

View file

@ -54,15 +54,16 @@ export interface DisguiseVideoWithFaceSelectorProps
resolveIdentityMode?: (identityId: string) => DisguiseMode | undefined;
/**
* Called when the camera button is clicked on a face box.
* Receives: face index, JPEG data URL of the cropped portrait, and the
* face's current identity ID (null = unassigned).
* Receives: face index, JPEG data URL of the cropped portrait (null if capture
* failed e.g., face not yet in detection results), and the face's current
* identity ID (null = unassigned).
* Return the identity ID to assign to this face (existing or newly created),
* or null to skip the assignment.
* Synchronous create the identity in the parent and return its ID.
*/
onCapturePortrait?: (
faceIndex: number,
portraitDataUrl: string,
portraitDataUrl: string | null,
existingIdentityId: string | null,
) => string | null;
/**
@ -210,9 +211,7 @@ export function DisguiseVideoWithFaceSelector({
}, []);
const handlePortraitCapture = useCallback((faceIndex: number) => {
const dataUrl = portraitCaptureRef.current?.(faceIndex);
if (!dataUrl) return;
const dataUrl = portraitCaptureRef.current?.(faceIndex) ?? null;
const existingIdentityId = faceIdentitiesRef.current.get(faceIndex) ?? null;
const resultId = onCapturePortraitRef.current?.(faceIndex, dataUrl, existingIdentityId);
@ -276,20 +275,22 @@ export function DisguiseVideoWithFaceSelector({
faceDisguiseModes={faceDisguiseModes}
portraitCaptureRef={portraitCaptureRef}
/>
<FaceSelectionOverlay
faces={faces}
selectedIndices={selectedIndices}
intrinsicWidth={width}
intrinsicHeight={height}
onToggle={handleToggle}
disguiseModes={faceDisguiseModes}
globalDisguise={disguise}
onDisguiseModeChange={showModePicker ? handleModeChange : undefined}
identities={identities}
faceIdentities={faceIdentities}
onIdentityAssign={identities !== undefined ? handleIdentityAssign : undefined}
onCapturePortrait={onCapturePortrait ? handlePortraitCapture : undefined}
/>
{showOverlay && (
<FaceSelectionOverlay
faces={faces}
selectedIndices={selectedIndices}
intrinsicWidth={width}
intrinsicHeight={height}
onToggle={handleToggle}
disguiseModes={faceDisguiseModes}
globalDisguise={disguise}
onDisguiseModeChange={showModePicker ? handleModeChange : undefined}
identities={identities}
faceIdentities={faceIdentities}
onIdentityAssign={identities !== undefined ? handleIdentityAssign : undefined}
onCapturePortrait={onCapturePortrait ? handlePortraitCapture : undefined}
/>
)}
</div>
);
}

View file

@ -21,7 +21,7 @@ export interface DemonParams {
}
export interface SuccubusParams {
/** Horn height multiplier — 0.42.0. */
/** Horn height multiplier — 0.44.0. */
hornHeight: number;
/** Horn tip X-sweep multiplier — 0.42.0. */
hornSweep: number;
@ -57,14 +57,14 @@ export const DEFAULT_DEMON_PARAMS: DemonParams = {
};
export const DEFAULT_SUCCUBUS_PARAMS: SuccubusParams = {
hornHeight: 2.00,
hornSweep: 1.95,
eyeGlow: 1.70,
sparkleCount: 7,
hornHeight: 1.00,
hornSweep: 1.00,
eyeGlow: 1.00,
sparkleCount: 3,
sparkleSize: 0.85,
wingSpread: 0.80,
earHeight: 2.00,
tintStrength: 1.45,
earHeight: 1.00,
tintStrength: 1.00,
tongue: true,
};