From 29ee161a2f3d3f39c622bdc9a07c8749cef7b044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 10 Dec 2025 17:43:39 +0800 Subject: [PATCH] fix: doc --- AssetManager.tsx | 119 ++++++++ Header.tsx | 75 ++++++ InteractiveAssetEditor.tsx | 377 ++++++++++++++++++++++++++ PersonaManager.tsx | 204 ++++++++++++++ PipelineVisualizer.tsx | 139 ++++++++++ ProjectTypeSelector.tsx | 77 ++++++ SceneEditor.tsx | 540 +++++++++++++++++++++++++++++++++++++ SettingsModal.tsx | 251 +++++++++++++++++ StructurePlanner.tsx | 303 +++++++++++++++++++++ StyleCard.tsx | 50 ++++ 10 files changed, 2135 insertions(+) create mode 100644 AssetManager.tsx create mode 100644 Header.tsx create mode 100644 InteractiveAssetEditor.tsx create mode 100644 PersonaManager.tsx create mode 100644 PipelineVisualizer.tsx create mode 100644 ProjectTypeSelector.tsx create mode 100644 SceneEditor.tsx create mode 100644 SettingsModal.tsx create mode 100644 StructurePlanner.tsx create mode 100644 StyleCard.tsx 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}
+
+ ))} +
+
+ )} + +
+ +