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:
parent
f0464b023b
commit
2a0ecea7fe
10 changed files with 745 additions and 49 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }[] = [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue