541 lines
26 KiB
TypeScript
541 lines
26 KiB
TypeScript
|
|
|
||
|
|
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;
|