This commit is contained in:
王性驊 2025-12-10 17:43:39 +08:00
commit 29ee161a2f
10 changed files with 2135 additions and 0 deletions

119
AssetManager.tsx Normal file
View File

@ -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<AssetManagerProps> = ({
characters,
locations,
styleOption,
onUpdateCharacter,
onUpdateLocation,
onAddCharacter,
onAddLocation,
onComplete,
modelSettings
}) => {
const [activeTab, setActiveTab] = useState<'characters' | 'locations'>('characters');
return (
<div className="animate-fade-in space-y-8 pb-32">
<div className="glass-card rounded-3xl p-8 flex flex-col md:flex-row justify-between items-end gap-6">
<div>
<h2 className="text-3xl font-bold text-white mb-2 text-glow"></h2>
<div className="flex items-center gap-3 text-sm text-zinc-400">
<span> DNA:</span>
<span className="px-3 py-1 bg-indigo-500/10 text-indigo-300 rounded-full border border-indigo-500/20 font-medium shadow-[0_0_10px_rgba(99,102,241,0.2)]">{styleOption.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="glass-panel p-1 rounded-xl flex">
<button
onClick={() => setActiveTab('characters')}
className={`px-6 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2 transition-all duration-300 ${
activeTab === 'characters' ? 'bg-white/10 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
<User className="w-4 h-4" />
(Characters)
</button>
<button
onClick={() => setActiveTab('locations')}
className={`px-6 py-2.5 rounded-lg text-sm font-bold flex items-center gap-2 transition-all duration-300 ${
activeTab === 'locations' ? 'bg-white/10 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
<MapPin className="w-4 h-4" />
(Locations)
</button>
</div>
<button
onClick={activeTab === 'characters' ? onAddCharacter : onAddLocation}
className="glass-btn w-10 h-10 rounded-xl flex items-center justify-center text-emerald-400 hover:bg-emerald-500/10 border-emerald-500/30"
title={activeTab === 'characters' ? "新增角色" : "新增場景"}
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
<div className="space-y-12">
{activeTab === 'characters' && characters.map(char => (
<InteractiveAssetEditor
key={char.id}
type="character"
data={char}
styleOption={styleOption}
onUpdate={(updated) => onUpdateCharacter(updated as CharacterData)}
modelSettings={modelSettings}
/>
))}
{activeTab === 'locations' && locations.map(loc => (
<InteractiveAssetEditor
key={loc.id}
type="location"
data={loc}
styleOption={styleOption}
onUpdate={(updated) => onUpdateLocation(updated as LocationData)}
modelSettings={modelSettings}
/>
))}
</div>
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-40 w-[90%] max-w-4xl">
<div className="glass-card rounded-2xl p-4 flex justify-between items-center shadow-2xl backdrop-blur-xl">
<div className="flex items-center gap-3 px-4">
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-400">
<Info className="w-4 h-4" />
</div>
<span className="text-zinc-300 text-sm hidden md:block"></span>
</div>
<button
onClick={onComplete}
className="glass-btn bg-emerald-500/20 border-emerald-500/50 text-white px-8 py-3 rounded-xl font-bold flex items-center gap-2 hover:bg-emerald-500/30 text-shadow-sm"
>
<Wand2 className="w-5 h-5 text-emerald-400" />
</button>
</div>
</div>
</div>
);
};
export default AssetManager;

75
Header.tsx Normal file
View File

@ -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<HTMLInputElement>;
onLoad: (e: React.ChangeEvent<HTMLInputElement>) => void;
onOpenSettings: () => void;
}
const Header: React.FC<HeaderProps> = ({
step,
setStep,
isAnalyzing,
onSave,
onLoadRef,
onLoad,
onOpenSettings
}) => {
return (
<header className="fixed top-4 left-1/2 -translate-x-1/2 z-50 w-[95%] max-w-7xl">
{/* Height increased to h-28 to prevent clipping of Visualizer labels */}
<div className="glass-card rounded-2xl px-6 h-28 flex items-center justify-between shadow-2xl shadow-black/50 relative">
{/* Logo */}
<div
className="flex items-center gap-3 cursor-pointer group z-10 shrink-0"
onClick={() => setStep(GenerationStep.PROJECT_SELECTION)}
>
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 via-purple-600 to-pink-500 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/30 group-hover:scale-105 transition-transform">
<Wand2 className="text-white w-5 h-5" />
</div>
<div className="hidden md:block">
<h1 className="font-bold text-xl tracking-tight text-white group-hover:text-glow transition-all">
Lumina <span className="font-light opacity-70">Studio</span>
</h1>
<p className="text-[10px] text-zinc-400 uppercase tracking-widest">AI </p>
</div>
</div>
{/* Pipeline Visualizer (The DAG) */}
<div className="flex-1 px-8 hidden lg:block h-full">
<PipelineVisualizer
currentStep={step}
onNavigate={(s) => setStep(s)}
isProcessing={isAnalyzing}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-3 shrink-0 z-10">
<button onClick={onSave} className="p-2.5 text-zinc-400 hover:text-green-400 hover:bg-white/5 rounded-full transition-all" title="匯出專案 (Zip)">
<Download className="w-5 h-5" />
</button>
<button onClick={() => onLoadRef.current?.click()} className="p-2.5 text-zinc-400 hover:text-blue-400 hover:bg-white/5 rounded-full transition-all" title="匯入專案 (Zip)">
<FolderOpen className="w-5 h-5" />
</button>
<input type="file" ref={onLoadRef} accept=".zip" className="hidden" onChange={onLoad} />
<div className="w-px h-8 bg-white/10 mx-2" />
<button onClick={onOpenSettings} className="p-2.5 text-zinc-400 hover:text-white hover:bg-white/5 rounded-full transition-all" title="設定">
<Settings className="w-5 h-5" />
</button>
</div>
</div>
</header>
);
};
export default Header;

377
InteractiveAssetEditor.tsx Normal file
View File

@ -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<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;

204
PersonaManager.tsx Normal file
View File

@ -0,0 +1,204 @@
import React, { useState } from 'react';
import { InfluencerPersona } from '../types';
import { User, Plus, Save, Trash2, Smartphone, Sparkles, ChevronRight } from 'lucide-react';
interface PersonaManagerProps {
savedPersonas: InfluencerPersona[];
activePersona: InfluencerPersona | null;
onUpdatePersonas: (personas: InfluencerPersona[]) => void;
onSelectPersona: (persona: InfluencerPersona) => void;
onGeneratePlan: (campaignInput: string) => void;
isAnalyzing: boolean;
}
const PersonaManager: React.FC<PersonaManagerProps> = ({
savedPersonas,
activePersona,
onUpdatePersonas,
onSelectPersona,
onGeneratePlan,
isAnalyzing
}) => {
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState<InfluencerPersona>({
id: '', name: '', gender: '', age: '', appearance: '', style: '', traits: ''
});
const [campaignInput, setCampaignInput] = useState('');
const handleCreate = () => {
const newPersona: InfluencerPersona = {
id: Date.now().toString(),
name: '妍妍',
gender: '女性',
age: '20',
appearance: '綁著高馬尾清新的韓國女生臉孔大眼睛身高170公分身材結實勻稱像是專業的網球選手',
style: '穿著時尚的運動套裝 (Athletic Wear) 與緊身瑜珈褲 (Leggings),搭配白色運動鞋,展現健康活力',
traits: '活潑、充滿能量、喜歡戶外運動、陽光笑容'
};
setForm(newPersona);
setIsEditing(true);
onSelectPersona(newPersona); // Temporarily select for preview
};
const handleEdit = (persona: InfluencerPersona) => {
setForm(persona);
setIsEditing(true);
onSelectPersona(persona);
};
const handleSave = () => {
let newPersonas = [...savedPersonas];
const index = newPersonas.findIndex(p => p.id === form.id);
if (index >= 0) {
newPersonas[index] = form;
} else {
newPersonas.push(form);
}
onUpdatePersonas(newPersonas);
onSelectPersona(form);
setIsEditing(false);
};
const handleDelete = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if(confirm('確定要刪除此人設嗎?')) {
const newPersonas = savedPersonas.filter(p => p.id !== id);
onUpdatePersonas(newPersonas);
if (activePersona?.id === id) {
onSelectPersona(null as any);
setIsEditing(false);
}
}
};
return (
<div className="animate-fade-in grid grid-cols-1 md:grid-cols-12 gap-6 max-w-6xl mx-auto">
{/* LEFT: List */}
<div className="md:col-span-4 space-y-4">
<div className="glass-card rounded-2xl p-6 min-h-[500px] flex flex-col">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<User className="w-5 h-5 text-cyan-400" />
</h3>
<button onClick={handleCreate} className="glass-btn p-2 rounded-lg text-cyan-400 hover:bg-cyan-500/10">
<Plus className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 flex-1 overflow-y-auto pr-2">
{savedPersonas.length === 0 && (
<div className="text-center text-zinc-500 py-10 text-sm">
<br/>+
</div>
)}
{savedPersonas.map(p => (
<div
key={p.id}
onClick={() => { setIsEditing(false); onSelectPersona(p); }}
className={`p-4 rounded-xl cursor-pointer border transition-all ${activePersona?.id === p.id ? 'bg-cyan-500/10 border-cyan-500/50' : 'bg-white/5 border-transparent hover:border-white/20'}`}
>
<div className="flex justify-between items-start">
<div className="font-bold text-white">{p.name}</div>
<div className="flex gap-1">
<button onClick={(e) => { e.stopPropagation(); handleEdit(p); }} className="p-1 hover:text-white text-zinc-500"><Sparkles className="w-3 h-3" /></button>
<button onClick={(e) => handleDelete(p.id, e)} className="p-1 hover:text-red-400 text-zinc-500"><Trash2 className="w-3 h-3" /></button>
</div>
</div>
<div className="text-xs text-zinc-400 mt-1 line-clamp-2">{p.appearance}</div>
</div>
))}
</div>
</div>
</div>
{/* RIGHT: Detail / Edit */}
<div className="md:col-span-8">
<div className="glass-card rounded-2xl p-8 min-h-[500px] flex flex-col">
{isEditing ? (
// EDIT MODE
<div className="space-y-4 animate-fade-in">
<h3 className="text-xl font-bold text-white mb-4 border-b border-white/10 pb-4"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-bold text-zinc-500 uppercase"></label>
<input type="text" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="glass-input w-full rounded-lg p-2" />
</div>
<div>
<label className="text-xs font-bold text-zinc-500 uppercase"> / </label>
<div className="flex gap-2">
<input type="text" value={form.gender} onChange={e => setForm({...form, gender: e.target.value})} className="glass-input w-full rounded-lg p-2" placeholder="女性" />
<input type="text" value={form.age} onChange={e => setForm({...form, age: e.target.value})} className="glass-input w-full rounded-lg p-2" placeholder="20" />
</div>
</div>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 uppercase"> (Prompt )</label>
<textarea value={form.appearance} onChange={e => setForm({...form, appearance: e.target.value})} className="glass-input w-full rounded-lg p-3 h-24" placeholder="例如粉色波浪捲髮身高165公分右眼角有淚痣..." />
</div>
<div>
<label className="text-xs font-bold text-zinc-500 uppercase">穿</label>
<input type="text" value={form.style} onChange={e => setForm({...form, style: e.target.value})} className="glass-input w-full rounded-lg p-2" placeholder="Y2K, Cyberpunk, 簡約風..." />
</div>
<div className="pt-4 flex justify-end gap-3">
<button onClick={() => setIsEditing(false)} className="text-zinc-400 hover:text-white px-4"></button>
<button onClick={handleSave} className="glass-btn bg-cyan-600/20 text-cyan-300 border-cyan-500/50 px-6 py-2 rounded-lg font-bold flex items-center gap-2">
<Save className="w-4 h-4" />
</button>
</div>
</div>
) : activePersona ? (
// PREVIEW & CAMPAIGN MODE
<div className="flex flex-col h-full animate-fade-in">
<div className="flex items-start gap-6 mb-8">
<div className="w-24 h-24 rounded-full bg-cyan-500/20 flex items-center justify-center border-2 border-cyan-500/30 shadow-[0_0_20px_rgba(6,182,212,0.3)]">
<User className="w-10 h-10 text-cyan-300" />
</div>
<div>
<h2 className="text-3xl font-bold text-white mb-2">{activePersona.name}</h2>
<div className="flex flex-wrap gap-2 text-sm text-zinc-300">
<span className="bg-white/10 px-2 py-1 rounded">{activePersona.gender}</span>
<span className="bg-white/10 px-2 py-1 rounded">{activePersona.age}</span>
<span className="bg-white/10 px-2 py-1 rounded">{activePersona.style}</span>
</div>
<p className="mt-3 text-zinc-400 text-sm max-w-lg">{activePersona.appearance}</p>
</div>
</div>
<div className="mt-auto pt-8 border-t border-white/10">
<h4 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Smartphone className="w-5 h-5 text-pink-400" />
</h4>
<p className="text-xs text-zinc-400 mb-3"> Vlog</p>
<textarea
value={campaignInput}
onChange={e => setCampaignInput(e.target.value)}
className="glass-input w-full rounded-xl p-4 text-sm h-32 mb-4"
placeholder="輸入企劃內容..."
/>
<div className="flex justify-end">
<button
onClick={() => onGeneratePlan(campaignInput)}
disabled={isAnalyzing || !campaignInput}
className="glass-btn bg-gradient-to-r from-cyan-600/40 to-blue-600/40 border-cyan-500/50 text-white px-8 py-3 rounded-xl font-bold flex items-center gap-2 hover:shadow-lg hover:shadow-cyan-500/20"
>
{isAnalyzing ? '正在規劃分鏡...' : '生成社群貼文規劃'}
{!isAnalyzing && <ChevronRight className="w-4 h-4" />}
</button>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-zinc-500">
<User className="w-16 h-16 mb-4 opacity-20" />
<p></p>
</div>
)}
</div>
</div>
</div>
);
};
export default PersonaManager;

139
PipelineVisualizer.tsx Normal file
View File

@ -0,0 +1,139 @@
import React from 'react';
import { GenerationStep } from '../types';
import { Database, Palette, FileText, Layers, Clapperboard, Check } from 'lucide-react';
interface PipelineVisualizerProps {
currentStep: GenerationStep;
onNavigate: (step: GenerationStep) => void;
isProcessing: boolean;
}
const PipelineVisualizer: React.FC<PipelineVisualizerProps> = ({ currentStep, onNavigate, isProcessing }) => {
const nodes = [
{ id: 'source', label: '來源選擇', icon: Database, steps: [GenerationStep.PROJECT_SELECTION], color: '#ec4899' },
{ id: 'style', label: '風格定義', icon: Palette, steps: [GenerationStep.STYLE_SELECTION], color: '#8b5cf6' },
{ id: 'blueprint', label: '結構藍圖', icon: FileText, steps: [GenerationStep.INPUT_PHASE, GenerationStep.STRUCTURE_PLANNING], color: '#6366f1' },
{ id: 'assets', label: '資產合成', icon: Layers, steps: [GenerationStep.ASSET_MANAGEMENT], color: '#06b6d4' },
{ id: 'prod', label: '分鏡製作', icon: Clapperboard, steps: [GenerationStep.PRODUCTION_DASHBOARD], color: '#10b981' },
];
const getNodeStatus = (nodeSteps: GenerationStep[]) => {
const stepOrder = Object.values(GenerationStep);
const firstNodeStepIndex = stepOrder.indexOf(nodeSteps[0]);
const currentStepIndex = stepOrder.indexOf(currentStep);
if (nodeSteps.includes(currentStep)) return 'active';
if (currentStepIndex > firstNodeStepIndex) return 'completed';
return 'pending';
};
const getTargetStep = (nodeSteps: GenerationStep[]) => {
return nodeSteps[0];
}
const nodeWidth = 40;
const gap = 120;
const startX = 50;
const y = 40; // Centered vertically in the available space of Header (excluding text)
return (
<div className="w-full h-full flex items-center justify-center select-none">
<div className="relative w-full max-w-3xl h-full">
<svg className="w-full h-full overflow-visible">
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
{/* Edges */}
{nodes.map((node, i) => {
if (i === nodes.length - 1) return null;
const status = getNodeStatus(node.steps);
const nextStatus = getNodeStatus(nodes[i+1].steps);
const isActiveFlow = status === 'active' && isProcessing;
const isCompletedFlow = nextStatus === 'active' || nextStatus === 'completed';
return (
<g key={`edge-${i}`}>
<line
x1={startX + (i * gap) + nodeWidth/2}
y1={y}
x2={startX + ((i + 1) * gap) - nodeWidth/2}
y2={y}
stroke={isCompletedFlow ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.05)"}
strokeWidth="2"
/>
{(isActiveFlow || isCompletedFlow) && (
<line
x1={startX + (i * gap) + nodeWidth/2}
y1={y}
x2={startX + ((i + 1) * gap) - nodeWidth/2}
y2={y}
stroke={node.color}
strokeWidth="2"
strokeDasharray="4 4"
className={isActiveFlow ? "animate-flow" : ""}
opacity={isActiveFlow ? 1 : 0.5}
/>
)}
</g>
);
})}
{/* Nodes */}
{nodes.map((node, i) => {
const status = getNodeStatus(node.steps);
const cx = startX + (i * gap);
const isClickable = status === 'completed' || status === 'active';
return (
<g
key={node.id}
onClick={() => isClickable && onNavigate(getTargetStep(node.steps))}
className={`transition-all duration-300 ${isClickable ? 'cursor-pointer hover:opacity-80' : 'opacity-40 cursor-not-allowed'}`}
>
{status === 'active' && (
<circle cx={cx} cy={y} r={nodeWidth / 1.5} fill={node.color} opacity="0.2" filter="url(#glow)" className="animate-pulse" />
)}
<circle
cx={cx}
cy={y}
r={nodeWidth / 2}
fill={status === 'active' ? node.color : (status === 'completed' ? '#1f2937' : '#000')}
stroke={status === 'active' ? '#fff' : (status === 'completed' ? node.color : '#374151')}
strokeWidth={status === 'active' ? 2 : 1}
/>
<foreignObject x={cx - 10} y={y - 10} width="20" height="20">
<div className="flex items-center justify-center w-full h-full text-white">
{status === 'completed' ? <Check className="w-4 h-4 text-emerald-400" /> : <node.icon className="w-4 h-4" />}
</div>
</foreignObject>
<text
x={cx}
y={y + 35}
textAnchor="middle"
fill={status === 'active' ? '#fff' : '#6b7280'}
fontSize="10"
fontWeight="bold"
letterSpacing="1"
className={status === 'active' ? 'text-glow' : ''}
>
{node.label}
</text>
</g>
)
})}
</svg>
</div>
</div>
);
};
export default PipelineVisualizer;

77
ProjectTypeSelector.tsx Normal file
View File

@ -0,0 +1,77 @@
import React from 'react';
import { ProjectType } from '../types';
import { Tv, Music, Smartphone, Sparkles, Clapperboard } from 'lucide-react';
interface ProjectTypeSelectorProps {
onSelect: (type: ProjectType) => void;
}
const ProjectTypeSelector: React.FC<ProjectTypeSelectorProps> = ({ onSelect }) => {
const options = [
{
id: ProjectType.NETFLIX_SERIES,
title: "Netflix 影集改編",
description: "AI 影集統籌模式 (Showrunner)。將小說拆解為季/集結構,並進行長篇敘事規劃。",
icon: Tv,
color: "text-rose-400",
bg: "bg-rose-500/10",
border: "hover:border-rose-500/50"
},
{
id: ProjectType.MUSIC_VIDEO,
title: "MV 音樂錄影帶",
description: "AI 導演模式 (Director)。視覺化歌詞節奏,創造風格強烈的短片分鏡。",
icon: Music,
color: "text-purple-400",
bg: "bg-purple-500/10",
border: "hover:border-purple-500/50"
},
{
id: ProjectType.VIRTUAL_INFLUENCER,
title: "虛擬網紅運營",
description: "AI 經紀人模式 (Manager)。生成連續性的人設生活動態與社群內容。",
icon: Smartphone,
color: "text-cyan-400",
bg: "bg-cyan-500/10",
border: "hover:border-cyan-500/50"
}
];
return (
<div className="animate-fade-in max-w-6xl mx-auto pt-10">
<div className="text-center mb-16">
<div className="inline-block p-5 rounded-3xl glass-panel mb-6 shadow-2xl">
<Clapperboard className="w-16 h-16 text-indigo-400 drop-shadow-[0_0_15px_rgba(129,140,248,0.5)]" />
</div>
<h2 className="text-6xl font-bold text-white mb-6 tracking-tighter text-glow">Lumina Studio</h2>
<p className="text-xl text-zinc-400 max-w-2xl mx-auto font-light">
AI
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{options.map((opt) => (
<div
key={opt.id}
onClick={() => onSelect(opt.id)}
className={`glass-card rounded-3xl p-8 cursor-pointer transition-all duration-500 hover:-translate-y-2 group border border-white/5 ${opt.border}`}
>
<div className={`w-16 h-16 rounded-2xl ${opt.bg} flex items-center justify-center mb-8 shadow-lg backdrop-blur-md group-hover:scale-110 transition-transform duration-300`}>
<opt.icon className={`w-8 h-8 ${opt.color}`} />
</div>
<h3 className="text-2xl font-bold text-white mb-4">{opt.title}</h3>
<p className="text-zinc-400 leading-relaxed text-sm font-light">{opt.description}</p>
<div className="mt-10 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-zinc-500 group-hover:text-white transition-colors">
<span></span>
<Sparkles className="w-4 h-4" />
</div>
</div>
))}
</div>
</div>
);
};
export default ProjectTypeSelector;

540
SceneEditor.tsx Normal file
View File

@ -0,0 +1,540 @@
import React, { useState, useEffect, useRef } from 'react';
import { SceneData, SceneStatus, StyleOption, ModelSettings, CharacterData, LocationData, ReferenceImage, CameraAngle, ShotSize } from '../types';
import { generateImageService, generateVideoService, refineActionToPrompt, constructSpatialPrompt } from '../services/geminiService';
import { CAMERA_ANGLE_LABELS, SHOT_SIZE_LABELS } from '../constants';
import { Image, Video, RefreshCw, Sparkles, Settings, X, Camera, Film, Eye, Wand2, ChevronDown, CheckCircle, Move, Download, Scan, Trash2, MapPin, User, Package, Upload } from 'lucide-react';
interface SceneEditorProps {
scene: SceneData;
characters: CharacterData[];
locations: LocationData[];
styleOption: StyleOption;
onUpdate: (updatedScene: SceneData) => void;
onDelete: (sceneId: string) => void;
modelSettings: ModelSettings;
}
const SceneEditor: React.FC<SceneEditorProps> = ({ scene, characters, locations, styleOption, onUpdate, onDelete, modelSettings }) => {
const [isLoading, setIsLoading] = useState(false);
const [isRefining, setIsRefining] = useState(false);
const [actionInput, setActionInput] = useState(scene.characterPrompt || scene.description);
const [videoPromptInput, setVideoPromptInput] = useState(scene.videoPrompt || "");
const [cameraAngle, setCameraAngle] = useState<CameraAngle>(scene.cameraAngle || CameraAngle.EYE_LEVEL);
const [shotSize, setShotSize] = useState<ShotSize>(scene.shotSize || ShotSize.MEDIUM);
const [showRawPromptModal, setShowRawPromptModal] = useState(false);
const [rawPrompt, setRawPrompt] = useState("");
const canvasRef = useRef<HTMLDivElement>(null);
const propInputRef = useRef<HTMLInputElement>(null);
const [charPositions, setCharPositions] = useState<Record<string, {x: number, y: number}>>({});
const [propPositions, setPropPositions] = useState<Record<string, {x: number, y: number}>>({});
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragType, setDragType] = useState<'char' | 'prop' | null>(null);
const currentLocation = locations.find(l => l.id === scene.locationId);
const activeCharacters = characters.filter(c => scene.characterIds && scene.characterIds.includes(c.id));
const activeProps = scene.props || [];
const hasLocationMaster = !!currentLocation?.masterImage;
const currentImage = scene.videoUrl || scene.compositeImage || scene.backgroundImage || currentLocation?.masterImage;
useEffect(() => {
// Initialize character positions
const initialCharPos: Record<string, {x: number, y: number}> = {};
activeCharacters.forEach((char, index) => {
if (!charPositions[char.id]) {
initialCharPos[char.id] = { x: 20 + (index * 30), y: 60 };
}
});
setCharPositions(prev => ({...prev, ...initialCharPos}));
// Initialize prop positions
const initialPropPos: Record<string, {x: number, y: number}> = {};
activeProps.forEach((prop, index) => {
if (!propPositions[prop.id]) {
initialPropPos[prop.id] = { x: 50 + (index * 10), y: 50 };
}
});
setPropPositions(prev => ({...prev, ...initialPropPos}));
}, [scene.characterIds, scene.props]);
useEffect(() => {
if (scene.characterPrompt) setActionInput(scene.characterPrompt);
if (scene.videoPrompt) {
setVideoPromptInput(scene.videoPrompt);
} else {
const defaultVideoJson = {
prompt: "Cinematic motion, character moving naturally",
camera_movement: "pan_right",
motion_strength: 5
};
setVideoPromptInput(JSON.stringify(defaultVideoJson, null, 2));
}
if (scene.cameraAngle) setCameraAngle(scene.cameraAngle);
if (scene.shotSize) setShotSize(scene.shotSize);
}, [scene.characterPrompt, scene.videoPrompt, scene.description, scene.cameraAngle, scene.shotSize]);
const getSemanticPosition = (x: number, y: number): string => {
let h = "center";
if (x < 33) h = "left"; else if (x > 66) h = "right";
let v = "center";
if (y < 33) v = "top"; else if (y > 66) v = "bottom";
if (h === "center" && v === "center") return "center of the frame";
return `${v} ${h} of the frame`;
};
const constructFullPrompt = (overrideAction?: string) => {
const refs: ReferenceImage[] = [];
activeCharacters.forEach(c => {
const pos = charPositions[c.id] || {x: 50, y: 50};
// DETERMINE VARIANT IMAGE
const variantId = scene.characterVariantMap?.[c.id];
const variant = c.variants?.find(v => v.id === variantId);
const imageUrl = variant ? variant.imageUrl : c.masterImage;
if (imageUrl) {
refs.push({ id: c.id, url: imageUrl, x: pos.x, y: pos.y, width: 0, zIndex: 0, usage: 'face', semanticPosition: getSemanticPosition(pos.x, pos.y) });
}
});
activeProps.forEach(p => {
const pos = propPositions[p.id] || {x: 50, y: 50};
refs.push({ ...p, semanticPosition: getSemanticPosition(pos.x, pos.y) });
});
const actionWithCamera = `[Camera: ${cameraAngle}, Shot: ${shotSize}] ${overrideAction || actionInput}`;
return constructSpatialPrompt(
actionWithCamera,
styleOption,
refs,
'scene',
true,
modelSettings.googleApiKey
);
};
const openRawPromptModal = () => { setRawPrompt(constructFullPrompt()); setShowRawPromptModal(true); };
const handleRefineAction = async () => {
if (!actionInput) return;
if (!modelSettings.googleApiKey) {
alert("請先輸入 Google API Key");
return;
}
setIsRefining(true);
try {
const refined = await refineActionToPrompt(actionInput, styleOption.promptModifier, modelSettings.textModel, modelSettings.googleApiKey);
setActionInput(refined);
} catch (e) { console.error(e); } finally { setIsRefining(false); }
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!draggedId || !canvasRef.current || !dragType) 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));
if (dragType === 'char') {
setCharPositions(prev => ({ ...prev, [draggedId]: { x, y } }));
} else {
setPropPositions(prev => ({ ...prev, [draggedId]: { x, y } }));
}
};
const handlePropUpload = (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 newProp: ReferenceImage = {
id: `prop-${Date.now()}`,
url: event.target.result as string,
x: 50, y: 50, width: 80, zIndex: 10,
usage: 'prop'
};
const newProps = [...(scene.props || []), newProp];
onUpdate({ ...scene, props: newProps });
}
};
reader.readAsDataURL(file);
e.target.value = '';
};
const removeProp = (propId: string) => {
onUpdate({ ...scene, props: (scene.props || []).filter(p => p.id !== propId) });
};
const handleVariantChange = (charId: string, variantId: string) => {
const newMap = { ...(scene.characterVariantMap || {}), [charId]: variantId };
onUpdate({ ...scene, characterVariantMap: newMap });
};
const handleDirectSynthesis = async () => {
if (!hasLocationMaster) {
alert("請先選擇一個已生成定裝照的場景 (Location)。");
return;
}
if (!modelSettings.googleApiKey) {
alert("請先輸入 Google API Key");
return;
}
setIsLoading(true);
onUpdate({ ...scene, status: SceneStatus.GENERATING_CHAR });
try {
const refs: ReferenceImage[] = [];
// Add Character Refs (Variant Aware)
activeCharacters.forEach(c => {
const variantId = scene.characterVariantMap?.[c.id];
const variant = c.variants?.find(v => v.id === variantId);
const imageUrl = variant ? variant.imageUrl : c.masterImage;
if (imageUrl) {
const pos = charPositions[c.id] || {x: 50, y: 50};
refs.push({
id: `char-master-${c.id}`,
url: imageUrl,
x: pos.x,
y: pos.y,
width: 0,
zIndex: 0,
usage: 'face',
semanticPosition: getSemanticPosition(pos.x, pos.y)
});
}
});
// Add Prop Refs
activeProps.forEach(p => {
const pos = propPositions[p.id] || {x: 50, y: 50};
refs.push({
...p,
semanticPosition: getSemanticPosition(pos.x, pos.y)
});
});
const actionWithCamera = `[Camera: ${cameraAngle}, Shot: ${shotSize}] ${actionInput}`;
const finalPrompt = constructSpatialPrompt(
actionWithCamera,
styleOption,
refs,
'scene',
true,
modelSettings.googleApiKey
);
const imageUrl = await generateImageService(
finalPrompt,
styleOption,
modelSettings.imageModel,
'scene',
currentLocation!.masterImage,
refs,
modelSettings.googleApiKey,
modelSettings.devMode || false
);
onUpdate({
...scene,
backgroundImage: currentLocation!.masterImage,
compositeImage: imageUrl,
characterPrompt: actionInput,
cameraAngle: cameraAngle,
shotSize: shotSize,
status: SceneStatus.CHAR_READY
});
} catch (error: any) {
onUpdate({ ...scene, status: SceneStatus.ERROR });
alert(error.message);
} finally { setIsLoading(false); setShowRawPromptModal(false); }
};
const handleGenerateVideo = async () => {
if (!scene.compositeImage) return;
setIsLoading(true);
onUpdate({ ...scene, status: SceneStatus.GENERATING_VIDEO });
try {
const videoUrl = await generateVideoService(videoPromptInput, scene.compositeImage);
onUpdate({ ...scene, videoUrl: videoUrl, videoPrompt: videoPromptInput, status: SceneStatus.VIDEO_READY });
} catch (error) { onUpdate({ ...scene, status: SceneStatus.ERROR }); } finally { setIsLoading(false); }
};
const handleDownloadImage = (url: string, filename: string) => {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const toggleCharacter = (charId: string) => {
const currentIds = scene.characterIds || [];
const newIds = currentIds.includes(charId)
? currentIds.filter(id => id !== charId)
: [...currentIds, charId];
onUpdate({ ...scene, characterIds: newIds });
};
return (
<div className="glass-card rounded-3xl overflow-hidden flex flex-col md:flex-row shadow-2xl min-h-[550px] transition-all hover:border-white/20">
{/* Visual Area */}
<div
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseUp={() => { setDraggedId(null); setDragType(null); }}
onMouseLeave={() => { setDraggedId(null); setDragType(null); }}
className="relative w-full md:w-7/12 bg-black/40 group overflow-hidden flex items-center justify-center backdrop-blur-sm select-none"
>
<div className="absolute top-4 left-4 z-30 flex gap-2">
<button onClick={() => propInputRef.current?.click()} className="p-2 bg-black/50 hover:bg-black/80 text-emerald-400 rounded-lg backdrop-blur-md transition-all flex items-center gap-2 text-xs font-bold border border-emerald-500/30">
<Upload className="w-3 h-3" />
</button>
<input type="file" ref={propInputRef} className="hidden" accept="image/*" onChange={handlePropUpload} />
</div>
{currentImage ? (
scene.videoUrl ? (
<video src={currentImage} controls autoPlay loop className="w-full h-full object-contain" />
) : (
<div className="relative w-full h-full">
<img src={currentImage} className="w-full h-full object-contain pointer-events-none" />
{scene.compositeImage && (
<button
onClick={() => handleDownloadImage(scene.compositeImage!, `lumina-scene-${scene.sceneNumber}.png`)}
className="absolute bottom-4 right-4 bg-black/60 hover:bg-black/90 text-white p-3 rounded-full backdrop-blur-md transition-all z-40 border border-white/20 shadow-lg"
title="下載合成圖片"
>
<Download className="w-5 h-5" />
</button>
)}
</div>
)
) : (
<div className="text-center p-8"><Image className="w-16 h-16 mb-4 text-zinc-700 mx-auto" /><p className="text-zinc-600 font-medium"></p></div>
)}
{!scene.videoUrl && !isLoading && !scene.compositeImage && activeCharacters.map(char => {
const variantId = scene.characterVariantMap?.[char.id];
const variant = char.variants?.find(v => v.id === variantId);
const img = variant ? variant.imageUrl : char.masterImage;
if (!img) return null;
const pos = charPositions[char.id] || {x: 50, y: 50};
return (
<div
key={char.id}
onMouseDown={(e) => { e.preventDefault(); setDraggedId(char.id); setDragType('char'); }}
className={`absolute w-20 h-20 rounded-full border-2 border-white/50 shadow-2xl overflow-hidden cursor-move z-10 ${draggedId === char.id ? 'scale-110 border-indigo-400' : ''}`}
style={{
left: `${pos.x}%`,
top: `${pos.y}%`,
transform: 'translate(-50%, -50%)',
backgroundImage: `url(${img})`,
backgroundSize: 'cover'
}}
title={`${char.name} (${variant ? variant.name : 'Default'})`}
/>
);
})}
{!scene.videoUrl && !isLoading && !scene.compositeImage && activeProps.map(prop => {
const pos = propPositions[prop.id] || {x: 50, y: 50};
return (
<div
key={prop.id}
onMouseDown={(e) => { e.preventDefault(); setDraggedId(prop.id); setDragType('prop'); }}
className={`absolute w-20 h-20 border border-emerald-400/50 shadow-xl cursor-move z-20 group/prop ${draggedId === prop.id ? 'scale-110 border-emerald-400' : ''}`}
style={{
left: `${pos.x}%`,
top: `${pos.y}%`,
transform: 'translate(-50%, -50%)'
}}
>
<img src={prop.url} className="w-full h-full object-contain pointer-events-none" />
<button onClick={() => removeProp(prop.id)} className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-0.5 opacity-0 group-hover/prop:opacity-100 transition-opacity"><X className="w-3 h-3" /></button>
</div>
);
})}
{isLoading && <div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20"><RefreshCw className="w-10 h-10 text-indigo-500 animate-spin mb-4" /><span className="text-xs text-indigo-400 font-bold tracking-[0.2em] animate-pulse">RENDERING...</span></div>}
</div>
{/* Control Area */}
<div className="w-full md:w-5/12 p-8 flex flex-col bg-zinc-900/10 backdrop-blur-md border-l border-white/5">
<div className="mb-4 pb-4 border-b border-white/5 flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-zinc-500"></span>
<input
type="number"
value={scene.sceneNumber}
onChange={(e) => onUpdate({ ...scene, sceneNumber: parseInt(e.target.value) || 1 })}
className="text-2xl font-bold text-white tracking-tight bg-transparent border-none outline-none focus:outline-none focus:ring-0 p-0 w-20"
style={{ borderBottom: '1px solid transparent' }}
onFocus={(e) => e.target.style.borderBottomColor = 'rgba(99, 102, 241, 0.5)'}
onBlur={(e) => e.target.style.borderBottomColor = 'transparent'}
min="1"
/>
</div>
<p className="text-xs text-zinc-500">Scene Control</p>
</div>
<button onClick={() => onDelete(scene.id)} className="text-zinc-600 hover:text-red-400 p-2 rounded-lg hover:bg-white/5 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="space-y-6 flex-1 overflow-y-auto pr-2">
<div className="bg-white/5 rounded-xl p-4 space-y-4">
<h4 className="text-xs font-bold text-zinc-400 uppercase tracking-wider flex items-center gap-2"><Settings className="w-3 h-3" /> 調 (Setup)</h4>
<div>
<label className="text-[10px] text-zinc-500 uppercase font-bold mb-1 block flex items-center gap-1"><MapPin className="w-3 h-3" /> (Location)</label>
<select
value={scene.locationId || ''}
onChange={(e) => onUpdate({ ...scene, locationId: e.target.value })}
className="glass-input w-full rounded-lg p-2 text-xs"
>
<option value="">...</option>
{locations.map(l => <option key={l.id} value={l.id}>{l.name} {l.masterImage ? '✓' : ''}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-zinc-500 uppercase font-bold mb-2 block flex items-center gap-1"><User className="w-3 h-3" /> (Cast)</label>
<div className="flex flex-col gap-2">
{characters.map(c => {
const isSelected = scene.characterIds?.includes(c.id);
const variants = c.variants || [];
return (
<div key={c.id} className="flex items-center gap-2">
<button
onClick={() => toggleCharacter(c.id)}
className={`px-2 py-1.5 rounded text-xs border transition-all flex-1 text-left ${isSelected ? 'bg-indigo-500/30 border-indigo-500 text-white' : 'border-zinc-700 text-zinc-500 hover:border-zinc-500'}`}
>
{c.name} {c.masterImage ? '✓' : ''}
</button>
{/* Variant Selector */}
{isSelected && variants.length > 0 && (
<select
value={scene.characterVariantMap?.[c.id] || ''}
onChange={(e) => handleVariantChange(c.id, e.target.value)}
className="glass-input rounded-lg p-1 text-[10px] w-24 bg-black"
>
<option value=""></option>
{variants.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
)}
</div>
)
})}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-zinc-500 uppercase font-bold tracking-wider mb-1 block">
<Move className="w-3 h-3 inline mr-1" /> (Angle)
</label>
<select
value={cameraAngle}
onChange={(e) => setCameraAngle(e.target.value as CameraAngle)}
className="glass-input w-full rounded-xl p-2 text-xs appearance-none bg-zinc-900"
>
{Object.entries(CAMERA_ANGLE_LABELS).map(([key, label]) => (
<option key={key} value={key} className="bg-zinc-900">{label}</option>
))}
</select>
</div>
<div>
<label className="text-[10px] text-zinc-500 uppercase font-bold tracking-wider mb-1 block">
<Scan className="w-3 h-3 inline mr-1" /> (Shot)
</label>
<select
value={shotSize}
onChange={(e) => setShotSize(e.target.value as ShotSize)}
className="glass-input w-full rounded-xl p-2 text-xs appearance-none bg-zinc-900"
>
{Object.entries(SHOT_SIZE_LABELS).map(([key, label]) => (
<option key={key} value={key} className="bg-zinc-900">{label}</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="text-[10px] text-zinc-500 uppercase font-bold tracking-wider">/ (Action)</label>
<button onClick={openRawPromptModal} className="text-[10px] text-indigo-400 flex items-center gap-1"><Settings className="w-3 h-3" /> </button>
</div>
<div className="relative">
<textarea
value={actionInput}
onChange={(e) => setActionInput(e.target.value)}
className="glass-input w-full rounded-xl p-3 text-sm focus:outline-none resize-none min-h-[80px]"
placeholder="請描述動作..."
/>
<button onClick={handleRefineAction} disabled={isRefining} className="absolute right-2 bottom-2 text-indigo-500 hover:text-white bg-indigo-500/10 p-1.5 rounded-lg"><Wand2 className={`w-4 h-4 ${isRefining ? 'animate-spin' : ''}`} /></button>
</div>
</div>
{hasLocationMaster ? (
<button onClick={handleDirectSynthesis} disabled={isLoading} className="glass-btn w-full bg-indigo-600/30 border-indigo-500/50 hover:bg-indigo-600/50 text-white py-3 rounded-xl font-bold flex items-center justify-center gap-2">
<Sparkles className="w-4 h-4" /> {scene.compositeImage ? '重新合成 (Re-Synthesize)' : '合成場景 (Synthesize)'}
</button>
) : (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-red-300 text-xs text-center">
(Location)
</div>
)}
{scene.compositeImage && (
<div className="border-t border-white/5 pt-4 mt-2 space-y-3">
<label className="text-[10px] text-zinc-500 uppercase font-bold tracking-wider flex items-center gap-1"><Film className="w-3 h-3" /> (Veo JSON)</label>
<textarea
value={videoPromptInput}
onChange={(e) => setVideoPromptInput(e.target.value)}
className="glass-input w-full rounded-xl p-3 text-xs font-mono text-emerald-300 focus:outline-none resize-none h-24"
placeholder='{"prompt": "...", "camera_movement": "pan_right"}'
/>
<button onClick={handleGenerateVideo} disabled={isLoading} className="glass-btn w-full hover:bg-white/10 text-white py-3 rounded-xl font-bold flex items-center justify-center gap-2">
<Video className="w-4 h-4 text-purple-400" /> (Generate Video)
</button>
</div>
)}
</div>
</div>
{showRawPromptModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
<div className="glass-card rounded-2xl w-full max-w-2xl flex flex-col max-h-[90vh]">
<div className="p-4 border-b border-white/5 flex justify-between items-center">
<h3 className="font-bold text-white"> (Prompt Preview)</h3>
<button onClick={() => setShowRawPromptModal(false)}><X className="w-5 h-5 text-zinc-500" /></button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
<pre className="text-xs text-emerald-400 font-mono whitespace-pre-wrap">{rawPrompt}</pre>
</div>
<div className="p-4 border-t border-white/5 text-right">
<button onClick={() => setShowRawPromptModal(false)} className="glass-btn px-6 py-2 rounded-lg text-white font-bold"></button>
</div>
</div>
</div>
)}
</div>
);
};
export default SceneEditor;

251
SettingsModal.tsx Normal file
View File

@ -0,0 +1,251 @@
import React, { useEffect, useState } from 'react';
import { Settings, X, RefreshCw, Cpu, Server, Key, AlertTriangle, Code } from 'lucide-react';
import { ModelSettings } from '../types';
import { AVAILABLE_TEXT_MODELS, AVAILABLE_IMAGE_MODELS } from '../constants';
import { fetchAvailableGeminiModels } from '../services/gemini/client';
import { fetchOpenAIModels } from '../services/openai/client';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
modelSettings: ModelSettings;
onUpdateSettings: (settings: ModelSettings) => void;
}
const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, modelSettings, onUpdateSettings }) => {
const [loadingGoogleModels, setLoadingGoogleModels] = useState(false);
const [loadingOpenAIModels, setLoadingOpenAIModels] = useState(false);
const [openAIError, setOpenAIError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && modelSettings.provider === 'google' && modelSettings.googleApiKey) {
// Optional: auto-fetch on open if key exists?
// Better to let user click refresh to save quota/calls
}
}, [isOpen]);
const loadDynamicGoogleModels = async () => {
if (!modelSettings.googleApiKey) {
alert("請先輸入 Google API Key");
return;
}
setLoadingGoogleModels(true);
try {
const { textModels, imageModels } = await fetchAvailableGeminiModels(modelSettings.googleApiKey);
if (textModels.length > 0 || imageModels.length > 0) {
onUpdateSettings({
...modelSettings,
availableTextModels: textModels,
availableImageModels: imageModels
});
}
} catch (e: any) {
alert(`Google Models 更新失敗: ${e.message}`);
} finally {
setLoadingGoogleModels(false);
}
};
const loadDynamicOpenAIModels = async () => {
if (!modelSettings.openaiApiKey) {
alert("請先輸入 API Key");
return;
}
setLoadingOpenAIModels(true);
setOpenAIError(null);
try {
const models = await fetchOpenAIModels(modelSettings.openaiBaseUrl, modelSettings.openaiApiKey);
if (models.length > 0) {
onUpdateSettings({
...modelSettings,
availableOpenAIModels: models
});
} else {
setOpenAIError("未找到可用模型,請檢查 URL 或 Key");
}
} catch (e: any) {
console.error(e);
setOpenAIError(e.message);
} finally {
setLoadingOpenAIModels(false);
}
};
if (!isOpen) return null;
const googleTextModels = modelSettings.availableTextModels?.length ? modelSettings.availableTextModels : AVAILABLE_TEXT_MODELS;
const googleImageModels = modelSettings.availableImageModels?.length ? modelSettings.availableImageModels : AVAILABLE_IMAGE_MODELS;
const openAIModels = modelSettings.availableOpenAIModels || [];
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className="glass-card rounded-3xl w-full max-w-lg p-8 shadow-2xl relative animate-fade-in-up flex flex-col max-h-[90vh]">
<button onClick={onClose} className="absolute top-4 right-4 text-zinc-500 hover:text-white">
<X className="w-5 h-5" />
</button>
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
<Settings className="w-5 h-5 text-indigo-500" /> (System Settings)
</h3>
<div className="space-y-6 overflow-y-auto pr-2">
{/* Provider Toggle */}
<div className="bg-white/5 p-4 rounded-xl border border-white/10">
<label className="text-xs font-bold text-zinc-400 mb-3 uppercase tracking-wider flex items-center gap-2">
<Cpu className="w-4 h-4" /> AI (Inference Provider)
</label>
<div className="flex bg-black/40 rounded-lg p-1">
<button
onClick={() => onUpdateSettings({ ...modelSettings, provider: 'google' })}
className={`flex-1 py-2 rounded-md text-sm font-bold transition-all ${modelSettings.provider === 'google' ? 'bg-indigo-500 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'}`}
>
Google Gemini
</button>
<button
onClick={() => onUpdateSettings({ ...modelSettings, provider: 'openai' })}
className={`flex-1 py-2 rounded-md text-sm font-bold transition-all ${modelSettings.provider === 'openai' ? 'bg-indigo-500 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'}`}
>
OpenAI / Grok
</button>
</div>
</div>
{modelSettings.provider === 'google' ? (
// GOOGLE SETTINGS
<div className="space-y-4 animate-fade-in">
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 uppercase tracking-wider flex items-center gap-2"><Key className="w-3 h-3" /> Google API Key</label>
<input
type="password"
className="glass-input w-full rounded-xl p-3 text-sm font-mono text-indigo-300"
value={modelSettings.googleApiKey || ''}
onChange={(e) => onUpdateSettings({ ...modelSettings, googleApiKey: e.target.value })}
placeholder="AIza..."
/>
</div>
<div className="relative">
<div className="flex justify-between items-center mb-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider"> (Text Model)</label>
<button onClick={loadDynamicGoogleModels} disabled={loadingGoogleModels} className="text-[10px] text-indigo-400 flex items-center gap-1 hover:text-indigo-300 bg-indigo-500/10 px-2 py-1 rounded">
<RefreshCw className={`w-3 h-3 ${loadingGoogleModels ? 'animate-spin' : ''}`} />
{loadingGoogleModels ? '抓取中...' : '刷新真實列表'}
</button>
</div>
<select
className="glass-input w-full rounded-xl p-3 text-sm bg-black"
value={modelSettings.textModel}
onChange={(e) => onUpdateSettings({ ...modelSettings, textModel: e.target.value })}
>
{googleTextModels.map(m => <option key={m.id} value={m.id} className="bg-zinc-900">{m.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 uppercase tracking-wider"> (Image Model)</label>
<select
className="glass-input w-full rounded-xl p-3 text-sm bg-black"
value={modelSettings.imageModel}
onChange={(e) => onUpdateSettings({ ...modelSettings, imageModel: e.target.value })}
>
{googleImageModels.map(m => <option key={m.id} value={m.id} className="bg-zinc-900">{m.name}</option>)}
</select>
</div>
</div>
) : (
// OPENAI / GROK SETTINGS
<div className="space-y-4 animate-fade-in">
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 uppercase tracking-wider flex items-center gap-2"><Server className="w-3 h-3" /> API Base URL</label>
<input
type="text"
className="glass-input w-full rounded-xl p-3 text-sm font-mono text-emerald-300"
value={modelSettings.openaiBaseUrl}
onChange={(e) => onUpdateSettings({ ...modelSettings, openaiBaseUrl: e.target.value })}
placeholder="https://api.x.ai/v1"
/>
<p className="text-[10px] text-zinc-500 mt-1">例如: Grok (https://api.x.ai/v1), OpenAI (https://api.openai.com/v1)</p>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-2 uppercase tracking-wider flex items-center gap-2"><Key className="w-3 h-3" /> API Key</label>
<input
type="password"
className="glass-input w-full rounded-xl p-3 text-sm font-mono"
value={modelSettings.openaiApiKey}
onChange={(e) => onUpdateSettings({ ...modelSettings, openaiApiKey: e.target.value })}
placeholder="sk-..."
/>
</div>
<div className="relative">
<div className="flex justify-between items-center mb-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-wider">Model Name</label>
<button onClick={loadDynamicOpenAIModels} disabled={loadingOpenAIModels} className="text-[10px] text-emerald-400 flex items-center gap-1 hover:text-emerald-300 bg-emerald-500/10 px-2 py-1 rounded">
<RefreshCw className={`w-3 h-3 ${loadingOpenAIModels ? 'animate-spin' : ''}`} />
{loadingOpenAIModels ? '抓取中...' : '刷新模型列表'}
</button>
</div>
{openAIError && (
<div className="mb-2 text-[10px] text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> {openAIError}
</div>
)}
{openAIModels.length > 0 ? (
<select
className="glass-input w-full rounded-xl p-3 text-sm bg-black"
value={modelSettings.openaiModel}
onChange={(e) => onUpdateSettings({ ...modelSettings, openaiModel: e.target.value })}
>
{openAIModels.map(m => (
<option key={m.id} value={m.id} className="bg-zinc-900">{m.name}</option>
))}
</select>
) : (
<input
type="text"
className="glass-input w-full rounded-xl p-3 text-sm font-mono"
value={modelSettings.openaiModel}
onChange={(e) => onUpdateSettings({ ...modelSettings, openaiModel: e.target.value })}
placeholder="grok-beta"
/>
)}
</div>
<div className="bg-yellow-500/10 border border-yellow-500/30 p-3 rounded-lg text-xs text-yellow-200">
OpenAI / Grok Google Gemini Image Model ( Google Key)
</div>
</div>
)}
{/* DEV MODE Toggle */}
<div className="bg-amber-500/10 border border-amber-500/30 p-4 rounded-xl">
<label className="text-xs font-bold text-amber-300 mb-3 uppercase tracking-wider flex items-center gap-2">
<Code className="w-4 h-4" /> (DEV MODE)
</label>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-xs text-amber-200/80 mb-1">使</p>
<p className="text-[10px] text-amber-300/60">Use placeholder images when generation fails</p>
</div>
<button
onClick={() => onUpdateSettings({ ...modelSettings, devMode: !modelSettings.devMode })}
className={`relative w-14 h-8 rounded-full transition-all duration-300 ${
modelSettings.devMode ? 'bg-amber-500' : 'bg-zinc-700'
}`}
>
<div className={`absolute top-1 left-1 w-6 h-6 bg-white rounded-full transition-transform duration-300 ${
modelSettings.devMode ? 'translate-x-6' : 'translate-x-0'
}`} />
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default SettingsModal;

303
StructurePlanner.tsx Normal file
View File

@ -0,0 +1,303 @@
import React, { useState } from 'react';
import { ProjectStructure, StructureItem, ProjectType } from '../types';
import { Film, Edit2, Trash2, Plus, Save, Eye, Dices, X, Sparkles, Clock, Zap, Terminal, ChevronDown, ChevronRight } from 'lucide-react';
import { optimizeVisualPrompt } from '../services/geminiService';
interface StructurePlannerProps {
structure: ProjectStructure;
type: ProjectType;
onSelectEpisode: (item: StructureItem) => void;
onUpdateStructure: (newStructure: ProjectStructure) => void;
onReroll: () => void;
isGenerating: boolean;
producingItemId: string | null;
modelSettings: any; // Using any to avoid circular type ref, should be ModelSettings
}
const StructurePlanner: React.FC<StructurePlannerProps> = ({ structure, type, onSelectEpisode, onUpdateStructure, onReroll, isGenerating, producingItemId, modelSettings }) => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<StructureItem | null>(null);
const [editMode, setEditMode] = useState<'auto' | 'pro'>('auto'); // New: Auto vs Pro mode
const [isOptimizing, setIsOptimizing] = useState(false);
const getHeaderIcon = () => {
switch (type) {
case ProjectType.MUSIC_VIDEO: return <MusicIcon className="w-6 h-6 text-purple-500" />;
case ProjectType.VIRTUAL_INFLUENCER: return <SmartphoneIcon className="w-6 h-6 text-pink-500" />;
default: return <Film className="w-6 h-6 text-red-500" />;
}
};
const getItemLabel = () => {
switch (type) {
case ProjectType.MUSIC_VIDEO: return "段落 (Beat)";
case ProjectType.VIRTUAL_INFLUENCER: return "貼文 (Post)";
default: return "集數 (Episode)";
}
};
const handleEditClick = (item: StructureItem) => {
setEditingId(item.id);
setEditForm({ ...item });
setEditMode('auto'); // Default to auto mode on open
};
const handleSaveEdit = () => {
if (editForm) {
const newItems = structure.items.map(i => i.id === editForm.id ? editForm : i);
onUpdateStructure({ ...structure, items: newItems });
setEditingId(null);
setEditForm(null);
}
};
const handleDelete = (id: string) => {
if (confirm('確定要刪除這個項目嗎?')) {
const newItems = structure.items.filter(i => i.id !== id);
onUpdateStructure({ ...structure, items: newItems });
}
};
const handleAdd = () => {
const newItem: StructureItem = {
id: `custom-${Date.now()}`,
title: '新項目',
summary: '',
visualConcept: '',
estimatedDuration: '1 min'
};
onUpdateStructure({ ...structure, items: [...structure.items, newItem] });
setEditingId(newItem.id);
setEditForm(newItem);
setEditMode('auto');
};
const handleOptimizePrompt = async () => {
if (!editForm) return;
if (!modelSettings.googleApiKey) {
alert("請輸入 Google API Key");
return;
}
setIsOptimizing(true);
try {
const refined = await optimizeVisualPrompt(editForm.summary, editForm.visualConcept, modelSettings.textModel, modelSettings.googleApiKey);
setEditForm({ ...editForm, visualConcept: refined });
} catch (e) {
console.error(e);
} finally {
setIsOptimizing(false);
}
};
// Helper to parse duration string "45 min" -> ["45", "min"]
const parseDuration = (dur: string = '1 min') => {
const match = dur.match(/(\d+)\s*(min|sec)/);
if (match) return { val: match[1], unit: match[2] };
return { val: '1', unit: 'min' };
};
const updateDuration = (val: string, unit: string) => {
if (!editForm) return;
setEditForm({ ...editForm, estimatedDuration: `${val} ${unit}` });
};
return (
<div className="animate-fade-in space-y-8 pb-32 max-w-full">
{/* Series Header */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 text-center relative overflow-hidden group hover:border-indigo-500/30 transition-colors">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500" />
<h2 className="text-3xl font-bold text-white mb-2 text-glow break-all max-w-full">{structure.title}</h2>
<p className="text-zinc-400 max-w-2xl mx-auto italic break-all">{structure.logline}</p>
</div>
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-4 flex-wrap">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
{getHeaderIcon()}
{type === ProjectType.NETFLIX_SERIES ? '季/集規劃 (Season Breakdown)' : '內容規劃 (Content Plan)'}
</h3>
<button
onClick={onReroll}
disabled={isGenerating}
className="glass-btn px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-2 text-zinc-400 hover:text-white shrink-0"
title="換一批 (隨機重骰)"
>
<Dices className={`w-4 h-4 ${isGenerating ? 'animate-spin' : ''}`} />
<span>{isGenerating ? '列表更新中...' : '換一批'}</span>
</button>
</div>
<button
onClick={handleAdd}
className="glass-btn px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 text-emerald-400 hover:bg-emerald-500/10 shrink-0"
>
<Plus className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{structure.items.map((item, index) => {
const isEditing = editingId === item.id;
const isProducing = producingItemId === item.id;
return (
<div key={item.id} className={`bg-black border rounded-xl overflow-hidden transition-all w-full max-w-full ${isEditing ? 'border-indigo-500 shadow-[0_0_20px_rgba(99,102,241,0.2)]' : 'border-zinc-800 hover:border-zinc-600'} ${isProducing ? 'ring-2 ring-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]' : ''}`}>
<div className="p-6">
{isEditing && editForm ? (
// EDIT MODE
<div className="space-y-4 max-w-full">
<div className="flex flex-wrap justify-between items-center mb-2 pb-2 border-b border-white/5 gap-2">
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setEditMode('auto')}
className={`px-3 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors flex items-center gap-1 ${editMode === 'auto' ? 'bg-indigo-500 text-white' : 'text-zinc-500 hover:text-white'}`}
>
<Zap className="w-3 h-3" />
</button>
<button
onClick={() => setEditMode('pro')}
className={`px-3 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors flex items-center gap-1 ${editMode === 'pro' ? 'bg-purple-500 text-white' : 'text-zinc-500 hover:text-white'}`}
>
<Terminal className="w-3 h-3" />
</button>
</div>
<button onClick={() => setEditingId(null)} className="text-zinc-500 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 min-w-0">
<label className="text-[10px] text-zinc-500 uppercase font-bold"> ()</label>
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm({...editForm, title: e.target.value})}
className="glass-input w-full rounded-lg px-3 py-2 text-sm font-bold min-w-0"
placeholder="例如:第一集:暗巷之戰"
/>
</div>
<div className="w-full sm:w-32 shrink-0">
<label className="text-[10px] text-zinc-500 uppercase font-bold"></label>
<div className="flex relative">
<input
type="number"
min="1"
value={parseDuration(editForm.estimatedDuration).val}
onChange={(e) => updateDuration(e.target.value, parseDuration(editForm.estimatedDuration).unit)}
className="glass-input w-full rounded-l-lg px-2 py-2 text-sm text-center border-r-0"
/>
<div className="relative w-20">
<select
value={parseDuration(editForm.estimatedDuration).unit}
onChange={(e) => updateDuration(parseDuration(editForm.estimatedDuration).val, e.target.value)}
className="glass-input w-full rounded-r-lg pl-1 pr-6 py-2 text-xs appearance-none bg-zinc-900 border-l-0"
>
<option value="min">min</option>
<option value="sec">sec</option>
</select>
<ChevronDown className="absolute right-1 top-1/2 -translate-y-1/2 w-3 h-3 text-zinc-500 pointer-events-none" />
</div>
</div>
</div>
</div>
<div>
<label className="text-[10px] text-zinc-500 uppercase font-bold">/ ()</label>
<textarea
value={editForm.summary}
onChange={(e) => setEditForm({...editForm, summary: e.target.value})}
className="glass-input w-full rounded-lg px-3 py-2 text-sm min-h-[80px]"
placeholder="描述這一段的主要情節..."
/>
</div>
{editMode === 'pro' && (
<div className="animate-fade-in">
<div className="flex justify-between items-center mb-1">
<label className="text-[10px] text-purple-400 uppercase font-bold">Visual Prompt (English)</label>
<button
onClick={handleOptimizePrompt}
disabled={isOptimizing}
className="text-[10px] flex items-center gap-1 text-purple-300 hover:text-white bg-purple-500/10 px-2 py-0.5 rounded border border-purple-500/30"
>
<Sparkles className={`w-3 h-3 ${isOptimizing ? 'animate-spin' : ''}`} />
{isOptimizing ? 'AI 分析優化中...' : 'AI 分析優化'}
</button>
</div>
<textarea
value={editForm.visualConcept}
onChange={(e) => setEditForm({...editForm, visualConcept: e.target.value})}
className="glass-input w-full rounded-lg px-3 py-2 text-xs font-mono text-purple-200 min-h-[80px] border-purple-500/30"
placeholder="Enter detailed English prompt here..."
/>
</div>
)}
<div className="flex gap-2 pt-2">
<button onClick={handleSaveEdit} className="glass-btn flex-1 bg-indigo-600/30 text-white py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 hover:bg-indigo-500/50">
<Save className="w-4 h-4" />
</button>
</div>
</div>
) : (
// DISPLAY MODE
<div className="flex flex-col h-full max-w-full">
<div className="flex justify-between items-start mb-4">
<span className="text-xs font-bold text-zinc-500 uppercase tracking-wider">{getItemLabel()} {index + 1}</span>
<div className="flex gap-2 shrink-0">
{/* Hover Tooltip for Prompt */}
<div className="relative group">
<div className="p-1.5 text-zinc-600 hover:text-indigo-400 cursor-help rounded-lg transition-colors">
<Eye className="w-3.5 h-3.5" />
</div>
{/* Tooltip Content */}
<div className="absolute bottom-full right-0 mb-2 w-64 p-3 bg-black/95 border border-indigo-500/30 rounded-lg text-xs text-indigo-200 shadow-xl hidden group-hover:block z-50 pointer-events-none backdrop-blur-md">
<div className="font-bold mb-1 text-white border-b border-white/10 pb-1">Visual Prompt (AI )</div>
<div className="font-mono opacity-80 leading-relaxed break-words">{item.visualConcept || "Auto-generated"}</div>
</div>
</div>
<button onClick={() => handleEditClick(item)} className="p-1.5 text-zinc-600 hover:text-white hover:bg-white/10 rounded-lg transition-colors" title="編輯"><Edit2 className="w-3.5 h-3.5" /></button>
<button onClick={() => handleDelete(item.id)} className="p-1.5 text-zinc-600 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors" title="刪除"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
<div className="mb-4 overflow-hidden">
<h4 className="text-lg font-bold text-white mb-1 line-clamp-2 break-all">{item.title}</h4>
<div className="flex items-center gap-2 text-[10px] text-zinc-500 font-bold uppercase tracking-wider mb-2">
<Clock className="w-3 h-3" /> {item.estimatedDuration || '1 min'}
</div>
<p className="text-sm text-zinc-400 line-clamp-3 leading-relaxed break-all">{item.summary}</p>
</div>
<div className="mt-auto pt-2">
<button
onClick={() => onSelectEpisode(item)}
disabled={isGenerating || !!producingItemId}
className={`w-full py-3 rounded-xl font-bold transition-all flex items-center justify-center gap-2 group-hover:scale-[1.02] border ${isProducing ? 'bg-emerald-600/20 border-emerald-500/50 text-emerald-300' : 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/30 border-indigo-400/50'}`}
>
{isProducing ? (
<><Sparkles className="w-4 h-4 animate-spin" /> ...</>
) : (
<>{isGenerating && producingItemId !== item.id ? '等待中...' : '製作此集 (Produce)'} {!isGenerating && <ChevronRight className="w-4 h-4" />}</>
)}
</button>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
// Icons helper
const MusicIcon = ({className}: {className?: string}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>;
const SmartphoneIcon = ({className}: {className?: string}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>;
export default StructurePlanner;

50
StyleCard.tsx Normal file
View File

@ -0,0 +1,50 @@
import React from 'react';
import { StyleOption } from '../types';
import { Check } from 'lucide-react';
interface StyleCardProps {
styleOption: StyleOption;
isSelected: boolean;
onSelect: (style: StyleOption) => void;
}
const StyleCard: React.FC<StyleCardProps> = ({ styleOption, isSelected, onSelect }) => {
return (
<div
onClick={() => onSelect(styleOption)}
className={`glass-card relative group cursor-pointer rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 h-[340px] flex flex-col ${
isSelected ? 'ring-2 ring-indigo-500 shadow-[0_0_30px_rgba(99,102,241,0.3)]' : 'hover:bg-white/5'
}`}
>
<div className="h-48 overflow-hidden relative">
<div className="absolute inset-0 bg-gradient-to-t from-[#0f0f11] via-transparent to-transparent z-10" />
<img
src={styleOption.previewImage}
alt={styleOption.name}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
{isSelected && (
<div className="absolute top-4 right-4 z-20 bg-indigo-500/80 backdrop-blur-md rounded-full p-1.5 shadow-lg shadow-indigo-500/50">
<Check className="w-4 h-4 text-white" />
</div>
)}
</div>
<div className="p-6 flex flex-col flex-1 relative z-20">
<h3 className={`text-xl font-bold mb-2 ${isSelected ? 'text-indigo-400 text-glow' : 'text-white group-hover:text-indigo-300'} transition-colors`}>
{styleOption.name}
</h3>
<p className="text-sm text-zinc-400 leading-relaxed line-clamp-3 font-light">{styleOption.description}</p>
<div className="mt-auto pt-4 flex">
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-600 border border-zinc-700/50 px-2 py-1 rounded group-hover:border-indigo-500/30 group-hover:text-indigo-400 transition-colors">
Visual DNA
</span>
</div>
</div>
</div>
);
};
export default StyleCard;