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 = ({ 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(scene.cameraAngle || CameraAngle.EYE_LEVEL); const [shotSize, setShotSize] = useState(scene.shotSize || ShotSize.MEDIUM); const [showRawPromptModal, setShowRawPromptModal] = useState(false); const [rawPrompt, setRawPrompt] = useState(""); const canvasRef = useRef(null); const propInputRef = useRef(null); const [charPositions, setCharPositions] = useState>({}); const [propPositions, setPropPositions] = useState>({}); const [draggedId, setDraggedId] = useState(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 = {}; 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 = {}; 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) => { 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 (
{/* Visual Area */}
{ 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" >
{currentImage ? ( scene.videoUrl ? (