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:
parent
9770813805
commit
c043295751
7 changed files with 69 additions and 48 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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))} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export interface DemonParams {
|
|||
}
|
||||
|
||||
export interface SuccubusParams {
|
||||
/** Horn height multiplier — 0.4–2.0. */
|
||||
/** Horn height multiplier — 0.4–4.0. */
|
||||
hornHeight: number;
|
||||
/** Horn tip X-sweep multiplier — 0.4–2.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,
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue