studio/InteractiveAssetEditor.tsx

378 lines
21 KiB
TypeScript
Raw Permalink Normal View History

2025-12-10 09:43:39 +00:00
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;