| import React, { useEffect, useState, useCallback } from "react"; |
| import { ArrowLeft, Play, Pause, SkipBack, SkipForward, Plus, RefreshCw, Check, Loader2, ImageIcon, Monitor, Edit3, Wand2, Save, X, Settings, FileText, Sparkles, AlertCircle, Download, Tv, Smartphone, Presentation, Camera, Palette, Star, Lock, Shield, Film, MoreHorizontal, Sliders, ChevronDown, } from "lucide-react"; |
| import { useTVModeStore } from "./studio/stores/tvModeStore"; |
| import { TVModeContainer } from "./studio/components/TVMode/TVModeContainer"; |
| import CreatorStudioSettings, { CREATOR_STUDIO_PARAM_DEFAULTS, } from "./CreatorStudioSettings"; |
| import CreatorExportWizard from "./components/CreatorExportWizard"; |
| |
| |
| |
| |
| export function CreatorStudioEditor({ projectId, backendUrl, apiKey, onExit, autoGenerateFirst = false, targetSceneCount = 8, defaultLLMModel = "", imageProvider = "comfyui", imageModel, imageWidth = 1344, imageHeight = 768, imageSteps, imageCfg, videoModel, enableVideoGeneration = false, }) { |
| const authKey = (apiKey || "").trim(); |
| const [hasAutoTriggered, setHasAutoTriggered] = useState(false); |
| const [hasAutoGeneratedOutline, setHasAutoGeneratedOutline] = useState(false); |
| |
| const tvModeActive = useTVModeStore((s) => s.isActive); |
| const enterTVMode = useTVModeStore((s) => s.enterTVMode); |
| const updateSceneImageByIdx = useTVModeStore((s) => s.updateSceneImageByIdx); |
| |
| const [project, setProject] = useState(null); |
| const [scenes, setScenes] = useState([]); |
| const [loading, setLoading] = useState(true); |
| |
| const [storyOutline, setStoryOutline] = useState(null); |
| const [isGeneratingOutline, setIsGeneratingOutline] = useState(false); |
| const [showOutlinePanel, setShowOutlinePanel] = useState(false); |
| const [showExportWizard, setShowExportWizard] = useState(false); |
| |
| const [showSceneEditor, setShowSceneEditor] = useState(false); |
| const [editingScene, setEditingScene] = useState(null); |
| const [editNarration, setEditNarration] = useState(""); |
| const [editImagePrompt, setEditImagePrompt] = useState(""); |
| const [editNegativePrompt, setEditNegativePrompt] = useState(""); |
| const [isSavingScene, setIsSavingScene] = useState(false); |
| |
| const [availableLLMModels, setAvailableLLMModels] = useState([]); |
| const [availableImageModels, setAvailableImageModels] = useState([]); |
| const [selectedLLMModel, setSelectedLLMModel] = useState(defaultLLMModel); |
| const [selectedImageModel, setSelectedImageModel] = useState(imageModel || ""); |
| const [selectedVideoModel, setSelectedVideoModel] = useState(videoModel || ""); |
| const [settingsLLMModel, setSettingsLLMModel] = useState(defaultLLMModel); |
| const [error, setError] = useState(null); |
| const [currentSceneIndex, setCurrentSceneIndex] = useState(0); |
| const [isPlaying, setIsPlaying] = useState(false); |
| const [isTTSSpeaking, setIsTTSSpeaking] = useState(false); |
| const [isSaving, setIsSaving] = useState(false); |
| const [lastSaved, setLastSaved] = useState(null); |
| const [isGeneratingScene, setIsGeneratingScene] = useState(false); |
| const [isGeneratingImage, setIsGeneratingImage] = useState(false); |
| const [isGeneratingVideo, setIsGeneratingVideo] = useState(false); |
| const [hoveredSceneIdx, setHoveredSceneIdx] = useState(null); |
| const [canPlayWebm, setCanPlayWebm] = useState(true); |
| |
| const [isBatchGenerating, setIsBatchGenerating] = useState(false); |
| const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, phase: 'scene' }); |
| |
| const [showSettingsModal, setShowSettingsModal] = useState(false); |
| const [settingsTitle, setSettingsTitle] = useState(""); |
| const [settingsLogline, setSettingsLogline] = useState(""); |
| const [settingsPlatform, setSettingsPlatform] = useState("youtube_16_9"); |
| const [settingsGoal, setSettingsGoal] = useState("Educate"); |
| const [settingsVisualStyle, setSettingsVisualStyle] = useState("Cinematic"); |
| const [settingsTones, setSettingsTones] = useState(["Documentary", "Calm"]); |
| const [settingsSceneCount, setSettingsSceneCount] = useState(8); |
| const [settingsSceneDuration, setSettingsSceneDuration] = useState(5); |
| const [settingsLockIdentity, setSettingsLockIdentity] = useState(true); |
| const [settingsContentRating, setSettingsContentRating] = useState("sfw"); |
| const [settingsEnableVideo, setSettingsEnableVideo] = useState(false); |
| const [isSavingSettings, setIsSavingSettings] = useState(false); |
| |
| const [settingsImageModel, setSettingsImageModel] = useState(""); |
| const [settingsVideoModel, setSettingsVideoModel] = useState(""); |
| const [availableVideoModels, setAvailableVideoModels] = useState([]); |
| const [loadingModels, setLoadingModels] = useState(false); |
| |
| const [genParams, setGenParams] = useState(CREATOR_STUDIO_PARAM_DEFAULTS); |
| |
| |
| |
| const projectWantsVideo = Boolean(project?.metadata?.generationMode === "video" || |
| project?.tags?.includes("mode:video") || |
| project?.tags?.includes("projectType:video") || |
| project?.tags?.includes("projectType:video_series") || |
| enableVideoGeneration === true |
| ); |
| |
| const fetchApi = useCallback(async (path) => { |
| const url = `${backendUrl.replace(/\/+$/, "")}${path}`; |
| const res = await fetch(url, { |
| method: "GET", |
| headers: { |
| "Content-Type": "application/json", |
| ...(authKey ? { "x-api-key": authKey } : {}), |
| }, |
| }); |
| if (!res.ok) { |
| const text = await res.text().catch(() => ""); |
| throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ""}`); |
| } |
| return (await res.json()); |
| }, [backendUrl, authKey]); |
| const postApi = useCallback(async (path, body) => { |
| const url = `${backendUrl.replace(/\/+$/, "")}${path}`; |
| const res = await fetch(url, { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| ...(authKey ? { "x-api-key": authKey } : {}), |
| }, |
| body: JSON.stringify(body), |
| }); |
| if (!res.ok) { |
| const text = await res.text().catch(() => ""); |
| throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ""}`); |
| } |
| return (await res.json()); |
| }, [backendUrl, authKey]); |
| const patchApi = useCallback(async (path, body) => { |
| const url = `${backendUrl.replace(/\/+$/, "")}${path}`; |
| const res = await fetch(url, { |
| method: "PATCH", |
| headers: { |
| "Content-Type": "application/json", |
| ...(authKey ? { "x-api-key": authKey } : {}), |
| }, |
| body: JSON.stringify(body), |
| }); |
| if (!res.ok) { |
| const text = await res.text().catch(() => ""); |
| throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ""}`); |
| } |
| return (await res.json()); |
| }, [backendUrl, authKey]); |
| const deleteApi = useCallback(async (path) => { |
| const url = `${backendUrl.replace(/\/+$/, "")}${path}`; |
| const res = await fetch(url, { |
| method: "DELETE", |
| headers: { |
| "Content-Type": "application/json", |
| ...(authKey ? { "x-api-key": authKey } : {}), |
| }, |
| }); |
| if (!res.ok) { |
| const text = await res.text().catch(() => ""); |
| throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ""}`); |
| } |
| return (await res.json()); |
| }, [backendUrl, authKey]); |
| |
| |
| const normalizeScene = useCallback((raw) => { |
| return { |
| id: raw.id, |
| videoId: raw.videoId ?? raw.video_id ?? "", |
| idx: raw.idx ?? raw.index ?? raw.scene_index ?? 0, |
| narration: raw.narration ?? "", |
| imagePrompt: raw.imagePrompt ?? raw.image_prompt ?? "", |
| negativePrompt: raw.negativePrompt ?? raw.negative_prompt ?? "", |
| imageUrl: raw.imageUrl ?? raw.image_url ?? null, |
| videoUrl: raw.videoUrl ?? raw.video_url ?? null, |
| audioUrl: raw.audioUrl ?? raw.audio_url ?? null, |
| status: raw.status ?? "pending", |
| durationSec: raw.durationSec ?? raw.duration_sec ?? 5, |
| createdAt: raw.createdAt ?? raw.created_at ?? 0, |
| updatedAt: raw.updatedAt ?? raw.updated_at ?? 0, |
| }; |
| }, []); |
| const normalizeScenes = useCallback((arr) => { |
| if (!Array.isArray(arr)) |
| return []; |
| return arr.map(normalizeScene).sort((a, b) => a.idx - b.idx); |
| }, [normalizeScene]); |
| |
| const toScenePatch = useCallback((patch) => { |
| const out = { ...patch }; |
| if ("imageUrl" in out) |
| out.image_url = out.imageUrl; |
| if ("videoUrl" in out) |
| out.video_url = out.videoUrl; |
| if ("audioUrl" in out) |
| out.audio_url = out.audioUrl; |
| if ("imagePrompt" in out) |
| out.image_prompt = out.imagePrompt; |
| if ("negativePrompt" in out) |
| out.negative_prompt = out.negativePrompt; |
| if ("durationSec" in out) |
| out.duration_sec = out.durationSec; |
| return out; |
| }, []); |
| |
| const refreshScenes = useCallback(async () => { |
| try { |
| const scenesRes = await fetchApi(`/studio/videos/${projectId}/scenes`); |
| setScenes(normalizeScenes(scenesRes.scenes)); |
| } |
| catch (e) { |
| |
| console.warn('[CreatorStudioEditor] Failed to refresh scenes:', e); |
| } |
| }, [fetchApi, projectId, normalizeScenes]); |
| |
| |
| const proxyVideoUrl = useCallback((rawUrl) => { |
| if (!rawUrl) |
| return null; |
| |
| if (rawUrl.startsWith('http://localhost:8188/') || rawUrl.startsWith('http://127.0.0.1:8188/')) { |
| return `${backendUrl.replace(/\/+$/, '')}/studio/media?url=${encodeURIComponent(rawUrl)}`; |
| } |
| |
| return rawUrl; |
| }, [backendUrl]); |
| |
| |
| const isAnimatedImageUrl = useCallback((u) => { |
| if (!u) |
| return false; |
| const s = u.toLowerCase(); |
| |
| return s.includes(".webp") || s.includes(".gif"); |
| }, []); |
| |
| const isWebmUrl = useCallback((u) => { |
| if (!u) |
| return false; |
| return u.toLowerCase().includes(".webm"); |
| }, []); |
| |
| const syncOutlineWithScenes = useCallback(async () => { |
| if (!projectId) |
| return; |
| try { |
| console.log('[CreatorStudioEditor] Syncing outline with scenes...'); |
| const data = await postApi(`/studio/videos/${projectId}/sync-outline`, {}); |
| if (data.ok && data.outline) { |
| setStoryOutline(data.outline); |
| console.log(`[CreatorStudioEditor] Outline synced: ${data.scene_count} scenes`); |
| } |
| } |
| catch (e) { |
| console.warn('[CreatorStudioEditor] Failed to sync outline:', e.message); |
| |
| } |
| }, [projectId, postApi]); |
| |
| const generateStoryOutline = useCallback(async () => { |
| if (!project || isGeneratingOutline) |
| return; |
| setIsGeneratingOutline(true); |
| try { |
| console.log('[CreatorStudioEditor] Generating story outline...'); |
| const data = await postApi(`/studio/videos/${projectId}/generate-outline`, { |
| target_scenes: targetSceneCount, |
| scene_duration: 5, |
| ollama_model: selectedLLMModel || undefined, |
| }); |
| if (data.ok && data.outline) { |
| setStoryOutline(data.outline); |
| console.log('[CreatorStudioEditor] Story outline generated:', data.outline.title); |
| } |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to generate outline:', e); |
| alert(`Failed to generate outline: ${e.message}`); |
| } |
| finally { |
| setIsGeneratingOutline(false); |
| } |
| }, [project, projectId, targetSceneCount, selectedLLMModel, isGeneratingOutline, postApi]); |
| |
| const loadStoryOutline = useCallback(async () => { |
| try { |
| const data = await fetchApi(`/studio/videos/${projectId}/outline`); |
| if (data.ok && data.outline) { |
| setStoryOutline(data.outline); |
| } |
| } |
| catch (e) { |
| console.log('[CreatorStudioEditor] No existing outline found'); |
| } |
| }, [projectId, fetchApi]); |
| |
| const openSceneEditor = useCallback((scene) => { |
| setEditingScene(scene); |
| setEditNarration(scene.narration || ""); |
| setEditImagePrompt(scene.imagePrompt || ""); |
| setEditNegativePrompt(scene.negativePrompt || ""); |
| setShowSceneEditor(true); |
| }, []); |
| |
| const saveSceneEdits = useCallback(async () => { |
| if (!editingScene) |
| return; |
| setIsSavingScene(true); |
| try { |
| await patchApi(`/studio/videos/${projectId}/scenes/${editingScene.id}`, { |
| narration: editNarration, |
| imagePrompt: editImagePrompt, |
| negativePrompt: editNegativePrompt, |
| }); |
| setScenes((prev) => prev.map((s) => s.id === editingScene.id |
| ? { ...s, narration: editNarration, imagePrompt: editImagePrompt, negativePrompt: editNegativePrompt } |
| : s)); |
| setLastSaved(new Date()); |
| setShowSceneEditor(false); |
| setEditingScene(null); |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to save scene:', e); |
| alert(`Failed to save scene: ${e.message}`); |
| } |
| finally { |
| setIsSavingScene(false); |
| } |
| }, [editingScene, editNarration, editImagePrompt, editNegativePrompt, projectId, patchApi]); |
| |
| const parseTagsFromProject = useCallback((proj) => { |
| const tags = proj.tags || []; |
| let goal = "Educate"; |
| let visualStyle = "Cinematic"; |
| let tones = []; |
| let lockIdentity = true; |
| let sceneCount = 8; |
| let sceneDuration = 5; |
| tags.forEach((tag) => { |
| if (tag.startsWith("goal:")) { |
| const g = tag.replace("goal:", ""); |
| if (g === "entertain") |
| goal = "Entertain"; |
| else if (g === "educate") |
| goal = "Educate"; |
| else if (g === "inspire") |
| goal = "Inspire"; |
| } |
| else if (tag.startsWith("visual:")) { |
| const v = tag.replace("visual:", "").replace(/_/g, " "); |
| if (v.toLowerCase() === "cinematic") |
| visualStyle = "Cinematic"; |
| else if (v.toLowerCase() === "digital art") |
| visualStyle = "Digital Art"; |
| else if (v.toLowerCase() === "anime") |
| visualStyle = "Anime"; |
| } |
| else if (tag.startsWith("tone:")) { |
| const t = tag.replace("tone:", "").replace(/_/g, " "); |
| const capitalizedTone = t.charAt(0).toUpperCase() + t.slice(1); |
| tones.push(capitalizedTone); |
| } |
| else if (tag === "lock:identity") { |
| lockIdentity = true; |
| } |
| else if (tag.startsWith("scenes:")) { |
| sceneCount = parseInt(tag.replace("scenes:", ""), 10) || 8; |
| } |
| else if (tag.startsWith("duration:")) { |
| sceneDuration = parseInt(tag.replace("duration:", ""), 10) || 5; |
| } |
| }); |
| return { goal, visualStyle, tones, lockIdentity, sceneCount, sceneDuration }; |
| }, []); |
| |
| const parseGenParamsFromTags = useCallback((tags) => { |
| const t = tags || []; |
| const next = { ...CREATOR_STUDIO_PARAM_DEFAULTS }; |
| const get = (k) => t.find(x => x.startsWith(`gen:${k}=`))?.split("=")[1]; |
| next.enabled = get("enabled") === "1"; |
| next.steps = Number(get("steps") ?? next.steps); |
| next.cfgScale = Number(get("cfg") ?? next.cfgScale); |
| next.creativity = Number(get("creativity") ?? next.creativity); |
| next.lockSeed = get("seedlock") === "1"; |
| next.seed = Number(get("seed") ?? next.seed); |
| return next; |
| }, []); |
| |
| const openSettingsModal = useCallback(async () => { |
| if (!project) |
| return; |
| setSettingsTitle(project.title || ""); |
| setSettingsLogline(project.logline || ""); |
| setSettingsPlatform(project.platformPreset || "youtube_16_9"); |
| setSettingsContentRating(project.contentRating || "sfw"); |
| const parsed = parseTagsFromProject(project); |
| setSettingsGoal(parsed.goal); |
| setSettingsVisualStyle(parsed.visualStyle); |
| setSettingsTones(parsed.tones.length > 0 ? parsed.tones : ["Documentary", "Calm"]); |
| setSettingsSceneCount(parsed.sceneCount); |
| setSettingsSceneDuration(parsed.sceneDuration); |
| setSettingsLockIdentity(parsed.lockIdentity); |
| |
| const tags = project.tags || []; |
| const llmTag = tags.find((t) => t.startsWith("llm:")); |
| setSettingsLLMModel(llmTag ? llmTag.replace("llm:", "") : selectedLLMModel); |
| |
| const hasVideoMode = tags.includes("mode:video") || |
| tags.includes("projectType:video") || |
| tags.includes("projectType:video_series") || |
| project?.metadata?.generationMode === "video" || |
| enableVideoGeneration === true; |
| setSettingsEnableVideo(hasVideoMode); |
| |
| const parsedGenParams = parseGenParamsFromTags(tags); |
| |
| try { |
| const key = `creatorstudio:genparams:${projectId}:neg`; |
| const stored = localStorage.getItem(key); |
| if (stored) { |
| const neg = JSON.parse(stored); |
| parsedGenParams.useCustomNegativePrompt = Boolean(neg?.use); |
| parsedGenParams.customNegativePrompt = String(neg?.text || ""); |
| } |
| } |
| catch { } |
| setGenParams(parsedGenParams); |
| |
| const imageModelTag = tags.find((t) => t.startsWith("imageModel:")); |
| setSettingsImageModel(imageModelTag?.replace("imageModel:", "") || imageModel || ""); |
| const videoModelTag = tags.find((t) => t.startsWith("videoModel:")); |
| setSettingsVideoModel(videoModelTag?.replace("videoModel:", "") || videoModel || ""); |
| |
| setLoadingModels(true); |
| try { |
| |
| const [llmData, imgData, vidData] = await Promise.all([ |
| fetchApi('/models?provider=ollama'), |
| fetchApi('/models?provider=comfyui&model_type=image'), |
| fetchApi('/models?provider=comfyui&model_type=video'), |
| ]); |
| if (llmData.models) { |
| const models = llmData.models.map(m => ({ id: m.id, name: m.name || m.id })); |
| setAvailableLLMModels(models); |
| } |
| if (imgData.models) { |
| const models = imgData.models.map(m => ({ id: m, name: m })); |
| setAvailableImageModels(models); |
| } |
| if (vidData.models) { |
| const models = vidData.models.map(m => ({ id: m, name: m })); |
| setAvailableVideoModels(models); |
| } |
| } |
| catch (e) { |
| console.log('[CreatorStudioEditor] Failed to fetch models for settings:', e); |
| } |
| finally { |
| setLoadingModels(false); |
| } |
| setShowSettingsModal(true); |
| }, [project, projectId, parseTagsFromProject, parseGenParamsFromTags, selectedLLMModel, imageModel, videoModel, enableVideoGeneration, fetchApi]); |
| |
| const toggleSettingsTone = useCallback((tone) => { |
| setSettingsTones((prev) => { |
| if (prev.includes(tone)) { |
| return prev.filter((t) => t !== tone); |
| } |
| return [...prev, tone]; |
| }); |
| }, []); |
| |
| const buildTagsFromSettings = useCallback(() => { |
| const tags = []; |
| |
| tags.push(`mode:${settingsEnableVideo ? "video" : "slideshow"}`); |
| if (settingsGoal) |
| tags.push(`goal:${settingsGoal.toLowerCase()}`); |
| if (settingsVisualStyle) |
| tags.push(`visual:${settingsVisualStyle.toLowerCase().replace(/ /g, "_")}`); |
| settingsTones.forEach((t) => tags.push(`tone:${t.toLowerCase().replace(/ /g, "_")}`)); |
| if (settingsLockIdentity) |
| tags.push("lock:identity"); |
| tags.push(`scenes:${settingsSceneCount}`); |
| tags.push(`duration:${settingsSceneDuration}`); |
| if (settingsLLMModel) |
| tags.push(`llm:${settingsLLMModel}`); |
| |
| if (settingsImageModel) |
| tags.push(`imageModel:${settingsImageModel}`); |
| if (settingsVideoModel) |
| tags.push(`videoModel:${settingsVideoModel}`); |
| return tags; |
| }, [settingsEnableVideo, settingsGoal, settingsVisualStyle, settingsTones, settingsLockIdentity, settingsSceneCount, settingsSceneDuration, settingsLLMModel, settingsImageModel, settingsVideoModel]); |
| |
| const buildGenTags = useCallback((p) => { |
| |
| const tags = buildTagsFromSettings().filter(t => !t.startsWith("gen:")); |
| |
| tags.push(`gen:enabled=${p.enabled ? "1" : "0"}`); |
| tags.push(`gen:steps=${p.steps}`); |
| tags.push(`gen:cfg=${p.cfgScale}`); |
| tags.push(`gen:creativity=${p.creativity}`); |
| tags.push(`gen:seedlock=${p.lockSeed ? "1" : "0"}`); |
| tags.push(`gen:seed=${p.seed}`); |
| return tags; |
| }, [buildTagsFromSettings]); |
| |
| const saveProjectSettings = useCallback(async () => { |
| if (!project) |
| return; |
| setIsSavingSettings(true); |
| try { |
| const tags = buildGenTags(genParams); |
| await patchApi(`/studio/videos/${projectId}`, { |
| title: settingsTitle.trim(), |
| logline: settingsLogline.trim(), |
| platformPreset: settingsPlatform, |
| contentRating: settingsContentRating, |
| tags, |
| }); |
| |
| try { |
| const key = `creatorstudio:genparams:${projectId}:neg`; |
| localStorage.setItem(key, JSON.stringify({ |
| use: genParams.useCustomNegativePrompt, |
| text: genParams.customNegativePrompt, |
| })); |
| } |
| catch { } |
| |
| setProject((prev) => prev ? { |
| ...prev, |
| title: settingsTitle.trim(), |
| logline: settingsLogline.trim(), |
| platformPreset: settingsPlatform, |
| contentRating: settingsContentRating, |
| tags, |
| } : null); |
| |
| setSelectedLLMModel(settingsLLMModel); |
| if (settingsImageModel) |
| setSelectedImageModel(settingsImageModel); |
| if (settingsVideoModel) |
| setSelectedVideoModel(settingsVideoModel); |
| setLastSaved(new Date()); |
| setShowSettingsModal(false); |
| console.log('[CreatorStudioEditor] Project settings saved'); |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to save settings:', e); |
| alert(`Failed to save settings: ${e.message}`); |
| } |
| finally { |
| setIsSavingSettings(false); |
| } |
| }, [project, projectId, settingsTitle, settingsLogline, settingsPlatform, settingsContentRating, settingsLLMModel, settingsImageModel, settingsVideoModel, genParams, buildGenTags, patchApi]); |
| |
| const deleteScene = useCallback(async (sceneId) => { |
| if (scenes.length <= 1) { |
| alert("Cannot delete the last scene."); |
| return; |
| } |
| if (!window.confirm("Delete this scene? This cannot be undone.")) { |
| return; |
| } |
| try { |
| console.log('[CreatorStudioEditor] Deleting scene:', sceneId); |
| await deleteApi(`/studio/videos/${projectId}/scenes/${sceneId}`); |
| const deletedIndex = scenes.findIndex((s) => s.id === sceneId); |
| setScenes((prev) => { |
| const newScenes = prev |
| .filter((s) => s.id !== sceneId) |
| .map((s, i) => ({ ...s, idx: i })); |
| return newScenes; |
| }); |
| if (deletedIndex >= 0 && deletedIndex <= currentSceneIndex) { |
| setCurrentSceneIndex((prev) => Math.max(0, prev - 1)); |
| } |
| setLastSaved(new Date()); |
| console.log('[CreatorStudioEditor] Scene deleted successfully'); |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to delete scene:', e); |
| alert(`Failed to delete scene: ${e.message}`); |
| } |
| }, [projectId, scenes, currentSceneIndex, deleteApi]); |
| |
| const fetchAvailableModels = useCallback(async () => { |
| try { |
| const llmData = await fetchApi('/models?provider=ollama'); |
| if (llmData.models) { |
| const models = llmData.models.map(m => ({ id: m.id, name: m.name || m.id })); |
| setAvailableLLMModels(models); |
| |
| if (!selectedLLMModel && models.length > 0) { |
| |
| const preferred = models.find(m => m.id === "llama3:8b") || models[0]; |
| setSelectedLLMModel(preferred.id); |
| setSettingsLLMModel(preferred.id); |
| } |
| } |
| } |
| catch (e) { |
| console.log('[CreatorStudioEditor] Failed to fetch LLM models:', e); |
| } |
| try { |
| const imgData = await fetchApi('/models?provider=comfyui&model_type=image'); |
| if (imgData.models) { |
| setAvailableImageModels(imgData.models.map(m => ({ id: m, name: m }))); |
| } |
| } |
| catch (e) { |
| console.log('[CreatorStudioEditor] Failed to fetch image models:', e); |
| } |
| }, [fetchApi, selectedLLMModel]); |
| |
| const sceneToTVScene = useCallback((scene) => { |
| return { |
| idx: scene.idx, |
| narration: scene.narration || "", |
| image_prompt: scene.imagePrompt || "", |
| negative_prompt: scene.negativePrompt || "", |
| duration_s: scene.durationSec || 5, |
| tags: {}, |
| image_url: scene.imageUrl || null, |
| status: scene.status === "ready" ? "ready" : "pending", |
| imageStatus: scene.imageUrl ? "ready" : "pending", |
| }; |
| }, []); |
| |
| const handleEnterTVMode = useCallback(() => { |
| if (!project || scenes.length === 0) |
| return; |
| const tvScenes = scenes.map(sceneToTVScene); |
| enterTVMode(projectId, project.title, tvScenes, currentSceneIndex); |
| }, [project, projectId, scenes, currentSceneIndex, enterTVMode, sceneToTVScene]); |
| |
| useEffect(() => { |
| if (!isPlaying || scenes.length === 0) { |
| |
| if (window.SpeechService?.stopSpeaking) { |
| window.SpeechService.stopSpeaking(); |
| } |
| setIsTTSSpeaking(false); |
| return; |
| } |
| const currentScene = scenes[currentSceneIndex]; |
| if (!currentScene?.narration) { |
| |
| const timer = setTimeout(() => { |
| if (currentSceneIndex < scenes.length - 1) { |
| setCurrentSceneIndex((i) => i + 1); |
| } |
| else { |
| setIsPlaying(false); |
| } |
| }, 2000); |
| return () => clearTimeout(timer); |
| } |
| |
| const svc = window.SpeechService; |
| if (!svc?.speak) { |
| |
| const timer = setTimeout(() => { |
| if (currentSceneIndex < scenes.length - 1) { |
| setCurrentSceneIndex((i) => i + 1); |
| } |
| else { |
| setIsPlaying(false); |
| } |
| }, 5000); |
| return () => clearTimeout(timer); |
| } |
| console.log(`[Editor] Speaking scene ${currentSceneIndex + 1} narration...`); |
| setIsTTSSpeaking(true); |
| svc.speak(currentScene.narration, { |
| onStart: () => setIsTTSSpeaking(true), |
| onEnd: () => { |
| setIsTTSSpeaking(false); |
| |
| if (currentSceneIndex < scenes.length - 1) { |
| setCurrentSceneIndex((i) => i + 1); |
| } |
| else { |
| setIsPlaying(false); |
| } |
| }, |
| onError: () => { |
| setIsTTSSpeaking(false); |
| |
| setTimeout(() => { |
| if (currentSceneIndex < scenes.length - 1) { |
| setCurrentSceneIndex((i) => i + 1); |
| } |
| else { |
| setIsPlaying(false); |
| } |
| }, 2000); |
| }, |
| }); |
| return () => { |
| svc.stopSpeaking?.(); |
| }; |
| }, [isPlaying, currentSceneIndex, scenes]); |
| |
| const generateImageForScene = useCallback(async (sceneId, imagePrompt, force = false) => { |
| if (isGeneratingImage && !force) { |
| console.log('[CreatorStudioEditor] Already generating image, skipping'); |
| return; |
| } |
| setIsGeneratingImage(true); |
| console.log('[CreatorStudioEditor] Generating image for scene:', sceneId); |
| try { |
| const llmProvider = imageProvider === 'comfyui' ? 'ollama' : imageProvider; |
| |
| const effectiveSteps = genParams.enabled ? genParams.steps : imageSteps; |
| const effectiveCfg = genParams.enabled ? genParams.cfgScale : imageCfg; |
| |
| const scene = scenes.find(s => s.id === sceneId); |
| const sceneNeg = scene?.negativePrompt || ""; |
| const combinedNegativePrompt = (genParams.enabled && genParams.useCustomNegativePrompt && genParams.customNegativePrompt.trim()) |
| ? [sceneNeg, genParams.customNegativePrompt.trim()].filter(Boolean).join(", ") |
| : sceneNeg || undefined; |
| |
| |
| const data = await postApi('/chat', { |
| message: `imagine ${imagePrompt}`, |
| mode: 'imagine', |
| provider: llmProvider, |
| imgModel: selectedImageModel || imageModel || undefined, |
| |
| |
| ...(imageWidth ? { imgWidth: imageWidth } : {}), |
| ...(imageHeight ? { imgHeight: imageHeight } : {}), |
| imgSteps: effectiveSteps, |
| imgCfg: effectiveCfg, |
| negativePrompt: combinedNegativePrompt, |
| imgSeed: genParams.enabled && genParams.lockSeed ? genParams.seed : undefined, |
| creativity: genParams.enabled ? genParams.creativity : undefined, |
| promptRefinement: false, |
| }); |
| const imageUrl = data?.media?.images?.[0]; |
| if (imageUrl) { |
| console.log('[CreatorStudioEditor] Image generated:', imageUrl); |
| await patchApi(`/studio/videos/${projectId}/scenes/${sceneId}`, toScenePatch({ |
| imageUrl, |
| status: 'ready', |
| })); |
| setScenes((prev) => prev.map((s) => s.id === sceneId ? { ...s, imageUrl, status: 'ready' } : s)); |
| setLastSaved(new Date()); |
| } |
| else { |
| console.warn('[CreatorStudioEditor] No image returned from backend'); |
| } |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to generate image:', e); |
| } |
| finally { |
| setIsGeneratingImage(false); |
| } |
| }, [projectId, imageProvider, imageModel, selectedImageModel, imageWidth, imageHeight, imageSteps, imageCfg, postApi, patchApi, isGeneratingImage, toScenePatch, genParams, scenes]); |
| |
| const generateVideoForScene = useCallback(async (sceneId, imageUrl, prompt) => { |
| if (isGeneratingVideo) { |
| console.log('[CreatorStudioEditor] Already generating video, skipping'); |
| return; |
| } |
| if (!imageUrl) { |
| console.warn('[CreatorStudioEditor] No image URL to animate'); |
| return; |
| } |
| setIsGeneratingVideo(true); |
| console.log('[CreatorStudioEditor] Generating video for scene:', sceneId); |
| try { |
| |
| |
| |
| const data = await postApi('/chat', { |
| message: `${prompt || 'Animate this scene with subtle motion'} ${imageUrl}`, |
| mode: 'animate', |
| provider: 'ollama', |
| vidModel: selectedVideoModel || videoModel || undefined, |
| |
| vidSeed: genParams.enabled && genParams.lockSeed ? genParams.seed : undefined, |
| vidSteps: genParams.enabled ? genParams.steps : undefined, |
| vidCfg: genParams.enabled ? genParams.cfgScale : undefined, |
| creativity: genParams.enabled ? genParams.creativity : undefined, |
| }); |
| |
| const rawVideoUrl = data?.media?.video_url || |
| data?.media?.videos?.[0] || |
| null; |
| if (rawVideoUrl) { |
| |
| const proxiedVideoUrl = proxyVideoUrl(rawVideoUrl) || rawVideoUrl; |
| console.log('[CreatorStudioEditor] Video generated:', rawVideoUrl, '-> proxied:', proxiedVideoUrl); |
| await patchApi(`/studio/videos/${projectId}/scenes/${sceneId}`, toScenePatch({ |
| videoUrl: proxiedVideoUrl, |
| status: 'ready', |
| })); |
| setScenes((prev) => prev.map((s) => s.id === sceneId ? { ...s, videoUrl: proxiedVideoUrl, status: 'ready' } : s)); |
| setLastSaved(new Date()); |
| } |
| else { |
| console.warn('[CreatorStudioEditor] No video returned from backend'); |
| } |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to generate video:', e); |
| } |
| finally { |
| setIsGeneratingVideo(false); |
| } |
| }, [projectId, videoModel, selectedVideoModel, postApi, patchApi, isGeneratingVideo, toScenePatch, proxyVideoUrl, genParams]); |
| |
| const removeVideoForScene = useCallback(async (sceneId) => { |
| if (!window.confirm("Remove the video for this scene and keep the image?")) |
| return; |
| try { |
| await patchApi(`/studio/videos/${projectId}/scenes/${sceneId}`, toScenePatch({ videoUrl: null, status: 'ready' })); |
| setScenes(prev => prev.map(s => (s.id === sceneId ? { ...s, videoUrl: null, status: 'ready' } : s))); |
| setLastSaved(new Date()); |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to remove video:', e); |
| alert(`Failed to remove video: ${e.message}`); |
| } |
| }, [projectId, patchApi, toScenePatch]); |
| |
| const generateSceneFromOutline = useCallback(async (sceneIndex) => { |
| if (!storyOutline || sceneIndex >= storyOutline.scenes.length) |
| return; |
| setIsGeneratingScene(true); |
| try { |
| const data = await postApi(`/studio/videos/${projectId}/scenes/generate-from-outline?scene_index=${sceneIndex}`, {}); |
| if (data.ok && data.scene) { |
| setScenes((prev) => [...prev, data.scene]); |
| setCurrentSceneIndex(scenes.length); |
| setLastSaved(new Date()); |
| generateImageForScene(data.scene.id, data.scene.imagePrompt); |
| } |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to generate scene from outline:', e); |
| alert(`Failed to generate scene: ${e.message}`); |
| } |
| finally { |
| setIsGeneratingScene(false); |
| } |
| }, [projectId, storyOutline, scenes.length, postApi, generateImageForScene]); |
| |
| const generateAllScenesFromOutline = useCallback(async () => { |
| if (!storyOutline || !storyOutline.scenes || storyOutline.scenes.length === 0) { |
| console.log('[CreatorStudioEditor] No outline scenes to generate'); |
| return; |
| } |
| const totalScenes = storyOutline.scenes.length; |
| console.log(`[CreatorStudioEditor] Starting batch generation of ${totalScenes} scenes`); |
| setIsBatchGenerating(true); |
| setBatchProgress({ current: 0, total: totalScenes, phase: 'scene' }); |
| const generatedScenes = []; |
| try { |
| |
| for (let i = 0; i < totalScenes; i++) { |
| setBatchProgress({ current: i + 1, total: totalScenes, phase: 'scene' }); |
| console.log(`[CreatorStudioEditor] Generating scene ${i + 1}/${totalScenes}`); |
| try { |
| const data = await postApi(`/studio/videos/${projectId}/scenes/generate-from-outline?scene_index=${i}`, {}); |
| if (data.ok && data.scene) { |
| generatedScenes.push(data.scene); |
| setScenes((prev) => [...prev, data.scene]); |
| setCurrentSceneIndex(i); |
| } |
| } |
| catch (sceneErr) { |
| console.error(`[CreatorStudioEditor] Failed to generate scene ${i + 1}:`, sceneErr); |
| |
| } |
| |
| if (i < totalScenes - 1) { |
| await new Promise(resolve => setTimeout(resolve, 200)); |
| } |
| } |
| console.log(`[CreatorStudioEditor] Created ${generatedScenes.length} scenes, now generating images...`); |
| |
| const scenesWithImages = []; |
| for (let i = 0; i < generatedScenes.length; i++) { |
| const scene = generatedScenes[i]; |
| setBatchProgress({ current: i + 1, total: generatedScenes.length, phase: 'image' }); |
| console.log(`[CreatorStudioEditor] Generating image ${i + 1}/${generatedScenes.length}`); |
| try { |
| |
| const llmProvider = imageProvider === 'comfyui' ? 'ollama' : imageProvider; |
| const data = await postApi('/chat', { |
| message: `imagine ${scene.imagePrompt}`, |
| mode: 'imagine', |
| provider: llmProvider, |
| imgModel: selectedImageModel || imageModel || undefined, |
| imgAspectRatio: '16:9', |
| imgSteps: imageSteps, |
| imgCfg: imageCfg, |
| promptRefinement: false, |
| }); |
| const imageUrl = data?.media?.images?.[0]; |
| if (imageUrl) { |
| await patchApi(`/studio/videos/${projectId}/scenes/${scene.id}`, toScenePatch({ |
| imageUrl, |
| status: projectWantsVideo ? 'generating' : 'ready', |
| })); |
| setScenes((prev) => prev.map((s) => s.id === scene.id ? { ...s, imageUrl, status: (projectWantsVideo ? 'generating' : 'ready') } : s)); |
| scenesWithImages.push({ scene, imageUrl }); |
| } |
| } |
| catch (imgErr) { |
| console.error(`[CreatorStudioEditor] Failed to generate image for scene ${i + 1}:`, imgErr); |
| |
| } |
| } |
| |
| if (projectWantsVideo && scenesWithImages.length > 0) { |
| console.log(`[CreatorStudioEditor] Phase 3: Generating ${scenesWithImages.length} videos...`); |
| for (let i = 0; i < scenesWithImages.length; i++) { |
| const { scene, imageUrl } = scenesWithImages[i]; |
| setBatchProgress({ current: i + 1, total: scenesWithImages.length, phase: 'video' }); |
| console.log(`[CreatorStudioEditor] Generating video ${i + 1}/${scenesWithImages.length}`); |
| try { |
| |
| |
| const data = await postApi('/chat', { |
| message: `${scene.imagePrompt} ${imageUrl}`, |
| mode: 'animate', |
| provider: 'ollama', |
| vidModel: selectedVideoModel || videoModel || undefined, |
| }); |
| |
| const rawVideoUrl = data?.media?.video_url || |
| data?.media?.videos?.[0] || |
| null; |
| if (rawVideoUrl) { |
| |
| const proxiedVideoUrl = proxyVideoUrl(rawVideoUrl) || rawVideoUrl; |
| await patchApi(`/studio/videos/${projectId}/scenes/${scene.id}`, toScenePatch({ |
| videoUrl: proxiedVideoUrl, |
| status: 'ready', |
| })); |
| setScenes((prev) => prev.map((s) => s.id === scene.id ? { ...s, videoUrl: proxiedVideoUrl, status: 'ready' } : s)); |
| } |
| else { |
| |
| await patchApi(`/studio/videos/${projectId}/scenes/${scene.id}`, toScenePatch({ |
| status: 'ready', |
| })); |
| setScenes((prev) => prev.map((s) => s.id === scene.id ? { ...s, status: 'ready' } : s)); |
| } |
| } |
| catch (vidErr) { |
| console.error(`[CreatorStudioEditor] Failed to generate video for scene ${i + 1}:`, vidErr); |
| |
| try { |
| await patchApi(`/studio/videos/${projectId}/scenes/${scene.id}`, toScenePatch({ status: 'ready' })); |
| setScenes((prev) => prev.map((s) => s.id === scene.id ? { ...s, status: 'ready' } : s)); |
| } |
| catch { } |
| } |
| } |
| } |
| setLastSaved(new Date()); |
| setCurrentSceneIndex(0); |
| console.log('[CreatorStudioEditor] Batch generation complete!'); |
| |
| await syncOutlineWithScenes(); |
| |
| await refreshScenes(); |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Batch generation failed:', e); |
| } |
| finally { |
| setIsBatchGenerating(false); |
| setBatchProgress({ current: 0, total: 0, phase: 'scene' }); |
| } |
| }, [storyOutline, projectId, postApi, patchApi, imageProvider, imageModel, selectedImageModel, imageSteps, imageCfg, projectWantsVideo, videoModel, selectedVideoModel, syncOutlineWithScenes, toScenePatch, refreshScenes, proxyVideoUrl]); |
| |
| useEffect(() => { |
| async function loadData() { |
| setLoading(true); |
| setError(null); |
| try { |
| const [projectRes, scenesRes] = await Promise.all([ |
| fetchApi(`/studio/videos/${projectId}`), |
| fetchApi(`/studio/videos/${projectId}/scenes`), |
| ]); |
| setProject(projectRes.video); |
| setScenes(normalizeScenes(scenesRes.scenes)); |
| |
| const tags = projectRes.video.tags || []; |
| const llmTag = tags.find((t) => t.startsWith("llm:")); |
| if (llmTag && !defaultLLMModel) { |
| const modelFromTag = llmTag.replace("llm:", ""); |
| setSelectedLLMModel(modelFromTag); |
| setSettingsLLMModel(modelFromTag); |
| } |
| } |
| catch (e) { |
| setError(e.message || String(e)); |
| } |
| finally { |
| setLoading(false); |
| } |
| } |
| loadData(); |
| }, [projectId, fetchApi, defaultLLMModel, normalizeScenes]); |
| |
| useEffect(() => { |
| fetchAvailableModels(); |
| loadStoryOutline(); |
| }, [fetchAvailableModels, loadStoryOutline]); |
| |
| useEffect(() => { |
| try { |
| const v = document.createElement("video"); |
| const ok = Boolean(v.canPlayType('video/webm; codecs="vp8, vorbis"') || |
| v.canPlayType('video/webm; codecs="vp9"') || |
| v.canPlayType("video/webm")); |
| setCanPlayWebm(ok); |
| console.log('[CreatorStudioEditor] WebM playback support:', ok); |
| } |
| catch { |
| setCanPlayWebm(false); |
| } |
| }, []); |
| |
| useEffect(() => { |
| if (autoGenerateFirst && |
| !hasAutoGeneratedOutline && |
| !loading && |
| project && |
| !storyOutline && |
| !isGeneratingOutline && |
| selectedLLMModel |
| ) { |
| console.log('[CreatorStudioEditor] Auto-generating story outline for new project'); |
| setHasAutoGeneratedOutline(true); |
| generateStoryOutline(); |
| } |
| }, [autoGenerateFirst, hasAutoGeneratedOutline, loading, project, storyOutline, isGeneratingOutline, selectedLLMModel, generateStoryOutline]); |
| |
| const currentScene = scenes[currentSceneIndex] || null; |
| |
| const getVisualStyle = useCallback(() => { |
| if (!project) |
| return "cinematic"; |
| const tags = project.tags || []; |
| const visualTag = tags.find((t) => t.startsWith("visual:")); |
| if (visualTag) { |
| const style = visualTag.replace("visual:", "").replace(/_/g, " "); |
| return style; |
| } |
| return "cinematic"; |
| }, [project]); |
| |
| const getTones = useCallback(() => { |
| if (!project) |
| return ["documentary"]; |
| const tags = project.tags || []; |
| const tones = tags.filter((t) => t.startsWith("tone:")).map((t) => t.replace("tone:", "").replace(/_/g, " ")); |
| return tones.length > 0 ? tones : ["documentary"]; |
| }, [project]); |
| |
| const generateFirstSceneWithAI = useCallback(async () => { |
| if (!project || isGeneratingScene) |
| return; |
| setIsGeneratingScene(true); |
| try { |
| let narration; |
| let imagePrompt; |
| let negativePrompt = "blurry, low quality, text, watermark, ugly, deformed, disfigured, bad anatomy, worst quality, low resolution, duplicate, clone, multiple people, two heads, two faces, split image, extra limbs"; |
| if (storyOutline && storyOutline.scenes && storyOutline.scenes.length > 0) { |
| const outlineScene = storyOutline.scenes[0]; |
| narration = outlineScene.narration; |
| imagePrompt = outlineScene.image_prompt; |
| negativePrompt = outlineScene.negative_prompt || negativePrompt; |
| console.log('[CreatorStudioEditor] Using story outline for first scene'); |
| } |
| else { |
| const visualStyle = getVisualStyle(); |
| const tones = getTones(); |
| const toneDesc = tones.join(", "); |
| narration = `The story begins. ${project.logline || `Welcome to "${project.title}".`}`; |
| imagePrompt = `${visualStyle} style, ${project.logline || project.title}, opening scene, establishing shot, ${toneDesc} mood, high quality, detailed, 4k, masterpiece`; |
| } |
| const data = await postApi(`/studio/videos/${projectId}/scenes`, { |
| narration, |
| imagePrompt, |
| negativePrompt, |
| durationSec: 5.0, |
| }); |
| setScenes((prev) => [...prev, data.scene]); |
| setCurrentSceneIndex(0); |
| setLastSaved(new Date()); |
| console.log('[CreatorStudioEditor] Auto-generating image for first scene'); |
| generateImageForScene(data.scene.id, data.scene.imagePrompt); |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to create scene:', e); |
| alert(`Failed to create scene: ${e.message}`); |
| } |
| finally { |
| setIsGeneratingScene(false); |
| } |
| }, [project, projectId, isGeneratingScene, postApi, getVisualStyle, getTones, storyOutline, generateImageForScene]); |
| |
| useEffect(() => { |
| if (autoGenerateFirst && |
| !hasAutoTriggered && |
| !loading && |
| project && |
| storyOutline && |
| storyOutline.scenes && |
| storyOutline.scenes.length > 0 && |
| scenes.length === 0 && |
| !isGeneratingScene && |
| !isBatchGenerating) { |
| console.log('[CreatorStudioEditor] Auto-generating ALL scenes from outline'); |
| setHasAutoTriggered(true); |
| generateAllScenesFromOutline(); |
| } |
| }, [autoGenerateFirst, hasAutoTriggered, loading, project, storyOutline, scenes.length, isGeneratingScene, isBatchGenerating, generateAllScenesFromOutline]); |
| |
| const generateFirstScene = useCallback(async () => { |
| if (!project || isGeneratingScene) |
| return; |
| setIsGeneratingScene(true); |
| try { |
| const data = await postApi(`/studio/videos/${projectId}/scenes`, { |
| narration: `Opening scene for "${project.title}"`, |
| imagePrompt: `${project.logline || project.title}, cinematic, high quality, detailed`, |
| negativePrompt: "blurry, low quality, text, watermark", |
| durationSec: 5.0, |
| }); |
| setScenes((prev) => [...prev, data.scene]); |
| setCurrentSceneIndex(0); |
| setLastSaved(new Date()); |
| } |
| catch (e) { |
| alert(`Failed to create scene: ${e.message}`); |
| } |
| finally { |
| setIsGeneratingScene(false); |
| } |
| }, [project, projectId, isGeneratingScene, postApi]); |
| |
| const generateNextScene = useCallback(async () => { |
| if (!project || isGeneratingScene) |
| return; |
| setIsGeneratingScene(true); |
| try { |
| const nextSceneIndex = scenes.length; |
| |
| try { |
| const data = await postApi(`/studio/videos/${projectId}/scenes/generate-from-outline?scene_index=${nextSceneIndex}`, {}); |
| if (data.ok && data.scene) { |
| setScenes((prev) => [...prev, data.scene]); |
| setCurrentSceneIndex(nextSceneIndex); |
| setLastSaved(new Date()); |
| console.log(`[CreatorStudioEditor] Generated scene ${nextSceneIndex + 1} from outline`); |
| generateImageForScene(data.scene.id, data.scene.imagePrompt); |
| |
| syncOutlineWithScenes(); |
| return; |
| } |
| } |
| catch (outlineErr) { |
| |
| console.log('[CreatorStudioEditor] No outline scene available, trying AI continuation:', outlineErr.message); |
| } |
| |
| if (scenes.length > 0) { |
| try { |
| console.log('[CreatorStudioEditor] Generating AI continuation from previous context...'); |
| const contData = await postApi(`/studio/videos/${projectId}/scenes/generate-continuation`, {}); |
| if (contData.ok && contData.scene) { |
| setScenes((prev) => [...prev, contData.scene]); |
| setCurrentSceneIndex(nextSceneIndex); |
| setLastSaved(new Date()); |
| console.log(`[CreatorStudioEditor] Generated scene ${nextSceneIndex + 1} via AI continuation`); |
| generateImageForScene(contData.scene.id, contData.scene.imagePrompt); |
| |
| syncOutlineWithScenes(); |
| return; |
| } |
| } |
| catch (contErr) { |
| console.log('[CreatorStudioEditor] AI continuation failed, using fallback:', contErr.message); |
| } |
| } |
| |
| const sceneNum = scenes.length + 1; |
| const visualStyle = getVisualStyle(); |
| const tones = getTones(); |
| const toneDesc = tones.join(", "); |
| const narration = `Scene ${sceneNum}. ${project.logline || `The story of "${project.title}" continues...`}`; |
| const imagePrompt = `${visualStyle} style, ${project.logline || project.title}, scene ${sceneNum}, ${toneDesc} mood, high quality, detailed, 4k, masterpiece`; |
| const negativePrompt = "blurry, low quality, text, watermark, ugly, deformed, disfigured, bad anatomy, worst quality, low resolution, duplicate, clone, multiple people, two heads, two faces, split image, extra limbs"; |
| const data = await postApi(`/studio/videos/${projectId}/scenes`, { |
| narration, |
| imagePrompt, |
| negativePrompt, |
| durationSec: 5.0, |
| }); |
| setScenes((prev) => [...prev, data.scene]); |
| setCurrentSceneIndex(scenes.length); |
| setLastSaved(new Date()); |
| console.log('[CreatorStudioEditor] Generated scene with fallback content:', sceneNum); |
| generateImageForScene(data.scene.id, data.scene.imagePrompt); |
| |
| syncOutlineWithScenes(); |
| } |
| catch (e) { |
| alert(`Failed to create scene: ${e.message}`); |
| } |
| finally { |
| setIsGeneratingScene(false); |
| } |
| }, [project, projectId, scenes.length, isGeneratingScene, postApi, getVisualStyle, getTones, generateImageForScene, syncOutlineWithScenes]); |
| |
| const generateNextForTVMode = useCallback(async () => { |
| if (!project || isGeneratingScene) |
| return null; |
| try { |
| const nextSceneIndex = scenes.length; |
| |
| try { |
| const data = await postApi(`/studio/videos/${projectId}/scenes/generate-from-outline?scene_index=${nextSceneIndex}`, {}); |
| if (data.ok && data.scene) { |
| setScenes((prev) => [...prev, data.scene]); |
| console.log(`[CreatorStudioEditor] TV Mode: Generated scene ${nextSceneIndex + 1} from outline`); |
| return sceneToTVScene(data.scene); |
| } |
| } |
| catch (outlineErr) { |
| console.log('[CreatorStudioEditor] TV Mode: No outline available, trying AI continuation'); |
| } |
| |
| if (scenes.length > 0) { |
| try { |
| console.log('[CreatorStudioEditor] TV Mode: Generating AI continuation from previous context...'); |
| const contData = await postApi(`/studio/videos/${projectId}/scenes/generate-continuation`, {}); |
| if (contData.ok && contData.scene) { |
| setScenes((prev) => [...prev, contData.scene]); |
| console.log(`[CreatorStudioEditor] TV Mode: Generated scene ${nextSceneIndex + 1} via AI continuation`); |
| return sceneToTVScene(contData.scene); |
| } |
| } |
| catch (contErr) { |
| console.log('[CreatorStudioEditor] TV Mode: AI continuation failed, using fallback:', contErr.message); |
| } |
| } |
| |
| const sceneNum = scenes.length + 1; |
| const visualStyle = getVisualStyle(); |
| const tones = getTones(); |
| const toneDesc = tones.join(", "); |
| const narration = `Scene ${sceneNum}. ${project.logline || `The story of "${project.title}" continues...`}`; |
| const imagePrompt = `${visualStyle} style, ${project.logline || project.title}, scene ${sceneNum}, ${toneDesc} mood, high quality, detailed, 4k, masterpiece`; |
| const negativePrompt = "blurry, low quality, text, watermark, ugly, deformed, disfigured, bad anatomy, worst quality, low resolution, duplicate, clone, multiple people, two heads, two faces, split image, extra limbs"; |
| const data = await postApi(`/studio/videos/${projectId}/scenes`, { |
| narration, |
| imagePrompt, |
| negativePrompt, |
| durationSec: 5.0, |
| }); |
| setScenes((prev) => [...prev, data.scene]); |
| return sceneToTVScene(data.scene); |
| } |
| catch (e) { |
| console.error('[CreatorStudioEditor] Failed to generate scene for TV mode:', e); |
| return null; |
| } |
| }, [project, projectId, scenes.length, isGeneratingScene, postApi, getVisualStyle, getTones, sceneToTVScene]); |
| |
| const ensureImageForTVMode = useCallback((tvScene) => { |
| const scene = scenes.find(s => s.idx === tvScene.idx); |
| if (!scene) |
| return; |
| if (!tvScene.image_url && !tvScene.image) { |
| generateImageForScene(scene.id, scene.imagePrompt).then(() => { |
| const updatedScene = scenes.find(s => s.idx === tvScene.idx); |
| if (updatedScene?.imageUrl) { |
| updateSceneImageByIdx(tvScene.idx, updatedScene.imageUrl); |
| } |
| }); |
| } |
| }, [scenes, generateImageForScene, updateSceneImageByIdx]); |
| |
| const getStatusBadge = (status) => { |
| switch (status) { |
| case "draft": |
| return { bg: "bg-amber-500/20", text: "text-amber-300", label: "Draft" }; |
| case "approved": |
| return { bg: "bg-emerald-500/20", text: "text-emerald-300", label: "Finished" }; |
| case "in_review": |
| return { bg: "bg-cyan-500/20", text: "text-cyan-300", label: "In Review" }; |
| case "archived": |
| return { bg: "bg-slate-500/20", text: "text-slate-300", label: "Archived" }; |
| default: |
| return { bg: "bg-slate-500/20", text: "text-slate-300", label: status }; |
| } |
| }; |
| |
| const SceneStatusIndicator = ({ status }) => { |
| switch (status) { |
| case 'generating': |
| return (<div className="w-4 h-4 rounded-full bg-black/60 flex items-center justify-center"> |
| <Loader2 size={10} className="text-cyan-400 animate-spin"/> |
| </div>); |
| case 'ready': |
| return null; |
| case 'error': |
| return (<div className="w-4 h-4 rounded-full bg-red-500/80 flex items-center justify-center"> |
| <AlertCircle size={10} className="text-white"/> |
| </div>); |
| case 'pending': |
| default: |
| return (<div className="w-4 h-4 rounded-full bg-black/60 flex items-center justify-center"> |
| <div className="w-2 h-2 rounded-full bg-white/40"/> |
| </div>); |
| } |
| }; |
| |
| if (loading) { |
| return (<div className="min-h-screen w-full bg-gradient-to-b from-black via-[#0a0a0f] to-[#0f0f18] text-white flex items-center justify-center"> |
| <div className="flex flex-col items-center gap-4"> |
| <Loader2 className="w-10 h-10 animate-spin text-cyan-400"/> |
| <div className="text-white/60 text-sm">Loading project...</div> |
| </div> |
| </div>); |
| } |
| |
| if (error || !project) { |
| return (<div className="min-h-screen w-full bg-gradient-to-b from-black via-[#0a0a0f] to-[#0f0f18] text-white flex items-center justify-center"> |
| <div className="flex flex-col items-center gap-4 max-w-md text-center"> |
| <div className="w-16 h-16 rounded-2xl bg-red-500/10 border border-red-500/20 flex items-center justify-center mb-2"> |
| <AlertCircle size={28} className="text-red-400"/> |
| </div> |
| <div className="text-red-400 text-lg font-medium">Failed to load project</div> |
| <div className="text-white/50 text-sm">{error || "Project not found"}</div> |
| <button onClick={onExit} className="mt-4 px-6 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-colors text-sm"> |
| ← Back to Studio |
| </button> |
| </div> |
| </div>); |
| } |
| const statusBadge = getStatusBadge(project.status); |
| return (<div className="min-h-screen w-full bg-gradient-to-b from-black via-[#0a0a0f] to-[#0f0f18] text-white flex flex-col"> |
| {/* Batch Generation Progress Overlay */} |
| {isBatchGenerating && (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"> |
| <div className="bg-[#1a1a2e] border border-white/10 rounded-2xl p-8 max-w-md w-full mx-4 shadow-2xl"> |
| <div className="text-center"> |
| {/* Animated Icon */} |
| <div className="mb-6 flex justify-center"> |
| <div className="relative"> |
| <div className="w-20 h-20 rounded-full border-4 border-[#3ea6ff]/20"/> |
| <div className="absolute inset-0 w-20 h-20 rounded-full border-4 border-transparent border-t-[#3ea6ff] animate-spin" style={{ animationDuration: '1s' }}/> |
| <div className="absolute inset-0 flex items-center justify-center"> |
| {batchProgress.phase === 'scene' ? (<Sparkles size={28} className="text-[#3ea6ff]"/>) : batchProgress.phase === 'image' ? (<ImageIcon size={28} className="text-[#3ea6ff]"/>) : (<Film size={28} className="text-[#3ea6ff]"/>)} |
| </div> |
| </div> |
| </div> |
| |
| {/* Title */} |
| <h2 className="text-xl font-semibold text-white mb-2"> |
| {batchProgress.phase === 'scene' ? 'Creating Scenes' : |
| batchProgress.phase === 'image' ? 'Generating Images' : 'Generating Videos'} |
| </h2> |
| |
| {/* Progress Text */} |
| <p className="text-white/60 mb-6"> |
| {batchProgress.phase === 'scene' |
| ? `Building scene ${batchProgress.current} of ${batchProgress.total}...` |
| : batchProgress.phase === 'image' |
| ? `Generating image ${batchProgress.current} of ${batchProgress.total}...` |
| : `Generating video ${batchProgress.current} of ${batchProgress.total}...`} |
| </p> |
| |
| {/* Progress Bar */} |
| <div className="mb-4"> |
| <div className="h-2 bg-white/10 rounded-full overflow-hidden"> |
| <div className="h-full bg-gradient-to-r from-[#3ea6ff] to-[#6ec7ff] transition-all duration-300 ease-out" style={{ |
| width: batchProgress.total > 0 |
| ? `${(batchProgress.current / batchProgress.total) * 100}%` |
| : '0%' |
| }}/> |
| </div> |
| <div className="mt-2 text-xs text-white/40"> |
| {batchProgress.phase === 'scene' |
| ? (projectWantsVideo ? 'Phase 1/3: Creating scenes' : 'Phase 1/2: Creating scenes') |
| : batchProgress.phase === 'image' |
| ? (projectWantsVideo ? 'Phase 2/3: Generating images' : 'Phase 2/2: Generating images') |
| : 'Phase 3/3: Generating videos'} |
| </div> |
| </div> |
| |
| {/* Tip */} |
| <p className="text-xs text-white/30 mt-4"> |
| {batchProgress.phase === 'video' |
| ? 'Video generation may take several minutes per scene' |
| : 'This may take a few minutes depending on your hardware'} |
| </p> |
| </div> |
| </div> |
| </div>)} |
| |
| {/* Header - Compact & Cinematic */} |
| <header className="flex items-center justify-between px-4 py-3 border-b border-white/5 bg-black/40 backdrop-blur-md"> |
| <div className="flex items-center gap-4"> |
| <button onClick={onExit} className="flex items-center gap-2 px-3 py-2 text-sm text-white/50 hover:text-white hover:bg-white/5 rounded-lg transition-all"> |
| <ArrowLeft size={16}/> |
| <span className="hidden sm:inline">Back</span> |
| </button> |
| |
| <div className="h-6 w-px bg-white/10"/> |
| |
| <div> |
| <h1 className="text-base font-semibold text-white">{project.title}</h1> |
| <div className="text-xs text-white/40"> |
| {scenes.length} scene{scenes.length !== 1 ? "s" : ""} • Creator Studio |
| </div> |
| </div> |
| </div> |
| |
| <div className="flex items-center gap-2"> |
| {/* Status Badge */} |
| <span className={`text-xs px-2.5 py-1 rounded-full font-medium ${statusBadge.bg} ${statusBadge.text}`}> |
| {statusBadge.label} |
| </span> |
| |
| {/* Save Indicator */} |
| <div className="hidden sm:flex items-center gap-1.5 text-xs text-white/40 px-2"> |
| {isSaving ? (<> |
| <Loader2 size={12} className="animate-spin"/> |
| <span>Saving...</span> |
| </>) : lastSaved ? (<> |
| <Check size={12} className="text-emerald-400"/> |
| <span>Saved</span> |
| </>) : null} |
| </div> |
| |
| {/* Project Settings Button */} |
| <button onClick={openSettingsModal} className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all" title="Project Settings"> |
| <Settings size={16} className="text-white/60"/> |
| </button> |
| |
| {/* Story Outline Button */} |
| <button onClick={() => setShowOutlinePanel(true)} className="flex items-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-sm transition-all" title="Story Outline"> |
| <Wand2 size={14} className="text-cyan-400"/> |
| <span className="hidden sm:inline">Outline</span> |
| </button> |
| |
| {/* Export Button — opens the 2-step MP4 export wizard. */} |
| <button type="button" onClick={() => setShowExportWizard(true)} className="flex items-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-sm transition-all" title="Export project"> |
| <Download size={14}/> |
| <span className="hidden sm:inline">Export</span> |
| </button> |
| </div> |
| </header> |
|
|
| { |
| } |
| {showExportWizard && (<CreatorExportWizard open={showExportWizard} onClose={() => setShowExportWizard(false)} backendUrl={backendUrl} videoId={projectId} videoTitle={project?.title} scenes={scenes.map((s) => ({ |
| id: s.id, |
| idx: s.idx, |
| narration: s.narration || "", |
| audioUrl: s.audioUrl || null, |
| }))} disabledReason={scenes.length === 0 |
| ? "Add at least one scene before exporting." |
| : scenes.some((s) => s.status !== "ready") |
| ? "All scenes must finish generating before export." |
| : null}/>)} |
|
|
| {} |
| {scenes.length > 0 && (<div className="w-full overflow-x-auto scrollbar-hide border-b border-white/5 bg-black/20"> |
| <div className="flex gap-2 px-4 py-3 min-w-max"> |
| {scenes.map((scene, idx) => { |
| const isActive = idx === currentSceneIndex; |
| const hasImage = Boolean(scene.imageUrl); |
| const hasVideo = Boolean(scene.videoUrl); |
| const isHovered = hoveredSceneIdx === idx; |
| const showDelete = isHovered && scenes.length > 1; |
| return (<div key={scene.id} className="relative" onMouseEnter={() => setHoveredSceneIdx(idx)} onMouseLeave={() => setHoveredSceneIdx(null)}> |
| <button onClick={() => setCurrentSceneIndex(idx)} className={` |
| relative rounded-lg overflow-hidden transition-all duration-200 |
| ${isActive |
| ? "ring-2 ring-cyan-400 ring-offset-2 ring-offset-black scale-105" |
| : hasVideo |
| ? "ring-1 ring-cyan-500/50 opacity-80 hover:opacity-100 hover:scale-102" |
| : "opacity-60 hover:opacity-100 hover:scale-102"} |
| `} type="button" title={`Scene ${idx + 1}${hasVideo ? ' (Video)' : ''}`}> |
| <div className="w-20 h-12 flex items-center justify-center bg-white/5"> |
| {hasImage ? (<img src={scene.imageUrl} alt={`Scene ${idx + 1}`} className="w-full h-full object-cover"/>) : (<ImageIcon size={16} className="text-white/20"/>)} |
| </div> |
| |
| {/* Subtle video indicator - small dot in corner */} |
| {hasVideo && (<div className="absolute top-1 right-1 w-2 h-2 bg-cyan-400 rounded-full shadow-sm shadow-cyan-400/50" title="Has video"/>)} |
| |
| {/* Status indicator */} |
| <div className="absolute bottom-1 right-1"> |
| <SceneStatusIndicator status={scene.status}/> |
| </div> |
| |
| {/* Scene number */} |
| {!scene.status || scene.status === 'ready' ? (<div className="absolute bottom-1 left-1 text-[10px] bg-black/70 px-1.5 rounded font-medium"> |
| {idx + 1} |
| </div>) : null} |
| </button> |
| |
| {/* Delete button */} |
| {showDelete && (<button onClick={(e) => { |
| e.stopPropagation(); |
| deleteScene(scene.id); |
| }} className="absolute -top-2 -right-2 w-5 h-5 rounded-full flex items-center justify-center transition-all transform hover:scale-110 bg-black/90 text-white/60 hover:text-white hover:bg-red-500 border border-white/10" type="button" title="Delete scene"> |
| <X size={10}/> |
| </button>)} |
| </div>); |
| })} |
| |
| {/* Add Scene Chip */} |
| <button onClick={generateNextScene} disabled={isGeneratingScene} className="w-20 h-12 rounded-lg border border-dashed border-white/20 hover:border-cyan-400/50 hover:bg-cyan-400/5 flex items-center justify-center transition-all disabled:opacity-40" title="Add scene"> |
| {isGeneratingScene ? (<Loader2 size={16} className="text-cyan-400 animate-spin"/>) : (<Plus size={16} className="text-white/40"/>)} |
| </button> |
| </div> |
| </div>)} |
|
|
| {} |
| {scenes.length === 0 ? ( |
| |
| <div className="flex-1 flex items-center justify-center p-8"> |
| <div className="max-w-md text-center"> |
| <div className="w-28 h-28 mx-auto mb-8 rounded-3xl bg-gradient-to-br from-cyan-500/20 via-blue-500/10 to-transparent border border-cyan-500/20 flex items-center justify-center"> |
| <ImageIcon size={48} className="text-cyan-400/60"/> |
| </div> |
| |
| <h2 className="text-2xl font-semibold text-white mb-3">Create Your First Scene</h2> |
| <p className="text-white/50 mb-8 leading-relaxed"> |
| Your project is ready. Generate a scene to start bringing your story to life with AI-powered visuals. |
| </p> |
| |
| <button onClick={generateFirstScene} disabled={isGeneratingScene} className="inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 disabled:opacity-50 disabled:cursor-not-allowed rounded-2xl text-base font-semibold shadow-lg shadow-cyan-500/25 transition-all"> |
| {isGeneratingScene ? (<> |
| <Loader2 size={20} className="animate-spin"/> |
| Generating... |
| </>) : (<> |
| <Play size={20} fill="currentColor"/> |
| Generate First Scene |
| </>)} |
| </button> |
|
|
| <p className="text-xs text-white/30 mt-6"> |
| Powered by AI • Based on your project settings |
| </p> |
| </div> |
| </div>) : ( |
| |
| <div className="flex-1 flex flex-col"> |
| {/* Preview Panel - Dominant */} |
| <div className="flex-1 relative overflow-hidden"> |
| {/* Background gradient */} |
| <div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#0a0a0f] to-[#0f0f18]"/> |
| |
| {/* Main preview area */} |
| <div className="absolute inset-0 flex items-center justify-center p-6"> |
| {currentScene?.videoUrl ? ( |
| /* Video/Animation Preview - when scene has generated media */ |
| <div className="relative max-w-full max-h-full group"> |
| {isAnimatedImageUrl(currentScene.videoUrl) ? ( |
| /* Animated WebP/GIF - render as <img> (some Comfy workflows output this) */ |
| <img src={proxyVideoUrl(currentScene.videoUrl) || currentScene.videoUrl} alt={`Scene ${currentSceneIndex + 1} animation`} className="max-h-[calc(100vh-320px)] max-w-full object-contain rounded-xl shadow-2xl shadow-black/50"/>) : isWebmUrl(currentScene.videoUrl) ? ( |
| /* WebM video - render as <video> */ |
| canPlayWebm ? (<video className="max-h-[calc(100vh-320px)] max-w-full object-contain rounded-xl shadow-2xl shadow-black/50" controls loop muted autoPlay playsInline preload="metadata" crossOrigin="anonymous" src={proxyVideoUrl(currentScene.videoUrl) || currentScene.videoUrl}/>) : (<div className="max-w-xl w-full bg-black/40 border border-white/10 rounded-xl p-6 text-center"> |
| <div className="flex items-center justify-center gap-2 text-cyan-300 font-medium mb-2"> |
| <Film size={16}/> |
| Video generated (WebM) |
| </div> |
| <div className="text-white/60 text-sm mb-4"> |
| This browser cannot play WebM inline. Use Chrome/Edge/Firefox, or download the clip. |
| </div> |
| <a href={proxyVideoUrl(currentScene.videoUrl) || currentScene.videoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10 text-sm"> |
| <Download size={14}/> |
| Open / Download WebM |
| </a> |
| </div>)) : ( |
| /* Unknown format fallback - try to display with download option */ |
| <div className="max-w-xl w-full bg-black/40 border border-white/10 rounded-xl p-6 text-center"> |
| <div className="flex items-center justify-center gap-2 text-cyan-300 font-medium mb-2"> |
| <Film size={16}/> |
| Generated media |
| </div> |
| <div className="text-white/60 text-sm mb-4"> |
| This output format may not preview inline. Try opening in a new tab. |
| </div> |
| <a href={proxyVideoUrl(currentScene.videoUrl) || currentScene.videoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/10 hover:bg-white/15 border border-white/10 text-sm"> |
| <Download size={14}/> |
| Open |
| </a> |
| </div>)} |
| |
| {/* Top-right overlay controls - glass style */} |
| <div className="absolute top-3 right-3 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"> |
| {/* More options */} |
| <button type="button" className="w-9 h-9 rounded-full bg-black/40 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/70 hover:text-white hover:bg-black/60 transition-all disabled:opacity-50 disabled:cursor-not-allowed" title="More options" onClick={() => { |
| setEditingScene(currentScene); |
| setEditNarration(currentScene.narration); |
| setEditImagePrompt(currentScene.imagePrompt); |
| setEditNegativePrompt(currentScene.negativePrompt || ''); |
| setShowSceneEditor(true); |
| }}> |
| <MoreHorizontal size={16}/> |
| </button> |
| {/* Regenerate video */} |
| <button type="button" onClick={() => generateVideoForScene(currentScene.id, currentScene.imageUrl, currentScene.imagePrompt)} disabled={isGeneratingVideo} className="w-9 h-9 rounded-full bg-black/40 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/70 hover:text-white hover:bg-black/60 transition-all disabled:opacity-50 disabled:cursor-not-allowed" title="Regenerate video"> |
| <RefreshCw size={16} className={isGeneratingVideo ? 'animate-spin' : ''}/> |
| </button> |
| {/* Remove video */} |
| <button type="button" onClick={() => removeVideoForScene(currentScene.id)} disabled={isGeneratingVideo} className="w-9 h-9 rounded-full bg-red-500/30 backdrop-blur-md border border-red-500/30 flex items-center justify-center text-red-200 hover:text-white hover:bg-red-500/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed" title="Remove video (keep image)"> |
| <X size={16}/> |
| </button> |
| </div> |
| |
| {/* Generating video overlay */} |
| {isGeneratingVideo && (<div className="absolute inset-0 flex items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl"> |
| <div className="flex flex-col items-center gap-3"> |
| <Loader2 size={36} className="text-cyan-400 animate-spin"/> |
| <span className="text-white/70 text-sm">Generating video...</span> |
| </div> |
| </div>)} |
| </div>) : currentScene?.imageUrl ? ( |
| /* Image Preview - when scene has image but no video */ |
| <div className="relative max-w-full max-h-full group"> |
| <img src={currentScene.imageUrl} alt={`Scene ${currentSceneIndex + 1}`} className="max-h-[calc(100vh-320px)] max-w-full object-contain rounded-xl shadow-2xl shadow-black/50 transition-all duration-500"/> |
| |
| {/* Top-right overlay controls - glass style */} |
| <div className="absolute top-3 right-3 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"> |
| {/* More options */} |
| <button type="button" className="w-9 h-9 rounded-full bg-black/40 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/70 hover:text-white hover:bg-black/60 transition-all disabled:opacity-50 disabled:cursor-not-allowed" title="Edit scene" onClick={() => { |
| setEditingScene(currentScene); |
| setEditNarration(currentScene.narration); |
| setEditImagePrompt(currentScene.imagePrompt); |
| setEditNegativePrompt(currentScene.negativePrompt || ''); |
| setShowSceneEditor(true); |
| }}> |
| <MoreHorizontal size={16}/> |
| </button> |
| {/* Regenerate image */} |
| <button type="button" onClick={() => generateImageForScene(currentScene.id, currentScene.imagePrompt, true)} disabled={isGeneratingImage || isGeneratingVideo} className="w-9 h-9 rounded-full bg-black/40 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/70 hover:text-white hover:bg-black/60 transition-all disabled:opacity-50 disabled:cursor-not-allowed" title="Regenerate image"> |
| <RefreshCw size={16} className={isGeneratingImage ? 'animate-spin' : ''}/> |
| </button> |
| </div> |
| |
| {/* Bottom-right Make Video CTA - only show if project wants video and scene has no video yet */} |
| {projectWantsVideo && !currentScene.videoUrl && !isGeneratingImage && !isGeneratingVideo && (<div className="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200"> |
| <button type="button" onClick={() => generateVideoForScene(currentScene.id, currentScene.imageUrl, currentScene.imagePrompt)} className="flex items-center gap-2 px-4 py-2 bg-cyan-500/90 backdrop-blur-md border border-cyan-400/30 rounded-full text-white text-sm font-medium hover:bg-cyan-500 transition-all shadow-lg shadow-cyan-500/20" title="Convert to video"> |
| <Film size={14}/> |
| Make Video |
| </button> |
| </div>)} |
| |
| {/* Generating image overlay */} |
| {isGeneratingImage && (<div className="absolute inset-0 flex items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl"> |
| <div className="flex flex-col items-center gap-3"> |
| <Loader2 size={36} className="text-cyan-400 animate-spin"/> |
| <span className="text-white/70 text-sm">Generating image...</span> |
| </div> |
| </div>)} |
| |
| {/* Generating video overlay */} |
| {isGeneratingVideo && (<div className="absolute inset-0 flex items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl"> |
| <div className="flex flex-col items-center gap-3"> |
| <Loader2 size={36} className="text-cyan-400 animate-spin"/> |
| <span className="text-white/70 text-sm">Converting to video...</span> |
| <span className="text-white/40 text-xs">This may take a few minutes</span> |
| </div> |
| </div>)} |
| </div>) : ( |
| /* Empty state when no image */ |
| <div className="flex flex-col items-center justify-center text-center p-8"> |
| {isGeneratingImage ? (<> |
| <Loader2 size={48} className="text-cyan-400 animate-spin mb-4"/> |
| <p className="text-white/60 text-sm">Generating image...</p> |
| {currentScene?.imagePrompt && (<p className="text-white/30 text-xs mt-2 max-w-md line-clamp-2">{currentScene.imagePrompt}</p>)} |
| </>) : (<> |
| <div className="w-20 h-20 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-4"> |
| <ImageIcon size={32} className="text-white/20"/> |
| </div> |
| <p className="text-white/40 text-sm mb-4">No image for this scene</p> |
| <button onClick={() => currentScene && generateImageForScene(currentScene.id, currentScene.imagePrompt)} className="flex items-center gap-2 px-5 py-2.5 bg-cyan-500 hover:bg-cyan-400 rounded-full text-white text-sm font-medium transition-colors" type="button"> |
| Generate Image |
| </button> |
| </>)} |
| </div>)} |
| </div> |
| |
| {/* Narration subtitle overlay */} |
| {currentScene?.narration && (<div className="absolute bottom-8 left-0 right-0 flex justify-center px-8 pointer-events-none"> |
| <div className="bg-black/80 backdrop-blur-md px-6 py-4 rounded-xl max-w-3xl shadow-xl border border-white/5"> |
| <p className="text-base md:text-lg text-white leading-relaxed text-center"> |
| {currentScene.narration} |
| </p> |
| </div> |
| </div>)} |
| </div> |
| |
| {/* Action Bar - Like Play Story */} |
| <div className="border-t border-white/5 bg-black/60 backdrop-blur-md"> |
| <div className="max-w-4xl mx-auto px-4 py-4"> |
| <div className="flex items-center justify-between gap-4"> |
| {/* Left: Playback controls */} |
| <div className="flex items-center gap-2"> |
| <button onClick={() => setCurrentSceneIndex((i) => Math.max(0, i - 1))} disabled={currentSceneIndex === 0} className="p-3 text-white/40 hover:text-white hover:bg-white/5 rounded-full transition-all disabled:opacity-30 disabled:cursor-not-allowed" type="button" title="Previous scene"> |
| <SkipBack size={20}/> |
| </button> |
| |
| <button onClick={() => setIsPlaying(!isPlaying)} className="p-4 bg-cyan-500 hover:bg-cyan-400 rounded-full transition-all shadow-lg shadow-cyan-500/25" type="button" title={isPlaying ? "Pause" : "Play"}> |
| {isPlaying ? <Pause size={24}/> : <Play size={24} fill="currentColor"/>} |
| </button> |
| |
| <button onClick={() => setCurrentSceneIndex((i) => Math.min(scenes.length - 1, i + 1))} disabled={currentSceneIndex >= scenes.length - 1} className="p-3 text-white/40 hover:text-white hover:bg-white/5 rounded-full transition-all disabled:opacity-30 disabled:cursor-not-allowed" type="button" title="Next scene"> |
| <SkipForward size={20}/> |
| </button> |
| </div> |
| |
| {/* Center: Scene progress bar */} |
| <div className="flex-1 mx-4 hidden sm:block"> |
| <div className="flex gap-1"> |
| {scenes.map((_, i) => (<button key={i} onClick={() => setCurrentSceneIndex(i)} className={`flex-1 h-1.5 rounded-full transition-all ${i === currentSceneIndex |
| ? 'bg-cyan-400' |
| : i < currentSceneIndex |
| ? 'bg-white/30' |
| : 'bg-white/10'}`} type="button" title={`Scene ${i + 1}`}/>))} |
| </div> |
| </div> |
| |
| {/* Right: Actions */} |
| <div className="flex items-center gap-2"> |
| {/* Edit Scene Button */} |
| <button onClick={() => currentScene && openSceneEditor(currentScene)} disabled={!currentScene} className="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-sm transition-all disabled:opacity-40" title="Edit scene"> |
| <Edit3 size={14}/> |
| <span className="hidden sm:inline">Edit</span> |
| </button> |
| |
| {/* Generate Next Scene */} |
| <button onClick={generateNextScene} disabled={isGeneratingScene} className="flex items-center gap-2 px-4 py-2 bg-cyan-500/20 hover:bg-cyan-500/30 text-cyan-300 border border-cyan-500/30 rounded-full text-sm transition-all disabled:opacity-50" title="Generate next scene"> |
| {isGeneratingScene ? (<> |
| <Loader2 size={14} className="animate-spin"/> |
| <span className="hidden sm:inline">Generating...</span> |
| </>) : (<> |
| <Plus size={14}/> |
| <span className="hidden sm:inline">Next Scene</span> |
| </>)} |
| </button> |
| |
| {/* TV Mode */} |
| <button onClick={handleEnterTVMode} disabled={scenes.length === 0} className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-500/20 to-blue-500/20 hover:from-cyan-500/30 hover:to-blue-500/30 text-cyan-300 border border-cyan-500/30 rounded-full text-sm transition-all disabled:opacity-40" type="button" title="Watch in TV Mode"> |
| <Monitor size={14}/> |
| <span className="hidden sm:inline">TV Mode</span> |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div>)} |
|
|
| {} |
| {showSceneEditor && editingScene && (<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> |
| <div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={() => setShowSceneEditor(false)}/> |
| <div className="relative w-full max-w-2xl rounded-2xl border border-white/10 bg-[#0f0f18] shadow-2xl max-h-[90vh] overflow-y-auto"> |
| {/* Modal Header */} |
| <div className="flex items-center justify-between p-5 border-b border-white/10"> |
| <div className="flex items-center gap-3"> |
| <div className="w-10 h-10 rounded-xl bg-cyan-500/20 flex items-center justify-center"> |
| <Edit3 size={18} className="text-cyan-400"/> |
| </div> |
| <div> |
| <h2 className="text-lg font-semibold">Edit Scene {editingScene.idx + 1}</h2> |
| <p className="text-xs text-white/40">Update narration and prompts</p> |
| </div> |
| </div> |
| <button onClick={() => setShowSceneEditor(false)} className="p-2 rounded-lg hover:bg-white/5 transition-colors"> |
| <X size={18} className="text-white/40"/> |
| </button> |
| </div> |
| |
| {/* Modal Content */} |
| <div className="p-5 space-y-5"> |
| {/* Narration */} |
| <div> |
| <label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2"> |
| <FileText size={14}/> |
| Narration |
| </label> |
| <textarea value={editNarration} onChange={(e) => setEditNarration(e.target.value)} className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl text-white placeholder-white/30 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/25 focus:outline-none resize-none transition-all" rows={3} placeholder="Enter narration text for this scene..."/> |
| </div> |
| |
| {/* Image Prompt */} |
| <div> |
| <label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2"> |
| <Sparkles size={14}/> |
| Image Prompt |
| </label> |
| <textarea value={editImagePrompt} onChange={(e) => setEditImagePrompt(e.target.value)} className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl text-white placeholder-white/30 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/25 focus:outline-none resize-none transition-all" rows={4} placeholder="Describe the visual elements for image generation..."/> |
| </div> |
| |
| {/* Negative Prompt */} |
| <div> |
| <label className="text-sm font-medium text-white/70 mb-2 block"> |
| Negative Prompt |
| </label> |
| <textarea value={editNegativePrompt} onChange={(e) => setEditNegativePrompt(e.target.value)} className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl text-white placeholder-white/30 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/25 focus:outline-none resize-none transition-all" rows={2} placeholder="Elements to avoid in the image..."/> |
| </div> |
| |
| {/* Model Selection */} |
| <div className="grid grid-cols-2 gap-4"> |
| <div> |
| <label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2"> |
| <Settings size={14}/> |
| LLM Model |
| </label> |
| <select value={selectedLLMModel} onChange={(e) => setSelectedLLMModel(e.target.value)} className="w-full px-3 py-2.5 bg-black/40 border border-white/10 rounded-xl text-white focus:border-cyan-500/50 focus:outline-none transition-all"> |
| <option value="">Default</option> |
| {availableLLMModels.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>))} |
| </select> |
| </div> |
| |
| <div> |
| <label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2"> |
| <ImageIcon size={14}/> |
| Image Model |
| </label> |
| <select value={selectedImageModel} onChange={(e) => setSelectedImageModel(e.target.value)} className="w-full px-3 py-2.5 bg-black/40 border border-white/10 rounded-xl text-white focus:border-cyan-500/50 focus:outline-none transition-all"> |
| <option value="">Default</option> |
| {availableImageModels.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>))} |
| </select> |
| </div> |
| </div> |
| </div> |
| |
| {/* Modal Footer */} |
| <div className="flex items-center justify-between p-5 border-t border-white/10"> |
| <div className="flex items-center gap-2"> |
| {/* Regenerate Image button */} |
| <button onClick={() => { |
| generateImageForScene(editingScene.id, editImagePrompt, true); |
| setShowSceneEditor(false); |
| }} disabled={isGeneratingImage || isGeneratingVideo} className="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-sm transition-all disabled:opacity-50"> |
| <RefreshCw size={14}/> |
| Regenerate Image |
| </button> |
| |
| {/* Make Video / Regenerate Video button */} |
| {projectWantsVideo && editingScene.imageUrl && (<div className="flex items-center gap-2"> |
| <button onClick={() => { |
| generateVideoForScene(editingScene.id, editingScene.imageUrl, editImagePrompt); |
| setShowSceneEditor(false); |
| }} disabled={isGeneratingImage || isGeneratingVideo} className={[ |
| "flex items-center gap-2 px-4 py-2 rounded-xl text-sm transition-all disabled:opacity-50", |
| editingScene.videoUrl |
| ? "bg-white/5 hover:bg-white/10 border border-white/10" |
| : "bg-cyan-500/20 hover:bg-cyan-500/30 border border-cyan-500/30 text-cyan-300" |
| ].join(" ")}> |
| <Film size={14}/> |
| {editingScene.videoUrl ? 'Regenerate Video' : 'Make Video'} |
| </button> |
| |
| {editingScene.videoUrl && (<button onClick={() => { |
| removeVideoForScene(editingScene.id); |
| setShowSceneEditor(false); |
| }} disabled={isGeneratingImage || isGeneratingVideo} className="flex items-center gap-2 px-4 py-2 bg-red-500/15 hover:bg-red-500/25 border border-red-500/25 rounded-xl text-sm text-red-200 transition-all disabled:opacity-50" title="Remove video and keep image"> |
| <X size={14}/> |
| Remove Video |
| </button>)} |
| </div>)} |
| </div> |
| |
| <div className="flex items-center gap-3"> |
| <button onClick={() => setShowSceneEditor(false)} className="px-4 py-2 text-sm text-white/50 hover:text-white transition-colors"> |
| Cancel |
| </button> |
| <button onClick={saveSceneEdits} disabled={isSavingScene} className="flex items-center gap-2 px-5 py-2.5 bg-cyan-500 hover:bg-cyan-400 disabled:opacity-50 rounded-xl text-sm font-medium transition-all"> |
| {isSavingScene ? (<> |
| <Loader2 size={14} className="animate-spin"/> |
| Saving... |
| </>) : (<> |
| <Save size={14}/> |
| Save Changes |
| </>)} |
| </button> |
| </div> |
| </div> |
| </div> |
| </div>)} |
|
|
| {} |
| {showOutlinePanel && (<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> |
| <div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={() => setShowOutlinePanel(false)}/> |
| <div className="relative w-full max-w-3xl rounded-2xl border border-white/10 bg-[#0f0f18] shadow-2xl max-h-[90vh] overflow-y-auto"> |
| {/* Panel Header */} |
| <div className="flex items-center justify-between p-5 border-b border-white/10"> |
| <div className="flex items-center gap-3"> |
| <div className="w-10 h-10 rounded-xl bg-cyan-500/20 flex items-center justify-center"> |
| <Wand2 size={18} className="text-cyan-400"/> |
| </div> |
| <div> |
| <h2 className="text-lg font-semibold">Story Outline</h2> |
| <p className="text-xs text-white/40">AI-powered story structure</p> |
| </div> |
| </div> |
| <div className="flex items-center gap-3"> |
| <button onClick={generateStoryOutline} disabled={isGeneratingOutline} className="flex items-center gap-2 px-4 py-2 bg-cyan-500 hover:bg-cyan-400 disabled:opacity-50 rounded-xl text-sm font-medium transition-all"> |
| {isGeneratingOutline ? (<> |
| <Loader2 size={14} className="animate-spin"/> |
| Generating... |
| </>) : (<> |
| <Sparkles size={14}/> |
| {storyOutline ? "Regenerate" : "Generate"} |
| </>)} |
| </button> |
| <button onClick={() => setShowOutlinePanel(false)} className="p-2 rounded-lg hover:bg-white/5 transition-colors"> |
| <X size={18} className="text-white/40"/> |
| </button> |
| </div> |
| </div> |
|
|
| {} |
| <div className="p-5"> |
| {storyOutline ? (<div className="space-y-6"> |
| {/* Story Arc */} |
| <div className="p-4 bg-black/30 rounded-xl border border-white/5"> |
| <h3 className="text-sm font-semibold text-cyan-400 mb-3">Story Arc</h3> |
| <div className="grid grid-cols-5 gap-2 text-xs"> |
| {['beginning', 'rising_action', 'climax', 'falling_action', 'resolution'].map((key, i) => (<div key={key} className="p-2.5 bg-white/5 rounded-lg"> |
| <div className="text-white/40 mb-1 capitalize">{['Beginning', 'Rising', 'Climax', 'Falling', 'Resolution'][i]}</div> |
| <div className="text-white/80">{storyOutline.story_arc?.[key] || "—"}</div> |
| </div>))} |
| </div> |
| </div> |
| |
| {/* Scene Outlines */} |
| <div> |
| <h3 className="text-sm font-semibold text-white/70 mb-3"> |
| Scene Outlines ({storyOutline.scenes?.length || 0} scenes) |
| </h3> |
| <div className="space-y-3"> |
| {storyOutline.scenes?.map((scene, idx) => { |
| const alreadyGenerated = scenes.length > idx; |
| return (<div key={idx} className={`p-4 rounded-xl border transition-all ${alreadyGenerated |
| ? "bg-emerald-500/10 border-emerald-500/30" |
| : "bg-black/30 border-white/10 hover:border-white/20"}`}> |
| <div className="flex items-start justify-between gap-4"> |
| <div className="flex-1"> |
| <div className="flex items-center gap-2 mb-1"> |
| <span className="text-sm font-medium text-white"> |
| Scene {scene.scene_number}: {scene.title} |
| </span> |
| {alreadyGenerated && (<span className="text-xs px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full"> |
| Generated |
| </span>)} |
| </div> |
| <p className="text-sm text-white/50 mb-2">{scene.description}</p> |
| <p className="text-xs text-white/30 italic">"{scene.narration}"</p> |
| </div> |
| {!alreadyGenerated && scenes.length === idx && (<button onClick={() => { |
| generateSceneFromOutline(idx); |
| setShowOutlinePanel(false); |
| }} disabled={isGeneratingScene} className="flex items-center gap-1.5 px-3 py-1.5 bg-cyan-500 hover:bg-cyan-400 disabled:opacity-50 rounded-lg text-xs font-medium transition-all"> |
| <Plus size={12}/> |
| Generate |
| </button>)} |
| </div> |
| </div>); |
| })} |
| </div> |
| </div> |
| </div>) : (<div className="text-center py-16"> |
| <div className="w-20 h-20 mx-auto rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-6"> |
| <Wand2 size={32} className="text-white/20"/> |
| </div> |
| <h3 className="text-lg font-medium text-white mb-2">No Outline Yet</h3> |
| <p className="text-white/40 mb-8 max-w-md mx-auto"> |
| Generate an AI-powered story outline based on your project settings. |
| This creates a complete story arc with scene-by-scene planning. |
| </p> |
| <button onClick={generateStoryOutline} disabled={isGeneratingOutline} className="inline-flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 disabled:opacity-50 rounded-2xl font-semibold shadow-lg shadow-cyan-500/25 transition-all"> |
| {isGeneratingOutline ? (<> |
| <Loader2 size={20} className="animate-spin"/> |
| Generating Outline... |
| </>) : (<> |
| <Sparkles size={20}/> |
| Generate Story Outline |
| </>)} |
| </button> |
| </div>)} |
| </div> |
| </div> |
| </div>)} |
|
|
| {} |
| {showSettingsModal && (<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> |
| <div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={() => setShowSettingsModal(false)}/> |
| <div className="relative w-full max-w-2xl rounded-2xl border border-white/10 bg-[#0f0f18] shadow-2xl max-h-[90vh] overflow-y-auto"> |
| {/* Modal Header */} |
| <div className="flex items-center justify-between p-5 border-b border-white/10 sticky top-0 bg-[#0f0f18] z-10"> |
| <div className="flex items-center gap-3"> |
| <div className="w-10 h-10 rounded-xl bg-cyan-500/20 flex items-center justify-center"> |
| <Settings size={18} className="text-cyan-400"/> |
| </div> |
| <div> |
| <h2 className="text-lg font-semibold">Project Settings</h2> |
| <p className="text-xs text-white/40">Customize your project configuration</p> |
| </div> |
| </div> |
| <button onClick={() => setShowSettingsModal(false)} className="p-2 rounded-lg hover:bg-white/5 transition-colors"> |
| <X size={18} className="text-white/40"/> |
| </button> |
| </div> |
| |
| {/* Modal Content */} |
| <div className="p-5 space-y-6"> |
| {/* Title & Description */} |
| <div className="space-y-4"> |
| <h3 className="text-sm font-semibold text-cyan-400 flex items-center gap-2"> |
| <FileText size={14}/> |
| Details |
| </h3> |
| |
| <div> |
| <label className="block text-xs font-medium text-white/50 mb-2">Title</label> |
| <input type="text" value={settingsTitle} onChange={(e) => setSettingsTitle(e.target.value)} className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl text-white placeholder-white/30 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/25 focus:outline-none transition-all" placeholder="Project title"/> |
| </div> |
| |
| <div> |
| <label className="block text-xs font-medium text-white/50 mb-2">Description</label> |
| <textarea value={settingsLogline} onChange={(e) => setSettingsLogline(e.target.value)} className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl text-white placeholder-white/30 focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/25 focus:outline-none resize-none transition-all" rows={2} placeholder="A short description of your project..."/> |
| </div> |
| </div> |
| |
| {/* Format */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400 flex items-center gap-2"> |
| <Tv size={14}/> |
| Format |
| </h3> |
| <div className="grid grid-cols-3 gap-3"> |
| <button onClick={() => setSettingsPlatform("youtube_16_9")} className={`p-4 rounded-xl border text-center transition-all ${settingsPlatform === "youtube_16_9" |
| ? "border-cyan-500 bg-cyan-500/10" |
| : "border-white/10 bg-white/5 hover:bg-white/10"}`}> |
| <Tv size={24} className={`mx-auto mb-2 ${settingsPlatform === "youtube_16_9" ? "text-cyan-400" : "text-white/40"}`}/> |
| <div className="text-sm font-medium">YouTube Video</div> |
| <div className="text-xs text-white/40">16:9 Landscape</div> |
| </button> |
| |
| <button onClick={() => setSettingsPlatform("shorts_9_16")} className={`p-4 rounded-xl border text-center transition-all ${settingsPlatform === "shorts_9_16" |
| ? "border-cyan-500 bg-cyan-500/10" |
| : "border-white/10 bg-white/5 hover:bg-white/10"}`}> |
| <Smartphone size={24} className={`mx-auto mb-2 ${settingsPlatform === "shorts_9_16" ? "text-cyan-400" : "text-white/40"}`}/> |
| <div className="text-sm font-medium">YouTube Short</div> |
| <div className="text-xs text-white/40">9:16 Vertical</div> |
| </button> |
| |
| <button onClick={() => setSettingsPlatform("slides_16_9")} className={`p-4 rounded-xl border text-center transition-all ${settingsPlatform === "slides_16_9" |
| ? "border-cyan-500 bg-cyan-500/10" |
| : "border-white/10 bg-white/5 hover:bg-white/10"}`}> |
| <Presentation size={24} className={`mx-auto mb-2 ${settingsPlatform === "slides_16_9" ? "text-cyan-400" : "text-white/40"}`}/> |
| <div className="text-sm font-medium">Slides</div> |
| <div className="text-xs text-white/40">16:9 Presentation</div> |
| </button> |
| </div> |
| </div> |
| |
| {/* Image Generation Model */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400 flex items-center gap-2"> |
| <ImageIcon size={14}/> |
| Image Generation |
| </h3> |
| <div className="p-4 rounded-xl border border-white/10 bg-white/5"> |
| <label className="block text-xs font-medium text-white/50 mb-2">Image Model</label> |
| <div className="relative"> |
| <select value={settingsImageModel} onChange={(e) => setSettingsImageModel(e.target.value)} className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl text-white focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/25 focus:outline-none transition-all appearance-none cursor-pointer" disabled={loadingModels}> |
| {loadingModels ? (<option value="">Loading models...</option>) : availableImageModels.length === 0 ? (<option value="">No models available</option>) : (availableImageModels.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>)))} |
| </select> |
| <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 pointer-events-none"/> |
| </div> |
| <p className="text-xs text-white/30 mt-2">Override the global image model for this project</p> |
| </div> |
| </div> |
| |
| {/* Video Generation Toggle */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400 flex items-center gap-2"> |
| <Film size={14}/> |
| Video Generation |
| </h3> |
| <div className={`p-4 rounded-xl border transition-all ${settingsEnableVideo |
| ? "border-cyan-500 bg-cyan-500/10" |
| : "border-white/10 bg-white/5 hover:bg-white/10 cursor-pointer"}`} onClick={(e) => { |
| // Only toggle if clicking on the toggle area, not on child elements like select |
| if (e.target.tagName !== 'SELECT') { |
| if (!settingsEnableVideo) |
| setSettingsEnableVideo(true); |
| } |
| }}> |
| <div className="flex items-center justify-between"> |
| <div className="flex items-center gap-3"> |
| <div className={`w-10 h-10 rounded-lg flex items-center justify-center ${settingsEnableVideo ? "bg-cyan-500/30" : "bg-white/10"}`}> |
| <Film size={18} className={settingsEnableVideo ? "text-cyan-400" : "text-white/40"}/> |
| </div> |
| <div> |
| <div className="text-sm font-medium">Enable Video Generation</div> |
| <div className="text-xs text-white/40"> |
| {settingsEnableVideo |
| ? "Make Video button visible for each scene" |
| : "Scenes will be static images only"} |
| </div> |
| </div> |
| </div> |
| <div className={`relative w-12 h-6 rounded-full transition-colors ${settingsEnableVideo ? "bg-cyan-500" : "bg-white/20"}`}> |
| <div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${settingsEnableVideo ? "translate-x-7" : "translate-x-1"}`}/> |
| </div> |
| </div> |
| {settingsEnableVideo && settingsPlatform === "slides_16_9" && (<div className="mt-3 p-2 bg-amber-500/10 border border-amber-500/30 rounded-lg text-xs text-amber-400"> |
| Tip: Slides with video will create animated clips instead of static images with Ken Burns effect. |
| </div>)} |
| {/* Video Model Selector - shown when video is enabled */} |
| {settingsEnableVideo && (<div className="mt-4 pt-4 border-t border-white/10" onClick={(e) => e.stopPropagation()}> |
| <label className="block text-xs font-medium text-white/50 mb-2">Video Model</label> |
| <div className="relative"> |
| <select value={settingsVideoModel} onChange={(e) => setSettingsVideoModel(e.target.value)} className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl text-white focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/25 focus:outline-none transition-all appearance-none cursor-pointer" disabled={loadingModels}> |
| {loadingModels ? (<option value="">Loading models...</option>) : availableVideoModels.length === 0 ? (<option value="">No video models available</option>) : (availableVideoModels.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>)))} |
| </select> |
| <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 pointer-events-none"/> |
| </div> |
| <p className="text-xs text-white/30 mt-2">Override the global video model for this project</p> |
| </div>)} |
| {/* Toggle OFF button when video is enabled */} |
| {settingsEnableVideo && (<button type="button" onClick={(e) => { e.stopPropagation(); setSettingsEnableVideo(false); }} className="mt-4 w-full px-4 py-2 text-xs text-white/50 hover:text-white/70 border border-white/10 rounded-lg hover:bg-white/5 transition-all"> |
| Disable Video Generation |
| </button>)} |
| </div> |
| </div> |
| |
| {/* Intent */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400">Intent</h3> |
| <div className="flex gap-2 flex-wrap"> |
| {["Entertain", "Educate", "Inspire"].map((g) => (<button key={g} onClick={() => setSettingsGoal(g)} className={`px-4 py-2 rounded-full text-sm border transition-all ${settingsGoal === g |
| ? "bg-white text-black font-medium border-transparent" |
| : "bg-white/5 text-white/60 border-white/10 hover:bg-white/10 hover:text-white"}`}> |
| {g} |
| </button>))} |
| </div> |
| </div> |
| |
| {/* Episode Configuration */} |
| <div className="grid grid-cols-2 gap-4"> |
| <div> |
| <label className="block text-xs font-medium text-white/50 mb-2">Scenes per Episode</label> |
| <div className="flex gap-2 flex-wrap"> |
| {[4, 6, 8, 10, 12].map((count) => (<button key={count} onClick={() => setSettingsSceneCount(count)} className={`px-4 py-2 rounded-lg text-sm transition-all ${settingsSceneCount === count |
| ? "bg-cyan-500 text-white font-medium" |
| : "bg-white/5 text-white/60 hover:bg-white/10"}`}> |
| {count} |
| </button>))} |
| </div> |
| <p className="text-xs text-white/30 mt-1"> |
| ~{settingsSceneCount * settingsSceneDuration}s total |
| </p> |
| </div> |
| |
| <div> |
| <label className="block text-xs font-medium text-white/50 mb-2">Scene Duration</label> |
| <div className="flex gap-2 flex-wrap"> |
| {[3, 5, 7, 10].map((dur) => (<button key={dur} onClick={() => setSettingsSceneDuration(dur)} className={`px-4 py-2 rounded-lg text-sm transition-all ${settingsSceneDuration === dur |
| ? "bg-cyan-500 text-white font-medium" |
| : "bg-white/5 text-white/60 hover:bg-white/10"}`}> |
| {dur}s |
| </button>))} |
| </div> |
| </div> |
| </div> |
| |
| {/* Visual Style */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400 flex items-center gap-2"> |
| <Palette size={14}/> |
| Visual Style |
| </h3> |
| <div className="grid grid-cols-3 gap-3"> |
| <button onClick={() => setSettingsVisualStyle("Cinematic")} className={`p-3 rounded-xl border text-center transition-all ${settingsVisualStyle === "Cinematic" |
| ? "border-cyan-500 bg-cyan-500/10" |
| : "border-white/10 bg-white/5 hover:bg-white/10"}`}> |
| <Camera size={20} className={`mx-auto mb-1.5 ${settingsVisualStyle === "Cinematic" ? "text-cyan-400" : "text-white/40"}`}/> |
| <div className="text-sm font-medium">Cinematic</div> |
| <div className="text-xs text-white/40">High fidelity</div> |
| </button> |
| |
| <button onClick={() => setSettingsVisualStyle("Digital Art")} className={`p-3 rounded-xl border text-center transition-all ${settingsVisualStyle === "Digital Art" |
| ? "border-cyan-500 bg-cyan-500/10" |
| : "border-white/10 bg-white/5 hover:bg-white/10"}`}> |
| <Palette size={20} className={`mx-auto mb-1.5 ${settingsVisualStyle === "Digital Art" ? "text-cyan-400" : "text-white/40"}`}/> |
| <div className="text-sm font-medium">Digital Art</div> |
| <div className="text-xs text-white/40">Stylized</div> |
| </button> |
| |
| <button onClick={() => setSettingsVisualStyle("Anime")} className={`p-3 rounded-xl border text-center transition-all ${settingsVisualStyle === "Anime" |
| ? "border-cyan-500 bg-cyan-500/10" |
| : "border-white/10 bg-white/5 hover:bg-white/10"}`}> |
| <Star size={20} className={`mx-auto mb-1.5 ${settingsVisualStyle === "Anime" ? "text-cyan-400" : "text-white/40"}`}/> |
| <div className="text-sm font-medium">Anime</div> |
| <div className="text-xs text-white/40">Japanese style</div> |
| </button> |
| </div> |
| </div> |
| |
| {/* Mood & Tone */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400">Mood & Tone</h3> |
| <div className="flex gap-2 flex-wrap"> |
| {["Documentary", "Dramatic", "Calm", "Upbeat", "Dark"].map((t) => (<button key={t} onClick={() => toggleSettingsTone(t)} className={`px-4 py-2 rounded-full text-sm border transition-all ${settingsTones.includes(t) |
| ? "bg-white text-black font-medium border-transparent" |
| : "bg-white/5 text-white/60 border-white/10 hover:bg-white/10 hover:text-white"}`}> |
| {t} |
| </button>))} |
| </div> |
| </div> |
| |
| {/* AI Story Model */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400 flex items-center gap-2"> |
| <Sparkles size={14}/> |
| AI Story Model |
| </h3> |
| <p className="text-xs text-white/40">Select the AI model for generating outlines and narration</p> |
| <select value={settingsLLMModel} onChange={(e) => setSettingsLLMModel(e.target.value)} className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded-xl text-white focus:border-cyan-500/50 focus:outline-none transition-all appearance-none cursor-pointer"> |
| {availableLLMModels.length === 0 ? (<option value="">No models available</option>) : (availableLLMModels.map((m) => (<option key={m.id} value={m.id}> |
| {m.name} |
| </option>)))} |
| </select> |
| {availableLLMModels.length === 0 && (<p className="text-xs text-red-400"> |
| No Ollama models found. Make sure Ollama is running with at least one model. |
| </p>)} |
| </div> |
| |
| {/* Checks */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400 flex items-center gap-2"> |
| <Shield size={14}/> |
| Checks |
| </h3> |
| <div className="bg-black/30 rounded-xl border border-white/10 divide-y divide-white/10"> |
| <label className="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5 transition-colors"> |
| <div className="flex items-center gap-3"> |
| <Lock size={16} className="text-white/40"/> |
| <div> |
| <div className="text-sm font-medium">Consistency Lock</div> |
| <div className="text-xs text-white/40">Keep characters stable across scenes</div> |
| </div> |
| </div> |
| <input type="checkbox" checked={settingsLockIdentity} onChange={(e) => setSettingsLockIdentity(e.target.checked)} className="w-5 h-5 accent-cyan-500"/> |
| </label> |
| |
| <label className="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5 transition-colors"> |
| <div className="flex items-center gap-3"> |
| <Shield size={16} className="text-white/40"/> |
| <div> |
| <div className="text-sm font-medium">Safe for Work (SFW)</div> |
| <div className="text-xs text-white/40">Filter explicit content</div> |
| </div> |
| </div> |
| <input type="checkbox" checked={settingsContentRating === "sfw"} onChange={(e) => setSettingsContentRating(e.target.checked ? "sfw" : "mature")} className="w-5 h-5 accent-cyan-500"/> |
| </label> |
| </div> |
| |
| {settingsContentRating === "mature" && (<div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-xl text-sm"> |
| <span className="font-medium text-purple-400">Mature Mode Enabled</span> |
| <span className="text-white/50 ml-2">- This project may generate explicit content.</span> |
| </div>)} |
| </div> |
| |
| {/* Generation Parameters */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-semibold text-cyan-400 flex items-center gap-2"> |
| <Sliders size={14}/> |
| Generation Parameters |
| </h3> |
| <p className="text-xs text-white/40"> |
| Customize generation defaults for images & videos. These override model presets. |
| </p> |
| <CreatorStudioSettings value={genParams} onChange={setGenParams}/> |
| </div> |
| </div> |
| |
| {/* Modal Footer */} |
| <div className="flex items-center justify-end gap-3 p-5 border-t border-white/10 sticky bottom-0 bg-[#0f0f18]"> |
| <button onClick={() => setShowSettingsModal(false)} className="px-4 py-2 text-sm text-white/50 hover:text-white transition-colors"> |
| Cancel |
| </button> |
| <button onClick={saveProjectSettings} disabled={isSavingSettings || !settingsTitle.trim()} className="flex items-center gap-2 px-5 py-2.5 bg-cyan-500 hover:bg-cyan-400 disabled:opacity-50 rounded-xl text-sm font-medium transition-all"> |
| {isSavingSettings ? (<> |
| <Loader2 size={14} className="animate-spin"/> |
| Saving... |
| </>) : (<> |
| <Save size={14}/> |
| Save Settings |
| </>)} |
| </button> |
| </div> |
| </div> |
| </div>)} |
|
|
| {} |
| {tvModeActive && (<TVModeContainer onGenerateNext={generateNextForTVMode} onEnsureImage={ensureImageForTVMode} onSyncOutline={syncOutlineWithScenes}/>)} |
|
|
| {} |
| <style>{` |
| .scrollbar-hide::-webkit-scrollbar { display: none; } |
| .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; } |
| .line-clamp-2 { |
| display: -webkit-box; |
| -webkit-line-clamp: 2; |
| -webkit-box-orient: vertical; |
| overflow: hidden; |
| } |
| `}</style> |
| </div>); |
| } |
| export default CreatorStudioEditor; |
|
|