import React, { useState, useEffect } from "react"; import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip, Dialog, DialogContent } from "@mui/material"; import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, RadioButtonUnchecked as RadioButtonUncheckedIcon, VolumeUp as VolumeUpIcon, PlayArrow as PlayArrowIcon, Image as ImageIcon, Delete as DeleteIcon, Fullscreen as FullscreenIcon, Close as CloseIcon, Refresh as RefreshIcon, Info as InfoIcon, Mic as MicIcon, HelpOutline as HelpOutlineIcon, } from "@mui/icons-material"; import { Scene, Line, Knobs } from "../types"; import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; import { OperationButton } from "../../shared/OperationButton"; import { LineEditor } from "./LineEditor"; import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal"; import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal"; import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi"; import { aiApiClient } from "../../../api/client"; import { getCachedMedia, setCachedMedia } from "../../../utils/mediaCache"; interface SceneEditorProps { scene: Scene; onUpdateScene: (s: Scene) => void; onApprove: (id: string) => Promise; onDelete: (sceneId: string) => void; knobs: Knobs; approvingSceneId?: string | null; generatingAudioId?: string | null; onAudioGenerationStart?: (sceneId: string) => void; onAudioGenerated?: (sceneId: string, audioUrl: string) => void; idea?: string; // Podcast idea for image generation context avatarUrl?: string | null; // Base avatar URL for consistent scene image generation totalScenes?: number; // Total number of scenes in the script sceneIndex?: number; // Current scene index (0-based) for 1/N numbering analysis?: { audience?: string; contentType?: string; topKeywords?: string[]; } | null; } export const SceneEditor: React.FC = ({ scene, onUpdateScene, onApprove, onDelete, knobs, approvingSceneId, generatingAudioId, onAudioGenerationStart, onAudioGenerated, idea, avatarUrl, totalScenes, sceneIndex, analysis, }) => { const [localGenerating, setLocalGenerating] = useState(false); const [generatingImage, setGeneratingImage] = useState(false); const [imageGenerationStatus, setImageGenerationStatus] = useState(""); const [imageGenerationProgress, setImageGenerationProgress] = useState(0); const [audioBlobUrl, setAudioBlobUrl] = useState(null); const [imageBlobUrl, setImageBlobUrl] = useState(null); const [imageLoading, setImageLoading] = useState(false); const [showRegenerateModal, setShowRegenerateModal] = useState(false); const [showAudioModal, setShowAudioModal] = useState(false); const [showImagePreview, setShowImagePreview] = useState(false); const [showApprovalInfo, setShowApprovalInfo] = useState(false); const [showWhyScript, setShowWhyScript] = useState(false); const [audioSettings, setAudioSettings] = useState({ voiceId: knobs.voice_id || "Wise_Woman", customVoiceId: knobs.custom_voice_id || undefined, useVoiceClone: knobs.is_voice_clone || false, voiceSampleUrl: knobs.voice_sample_url || undefined, voiceCloneEngine: knobs.voice_clone_engine || undefined, speed: knobs.voice_speed ?? 1.0, volume: 1.0, pitch: 0.0, emotion: scene.emotion || knobs.voice_emotion || "neutral", englishNormalization: true, sampleRate: knobs.sample_rate || 24000, bitrate: knobs.bitrate === 'hd' ? 128000 : 64000, channel: "1", format: "mp3", languageBoost: "auto", }); // Load audio as blob when audioUrl is available useEffect(() => { if (!scene.audioUrl) { // Clean up blob URL if audioUrl is removed setAudioBlobUrl((currentBlobUrl) => { if (currentBlobUrl) { URL.revokeObjectURL(currentBlobUrl); } return null; }); return; } let isMounted = true; const currentAudioUrl = scene.audioUrl; // Capture current value const loadAudioBlob = async () => { try { // Normalize path let audioPath = currentAudioUrl.startsWith('/') ? currentAudioUrl : `/${currentAudioUrl}`; // Convert /api/story/audio/ to /api/podcast/audio/ if needed if (audioPath.includes('/api/story/audio/')) { const filename = audioPath.split('/api/story/audio/').pop() || ''; audioPath = `/api/podcast/audio/${filename}`; } // Ensure it's a podcast audio endpoint if (!audioPath.includes('/api/podcast/audio/')) { const filename = audioPath.split('/').pop() || currentAudioUrl; audioPath = `/api/podcast/audio/${filename}`; } // Remove query parameters if present audioPath = audioPath.split('?')[0]; const response = await aiApiClient.get(audioPath, { responseType: 'blob', }); if (!isMounted) { // Component unmounted or audioUrl changed, don't set blob URL return; } // Double-check that audioUrl hasn't changed if (scene.audioUrl !== currentAudioUrl) { return; } const blob = response.data; const blobUrl = URL.createObjectURL(blob); setAudioBlobUrl((prevBlobUrl) => { // Clean up previous blob URL if exists if (prevBlobUrl && prevBlobUrl !== blobUrl) { URL.revokeObjectURL(prevBlobUrl); } return blobUrl; }); } catch (error) { console.error(`Failed to load audio blob for scene ${scene.id}:`, error); // Don't set blob URL on error - will show error state } }; loadAudioBlob(); // Cleanup: only mark as unmounted, don't revoke blob URL here // The blob URL will be cleaned up when audioUrl changes (new effect) or component unmounts return () => { isMounted = false; }; }, [scene.audioUrl, scene.id]); // Load image as blob when imageUrl is available useEffect(() => { if (!scene.imageUrl) { // Clean up blob URL if imageUrl is removed setImageBlobUrl((currentBlobUrl) => { if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) { URL.revokeObjectURL(currentBlobUrl); } return null; }); return; } // Check cache first with scene context const cachedUrl = getCachedMedia(scene.imageUrl, scene.id); if (cachedUrl) { console.log('[SceneEditor] Using cached image:', scene.imageUrl, `(scene: ${scene.id})`); setImageBlobUrl(cachedUrl); setImageLoading(false); return; } let isMounted = true; const currentImageUrl = scene.imageUrl; // Capture current value const loadImageBlob = async () => { try { setImageLoading(true); // Check cache again in case it was loaded while we were waiting const cachedUrl = getCachedMedia(currentImageUrl, scene.id); if (cachedUrl) { if (isMounted) { setImageBlobUrl(cachedUrl); setImageLoading(false); } return; } console.log('[SceneEditor] Loading image blob for:', currentImageUrl); // Normalize path let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`; // Convert /api/story/images/ to /api/podcast/images/ if needed if (imagePath.includes('/api/story/images/')) { const filename = imagePath.split('/api/story/images/').pop() || ''; imagePath = `/api/podcast/images/${filename}`; } // Ensure it's a podcast image endpoint if (!imagePath.includes('/api/podcast/images/')) { const filename = imagePath.split('/').pop() || currentImageUrl; imagePath = `/api/podcast/images/${filename}`; } // Remove query parameters if present imagePath = imagePath.split('?')[0]; const response = await aiApiClient.get(imagePath, { responseType: 'blob', }); if (!isMounted) { return; } // Double-check that imageUrl hasn't changed if (scene.imageUrl !== currentImageUrl) { return; } const blob = response.data; const blobUrl = URL.createObjectURL(blob); // Cache the blob URL with scene context setCachedMedia(currentImageUrl, blobUrl, 'image', blob.size, scene.id); setImageBlobUrl((prevBlobUrl) => { // Clean up previous blob URL if exists if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) { URL.revokeObjectURL(prevBlobUrl); } return blobUrl; }); console.log('[SceneEditor] Image blob loaded and cached successfully:', currentImageUrl); } catch (error) { console.error('[SceneEditor] Failed to load image blob:', error); if (isMounted) { // Try adding query token as fallback try { const token = localStorage.getItem('clerk_dashboard_token') || ''; if (token) { const urlWithToken = `${currentImageUrl}?token=${encodeURIComponent(token)}`; setImageBlobUrl(urlWithToken); setCachedMedia(currentImageUrl, urlWithToken, 'image', undefined, scene.id); } } catch (fallbackError) { console.error('[SceneEditor] Fallback image loading failed:', fallbackError); } } } finally { if (isMounted) { setImageLoading(false); } } }; loadImageBlob(); return () => { isMounted = false; // Don't cleanup blob URL here - let the cache handle it }; }, [scene.imageUrl]); const updateLine = (updatedLine: Line) => { const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) }; onUpdateScene(updated); }; const approving = approvingSceneId === scene.id; const generating = generatingAudioId === scene.id || localGenerating; const hasAudio = Boolean(scene.audioUrl && audioBlobUrl); const hasImage = Boolean(scene.imageUrl); // Completion status for visual feedback const isComplete = hasAudio && hasImage; const completionPercent = (hasAudio ? 50 : 0) + (hasImage ? 50 : 0); // Scene order for 1/N badge display const sceneOrder = sceneIndex != null ? sceneIndex + 1 : null; const totalScenesInline = totalScenes ?? null; const showOrderBadge = sceneOrder != null && totalScenesInline != null; const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => { const wasAlreadyApproved = scene.approved; const sceneId = scene.id; try { // Set generating state setLocalGenerating(true); if (onAudioGenerationStart) { onAudioGenerationStart(sceneId); } // If scene is not approved yet, approve it first // This will update the parent script state if (!scene.approved) { await onApprove(sceneId); // The parent's approveScene already updated the script state // We need to wait for React to propagate the updated scene prop // For now, we'll update it locally too to ensure UI updates immediately onUpdateScene({ ...scene, approved: true }); } // Use the current scene (which should now be approved) // If scene prop hasn't updated yet, use the local update we just made const currentScene = { ...scene, approved: true }; // Generate audio const effectiveSettings = settings || audioSettings; const cachedClone = getCachedVoiceCloneInfo(); const result = await podcastApi.renderSceneAudio({ scene: currentScene, voiceId: effectiveSettings.voiceId || knobs.voice_id || "Wise_Woman", customVoiceId: effectiveSettings.customVoiceId || knobs.custom_voice_id || cachedClone?.customVoiceId, useVoiceClone: effectiveSettings.useVoiceClone || knobs.is_voice_clone || cachedClone?.isVoiceClone || false, voiceSampleUrl: effectiveSettings.voiceSampleUrl || knobs.voice_sample_url || cachedClone?.voiceSampleUrl || undefined, voiceCloneEngine: effectiveSettings.voiceCloneEngine || knobs.voice_clone_engine || cachedClone?.engine || undefined, emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral", speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0, volume: effectiveSettings.volume ?? 1.0, pitch: effectiveSettings.pitch ?? 0.0, englishNormalization: effectiveSettings.englishNormalization ?? true, sampleRate: effectiveSettings.sampleRate, bitrate: effectiveSettings.bitrate, channel: effectiveSettings.channel, format: effectiveSettings.format, languageBoost: effectiveSettings.languageBoost, }); // Update scene with audio URL and ensure approved state // This will sync with parent script state const updatedScene = { ...currentScene, audioUrl: result.audioUrl, approved: true }; onUpdateScene(updatedScene); if (onAudioGenerated) { onAudioGenerated(sceneId, result.audioUrl); } } catch (error) { console.error("Failed to approve and generate audio:", error); // Provide user-friendly error message based on error type let userMessage = "Failed to generate audio. Please try again."; if (error instanceof Error) { const errorMsg = error.message.toLowerCase(); if (errorMsg.includes("429") || errorMsg.includes("quota") || errorMsg.includes("limit")) { userMessage = "Audio generation limit reached. Please check your subscription and try again."; } else if (errorMsg.includes("voice") || errorMsg.includes("custom_voice")) { userMessage = "Invalid voice. Please select a different voice and try again."; } else if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) { userMessage = "Audio generation timed out. Please try again."; } else if (errorMsg.includes("network") || errorMsg.includes("connection")) { userMessage = "Network error. Please check your connection and try again."; } } // On error, revert approval only if we just approved it in this call if (!wasAlreadyApproved) { onUpdateScene({ ...scene, approved: false, audioUrl: undefined }); } throw error; } finally { setLocalGenerating(false); } }; const handleGenerateImage = async (settings?: ImageGenerationSettings) => { const sceneId = scene.id; const startTime = Date.now(); let progressInterval: NodeJS.Timeout | null = null; try { setGeneratingImage(true); setShowRegenerateModal(false); setImageGenerationStatus("Submitting image generation request..."); setImageGenerationProgress(10); // Build scene content from lines for context const sceneContent = scene.lines.map((line) => line.text).join(" "); // Log avatar URL for debugging console.log("[SceneEditor] Generating image with avatarUrl:", avatarUrl); console.log("[SceneEditor] Custom settings:", settings); // Simulate progress updates during API call progressInterval = setInterval(() => { const elapsed = Date.now() - startTime; const seconds = Math.floor(elapsed / 1000); // Update status based on elapsed time if (seconds < 5) { setImageGenerationStatus("Submitting request to AI service..."); setImageGenerationProgress(15); } else if (seconds < 15) { setImageGenerationStatus("AI is generating your image..."); setImageGenerationProgress(30); } else if (seconds < 30) { setImageGenerationStatus("Creating character-consistent scene image..."); setImageGenerationProgress(50); } else if (seconds < 60) { setImageGenerationStatus("Rendering image details..."); setImageGenerationProgress(70); } else { setImageGenerationStatus(`Processing... (${seconds}s elapsed)`); setImageGenerationProgress(Math.min(90, 50 + (seconds - 30) / 2)); } }, 1000); const result = await podcastApi.generateSceneImage({ sceneId: scene.id, sceneTitle: scene.title, sceneContent: sceneContent, sceneEmotion: scene.emotion, baseAvatarUrl: avatarUrl || undefined, idea: idea, analysis: analysis || undefined, width: 1024, height: 1024, customPrompt: settings?.prompt, style: settings?.style, renderingSpeed: settings?.renderingSpeed, aspectRatio: settings?.aspectRatio, }); if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } setImageGenerationStatus("Finalizing image..."); setImageGenerationProgress(95); // Update scene with image URL and the prompt used const updatedScene = { ...scene, imageUrl: result.image_url, imagePrompt: result.image_prompt || undefined, }; onUpdateScene(updatedScene); const elapsed = Math.floor((Date.now() - startTime) / 1000); setImageGenerationStatus(`Image generated successfully in ${elapsed}s`); setImageGenerationProgress(100); // Clear status after a moment setTimeout(() => { setImageGenerationStatus(""); setImageGenerationProgress(0); }, 2000); } catch (error: any) { // Clear interval on error if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } console.error("Failed to generate image:", error); // Extract error message from response if available const errorMessage = error?.response?.data?.detail?.message || error?.response?.data?.detail?.error || error?.response?.data?.detail || error?.message || "Failed to generate image. Please try again."; console.error("Error details:", { status: error?.response?.status, statusText: error?.response?.statusText, data: error?.response?.data, message: errorMessage, }); setImageGenerationStatus(`Error: ${errorMessage}`); setImageGenerationProgress(0); // Show user-friendly error message alert(`Image generation failed: ${errorMessage}`); throw error; } finally { // Ensure interval is cleared if (progressInterval) { clearInterval(progressInterval); } setGeneratingImage(false); } }; const handleRegenerateClick = () => { setShowRegenerateModal(true); }; const handleAudioRegenerateClick = () => { if (hasAudio) { setShowAudioModal(true); } else { handleApproveAndGenerate(audioSettings); } }; const handleAudioRegenerate = (settings: AudioGenerationSettings) => { setAudioSettings(settings); setShowAudioModal(false); handleApproveAndGenerate(settings); }; return ( 0 ? "2px solid #f59e0b" : "1px solid rgba(15, 23, 42, 0.08)", background: isComplete ? "linear-gradient(135deg, rgba(16, 185, 129, 0.03) 0%, rgba(255, 255, 255, 0.9) 100%)" : completionPercent > 0 ? "linear-gradient(135deg, rgba(245, 158, 11, 0.03) 0%, rgba(255, 255, 255, 0.9) 100%)" : glassyCardSx.background, boxShadow: isComplete ? "0 4px 20px rgba(16, 185, 129, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)" : completionPercent > 0 ? "0 4px 20px rgba(245, 158, 11, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)" : glassyCardSx.boxShadow, }} > {/* Completion Progress Bar */} 0 ? "#d97706" : "#667eea", fontSize: { xs: "1.25rem", sm: "1.5rem" } }} /> {scene.title} {showOrderBadge && ( )} {/* Completion Status Chip */} : completionPercent > 0 ? : } label={isComplete ? "Complete" : completionPercent > 0 ? `In Progress ${completionPercent}%` : "Pending"} size="small" sx={{ background: isComplete ? "linear-gradient(135deg, #10b981 0%, #059669 100%)" : completionPercent > 0 ? "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)" : "linear-gradient(135deg, #64748b 0%, #475569 100%)", color: "#ffffff", border: "none", fontWeight: 700, fontSize: "0.75rem", height: 26, borderRadius: "12px", px: 1, boxShadow: isComplete ? "0 3px 12px rgba(16, 185, 129, 0.4)" : completionPercent > 0 ? "0 3px 12px rgba(245, 158, 11, 0.4)" : "0 2px 6px rgba(100, 116, 139, 0.3)", '& .MuiChip-icon': { fontSize: '1rem', color: '#ffffff', }, '& .MuiChip-label': { pl: 0.5, }, }} /> {/* Audio Status */} : } label={hasAudio ? "Audio Ready" : "No Audio"} size="small" sx={{ background: hasAudio ? "linear-gradient(135deg, #10b981 0%, #059669 100%)" : "linear-gradient(135deg, #94a3b8 0%, #64748b 100%)", color: "#ffffff", border: "none", fontWeight: 600, fontSize: "0.7rem", height: 22, borderRadius: "10px", px: 0.75, boxShadow: hasAudio ? "0 2px 8px rgba(16, 185, 129, 0.35)" : "0 1px 4px rgba(100, 116, 139, 0.25)", '& .MuiChip-icon': { color: '#ffffff', }, '& .MuiChip-label': { pl: 0.5, }, }} /> {/* Image Status */} : } label={hasImage ? "Image Ready" : "No Image"} size="small" sx={{ background: hasImage ? "linear-gradient(135deg, #10b981 0%, #059669 100%)" : "linear-gradient(135deg, #94a3b8 0%, #64748b 100%)", color: "#ffffff", border: "none", fontWeight: 600, fontSize: "0.7rem", height: 22, borderRadius: "10px", px: 0.75, boxShadow: hasImage ? "0 2px 8px rgba(16, 185, 129, 0.35)" : "0 1px 4px rgba(100, 116, 139, 0.25)", '& .MuiChip-icon': { color: '#ffffff', }, '& .MuiChip-label': { pl: 0.5, }, }} /> Duration: {scene.duration}s {/* Approval Info Panel - Inline chips for guidance */} {/* Active Voice indicator */} } label={`Voice: ${knobs.voice_id === "Wise_Woman" ? "Wise Woman" : knobs.voice_id?.replace(/_/g, " ") || "Default"}`} size="small" sx={{ background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", color: "#ffffff", border: "none", fontWeight: 600, fontSize: "0.7rem", height: 22, borderRadius: "10px", px: 0.75, boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)", '& .MuiChip-icon': { color: '#ffffff' }, '& .MuiChip-label': { pl: 0.5, }, }} /> {/* Why Script chip - opens modal with guidance */} } label="Why Script?" size="small" onClick={() => setShowWhyScript(true)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowWhyScript(true); } }} tabIndex={0} role="button" aria-label="Learn why scene approval is required" sx={{ background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)", color: "#ffffff", border: "none", fontWeight: 600, fontSize: "0.7rem", height: 22, borderRadius: "10px", px: 0.75, cursor: "pointer", boxShadow: "0 2px 8px rgba(245, 158, 11, 0.35)", '& .MuiChip-icon': { color: '#ffffff' }, '& .MuiChip-label': { pl: 0.5, }, '&:hover': { background: "linear-gradient(135deg, #d97706 0%, #b45309 100%)", boxShadow: "0 3px 10px rgba(245, 158, 11, 0.45)", }, '&:focus': { outline: '2px solid rgba(245, 158, 11, 0.6)', outlineOffset: '2px', }, }} /> *': { width: { xs: '100%', sm: 'auto' } } }} > sum + l.text.length, 0), operation_type: "tts_full_render", actual_provider_name: "wavespeed", }} label={ hasAudio && !generating ? "✓ Regenerate Audio" : generating ? "Generating Audio..." : scene.approved ? "Generate Audio" : "Approve & Generate Audio" } variant="contained" size="medium" startIcon={ hasAudio && !generating ? ( ) : generating ? ( ) : ( ) } showCost={true} checkOnHover={true} checkOnMount={false} onClick={handleAudioRegenerateClick} disabled={approving || generating} loading={approving || generating} sx={{ minWidth: { xs: '100%', sm: 200 }, background: hasAudio ? "linear-gradient(135deg, #10b981 0%, #059669 100%)" : "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", color: "white", fontWeight: 600, textTransform: "none", fontSize: { xs: '0.8rem', sm: '0.875rem' }, py: { xs: 0.75, sm: 1 }, boxShadow: hasAudio ? "0 4px 14px rgba(16, 185, 129, 0.35)" : "0 4px 14px rgba(102, 126, 234, 0.35)", "&:hover": { background: hasAudio ? "linear-gradient(135deg, #059669 0%, #047857 100%)" : "linear-gradient(135deg, #764ba2 0%, #667eea 100%)", boxShadow: hasAudio ? "0 6px 20px rgba(16, 185, 129, 0.45)" : "0 6px 20px rgba(102, 126, 234, 0.45)", }, "&:disabled": { background: alpha("#9ca3af", 0.3), color: alpha("#fff", 0.5), }, }} /> ) : generatingImage ? ( ) : ( ) } showCost={true} checkOnHover={true} checkOnMount={false} onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()} disabled={generatingImage} loading={generatingImage} sx={{ minWidth: { xs: '100%', sm: 180 }, background: hasImage ? "linear-gradient(135deg, #10b981 0%, #059669 100%)" : "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", color: "white", fontWeight: 600, textTransform: "none", fontSize: { xs: '0.8rem', sm: '0.875rem' }, py: { xs: 0.75, sm: 1 }, boxShadow: hasImage ? "0 4px 14px rgba(16, 185, 129, 0.35)" : "0 4px 14px rgba(102, 126, 234, 0.35)", "&:hover": { background: hasImage ? "linear-gradient(135deg, #059669 0%, #047857 100%)" : "linear-gradient(135deg, #764ba2 0%, #667eea 100%)", boxShadow: hasImage ? "0 6px 20px rgba(16, 185, 129, 0.45)" : "0 6px 20px rgba(102, 126, 234, 0.45)", }, "&:disabled": { background: alpha("#9ca3af", 0.3), color: alpha("#fff", 0.5), }, }} /> onDelete(scene.id)} disabled={approving || generating || (totalScenes !== undefined && totalScenes <= 1)} sx={{ color: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)", borderRadius: 2, padding: { xs: 1, sm: 1.5 }, alignSelf: { xs: 'center', sm: 'auto' }, "&:hover": { backgroundColor: "rgba(239, 68, 68, 0.15)", borderColor: "rgba(239, 68, 68, 0.3)", }, "&:disabled": { backgroundColor: "rgba(156, 163, 175, 0.1)", borderColor: "rgba(156, 163, 175, 0.2)", color: "#9ca3af", }, }} > {scene.lines.map((line) => ( ))} {scene.audioUrl && ( <> {hasAudio ? "Audio Generated" : "Loading Audio..."} {hasAudio && audioBlobUrl ? ( ) : ( )} )} {/* Image Generation Progress - Show when generating */} {generatingImage && ( <> Generating Image... {/* Progress Bar */} {imageGenerationProgress}% {/* Status Message */} {imageGenerationStatus && ( {imageGenerationStatus} )} {/* Spinner */} )} {/* Generated Image Display - Show when image exists and not generating */} {scene.imageUrl && !generatingImage && ( <> {imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."} {imageBlobUrl && !imageLoading && ( setShowImagePreview(true)} sx={{ color: "#667eea", "&:hover": { background: "rgba(102, 126, 234, 0.1)" }, }} > )} {imageBlobUrl && !imageLoading ? ( { console.error('[SceneEditor] Image failed to load:', { src: e.currentTarget.src, imageUrl: scene.imageUrl, imageBlobUrl, }); }} onLoad={() => { console.log('[SceneEditor] Image loaded successfully'); }} /> ) : ( )} )} {/* Image Regeneration Modal */} setShowRegenerateModal(false)} onRegenerate={handleGenerateImage} initialPrompt={(() => { const promptParts = [ `Scene: ${scene.title}`, "Professional podcast recording studio", "Modern microphone setup", "Clean background, professional lighting", "16:9 aspect ratio, video-optimized composition" ]; if (idea) { promptParts.push(`Topic: ${idea.substring(0, 60)}`); } return promptParts.join(", "); })()} initialStyle="Realistic" initialRenderingSpeed="Quality" initialAspectRatio="16:9" isGenerating={generatingImage} /> setShowAudioModal(false)} onRegenerate={handleAudioRegenerate} initialSettings={audioSettings} isGenerating={generating} /> {/* Full-size Image Preview Modal */} setShowImagePreview(false)} maxWidth="lg" PaperProps={{ sx: { background: "rgba(0, 0, 0, 0.9)", borderRadius: 3, maxHeight: "90vh", } }} > setShowImagePreview(false)} sx={{ position: "absolute", top: 8, right: 8, color: "#fff", background: "rgba(0, 0, 0, 0.5)", zIndex: 1, "&:hover": { background: "rgba(0, 0, 0, 0.7)" }, }} > {/* Why Script Modal - Guidance for scene approval */} setShowWhyScript(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { borderRadius: 3, p: 2, } }} > Why Approve This Scene? Each scene requires approval before audio can be generated. Here's why the approval process matters: Script Accuracy: The AI generates audio based on the script text. Once approved, the text is locked to ensure consistency. Cost Control: Audio generation uses your subscription credits. Approving ensures you only pay for scenes you intend to render. Quality Check: Review your script for tone, pacing, and accuracy before spending credits on audio generation. Pro tip: You can always regenerate audio later with different voice settings after approval. setShowWhyScript(false)}> Got it ); };