fix: doc
This commit is contained in:
commit
29ee161a2f
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue