AI podcast maker performance optimizations

This commit is contained in:
ajaysi
2025-12-12 21:43:09 +05:30
parent 81590cf4db
commit eba5210577
46 changed files with 6176 additions and 1648 deletions

View File

@@ -0,0 +1,84 @@
import React from "react";
import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { Script } from "../types";
interface GuidancePanelProps {
scenes: Script["scenes"];
}
export const GuidancePanel: React.FC<GuidancePanelProps> = ({ scenes }) => {
const scenesNeedingAudio = scenes.filter((s) => !s.audioUrl).length;
const allScenesHaveAudio = scenes.length > 0 && scenesNeedingAudio === 0;
return (
<Paper
sx={{
p: 3,
mb: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
border: "2px solid rgba(102, 126, 234, 0.3)",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
}}
>
<Stack spacing={2}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1.5, fontSize: "1.125rem" }}>
<PlayArrowIcon sx={{ color: "#667eea", fontSize: "1.5rem" }} />
What's Next? Generate Audio for Your Scenes
</Typography>
<Stack spacing={1.5}>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7, fontSize: "0.9375rem" }}>
<strong>For each scene below:</strong>
</Typography>
<Box component="ul" sx={{ m: 0, pl: 2.5, color: "#475569" }}>
<Typography component="li" variant="body2" sx={{ mb: 1, lineHeight: 1.7 }}>
<strong>If audio is missing:</strong> Click <strong style={{ color: "#667eea" }}>"Generate Audio"</strong> to create the audio file for that scene
</Typography>
<Typography component="li" variant="body2" sx={{ mb: 1, lineHeight: 1.7 }}>
<strong>If audio exists:</strong> The scene is ready! You can download it or proceed to video generation
</Typography>
<Typography component="li" variant="body2" sx={{ lineHeight: 1.7 }}>
<strong>Optional:</strong> Use <strong>"Preview Sample"</strong> to test voice and pacing before full generation
</Typography>
</Box>
{scenesNeedingAudio > 0 && (
<Alert
severity="info"
sx={{
mt: 1,
background: alpha("#3b82f6", 0.1),
border: "1px solid rgba(59,130,246,0.3)",
"& .MuiAlert-icon": {
color: "#3b82f6",
},
}}
>
<Typography variant="body2" sx={{ color: "#1e40af", fontWeight: 600 }}>
📢 {scenesNeedingAudio} scene{scenesNeedingAudio !== 1 ? "s" : ""} need{scenesNeedingAudio === 1 ? "s" : ""} audio generation. Scroll down and click the <strong>"Generate Audio"</strong> buttons below!
</Typography>
</Alert>
)}
{allScenesHaveAudio && (
<Alert
severity="success"
sx={{
mt: 1,
background: alpha("#10b981", 0.1),
border: "1px solid rgba(16,185,129,0.3)",
"& .MuiAlert-icon": {
color: "#10b981",
},
}}
>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 600 }}>
All scenes have audio! Your podcast is ready. You can download individual scenes or proceed to video generation.
</Typography>
</Alert>
)}
</Stack>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,179 @@
import React from "react";
import { Stack } from "@mui/material";
import {
VolumeUp as VolumeUpIcon,
PlayArrow as PlayArrowIcon,
Refresh as RefreshIcon,
Image as ImageIcon,
Videocam as VideocamIcon,
Download as DownloadIcon,
Share as ShareIcon,
} from "@mui/icons-material";
import { Scene, Job } from "../types";
import { PrimaryButton, SecondaryButton } from "../ui";
interface SceneActionButtonsProps {
scene: Scene;
job?: Job;
hasAudio: boolean;
hasImage: boolean;
hasVideo: boolean;
audioUrl: string;
rendering: string | null;
generatingImage: string | null;
isBusy: boolean;
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;
onError: (message: string) => void;
}
export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
scene,
job,
hasAudio,
hasImage,
hasVideo,
audioUrl,
rendering,
generatingImage,
isBusy,
onRender,
onImageGenerate,
onVideoRender,
onDownloadAudio,
onDownloadVideo,
onShare,
onError,
}) => {
const isGeneratingImage = generatingImage === scene.id;
const needsAudio = !hasAudio && (!job || job.status === "idle");
// No audio - show generate buttons
if (needsAudio) {
return (
<Stack direction="row" spacing={1} justifyContent="flex-end">
<SecondaryButton
onClick={() => onRender(scene.id, "preview")}
disabled={isBusy}
startIcon={<VolumeUpIcon />}
tooltip="Preview a sample to test voice and pacing"
>
Preview Sample
</SecondaryButton>
<PrimaryButton
onClick={() => onRender(scene.id, "full")}
disabled={isBusy}
startIcon={<PlayArrowIcon />}
tooltip="Generate the complete, production-ready audio for this scene"
>
Generate Audio
</PrimaryButton>
</Stack>
);
}
// Failed - show retry
if (job?.status === "failed") {
return (
<Stack direction="row" spacing={1} justifyContent="flex-end">
<SecondaryButton
onClick={() => onRender(scene.id, "full")}
startIcon={<RefreshIcon />}
tooltip="Retry audio generation"
>
Retry
</SecondaryButton>
</Stack>
);
}
// Has audio - show all action buttons
return (
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
{/* Generate Image */}
<PrimaryButton
onClick={() => onImageGenerate(scene.id)}
disabled={isGeneratingImage || hasImage}
loading={isGeneratingImage}
startIcon={<ImageIcon />}
tooltip={
hasImage
? "Image already generated for this scene"
: isGeneratingImage
? "Generating image..."
: "Generate image for video (optional)"
}
sx={{ minWidth: 160 }}
>
{isGeneratingImage ? "Generating..." : hasImage ? "Image Ready" : "Generate Image"}
</PrimaryButton>
{/* Generate Video */}
<PrimaryButton
onClick={() => onVideoRender(scene.id)}
disabled={isBusy || !hasImage || hasVideo}
startIcon={<VideocamIcon />}
tooltip={
hasVideo
? "Video already generated"
: !hasImage
? "Generate an image first to create video"
: isBusy
? "Another operation in progress"
: "Generate video for this scene"
}
sx={{ minWidth: 160 }}
>
{hasVideo ? "Video Ready" : "Generate Video"}
</PrimaryButton>
{/* Download Video */}
{hasVideo && job?.videoUrl && (
<SecondaryButton
onClick={() => onDownloadVideo(job.videoUrl!, scene.title)}
startIcon={<VideocamIcon />}
tooltip="Download video file"
>
Download Video
</SecondaryButton>
)}
{/* Download Audio */}
<SecondaryButton
onClick={() => {
if (!audioUrl) {
onError("Audio URL not found. Please regenerate audio.");
return;
}
onDownloadAudio(audioUrl, scene.title);
}}
startIcon={<DownloadIcon />}
tooltip={hasAudio ? "Download this scene's audio file" : "No audio available. Generate audio first."}
disabled={!hasAudio}
>
Download Audio
</SecondaryButton>
{/* Share */}
<SecondaryButton
onClick={() => {
if (!audioUrl) {
onError("Audio URL not found. Please regenerate audio.");
return;
}
onShare(audioUrl, scene.title);
}}
startIcon={<ShareIcon />}
tooltip={hasAudio ? "Share this scene's audio" : "No audio available. Generate audio first."}
disabled={!hasAudio}
>
Share
</SecondaryButton>
</Stack>
);
};

View File

@@ -0,0 +1,415 @@
import React, { useState, useEffect } from "react";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha } from "@mui/material";
import {
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
Info as InfoIcon,
OpenInNew as OpenInNewIcon,
Videocam as VideocamIcon,
} from "@mui/icons-material";
import { Scene, Job } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { SceneActionButtons } from "./SceneActionButtons";
import { aiApiClient } from "../../../api/client";
interface SceneCardProps {
scene: Scene;
job?: Job;
rendering: string | null;
generatingImage: string | null;
isBusy: boolean;
avatarImageUrl?: string | null;
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;
onError: (message: string) => void;
}
const getInitials = (title: string): string => {
return title
.split(" ")
.slice(0, 2)
.map((s) => s[0])
.join("")
.toUpperCase();
};
const getStatusColor = (status: Job["status"]) => {
switch (status) {
case "completed":
return "success";
case "failed":
return "error";
case "running":
case "previewing":
return "info";
default:
return "default";
}
};
const getStatusIcon = (status: Job["status"]) => {
switch (status) {
case "completed":
return <CheckCircleIcon />;
case "failed":
return <InfoIcon />;
case "running":
case "previewing":
return <CircularProgress size={16} />;
default:
return <RadioButtonUncheckedIcon />;
}
};
export const SceneCard: React.FC<SceneCardProps> = ({
scene,
job,
rendering,
generatingImage,
isBusy,
avatarImageUrl,
onRender,
onImageGenerate,
onVideoRender,
onDownloadAudio,
onDownloadVideo,
onShare,
onError,
}) => {
const hasAudio = Boolean(scene.audioUrl || job?.finalUrl || job?.previewUrl);
const hasImage = Boolean(scene.imageUrl || job?.imageUrl);
const hasVideo = Boolean(job?.videoUrl);
const audioUrl = job?.finalUrl || job?.previewUrl || scene.audioUrl || "";
const imageUrl = job?.imageUrl || scene.imageUrl || "";
const status = job?.status || (hasAudio ? "completed" : "idle");
const initials = getInitials(scene.title);
// Load image as blob if it's an authenticated endpoint
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
useEffect(() => {
if (!imageUrl) {
setImageBlobUrl(null);
return;
}
console.log('[SceneCard] Loading image:', { imageUrl, hasImage, sceneId: scene.id });
// Check if this is a podcast image endpoint that requires authentication
const isPodcastImage = imageUrl.includes('/api/podcast/images/') || imageUrl.includes('/api/story/images/');
if (!isPodcastImage) {
// Regular URL (external), use directly
console.log('[SceneCard] Using external image URL directly');
setImageBlobUrl(imageUrl);
return;
}
// Fetch as blob for authenticated endpoints
let isMounted = true;
const currentImageUrl = imageUrl;
const loadImageBlob = async () => {
try {
// Normalize path
let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
// Convert /api/story/images/ to /api/podcast/images/ if needed
if (imagePath.includes('/api/story/images/')) {
const filename = imagePath.split('/api/story/images/').pop() || '';
imagePath = `/api/podcast/images/${filename}`;
}
// Ensure it's a podcast image endpoint
if (!imagePath.includes('/api/podcast/images/')) {
const filename = imagePath.split('/').pop() || currentImageUrl;
imagePath = `/api/podcast/images/${filename}`;
}
// Remove query parameters if present
imagePath = imagePath.split('?')[0];
console.log('[SceneCard] Fetching image blob from:', imagePath);
const response = await aiApiClient.get(imagePath, {
responseType: 'blob',
});
if (!isMounted || imageUrl !== currentImageUrl) {
console.log('[SceneCard] Component unmounted or URL changed, skipping blob URL set');
return;
}
const blob = response.data;
const newBlobUrl = URL.createObjectURL(blob);
console.log('[SceneCard] Image blob loaded successfully, created blob URL');
setImageBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== newBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return newBlobUrl;
});
} catch (err) {
console.error('[SceneCard] Failed to load image blob:', err);
if (isMounted && imageUrl === currentImageUrl) {
// Try adding query token as fallback
try {
// Normalize path again for fallback
let fallbackPath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
// Convert /api/story/images/ to /api/podcast/images/ if needed
if (fallbackPath.includes('/api/story/images/')) {
const filename = fallbackPath.split('/api/story/images/').pop() || '';
fallbackPath = `/api/podcast/images/${filename}`;
}
// Ensure it's a podcast image endpoint
if (!fallbackPath.includes('/api/podcast/images/')) {
const filename = fallbackPath.split('/').pop() || currentImageUrl;
fallbackPath = `/api/podcast/images/${filename}`;
}
// Remove query parameters if present
fallbackPath = fallbackPath.split('?')[0];
// Get auth token from localStorage or use aiApiClient's default token
const token = localStorage.getItem('clerk_dashboard_token') || '';
if (token) {
const urlWithToken = `${fallbackPath}?token=${encodeURIComponent(token)}`;
console.log('[SceneCard] Trying URL with query token');
setImageBlobUrl(urlWithToken);
} else {
// Fallback to original URL
console.log('[SceneCard] No token available, using original URL');
setImageBlobUrl(imageUrl);
}
} catch (fallbackErr) {
console.error('[SceneCard] Fallback also failed:', fallbackErr);
setImageBlobUrl(imageUrl);
}
}
}
};
loadImageBlob();
return () => {
isMounted = false;
// Cleanup blob URL when component unmounts or URL changes
setImageBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
};
}, [imageUrl, hasImage, scene.id]);
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
{/* Header */}
<Stack direction="row" spacing={2} alignItems="flex-start">
<Paper
sx={{
width: 56,
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha("#667eea", 0.2),
border: "1px solid rgba(102,126,234,0.3)",
fontWeight: 700,
fontSize: "1.2rem",
}}
>
{initials}
</Paper>
<Box flex={1}>
<Typography variant="h6" sx={{ mb: 0.5, color: "#0f172a", fontWeight: 600 }}>
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip label={`Scene ${scene.id.slice(-4)}`} size="small" variant="outlined" />
{job?.cost != null && (
<Chip
label={`$${job.cost.toFixed(2)}`}
size="small"
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
title="Generation cost"
/>
)}
{job?.fileSize && (
<Typography variant="caption" color="text.secondary">
{(job.fileSize / 1024).toFixed(1)} KB
</Typography>
)}
{!job && (
<Chip
label={hasAudio ? "Audio Ready" : "Needs Audio"}
size="small"
color={hasAudio ? "success" : "warning"}
sx={{
background: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
color: hasAudio ? "#059669" : "#d97706",
fontWeight: 600,
}}
/>
)}
</Stack>
{job?.finalUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={job.finalUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<OpenInNewIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">Download Final Audio</Typography>
</Box>
</Box>
)}
{hasVideo && job?.videoUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={job.videoUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<VideocamIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">Download Video</Typography>
</Box>
</Box>
)}
</Box>
{job && (
<Chip
icon={getStatusIcon(status)}
label={status.charAt(0).toUpperCase() + status.slice(1)}
color={getStatusColor(status)}
size="small"
sx={{
textTransform: "capitalize",
minWidth: 100,
}}
/>
)}
</Stack>
{/* Progress Bar */}
{job && job.status !== "idle" && job.status !== "completed" && (
<Box>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Progress
</Typography>
<Typography variant="caption" color="text.secondary">
{job.progress}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={job.progress}
sx={{
height: 8,
borderRadius: 4,
background: alpha("#fff", 0.1),
"& .MuiLinearProgress-bar": {
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
},
}}
/>
</Box>
)}
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} />
{/* Success Alert for Pre-generated Audio */}
{hasAudio && !job && (
<Alert severity="success" sx={{ width: "100%", background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)" }}>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
Audio already generated in Script Editor. Ready to use!
</Typography>
</Alert>
)}
{/* Audio Player */}
{hasAudio && audioUrl && (
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
)}
{/* Image Preview */}
{hasImage && (imageBlobUrl || imageUrl) && (
<Box
sx={{
width: "100%",
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
}}
>
<Box
component="img"
src={imageBlobUrl || imageUrl}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "cover",
}}
onError={(e) => {
console.error('[SceneCard] Image failed to load:', {
src: e.currentTarget.src,
imageUrl,
imageBlobUrl,
hasImage,
});
}}
onLoad={() => {
console.log('[SceneCard] Image loaded successfully:', {
src: imageBlobUrl || imageUrl,
});
}}
/>
</Box>
)}
{/* Action Buttons */}
<SceneActionButtons
scene={scene}
job={job}
hasAudio={hasAudio}
hasImage={hasImage}
hasVideo={hasVideo}
audioUrl={audioUrl}
rendering={rendering}
generatingImage={generatingImage}
isBusy={isBusy}
onRender={onRender}
onImageGenerate={onImageGenerate}
onVideoRender={onVideoRender}
onDownloadAudio={onDownloadAudio}
onDownloadVideo={onDownloadVideo}
onShare={onShare}
onError={onError}
/>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,71 @@
import React from "react";
import { Box, Stack, Typography, Paper } from "@mui/material";
import { Script, Job } from "../types";
interface SummaryStatsProps {
jobs: Job[];
scenes: Script["scenes"];
}
export const SummaryStats: React.FC<SummaryStatsProps> = ({ jobs, scenes }) => {
const totalScenes = jobs.length > 0 ? jobs.length : scenes.length;
const readyToRender = jobs.length > 0
? jobs.filter((j) => j.status === "idle").length
: scenes.filter((s) => !s.audioUrl).length;
const completed = jobs.length > 0
? jobs.filter((j) => j.status === "completed").length
: scenes.filter((s) => s.audioUrl).length;
const inProgress = jobs.length > 0
? jobs.filter((j) => j.status === "running" || j.status === "previewing").length
: 0;
if (totalScenes === 0) return null;
return (
<Paper
sx={{
p: 2.5,
mb: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={3} flexWrap="wrap" useFlexGap>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
Total Scenes
</Typography>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700 }}>
{totalScenes}
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
Ready to Render
</Typography>
<Typography variant="h6" sx={{ color: "#667eea", fontWeight: 700 }}>
{readyToRender}
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
Completed
</Typography>
<Typography variant="h6" sx={{ color: "#10b981", fontWeight: 700 }}>
{completed}
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
In Progress
</Typography>
<Typography variant="h6" sx={{ color: "#3b82f6", fontWeight: 700 }}>
{inProgress}
</Typography>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,6 @@
export { SceneCard } from "./SceneCard";
export { SceneActionButtons } from "./SceneActionButtons";
export { SummaryStats } from "./SummaryStats";
export { GuidancePanel } from "./GuidancePanel";
export { useRenderQueue } from "./useRenderQueue";

View File

@@ -0,0 +1,376 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "../types";
import { podcastApi } from "../../../services/podcastApi";
interface UseRenderQueueProps {
script: Script;
jobs: Job[];
knobs: Knobs;
projectId: string;
budgetCap?: number;
avatarImageUrl?: string | null;
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
onUpdateScript?: (script: Script) => void;
onError: (message: string) => void;
}
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral";
export const useRenderQueue = ({
script,
jobs,
knobs,
projectId,
budgetCap,
avatarImageUrl,
onUpdateJob,
onUpdateScript,
onError,
}: UseRenderQueueProps) => {
const [rendering, setRendering] = useState<string | null>(null);
const [generatingImage, setGeneratingImage] = useState<string | null>(null);
const [combiningAudio, setCombiningAudio] = useState(false);
const [combinedAudioResult, setCombinedAudioResult] = useState<{
url: string;
filename: string;
duration: number;
sceneCount: number;
} | null>(null);
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Cleanup polling intervals on unmount
useEffect(() => {
const intervals = pollingIntervals.current;
return () => {
intervals.forEach((interval) => clearInterval(interval));
intervals.clear();
};
}, []);
// Initialize jobs if empty
useEffect(() => {
if (jobs.length === 0 && script.scenes.length > 0) {
const initialJobs: Job[] = script.scenes.map((s) => {
const hasExistingAudio = Boolean(s.audioUrl);
return {
sceneId: s.id,
title: s.title,
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
progress: hasExistingAudio ? 100 : 0,
previewUrl: null,
finalUrl: hasExistingAudio ? s.audioUrl || null : null,
imageUrl: s.imageUrl || null, // Include existing imageUrl from scene
jobId: null,
};
});
initialJobs.forEach((job) => {
onUpdateJob(job.sceneId, job);
});
}
}, [script.scenes.length, jobs.length, onUpdateJob, script.scenes]);
const getScene = useCallback((sceneId: string) => script.scenes.find((s) => s.id === sceneId), [script.scenes]);
const pollTaskStatus = useCallback(async (taskId: string, sceneId: string) => {
try {
const status: TaskStatus = await podcastApi.pollTaskStatus(taskId);
onUpdateJob(sceneId, {
progress: status.progress ?? 0,
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
});
if (status.status === "completed" && status.result) {
const result = status.result;
onUpdateJob(sceneId, {
status: "completed",
progress: 100,
videoUrl: result.video_url,
cost: result.cost,
});
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
} else if (status.status === "failed") {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
onError(status.error || "Video generation failed");
}
return status.status === "completed" || status.status === "failed";
} catch (error) {
console.error("Error polling task status:", error);
return false;
}
}, [onUpdateJob, onError]);
const startPolling = useCallback((taskId: string, sceneId: string) => {
const existingInterval = pollingIntervals.current.get(sceneId);
if (existingInterval) {
clearInterval(existingInterval);
}
const interval = setInterval(async () => {
const isComplete = await pollTaskStatus(taskId, sceneId);
if (isComplete) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
}, 3000);
pollingIntervals.current.set(sceneId, interval);
}, [pollTaskStatus]);
const runRender = useCallback(async (sceneId: string, mode: "preview" | "full") => {
if (rendering && rendering !== sceneId) return;
const job = jobs.find((j) => j.sceneId === sceneId);
if (job && job.status !== "idle") return;
const scene = getScene(sceneId);
if (!scene) return;
const textLength = scene.lines.map((l) => l.text).join(" ").length;
const estimatedCost = (textLength / 1000) * 0.05;
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(4)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: mode === "preview" ? "previewing" : "running",
progress: mode === "preview" ? 25 : 40,
});
try {
const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene,
voiceId: "Wise_Woman",
emotion: scene.emotion || getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed,
});
const updates: Partial<Job> = {
status: "completed",
progress: 100,
cost: result.cost,
provider: result.provider,
voiceId: result.voiceId,
fileSize: result.fileSize,
};
if (mode === "preview") {
updates.previewUrl = result.audioUrl;
window.open(result.audioUrl, "_blank");
} else {
updates.finalUrl = result.audioUrl;
try {
await podcastApi.saveAudioToAssetLibrary({
audioUrl: result.audioUrl,
filename: result.audioFilename,
title: `${scene.title} - ${projectId}`,
description: `Podcast episode scene audio: ${scene.title}`,
projectId,
sceneId,
cost: result.cost,
provider: result.provider,
model: result.model,
fileSize: result.fileSize,
});
} catch (assetError) {
console.error("Failed to save to asset library:", assetError);
}
}
onUpdateJob(sceneId, updates);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Render failed";
onError(message);
} finally {
setRendering(null);
}
}, [rendering, jobs, getScene, knobs, budgetCap, projectId, onUpdateJob, onError]);
const runImageGeneration = useCallback(async (sceneId: string) => {
if (generatingImage && generatingImage !== sceneId) return;
const scene = getScene(sceneId);
if (!scene) return;
setGeneratingImage(sceneId);
try {
const sceneContent = scene.lines.map((line) => line.text).join(" ");
const result = await podcastApi.generateSceneImage({
sceneId: scene.id,
sceneTitle: scene.title,
sceneContent: sceneContent,
width: 1024,
height: 1024,
});
// Update job with image URL
onUpdateJob(sceneId, {
imageUrl: result.image_url,
});
// Also update the scene's imageUrl so it persists
if (onUpdateScript) {
const updatedScenes = script.scenes.map((s) =>
s.id === sceneId ? { ...s, imageUrl: result.image_url } : s
);
onUpdateScript({ ...script, scenes: updatedScenes });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Image generation failed";
onError(message);
} finally {
setGeneratingImage(null);
}
}, [generatingImage, getScene, onUpdateJob, onError]);
const runVideoRender = useCallback(async (sceneId: string) => {
if (rendering && rendering !== sceneId) return;
const scene = getScene(sceneId);
if (!scene) return;
const sceneImageUrl = scene.imageUrl || avatarImageUrl;
if (!sceneImageUrl) {
onError("Scene image is required for video generation. Please generate images for scenes first.");
return;
}
const job = jobs.find((j) => j.sceneId === sceneId);
if (!job?.finalUrl) {
onError("Please generate audio first before creating video.");
return;
}
const estimatedCost = 0.30;
if (budgetCap && budgetCap > 0) {
const currentSpent = jobs
.filter((j) => j.status === "completed" && j.cost)
.reduce((sum, j) => sum + (j.cost || 0), 0);
if (currentSpent + estimatedCost > budgetCap) {
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(2)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: "running",
progress: 5,
});
try {
const result = await podcastApi.generateVideo({
projectId,
sceneId,
sceneTitle: scene.title,
audioUrl: job.finalUrl,
avatarImageUrl: sceneImageUrl,
resolution: knobs.resolution || "720p",
});
onUpdateJob(sceneId, {
taskId: result.taskId,
status: "running",
progress: 5,
});
startPolling(result.taskId, sceneId);
} catch (error) {
onUpdateJob(sceneId, { status: "failed", progress: 0 });
const message = error instanceof Error ? error.message : "Video generation failed";
onError(message);
} finally {
setRendering(null);
}
}, [rendering, getScene, avatarImageUrl, jobs, budgetCap, projectId, knobs, onUpdateJob, onError, startPolling]);
const combineAudio = useCallback(async () => {
try {
setCombiningAudio(true);
const sceneIds: string[] = [];
const sceneAudioUrls: string[] = [];
script.scenes.forEach((scene) => {
if (scene.audioUrl) {
// Ensure we're using the correct URL format (not blob URLs)
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
if (audioUrl) {
sceneIds.push(scene.id);
sceneAudioUrls.push(audioUrl);
}
}
});
jobs.forEach((job) => {
// Prefer finalUrl over previewUrl, and ensure it's not a blob URL
const audioUrl = job.finalUrl || job.previewUrl;
if (audioUrl && !audioUrl.startsWith('blob:') && !sceneAudioUrls.includes(audioUrl)) {
sceneIds.push(job.sceneId);
sceneAudioUrls.push(audioUrl);
}
});
if (sceneIds.length === 0) {
onError("No audio files found to combine.");
return;
}
const result = await podcastApi.combineAudio({
projectId,
sceneIds,
sceneAudioUrls,
});
// Store combined audio result for preview
setCombinedAudioResult({
url: result.combined_audio_url,
filename: result.combined_audio_filename,
duration: result.total_duration,
sceneCount: result.scene_count,
});
// Auto-download the combined audio
const link = document.createElement("a");
link.href = result.combined_audio_url;
link.download = `podcast-episode-${projectId.slice(-8)}.mp3`;
link.click();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to combine audio";
onError(`Failed to combine audio: ${message}`);
} finally {
setCombiningAudio(false);
}
}, [script.scenes, jobs, projectId, onError]);
return {
rendering,
generatingImage,
combiningAudio,
combinedAudioResult,
isBusy: Boolean(rendering),
runRender,
runImageGeneration,
runVideoRender,
combineAudio,
};
};