From e90a29c27eb62ee3a6e42b0498b9da2b526ce1c7 Mon Sep 17 00:00:00 2001 From: ajaysi Date: Thu, 12 Mar 2026 15:21:08 +0530 Subject: [PATCH] Merge_PR_410_with_local_changes --- .gitignore | 3 + backend/api/subscription/routes/logs.py | 87 +++- .../services/subscription/limit_validation.py | 32 +- .../components/PodcastMaker/CameraSelfie.tsx | 399 +------------- .../components/PodcastMaker/RenderQueue.tsx | 3 + .../RenderQueue/SceneActionButtons.tsx | 105 +++- .../PodcastMaker/RenderQueue/SceneCard.tsx | 492 +++++++++++++++--- .../RenderQueue/useRenderQueue.ts | 162 ++++-- .../PodcastMaker/ScriptEditor/SceneEditor.tsx | 86 ++- .../ScriptEditor/ScriptEditor.tsx | 32 ++ 10 files changed, 841 insertions(+), 560 deletions(-) diff --git a/.gitignore b/.gitignore index 6cd26844..4034d318 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ __pycache__/ workspace/ workspace/* +.opencode + + .trae/ /backend/database/migrations/* /backend/.db diff --git a/backend/api/subscription/routes/logs.py b/backend/api/subscription/routes/logs.py index 0a56c335..925e429d 100644 --- a/backend/api/subscription/routes/logs.py +++ b/backend/api/subscription/routes/logs.py @@ -64,16 +64,44 @@ async def get_usage_logs( provider_enum = APIProvider.MISTRAL else: try: + # Refresh enum to ensure latest values + from models.subscription_models import APIProvider + _ = list(APIProvider) # Force enum refresh provider_enum = APIProvider(provider_lower) except ValueError: - # Invalid provider, return empty results - return { - "logs": [], - "total_count": 0, - "limit": limit, - "offset": offset, - "has_more": False - } + # Fallback: try to find matching provider or use default + try: + # Check if it's a known provider that might not be in enum + known_providers = ['gemini', 'openai', 'anthropic', 'mistral', 'wavespeed', 'tavily', 'serper', 'metaphor', 'firecrawl', 'stability', 'exa', 'video', 'image_edit', 'audio'] + if provider_lower in known_providers: + # Map to existing enum values or skip + provider_mapping = { + 'mistral': 'MISTRAL', + 'wavespeed': 'WAVESPEED', + 'video': 'VIDEO', + 'image_edit': 'IMAGE_EDIT', + 'audio': 'AUDIO' + } + mapped_provider = provider_mapping.get(provider_lower, provider_lower.upper()) + provider_enum = APIProvider(mapped_provider) + else: + # Invalid provider, return empty results + return { + "logs": [], + "total_count": 0, + "limit": limit, + "offset": offset, + "has_more": False + } + except (ValueError, AttributeError): + # If all else fails, return empty results + return { + "logs": [], + "total_count": 0, + "limit": limit, + "offset": offset, + "has_more": False + } query = query.filter(APIUsageLog.provider == provider_enum) if status_code is not None: @@ -98,15 +126,44 @@ async def get_usage_logs( provider_enum = APIProvider.MISTRAL else: try: + # Refresh enum to ensure latest values + from models.subscription_models import APIProvider + _ = list(APIProvider) # Force enum refresh provider_enum = APIProvider(provider_lower) except ValueError: - return { - "logs": [], - "total_count": 0, - "limit": limit, - "offset": offset, - "has_more": False - } + # Fallback: try to find matching provider or use default + try: + # Check if it's a known provider that might not be in enum + known_providers = ['gemini', 'openai', 'anthropic', 'mistral', 'wavespeed', 'tavily', 'serper', 'metaphor', 'firecrawl', 'stability', 'exa', 'video', 'image_edit', 'audio'] + if provider_lower in known_providers: + # Map to existing enum values or skip + provider_mapping = { + 'mistral': 'MISTRAL', + 'wavespeed': 'WAVESPEED', + 'video': 'VIDEO', + 'image_edit': 'IMAGE_EDIT', + 'audio': 'AUDIO' + } + mapped_provider = provider_mapping.get(provider_lower, provider_lower.upper()) + provider_enum = APIProvider(mapped_provider) + else: + # Invalid provider, return empty results + return { + "logs": [], + "total_count": 0, + "limit": limit, + "offset": offset, + "has_more": False + } + except (ValueError, AttributeError): + # If all else fails, return empty results + return { + "logs": [], + "total_count": 0, + "limit": limit, + "offset": offset, + "has_more": False + } query = query.filter(APIUsageLog.provider == provider_enum) if status_code is not None: query = query.filter(APIUsageLog.status_code == status_code) diff --git a/backend/services/subscription/limit_validation.py b/backend/services/subscription/limit_validation.py index ac45be5e..009bf5ec 100644 --- a/backend/services/subscription/limit_validation.py +++ b/backend/services/subscription/limit_validation.py @@ -173,12 +173,32 @@ class LimitValidator: if not usage: # First usage this period, create summary try: - usage = UsageSummary( - user_id=user_id, - billing_period=current_period - ) - self.db.add(usage) - self.db.commit() + # Try to create with minimal fields first to avoid missing column errors + from sqlalchemy import text + try: + # Insert with only essential fields + insert_sql = text(""" + INSERT INTO usage_summaries (user_id, billing_period, created_at, updated_at) + VALUES (:user_id, :period, datetime('now'), datetime('now')) + """) + self.db.execute(insert_sql, {'user_id': user_id, 'period': current_period}) + self.db.commit() + + # Now fetch the created record + usage = self.db.query(UsageSummary).filter( + UsageSummary.user_id == user_id, + UsageSummary.billing_period == current_period + ).first() + + except Exception as sql_error: + logger.debug(f"[Subscription Check] Direct SQL insert failed, trying ORM: {sql_error}") + # Fallback to ORM creation + usage = UsageSummary( + user_id=user_id, + billing_period=current_period + ) + self.db.add(usage) + self.db.commit() except Exception as create_error: logger.error(f"Error creating usage summary: {create_error}") self.db.rollback() diff --git a/frontend/src/components/PodcastMaker/CameraSelfie.tsx b/frontend/src/components/PodcastMaker/CameraSelfie.tsx index ad51b668..84853dad 100644 --- a/frontend/src/components/PodcastMaker/CameraSelfie.tsx +++ b/frontend/src/components/PodcastMaker/CameraSelfie.tsx @@ -1,25 +1,5 @@ -import React, { useState, useRef, useCallback } from 'react'; -import { - Box, - Button, - IconButton, - Typography, - CircularProgress, - Alert, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Tooltip, - alpha, -} from '@mui/material'; -import { - Camera as CameraIcon, - FlipCameraAndroid as FlipCameraIcon, - Close as CloseIcon, - PhotoCamera as PhotoCameraIcon, - VideocamOff as VideocamOffIcon, -} from '@mui/icons-material'; +import React from 'react'; +import { RobustCamera } from './RobustCamera'; interface CameraSelfieProps { onCapture: (imageDataUrl: string) => void; @@ -28,378 +8,5 @@ interface CameraSelfieProps { } export const CameraSelfie: React.FC = ({ onCapture, onClose, open }) => { - const [stream, setStream] = useState(null); - const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user'); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [cameraAvailable, setCameraAvailable] = useState(true); - - const videoRef = useRef(null); - const canvasRef = useRef(null); - - const startCamera = useCallback(async () => { - if (loading) { - return; // Prevent multiple simultaneous camera requests - } - - setLoading(true); - setError(null); - - try { - // Stop existing stream - if (stream) { - stream.getTracks().forEach(track => track.stop()); - } - - const constraints = { - video: { - facingMode: facingMode, - width: { ideal: 1280 }, - height: { ideal: 720 }, - }, - audio: false, - }; - - const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); - setStream(mediaStream); - - // Function to attach stream to video element - const attachStreamToVideo = () => { - if (videoRef.current) { - // Clear any existing stream - if (videoRef.current.srcObject) { - const oldStream = videoRef.current.srcObject as MediaStream; - oldStream.getTracks().forEach(track => track.stop()); - } - - // Attach new stream - videoRef.current.srcObject = mediaStream; - - // Wait for video to be ready - videoRef.current.onloadedmetadata = () => { - setCameraAvailable(true); - setLoading(false); - // Try to play the video - videoRef.current?.play().catch(err => { - console.error('Video play error:', err); - }); - }; - - // Handle video errors - videoRef.current.onerror = (err) => { - console.error('Video error:', err); - setError('Failed to display camera feed.'); - setLoading(false); - }; - - return true; // Successfully attached - } - return false; // Video ref not available - }; - - // Try to attach immediately - if (!attachStreamToVideo()) { - // Retry every 100ms for up to 2 seconds - let retryCount = 0; - const retryInterval = setInterval(() => { - retryCount++; - - if (attachStreamToVideo() || retryCount >= 20) { - clearInterval(retryInterval); - - if (retryCount >= 20) { - setCameraAvailable(true); - setLoading(false); - } - } - }, 100); - } - } catch (err) { - console.error('Camera access error:', err); - setCameraAvailable(false); - setLoading(false); // Set loading to false in error case - - if (err instanceof Error) { - if (err.name === 'NotAllowedError') { - setError('Camera access denied. Please allow camera permissions to take a selfie.'); - } else if (err.name === 'NotFoundError') { - setError('No camera found on this device.'); - } else if (err.name === 'NotReadableError') { - setError('Camera is already in use by another application.'); - } else { - setError('Failed to access camera. Please try again.'); - } - } - } - }, [facingMode, stream, loading]); - - const stopCamera = useCallback(() => { - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); - } - }, [stream]); - - const capturePhoto = useCallback(() => { - if (!videoRef.current || !canvasRef.current) return; - - const video = videoRef.current; - const canvas = canvasRef.current; - - // Set canvas dimensions to match video - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - - // Draw the current video frame to canvas - const context = canvas.getContext('2d'); - if (context) { - // Flip horizontally for selfie (mirror effect) - context.translate(canvas.width, 0); - context.scale(-1, 1); - context.drawImage(video, 0, 0, canvas.width, canvas.height); - - // Convert to data URL - const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9); - onCapture(imageDataUrl); - } - }, [onCapture]); - - const flipCamera = useCallback(() => { - setFacingMode(prev => prev === 'user' ? 'environment' : 'user'); - }, []); - - // Start camera when dialog opens - React.useEffect(() => { - if (open) { - // Small delay to ensure video element is mounted - const timer = setTimeout(() => { - startCamera(); - }, 100); - - return () => { - clearTimeout(timer); - stopCamera(); - }; - } - }, [open, startCamera, stopCamera]); // Add back dependencies with proper useCallback - - // Restart camera when facing mode changes - React.useEffect(() => { - if (open && stream) { - // Stop current stream before starting new one - stopCamera(); - // Small delay to ensure proper cleanup - setTimeout(() => { - startCamera(); - }, 100); - } - }, [facingMode, open, stream, startCamera, stopCamera]); // Add back dependencies - - return ( - - - Take a Selfie - - - - - - - {error && ( - - {error} - - )} - - {loading && ( - - - - Accessing camera... - - - )} - - {!loading && !error && cameraAvailable && ( - - - )} - - {!cameraAvailable && !error && ( - - - - Camera Not Available - - - Your device doesn't have a camera or it's not accessible. - Please use the file upload option instead. - - - )} - - - - - {cameraAvailable && ( - - )} - - - {/* Hidden canvas for image capture */} - - - ); + return ; }; diff --git a/frontend/src/components/PodcastMaker/RenderQueue.tsx b/frontend/src/components/PodcastMaker/RenderQueue.tsx index 70ecb3a2..4eac4ac2 100644 --- a/frontend/src/components/PodcastMaker/RenderQueue.tsx +++ b/frontend/src/components/PodcastMaker/RenderQueue.tsx @@ -56,6 +56,7 @@ export const RenderQueue: React.FC = ({ combiningProgress, finalVideoUrl, combineFinalVideo, + deleteScene, } = useRenderQueue({ script, jobs, @@ -303,6 +304,7 @@ export const RenderQueue: React.FC = ({ rendering={rendering} generatingImage={generatingImage} isBusy={isBusy} + totalScenes={script.scenes.length} avatarImageUrl={avatarImageUrl} bible={bible} analysis={analysis} @@ -312,6 +314,7 @@ export const RenderQueue: React.FC = ({ onDownloadAudio={handleDownloadAudio} onDownloadVideo={handleDownloadVideo} onShare={handleShare} + onDelete={deleteScene} onError={onError} /> ); diff --git a/frontend/src/components/PodcastMaker/RenderQueue/SceneActionButtons.tsx b/frontend/src/components/PodcastMaker/RenderQueue/SceneActionButtons.tsx index dcfab722..de4150b9 100644 --- a/frontend/src/components/PodcastMaker/RenderQueue/SceneActionButtons.tsx +++ b/frontend/src/components/PodcastMaker/RenderQueue/SceneActionButtons.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Stack, alpha } from "@mui/material"; +import { Stack, alpha, Tooltip, IconButton } from "@mui/material"; import { VolumeUp as VolumeUpIcon, Image as ImageIcon, @@ -8,6 +8,7 @@ import { Share as ShareIcon, PlayArrow as PlayArrowIcon, Refresh as RefreshIcon, + Delete as DeleteIcon, } from "@mui/icons-material"; import { Scene, Job } from "../types"; import { PrimaryButton, SecondaryButton } from "../ui"; @@ -23,12 +24,14 @@ interface SceneActionButtonsProps { rendering: string | null; generatingImage: string | null; isBusy: boolean; + totalScenes?: number; onRender: (sceneId: string, mode: "preview" | "full") => void; onImageGenerate: (sceneId: string) => void; onVideoRender: (sceneId: string) => void; onDownloadAudio: (audioUrl: string, title: string) => void; onDownloadVideo: (videoUrl: string, title: string) => void; onShare: (audioUrl: string, title: string) => void; + onDelete: (sceneId: string) => void; onError: (message: string) => void; } @@ -42,12 +45,14 @@ export const SceneActionButtons: React.FC = ({ rendering, generatingImage, isBusy, + totalScenes, onRender, onImageGenerate, onVideoRender, onDownloadAudio, onDownloadVideo, onShare, + onDelete, onError, }) => { const isGeneratingImage = generatingImage === scene.id; @@ -57,6 +62,30 @@ export const SceneActionButtons: React.FC = ({ if (needsAudio) { return ( + + onDelete(scene.id)} + disabled={isBusy || (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: 1.5, + "&: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", + }, + }} + > + + + onRender(scene.id, "preview")} disabled={isBusy} @@ -81,6 +110,30 @@ export const SceneActionButtons: React.FC = ({ if (job?.status === "failed" && !needsAudio && hasAudio) { return ( + + onDelete(scene.id)} + disabled={isBusy || (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: 1.5, + "&: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", + }, + }} + > + + + Video Generation Failed @@ -100,6 +153,30 @@ export const SceneActionButtons: React.FC = ({ if (job?.status === "failed") { return ( + + onDelete(scene.id)} + disabled={isBusy || (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: 1.5, + "&: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", + }, + }} + > + + + onRender(scene.id, "full")} startIcon={} @@ -117,6 +194,32 @@ export const SceneActionButtons: React.FC = ({ return ( + {/* Delete Scene Button */} + + onDelete(scene.id)} + disabled={isBusy || (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: 1.5, + "&: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", + }, + }} + > + + + + {/* Generate/Regenerate Image - ALWAYS visible if we have audio */} onImageGenerate(scene.id)} diff --git a/frontend/src/components/PodcastMaker/RenderQueue/SceneCard.tsx b/frontend/src/components/PodcastMaker/RenderQueue/SceneCard.tsx index 82db5c5c..6471d8b9 100644 --- a/frontend/src/components/PodcastMaker/RenderQueue/SceneCard.tsx +++ b/frontend/src/components/PodcastMaker/RenderQueue/SceneCard.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha, Modal, IconButton } from "@mui/material"; +import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha, Modal, IconButton, Tooltip, Button } from "@mui/material"; import { CheckCircle as CheckCircleIcon, RadioButtonUnchecked as RadioButtonUncheckedIcon, @@ -13,8 +13,9 @@ import { Scene, Job, VideoGenerationSettings } from "../types"; import { GlassyCard, glassyCardSx } from "../ui"; import { InlineAudioPlayer } from "../InlineAudioPlayer"; import { SceneActionButtons } from "./SceneActionButtons"; -import { aiApiClient } from "../../../api/client"; +import { aiApiClient, getAuthTokenGetter } from "../../../api/client"; import { fetchMediaBlobUrl } from "../../../utils/fetchMediaBlobUrl"; +import { mediaCache, getCachedMedia, setCachedMedia, hasCachedMedia } from "../../../utils/mediaCache"; import { VideoRegenerateModal } from "./VideoRegenerateModal"; interface SceneCardProps { @@ -23,6 +24,7 @@ interface SceneCardProps { rendering: string | null; generatingImage: string | null; isBusy: boolean; + totalScenes?: number; avatarImageUrl?: string | null; bible?: any; analysis?: any; @@ -32,6 +34,7 @@ interface SceneCardProps { onDownloadAudio: (audioUrl: string, title: string) => void; onDownloadVideo: (videoUrl: string, title: string) => void; onShare: (audioUrl: string, title: string) => void; + onDelete: (sceneId: string) => void; onError: (message: string) => void; } @@ -78,6 +81,7 @@ export const SceneCard: React.FC = ({ rendering, generatingImage, isBusy, + totalScenes, avatarImageUrl, bible, analysis, @@ -87,6 +91,7 @@ export const SceneCard: React.FC = ({ onDownloadAudio, onDownloadVideo, onShare, + onDelete, onError, }) => { const hasAudio = Boolean(scene.audioUrl || job?.finalUrl || job?.previewUrl); @@ -104,6 +109,10 @@ export const SceneCard: React.FC = ({ const [showVideoModal, setShowVideoModal] = useState(false); const [showImageModal, setShowImageModal] = useState(false); const [initialVideoPrompt, setInitialVideoPrompt] = useState(""); + const [videoLoading, setVideoLoading] = useState(false); + const [videoError, setVideoError] = useState(null); + const [imageLoading, setImageLoading] = useState(false); + const [imageError, setImageError] = useState(null); // Prepare a simple default prompt based on the scene title/description useEffect(() => { @@ -123,6 +132,18 @@ export const SceneCard: React.FC = ({ useEffect(() => { if (!imageUrl) { setImageBlobUrl(null); + setImageLoading(false); + setImageError(null); + return; + } + + // Check cache first with scene context + const cachedUrl = getCachedMedia(imageUrl, scene.id); + if (cachedUrl) { + console.log('[SceneCard] Using cached image:', imageUrl, `(scene: ${scene.id})`); + setImageBlobUrl(cachedUrl); + setImageLoading(false); + setImageError(null); return; } @@ -130,8 +151,11 @@ export const SceneCard: React.FC = ({ const isPodcastImage = imageUrl.includes('/api/podcast/images/') || imageUrl.includes('/api/story/images/'); if (!isPodcastImage) { - // Regular URL (external), use directly + // Regular URL (external), use directly and cache it with scene context setImageBlobUrl(imageUrl); + setCachedMedia(imageUrl, imageUrl, 'image', undefined, scene.id); + setImageLoading(false); + setImageError(null); return; } @@ -141,6 +165,21 @@ export const SceneCard: React.FC = ({ const loadImageBlob = async () => { try { + setImageLoading(true); + setImageError(null); + console.log('[SceneCard] Loading image blob for:', currentImageUrl); + + // 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); + setImageError(null); + } + return; + } + // Normalize path let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`; @@ -170,6 +209,9 @@ export const SceneCard: React.FC = ({ const blob = response.data; const newBlobUrl = URL.createObjectURL(blob); + // Cache the blob URL with scene context + setCachedMedia(currentImageUrl, newBlobUrl, 'image', blob.size, scene.id); + setImageBlobUrl((prevBlobUrl) => { // Clean up previous blob URL if exists if (prevBlobUrl && prevBlobUrl !== newBlobUrl && prevBlobUrl.startsWith('blob:')) { @@ -177,6 +219,7 @@ export const SceneCard: React.FC = ({ } return newBlobUrl; }); + console.log('[SceneCard] Image blob loaded and cached successfully:', currentImageUrl); } catch (err) { console.error('[SceneCard] Failed to load image blob:', err); if (isMounted && imageUrl === currentImageUrl) { @@ -205,15 +248,22 @@ export const SceneCard: React.FC = ({ if (token) { const urlWithToken = `${fallbackPath}?token=${encodeURIComponent(token)}`; setImageBlobUrl(urlWithToken); + setCachedMedia(currentImageUrl, urlWithToken, 'image', undefined, scene.id); } else { // Fallback to original URL setImageBlobUrl(imageUrl); + setCachedMedia(currentImageUrl, imageUrl, 'image', undefined, scene.id); } } catch (fallbackErr) { - console.error('[SceneCard] Fallback also failed:', fallbackErr); - setImageBlobUrl(imageUrl); + console.error('[SceneCard] Image fallback failed:', fallbackErr); + setImageBlobUrl(null); + setImageError('Failed to load image'); } } + } finally { + if (isMounted) { + setImageLoading(false); + } } }; @@ -221,13 +271,7 @@ export const SceneCard: React.FC = ({ return () => { isMounted = false; - // Cleanup blob URL when component unmounts or URL changes - setImageBlobUrl((prevBlobUrl) => { - if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) { - URL.revokeObjectURL(prevBlobUrl); - } - return null; - }); + // Don't cleanup blob URL here - let the cache handle it }; }, [imageUrl, hasImage, scene.id]); @@ -235,30 +279,142 @@ export const SceneCard: React.FC = ({ useEffect(() => { if (!job?.videoUrl) { setVideoBlobUrl(null); + setVideoLoading(false); + setVideoError(null); + return; + } + + // Check cache first with scene context + const cachedUrl = getCachedMedia(job.videoUrl, scene.id); + if (cachedUrl) { + console.log('[SceneCard] Using cached video:', job.videoUrl, `(scene: ${scene.id})`); + setVideoBlobUrl(cachedUrl); + setVideoLoading(false); + setVideoError(null); return; } let currentBlobUrl: string | null = null; + let retryCount = 0; + const maxRetries = 3; - fetchMediaBlobUrl(job.videoUrl) - .then((blobUrl) => { + const loadVideoBlob = async () => { + try { + setVideoLoading(true); + setVideoError(null); + + // Check cache again in case it was loaded while we were waiting + const cachedUrl = getCachedMedia(job.videoUrl!, scene.id); + if (cachedUrl) { + setVideoBlobUrl(cachedUrl); + setVideoLoading(false); + setVideoError(null); + return; + } + + console.log('[SceneCard] Loading video blob for:', job.videoUrl); + const blobUrl = await fetchMediaBlobUrl(job.videoUrl!); + if (blobUrl) { - currentBlobUrl = blobUrl; - setVideoBlobUrl(blobUrl); + // Validate the blob URL by checking if it's a valid blob + if (blobUrl.startsWith('blob:')) { + // Test the blob by trying to load it as a video + const testVideo = document.createElement('video'); + testVideo.src = blobUrl; + + // Wait for metadata to load to validate the blob + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + testVideo.onerror = null; + testVideo.onloadedmetadata = null; + reject(new Error('Video validation timeout')); + }, 5000); + + testVideo.onloadedmetadata = () => { + clearTimeout(timeout); + console.log('[SceneCard] Video blob validation successful:', { + duration: testVideo.duration, + videoWidth: testVideo.videoWidth, + videoHeight: testVideo.videoHeight, + }); + resolve(true); + }; + + testVideo.onerror = (e) => { + clearTimeout(timeout); + reject(new Error('Video blob validation failed')); + }; + }); + + // If we get here, the blob is valid + currentBlobUrl = blobUrl; + setVideoBlobUrl(blobUrl); + + // Cache the validated blob URL with scene context + setCachedMedia(job.videoUrl!, blobUrl, 'video', undefined, scene.id); + + console.log('[SceneCard] Video blob loaded, validated, and cached successfully:', job.videoUrl); + } else { + // Direct URL fallback + setVideoBlobUrl(blobUrl); + setCachedMedia(job.videoUrl!, blobUrl, 'video', undefined, scene.id); + console.log('[SceneCard] Using direct URL fallback and caching:', blobUrl); + } } else { // File not found (404) - clear the blob URL console.warn('[SceneCard] Video file not found (404):', job.videoUrl); setVideoBlobUrl(null); + setVideoError('Video file not found on server'); } - }) - .catch((err) => { + } catch (err) { console.error('[SceneCard] Failed to load video blob:', err); setVideoBlobUrl(null); - }); + + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + retryCount++; + console.log(`[SceneCard] Retrying video blob load (${retryCount}/${maxRetries})`); + setTimeout(loadVideoBlob, 1000 * retryCount); // Exponential backoff + } else { + console.error('[SceneCard] Max retries reached, trying authenticated direct URL'); + // Fallback to authenticated direct URL + try { + // Get auth token using the same method as aiApiClient + const authTokenGetter = getAuthTokenGetter(); + if (authTokenGetter && job.videoUrl) { + const token = await authTokenGetter(); + if (token) { + const separator = job.videoUrl.includes('?') ? '&' : '?'; + const authenticatedUrl = `${job.videoUrl}${separator}token=${encodeURIComponent(token)}`; + setVideoBlobUrl(authenticatedUrl); + setCachedMedia(job.videoUrl!, authenticatedUrl, 'video', undefined, scene.id); + console.log('[SceneCard] Using authenticated direct URL fallback and caching'); + } else { + setVideoBlobUrl(job.videoUrl || null); + setCachedMedia(job.videoUrl!, job.videoUrl || '', 'video', undefined, scene.id); + setVideoError('Failed to load video after multiple attempts'); + } + } else { + setVideoBlobUrl(job.videoUrl || null); + setCachedMedia(job.videoUrl!, job.videoUrl || '', 'video', undefined, scene.id); + setVideoError('Failed to load video after multiple attempts'); + } + } catch (fallbackErr) { + console.error('[SceneCard] Fallback authentication failed:', fallbackErr); + setVideoBlobUrl(null); + setVideoError('Failed to load video. Please try refreshing the page.'); + } + } + } finally { + setVideoLoading(false); + } + }; + + loadVideoBlob(); return () => { // Cleanup blob URL when component unmounts or URL changes - if (currentBlobUrl) { + if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) { URL.revokeObjectURL(currentBlobUrl); } }; @@ -442,90 +598,262 @@ export const SceneCard: React.FC = ({ {/* Video Preview - Show video if available, otherwise show image */} - {hasVideo && videoBlobUrl ? ( + {hasVideo ? ( - { - const videoElement = e.currentTarget as HTMLVideoElement; - console.error("[SceneCard] Video failed to load:", { - originalUrl: job?.videoUrl, - networkState: videoElement.networkState, - }); - }} - /> - - VIDEO - + {videoLoading && ( + + + + )} + + {videoError && ( + + + {videoError} + + + + )} + + {videoBlobUrl && !videoLoading && !videoError && ( + { + const videoElement = e.currentTarget as HTMLVideoElement; + console.error("[SceneCard] Video failed to load:", { + originalUrl: job?.videoUrl, + blobUrl: videoBlobUrl, + networkState: videoElement.networkState, + errorCode: videoElement.error?.code, + errorMessage: videoElement.error?.message, + }); + + // If blob URL failed, try fallback to authenticated direct URL + if (videoBlobUrl && videoBlobUrl.startsWith('blob:')) { + console.log('[SceneCard] Blob URL failed, trying authenticated direct URL fallback'); + try { + const authTokenGetter = getAuthTokenGetter(); + if (authTokenGetter && job?.videoUrl) { + const token = await authTokenGetter(); + if (token) { + const separator = job.videoUrl.includes('?') ? '&' : '?'; + const authenticatedUrl = `${job.videoUrl}${separator}token=${encodeURIComponent(token)}`; + setVideoBlobUrl(authenticatedUrl); + } else { + setVideoBlobUrl(null); + setVideoError('Failed to load video. Authentication required.'); + } + } else { + setVideoBlobUrl(null); + setVideoError('Failed to load video. Authentication required.'); + } + } catch (fallbackErr) { + console.error('[SceneCard] Auth fallback failed:', fallbackErr); + setVideoBlobUrl(null); + setVideoError('Failed to load video. Please try refreshing the page.'); + } + } else if (videoBlobUrl && videoBlobUrl.includes('token=')) { + // If authenticated URL also failed, show error to user + console.error('[SceneCard] Both blob and authenticated URL failed'); + setVideoError('Video file could not be loaded. The file may be corrupted or access was denied.'); + } else { + // If direct URL failed, try authenticated version + if (job?.videoUrl) { + try { + const authTokenGetter = getAuthTokenGetter(); + if (authTokenGetter) { + const token = await authTokenGetter(); + if (token) { + const separator = job.videoUrl.includes('?') ? '&' : '?'; + const authenticatedUrl = `${job.videoUrl}${separator}token=${encodeURIComponent(token)}`; + setVideoBlobUrl(authenticatedUrl); + console.log('[SceneCard] Trying authenticated URL as fallback'); + } else { + setVideoBlobUrl(null); + setVideoError('Failed to load video. Authentication required.'); + } + } else { + setVideoBlobUrl(null); + setVideoError('Failed to load video. Authentication required.'); + } + } catch (fallbackErr) { + console.error('[SceneCard] Final fallback failed:', fallbackErr); + setVideoBlobUrl(null); + setVideoError('Failed to load video. Please try refreshing the page.'); + } + } else { + setVideoError('Failed to load video. No video URL available.'); + } + } + }} + /> + )} - ) : hasImage && (imageBlobUrl || (imageUrl && !imageUrl.includes('/api/'))) ? ( + ) : hasImage ? ( setShowImageModal(true)} + onClick={() => !imageLoading && !imageError && setShowImageModal(true)} > - { - console.error("[SceneCard] Image failed to load:", { - src: e.currentTarget.src, - imageUrl, - }); - }} - /> + {imageLoading && ( + + + + )} + + {imageError && ( + + + {imageError} + + + + )} + + {imageBlobUrl && !imageLoading && !imageError && ( + { + console.error("[SceneCard] Image failed to load:", { + src: e.currentTarget.src, + imageUrl, + }); + setImageError('Failed to load image'); + }} + /> + )} + = ({ rendering={rendering} generatingImage={generatingImage} isBusy={isBusy} + totalScenes={totalScenes} onRender={onRender} onImageGenerate={onImageGenerate} onVideoRender={() => setShowVideoModal(true)} onDownloadAudio={onDownloadAudio} onDownloadVideo={onDownloadVideo} onShare={onShare} + onDelete={onDelete} onError={onError} /> diff --git a/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts b/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts index 08304b9c..e0a39d14 100644 --- a/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts +++ b/frontend/src/components/PodcastMaker/RenderQueue/useRenderQueue.ts @@ -229,56 +229,77 @@ export const useRenderQueue = ({ // Check for completion - handle both "completed" and "processing" with 100% progress const isCompleted = status.status === "completed" || (status.status === "processing" && status.progress === 100); - if (isCompleted && status.result) { - const result = status.result; - console.log("[useRenderQueue] Task completed, extracting video URL", { - result, - video_url: result.video_url, + if (isCompleted) { + console.log("[useRenderQueue] Task completed, checking for video URL", { status: status.status, progress: status.progress, - }); - - let videoUrl = result.video_url; - if (!videoUrl) { - console.error("[useRenderQueue] No video_url in result! Attempting to rescue from file system...", { result }); - // Try to rescue: check if video exists for this scene - const sceneNumberMatch = getScene(sceneId)?.id.match(/\d+/); - const sceneNumber = sceneNumberMatch ? parseInt(sceneNumberMatch[0], 10) : null; - if (sceneNumber !== null) { - podcastApi - .listVideos(projectId) - .then((videoList) => { - const sceneVideo = videoList.videos.find((v) => v.scene_number === sceneNumber); - if (sceneVideo) { - // Store the raw video URL - SceneCard will handle authentication via blob loading - onUpdateJob(sceneId, { - status: "completed", - progress: 100, - videoUrl: sceneVideo.video_url, - cost: result.cost || 0, - }); - } - }) - .catch((err) => console.error("[useRenderQueue] Failed to rescue video:", err)); - } - return true; // Stop polling - } - - // Store the raw video URL - SceneCard will handle authentication via blob loading - onUpdateJob(sceneId, { - status: "completed", - progress: 100, - videoUrl, - cost: result.cost, + hasResult: !!status.result, + result: status.result, }); - const interval = pollingIntervals.current.get(sceneId); - if (interval) { - clearInterval(interval); - pollingIntervals.current.delete(sceneId); + let videoUrl = null; + let cost = 0; + + // Try to get video URL from result + if (status.result) { + const result = status.result; + videoUrl = result.video_url; + cost = result.cost || 0; + } + + // If no video URL in result, try to rescue from video list + if (!videoUrl) { + console.log("[useRenderQueue] No video_url in result! Attempting to rescue from file system..."); + const sceneNumberMatch = getScene(sceneId)?.id.match(/\d+/); + const sceneNumber = sceneNumberMatch ? parseInt(sceneNumberMatch[0], 10) : null; + + if (sceneNumber !== null) { + try { + const videoList = await podcastApi.listVideos(projectId); + const sceneVideo = videoList.videos.find((v) => v.scene_number === sceneNumber); + if (sceneVideo) { + videoUrl = sceneVideo.video_url; + console.log("[useRenderQueue] Successfully rescued video from file system", { sceneNumber, videoUrl }); + } + } catch (err) { + console.error("[useRenderQueue] Failed to rescue video:", err); + } + } + } + + // If we have a video URL, mark as completed + if (videoUrl) { + console.log("[useRenderQueue] Video generation completed successfully", { videoUrl, cost }); + + // Store the raw video URL - SceneCard will handle authentication via blob loading + onUpdateJob(sceneId, { + status: "completed", + progress: 100, + videoUrl, + cost, + }); + + const interval = pollingIntervals.current.get(sceneId); + if (interval) { + clearInterval(interval); + pollingIntervals.current.delete(sceneId); + } + setRendering(null); + return true; // Stop polling + } else { + // Mark as failed if we can't find the video URL + console.error("[useRenderQueue] Task completed but no video URL found"); + onUpdateJob(sceneId, { status: "failed", progress: 0 }); + const interval = pollingIntervals.current.get(sceneId); + if (interval) { + clearInterval(interval); + pollingIntervals.current.delete(sceneId); + } + pollingErrorCounts.current.delete(sceneId); + setRendering(null); + onError("Video generation completed but no video URL was found. Please try generating again."); + return true; // Stop polling } - setRendering(null); - return true; // Stop polling } else if (status.status === "failed") { // Extract user-friendly error message let errorMessage = "Video generation failed"; @@ -764,6 +785,56 @@ export const useRenderQueue = ({ } }, [script.scenes, jobs, projectId, onError]); + // Delete scene functionality + const deleteScene = useCallback(async (sceneId: string) => { + if (!script) return; + + // Prevent deleting if it's the last scene + if (script.scenes.length <= 1) { + onError("Cannot delete the last scene. At least one scene is required."); + return; + } + + // Find the scene to delete + const sceneToDelete = script.scenes.find(s => s.id === sceneId); + if (!sceneToDelete) { + onError("Scene not found."); + return; + } + + try { + // Stop any ongoing polling for this scene + const interval = pollingIntervals.current.get(sceneId); + if (interval) { + clearInterval(interval); + pollingIntervals.current.delete(sceneId); + } + pollingErrorCounts.current.delete(sceneId); + + // If this scene is currently being rendered, stop it + if (rendering === sceneId) { + setRendering(null); + } + if (generatingImage === sceneId) { + setGeneratingImage(null); + } + + // Remove the scene from the script + const updatedScenes = script.scenes.filter(scene => scene.id !== sceneId); + const updatedScript = { ...script, scenes: updatedScenes }; + + // Update the script state + if (onUpdateScript) { + onUpdateScript(updatedScript); + } + + console.log(`[useRenderQueue] Deleted scene: ${sceneToDelete.title}`); + } catch (error) { + console.error("[useRenderQueue] Failed to delete scene:", error); + onError("Failed to delete scene. Please try again."); + } + }, [script, rendering, generatingImage, onUpdateScript, onError]); + return { rendering, generatingImage, @@ -778,6 +849,7 @@ export const useRenderQueue = ({ runVideoRender, combineAudio, combineFinalVideo, + deleteScene, }; }; diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx index 7f4e6b1c..3ce71089 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/SceneEditor.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress } from "@mui/material"; +import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress, IconButton, Tooltip } from "@mui/material"; import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, @@ -7,6 +7,7 @@ import { VolumeUp as VolumeUpIcon, PlayArrow as PlayArrowIcon, Image as ImageIcon, + Delete as DeleteIcon, } from "@mui/icons-material"; import { Scene, Line, Knobs } from "../types"; import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; @@ -15,11 +16,13 @@ import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerate import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal"; import { podcastApi } 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; @@ -27,12 +30,14 @@ interface SceneEditorProps { 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 } export const SceneEditor: React.FC = ({ scene, onUpdateScene, onApprove, + onDelete, knobs, approvingSceneId, generatingAudioId, @@ -40,6 +45,7 @@ export const SceneEditor: React.FC = ({ onAudioGenerated, idea, avatarUrl, + totalScenes, }) => { const [localGenerating, setLocalGenerating] = useState(false); const [generatingImage, setGeneratingImage] = useState(false); @@ -152,12 +158,34 @@ export const SceneEditor: React.FC = ({ 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}`; @@ -192,6 +220,9 @@ export const SceneEditor: React.FC = ({ 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:')) { @@ -199,17 +230,21 @@ export const SceneEditor: React.FC = ({ } return blobUrl; }); + console.log('[SceneEditor] Image blob loaded and cached successfully:', currentImageUrl); } catch (error) { console.error('[SceneEditor] Failed to load image blob:', error); - // Fallback: try with query token - try { - const token = localStorage.getItem('clerk_dashboard_token') || ''; - if (token) { - const urlWithToken = `${currentImageUrl}?token=${encodeURIComponent(token)}`; - setImageBlobUrl(urlWithToken); + 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); } - } catch (fallbackError) { - console.error('[SceneEditor] Fallback image loading failed:', fallbackError); } } finally { if (isMounted) { @@ -222,13 +257,7 @@ export const SceneEditor: React.FC = ({ return () => { isMounted = false; - // Cleanup blob URL on unmount or when imageUrl changes - setImageBlobUrl((prevBlobUrl) => { - if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) { - URL.revokeObjectURL(prevBlobUrl); - } - return null; - }); + // Don't cleanup blob URL here - let the cache handle it }; }, [scene.imageUrl]); @@ -555,6 +584,31 @@ export const SceneEditor: React.FC = ({ ? "Generating Image..." : "Generate Image"} + + + 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: 1.5, + "&: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", + }, + }} + > + + + diff --git a/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx b/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx index 3641d99c..98d4cd05 100644 --- a/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx +++ b/frontend/src/components/PodcastMaker/ScriptEditor/ScriptEditor.tsx @@ -151,6 +151,36 @@ export const ScriptEditor: React.FC = ({ } }; + const deleteScene = useCallback((sceneId: string) => { + if (!script) return; + + // Prevent deleting if it's the last scene + if (script.scenes.length <= 1) { + onError("Cannot delete the last scene. At least one scene is required."); + return; + } + + // Add confirmation dialog + const sceneToDelete = script.scenes.find(s => s.id === sceneId); + if (!sceneToDelete) return; + + const confirmDelete = window.confirm( + `Are you sure you want to delete "${sceneToDelete.title}"? This action cannot be undone.` + ); + + if (!confirmDelete) return; + + // Remove the scene from the script + const updatedScenes = script.scenes.filter(s => s.id !== sceneId); + const updatedScript = { ...script, scenes: updatedScenes }; + + emitScriptChange(updatedScript); + setScript(updatedScript); + + // Show success message + console.log(`[ScriptEditor] Scene "${sceneToDelete.title}" deleted successfully`); + }, [script, emitScriptChange, onError]); + const allApproved = script && script.scenes.every((s) => s.approved); const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0; const totalScenes = script ? script.scenes.length : 0; @@ -568,9 +598,11 @@ export const ScriptEditor: React.FC = ({ scene={scene} onUpdateScene={updateScene} onApprove={approveScene} + onDelete={deleteScene} knobs={knobs} approvingSceneId={approvingSceneId} generatingAudioId={generatingAudioId} + totalScenes={script.scenes.length} onAudioGenerationStart={(sceneId) => { setGeneratingAudioId(sceneId); }}