Merge_PR_410_with_local_changes
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ __pycache__/
|
||||
workspace/
|
||||
workspace/*
|
||||
|
||||
.opencode
|
||||
|
||||
|
||||
.trae/
|
||||
/backend/database/migrations/*
|
||||
/backend/.db
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<CameraSelfieProps> = ({ onCapture, onClose, open }) => {
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
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>
|
||||
);
|
||||
return <RobustCamera onCapture={onCapture} onClose={onClose} open={open} />;
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
combiningProgress,
|
||||
finalVideoUrl,
|
||||
combineFinalVideo,
|
||||
deleteScene,
|
||||
} = useRenderQueue({
|
||||
script,
|
||||
jobs,
|
||||
@@ -303,6 +304,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
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<RenderQueueProps> = ({
|
||||
onDownloadAudio={handleDownloadAudio}
|
||||
onDownloadVideo={handleDownloadVideo}
|
||||
onShare={handleShare}
|
||||
onDelete={deleteScene}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<SceneActionButtonsProps> = ({
|
||||
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<SceneActionButtonsProps> = ({
|
||||
if (needsAudio) {
|
||||
return (
|
||||
<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
|
||||
onClick={() => onRender(scene.id, "preview")}
|
||||
disabled={isBusy}
|
||||
@@ -81,6 +110,30 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
if (job?.status === "failed" && !needsAudio && hasAudio) {
|
||||
return (
|
||||
<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 }}>
|
||||
Video Generation Failed
|
||||
</Typography>
|
||||
@@ -100,6 +153,30 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
if (job?.status === "failed") {
|
||||
return (
|
||||
<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
|
||||
onClick={() => onRender(scene.id, "full")}
|
||||
startIcon={<RefreshIcon />}
|
||||
@@ -117,6 +194,32 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<PrimaryButton
|
||||
onClick={() => onImageGenerate(scene.id)}
|
||||
|
||||
@@ -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<SceneCardProps> = ({
|
||||
rendering,
|
||||
generatingImage,
|
||||
isBusy,
|
||||
totalScenes,
|
||||
avatarImageUrl,
|
||||
bible,
|
||||
analysis,
|
||||
@@ -87,6 +91,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
onDownloadAudio,
|
||||
onDownloadVideo,
|
||||
onShare,
|
||||
onDelete,
|
||||
onError,
|
||||
}) => {
|
||||
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 [showImageModal, setShowImageModal] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -123,6 +132,18 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
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<SceneCardProps> = ({
|
||||
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<SceneCardProps> = ({
|
||||
|
||||
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<SceneCardProps> = ({
|
||||
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<SceneCardProps> = ({
|
||||
}
|
||||
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<SceneCardProps> = ({
|
||||
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<SceneCardProps> = ({
|
||||
|
||||
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<SceneCardProps> = ({
|
||||
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<SceneCardProps> = ({
|
||||
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} />
|
||||
|
||||
{/* Video Preview - Show video if available, otherwise show image */}
|
||||
{hasVideo && videoBlobUrl ? (
|
||||
{hasVideo ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderRadius: 2,
|
||||
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),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="video"
|
||||
src={videoBlobUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
maxHeight: 420,
|
||||
objectFit: "contain",
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const videoElement = e.currentTarget as HTMLVideoElement;
|
||||
console.error("[SceneCard] Video failed to load:", {
|
||||
originalUrl: job?.videoUrl,
|
||||
networkState: videoElement.networkState,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: "rgba(56,189,248,0.9)",
|
||||
color: "white",
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
VIDEO
|
||||
</Box>
|
||||
{videoLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={40} sx={{ color: "#38bdf8" }} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{videoError && (
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
) : hasImage && (imageBlobUrl || (imageUrl && !imageUrl.includes('/api/'))) ? (
|
||||
) : hasImage ? (
|
||||
<Box sx={{ position: "relative", width: "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderRadius: 2,
|
||||
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),
|
||||
cursor: "pointer",
|
||||
"&:hover .zoom-icon": {
|
||||
opacity: 1,
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowImageModal(true)}
|
||||
onClick={() => !imageLoading && !imageError && setShowImageModal(true)}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageBlobUrl || imageUrl}
|
||||
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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{imageLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
zIndex: 1,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<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
|
||||
className="zoom-icon"
|
||||
sx={{
|
||||
@@ -561,12 +889,14 @@ export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
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<SceneEditorProps> = ({
|
||||
scene,
|
||||
onUpdateScene,
|
||||
onApprove,
|
||||
onDelete,
|
||||
knobs,
|
||||
approvingSceneId,
|
||||
generatingAudioId,
|
||||
@@ -40,6 +45,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
onAudioGenerated,
|
||||
idea,
|
||||
avatarUrl,
|
||||
totalScenes,
|
||||
}) => {
|
||||
const [localGenerating, setLocalGenerating] = useState(false);
|
||||
const [generatingImage, setGeneratingImage] = useState(false);
|
||||
@@ -152,12 +158,34 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
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<SceneEditorProps> = ({
|
||||
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<SceneEditorProps> = ({
|
||||
}
|
||||
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<SceneEditorProps> = ({
|
||||
|
||||
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<SceneEditorProps> = ({
|
||||
? "Generating Image..."
|
||||
: "Generate Image"}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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 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<ScriptEditorProps> = ({
|
||||
scene={scene}
|
||||
onUpdateScene={updateScene}
|
||||
onApprove={approveScene}
|
||||
onDelete={deleteScene}
|
||||
knobs={knobs}
|
||||
approvingSceneId={approvingSceneId}
|
||||
generatingAudioId={generatingAudioId}
|
||||
totalScenes={script.scenes.length}
|
||||
onAudioGenerationStart={(sceneId) => {
|
||||
setGeneratingAudioId(sceneId);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user