commit 29ee161a2f3d3f39c622bdc9a07c8749cef7b044 Author: 王性驊 Date: Wed Dec 10 17:43:39 2025 +0800 fix: doc diff --git a/AssetManager.tsx b/AssetManager.tsx new file mode 100644 index 0000000..471d5b6 --- /dev/null +++ b/AssetManager.tsx @@ -0,0 +1,119 @@ + +import React, { useState } from 'react'; +import { CharacterData, LocationData, StyleOption, ModelSettings } from '../types'; +import { User, MapPin, Info, Wand2, Plus } from 'lucide-react'; +import InteractiveAssetEditor from './InteractiveAssetEditor'; + +interface AssetManagerProps { + characters: CharacterData[]; + locations: LocationData[]; + styleOption: StyleOption; + onUpdateCharacter: (char: CharacterData) => void; + onUpdateLocation: (loc: LocationData) => void; + onAddCharacter: () => void; + onAddLocation: () => void; + onComplete: () => void; + modelSettings: ModelSettings; +} + +const AssetManager: React.FC = ({ + characters, + locations, + styleOption, + onUpdateCharacter, + onUpdateLocation, + onAddCharacter, + onAddLocation, + onComplete, + modelSettings +}) => { + const [activeTab, setActiveTab] = useState<'characters' | 'locations'>('characters'); + + return ( +
+
+
+

資產合成實驗室

+
+ 當前風格 DNA: + {styleOption.name} +
+
+ +
+
+ + +
+ +
+
+ +
+ {activeTab === 'characters' && characters.map(char => ( + onUpdateCharacter(updated as CharacterData)} + modelSettings={modelSettings} + /> + ))} + + {activeTab === 'locations' && locations.map(loc => ( + onUpdateLocation(updated as LocationData)} + modelSettings={modelSettings} + /> + ))} +
+ +
+
+
+
+ +
+ 確認後資產將鎖定並進入製作流程,請確保所有重要資產已生成完畢。 +
+ +
+
+
+ ); +}; + +export default AssetManager; diff --git a/Header.tsx b/Header.tsx new file mode 100644 index 0000000..a520138 --- /dev/null +++ b/Header.tsx @@ -0,0 +1,75 @@ + +import React from 'react'; +import { GenerationStep } from '../types'; +import { Wand2, Download, FolderOpen, Settings } from 'lucide-react'; +import PipelineVisualizer from './PipelineVisualizer'; + +interface HeaderProps { + step: GenerationStep; + setStep: (step: GenerationStep) => void; + isAnalyzing: boolean; + onSave: () => void; + onLoadRef: React.RefObject; + onLoad: (e: React.ChangeEvent) => void; + onOpenSettings: () => void; +} + +const Header: React.FC = ({ + step, + setStep, + isAnalyzing, + onSave, + onLoadRef, + onLoad, + onOpenSettings +}) => { + return ( +
+ {/* Height increased to h-28 to prevent clipping of Visualizer labels */} +
+ + {/* Logo */} +
setStep(GenerationStep.PROJECT_SELECTION)} + > +
+ +
+
+

+ Lumina Studio +

+

AI 影視神經網絡流水線

+
+
+ + {/* Pipeline Visualizer (The DAG) */} +
+ setStep(s)} + isProcessing={isAnalyzing} + /> +
+ + {/* Actions */} +
+ + + +
+ +
+
+
+ ); +}; + +export default Header; diff --git a/InteractiveAssetEditor.tsx b/InteractiveAssetEditor.tsx new file mode 100644 index 0000000..1813dd7 --- /dev/null +++ b/InteractiveAssetEditor.tsx @@ -0,0 +1,377 @@ + +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}
+
+ ))} +
+
+ )} + +
+ +