Merge_PR_410_with_local_changes

This commit is contained in:
ajaysi
2026-03-12 15:21:08 +05:30
parent ecf901c76f
commit e90a29c27e
10 changed files with 841 additions and 560 deletions

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ __pycache__/
workspace/ workspace/
workspace/* workspace/*
.opencode
.trae/ .trae/
/backend/database/migrations/* /backend/database/migrations/*
/backend/.db /backend/.db

View File

@@ -64,16 +64,44 @@ async def get_usage_logs(
provider_enum = APIProvider.MISTRAL provider_enum = APIProvider.MISTRAL
else: else:
try: try:
# Refresh enum to ensure latest values
from models.subscription_models import APIProvider
_ = list(APIProvider) # Force enum refresh
provider_enum = APIProvider(provider_lower) provider_enum = APIProvider(provider_lower)
except ValueError: except ValueError:
# Invalid provider, return empty results # Fallback: try to find matching provider or use default
return { try:
"logs": [], # Check if it's a known provider that might not be in enum
"total_count": 0, known_providers = ['gemini', 'openai', 'anthropic', 'mistral', 'wavespeed', 'tavily', 'serper', 'metaphor', 'firecrawl', 'stability', 'exa', 'video', 'image_edit', 'audio']
"limit": limit, if provider_lower in known_providers:
"offset": offset, # Map to existing enum values or skip
"has_more": False 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) query = query.filter(APIUsageLog.provider == provider_enum)
if status_code is not None: if status_code is not None:
@@ -98,15 +126,44 @@ async def get_usage_logs(
provider_enum = APIProvider.MISTRAL provider_enum = APIProvider.MISTRAL
else: else:
try: try:
# Refresh enum to ensure latest values
from models.subscription_models import APIProvider
_ = list(APIProvider) # Force enum refresh
provider_enum = APIProvider(provider_lower) provider_enum = APIProvider(provider_lower)
except ValueError: except ValueError:
return { # Fallback: try to find matching provider or use default
"logs": [], try:
"total_count": 0, # Check if it's a known provider that might not be in enum
"limit": limit, known_providers = ['gemini', 'openai', 'anthropic', 'mistral', 'wavespeed', 'tavily', 'serper', 'metaphor', 'firecrawl', 'stability', 'exa', 'video', 'image_edit', 'audio']
"offset": offset, if provider_lower in known_providers:
"has_more": False # 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) query = query.filter(APIUsageLog.provider == provider_enum)
if status_code is not None: if status_code is not None:
query = query.filter(APIUsageLog.status_code == status_code) query = query.filter(APIUsageLog.status_code == status_code)

View File

@@ -173,12 +173,32 @@ class LimitValidator:
if not usage: if not usage:
# First usage this period, create summary # First usage this period, create summary
try: try:
usage = UsageSummary( # Try to create with minimal fields first to avoid missing column errors
user_id=user_id, from sqlalchemy import text
billing_period=current_period try:
) # Insert with only essential fields
self.db.add(usage) insert_sql = text("""
self.db.commit() 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: except Exception as create_error:
logger.error(f"Error creating usage summary: {create_error}") logger.error(f"Error creating usage summary: {create_error}")
self.db.rollback() self.db.rollback()

View File

@@ -1,25 +1,5 @@
import React, { useState, useRef, useCallback } from 'react'; import React from 'react';
import { import { RobustCamera } from './RobustCamera';
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';
interface CameraSelfieProps { interface CameraSelfieProps {
onCapture: (imageDataUrl: string) => void; onCapture: (imageDataUrl: string) => void;
@@ -28,378 +8,5 @@ interface CameraSelfieProps {
} }
export const CameraSelfie: React.FC<CameraSelfieProps> = ({ onCapture, onClose, open }) => { export const CameraSelfie: React.FC<CameraSelfieProps> = ({ onCapture, onClose, open }) => {
const [stream, setStream] = useState<MediaStream | null>(null); return <RobustCamera onCapture={onCapture} onClose={onClose} open={open} />;
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cameraAvailable, setCameraAvailable] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(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 (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
overflow: 'hidden',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
bgcolor: 'primary.main',
color: '#ffffff',
}}
>
Take a Selfie
<IconButton onClick={onClose} sx={{ color: '#ffffff' }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 0, minHeight: 400 }}>
{error && (
<Alert severity="error" sx={{ m: 2 }}>
{error}
</Alert>
)}
{loading && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 400,
flexDirection: 'column',
gap: 2,
}}
>
<CircularProgress size={48} />
<Typography variant="body2" color="text.secondary">
Accessing camera...
</Typography>
</Box>
)}
{!loading && !error && cameraAvailable && (
<Box sx={{ position: 'relative', width: '100%', bgcolor: '#000000', minHeight: 400 }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
height: '100%',
minHeight: 400,
objectFit: 'cover',
display: 'block',
transform: facingMode === 'user' ? 'scaleX(-1)' : 'none',
}}
/>
{/* Camera controls overlay */}
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
p: 2,
background: 'linear-gradient(to top, rgba(0,0,0,0.7), transparent)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 2,
}}
>
<Tooltip title="Flip Camera">
<IconButton
onClick={flipCamera}
sx={{
bgcolor: alpha('#ffffff', 0.2),
color: '#ffffff',
'&:hover': {
bgcolor: alpha('#ffffff', 0.3),
},
}}
>
<FlipCameraIcon />
</IconButton>
</Tooltip>
<Tooltip title="Take Photo">
<IconButton
onClick={capturePhoto}
sx={{
bgcolor: '#ffffff',
color: '#000000',
width: 56,
height: 56,
'&:hover': {
bgcolor: alpha('#ffffff', 0.9),
},
}}
>
<PhotoCameraIcon sx={{ fontSize: 32 }} />
</IconButton>
</Tooltip>
<Tooltip title="Close">
<IconButton
onClick={onClose}
sx={{
bgcolor: alpha('#ffffff', 0.2),
color: '#ffffff',
'&:hover': {
bgcolor: alpha('#ffffff', 0.3),
},
}}
>
<VideocamOffIcon />
</IconButton>
</Tooltip>
</Box>
{/* Face guide overlay */}
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 200,
height: 250,
border: '2px dashed rgba(255,255,255,0.3)',
borderRadius: 2,
pointerEvents: 'none',
}}
>
<Typography
variant="caption"
sx={{
position: 'absolute',
top: -25,
left: '50%',
transform: 'translateX(-50%)',
color: '#ffffff',
bgcolor: 'rgba(0,0,0,0.5)',
px: 1,
py: 0.5,
borderRadius: 1,
fontSize: '0.75rem',
}}
>
Position face here
</Typography>
</Box>
</Box>
)}
{!cameraAvailable && !error && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 400,
flexDirection: 'column',
gap: 2,
}}
>
<CameraIcon sx={{ fontSize: 64, color: 'text.secondary' }} />
<Typography variant="h6" color="text.secondary">
Camera Not Available
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center">
Your device doesn't have a camera or it's not accessible.
Please use the file upload option instead.
</Typography>
</Box>
)}
</DialogContent>
<DialogActions sx={{ p: 2, gap: 1 }}>
<Button onClick={onClose} variant="outlined">
Cancel
</Button>
{cameraAvailable && (
<Button onClick={capturePhoto} variant="contained" startIcon={<PhotoCameraIcon />}>
Take Photo
</Button>
)}
</DialogActions>
{/* Hidden canvas for image capture */}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</Dialog>
);
}; };

View File

@@ -56,6 +56,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
combiningProgress, combiningProgress,
finalVideoUrl, finalVideoUrl,
combineFinalVideo, combineFinalVideo,
deleteScene,
} = useRenderQueue({ } = useRenderQueue({
script, script,
jobs, jobs,
@@ -303,6 +304,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
rendering={rendering} rendering={rendering}
generatingImage={generatingImage} generatingImage={generatingImage}
isBusy={isBusy} isBusy={isBusy}
totalScenes={script.scenes.length}
avatarImageUrl={avatarImageUrl} avatarImageUrl={avatarImageUrl}
bible={bible} bible={bible}
analysis={analysis} analysis={analysis}
@@ -312,6 +314,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
onDownloadAudio={handleDownloadAudio} onDownloadAudio={handleDownloadAudio}
onDownloadVideo={handleDownloadVideo} onDownloadVideo={handleDownloadVideo}
onShare={handleShare} onShare={handleShare}
onDelete={deleteScene}
onError={onError} onError={onError}
/> />
); );

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Stack, alpha } from "@mui/material"; import { Stack, alpha, Tooltip, IconButton } from "@mui/material";
import { import {
VolumeUp as VolumeUpIcon, VolumeUp as VolumeUpIcon,
Image as ImageIcon, Image as ImageIcon,
@@ -8,6 +8,7 @@ import {
Share as ShareIcon, Share as ShareIcon,
PlayArrow as PlayArrowIcon, PlayArrow as PlayArrowIcon,
Refresh as RefreshIcon, Refresh as RefreshIcon,
Delete as DeleteIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Scene, Job } from "../types"; import { Scene, Job } from "../types";
import { PrimaryButton, SecondaryButton } from "../ui"; import { PrimaryButton, SecondaryButton } from "../ui";
@@ -23,12 +24,14 @@ interface SceneActionButtonsProps {
rendering: string | null; rendering: string | null;
generatingImage: string | null; generatingImage: string | null;
isBusy: boolean; isBusy: boolean;
totalScenes?: number;
onRender: (sceneId: string, mode: "preview" | "full") => void; onRender: (sceneId: string, mode: "preview" | "full") => void;
onImageGenerate: (sceneId: string) => void; onImageGenerate: (sceneId: string) => void;
onVideoRender: (sceneId: string) => void; onVideoRender: (sceneId: string) => void;
onDownloadAudio: (audioUrl: string, title: string) => void; onDownloadAudio: (audioUrl: string, title: string) => void;
onDownloadVideo: (videoUrl: string, title: string) => void; onDownloadVideo: (videoUrl: string, title: string) => void;
onShare: (audioUrl: string, title: string) => void; onShare: (audioUrl: string, title: string) => void;
onDelete: (sceneId: string) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
@@ -42,12 +45,14 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
rendering, rendering,
generatingImage, generatingImage,
isBusy, isBusy,
totalScenes,
onRender, onRender,
onImageGenerate, onImageGenerate,
onVideoRender, onVideoRender,
onDownloadAudio, onDownloadAudio,
onDownloadVideo, onDownloadVideo,
onShare, onShare,
onDelete,
onError, onError,
}) => { }) => {
const isGeneratingImage = generatingImage === scene.id; const isGeneratingImage = generatingImage === scene.id;
@@ -57,6 +62,30 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
if (needsAudio) { if (needsAudio) {
return ( return (
<Stack direction="row" spacing={1} justifyContent="flex-end"> <Stack direction="row" spacing={1} justifyContent="flex-end">
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
<IconButton
onClick={() => 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",
},
}}
>
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
</IconButton>
</Tooltip>
<SecondaryButton <SecondaryButton
onClick={() => onRender(scene.id, "preview")} onClick={() => onRender(scene.id, "preview")}
disabled={isBusy} disabled={isBusy}
@@ -81,6 +110,30 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
if (job?.status === "failed" && !needsAudio && hasAudio) { if (job?.status === "failed" && !needsAudio && hasAudio) {
return ( return (
<Stack direction="row" spacing={1} justifyContent="flex-end"> <Stack direction="row" spacing={1} justifyContent="flex-end">
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
<IconButton
onClick={() => 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",
},
}}
>
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
</IconButton>
</Tooltip>
<Typography variant="caption" color="error" sx={{ alignSelf: "center", mr: 1 }}> <Typography variant="caption" color="error" sx={{ alignSelf: "center", mr: 1 }}>
Video Generation Failed Video Generation Failed
</Typography> </Typography>
@@ -100,6 +153,30 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
if (job?.status === "failed") { if (job?.status === "failed") {
return ( return (
<Stack direction="row" spacing={1} justifyContent="flex-end"> <Stack direction="row" spacing={1} justifyContent="flex-end">
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
<IconButton
onClick={() => 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",
},
}}
>
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
</IconButton>
</Tooltip>
<SecondaryButton <SecondaryButton
onClick={() => onRender(scene.id, "full")} onClick={() => onRender(scene.id, "full")}
startIcon={<RefreshIcon />} startIcon={<RefreshIcon />}
@@ -117,6 +194,32 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
return ( return (
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
{/* Delete Scene Button */}
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
<IconButton
onClick={() => 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",
},
}}
>
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
</IconButton>
</Tooltip>
{/* Generate/Regenerate Image - ALWAYS visible if we have audio */} {/* Generate/Regenerate Image - ALWAYS visible if we have audio */}
<PrimaryButton <PrimaryButton
onClick={() => onImageGenerate(scene.id)} onClick={() => onImageGenerate(scene.id)}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; 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 { import {
CheckCircle as CheckCircleIcon, CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon, RadioButtonUnchecked as RadioButtonUncheckedIcon,
@@ -13,8 +13,9 @@ import { Scene, Job, VideoGenerationSettings } from "../types";
import { GlassyCard, glassyCardSx } from "../ui"; import { GlassyCard, glassyCardSx } from "../ui";
import { InlineAudioPlayer } from "../InlineAudioPlayer"; import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { SceneActionButtons } from "./SceneActionButtons"; import { SceneActionButtons } from "./SceneActionButtons";
import { aiApiClient } from "../../../api/client"; import { aiApiClient, getAuthTokenGetter } from "../../../api/client";
import { fetchMediaBlobUrl } from "../../../utils/fetchMediaBlobUrl"; import { fetchMediaBlobUrl } from "../../../utils/fetchMediaBlobUrl";
import { mediaCache, getCachedMedia, setCachedMedia, hasCachedMedia } from "../../../utils/mediaCache";
import { VideoRegenerateModal } from "./VideoRegenerateModal"; import { VideoRegenerateModal } from "./VideoRegenerateModal";
interface SceneCardProps { interface SceneCardProps {
@@ -23,6 +24,7 @@ interface SceneCardProps {
rendering: string | null; rendering: string | null;
generatingImage: string | null; generatingImage: string | null;
isBusy: boolean; isBusy: boolean;
totalScenes?: number;
avatarImageUrl?: string | null; avatarImageUrl?: string | null;
bible?: any; bible?: any;
analysis?: any; analysis?: any;
@@ -32,6 +34,7 @@ interface SceneCardProps {
onDownloadAudio: (audioUrl: string, title: string) => void; onDownloadAudio: (audioUrl: string, title: string) => void;
onDownloadVideo: (videoUrl: string, title: string) => void; onDownloadVideo: (videoUrl: string, title: string) => void;
onShare: (audioUrl: string, title: string) => void; onShare: (audioUrl: string, title: string) => void;
onDelete: (sceneId: string) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
@@ -78,6 +81,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
rendering, rendering,
generatingImage, generatingImage,
isBusy, isBusy,
totalScenes,
avatarImageUrl, avatarImageUrl,
bible, bible,
analysis, analysis,
@@ -87,6 +91,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
onDownloadAudio, onDownloadAudio,
onDownloadVideo, onDownloadVideo,
onShare, onShare,
onDelete,
onError, onError,
}) => { }) => {
const hasAudio = Boolean(scene.audioUrl || job?.finalUrl || job?.previewUrl); const hasAudio = Boolean(scene.audioUrl || job?.finalUrl || job?.previewUrl);
@@ -104,6 +109,10 @@ export const SceneCard: React.FC<SceneCardProps> = ({
const [showVideoModal, setShowVideoModal] = useState(false); const [showVideoModal, setShowVideoModal] = useState(false);
const [showImageModal, setShowImageModal] = useState(false); const [showImageModal, setShowImageModal] = useState(false);
const [initialVideoPrompt, setInitialVideoPrompt] = useState<string>(""); const [initialVideoPrompt, setInitialVideoPrompt] = useState<string>("");
const [videoLoading, setVideoLoading] = useState(false);
const [videoError, setVideoError] = useState<string | null>(null);
const [imageLoading, setImageLoading] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
// Prepare a simple default prompt based on the scene title/description // Prepare a simple default prompt based on the scene title/description
useEffect(() => { useEffect(() => {
@@ -123,6 +132,18 @@ export const SceneCard: React.FC<SceneCardProps> = ({
useEffect(() => { useEffect(() => {
if (!imageUrl) { if (!imageUrl) {
setImageBlobUrl(null); 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; return;
} }
@@ -130,8 +151,11 @@ export const SceneCard: React.FC<SceneCardProps> = ({
const isPodcastImage = imageUrl.includes('/api/podcast/images/') || imageUrl.includes('/api/story/images/'); const isPodcastImage = imageUrl.includes('/api/podcast/images/') || imageUrl.includes('/api/story/images/');
if (!isPodcastImage) { if (!isPodcastImage) {
// Regular URL (external), use directly // Regular URL (external), use directly and cache it with scene context
setImageBlobUrl(imageUrl); setImageBlobUrl(imageUrl);
setCachedMedia(imageUrl, imageUrl, 'image', undefined, scene.id);
setImageLoading(false);
setImageError(null);
return; return;
} }
@@ -141,6 +165,21 @@ export const SceneCard: React.FC<SceneCardProps> = ({
const loadImageBlob = async () => { const loadImageBlob = async () => {
try { 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 // Normalize path
let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`; let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
@@ -170,6 +209,9 @@ export const SceneCard: React.FC<SceneCardProps> = ({
const blob = response.data; const blob = response.data;
const newBlobUrl = URL.createObjectURL(blob); const newBlobUrl = URL.createObjectURL(blob);
// Cache the blob URL with scene context
setCachedMedia(currentImageUrl, newBlobUrl, 'image', blob.size, scene.id);
setImageBlobUrl((prevBlobUrl) => { setImageBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists // Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== newBlobUrl && prevBlobUrl.startsWith('blob:')) { if (prevBlobUrl && prevBlobUrl !== newBlobUrl && prevBlobUrl.startsWith('blob:')) {
@@ -177,6 +219,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
} }
return newBlobUrl; return newBlobUrl;
}); });
console.log('[SceneCard] Image blob loaded and cached successfully:', currentImageUrl);
} catch (err) { } catch (err) {
console.error('[SceneCard] Failed to load image blob:', err); console.error('[SceneCard] Failed to load image blob:', err);
if (isMounted && imageUrl === currentImageUrl) { if (isMounted && imageUrl === currentImageUrl) {
@@ -205,15 +248,22 @@ export const SceneCard: React.FC<SceneCardProps> = ({
if (token) { if (token) {
const urlWithToken = `${fallbackPath}?token=${encodeURIComponent(token)}`; const urlWithToken = `${fallbackPath}?token=${encodeURIComponent(token)}`;
setImageBlobUrl(urlWithToken); setImageBlobUrl(urlWithToken);
setCachedMedia(currentImageUrl, urlWithToken, 'image', undefined, scene.id);
} else { } else {
// Fallback to original URL // Fallback to original URL
setImageBlobUrl(imageUrl); setImageBlobUrl(imageUrl);
setCachedMedia(currentImageUrl, imageUrl, 'image', undefined, scene.id);
} }
} catch (fallbackErr) { } catch (fallbackErr) {
console.error('[SceneCard] Fallback also failed:', fallbackErr); console.error('[SceneCard] Image fallback failed:', fallbackErr);
setImageBlobUrl(imageUrl); setImageBlobUrl(null);
setImageError('Failed to load image');
} }
} }
} finally {
if (isMounted) {
setImageLoading(false);
}
} }
}; };
@@ -221,13 +271,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
return () => { return () => {
isMounted = false; isMounted = false;
// Cleanup blob URL when component unmounts or URL changes // Don't cleanup blob URL here - let the cache handle it
setImageBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
}; };
}, [imageUrl, hasImage, scene.id]); }, [imageUrl, hasImage, scene.id]);
@@ -235,30 +279,142 @@ export const SceneCard: React.FC<SceneCardProps> = ({
useEffect(() => { useEffect(() => {
if (!job?.videoUrl) { if (!job?.videoUrl) {
setVideoBlobUrl(null); 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; return;
} }
let currentBlobUrl: string | null = null; let currentBlobUrl: string | null = null;
let retryCount = 0;
const maxRetries = 3;
fetchMediaBlobUrl(job.videoUrl) const loadVideoBlob = async () => {
.then((blobUrl) => { 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) { if (blobUrl) {
currentBlobUrl = blobUrl; // Validate the blob URL by checking if it's a valid blob
setVideoBlobUrl(blobUrl); 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 { } else {
// File not found (404) - clear the blob URL // File not found (404) - clear the blob URL
console.warn('[SceneCard] Video file not found (404):', job.videoUrl); console.warn('[SceneCard] Video file not found (404):', job.videoUrl);
setVideoBlobUrl(null); setVideoBlobUrl(null);
setVideoError('Video file not found on server');
} }
}) } catch (err) {
.catch((err) => {
console.error('[SceneCard] Failed to load video blob:', err); console.error('[SceneCard] Failed to load video blob:', err);
setVideoBlobUrl(null); 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 () => { return () => {
// Cleanup blob URL when component unmounts or URL changes // Cleanup blob URL when component unmounts or URL changes
if (currentBlobUrl) { if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentBlobUrl); URL.revokeObjectURL(currentBlobUrl);
} }
}; };
@@ -442,90 +598,262 @@ export const SceneCard: React.FC<SceneCardProps> = ({
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} /> <Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} />
{/* Video Preview - Show video if available, otherwise show image */} {/* Video Preview - Show video if available, otherwise show image */}
{hasVideo && videoBlobUrl ? ( {hasVideo ? (
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
borderRadius: 2, borderRadius: 2,
overflow: "hidden", overflow: "hidden",
border: "2px solid rgba(56,189,248,0.5)", border: videoError ? "2px solid rgba(239, 68, 68, 0.5)" : "2px solid rgba(56,189,248,0.5)",
background: alpha("#0f172a", 0.85), background: alpha("#0f172a", 0.85),
position: "relative", position: "relative",
}} }}
> >
<Box {videoLoading && (
component="video" <Box
src={videoBlobUrl} sx={{
controls position: "absolute",
preload="metadata" top: 0,
sx={{ left: 0,
width: "100%", right: 0,
height: "auto", bottom: 0,
display: "block", display: "flex",
maxHeight: 420, alignItems: "center",
objectFit: "contain", justifyContent: "center",
backgroundColor: "black", backgroundColor: "rgba(0,0,0,0.7)",
}} zIndex: 1,
onError={(e) => { }}
const videoElement = e.currentTarget as HTMLVideoElement; >
console.error("[SceneCard] Video failed to load:", { <CircularProgress size={40} sx={{ color: "#38bdf8" }} />
originalUrl: job?.videoUrl, </Box>
networkState: videoElement.networkState, )}
});
}} {videoError && (
/> <Box
<Box sx={{
sx={{ position: "absolute",
position: "absolute", top: 0,
top: 8, left: 0,
right: 8, right: 0,
bgcolor: "rgba(56,189,248,0.9)", bottom: 0,
color: "white", display: "flex",
px: 1, flexDirection: "column",
py: 0.5, alignItems: "center",
borderRadius: 1, justifyContent: "center",
fontSize: "0.75rem", backgroundColor: "rgba(0,0,0,0.8)",
fontWeight: 600, zIndex: 1,
}} p: 2,
> }}
VIDEO >
</Box> <Typography variant="body2" sx={{ color: "#ef4444", textAlign: "center", mb: 1 }}>
{videoError}
</Typography>
<Button
size="small"
variant="outlined"
onClick={() => {
setVideoError(null);
// Retry loading
if (job?.videoUrl) {
setVideoBlobUrl(null);
// This will trigger the useEffect to reload
setTimeout(() => {
if (job.videoUrl) {
setVideoBlobUrl(job.videoUrl);
}
}, 100);
}
}}
sx={{ color: "#38bdf8", borderColor: "#38bdf8" }}
>
Retry
</Button>
</Box>
)}
{videoBlobUrl && !videoLoading && !videoError && (
<Box
component="video"
src={videoBlobUrl}
controls
preload="metadata"
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 420,
objectFit: "contain",
backgroundColor: "black",
}}
onError={async (e) => {
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.');
}
}
}}
/>
)}
</Box> </Box>
) : hasImage && (imageBlobUrl || (imageUrl && !imageUrl.includes('/api/'))) ? ( ) : hasImage ? (
<Box sx={{ position: "relative", width: "100%" }}> <Box sx={{ position: "relative", width: "100%" }}>
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
borderRadius: 2, borderRadius: 2,
overflow: "hidden", overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)", border: imageError ? "2px solid rgba(239, 68, 68, 0.5)" : "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05), background: alpha("#667eea", 0.05),
cursor: "pointer", cursor: "pointer",
"&:hover .zoom-icon": { "&:hover .zoom-icon": {
opacity: 1, opacity: 1,
} }
}} }}
onClick={() => setShowImageModal(true)} onClick={() => !imageLoading && !imageError && setShowImageModal(true)}
> >
<Box {imageLoading && (
component="img" <Box
src={imageBlobUrl || imageUrl} sx={{
alt={scene.title} position: "absolute",
sx={{ top: 0,
width: "100%", left: 0,
height: "auto", right: 0,
display: "block", bottom: 0,
maxHeight: 400, display: "flex",
objectFit: "contain", alignItems: "center",
background: "#000", justifyContent: "center",
}} backgroundColor: "rgba(0,0,0,0.7)",
onError={(e) => { zIndex: 1,
console.error("[SceneCard] Image failed to load:", { minHeight: 200,
src: e.currentTarget.src, }}
imageUrl, >
}); <CircularProgress size={40} sx={{ color: "#38bdf8" }} />
}} </Box>
/> )}
{imageError && (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.8)",
zIndex: 1,
p: 2,
minHeight: 200,
}}
>
<Typography variant="body2" sx={{ color: "#ef4444", textAlign: "center", mb: 1 }}>
{imageError}
</Typography>
<Button
size="small"
variant="outlined"
onClick={() => {
setImageError(null);
// Retry loading
if (imageUrl) {
setImageBlobUrl(null);
setTimeout(() => setImageBlobUrl(imageUrl), 100);
}
}}
sx={{ color: "#38bdf8", borderColor: "#38bdf8" }}
>
Retry
</Button>
</Box>
)}
{imageBlobUrl && !imageLoading && !imageError && (
<Box
component="img"
src={imageBlobUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "contain",
background: "#000",
}}
onError={(e) => {
console.error("[SceneCard] Image failed to load:", {
src: e.currentTarget.src,
imageUrl,
});
setImageError('Failed to load image');
}}
/>
)}
<Box <Box
className="zoom-icon" className="zoom-icon"
sx={{ sx={{
@@ -561,12 +889,14 @@ export const SceneCard: React.FC<SceneCardProps> = ({
rendering={rendering} rendering={rendering}
generatingImage={generatingImage} generatingImage={generatingImage}
isBusy={isBusy} isBusy={isBusy}
totalScenes={totalScenes}
onRender={onRender} onRender={onRender}
onImageGenerate={onImageGenerate} onImageGenerate={onImageGenerate}
onVideoRender={() => setShowVideoModal(true)} onVideoRender={() => setShowVideoModal(true)}
onDownloadAudio={onDownloadAudio} onDownloadAudio={onDownloadAudio}
onDownloadVideo={onDownloadVideo} onDownloadVideo={onDownloadVideo}
onShare={onShare} onShare={onShare}
onDelete={onDelete}
onError={onError} onError={onError}
/> />

View File

@@ -229,56 +229,77 @@ export const useRenderQueue = ({
// Check for completion - handle both "completed" and "processing" with 100% progress // Check for completion - handle both "completed" and "processing" with 100% progress
const isCompleted = status.status === "completed" || (status.status === "processing" && status.progress === 100); const isCompleted = status.status === "completed" || (status.status === "processing" && status.progress === 100);
if (isCompleted && status.result) { if (isCompleted) {
const result = status.result; console.log("[useRenderQueue] Task completed, checking for video URL", {
console.log("[useRenderQueue] Task completed, extracting video URL", {
result,
video_url: result.video_url,
status: status.status, status: status.status,
progress: status.progress, progress: status.progress,
}); hasResult: !!status.result,
result: status.result,
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,
}); });
const interval = pollingIntervals.current.get(sceneId); let videoUrl = null;
if (interval) { let cost = 0;
clearInterval(interval);
pollingIntervals.current.delete(sceneId); // 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") { } else if (status.status === "failed") {
// Extract user-friendly error message // Extract user-friendly error message
let errorMessage = "Video generation failed"; let errorMessage = "Video generation failed";
@@ -764,6 +785,56 @@ export const useRenderQueue = ({
} }
}, [script.scenes, jobs, projectId, onError]); }, [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 { return {
rendering, rendering,
generatingImage, generatingImage,
@@ -778,6 +849,7 @@ export const useRenderQueue = ({
runVideoRender, runVideoRender,
combineAudio, combineAudio,
combineFinalVideo, combineFinalVideo,
deleteScene,
}; };
}; };

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; 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 { import {
EditNote as EditNoteIcon, EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon, CheckCircle as CheckCircleIcon,
@@ -7,6 +7,7 @@ import {
VolumeUp as VolumeUpIcon, VolumeUp as VolumeUpIcon,
PlayArrow as PlayArrowIcon, PlayArrow as PlayArrowIcon,
Image as ImageIcon, Image as ImageIcon,
Delete as DeleteIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Scene, Line, Knobs } from "../types"; import { Scene, Line, Knobs } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui"; import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
@@ -15,11 +16,13 @@ import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerate
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal"; import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
import { podcastApi } from "../../../services/podcastApi"; import { podcastApi } from "../../../services/podcastApi";
import { aiApiClient } from "../../../api/client"; import { aiApiClient } from "../../../api/client";
import { getCachedMedia, setCachedMedia } from "../../../utils/mediaCache";
interface SceneEditorProps { interface SceneEditorProps {
scene: Scene; scene: Scene;
onUpdateScene: (s: Scene) => void; onUpdateScene: (s: Scene) => void;
onApprove: (id: string) => Promise<void>; onApprove: (id: string) => Promise<void>;
onDelete: (sceneId: string) => void;
knobs: Knobs; knobs: Knobs;
approvingSceneId?: string | null; approvingSceneId?: string | null;
generatingAudioId?: string | null; generatingAudioId?: string | null;
@@ -27,12 +30,14 @@ interface SceneEditorProps {
onAudioGenerated?: (sceneId: string, audioUrl: string) => void; onAudioGenerated?: (sceneId: string, audioUrl: string) => void;
idea?: string; // Podcast idea for image generation context idea?: string; // Podcast idea for image generation context
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation 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<SceneEditorProps> = ({ export const SceneEditor: React.FC<SceneEditorProps> = ({
scene, scene,
onUpdateScene, onUpdateScene,
onApprove, onApprove,
onDelete,
knobs, knobs,
approvingSceneId, approvingSceneId,
generatingAudioId, generatingAudioId,
@@ -40,6 +45,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
onAudioGenerated, onAudioGenerated,
idea, idea,
avatarUrl, avatarUrl,
totalScenes,
}) => { }) => {
const [localGenerating, setLocalGenerating] = useState(false); const [localGenerating, setLocalGenerating] = useState(false);
const [generatingImage, setGeneratingImage] = useState(false); const [generatingImage, setGeneratingImage] = useState(false);
@@ -152,12 +158,34 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
return; 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; let isMounted = true;
const currentImageUrl = scene.imageUrl; // Capture current value const currentImageUrl = scene.imageUrl; // Capture current value
const loadImageBlob = async () => { const loadImageBlob = async () => {
try { try {
setImageLoading(true); 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 // Normalize path
let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`; let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
@@ -192,6 +220,9 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const blob = response.data; const blob = response.data;
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
// Cache the blob URL with scene context
setCachedMedia(currentImageUrl, blobUrl, 'image', blob.size, scene.id);
setImageBlobUrl((prevBlobUrl) => { setImageBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists // Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) { if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) {
@@ -199,17 +230,21 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
} }
return blobUrl; return blobUrl;
}); });
console.log('[SceneEditor] Image blob loaded and cached successfully:', currentImageUrl);
} catch (error) { } catch (error) {
console.error('[SceneEditor] Failed to load image blob:', error); console.error('[SceneEditor] Failed to load image blob:', error);
// Fallback: try with query token if (isMounted) {
try { // Try adding query token as fallback
const token = localStorage.getItem('clerk_dashboard_token') || ''; try {
if (token) { const token = localStorage.getItem('clerk_dashboard_token') || '';
const urlWithToken = `${currentImageUrl}?token=${encodeURIComponent(token)}`; if (token) {
setImageBlobUrl(urlWithToken); 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 { } finally {
if (isMounted) { if (isMounted) {
@@ -222,13 +257,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
return () => { return () => {
isMounted = false; isMounted = false;
// Cleanup blob URL on unmount or when imageUrl changes // Don't cleanup blob URL here - let the cache handle it
setImageBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
}; };
}, [scene.imageUrl]); }, [scene.imageUrl]);
@@ -555,6 +584,31 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
? "Generating Image..." ? "Generating Image..."
: "Generate Image"} : "Generate Image"}
</PrimaryButton> </PrimaryButton>
<Tooltip title={totalScenes && totalScenes <= 1 ? "Cannot delete the last scene" : "Delete this scene"}>
<IconButton
onClick={() => 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",
},
}}
>
<DeleteIcon sx={{ fontSize: "1.25rem" }} />
</IconButton>
</Tooltip>
</Stack> </Stack>
</Stack> </Stack>

View File

@@ -151,6 +151,36 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
} }
}; };
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 allApproved = script && script.scenes.every((s) => s.approved);
const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0; const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0;
const totalScenes = script ? script.scenes.length : 0; const totalScenes = script ? script.scenes.length : 0;
@@ -568,9 +598,11 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
scene={scene} scene={scene}
onUpdateScene={updateScene} onUpdateScene={updateScene}
onApprove={approveScene} onApprove={approveScene}
onDelete={deleteScene}
knobs={knobs} knobs={knobs}
approvingSceneId={approvingSceneId} approvingSceneId={approvingSceneId}
generatingAudioId={generatingAudioId} generatingAudioId={generatingAudioId}
totalScenes={script.scenes.length}
onAudioGenerationStart={(sceneId) => { onAudioGenerationStart={(sceneId) => {
setGeneratingAudioId(sceneId); setGeneratingAudioId(sceneId);
}} }}