Files
ALwrity/frontend/src/components/PodcastMaker/RenderQueue.tsx
2026-03-12 15:21:08 +05:30

586 lines
20 KiB
TypeScript

import React, { useCallback, useState, useEffect } from "react";
import { Box, Stack, Typography, Alert, Paper, alpha, Button, CircularProgress, LinearProgress } from "@mui/material";
import {
PlayArrow as PlayArrowIcon,
ArrowBack as ArrowBackIcon,
VideoLibrary as VideoLibraryIcon,
Download as DownloadIcon,
CheckCircle as CheckCircleIcon,
} from "@mui/icons-material";
import { Script, Knobs, Job } from "./types";
import { SecondaryButton } from "./ui";
import { SceneCard } from "./RenderQueue/SceneCard";
import { SummaryStats } from "./RenderQueue/SummaryStats";
import { GuidancePanel } from "./RenderQueue/GuidancePanel";
import { useRenderQueue } from "./RenderQueue/useRenderQueue";
import { fetchMediaBlobUrl } from "../../utils/fetchMediaBlobUrl";
interface RenderQueueProps {
projectId: string;
script: Script;
knobs: Knobs;
jobs: Job[];
bible?: any | null;
budgetCap?: number;
avatarImageUrl?: string | null;
analysis?: any | null; // Add analysis prop
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
onUpdateScript?: (script: Script) => void;
onBack: () => void;
onError: (message: string) => void;
}
export const RenderQueue: React.FC<RenderQueueProps> = ({
projectId,
script,
knobs,
jobs,
bible,
budgetCap,
avatarImageUrl,
analysis,
onUpdateJob,
onUpdateScript,
onBack,
onError,
}) => {
const [localError, setLocalError] = useState<string>("");
const {
rendering,
generatingImage,
isBusy,
runRender,
runImageGeneration,
runVideoRender,
combiningVideos,
combiningProgress,
finalVideoUrl,
combineFinalVideo,
deleteScene,
} = useRenderQueue({
script,
jobs,
knobs,
projectId,
bible,
budgetCap,
avatarImageUrl,
onUpdateJob,
onUpdateScript,
onError: (msg) => {
setLocalError(msg);
onError(msg);
},
});
const handleDownloadAudio = useCallback((audioUrl: string, title: string) => {
const link = document.createElement("a");
link.href = audioUrl;
link.download = `${title.replace(/\s+/g, "-")}.mp3`;
link.click();
}, []);
const handleDownloadVideo = useCallback((videoUrl: string, title: string) => {
const link = document.createElement("a");
link.href = videoUrl;
link.download = `${title.replace(/\s+/g, "-")}.mp4`;
link.click();
}, []);
const handleShare = useCallback(async (audioUrl: string, title: string) => {
if (navigator.share && audioUrl) {
try {
await navigator.share({
title,
text: `Check out this podcast episode: ${title}`,
url: audioUrl,
});
} catch (err) {
// User cancelled or error
}
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(audioUrl);
alert("Audio URL copied to clipboard!");
}
}, []);
const allScenesCompleted =
(jobs.length > 0 && jobs.every((j) => j.status === "completed" && j.imageUrl)) ||
(script.scenes.length > 0 && script.scenes.every((s) => s.audioUrl && s.imageUrl));
const allVideosReady = jobs.length > 0 && jobs.every((j) => j.videoUrl);
// State for final video blob URL
const [finalVideoBlobUrl, setFinalVideoBlobUrl] = useState<string | null>(null);
// Load final video as blob when URL changes
useEffect(() => {
if (finalVideoUrl) {
fetchMediaBlobUrl(finalVideoUrl)
.then((blobUrl) => {
if (blobUrl) {
setFinalVideoBlobUrl(blobUrl);
}
})
.catch((err) => {
console.error("Failed to load final video blob:", err);
});
} else {
setFinalVideoBlobUrl(null);
}
}, [finalVideoUrl]);
return (
<Box sx={{ mt: 3 }}>
{/* Header */}
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script
</SecondaryButton>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<PlayArrowIcon />
Render Queue
</Typography>
</Stack>
{/* Error Display */}
{localError && (
<Alert
severity="error"
onClose={() => setLocalError("")}
sx={{
mb: 3,
background: alpha("#ef4444", 0.1),
border: "1px solid",
borderColor: alpha("#ef4444", 0.3),
}}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{localError}
</Typography>
</Alert>
)}
{/* Compact Status Dashboard */}
<Paper
elevation={0}
sx={{
mb: 3,
p: 2,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 3,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
boxShadow: "0 2px 8px rgba(0,0,0,0.02)",
}}
>
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap" useFlexGap>
{/* Status Chips */}
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: alpha("#6366f1", 0.08),
color: "#4f46e5",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: alpha("#6366f1", 0.2),
}}
>
<Typography variant="caption" fontWeight={700} sx={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
Scenes
</Typography>
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.length}
</Typography>
</Box>
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: script.scenes.every(s => s.audioUrl)
? alpha("#10b981", 0.1)
: alpha("#f59e0b", 0.1),
color: script.scenes.every(s => s.audioUrl) ? "#059669" : "#d97706",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: script.scenes.every(s => s.audioUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
}}
>
<Typography variant="caption" fontWeight={700}>
Audio
</Typography>
{script.scenes.every(s => s.audioUrl) ? (
<CheckCircleIcon sx={{ fontSize: 18 }} />
) : (
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.filter(s => s.audioUrl).length}/{script.scenes.length}
</Typography>
)}
</Box>
<Box
sx={{
px: 1.5,
py: 0.75,
borderRadius: 2,
background: script.scenes.every(s => s.imageUrl)
? alpha("#10b981", 0.1)
: alpha("#f59e0b", 0.1),
color: script.scenes.every(s => s.imageUrl) ? "#059669" : "#d97706",
display: "flex",
alignItems: "center",
gap: 1,
border: "1px solid",
borderColor: script.scenes.every(s => s.imageUrl) ? alpha("#10b981", 0.3) : alpha("#f59e0b", 0.3),
}}
>
<Typography variant="caption" fontWeight={700}>
Images
</Typography>
{script.scenes.every(s => s.imageUrl) ? (
<CheckCircleIcon sx={{ fontSize: 18 }} />
) : (
<Typography variant="subtitle2" fontWeight={800}>
{script.scenes.filter(s => s.imageUrl).length}/{script.scenes.length}
</Typography>
)}
</Box>
</Box>
{/* Dynamic Guidance Message */}
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 500, display: "flex", alignItems: "center", gap: 1 }}>
<Box component="span" sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: allVideosReady ? "#10b981" : "#3b82f6",
display: "inline-block"
}} />
{allVideosReady
? "All assets ready. You can combine videos below."
: !script.scenes.every(s => s.audioUrl)
? "Generate audio for all scenes to proceed."
: !script.scenes.every(s => s.imageUrl)
? "Generate images for video backgrounds."
: "Ready to generate scene videos."}
</Typography>
</Stack>
</Paper>
{/* Scene Cards */}
<Stack spacing={2}>
{script.scenes.map((scene) => {
const job = jobs.find((j) => j.sceneId === scene.id);
return (
<SceneCard
key={scene.id}
scene={scene}
job={job}
rendering={rendering}
generatingImage={generatingImage}
isBusy={isBusy}
totalScenes={script.scenes.length}
avatarImageUrl={avatarImageUrl}
bible={bible}
analysis={analysis}
onRender={runRender}
onImageGenerate={runImageGeneration}
onVideoGenerate={(sceneId, settings) => runVideoRender(sceneId, settings)}
onDownloadAudio={handleDownloadAudio}
onDownloadVideo={handleDownloadVideo}
onShare={handleShare}
onDelete={deleteScene}
onError={onError}
/>
);
})}
</Stack>
{/* Final Export Section - Show when all scene videos are ready */}
{allVideosReady && (
<Paper
elevation={3}
sx={{
mt: 4,
p: 4,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(6, 182, 212, 0.05) 100%)",
border: "2px solid",
borderColor: finalVideoUrl ? "success.main" : "info.light",
borderRadius: 3,
position: "relative",
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "4px",
background: finalVideoUrl
? "linear-gradient(90deg, #10b981 0%, #06b6d4 100%)"
: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
},
}}
>
<Stack spacing={3}>
{/* Header */}
<Stack direction="row" alignItems="center" spacing={2}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
background: finalVideoUrl
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.3)",
}}
>
{finalVideoUrl ? (
<CheckCircleIcon sx={{ color: "white", fontSize: 32 }} />
) : (
<VideoLibraryIcon sx={{ color: "white", fontSize: 32 }} />
)}
</Box>
<Box>
<Typography
variant="h5"
sx={{
fontWeight: 700,
color: "#0f172a",
mb: 0.5,
}}
>
{finalVideoUrl ? "🎉 Final Podcast Ready!" : "🎬 Final Podcast Export"}
</Typography>
<Typography variant="body2" sx={{ color: "#64748b" }}>
{finalVideoUrl
? "Your complete podcast video is ready to download"
: `Combine ${script.scenes.length} scene videos into one final podcast`}
</Typography>
</Box>
</Stack>
{finalVideoUrl ? (
<Stack spacing={3}>
<Alert
severity="success"
icon={<CheckCircleIcon />}
sx={{
background: alpha("#10b981", 0.1),
border: "1px solid",
borderColor: alpha("#10b981", 0.3),
}}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Your final podcast video has been created successfully!
</Typography>
</Alert>
{/* Video Preview */}
<Box
sx={{
width: "100%",
maxWidth: 900,
mx: "auto",
borderRadius: 2,
overflow: "hidden",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
border: "1px solid",
borderColor: alpha("#10b981", 0.2),
}}
>
<video
controls
src={finalVideoBlobUrl || finalVideoUrl}
style={{
width: "100%",
display: "block",
backgroundColor: "#000",
}}
>
Your browser does not support video playback.
</video>
</Box>
{/* Download Button */}
<Stack direction="row" spacing={2} justifyContent="center" sx={{ pt: 2 }}>
<Button
variant="contained"
size="large"
startIcon={<DownloadIcon />}
onClick={async () => {
if (finalVideoBlobUrl) {
const link = document.createElement("a");
link.href = finalVideoBlobUrl;
link.download = `podcast-final-${Date.now()}.mp4`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}}
sx={{
px: 4,
py: 1.5,
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.4)",
"&:hover": {
background: "linear-gradient(135deg, #059669 0%, #047857 100%)",
boxShadow: "0 6px 16px rgba(16, 185, 129, 0.5)",
},
}}
>
Download Final Podcast
</Button>
</Stack>
</Stack>
) : (
<Stack spacing={3}>
<Alert
severity="info"
sx={{
background: alpha("#3b82f6", 0.08),
border: "1px solid",
borderColor: alpha("#3b82f6", 0.2),
}}
>
<Typography variant="body2">
<strong>Ready to export!</strong> Click below to combine all {script.scenes.length} scene videos into your final podcast video.
</Typography>
</Alert>
{combiningVideos && (
<Box sx={{ width: "100%" }}>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: "#0f172a" }}>
{combiningProgress?.message || "Combining videos..."}
</Typography>
{combiningProgress && (
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600 }}>
{combiningProgress.progress.toFixed(0)}%
</Typography>
)}
</Stack>
<LinearProgress
variant={combiningProgress ? "determinate" : "indeterminate"}
value={combiningProgress?.progress || 0}
sx={{
height: 8,
borderRadius: 4,
background: alpha("#667eea", 0.1),
"& .MuiLinearProgress-bar": {
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
borderRadius: 4,
},
}}
/>
{combiningProgress && combiningProgress.progress < 100 && (
<Typography variant="caption" sx={{ color: "#64748b", mt: 0.5, display: "block" }}>
Video encoding in progress. This may take a few minutes...
</Typography>
)}
</Box>
)}
<Button
variant="contained"
size="large"
fullWidth
startIcon={combiningVideos ? <CircularProgress size={20} sx={{ color: "white" }} /> : <VideoLibraryIcon />}
onClick={combineFinalVideo}
disabled={combiningVideos}
sx={{
py: 2,
fontSize: "1.1rem",
fontWeight: 700,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.4)",
"&:hover": {
background: "linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%)",
boxShadow: "0 6px 16px rgba(102, 126, 234, 0.5)",
},
"&:disabled": {
background: alpha("#667eea", 0.5),
},
}}
>
{combiningVideos ? "Combining Videos..." : "Combine Scenes into Final Video"}
</Button>
</Stack>
)}
</Stack>
</Paper>
)}
{/* Footer - Video Generation Focus */}
<Paper
sx={{
mt: 4,
p: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 2,
}}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" spacing={2}>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script
</SecondaryButton>
{allVideosReady ? (
<Stack spacing={1} alignItems="flex-end">
<Typography variant="body1" sx={{ color: "#10b981", fontWeight: 700, fontSize: "1rem" }}>
🎉 All scene videos ready!
</Typography>
<Typography variant="body2" sx={{ color: "#64748b" }}>
Scroll up to combine them into your final podcast video.
</Typography>
</Stack>
) : allScenesCompleted ? (
<Stack spacing={1} alignItems="flex-end">
<Typography variant="body1" sx={{ color: "#10b981", fontWeight: 700, fontSize: "1rem" }}>
🎉 All scenes ready for video generation!
</Typography>
<Typography variant="body2" sx={{ color: "#64748b" }}>
Generate videos for individual scenes above.
</Typography>
</Stack>
) : (
<Typography variant="body2" sx={{ color: "#64748b" }}>
Complete audio and image generation for all scenes to enable video generation.
</Typography>
)}
</Stack>
</Stack>
</Paper>
</Box>
);
};