378 lines
21 KiB
TypeScript
378 lines
21 KiB
TypeScript
|
||
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<InteractiveAssetEditorProps> = ({ 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<HTMLInputElement>(null);
|
||
const overrideInputRef = useRef<HTMLInputElement>(null);
|
||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||
const canvasRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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 (
|
||
<div className="glass-card rounded-3xl overflow-hidden flex flex-col lg:flex-row min-h-[750px]">
|
||
{/* LEFT: Canvas */}
|
||
<div
|
||
ref={canvasRef}
|
||
className="relative w-full lg:w-7/12 bg-black/30 overflow-hidden cursor-crosshair select-none flex items-center justify-center backdrop-blur-sm"
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={() => setDraggedId(null)}
|
||
onMouseLeave={() => setDraggedId(null)}
|
||
>
|
||
<div className="absolute inset-0 bg-grid opacity-20 pointer-events-none" />
|
||
<div className="absolute top-6 left-6 z-10 flex flex-col gap-2">
|
||
<div className={`glass-panel px-4 py-2 rounded-full font-bold flex items-center gap-2 text-xs uppercase tracking-wider ${hasMasterImage ? 'text-purple-300 border-purple-500/30' : 'text-amber-300 border-amber-500/30'}`}>
|
||
{hasMasterImage ? <><History className="w-4 h-4" /> 變體模式 (Variation)</> : <><ScanFace className="w-4 h-4" /> 原型生成 (Base)</>}
|
||
</div>
|
||
</div>
|
||
|
||
{masterImg ? (
|
||
<img src={masterImg} className="absolute inset-0 w-full h-full object-contain z-0" alt="Master Asset" />
|
||
) : (
|
||
<div className="text-zinc-600 flex flex-col items-center">
|
||
<User className="w-24 h-24 mb-4 opacity-20" />
|
||
<span className="text-sm font-mono opacity-50">拖曳編輯區</span>
|
||
</div>
|
||
)}
|
||
|
||
{data.referenceImages.map((ref) => (
|
||
<div
|
||
key={ref.id}
|
||
onMouseDown={(e) => 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 }}
|
||
>
|
||
<div className="relative group">
|
||
<img src={ref.url} className="w-full h-auto bg-black pointer-events-none" />
|
||
<div className={`absolute top-0 left-0 px-2 py-0.5 text-[9px] font-bold text-black uppercase ${ref.usage === 'face' ? 'bg-amber-500' : 'bg-indigo-500 text-white'}`}>{ref.usage}</div>
|
||
<button onClick={(e) => handleRemoveRef(ref.id, e)} className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"><X className="w-3 h-3" /></button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* RIGHT: Control Panel */}
|
||
<div className="w-full lg:w-5/12 bg-black/20 flex flex-col border-l border-white/5">
|
||
<div className="p-6 border-b border-white/5">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className={`p-2 rounded-lg ${type === 'character' ? 'bg-indigo-500/20 text-indigo-400' : 'bg-emerald-500/20 text-emerald-400'}`}>
|
||
{type === 'character' ? <User className="w-6 h-6" /> : <MapPin className="w-6 h-6" />}
|
||
</div>
|
||
<div>
|
||
<input
|
||
type="text"
|
||
value={data.name}
|
||
onChange={(e) => 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'}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => overrideInputRef.current?.click()} className="p-2 text-zinc-400 hover:text-white bg-white/5 rounded-lg border border-white/5 hover:border-white/20 text-xs flex items-center gap-1" title="上傳圖片並逆向分析 Prompt">
|
||
{isAnalyzingImage ? <RefreshCw className="w-4 h-4 animate-spin" /> : <ArrowLeftRight className="w-4 h-4" />}
|
||
<span className="hidden sm:inline">逆向工程</span>
|
||
</button>
|
||
<input type="file" ref={overrideInputRef} className="hidden" accept="image/*" onChange={handleOverrideUpload} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||
{/* VARIANTS SECTION (Only for Characters) */}
|
||
{type === 'character' && (
|
||
<div className="glass-panel rounded-xl p-4">
|
||
<div className="flex justify-between items-center mb-3">
|
||
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider flex items-center gap-2">
|
||
<Layers className="w-3 h-3" /> 多重造型庫 (Variants)
|
||
</label>
|
||
<button
|
||
onClick={() => setIsSavingVariant(true)}
|
||
className="text-[10px] bg-indigo-500/20 text-indigo-300 px-2 py-1 rounded border border-indigo-500/30 hover:bg-indigo-500/40"
|
||
>
|
||
+ 儲存當前造型
|
||
</button>
|
||
</div>
|
||
|
||
{isSavingVariant && (
|
||
<div className="flex gap-2 mb-3">
|
||
<input
|
||
type="text"
|
||
value={variantName}
|
||
onChange={(e) => setVariantName(e.target.value)}
|
||
placeholder="輸入造型名稱 (如: 睡衣)"
|
||
className="glass-input w-full rounded p-1 text-xs"
|
||
/>
|
||
<button onClick={handleSaveVariant} className="text-xs bg-emerald-500/20 text-emerald-400 px-2 rounded"><Save className="w-3 h-3" /></button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||
<div
|
||
onClick={() => 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 && <img src={masterImg} className="w-full h-full object-cover" />}
|
||
<div className="absolute inset-x-0 bottom-0 bg-indigo-600/80 text-[8px] text-white text-center py-0.5 truncate">當前</div>
|
||
</div>
|
||
{variants.map(v => (
|
||
<div
|
||
key={v.id}
|
||
onClick={() => 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}
|
||
>
|
||
<img src={v.imageUrl} className="w-full h-full object-cover" />
|
||
<div className="absolute inset-x-0 bottom-0 bg-black/60 text-[8px] text-white text-center py-0.5 truncate">{v.name}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
<label className="flex items-center gap-2 text-xs font-bold text-zinc-400 uppercase tracking-wider">
|
||
<Sparkles className="w-3 h-3 text-indigo-400" />
|
||
{hasMasterImage ? '變體描述 (Variation Instruction)' : '自然語言描述 (Description)'}
|
||
</label>
|
||
<textarea
|
||
className="glass-input w-full rounded-xl p-4 text-sm h-24 resize-none"
|
||
value={description} onChange={(e) => setDescription(e.target.value)}
|
||
placeholder={hasMasterImage ? "請描述要如何修改這張圖片 (例如: 變成生氣的表情, 改成敬禮姿勢)..." : "請描述角色的外貌特徵、服裝與氣質..."}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<label className="flex items-center gap-2 text-xs font-bold text-zinc-400 uppercase tracking-wider">
|
||
{hasMasterImage ? '添加配件 (Add Accessories)' : '添加參考圖 (Add References)'}
|
||
</label>
|
||
<div onClick={() => fileInputRef.current?.click()} className={`glass-input border-dashed rounded-xl p-6 cursor-pointer text-center group hover:bg-white/5 ${hasMasterImage ? 'hover:border-purple-500' : 'hover:border-amber-500'}`}>
|
||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleFileUpload} />
|
||
<div className="flex flex-col items-center gap-3 text-zinc-500 group-hover:text-white transition-colors">
|
||
<Upload className="w-6 h-6" />
|
||
<span className="text-xs font-medium">{hasMasterImage ? '上傳配件 (維持基底)' : '上傳臉部/風格參考'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="glass-panel rounded-xl p-4 font-mono text-[10px] text-zinc-500 relative group">
|
||
<div className="flex items-center gap-2 mb-2 text-indigo-400 uppercase tracking-wider font-bold">
|
||
<Terminal className="w-3 h-3" /> 提示詞檢視器
|
||
<button
|
||
onClick={handleCopyPrompt}
|
||
className="ml-auto hover:text-white text-zinc-500 transition-colors p-1 rounded hover:bg-white/10"
|
||
title="複製提示詞"
|
||
>
|
||
{isCopied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||
</button>
|
||
</div>
|
||
<div className="opacity-70 max-h-32 overflow-y-auto break-all">{constructedPrompt || '等待輸入中...'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-6 border-t border-white/5">
|
||
<button
|
||
onClick={handleGenerate} disabled={isGenerating}
|
||
className={`glass-btn w-full py-4 rounded-xl font-bold flex items-center justify-center gap-3 ${isGenerating ? 'opacity-50' : hasMasterImage ? 'bg-purple-600/20 border-purple-500/50 hover:bg-purple-600/40 text-purple-200' : 'hover:bg-indigo-600/30 hover:border-indigo-500/50 text-white'}`}
|
||
>
|
||
{isGenerating ? <RefreshCw className="w-5 h-5 animate-spin" /> : hasMasterImage ? <Sparkles className="w-5 h-5" /> : <Wand2 className="w-5 h-5" />}
|
||
<span>{isGenerating ? '處理中...' : hasMasterImage ? '基於原圖生成變體 (Generate Variation)' : '生成原型 (Generate Base)'}</span>
|
||
</button>
|
||
{hasMasterImage && (
|
||
<button onClick={() => { onUpdate({ ...data, masterImage: undefined } as any); }} className="w-full mt-3 py-2 text-xs text-zinc-500 hover:text-red-400 uppercase font-bold">重置 (Reset)</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default InteractiveAssetEditor;
|