HomePilot / frontend /src /ui /CreatorStudioEditor.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
130 kB
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";
/**
* CreatorStudioEditor - Professional editor for Creator Studio projects
* Styled like Play Story mode but enhanced for creators
*/
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);
// TV Mode store
const tvModeActive = useTVModeStore((s) => s.isActive);
const enterTVMode = useTVModeStore((s) => s.enterTVMode);
const updateSceneImageByIdx = useTVModeStore((s) => s.updateSceneImageByIdx);
// State
const [project, setProject] = useState(null);
const [scenes, setScenes] = useState([]);
const [loading, setLoading] = useState(true);
// Story outline state
const [storyOutline, setStoryOutline] = useState(null);
const [isGeneratingOutline, setIsGeneratingOutline] = useState(false);
const [showOutlinePanel, setShowOutlinePanel] = useState(false);
const [showExportWizard, setShowExportWizard] = useState(false);
// Scene editor state
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);
// Model selection state
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);
// Batch generation state (generates all scenes from outline)
const [isBatchGenerating, setIsBatchGenerating] = useState(false);
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, phase: 'scene' });
// Project Settings Modal state
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); // Enable video generation capability
const [isSavingSettings, setIsSavingSettings] = useState(false);
// Model override state for Project Settings
const [settingsImageModel, setSettingsImageModel] = useState("");
const [settingsVideoModel, setSettingsVideoModel] = useState("");
const [availableVideoModels, setAvailableVideoModels] = useState([]);
const [loadingModels, setLoadingModels] = useState(false);
// Generation parameters state (advanced customization)
const [genParams, setGenParams] = useState(CREATOR_STUDIO_PARAM_DEFAULTS);
// Project-level capability: can this project generate videos?
// IMPORTANT: do NOT rely only on enableVideoGeneration (wizard-only, transient)
// This derives video capability from project metadata/tags for existing projects
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 // backward compatibility with wizard prop
);
// API helpers
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]);
// ---------- API <-> UI normalization helpers ----------
// Backend may return snake_case (image_url/video_url). UI uses camelCase (imageUrl/videoUrl).
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]);
// Convert UI patch (camelCase) to backend-friendly payload (include snake_case too)
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;
}, []);
// Authoritative refresh from backend (source of truth)
const refreshScenes = useCallback(async () => {
try {
const scenesRes = await fetchApi(`/studio/videos/${projectId}/scenes`);
setScenes(normalizeScenes(scenesRes.scenes));
}
catch (e) {
// Non-critical - log but don't alert
console.warn('[CreatorStudioEditor] Failed to refresh scenes:', e);
}
}, [fetchApi, projectId, normalizeScenes]);
// Proxy video URL through backend for correct Content-Type headers
// This ensures WebM videos play correctly in browsers
const proxyVideoUrl = useCallback((rawUrl) => {
if (!rawUrl)
return null;
// Only proxy ComfyUI localhost URLs
if (rawUrl.startsWith('http://localhost:8188/') || rawUrl.startsWith('http://127.0.0.1:8188/')) {
return `${backendUrl.replace(/\/+$/, '')}/studio/media?url=${encodeURIComponent(rawUrl)}`;
}
// Already proxied or external URL - return as-is
return rawUrl;
}, [backendUrl]);
// Detect when "videoUrl" is actually an animated image (e.g. .webp, .gif)
// Some Comfy workflows output animated WebP instead of WebM when ffmpeg is unavailable
const isAnimatedImageUrl = useCallback((u) => {
if (!u)
return false;
const s = u.toLowerCase();
// Covers direct URLs and ComfyUI view?filename=... patterns
return s.includes(".webp") || s.includes(".gif");
}, []);
// Detect if URL is a WebM video
const isWebmUrl = useCallback((u) => {
if (!u)
return false;
return u.toLowerCase().includes(".webm");
}, []);
// Sync outline with current scenes (keeps outline in sync with actual scene data)
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);
// Non-critical - don't alert user
}
}, [projectId, postApi]);
// Generate AI-powered story outline
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]);
// Load existing story outline
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]);
// Open scene editor
const openSceneEditor = useCallback((scene) => {
setEditingScene(scene);
setEditNarration(scene.narration || "");
setEditImagePrompt(scene.imagePrompt || "");
setEditNegativePrompt(scene.negativePrompt || "");
setShowSceneEditor(true);
}, []);
// Save scene edits
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]);
// Parse tags from project
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 };
}, []);
// Parse generation params from project tags
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;
}, []);
// Open settings modal
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);
// Set the LLM model from current selection or project tags
const tags = project.tags || [];
const llmTag = tags.find((t) => t.startsWith("llm:"));
setSettingsLLMModel(llmTag ? llmTag.replace("llm:", "") : selectedLLMModel);
// Initialize video generation enabled state from tags or enableVideoGeneration prop
const hasVideoMode = tags.includes("mode:video") ||
tags.includes("projectType:video") ||
tags.includes("projectType:video_series") ||
project?.metadata?.generationMode === "video" ||
enableVideoGeneration === true;
setSettingsEnableVideo(hasVideoMode);
// Initialize generation params from project tags
const parsedGenParams = parseGenParamsFromTags(tags);
// Also restore negative prompt from localStorage
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);
// Initialize image/video model overrides from project tags or props
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 || "");
// Fetch available models to ensure the dropdowns are populated
setLoadingModels(true);
try {
// Fetch LLM, Image, and Video models in parallel
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]);
// Toggle tone in settings
const toggleSettingsTone = useCallback((tone) => {
setSettingsTones((prev) => {
if (prev.includes(tone)) {
return prev.filter((t) => t !== tone);
}
return [...prev, tone];
});
}, []);
// Build tags from settings
const buildTagsFromSettings = useCallback(() => {
const tags = [];
// Video generation mode - critical for "Make Video" button visibility
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}`);
// Model overrides - allows per-project model selection
if (settingsImageModel)
tags.push(`imageModel:${settingsImageModel}`);
if (settingsVideoModel)
tags.push(`videoModel:${settingsVideoModel}`);
return tags;
}, [settingsEnableVideo, settingsGoal, settingsVisualStyle, settingsTones, settingsLockIdentity, settingsSceneCount, settingsSceneDuration, settingsLLMModel, settingsImageModel, settingsVideoModel]);
// Build tags including generation params
const buildGenTags = useCallback((p) => {
// Start with base settings tags, filter out any existing gen:* tags
const tags = buildTagsFromSettings().filter(t => !t.startsWith("gen:"));
// Add generation params
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]);
// Save project settings
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,
});
// Persist negative prompt locally (can be long, so not in tags)
try {
const key = `creatorstudio:genparams:${projectId}:neg`;
localStorage.setItem(key, JSON.stringify({
use: genParams.useCustomNegativePrompt,
text: genParams.customNegativePrompt,
}));
}
catch { }
// Update local project state
setProject((prev) => prev ? {
...prev,
title: settingsTitle.trim(),
logline: settingsLogline.trim(),
platformPreset: settingsPlatform,
contentRating: settingsContentRating,
tags,
} : null);
// Update selected models to match settings
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]);
// Delete scene
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]);
// Fetch available models
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);
// Auto-select a model if none selected
if (!selectedLLMModel && models.length > 0) {
// Prefer llama3:8b if available, otherwise first model
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]);
// Convert Creator Studio scene to TV Mode scene format
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",
};
}, []);
// Enter TV Mode with current scenes
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]);
// Editor playback with TTS - speak scene narration when playing
useEffect(() => {
if (!isPlaying || scenes.length === 0) {
// Stop any ongoing speech when not playing
if (window.SpeechService?.stopSpeaking) {
window.SpeechService.stopSpeaking();
}
setIsTTSSpeaking(false);
return;
}
const currentScene = scenes[currentSceneIndex];
if (!currentScene?.narration) {
// No narration, advance to next scene after a short delay
const timer = setTimeout(() => {
if (currentSceneIndex < scenes.length - 1) {
setCurrentSceneIndex((i) => i + 1);
}
else {
setIsPlaying(false); // End of scenes
}
}, 2000);
return () => clearTimeout(timer);
}
// Speak the current scene's narration
const svc = window.SpeechService;
if (!svc?.speak) {
// Fallback if no TTS - use fixed timer
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);
// Auto-advance to next scene after narration finishes
if (currentSceneIndex < scenes.length - 1) {
setCurrentSceneIndex((i) => i + 1);
}
else {
setIsPlaying(false); // End of scenes
}
},
onError: () => {
setIsTTSSpeaking(false);
// On error, advance anyway after delay
setTimeout(() => {
if (currentSceneIndex < scenes.length - 1) {
setCurrentSceneIndex((i) => i + 1);
}
else {
setIsPlaying(false);
}
}, 2000);
},
});
return () => {
svc.stopSpeaking?.();
};
}, [isPlaying, currentSceneIndex, scenes]);
// Generate image for a scene
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;
// Apply generation parameters if enabled
const effectiveSteps = genParams.enabled ? genParams.steps : imageSteps;
const effectiveCfg = genParams.enabled ? genParams.cfgScale : imageCfg;
// Get scene-level negative prompt if available
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;
// Send explicit width/height from wizard selection to backend
// This ensures the resolution selected in Step 2 is actually used
const data = await postApi('/chat', {
message: `imagine ${imagePrompt}`,
mode: 'imagine',
provider: llmProvider,
imgModel: selectedImageModel || imageModel || undefined,
// Pass explicit resolution from wizard (if available)
// Backend will use these values directly instead of computing from aspect ratio
...(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]);
// Generate video for a scene (converts image to video)
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 {
// IMPORTANT:
// Backend animate mode detects the reference image via URL in the message (in this project version).
// Backend also expects vidModel (not videoModel).
const data = await postApi('/chat', {
message: `${prompt || 'Animate this scene with subtle motion'} ${imageUrl}`,
mode: 'animate',
provider: 'ollama',
vidModel: selectedVideoModel || videoModel || undefined,
// Apply generation parameters if enabled
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,
});
// Backend returns media.video_url (NOT always media.videos[]).
const rawVideoUrl = data?.media?.video_url ||
data?.media?.videos?.[0] ||
null;
if (rawVideoUrl) {
// Proxy the URL for correct Content-Type headers (WebM playback)
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]);
// Remove video and fall back to image-only for a scene
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]);
// Generate scene from outline
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]);
// Generate ALL scenes from outline in sequence (batch generation)
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 {
// Generate all scenes first (without images for speed)
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);
// Continue with remaining scenes even if one fails
}
// Small delay between scene creations to avoid overwhelming the server
if (i < totalScenes - 1) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
console.log(`[CreatorStudioEditor] Created ${generatedScenes.length} scenes, now generating images...`);
// Phase 2: Generate images for all scenes
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 {
// Generate image inline (can't use generateImageForScene due to isGeneratingImage lock)
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);
// Continue with remaining images
}
}
// Phase 3: Generate videos from images (if enabled)
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 {
// Use the animate endpoint to generate video from image
// IMPORTANT: Include imageUrl in message and use vidModel (not videoModel)
const data = await postApi('/chat', {
message: `${scene.imagePrompt} ${imageUrl}`,
mode: 'animate',
provider: 'ollama',
vidModel: selectedVideoModel || videoModel || undefined,
});
// Backend returns media.video_url (NOT always media.videos[])
const rawVideoUrl = data?.media?.video_url ||
data?.media?.videos?.[0] ||
null;
if (rawVideoUrl) {
// Proxy the URL for correct Content-Type headers (WebM playback)
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 {
// No video generated, mark scene as ready anyway (image only)
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);
// Mark scene as ready anyway (fallback to image only)
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); // Go back to first scene
console.log('[CreatorStudioEditor] Batch generation complete!');
// Sync outline with actual scene data to keep them in sync
await syncOutlineWithScenes();
// Final refresh so UI shows all persisted videoUrl values
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]);
// Load project and scenes
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));
// Extract LLM model from project tags if not already set
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]);
// Load available models and existing outline on mount
useEffect(() => {
fetchAvailableModels();
loadStoryOutline();
}, [fetchAvailableModels, loadStoryOutline]);
// Detect whether the browser can play WebM videos
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);
}
}, []);
// Auto-generate outline when project is newly created
useEffect(() => {
if (autoGenerateFirst &&
!hasAutoGeneratedOutline &&
!loading &&
project &&
!storyOutline &&
!isGeneratingOutline &&
selectedLLMModel // Wait until we have a model selected
) {
console.log('[CreatorStudioEditor] Auto-generating story outline for new project');
setHasAutoGeneratedOutline(true);
generateStoryOutline();
}
}, [autoGenerateFirst, hasAutoGeneratedOutline, loading, project, storyOutline, isGeneratingOutline, selectedLLMModel, generateStoryOutline]);
// Current scene
const currentScene = scenes[currentSceneIndex] || null;
// Extract visual style from project tags
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]);
// Extract tone from project tags
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]);
// Generate AI-powered scene with better prompts
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]);
// Auto-generate ALL scenes after outline is generated (batch generation)
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]);
// Generate first scene (non-AI fallback)
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]);
// Generate next scene with AI-powered prompts (uses backend outline for reliability)
const generateNextScene = useCallback(async () => {
if (!project || isGeneratingScene)
return;
setIsGeneratingScene(true);
try {
const nextSceneIndex = scenes.length;
// First, try to generate from outline via backend (reads outline from database - most reliable)
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);
// Sync outline with new scene
syncOutlineWithScenes();
return;
}
}
catch (outlineErr) {
// Outline not available or scene index out of range - try AI continuation
console.log('[CreatorStudioEditor] No outline scene available, trying AI continuation:', outlineErr.message);
}
// Second: Try AI-powered continuation based on previous scenes
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);
// Sync outline with new scene
syncOutlineWithScenes();
return;
}
}
catch (contErr) {
console.log('[CreatorStudioEditor] AI continuation failed, using fallback:', contErr.message);
}
}
// Final fallback: Generate a scene based on project settings (no outline, no AI)
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);
// Sync outline with new scene
syncOutlineWithScenes();
}
catch (e) {
alert(`Failed to create scene: ${e.message}`);
}
finally {
setIsGeneratingScene(false);
}
}, [project, projectId, scenes.length, isGeneratingScene, postApi, getVisualStyle, getTones, generateImageForScene, syncOutlineWithScenes]);
// Generate next scene for TV Mode (uses backend outline for reliability)
const generateNextForTVMode = useCallback(async () => {
if (!project || isGeneratingScene)
return null;
try {
const nextSceneIndex = scenes.length;
// First, try to generate from outline via backend
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');
}
// Second: Try AI-powered continuation based on previous scenes
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);
}
}
// Final fallback: Generate scene based on project settings
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]);
// Ensure image for TV Mode scene
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]);
// Status badge color
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 };
}
};
// Scene status indicator
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>);
}
};
// Loading state
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>);
}
// Error state
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>
{/* Export Wizard — mounts only while open. The wizard self-cleans
its in-flight polling when closed (epoch guard). */}
{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}/>)}
{/* Scene Chips Rail - Like Play Story */}
{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>)}
{/* Main Content */}
{scenes.length === 0 ? (
// Empty State - Cinematic
<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>) : (
// Preview + Actions - Cinematic Layout
<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>)}
{/* Scene Editor Modal */}
{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>)}
{/* Story Outline Panel */}
{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>
{/* Panel Content */}
<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>)}
{/* Project Settings Modal */}
{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>)}
{/* TV Mode Overlay */}
{tvModeActive && (<TVModeContainer onGenerateNext={generateNextForTVMode} onEnsureImage={ensureImageForTVMode} onSyncOutline={syncOutlineWithScenes}/>)}
{/* Custom scrollbar hide */}
<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;