/** * CharacterWizard — 7-step professional character creation wizard. * * Steps: Identity, Body, Face, Hair, Profession, Outfit, Generate * * Features: * - Quick Create mode (name + gender + profession → generate) * - Full Studio mode (all 7 steps with presets + advanced) * - NSFW gated by global setting (Romance & Roleplay 18+) * - Keeps 3 generation modes: Design Character, From Reference, Face + Style * - Live character card preview (right panel) * - Prompt builder assembles all choices into diffusion-friendly prompt */ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { X, ChevronRight, ChevronLeft, Sparkles, Loader2, User, Palette, Camera, CheckCircle2, EyeOff, Shuffle, Zap, Layers, Check, ChevronDown, Star, Flame, RotateCcw, Search, } from 'lucide-react'; import { DEFAULT_DRAFT, WIZARD_STEPS, SKIN_TONES, EYE_COLORS, EYE_SHAPES, FACE_PRESETS, HAIR_STYLES, HAIR_COLORS, OUTFIT_STYLES_SFW, OUTFIT_STYLES_NSFW, COLOR_PALETTE, ACCESSORIES, POSES, BACKGROUNDS, LIGHTING_OPTIONS, EXPRESSIONS, NSFW_POSES, NSFW_BACKGROUNDS, LINGERIE_GARMENTS, LINGERIE_STYLES, LINGERIE_FABRICS, LINGERIE_BRANDS, LINGERIE_EXTRAS, findPreset, } from './wizardTypes'; import { PROFESSIONS, getProfession } from './professionRegistry'; import { loadVibeTab, saveVibeTab } from '../vibeTabPersistence'; import { useGenerateAvatars } from '../useGenerateAvatars'; import { useAvatarGallery } from '../useAvatarGallery'; import { loadAvatarSettings, resolveCheckpoint } from '../AvatarSettingsPanel'; import { resolveFileUrl } from '../../resolveFileUrl'; import { AVATAR_VIBE_PRESETS, CHARACTER_STYLE_PRESETS, GENDER_OPTIONS, } from '../galleryTypes'; // --------------------------------------------------------------------------- // Shared UI helpers // --------------------------------------------------------------------------- function PillButton({ label, selected, onClick, icon, accent = 'purple' }) { const colors = { purple: selected ? 'bg-purple-500/15 text-purple-300 border-purple-500/30' : '', rose: selected ? 'bg-rose-500/15 text-rose-300 border-rose-500/30' : '', }; return (); } function ColorSwatch({ color, selected, onClick, label }) { return (); } // --------------------------------------------------------------------------- // Step customization check — returns true if the user changed anything from defaults // --------------------------------------------------------------------------- function isStepCustomized(step, draft) { const d = DEFAULT_DRAFT; switch (step) { case 0: return draft.gender !== d.gender || draft.ageRange !== d.ageRange || draft.name !== d.name; case 1: return draft.bodyType !== d.bodyType || draft.skinTone !== d.skinTone || draft.posture !== d.posture; case 2: return draft.facePreset !== d.facePreset || draft.eyeColor !== d.eyeColor || draft.expression !== d.expression; case 3: return draft.hairStyle !== d.hairStyle || draft.hairColor !== d.hairColor; case 4: return draft.professionId !== d.professionId; case 5: return draft.outfitStyle !== d.outfitStyle || draft.accessories.length > 0; case 6: return draft.portraitType !== d.portraitType || draft.background !== d.background; default: return false; } } // --------------------------------------------------------------------------- // Prompt builder // --------------------------------------------------------------------------- function buildPrompt(d, nsfwEnabled) { const parts = []; // Portrait framing const framing = { headshot: 'headshot portrait, face closeup', half_body: 'upper body portrait, from waist up', mid_body: 'mid-body portrait, from head to hips, upper body and hip area visible, frame ends at upper thighs', full_body: 'full body portrait', }; parts.push(framing[d.portraitType] || framing.headshot); // Single subject + camera parts.push('single person, front-facing, looking at camera'); // Gender + age const genderWord = d.gender === 'neutral' ? 'androgynous person' : d.gender === 'female' ? 'woman' : 'man'; const ageWord = d.ageRange === 'young_adult' ? 'young adult' : d.ageRange === 'mature' ? 'mature' : 'adult'; parts.push(`${ageWord} ${genderWord}`); // Body parts.push(`${d.bodyType} build`); parts.push(`${d.posture} posture`); // Skin const skin = findPreset(SKIN_TONES, d.skinTone); if (skin) parts.push(skin.prompt); // Face const face = findPreset(FACE_PRESETS, d.facePreset); if (face) parts.push(face.prompt); // Facial detail sliders (only add when notably different from default 50) if (d.jawline >= 70) parts.push('strong defined jawline'); else if (d.jawline <= 30) parts.push('soft rounded jawline'); if (d.lips >= 70) parts.push('full prominent lips'); else if (d.lips <= 30) parts.push('thin delicate lips'); if (d.browDefinition >= 70) parts.push('strong defined eyebrows'); else if (d.browDefinition <= 30) parts.push('soft subtle eyebrows'); // Eyes const eyeC = findPreset(EYE_COLORS, d.eyeColor); if (eyeC) parts.push(eyeC.prompt); const eyeS = findPreset(EYE_SHAPES, d.eyeShape); if (eyeS) parts.push(eyeS.prompt); // Expression const expr = findPreset(EXPRESSIONS, d.expression); if (expr) parts.push(expr.prompt); // Hair const hairS = findPreset(HAIR_STYLES, d.hairStyle); if (hairS) parts.push(hairS.prompt); const hairC = findPreset(HAIR_COLORS, d.hairColor); if (hairC) parts.push(hairC.prompt); // Outfit const outfit = findPreset([...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW], d.outfitStyle); if (outfit) parts.push(outfit.prompt); // Lingerie builder (additive — iterates over all active garments for multi-piece outfits) if (d.outfitStyle === 'lingerie' || d.outfitStyle === 'boudoir_wear') { const activeGarments = d.lingerieActiveGarments ?? (d.lingerieType ? [d.lingerieType] : []); const selections = d.lingerieSelections ?? {}; for (const gId of activeGarments) { const garment = LINGERIE_GARMENTS.find((g) => g.id === gId); if (garment) parts.push(garment.prompt); const sel = selections[gId]; if (sel?.style) { const styles = LINGERIE_STYLES[gId]; const style = styles?.find((s) => s.id === sel.style); if (style) parts.push(style.prompt); } if (sel?.fabric) { const fabric = LINGERIE_FABRICS.find((f) => f.id === sel.fabric); if (fabric) parts.push(fabric.prompt); } if (sel?.brand) { const brand = LINGERIE_BRANDS.find((b) => b.id === sel.brand); if (brand) parts.push(brand.prompt); } } for (const extId of d.lingerieExtras ?? []) { const ext = LINGERIE_EXTRAS.find((e) => e.id === extId); if (ext) parts.push(ext.prompt); } } // Profession context (influences scene/outfit/pose) const prof = getProfession(d.professionId); if (prof && prof.id !== 'custom') parts.push(prof.label); // Colors — bind to specific garment pieces when lingerie multi-piece is active const primC = findPreset(COLOR_PALETTE, d.outfitPrimaryColor); const secC = findPreset(COLOR_PALETTE, d.outfitSecondaryColor); const isLingerie = d.outfitStyle === 'lingerie' || d.outfitStyle === 'boudoir_wear'; const activeGarmentsForColor = d.lingerieActiveGarments ?? (d.lingerieType ? [d.lingerieType] : []); const hasTopPiece = isLingerie && activeGarmentsForColor.includes('top'); const hasBottomPiece = isLingerie && activeGarmentsForColor.includes('bottom'); if (hasTopPiece && hasBottomPiece && primC && secC) { // Primary → top piece, Secondary → bottom piece parts.push(`${primC.prompt} colored top`); parts.push(`${secC.prompt} colored bottom`); } else if (hasTopPiece && hasBottomPiece && primC) { parts.push(`${primC.prompt} colored top`); } else if (hasTopPiece && hasBottomPiece && secC) { parts.push(`${secC.prompt} colored bottom`); } else { // Generic outfit coloring (non-lingerie, or single-piece/set) if (primC && secC) parts.push(`${primC.prompt} and ${secC.prompt} color scheme`); else if (primC) parts.push(`${primC.prompt} colored outfit`); else if (secC) parts.push(`${secC.prompt} accents`); } // Accessories for (const accId of d.accessories) { const acc = findPreset(ACCESSORIES, accId); if (acc) parts.push(acc.prompt); } // Polish / makeup const polishMap = { natural: 'natural look', light_makeup: 'light professional makeup', formal: 'formal polished appearance, refined makeup', }; parts.push(polishMap[d.polish] || ''); // Pose + background (check NSFW-specific poses/backgrounds first, then standard) const pose = findPreset(NSFW_POSES, d.pose) || findPreset(POSES, d.pose); if (pose) parts.push(pose.prompt); const bg = findPreset(NSFW_BACKGROUNDS, d.background) || findPreset(BACKGROUNDS, d.background); if (bg) parts.push(bg.prompt); // Lighting const light = findPreset(LIGHTING_OPTIONS, d.lighting); if (light) parts.push(light.prompt); // NSFW modifiers (gated) if (nsfwEnabled && d.nsfwExposure) { const nsfwMap = { suggestive: 'suggestive pose, revealing outfit, tastefully showing skin, sensual', clothed_revealing: 'clothed but revealing, deep neckline, exposed skin, alluring', partial_nudity: 'partial nudity, exposed skin, provocative sensual pose, alluring', topless: 'topless, implied nude, exposed upper body, sensual artistic pose', full_nude: 'fully nude, naked body, sensual erotic pose, artistic nude', explicit: 'fully nude, explicit adult content, naked body, sensual erotic pose, anatomically detailed', }; parts.push(nsfwMap[d.nsfwExposure] || ''); // Intensity amplifier const intensity = d.nsfwIntensity ?? 5; if (intensity >= 8) parts.push('extremely sensual, raw, uninhibited'); else if (intensity >= 5) parts.push('sensual, inviting'); // Pose — unified: quick-pick categories or specific NSFW pose presets if (d.nsfwPose === 'subtle') parts.push('subtle teasing pose, coy expression'); else if (d.nsfwPose === 'confident') parts.push('confident provocative display, bold body language'); else if (d.nsfwPose === 'intimate') parts.push('intimate close pose, bedroom eyes, inviting'); else if (d.nsfwPose) { const posePreset = findPreset(NSFW_POSES, d.nsfwPose); if (posePreset) parts.push(posePreset.prompt); } // Fantasy tone if (d.nsfwFantasyTone === 'romantic') parts.push('romantic tender mood, soft warm tones'); if (d.nsfwFantasyTone === 'seductive') parts.push('seductive alluring expression, smoldering gaze'); if (d.nsfwFantasyTone === 'dramatic') parts.push('dramatic intense mood, powerful commanding presence'); // Dominance if (d.nsfwDominanceStyle === 'soft') parts.push('gentle submissive energy, yielding'); if (d.nsfwDominanceStyle === 'balanced') parts.push('balanced confident energy, natural relaxed poise'); if (d.nsfwDominanceStyle === 'strong') parts.push('dominant commanding presence, powerful stance, in control'); } // Quality + detail level const realismWord = d.realism > 70 ? 'photorealistic' : d.realism > 40 ? 'semi-realistic' : 'stylized'; const detailWord = d.detailLevel > 75 ? 'extremely detailed, intricate textures' : d.detailLevel > 50 ? 'highly detailed' : d.detailLevel > 25 ? 'moderate detail' : 'simplified, clean lines'; parts.push(`${realismWord}, ${detailWord}, 8k resolution`); return parts.filter(Boolean).join(', '); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function CharacterWizard({ backendUrl, apiKey, globalModelImages, onClose, onSaveGeneration }) { const DRAFT_STORAGE_KEY = 'homepilot_wizard_draft'; const STEP_STORAGE_KEY = 'homepilot_wizard_step'; // Restore draft + step from sessionStorage (survives refresh, cleared on tab close) const loadSavedDraft = useCallback(() => { try { const raw = sessionStorage.getItem(DRAFT_STORAGE_KEY); if (raw) return { ...DEFAULT_DRAFT, ...JSON.parse(raw) }; } catch { /* ignore corrupt data */ } return { ...DEFAULT_DRAFT }; }, []); const loadSavedStep = useCallback(() => { try { const raw = sessionStorage.getItem(STEP_STORAGE_KEY); if (raw) { const n = Number(raw); if (n >= 0 && n <= 6) return n; } } catch { /* ignore */ } return 0; }, []); const [wizardMode, setWizardMode] = useState('studio'); const [step, setStep] = useState(loadSavedStep); const [draft, setDraft] = useState(loadSavedDraft); const [advancedOpen, setAdvancedOpen] = useState(false); const [selectedResultIndex, setSelectedResultIndex] = useState(null); const [showNsfw, setShowNsfw] = useState(false); const [showCountMenu, setShowCountMenu] = useState(false); const [count, setCount] = useState(4); const [professionSearch, setProfessionSearch] = useState(''); // Persist draft + step to sessionStorage on every change useEffect(() => { try { sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)); } catch { /* quota */ } }, [draft]); useEffect(() => { try { sessionStorage.setItem(STEP_STORAGE_KEY, String(step)); } catch { /* quota */ } }, [step]); // Toast const [toast, setToast] = useState(null); const toastTimer = useRef(); const showToast = useCallback((message, type = 'info') => { clearTimeout(toastTimer.current); setToast({ message, type }); toastTimer.current = setTimeout(() => setToast(null), 4000); }, []); // File input for reference upload const fileInputRef = useRef(null); // NSFW mode — read directly from localStorage so it stays reactive. // The prop from the parent is stale (captured at mount time), so we ignore it // and poll localStorage to pick up changes made in the Settings panel. const readNsfw = useCallback(() => { try { return localStorage.getItem('homepilot_nsfw_mode') === 'true'; } catch { return false; } }, []); const [nsfwEnabled, setNsfwEnabled] = useState(readNsfw); useEffect(() => { const sync = () => setNsfwEnabled(readNsfw()); // Re-read on window focus (e.g. switching tabs/windows) window.addEventListener('focus', sync); // Listen for cross-tab localStorage changes window.addEventListener('storage', sync); // Poll every 1s to catch same-tab settings changes (localStorage // writes within the same tab do NOT fire the 'storage' event) const interval = setInterval(sync, 1000); return () => { window.removeEventListener('focus', sync); window.removeEventListener('storage', sync); clearInterval(interval); }; }, [readNsfw]); // Standard / Spicy tab (for Step 6 outfit + Step 7 vibe selection) — persisted const [vibeTab, _setVibeTab] = useState(loadVibeTab); const setVibeTab = useCallback((tab) => { _setVibeTab(tab); saveVibeTab(tab); }, []); // Reset to standard tab if NSFW gets disabled mid-session useEffect(() => { if (!nsfwEnabled && vibeTab === 'spicy') setVibeTab('standard'); }, [nsfwEnabled, vibeTab, setVibeTab]); // Hooks const gen = useGenerateAvatars(backendUrl, apiKey); const gallery = useAvatarGallery(); // Update draft const update = useCallback((changes) => { setDraft((prev) => ({ ...prev, ...changes })); }, []); // Randomize appearance settings (body, face, hair, outfit) // When spicy mode is active, also randomize NSFW-specific parameters const randomizeAppearance = useCallback(() => { const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; const isSpicyMode = nsfwEnabled && vibeTab === 'spicy'; // Outfit presets for Step 5 (Outfit) const outfitPool = isSpicyMode ? [...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW] : OUTFIT_STYLES_SFW; // Character style presets for Step 6 (Generate) and Quick Create style vibes const stylePool = CHARACTER_STYLE_PRESETS.filter((s) => isSpicyMode ? s.category === 'spicy' : s.category === 'standard'); const posePool = isSpicyMode ? NSFW_POSES : POSES; const bgPool = isSpicyMode ? NSFW_BACKGROUNDS : BACKGROUNDS; // Pick from both pools so outfitStyle works on both Step 5 and Step 6 const combinedOutfitPool = [...outfitPool.map((o) => o.id), ...stylePool.map((s) => s.id)]; setDraft((prev) => ({ ...prev, gender: pick(['female', 'male', 'neutral']), ageRange: pick(['young_adult', 'adult', 'mature']), bodyType: pick(['slim', 'average', 'athletic', 'curvy']), heightCm: 155 + Math.floor(Math.random() * 40), posture: pick(['upright', 'relaxed', 'confident']), skinTone: pick(SKIN_TONES).id, polish: pick(['natural', 'light_makeup', 'formal']), facePreset: pick(FACE_PRESETS).id, eyeColor: pick(EYE_COLORS).id, eyeShape: pick(EYE_SHAPES).id, expression: pick(EXPRESSIONS).id, jawline: 20 + Math.floor(Math.random() * 60), lips: 20 + Math.floor(Math.random() * 60), browDefinition: 20 + Math.floor(Math.random() * 60), hairStyle: pick(HAIR_STYLES).id, hairColor: pick(HAIR_COLORS).id, hairShine: 10 + Math.floor(Math.random() * 70), outfitStyle: pick(combinedOutfitPool), outfitPrimaryColor: pick(COLOR_PALETTE).id, outfitSecondaryColor: pick(COLOR_PALETTE).id, accessories: ACCESSORIES.filter(() => Math.random() > 0.7).map((a) => a.id), portraitType: pick(['headshot', 'half_body', 'full_body']), pose: pick(posePool).id, background: pick(bgPool).id, lighting: pick(LIGHTING_OPTIONS).id, realism: 40 + Math.floor(Math.random() * 50), // NSFW-specific parameters (only meaningful when spicy) ...(isSpicyMode ? { nsfwExposure: pick(['suggestive', 'clothed_revealing', 'partial_nudity', 'topless', 'full_nude', 'explicit']), nsfwIntensity: 2 + Math.floor(Math.random() * 8), nsfwPose: pick(['subtle', 'confident', 'intimate', 'seductive_lean', 'lying_down', 'back_arch', 'kneeling', 'over_shoulder', 'hands_above_head']), nsfwDominanceStyle: pick(['soft', 'balanced', 'strong']), nsfwFantasyTone: pick(['romantic', 'seductive', 'dramatic']), } : { nsfwExposure: undefined, nsfwIntensity: undefined, nsfwPose: undefined, nsfwDominanceStyle: undefined, nsfwFantasyTone: undefined, }), })); }, [nsfwEnabled, vibeTab]); // Reset all appearance settings back to defaults const resetToDefaults = useCallback(() => { setDraft({ ...DEFAULT_DRAFT }); setVibeTab('standard'); }, []); // Build prompt from all wizard choices const prompt = useMemo(() => buildPrompt(draft, nsfwEnabled), [draft, nsfwEnabled]); // Is spicy content const isSpicy = vibeTab === 'spicy' || !!draft.nsfwExposure; // Filtered vibes for Step 7 reference/faceswap modes const vibes = AVATAR_VIBE_PRESETS.filter((v) => vibeTab === 'standard' ? v.category === 'standard' : v.category === 'spicy'); const charStyles = CHARACTER_STYLE_PRESETS.filter((s) => vibeTab === 'standard' ? s.category === 'standard' : s.category === 'spicy'); // --------------------------------------------------------------------------- // Step navigation // --------------------------------------------------------------------------- const canProceed = true; // All steps are optional — user can skip ahead const goNext = () => { if (canProceed && step < 6) setStep((s) => (s + 1)); }; const goBack = () => { if (step > 0) setStep((s) => (s - 1)); }; // --------------------------------------------------------------------------- // Apply profession defaults // --------------------------------------------------------------------------- const applyProfession = useCallback((profId) => { const prof = getProfession(profId); if (!prof) return; update({ professionId: profId, tools: [...prof.defaults.tools], memoryEngine: prof.defaults.memoryEngine, autonomy: prof.defaults.autonomy, tone: prof.defaults.tone, systemPrompt: prof.defaults.systemPrompt, responseStyle: prof.defaults.responseStyle, }); }, [update]); // --------------------------------------------------------------------------- // File upload (for reference/faceswap) // --------------------------------------------------------------------------- const handleFileUpload = useCallback(async (file) => { const formData = new FormData(); formData.append('file', file); try { const res = await fetch(`${backendUrl}/upload`, { method: 'POST', headers: apiKey ? { 'x-api-key': apiKey } : undefined, body: formData, }); if (!res.ok) throw new Error('Upload failed'); const data = await res.json(); const url = data.url || data.file_url || ''; update({ referenceUrl: url, referencePreview: URL.createObjectURL(file) }); } catch { showToast('Failed to upload reference photo', 'error'); } }, [backendUrl, apiKey, update, showToast]); // --------------------------------------------------------------------------- // Generate // --------------------------------------------------------------------------- const avatarSettings = loadAvatarSettings(); const checkpoint = resolveCheckpoint(avatarSettings, globalModelImages); const onGenerate = useCallback(async () => { // Send the real mode to the backend — it routes studio_random to the // avatar-service (StyleGAN) and other modes to ComfyUI automatically. const apiMode = draft.generationMode; try { const result = await gen.run({ mode: apiMode, count, prompt: prompt || undefined, reference_image_url: draft.generationMode !== 'studio_random' ? draft.referenceUrl || undefined : undefined, truncation: 0.7, checkpoint_override: checkpoint, }); if (result?.results?.length) { setSelectedResultIndex(null); if (result.results.length === 1) { showToast('Avatar generated — click to select, then Create Avatar', 'success'); } else { showToast(`${result.results.length} avatars generated — pick your favourite`, 'success'); } } } catch { showToast('Generation failed. Click Generate to try again.', 'error'); } }, [gen, draft.generationMode, draft.referenceUrl, count, prompt, checkpoint, showToast]); // Keyboard shortcut useEffect(() => { const handler = (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && step === 6 && !gen.loading) { e.preventDefault(); onGenerate(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [onGenerate, step, gen.loading]); // --------------------------------------------------------------------------- // Create avatar (gallery only) // --------------------------------------------------------------------------- const handleCreateAvatar = useCallback(() => { if (selectedResultIndex === null || !gen.result?.results?.[selectedResultIndex]) return; const allResults = gen.result.results; const chosen = allResults[selectedResultIndex]; // Save selected as anchor + non-selected as linked portraits (one atomic op) const portraits = allResults.filter((_, i) => i !== selectedResultIndex); // Build wizard metadata so persona export can access profession/tools/tone const prof = getProfession(draft.professionId); const wizardMeta = { professionId: draft.professionId, professionLabel: prof?.label, professionDescription: prof?.description, tools: draft.tools, memoryEngine: draft.memoryEngine, autonomy: draft.autonomy, tone: draft.tone, systemPrompt: draft.systemPrompt || prof?.defaults.systemPrompt, responseStyle: draft.responseStyle, gender: draft.gender, ageRange: draft.ageRange, outfitStyle: draft.outfitStyle, }; if (onSaveGeneration) { onSaveGeneration(chosen, portraits, draft.generationMode, prompt, draft.referenceUrl, isSpicy, wizardMeta); } else { const framingType = draft.portraitType === 'headshot' ? 'headshot' : draft.portraitType === 'mid_body' ? 'mid_body' : 'half_body'; gallery.addAnchorWithPortraits(chosen, portraits, draft.generationMode, prompt, draft.referenceUrl, { nsfw: isSpicy || undefined, wizardMeta, framingType }); } const portraitCount = portraits.length; showToast(portraitCount > 0 ? `Avatar created! ${portraitCount} alternative${portraitCount > 1 ? 's' : ''} saved as portraits` : 'Avatar created!', 'success'); // Clear session draft — wizard is complete try { sessionStorage.removeItem(DRAFT_STORAGE_KEY); sessionStorage.removeItem(STEP_STORAGE_KEY); } catch { /* */ } onClose(); }, [selectedResultIndex, gen.result, draft, prompt, isSpicy, gallery, onSaveGeneration, onClose, showToast]); // --------------------------------------------------------------------------- // Render: Step content // --------------------------------------------------------------------------- function renderStep() { switch (step) { // ===================================================================== // STEP 0 — IDENTITY // ===================================================================== case 0: return (
Gender
{GENDER_OPTIONS.map((g) => ( update({ gender: g.id })}/>))}
Age Range
{['young_adult', 'adult', 'mature'].map((a) => ( update({ ageRange: a })}/>))}
); // ===================================================================== // STEP 1 — BODY // ===================================================================== case 1: return (
Body Type
{['slim', 'average', 'athletic', 'curvy'].map((b) => ( update({ bodyType: b })}/>))}
update({ heightCm: v })}/> Posture
{['upright', 'relaxed', 'confident'].map((p) => ( update({ posture: p })}/>))}
Skin Tone
{SKIN_TONES.map((t) => ( update({ skinTone: t.id })}/>))}
Polish
{['natural', 'light_makeup', 'formal'].map((p) => ( update({ polish: p })}/>))}
); // ===================================================================== // STEP 2 — FACE // ===================================================================== case 2: return (
Face Shape
{FACE_PRESETS.map((f) => ( update({ facePreset: f.id })}/>))}
Eye Color
{EYE_COLORS.map((c) => ( update({ eyeColor: c.id })}/>))}
Eye Shape
{EYE_SHAPES.map((s) => ( update({ eyeShape: s.id })}/>))}
Default Expression
{EXPRESSIONS.map((e) => ( update({ expression: e.id })}/>))}
setAdvancedOpen(!advancedOpen)}/> {advancedOpen && (
update({ jawline: v })}/> update({ lips: v })}/> update({ browDefinition: v })}/>
)}
); // ===================================================================== // STEP 3 — HAIR // ===================================================================== case 3: return (
Hairstyle
{HAIR_STYLES.map((h) => ( update({ hairStyle: h.id })}/>))}
Hair Color
{HAIR_COLORS.map((c) => ( update({ hairColor: c.id })}/>))}
update({ hairShine: v })}/>
); // ===================================================================== // STEP 4 — PROFESSION // ===================================================================== case 4: { const q = professionSearch.toLowerCase().trim(); const filteredProfs = q ? PROFESSIONS.filter((p) => p.label.toLowerCase().includes(q) || p.description.toLowerCase().includes(q) || p.category.toLowerCase().includes(q)) : PROFESSIONS; return (
Choose a Profession

Profession influences outfit style, pose, and scene context

{/* Search / filter */}
setProfessionSearch(e.target.value)} placeholder="Search professions..." className="w-full pl-9 pr-3 py-2.5 rounded-xl bg-white/[0.04] border border-white/[0.08] text-xs text-white/70 placeholder-white/25 outline-none focus:border-purple-500/30 transition-colors"/>
{filteredProfs.length === 0 && (

No professions match “{professionSearch}”

)} {filteredProfs.map((p) => { const active = draft.professionId === p.id; return (); })}
); } // ===================================================================== // STEP 5 — OUTFIT // ===================================================================== case 5: { const outfits = vibeTab === 'spicy' && nsfwEnabled ? [...OUTFIT_STYLES_SFW, ...OUTFIT_STYLES_NSFW] : OUTFIT_STYLES_SFW; return (
{/* SFW / NSFW tabs — only show when NSFW is globally enabled */} {nsfwEnabled && (
)} Outfit Style
{outfits.map((o) => ( n.id === o.id) ? 'rose' : 'purple'} onClick={() => update({ outfitStyle: o.id })}/>))}
{/* ── Lingerie Builder — only when lingerie/boudoir_wear is selected ── */} {(draft.outfitStyle === 'lingerie' || draft.outfitStyle === 'boudoir_wear') && (() => { const activeGarments = draft.lingerieActiveGarments ?? []; const selections = draft.lingerieSelections ?? {}; const viewingGarment = draft.lingerieType; const currentSel = viewingGarment ? (selections[viewingGarment] ?? {}) : {}; return (
{/* Garment Type — multi-select, click toggles active, also sets viewing */}
Garment Type
Select one or more pieces. Click to toggle & configure.
{LINGERIE_GARMENTS.map((g) => { const isActive = activeGarments.includes(g.id); const isViewing = viewingGarment === g.id; return ( { if (isViewing) { // Second click on viewing garment → deactivate it update({ lingerieType: null, lingerieActiveGarments: activeGarments.filter((x) => x !== g.id), }); } else if (isActive) { // Already active but not viewing → switch to view it update({ lingerieType: g.id }); } else { // Not active → activate + view, preserve previous selections update({ lingerieType: g.id, lingerieActiveGarments: [...activeGarments, g.id], }); } }}/>); })}
{/* Active garment summary chips */} {activeGarments.length > 1 && (
{activeGarments.map((gId) => { const g = LINGERIE_GARMENTS.find((x) => x.id === gId); const sel = selections[gId]; const styleLabel = sel?.style && LINGERIE_STYLES[gId]?.find((s) => s.id === sel.style)?.label; return ( update({ lingerieType: gId })}> {g?.icon} {g?.label}{styleLabel ? `: ${styleLabel}` : ''} ); })}
)}
{/* Style Picker — scoped to currently viewed garment */} {viewingGarment && LINGERIE_STYLES[viewingGarment] && (
{viewingGarment === 'bottom' ? 'Panty Style' : viewingGarment === 'top' ? 'Bra Style' : viewingGarment === 'set' ? 'Set Style' : 'Bodysuit Style'}
{LINGERIE_STYLES[viewingGarment].map((s) => ( { const newSel = { ...currentSel, style: currentSel.style === s.id ? null : s.id }; update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } }); }}/>))}
)} {/* Fabric — scoped to currently viewed garment */} {viewingGarment && (
Fabric
{LINGERIE_FABRICS.map((f) => ( { const newSel = { ...currentSel, fabric: currentSel.fabric === f.id ? null : f.id }; update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } }); }}/>))}
)} {/* Brand Inspired — scoped to currently viewed garment */} {viewingGarment && (
Brand Inspired
{LINGERIE_BRANDS.map((b) => ( { const newSel = { ...currentSel, brand: currentSel.brand === b.id ? null : b.id }; update({ lingerieSelections: { ...selections, [viewingGarment]: newSel } }); }}/>))}
)} {/* Lingerie Accessories — shared across all garments */}
Lingerie Accessories
{LINGERIE_EXTRAS.map((e) => { const active = (draft.lingerieExtras ?? []).includes(e.id); return ( { const prev = draft.lingerieExtras ?? []; update({ lingerieExtras: active ? prev.filter((x) => x !== e.id) : [...prev, e.id] }); }}/>); })}
{/* Primary & Secondary Color — inside lingerie builder for piece coloring */}
{activeGarments.includes('top') && activeGarments.includes('bottom') ? 'Top Color' : 'Primary Color'}
{COLOR_PALETTE.map((c) => ( update({ outfitPrimaryColor: c.id })}/>))}
{activeGarments.includes('top') && activeGarments.includes('bottom') ? 'Bottom Color' : 'Secondary Color'}
{COLOR_PALETTE.map((c) => ( update({ outfitSecondaryColor: c.id })}/>))}
); })()} {/* Primary & Secondary Color — shown only when NOT lingerie (lingerie has its own colors above) */} {draft.outfitStyle !== 'lingerie' && draft.outfitStyle !== 'boudoir_wear' && (
Primary Color
{COLOR_PALETTE.map((c) => ( update({ outfitPrimaryColor: c.id })}/>))}
Secondary Color
{COLOR_PALETTE.map((c) => ( update({ outfitSecondaryColor: c.id })}/>))}
)} {/* Accessories — collapsible, hidden by default */} {advancedOpen && (
{ACCESSORIES.map((a) => { const active = draft.accessories.includes(a.id); return ( { const next = active ? draft.accessories.filter((x) => x !== a.id) : [...draft.accessories, a.id]; update({ accessories: next }); }}/>); })}
)} {/* ── NSFW customization (18+ gated) ── */} {nsfwEnabled && vibeTab === 'spicy' && (
Adult Content Controls 18+
{/* Exposure Level — 6-tier granular selector */}
Nudity / Exposure Level
{([ { id: 'suggestive', label: 'Suggestive', desc: 'Tasteful hints' }, { id: 'clothed_revealing', label: 'Clothed Revealing', desc: 'Clothed but revealing' }, { id: 'partial_nudity', label: 'Partial Nudity', desc: 'Some exposed skin' }, { id: 'topless', label: 'Topless', desc: 'Topless / implied nude' }, { id: 'full_nude', label: 'Full Nude', desc: 'Artistic full nude' }, { id: 'explicit', label: 'Explicit', desc: 'Explicit adult content' }, ]).map((e) => ())}
{/* Intensity slider */} update({ nsfwIntensity: v })}/> {/* Sensual Pose — unified flat grid of all 9 poses */}
Sensual Pose
{([ { id: 'subtle', label: 'Subtle Tease' }, { id: 'confident', label: 'Confident Display' }, { id: 'intimate', label: 'Intimate Close' }, ...NSFW_POSES.map((p) => ({ id: p.id, label: p.label })), ]).map((p) => ( update({ nsfwPose: p.id })}/>))}
{/* Dominance / Power dynamic */}
Power Dynamic
{([ { id: 'soft', label: 'Soft & Romantic' }, { id: 'balanced', label: 'Balanced' }, { id: 'strong', label: 'Dominant & Bold' }, ]).map((d) => ( update({ nsfwDominanceStyle: d.id })}/>))}
{/* Fantasy Tone */}
Fantasy Tone
{([ { id: 'romantic', label: 'Romantic & Tender' }, { id: 'seductive', label: 'Seductive & Alluring' }, { id: 'dramatic', label: 'Dramatic & Intense' }, ]).map((t) => ( update({ nsfwFantasyTone: t.id })}/>))}
{/* NSFW Scene / Background */}
Scene / Setting
{NSFW_BACKGROUNDS.map((b) => ( update({ background: b.id })}/>))}
)}
); } // ===================================================================== // STEP 6 — GENERATE // ===================================================================== case 6: { const needsRef = draft.generationMode === 'studio_reference' || draft.generationMode === 'studio_faceswap'; const canGen = !gen.loading && (!needsRef || !!draft.referenceUrl); return (
{/* ── Generation Mode tabs ── */} Generation Mode
{[ { mode: 'studio_random', label: 'Design Character', icon: }, { mode: 'studio_reference', label: 'From Reference', icon: }, { mode: 'studio_faceswap', label: 'Face + Style', icon: }, ].map((m) => ())}
{/* ── Reference upload (for ref/faceswap) ── */} {needsRef && (
Upload a Face
{draft.referencePreview ? (
Reference
) : ()} { const f = e.target.files?.[0]; if (f) handleFileUpload(f); e.target.value = ''; }}/>
{draft.referencePreview ? (Reference photo attached) : ()}
)} {/* ── Style / Vibe selection (for reference/faceswap OR design character) ── */} {draft.generationMode === 'studio_random' ? (<> {/* Standard / Spicy tabs — only when NSFW globally enabled */} {nsfwEnabled && (
)}
{charStyles.map((s) => { const active = draft.outfitStyle === s.id || false; return (); })}
) : (<> {/* Vibe grid for reference/faceswap — tab bar only when NSFW enabled */} {nsfwEnabled && (
)}
{vibes.map((v) => ())}
)} {/* ── Portrait / Pose / Background controls ── */} Portrait Type
{['headshot', 'half_body', 'mid_body', 'full_body'].map((t) => ( update({ portraitType: t })}/>))}
Pose
{POSES.map((p) => ( update({ pose: p.id })}/>))}
Background
{BACKGROUNDS.map((b) => ( update({ background: b.id })}/>))}
{/* ── Advanced ── */} setAdvancedOpen(!advancedOpen)}/> {advancedOpen && (
update({ realism: v })}/> update({ detailLevel: v })}/> Lighting
{LIGHTING_OPTIONS.map((l) => ( update({ lighting: l.id })}/>))}
)} {/* ── Generate Button + Count Selector ── */}
{showCountMenu && (<>
setShowCountMenu(false)}/>
{[1, 4, 8].map((n) => ())}
)}
{gen.loading && ()} {canGen && !gen.loading && (Ctrl+Enter)}
{/* ── Loading skeleton ── */} {gen.loading && (
{Array.from({ length: count }).map((_, i) => (
))}
)} {/* ── Results grid ── */} {gen.result?.results?.length ? (
{gen.result.results.length === 1 ? 'Your Avatar' : 'Choose Your Avatar'}
{gen.result.results.map((item, i) => { const imgUrl = resolveFileUrl(item.url, backendUrl); const blurred = isSpicy && !showNsfw; const isSelected = selectedResultIndex === i; const hasSelection = selectedResultIndex !== null; return (
{ if (blurred) { setShowNsfw(true); return; } setSelectedResultIndex(isSelected ? null : i); }}>
{`Avatar {blurred && (
Click to reveal
)} {isSelected && !blurred && (
)}
); })}
{/* ── Create Avatar button ── */} {selectedResultIndex !== null && gen.result.results[selectedResultIndex] && (

Saves avatar to your gallery — export as persona later

)}
) : !gen.loading && !gen.result ? (

Your avatars will appear here

Configure your character above, then click Generate

) : null}
); } default: return null; } } // --------------------------------------------------------------------------- // Render: Quick Create mode // --------------------------------------------------------------------------- function renderQuickCreate() { const canGen = !gen.loading; return (

Quick Create

Gender, profession, style — done in 60 seconds

Gender
{GENDER_OPTIONS.map((g) => ( update({ gender: g.id })}/>))}
Profession
{PROFESSIONS.filter((p) => p.id !== 'custom').slice(0, 5).map((p) => ())}
{/* Standard / Romance & Roleplay / 18+ tabs — only when NSFW globally enabled */} {nsfwEnabled && (
)} {/* Style vibe — switches between standard & spicy presets */} Style Vibe
{CHARACTER_STYLE_PRESETS .filter((s) => vibeTab === 'spicy' && nsfwEnabled ? s.category === 'spicy' : s.category === 'standard') .slice(0, vibeTab === 'spicy' && nsfwEnabled ? 8 : 4) .map((s) => ())}
{/* Randomize + Reset + Generate */}
); } // (Character Preview sidebar removed — not needed for avatar-only flow) // --------------------------------------------------------------------------- // Main render // --------------------------------------------------------------------------- return (
{/* ═══════════ HEADER ═══════════ */}

Avatar Studio

{/* Quick / Studio toggle */}
{/* ═══════════ BODY ═══════════ */} {wizardMode === 'quick' ? (
{renderQuickCreate()}
) : (
{/* ── Sidebar ── */} {/* ── Main step content ── */}
{/* Step header */}
Step {step + 1} of 7

{WIZARD_STEPS[step].label}

{renderStep()} {/* Navigation (not on Generate step — it has its own buttons) */} {step < 6 && (
)}
{/* Preview sidebar removed — avatar-only flow */}
)} {/* ═══════════ TOAST ═══════════ */} {toast && (
{toast.type === 'success' && } {toast.message}
)}
); }