import React, { useState } from 'react'; import { generateAvatarImage } from '../services/geminiService'; import { analyzeAvatarImage } from '../services/visionService'; import { stitchAssets, fileToDataUrl } from '../services/imageService'; import LoadingSpinner from './LoadingSpinner'; import { Rect } from '../types'; interface AvatarCreatorProps { onAvatarGenerated: (url: string, name: string, initialData?: { leftEye?: Rect, rightEye?: Rect, mouth?: Rect, skinColor?: string, mainBody?: Rect, textureClosedEye?: Rect, textureOpenMouth?: Rect }) => void; } const AvatarCreator: React.FC = ({ onAvatarGenerated }) => { const [mode, setMode] = useState<'generate' | 'upload'>('generate'); // Generation State const [prompt, setPrompt] = useState(''); const [name, setName] = useState(''); const [status, setStatus] = useState<'idle' | 'generating' | 'analyzing' | 'stitching'>('idle'); const [error, setError] = useState(null); // Upload State const [baseFile, setBaseFile] = useState(null); const [blinkFile, setBlinkFile] = useState(null); const [talkFile, setTalkFile] = useState(null); const handleGenerate = async () => { if (!prompt || !name) return; setStatus('generating'); setError(null); try { // 1. Generate Image (Now creates a character sheet) const imageUrl = await generateAvatarImage(prompt); // 2. Analyze Image for Landmarks setStatus('analyzing'); const analysisData = await analyzeAvatarImage(imageUrl); // 3. Pass to parent if (analysisData) { onAvatarGenerated(imageUrl, name, analysisData); } else { onAvatarGenerated(imageUrl, name); } } catch (err) { console.error(err); setError("Failed to generate avatar. Please try again."); } finally { setStatus('idle'); } }; const handleUpload = async () => { if (!baseFile || !name) return; setStatus('stitching'); setError(null); try { // 1. Prepare Base Image and Analyze it separately // Analyzing separately ensures we get landmarks for the main face correctly // without interference from other faces in a stitched sheet. const baseDataUrl = await fileToDataUrl(baseFile); const baseAnalysis = await analyzeAvatarImage(baseDataUrl); // 2. Prepare and Analyze Variant Images let blinkDataUrl, blinkAnalysis; if (blinkFile) { blinkDataUrl = await fileToDataUrl(blinkFile); // Try to find eyes in the blink image to use as tight texture crop blinkAnalysis = await analyzeAvatarImage(blinkDataUrl); } let talkDataUrl, talkAnalysis; if (talkFile) { talkDataUrl = await fileToDataUrl(talkFile); // Try to find mouth in the talk image talkAnalysis = await analyzeAvatarImage(talkDataUrl); } // 3. Stitch Assets into Sheet const { imageUrl, mainBody, textureClosedEye: stitchBlinkRect, textureOpenMouth: stitchTalkRect } = await stitchAssets(baseDataUrl, blinkDataUrl, talkDataUrl); // 4. Map Analysis Data to Stitched Coordinate Space // Helper to map a rect from (0-1 in sub-image) to (0-1 in stitched-image) const mapRect = (r: Rect, container: Rect) => ({ x: container.x + r.x * container.w, y: container.y + r.y * container.h, w: r.w * container.w, h: r.h * container.h }); let initialData: any = { mainBody, textureClosedEye: stitchBlinkRect, textureOpenMouth: stitchTalkRect }; // Map Base Targets (Eyes, Mouth on main body) if (baseAnalysis) { initialData.leftEye = mapRect(baseAnalysis.leftEye, mainBody); initialData.rightEye = mapRect(baseAnalysis.rightEye, mainBody); initialData.mouth = mapRect(baseAnalysis.mouth, mainBody); initialData.skinColor = baseAnalysis.skinColor; } // Map Source Textures (Tight crop around features if detected) // If detections fail (e.g. eyes closed might not be detected), we fall back to the whole image (stitchBlinkRect) if (blinkAnalysis && stitchBlinkRect) { // Calculate a bounding box around both eyes in the blink image const be = blinkAnalysis; const minX = Math.min(be.leftEye.x, be.rightEye.x); const minY = Math.min(be.leftEye.y, be.rightEye.y); const maxX = Math.max(be.leftEye.x + be.leftEye.w, be.rightEye.x + be.rightEye.w); const maxY = Math.max(be.leftEye.y + be.leftEye.h, be.rightEye.y + be.rightEye.h); const eyesRect = { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; initialData.textureClosedEye = mapRect(eyesRect, stitchBlinkRect); } if (talkAnalysis && stitchTalkRect) { initialData.textureOpenMouth = mapRect(talkAnalysis.mouth, stitchTalkRect); } onAvatarGenerated(imageUrl, name, initialData); } catch (err) { console.error(err); setError("Failed to process uploaded images. Please ensure they are valid image files."); } finally { setStatus('idle'); } }; const handleFileChange = (e: React.ChangeEvent, setter: (f: File | null) => void) => { if (e.target.files && e.target.files[0]) { setter(e.target.files[0]); } }; return (
{/* Tabs */}

{mode === 'generate' ? 'Design Your Avatar' : 'Import Your Model'}

{mode === 'generate' ? 'Describe your dream VTuber model. Gemini will generate a character sheet with expression assets.' : 'Upload your existing character art. We support separate files for blink and talk variants.' }

setName(e.target.value)} placeholder="e.g., Neon Kitsune" className="w-full bg-slate-900/50 border border-slate-600 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all outline-none" />
{mode === 'generate' ? (