From 2a0ecea7feb5f846cf0f7741ea97be9a17b42b3f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 18 Mar 2026 01:54:50 -0700 Subject: [PATCH] =?UTF-8?q?feat(video-studio):=20=E2=9C=A8=20Add=20UI=20co?= =?UTF-8?q?mponents,=20renderer=20logic,=20and=20backend=20integration=20f?= =?UTF-8?q?or=20disguise=20mode=20with=20Demon=20and=20Succubus=20avatars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../backend-api/src/app.module.ts | 1 + features/image-assistant/macos/deploy.sh | 6 + .../src/components/DisguiseModeSelector.tsx | 4 +- .../DisguiseVideoParticipantVideo.tsx | 10 +- .../DisguiseVideoWithFaceSelector.tsx | 107 +++++--- .../src/components/FaceSelectionOverlay.tsx | 227 ++++++++++++++- .../video-studio/frontend-live/src/index.ts | 2 +- .../src/renderers/DemonRenderer.ts | 171 ++++++++++++ .../src/renderers/SuccubusRenderer.ts | 259 ++++++++++++++++++ .../src/renderers/head-circle-fallback.ts | 7 +- 10 files changed, 745 insertions(+), 49 deletions(-) create mode 100644 features/video-studio/frontend-live/src/renderers/DemonRenderer.ts create mode 100644 features/video-studio/frontend-live/src/renderers/SuccubusRenderer.ts diff --git a/features/image-assistant/backend-api/src/app.module.ts b/features/image-assistant/backend-api/src/app.module.ts index 10d3d250c..2d4c84f1b 100644 --- a/features/image-assistant/backend-api/src/app.module.ts +++ b/features/image-assistant/backend-api/src/app.module.ts @@ -54,6 +54,7 @@ const registry = buildDeploymentRegistry({ database: configService.get('DATABASE_POSTGRES_NAME', 'lilith_image_assistant'), autoLoadEntities: true, migrations, + migrationsTableName: 'typeorm_migrations', synchronize: false, // NEVER use synchronize in production - use migrations migrationsRun: configService.get('MIGRATIONS_RUN') !== 'false', logging: configService.get('NODE_ENV') !== 'production', diff --git a/features/image-assistant/macos/deploy.sh b/features/image-assistant/macos/deploy.sh index d51b58928..5a73db69a 100755 --- a/features/image-assistant/macos/deploy.sh +++ b/features/image-assistant/macos/deploy.sh @@ -66,6 +66,12 @@ build_frontend_locally() { return 0 fi + # Reuse pre-built dist if available + if [[ -d "$frontend_dir/dist" ]]; then + log_info "Using pre-built frontend from dist/" + return 0 + fi + log_info "Building frontend locally (to avoid requiring Node.js on plum)..." cd "$frontend_dir" diff --git a/features/video-studio/frontend-demo/src/components/DisguiseModeSelector.tsx b/features/video-studio/frontend-demo/src/components/DisguiseModeSelector.tsx index 2c68c0a6f..e33055f35 100644 --- a/features/video-studio/frontend-demo/src/components/DisguiseModeSelector.tsx +++ b/features/video-studio/frontend-demo/src/components/DisguiseModeSelector.tsx @@ -1,6 +1,6 @@ import type { ReactElement, ChangeEvent } from 'react'; -export type DisguiseMode = 'none' | 'blur' | 'mask' | 'masquerade' | 'anonymous' | 'egirl'; +export type DisguiseMode = 'none' | 'blur' | 'mask' | 'masquerade' | 'anonymous' | 'demon' | 'succubus' | 'egirl'; interface DisguiseModeSelectorProps { mode: DisguiseMode; @@ -15,6 +15,8 @@ const PROTECTION_MODES: { value: DisguiseMode; label: string; description: strin { value: 'mask', label: 'Gimp', description: 'Leather BDSM mask' }, { value: 'masquerade', label: 'Masquerade', description: 'Venetian eye mask' }, { value: 'anonymous', label: 'Anonymous', description: 'Guy Fawkes / V for Vendetta' }, + { value: 'demon', label: 'Demon', description: 'Horns, slit pupils, hellfire glow' }, + { value: 'succubus', label: 'Succubus', description: 'Horns, violet eyes, bat wings' }, ]; const AESTHETIC_MODES: { value: DisguiseMode; label: string; description: string }[] = [ diff --git a/features/video-studio/frontend-live/src/components/DisguiseVideoParticipantVideo.tsx b/features/video-studio/frontend-live/src/components/DisguiseVideoParticipantVideo.tsx index 8fc6ece72..5f8a7ef45 100644 --- a/features/video-studio/frontend-live/src/components/DisguiseVideoParticipantVideo.tsx +++ b/features/video-studio/frontend-live/src/components/DisguiseVideoParticipantVideo.tsx @@ -8,6 +8,8 @@ import { drawGimpMask } from '../renderers/GimpMaskRenderer'; import { drawMasqueradeMask } from '../renderers/MasqueradeRenderer'; import { drawAnonymousMask } from '../renderers/AnonymousRenderer'; import { drawEgirlOverlay } from '../renderers/EgirlRenderer'; +import { drawDemonMask } from '../renderers/DemonRenderer'; +import { drawSuccubusMask } from '../renderers/SuccubusRenderer'; import { createRendererPool, type RendererScratch } from '../renderers/RendererPool'; import { applyHeadCircleFallback } from '../renderers/head-circle-fallback'; @@ -24,7 +26,7 @@ const MAX_FACES = 4; const FACES_NOTIFY_INTERVAL_MS = 200; /** Visual disguise applied to detected faces. */ -export type DisguiseMode = 'blur' | 'mask' | 'masquerade' | 'anonymous' | 'egirl' | 'none'; +export type DisguiseMode = 'blur' | 'mask' | 'masquerade' | 'anonymous' | 'egirl' | 'demon' | 'succubus' | 'none'; /** A single face detected in the current frame. */ export interface DetectedFace { @@ -122,6 +124,12 @@ function applyDisguise( case 'egirl': drawEgirlOverlay(ctx, scratch, faceLandmarks, width, height); break; + case 'demon': + drawDemonMask(ctx, scratch, faceLandmarks, width, height); + break; + case 'succubus': + drawSuccubusMask(ctx, scratch, faceLandmarks, width, height); + break; case 'none': break; default: diff --git a/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx b/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx index 69f2b9a42..3a112410a 100644 --- a/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx +++ b/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx @@ -5,7 +5,7 @@ import { type DisguiseMode, type DisguiseVideoParticipantVideoProps, } from './DisguiseVideoParticipantVideo'; -import { FaceSelectionOverlay } from './FaceSelectionOverlay'; +import { FaceSelectionOverlay, type FaceIdentity } from './FaceSelectionOverlay'; export interface DisguiseVideoWithFaceSelectorProps extends Omit< @@ -18,23 +18,35 @@ export interface DisguiseVideoWithFaceSelectorProps */ defaultSelectAll?: boolean; /** - * Called when selection or per-face modes change — for external state sync. + * Called when selection or per-face disguise modes change. */ onSelectionChange?: ( selectedIndices: ReadonlySet, faceDisguiseModes: ReadonlyMap, ) => void; /** - * Whether to show the per-face mode picker badge. + * Whether to show the per-face disguise mode picker badge. * Default: false — simple toggle only. */ showModePicker?: boolean; + /** + * Available identities to assign to detected faces. + * When provided, each face box shows an identity badge and picker panel. + */ + identities?: FaceIdentity[]; + /** + * Called when the identity assignment for any face changes. + * The map contains only faces that have an assignment; absent = unassigned. + */ + onIdentityChange?: (faceIdentities: ReadonlyMap) => void; } export function DisguiseVideoWithFaceSelector({ defaultSelectAll = true, onSelectionChange, showModePicker = false, + identities, + onIdentityChange, disguise, width = 640, height = 480, @@ -45,18 +57,24 @@ export function DisguiseVideoWithFaceSelector({ const [faceDisguiseModes, setFaceDisguiseModes] = useState>( new Map(), ); + const [faceIdentities, setFaceIdentities] = useState>(new Map()); - // Use a ref for selectedIndices so the faces-detected callback can read - // the current value without stale closure captures. + // Refs so callbacks always read current values without stale closures. const selectedIndicesRef = useRef(selectedIndices); selectedIndicesRef.current = selectedIndices; const faceDisguiseModesRef = useRef(faceDisguiseModes); faceDisguiseModesRef.current = faceDisguiseModes; + const faceIdentitiesRef = useRef(faceIdentities); + faceIdentitiesRef.current = faceIdentities; + const onSelectionChangeRef = useRef(onSelectionChange); onSelectionChangeRef.current = onSelectionChange; + const onIdentityChangeRef = useRef(onIdentityChange); + onIdentityChangeRef.current = onIdentityChange; + const handleFacesDetected = useCallback( (detected: DetectedFace[]) => { setFaces(detected); @@ -64,20 +82,21 @@ export function DisguiseVideoWithFaceSelector({ const incomingIndices = new Set(detected.map((f) => f.index)); const currentSelected = selectedIndicesRef.current; const currentModes = faceDisguiseModesRef.current; + const currentIdentities = faceIdentitiesRef.current; - // Prune stale indices that are no longer detected. + // Prune stale selected indices. 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; + let selectionChanged = prunedSelected.size !== currentSelected.size; if (defaultSelectAll) { for (const idx of incomingIndices) { if (!currentSelected.has(idx)) { prunedSelected.add(idx); - changed = true; + selectionChanged = true; } } } @@ -89,12 +108,23 @@ export function DisguiseVideoWithFaceSelector({ } const modesChanged = prunedModes.size !== currentModes.size; - if (changed) setSelectedIndices(prunedSelected); - if (modesChanged) setFaceDisguiseModes(prunedModes); + // Prune identity assignments for vanished faces. + const prunedIdentities = new Map(); + for (const [idx, id] of currentIdentities) { + if (incomingIndices.has(idx)) prunedIdentities.set(idx, id); + } + const identitiesChanged = prunedIdentities.size !== currentIdentities.size; - if ((changed || modesChanged) && onSelectionChangeRef.current) { + if (selectionChanged) setSelectedIndices(prunedSelected); + if (modesChanged) setFaceDisguiseModes(prunedModes); + if (identitiesChanged) { + setFaceIdentities(prunedIdentities); + onIdentityChangeRef.current?.(prunedIdentities); + } + + if ((selectionChanged || modesChanged) && onSelectionChangeRef.current) { onSelectionChangeRef.current( - changed ? prunedSelected : currentSelected, + selectionChanged ? prunedSelected : currentSelected, modesChanged ? prunedModes : currentModes, ); } @@ -102,28 +132,38 @@ export function DisguiseVideoWithFaceSelector({ [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 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 handleModeChange = useCallback((index: number, mode: DisguiseMode) => { + setFaceDisguiseModes((prev) => { + const next = new Map(prev); + next.set(index, mode); + onSelectionChangeRef.current?.(selectedIndicesRef.current, next); + return next; + }); + }, []); + + const handleIdentityAssign = useCallback( + (faceIndex: number, identityId: string | null) => { + setFaceIdentities((prev) => { const next = new Map(prev); - next.set(index, mode); - onSelectionChangeRef.current?.(selectedIndicesRef.current, next); + if (identityId === null) { + next.delete(faceIndex); + } else { + next.set(faceIndex, identityId); + } + onIdentityChangeRef.current?.(next); return next; }); }, @@ -150,6 +190,9 @@ export function DisguiseVideoWithFaceSelector({ disguiseModes={faceDisguiseModes} globalDisguise={disguise} onDisguiseModeChange={showModePicker ? handleModeChange : undefined} + identities={identities} + faceIdentities={faceIdentities} + onIdentityAssign={identities ? handleIdentityAssign : undefined} /> ); diff --git a/features/video-studio/frontend-live/src/components/FaceSelectionOverlay.tsx b/features/video-studio/frontend-live/src/components/FaceSelectionOverlay.tsx index 6958eeb14..2a2e4176b 100644 --- a/features/video-studio/frontend-live/src/components/FaceSelectionOverlay.tsx +++ b/features/video-studio/frontend-live/src/components/FaceSelectionOverlay.tsx @@ -1,6 +1,14 @@ -import { useRef, useState, type CSSProperties, type ReactElement } from 'react'; +import { useState, type CSSProperties, type ReactElement } from 'react'; import type { DetectedFace, DisguiseMode } from './DisguiseVideoParticipantVideo'; +/** A saved identity that can be assigned to a detected face. */ +export interface FaceIdentity { + id: string; + name: string; + /** Optional thumbnail URL (e.g. a reference image). Falls back to initials avatar. */ + thumbnailUrl?: string; +} + export interface FaceSelectionOverlayProps { /** Detected faces from onFacesDetected — bounding boxes in canvas pixels. */ faces: DetectedFace[]; @@ -21,6 +29,18 @@ export interface FaceSelectionOverlayProps { * When undefined the mode badge is not interactive. */ onDisguiseModeChange?: (index: number, mode: DisguiseMode) => void; + /** + * Available identities to assign to faces. + * When provided, each face box shows an identity badge that opens a picker. + */ + identities?: FaceIdentity[]; + /** Current face index → identity ID assignments. */ + faceIdentities?: ReadonlyMap; + /** + * Called when an identity is assigned or cleared for a face. + * Pass `null` to clear the assignment. + */ + onIdentityAssign?: (faceIndex: number, identityId: string | null) => void; className?: string; style?: CSSProperties; } @@ -31,6 +51,65 @@ 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)'; +const PANEL_STYLE: CSSProperties = { + position: 'absolute', + top: 'calc(100% + 4px)', + right: 0, + width: 168, + maxHeight: 200, + overflowY: 'auto', + background: 'rgba(12,12,12,0.97)', + border: '1px solid rgba(255,255,255,0.18)', + borderRadius: 5, + zIndex: 20, + cursor: 'default', +}; + +const IDENTITY_ROW_BASE: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 7, + padding: '5px 8px', + cursor: 'pointer', + transition: 'background 0.1s', + fontSize: 11, + color: '#e0e0e0', +}; + +function getInitials(name: string): string { + return name + .split(' ') + .map((w) => w[0] ?? '') + .join('') + .toUpperCase() + .slice(0, 2); +} + +function AvatarCircle({ name, size = 26 }: { name: string; size?: number }): ReactElement { + return ( + + {getInitials(name)} + + ); +} + +type OpenPanel = { index: number; type: 'mode' | 'identity' } | null; + export function FaceSelectionOverlay({ faces, selectedIndices, @@ -40,16 +119,24 @@ export function FaceSelectionOverlay({ disguiseModes, globalDisguise = 'none', onDisguiseModeChange, + identities, + faceIdentities, + onIdentityAssign, 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); + const [openPanel, setOpenPanel] = useState(null); + + function closePanel(): void { setOpenPanel(null); } + + function togglePanel(index: number, type: 'mode' | 'identity'): void { + setOpenPanel((prev) => + prev?.index === index && prev.type === type ? null : { index, type }, + ); + } return (
id.id === assignedIdentityId) ?? null; + const modePanelOpen = openPanel?.index === index && openPanel.type === 'mode'; + const identityPanelOpen = openPanel?.index === index && openPanel.type === 'identity'; const left = `${(bounds.x / intrinsicWidth) * 100}%`; const top = `${(bounds.y / intrinsicHeight) * 100}%`; @@ -72,7 +162,7 @@ export function FaceSelectionOverlay({ return (
{ onToggle(index); }} + onClick={() => { closePanel(); onToggle(index); }} style={{ position: 'absolute', left, @@ -88,7 +178,7 @@ export function FaceSelectionOverlay({ transition: 'border-color 0.15s, box-shadow 0.15s', }} > - {/* Face index badge — top-left */} + {/* Face index — top-left */} + {/* Identity badge — top-right (only when identities provided) */} + {identities && onIdentityAssign && ( + { + e.stopPropagation(); + togglePanel(index, 'identity'); + }} + > + {assignedIdentity ? ( + <> + + + {assignedIdentity.name} + + + ) : ( + ? person + )} + + {/* Identity picker panel */} + {identityPanelOpen && ( +
{ e.stopPropagation(); }} + > + {/* Unassigned / clear option */} +
{ + onIdentityAssign(index, null); + closePanel(); + }} + > + + × + + Unassigned + {!assignedIdentity && ( + + )} +
+ + {identities.length === 0 ? ( +
+ No identities +
+ ) : ( + identities.map((identity) => { + const isAssigned = identity.id === assignedIdentityId; + return ( +
{ + onIdentityAssign(index, identity.id); + closePanel(); + }} + > + + + {identity.name} + + {isAssigned && ( + + )} +
+ ); + }) + )} +
+ )} +
+ )} + {/* Disguise mode badge — bottom-left */} {onDisguiseModeChange ? ( { e.stopPropagation(); - setOpenPickerIndex(pickerOpen ? -1 : index); + togglePanel(index, 'mode'); }} > {activeMode} - {pickerOpen && ( + {modePanelOpen && (