studio/InteractiveAssetEditor.tsx

378 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;