Merge_PR_410_with_local_changes
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ __pycache__/
|
|||||||
workspace/
|
workspace/
|
||||||
workspace/*
|
workspace/*
|
||||||
|
|
||||||
|
.opencode
|
||||||
|
|
||||||
|
|
||||||
.trae/
|
.trae/
|
||||||
/backend/database/migrations/*
|
/backend/database/migrations/*
|
||||||
/backend/.db
|
/backend/.db
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user