feat(video-studio): Add UI components, renderer logic, and backend integration for disguise mode with Demon and Succubus avatars

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 01:54:50 -07:00
parent f0464b023b
commit 2a0ecea7fe
10 changed files with 745 additions and 49 deletions

View file

@ -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',

View file

@ -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"

View file

@ -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 }[] = [

View file

@ -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:

View file

@ -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<number>,
faceDisguiseModes: ReadonlyMap<number, DisguiseMode>,
) => 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<number, string>) => 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<ReadonlyMap<number, DisguiseMode>>(
new Map(),
);
const [faceIdentities, setFaceIdentities] = useState<ReadonlyMap<number, string>>(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<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;
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<number, string>();
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}
/>
</div>
);

View file

@ -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<number, string>;
/**
* 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 (
<span
style={{
flexShrink: 0,
width: size,
height: size,
borderRadius: '50%',
background: 'linear-gradient(135deg, #9b59b6, #ff69b4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.38,
fontWeight: 700,
color: '#fff',
letterSpacing: 0.5,
}}
>
{getInitials(name)}
</span>
);
}
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<HTMLDivElement>(null);
const [openPanel, setOpenPanel] = useState<OpenPanel>(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 (
<div
ref={containerRef}
className={className}
style={{
position: 'absolute',
@ -62,7 +149,10 @@ export function FaceSelectionOverlay({
const { index, bounds } = face;
const selected = selectedIndices.has(index);
const activeMode = disguiseModes?.get(index) ?? globalDisguise;
const pickerOpen = openPickerIndex === index;
const assignedIdentityId = faceIdentities?.get(index) ?? null;
const assignedIdentity = identities?.find((id) => 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 (
<div
key={index}
onClick={() => { 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 */}
<span
style={{
position: 'absolute',
@ -106,6 +196,119 @@ export function FaceSelectionOverlay({
{`Face ${index}`}
</span>
{/* Identity badge — top-right (only when identities provided) */}
{identities && onIdentityAssign && (
<span
style={{
position: 'absolute',
top: 2,
right: 2,
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 10,
lineHeight: 1,
padding: '2px 5px 2px 3px',
background: assignedIdentity
? 'rgba(155,89,182,0.75)'
: 'rgba(0,0,0,0.55)',
color: '#fff',
borderRadius: 3,
cursor: 'pointer',
pointerEvents: 'all',
border: identityPanelOpen
? '1px solid rgba(255,105,180,0.8)'
: '1px solid transparent',
}}
onClick={(e) => {
e.stopPropagation();
togglePanel(index, 'identity');
}}
>
{assignedIdentity ? (
<>
<AvatarCircle name={assignedIdentity.name} size={14} />
<span style={{ maxWidth: 60, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{assignedIdentity.name}
</span>
</>
) : (
<span style={{ opacity: 0.7 }}>? person</span>
)}
{/* Identity picker panel */}
{identityPanelOpen && (
<div
style={PANEL_STYLE}
onClick={(e) => { e.stopPropagation(); }}
>
{/* Unassigned / clear option */}
<div
style={{
...IDENTITY_ROW_BASE,
borderBottom: '1px solid rgba(255,255,255,0.08)',
color: assignedIdentity ? '#aaa' : '#22ff88',
}}
onClick={() => {
onIdentityAssign(index, null);
closePanel();
}}
>
<span style={{
flexShrink: 0,
width: 26,
height: 26,
borderRadius: '50%',
border: '1.5px dashed rgba(255,255,255,0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
color: 'rgba(255,255,255,0.4)',
}}>
×
</span>
<span>Unassigned</span>
{!assignedIdentity && (
<span style={{ marginLeft: 'auto', color: '#22ff88', fontSize: 12 }}></span>
)}
</div>
{identities.length === 0 ? (
<div style={{ ...IDENTITY_ROW_BASE, color: '#666', cursor: 'default' }}>
No identities
</div>
) : (
identities.map((identity) => {
const isAssigned = identity.id === assignedIdentityId;
return (
<div
key={identity.id}
style={{
...IDENTITY_ROW_BASE,
background: isAssigned ? 'rgba(155,89,182,0.2)' : undefined,
}}
onClick={() => {
onIdentityAssign(index, identity.id);
closePanel();
}}
>
<AvatarCircle name={identity.name} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{identity.name}
</span>
{isAssigned && (
<span style={{ flexShrink: 0, color: '#22ff88', fontSize: 12 }}></span>
)}
</div>
);
})
)}
</div>
)}
</span>
)}
{/* Disguise mode badge — bottom-left */}
{onDisguiseModeChange ? (
<span
@ -125,20 +328,20 @@ export function FaceSelectionOverlay({
}}
onClick={(e) => {
e.stopPropagation();
setOpenPickerIndex(pickerOpen ? -1 : index);
togglePanel(index, 'mode');
}}
>
{activeMode}
{pickerOpen && (
{modePanelOpen && (
<select
value={activeMode}
onChange={(e) => {
e.stopPropagation();
onDisguiseModeChange(index, e.target.value as DisguiseMode);
setOpenPickerIndex(-1);
closePanel();
}}
onClick={(e) => { e.stopPropagation(); }}
onBlur={() => { setOpenPickerIndex(-1); }}
onBlur={closePanel}
autoFocus
style={{
position: 'absolute',

View file

@ -5,7 +5,7 @@ export type {
DisguiseMode,
} from './components/DisguiseVideoParticipantVideo';
export { FaceSelectionOverlay } from './components/FaceSelectionOverlay';
export type { FaceSelectionOverlayProps } from './components/FaceSelectionOverlay';
export type { FaceSelectionOverlayProps, FaceIdentity } from './components/FaceSelectionOverlay';
export { DisguiseVideoWithFaceSelector } from './components/DisguiseVideoWithFaceSelector';
export type { DisguiseVideoWithFaceSelectorProps } from './components/DisguiseVideoWithFaceSelector';
export { useFaceDetection } from './hooks/useFaceDetection';

View file

@ -0,0 +1,171 @@
import type { NormalizedLandmark } from '@mediapipe/tasks-vision';
import type { RendererScratch } from './RendererPool';
const HORN_DARK = '#1C0000';
const HORN_MID = '#7A0000';
const HORN_RIM = '#C41E00';
const EYE_CORE = '#FF5500';
const BROW_INK = '#150000';
export function drawDemonMask(
ctx: CanvasRenderingContext2D,
scratch: RendererScratch,
landmarks: NormalizedLandmark[],
w: number,
h: number,
): void {
function pt(idx: number): [number, number] | null {
const lm = landmarks[idx];
if (!lm) return null;
return [lm.x * w, lm.y * h];
}
const oc = scratch.ctx;
oc.clearRect(0, 0, w, h);
const leftIris = pt(468);
const rightIris = pt(473);
const forehead = pt(10);
const chin = pt(152);
if (!leftIris || !rightIris || !forehead) return;
const interEye = Math.hypot(rightIris[0] - leftIris[0], rightIris[1] - leftIris[1]);
const cx = (leftIris[0] + rightIris[0]) / 2;
const irisMidY = (leftIris[1] + rightIris[1]) / 2;
const faceTop = forehead[1];
const faceBot = chin ? chin[1] : irisMidY + interEye * 1.5;
const faceMidY = (faceTop + faceBot) / 2;
const faceH = faceBot - faceTop;
// ── 1. Dark crimson face shadow ───────────────────────────────────────────
const shadowGrad = oc.createRadialGradient(cx, faceMidY, 0, cx, faceMidY, faceH * 0.68);
shadowGrad.addColorStop(0, 'rgba(50,0,0,0.12)');
shadowGrad.addColorStop(0.5, 'rgba(20,0,0,0.38)');
shadowGrad.addColorStop(1, 'rgba(8,0,0,0.64)');
oc.fillStyle = shadowGrad;
oc.beginPath();
oc.ellipse(cx, faceMidY, faceH * 0.54, faceH * 0.68, 0, 0, Math.PI * 2);
oc.fill();
// ── 2. Horns ──────────────────────────────────────────────────────────────
// signX: +1 for left horn (inner edge toward +x / center), -1 for right horn
function drawHorn(baseX: number, baseY: number, tipX: number, tipY: number, signX: number): void {
const halfW = interEye * 0.075;
const hornGrad = oc.createLinearGradient(baseX, baseY, tipX, tipY);
hornGrad.addColorStop(0, HORN_DARK);
hornGrad.addColorStop(0.45, HORN_MID);
hornGrad.addColorStop(1, HORN_DARK);
oc.fillStyle = hornGrad;
oc.beginPath();
// Inner base (toward face center)
oc.moveTo(baseX + signX * halfW, baseY);
// Inner edge — curves inward toward tip
oc.bezierCurveTo(
baseX + signX * halfW * 0.6, baseY - interEye * 0.45,
tipX + signX * halfW * 0.4, tipY + interEye * 0.18,
tipX, tipY,
);
// Outer edge — curves back to outer base
oc.bezierCurveTo(
tipX - signX * halfW * 0.4, tipY + interEye * 0.18,
baseX - signX * halfW * 1.4, baseY - interEye * 0.28,
baseX - signX * halfW, baseY,
);
oc.closePath();
oc.fill();
// Lit inner-edge rim
oc.strokeStyle = HORN_RIM;
oc.lineWidth = Math.max(1, interEye * 0.018);
oc.lineCap = 'round';
oc.beginPath();
oc.moveTo(baseX + signX * halfW, baseY);
oc.bezierCurveTo(
baseX + signX * halfW * 0.6, baseY - interEye * 0.45,
tipX + signX * halfW * 0.4, tipY + interEye * 0.18,
tipX, tipY,
);
oc.stroke();
}
// Left horn — sweeps up and outward from forehead
drawHorn(
cx - interEye * 0.26, faceTop - interEye * 0.04,
cx - interEye * 0.62, faceTop - interEye * 1.22,
+1,
);
// Right horn — mirror
drawHorn(
cx + interEye * 0.26, faceTop - interEye * 0.04,
cx + interEye * 0.62, faceTop - interEye * 1.22,
-1,
);
// ── 3. Eye socket shadow ──────────────────────────────────────────────────
for (const iris of [leftIris, rightIris] as [number, number][]) {
const socketGrad = oc.createRadialGradient(
iris[0], iris[1] - interEye * 0.08, 0,
iris[0], iris[1] - interEye * 0.08, interEye * 0.26,
);
socketGrad.addColorStop(0, 'rgba(10,0,0,0)');
socketGrad.addColorStop(0.35, 'rgba(10,0,0,0)');
socketGrad.addColorStop(0.75, 'rgba(5,0,0,0.45)');
socketGrad.addColorStop(1, 'rgba(5,0,0,0)');
oc.fillStyle = socketGrad;
oc.beginPath();
oc.ellipse(iris[0], iris[1], interEye * 0.28, interEye * 0.22, 0, 0, Math.PI * 2);
oc.fill();
}
// ── 4. Glowing fiery eyes ─────────────────────────────────────────────────
for (const iris of [leftIris, rightIris] as [number, number][]) {
// Outer diffuse glow
const glow = oc.createRadialGradient(iris[0], iris[1], 0, iris[0], iris[1], interEye * 0.34);
glow.addColorStop(0, 'rgba(255,80,0,0.65)');
glow.addColorStop(0.45, 'rgba(220,50,0,0.28)');
glow.addColorStop(1, 'rgba(200,20,0,0)');
oc.fillStyle = glow;
oc.beginPath();
oc.ellipse(iris[0], iris[1], interEye * 0.34, interEye * 0.28, 0, 0, Math.PI * 2);
oc.fill();
// Bright iris
oc.fillStyle = EYE_CORE;
oc.beginPath();
oc.ellipse(iris[0], iris[1], interEye * 0.14, interEye * 0.11, 0, 0, Math.PI * 2);
oc.fill();
// Vertical slit pupil
oc.fillStyle = '#200000';
oc.beginPath();
oc.ellipse(iris[0], iris[1], interEye * 0.03, interEye * 0.09, 0, 0, Math.PI * 2);
oc.fill();
}
// ── 5. Sharp angular brow ridges ─────────────────────────────────────────
oc.strokeStyle = BROW_INK;
oc.lineWidth = Math.max(2.5, interEye * 0.068);
oc.lineCap = 'round';
oc.lineJoin = 'miter';
const browY = irisMidY - interEye * 0.56;
const browPeakY = browY - interEye * 0.16;
// Left brow — V-peak closest to nose bridge, descends steeply outward
oc.beginPath();
oc.moveTo(cx - interEye * 0.06, browY + interEye * 0.04);
oc.lineTo(cx - interEye * 0.30, browPeakY);
oc.lineTo(cx - interEye * 0.68, browY + interEye * 0.10);
oc.stroke();
// Right brow — mirror
oc.beginPath();
oc.moveTo(cx + interEye * 0.06, browY + interEye * 0.04);
oc.lineTo(cx + interEye * 0.30, browPeakY);
oc.lineTo(cx + interEye * 0.68, browY + interEye * 0.10);
oc.stroke();
ctx.drawImage(scratch.canvas, 0, 0);
}

View file

@ -0,0 +1,259 @@
import type { NormalizedLandmark } from '@mediapipe/tasks-vision';
import type { RendererScratch } from './RendererPool';
const HORN_DARK = '#1A0030';
const HORN_MID = '#6B00A8';
const HORN_RIM = '#B040E0';
const EYE_CORE = '#EE00FF';
const WING_DARK = '#2A003D';
const WING_MID = '#7B00BB';
const EAR_FILL = 'rgba(48, 0, 72, 0.88)';
const EAR_RIM = '#A030CC';
const SPARKLE = '#FF66CC';
export function drawSuccubusMask(
ctx: CanvasRenderingContext2D,
scratch: RendererScratch,
landmarks: NormalizedLandmark[],
w: number,
h: number,
): void {
function pt(idx: number): [number, number] | null {
const lm = landmarks[idx];
if (!lm) return null;
return [lm.x * w, lm.y * h];
}
const oc = scratch.ctx;
oc.clearRect(0, 0, w, h);
const leftIris = pt(468);
const rightIris = pt(473);
const forehead = pt(10);
const chin = pt(152);
// Ear tragion landmarks (side-of-head contact point)
const leftEarPt = pt(234);
const rightEarPt = pt(454);
if (!leftIris || !rightIris || !forehead) return;
const interEye = Math.hypot(rightIris[0] - leftIris[0], rightIris[1] - leftIris[1]);
const cx = (leftIris[0] + rightIris[0]) / 2;
const irisMidY = (leftIris[1] + rightIris[1]) / 2;
const faceTop = forehead[1];
const faceBot = chin ? chin[1] : irisMidY + interEye * 1.5;
const faceMidY = (faceTop + faceBot) / 2;
const faceH = faceBot - faceTop;
// ── 1. Deep violet face tint ─────────────────────────────────────────────
const tintGrad = oc.createRadialGradient(cx, faceMidY, 0, cx, faceMidY, faceH * 0.68);
tintGrad.addColorStop(0, 'rgba(70,0,100,0.10)');
tintGrad.addColorStop(0.5, 'rgba(55,0,85,0.30)');
tintGrad.addColorStop(1, 'rgba(30,0,55,0.58)');
oc.fillStyle = tintGrad;
oc.beginPath();
oc.ellipse(cx, faceMidY, faceH * 0.54, faceH * 0.68, 0, 0, Math.PI * 2);
oc.fill();
// ── 2. Pointed ears ───────────────────────────────────────────────────────
// signX: -1 = left ear extends leftward, +1 = right ear extends rightward
function drawPointedEar(earX: number, earY: number, signX: number): void {
const eH = interEye * 0.34;
const eW = interEye * 0.13;
oc.fillStyle = EAR_FILL;
oc.beginPath();
oc.moveTo(earX, earY + eH * 0.28); // lobe bottom
// Outer edge sweeps up and slightly out
oc.bezierCurveTo(
earX + signX * eW, earY + eH * 0.05,
earX + signX * eW * 1.25, earY - eH * 0.32,
earX + signX * eW * 0.45, earY - eH, // sharp tip
);
// Inner edge comes back down
oc.bezierCurveTo(
earX + signX * eW * 0.08, earY - eH * 0.5,
earX - signX * eW * 0.05, earY - eH * 0.08,
earX, earY + eH * 0.28,
);
oc.closePath();
oc.fill();
oc.strokeStyle = EAR_RIM;
oc.lineWidth = Math.max(1, interEye * 0.02);
oc.lineCap = 'round';
oc.stroke();
}
const leftEar = leftEarPt ?? ([cx - interEye * 0.88, irisMidY - interEye * 0.08] as [number, number]);
const rightEar = rightEarPt ?? ([cx + interEye * 0.88, irisMidY - interEye * 0.08] as [number, number]);
drawPointedEar(leftEar[0], leftEar[1], -1);
drawPointedEar(rightEar[0], rightEar[1], +1);
// ── 3. Elegant curved horns ───────────────────────────────────────────────
// Smaller than demon, sweep outward with a graceful curve (satyress style).
// signX: +1 = left horn inner toward +x, -1 = right horn inner toward -x
function drawHorn(baseX: number, baseY: number, tipX: number, tipY: number, signX: number): void {
const halfW = interEye * 0.058;
const hornGrad = oc.createLinearGradient(baseX, baseY, tipX, tipY);
hornGrad.addColorStop(0, HORN_DARK);
hornGrad.addColorStop(0.40, HORN_MID);
hornGrad.addColorStop(1, HORN_DARK);
oc.fillStyle = hornGrad;
oc.beginPath();
oc.moveTo(baseX + signX * halfW, baseY);
// Inner (center-facing) edge
oc.bezierCurveTo(
baseX + signX * halfW * 0.5, baseY - interEye * 0.38,
tipX + signX * halfW * 0.3, tipY + interEye * 0.14,
tipX, tipY,
);
// Outer (temple-facing) edge
oc.bezierCurveTo(
tipX - signX * halfW * 0.3, tipY + interEye * 0.14,
baseX - signX * halfW * 1.5, baseY - interEye * 0.22,
baseX - signX * halfW, baseY,
);
oc.closePath();
oc.fill();
// Rim highlight on inner edge
oc.strokeStyle = HORN_RIM;
oc.lineWidth = Math.max(1, interEye * 0.016);
oc.lineCap = 'round';
oc.beginPath();
oc.moveTo(baseX + signX * halfW, baseY);
oc.bezierCurveTo(
baseX + signX * halfW * 0.5, baseY - interEye * 0.38,
tipX + signX * halfW * 0.3, tipY + interEye * 0.14,
tipX, tipY,
);
oc.stroke();
}
// Horns sit closer to center, tips swept further outward than demon
drawHorn(
cx - interEye * 0.20, faceTop - interEye * 0.02,
cx - interEye * 0.58, faceTop - interEye * 0.92,
+1,
);
drawHorn(
cx + interEye * 0.20, faceTop - interEye * 0.02,
cx + interEye * 0.58, faceTop - interEye * 0.92,
-1,
);
// ── 4. Bat-wing filigree at outer temples ────────────────────────────────
// Simple two-lobe bat wing drawn just outside each eye corner.
function drawWingFiligree(originX: number, originY: number, signX: number): void {
const wL = interEye * 0.38; // wing spread
oc.fillStyle = WING_DARK;
oc.strokeStyle = WING_MID;
oc.lineWidth = Math.max(1, interEye * 0.016);
oc.lineCap = 'round';
// Upper lobe
oc.beginPath();
oc.moveTo(originX, originY);
oc.bezierCurveTo(
originX + signX * wL * 0.28, originY - interEye * 0.28,
originX + signX * wL * 0.62, originY - interEye * 0.18,
originX + signX * wL * 0.70, originY + interEye * 0.04,
);
// Inner notch
oc.bezierCurveTo(
originX + signX * wL * 0.50, originY - interEye * 0.04,
originX + signX * wL * 0.28, originY + interEye * 0.06,
originX, originY,
);
oc.closePath();
oc.fill();
oc.stroke();
// Lower lobe (smaller, droops down)
oc.beginPath();
oc.moveTo(originX, originY);
oc.bezierCurveTo(
originX + signX * wL * 0.22, originY + interEye * 0.10,
originX + signX * wL * 0.50, originY + interEye * 0.16,
originX + signX * wL * 0.48, originY + interEye * 0.28,
);
oc.bezierCurveTo(
originX + signX * wL * 0.32, originY + interEye * 0.18,
originX + signX * wL * 0.14, originY + interEye * 0.12,
originX, originY,
);
oc.closePath();
oc.fill();
oc.stroke();
}
// Wing origin: outer corner of each eye
const leftOuterEye = pt(33) ?? ([leftIris[0] - interEye * 0.28, leftIris[1]] as [number, number]);
const rightOuterEye = pt(263) ?? ([rightIris[0] + interEye * 0.28, rightIris[1]] as [number, number]);
drawWingFiligree(leftOuterEye[0], leftOuterEye[1], -1);
drawWingFiligree(rightOuterEye[0], rightOuterEye[1], +1);
// ── 5. Glowing violet eyes ────────────────────────────────────────────────
for (const iris of [leftIris, rightIris] as [number, number][]) {
// Diffuse outer glow
const glow = oc.createRadialGradient(iris[0], iris[1], 0, iris[0], iris[1], interEye * 0.32);
glow.addColorStop(0, 'rgba(200,0,255,0.62)');
glow.addColorStop(0.45, 'rgba(150,0,220,0.26)');
glow.addColorStop(1, 'rgba(80,0,150,0)');
oc.fillStyle = glow;
oc.beginPath();
oc.ellipse(iris[0], iris[1], interEye * 0.32, interEye * 0.26, 0, 0, Math.PI * 2);
oc.fill();
// Bright iris
oc.fillStyle = EYE_CORE;
oc.beginPath();
oc.ellipse(iris[0], iris[1], interEye * 0.13, interEye * 0.10, 0, 0, Math.PI * 2);
oc.fill();
// Vertical slit pupil
oc.fillStyle = '#0A0010';
oc.beginPath();
oc.ellipse(iris[0], iris[1], interEye * 0.025, interEye * 0.085, 0, 0, Math.PI * 2);
oc.fill();
}
// ── 6. Sparkle accents at outer eye corners ───────────────────────────────
function drawSparkle(sx: number, sy: number, r: number): void {
oc.strokeStyle = SPARKLE;
oc.lineWidth = Math.max(1, r * 0.28);
oc.lineCap = 'round';
for (let i = 0; i < 4; i++) {
const angle = (Math.PI * i) / 4;
oc.beginPath();
oc.moveTo(sx - Math.cos(angle) * r, sy - Math.sin(angle) * r);
oc.lineTo(sx + Math.cos(angle) * r, sy + Math.sin(angle) * r);
oc.stroke();
}
}
const sparkR = Math.max(3, interEye * 0.055);
// Three sparkles per eye — staggered sizes, scattered outward-upward
const sparkOffsets: [number, number, number][] = [
[-0.38, -0.28, 1.00],
[-0.55, -0.12, 0.65],
[-0.28, -0.44, 0.50],
];
for (const iris of [leftIris, rightIris] as [number, number][]) {
const signX = iris === leftIris ? -1 : 1;
for (const [dx, dy, scale] of sparkOffsets) {
oc.globalAlpha = 0.80 * scale;
drawSparkle(
iris[0] + signX * dx * interEye,
iris[1] + dy * interEye,
sparkR * scale,
);
}
}
oc.globalAlpha = 1;
ctx.drawImage(scratch.canvas, 0, 0);
}

View file

@ -15,7 +15,7 @@ const LEATHER_BLACK = '#120E0E';
export function applyHeadCircleFallback(
ctx: CanvasRenderingContext2D,
video: HTMLVideoElement,
disguise: 'blur' | 'mask' | 'masquerade' | 'anonymous' | 'egirl' | 'none',
disguise: 'blur' | 'mask' | 'masquerade' | 'anonymous' | 'egirl' | 'demon' | 'succubus' | 'none',
circle: HeadCircle,
w: number,
h: number,
@ -45,7 +45,10 @@ export function applyHeadCircleFallback(
// All mask modes: solid head circle in the appropriate base colour.
// Detailed features (eye holes, zipper, decorations) are skipped — the face
// isn't visible at this orientation so they'd render in the wrong position.
const baseColour = disguise === 'mask' ? LEATHER_BLACK : LEATHER_BLACK;
const baseColour =
disguise === 'demon' ? '#1C0000' :
disguise === 'succubus' ? '#1A0030' :
LEATHER_BLACK;
ctx.save();
ctx.beginPath();