import React, { useState, useRef, useEffect } from 'react'; import { CharacterData, LocationData, StyleOption, ReferenceImage, ModelSettings, CharacterVariant } from '../types'; import { generateImageService, constructSpatialPrompt, describeImageContent } from '../services/geminiService'; import { User, MapPin, Upload, Sparkles, RefreshCw, Wand2, X, Shirt, ScanFace, Terminal, Copy, Check, Save, Layers, ArrowLeftRight, History } from 'lucide-react'; interface InteractiveAssetEditorProps { type: 'character' | 'location'; data: CharacterData | LocationData; styleOption: StyleOption; onUpdate: (data: CharacterData | LocationData) => void; modelSettings: ModelSettings; } const InteractiveAssetEditor: React.FC = ({ type, data, styleOption, onUpdate, modelSettings }) => { const [isGenerating, setIsGenerating] = useState(false); const [description, setDescription] = useState(data.naturalDescription); const [constructedPrompt, setConstructedPrompt] = useState(''); const [isCopied, setIsCopied] = useState(false); const [isSavingVariant, setIsSavingVariant] = useState(false); const [variantName, setVariantName] = useState(''); const [isAnalyzingImage, setIsAnalyzingImage] = useState(false); const fileInputRef = useRef(null); const overrideInputRef = useRef(null); const [draggedId, setDraggedId] = useState(null); const canvasRef = useRef(null); const hasMasterImage = !!(data as any).masterImage; const variants = (type === 'character') ? (data as CharacterData).variants || [] : []; const getSemanticPosition = (x: number, y: number): string => { let v = "center"; let h = "center"; if (y < 33) v = "top"; else if (y > 66) v = "bottom"; if (x < 33) h = "left"; else if (x > 66) h = "right"; if (v === "center" && h === "center") return "center"; return `${v}-${h}`; }; useEffect(() => { const prompt = constructSpatialPrompt( description, styleOption, data.referenceImages, type, hasMasterImage, modelSettings.googleApiKey ); setConstructedPrompt(prompt); }, [description, data.referenceImages, styleOption, type, hasMasterImage, modelSettings.googleApiKey]); const handleGenerate = async () => { if (!modelSettings.googleApiKey) { alert("請先輸入 Google API Key"); return; } setIsGenerating(true); try { const finalPrompt = constructSpatialPrompt( description, styleOption, data.referenceImages, type, hasMasterImage, modelSettings.googleApiKey ); // IF hasMasterImage, use it as BASE for variation const baseImage = hasMasterImage ? (data as any).masterImage : undefined; const imgUrl = await generateImageService( finalPrompt, styleOption, modelSettings.imageModel, type, baseImage, data.referenceImages, modelSettings.googleApiKey, modelSettings.devMode || false ); onUpdate({ ...data, naturalDescription: description, masterImage: imgUrl } as any); } catch (e: any) { console.error(e); alert(`生成失敗: ${e.message}`); } finally { setIsGenerating(false); } }; const handleCopyPrompt = () => { navigator.clipboard.writeText(constructedPrompt); setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); }; // --- REVERSE ENGINEERING --- const handleOverrideUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (!modelSettings.googleApiKey) { alert("請先輸入 Google API Key"); return; } setIsAnalyzingImage(true); const reader = new FileReader(); reader.onload = async (event) => { if (event.target?.result) { const base64 = event.target.result as string; try { // 1. Analyze image to get prompt const analysis = await describeImageContent(base64, modelSettings.textModel, modelSettings.googleApiKey); // 2. Update Asset setDescription(analysis); onUpdate({ ...data, naturalDescription: analysis, masterImage: base64 // Set uploaded image as master } as any); alert("圖片解析完成!Prompt 與圖片已更新。"); } catch (err: any) { alert(`解析失敗: ${err.message}`); } finally { setIsAnalyzingImage(false); } } }; reader.readAsDataURL(file); e.target.value = ''; }; // --- VARIANT MANAGEMENT --- const handleSaveVariant = () => { if (!variantName) return; const currentMaster = (data as any).masterImage; if (!currentMaster) return; const newVariant: CharacterVariant = { id: Date.now().toString(), name: variantName, imageUrl: currentMaster }; const updatedVariants = [...variants, newVariant]; onUpdate({ ...data, variants: updatedVariants } as any); setIsSavingVariant(false); setVariantName(''); }; const handleSelectVariant = (variant: CharacterVariant) => { onUpdate({ ...data, masterImage: variant.imageUrl } as any); }; const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { if (event.target?.result) { const newRef: ReferenceImage = { id: Date.now().toString(), url: event.target.result as string, x: 50, y: 50, width: 100, zIndex: data.referenceImages.length + 1, semanticPosition: 'center', usage: hasMasterImage ? 'accessory' : 'face' }; onUpdate({ ...data, referenceImages: [...data.referenceImages, newRef] }); } }; reader.readAsDataURL(file); e.target.value = ''; }; const handleRemoveRef = (id: string, e: React.MouseEvent) => { e.stopPropagation(); onUpdate({ ...data, referenceImages: data.referenceImages.filter(r => r.id !== id) }); } const handleMouseDown = (e: React.MouseEvent, id: string) => { e.preventDefault(); e.stopPropagation(); setDraggedId(id); }; const handleMouseMove = (e: React.MouseEvent) => { if (!draggedId || !canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const x = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)); const y = Math.max(0, Math.min(100, ((e.clientY - rect.top) / rect.height) * 100)); const updatedRefs = data.referenceImages.map(img => img.id === draggedId ? { ...img, x, y, semanticPosition: getSemanticPosition(x, y) } : img); onUpdate({ ...data, referenceImages: updatedRefs }); }; const masterImg = (data as any).masterImage; return (
{/* LEFT: Canvas */}
setDraggedId(null)} onMouseLeave={() => setDraggedId(null)} >
{hasMasterImage ? <> 變體模式 (Variation) : <> 原型生成 (Base)}
{masterImg ? ( Master Asset ) : (
拖曳編輯區
)} {data.referenceImages.map((ref) => (
handleMouseDown(e, ref.id)} className={`absolute cursor-move border-2 rounded-lg overflow-hidden shadow-2xl transition-transform ${draggedId === ref.id ? 'z-50 scale-105 border-white' : ref.usage === 'face' ? 'border-amber-500/50' : 'border-indigo-500/50'}`} style={{ left: `${ref.x}%`, top: `${ref.y}%`, width: '120px', transform: 'translate(-50%, -50%)', zIndex: ref.zIndex }} >
{ref.usage}
))}
{/* RIGHT: Control Panel */}
{type === 'character' ? : }
onUpdate({ ...data, name: e.target.value } as any)} className="font-bold text-white text-2xl tracking-tight bg-transparent border-none outline-none focus:outline-none focus:ring-0 p-0 w-full" style={{ borderBottom: '1px solid transparent' }} onFocus={(e) => e.target.style.borderBottomColor = 'rgba(99, 102, 241, 0.5)'} onBlur={(e) => e.target.style.borderBottomColor = 'transparent'} />
{/* VARIANTS SECTION (Only for Characters) */} {type === 'character' && (
{isSavingVariant && (
setVariantName(e.target.value)} placeholder="輸入造型名稱 (如: 睡衣)" className="glass-input w-full rounded p-1 text-xs" />
)}
onUpdate({...data, masterImage: (data as any).masterImage} as any)} className="w-16 h-16 shrink-0 rounded-lg border border-zinc-700 bg-black cursor-pointer overflow-hidden relative" title="Current Workspace" > {masterImg && }
當前
{variants.map(v => (
handleSelectVariant(v)} className="w-16 h-16 shrink-0 rounded-lg border border-zinc-700 bg-black cursor-pointer overflow-hidden relative group" title={v.name} >
{v.name}
))}
)}