Merge_PR_410_with_local_changes
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user