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

@@ -1,23 +1,15 @@
import React, { useEffect, useState, useRef } from "react";
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, Button, CircularProgress, alpha } from "@mui/material";
import React, { useCallback } from "react";
import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
import {
PlayArrow as PlayArrowIcon,
ArrowBack as ArrowBackIcon,
VolumeUp as VolumeUpIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
Info as InfoIcon,
OpenInNew as OpenInNewIcon,
Download as DownloadIcon,
Share as ShareIcon,
Refresh as RefreshIcon,
Videocam as VideocamIcon,
Cancel as CancelIcon,
} from "@mui/icons-material";
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "./types";
import { podcastApi } from "../../services/podcastApi";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
import { InlineAudioPlayer } from "./InlineAudioPlayer";
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";
interface RenderQueueProps {
projectId: string;
@@ -27,307 +19,81 @@ interface RenderQueueProps {
budgetCap?: number;
avatarImageUrl?: string | null;
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
onUpdateScript?: (script: Script) => void;
onBack: () => void;
onError: (message: string) => void;
}
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral";
export const RenderQueue: React.FC<RenderQueueProps> = ({
projectId,
script,
knobs,
jobs,
budgetCap,
avatarImageUrl,
onUpdateJob,
onUpdateScript,
onBack,
onError,
}) => {
const {
rendering,
generatingImage,
isBusy,
runRender,
runImageGeneration,
runVideoRender,
} = useRenderQueue({
script,
jobs,
knobs,
projectId,
budgetCap,
avatarImageUrl,
onUpdateJob,
onUpdateScript,
onError,
});
export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, knobs, jobs, budgetCap, avatarImageUrl, onUpdateJob, onBack, onError }) => {
const [rendering, setRendering] = useState<string | null>(null);
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
const isBusy = Boolean(rendering);
// Cleanup polling intervals on unmount
useEffect(() => {
return () => {
pollingIntervals.current.forEach((interval) => clearInterval(interval));
pollingIntervals.current.clear();
};
const handleDownloadAudio = useCallback((audioUrl: string, title: string) => {
const link = document.createElement("a");
link.href = audioUrl;
link.download = `${title.replace(/\s+/g, "-")}.mp3`;
link.click();
}, []);
// Initialize jobs if empty
useEffect(() => {
if (jobs.length === 0 && script.scenes.length > 0) {
const initialJobs: Job[] = script.scenes.map((s) => ({
sceneId: s.id,
title: s.title,
status: "idle" as const,
progress: 0,
previewUrl: null,
finalUrl: null,
jobId: null,
}));
// Update all jobs at once
initialJobs.forEach((job) => {
onUpdateJob(job.sceneId, job);
});
}
}, [script.scenes.length, jobs.length, onUpdateJob]);
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 getScene = (sceneId: string) => script.scenes.find((s) => s.id === sceneId);
const pollTaskStatus = 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;
const updates: Partial<Job> = {
status: "completed",
progress: 100,
videoUrl: result.video_url,
cost: result.cost,
};
onUpdateJob(sceneId, updates);
// Clear polling interval
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 });
// Clear polling interval
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;
}
};
const startPolling = (taskId: string, sceneId: string) => {
// Clear any existing interval for this scene
const existingInterval = pollingIntervals.current.get(sceneId);
if (existingInterval) {
clearInterval(existingInterval);
}
// Poll every 3 seconds
const interval = setInterval(async () => {
const isComplete = await pollTaskStatus(taskId, sceneId);
if (isComplete) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
}, 3000);
pollingIntervals.current.set(sceneId, interval);
};
const cancelRender = async (sceneId: string) => {
const job = jobs.find((j) => j.sceneId === sceneId);
if (job?.taskId) {
const handleShare = useCallback(async (audioUrl: string, title: string) => {
if (navigator.share && audioUrl) {
try {
await podcastApi.cancelTask(job.taskId);
onUpdateJob(sceneId, { status: "cancelled", progress: 0 });
// Clear polling interval
const interval = pollingIntervals.current.get(sceneId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
} catch (error) {
console.error("Error cancelling task:", error);
onError("Failed to cancel render job");
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 runRender = async (sceneId: string, mode: "preview" | "full") => {
// Prevent double-fire while another render is in-flight
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;
// Estimate cost (rough estimate: ~$0.05 per 1000 chars)
const textLength = scene.lines.map((l) => l.text).join(" ").length;
const estimatedCost = (textLength / 1000) * 0.05;
// Check budget cap if provided
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: 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;
// Save to asset library when final render completes
try {
await podcastApi.saveAudioToAssetLibrary({
audioUrl: result.audioUrl,
filename: result.audioFilename,
title: `${script.scenes.find((s) => s.id === sceneId)?.title || "Scene"} - ${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);
// Don't fail the render if asset save fails
}
}
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);
}
};
const runVideoRender = async (sceneId: string) => {
// Prevent double-fire while another render is in-flight
if (rendering && rendering !== sceneId) return;
const scene = getScene(sceneId);
if (!scene) return;
if (!avatarImageUrl) {
onError("Avatar image is required for video generation. Please upload an avatar image in project settings.");
return;
}
const job = jobs.find((j) => j.sceneId === sceneId);
if (!job?.finalUrl) {
onError("Please generate audio first before creating video.");
return;
}
// Estimate cost (video generation is ~$0.30 per 5 seconds at 720p)
const estimatedCost = 0.30; // Base cost per video
// Check budget cap if provided
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: avatarImageUrl,
resolution: knobs.resolution || "720p",
});
// Start polling for video generation status
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);
}
};
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 />;
}
};
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));
return (
<Box sx={{ mt: 3 }}>
{/* Header */}
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script
@@ -349,204 +115,99 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, kno
</Typography>
</Stack>
{/* Info Alert */}
<Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2">
<strong>Audio Generation:</strong> Preview creates a quick sample to test voice and pacing. Full render generates the complete, production-ready audio file for your episode.
</Typography>
</Alert>
{/* Summary Stats */}
<SummaryStats jobs={jobs} scenes={script.scenes} />
{/* Empty State */}
{jobs.length === 0 && script.scenes.length === 0 && (
<Paper
sx={{
p: 4,
textAlign: "center",
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
border: "2px dashed rgba(102, 126, 234, 0.3)",
borderRadius: 2,
}}
>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, mb: 1 }}>
No scenes to render
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mb: 3 }}>
Go back to the script editor to generate and approve scenes first.
</Typography>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script Editor
</SecondaryButton>
</Paper>
)}
{/* Guidance Panel */}
{script.scenes.length > 0 && <GuidancePanel scenes={script.scenes} />}
{/* Scene Cards */}
<Stack spacing={2}>
{jobs.map((job) => {
const scene = getScene(job.sceneId);
const initials = job.title
.split(" ")
.slice(0, 2)
.map((s) => s[0])
.join("")
.toUpperCase();
{script.scenes.map((scene) => {
const job = jobs.find((j) => j.sceneId === scene.id);
return (
<GlassyCard key={job.sceneId} sx={glassyCardSx}>
<Stack spacing={2}>
<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 }}>
{job.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip label={`Scene ${job.sceneId.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>
)}
</Stack>
{job.finalUrl && (
<Button
size="small"
startIcon={<OpenInNewIcon />}
href={job.finalUrl}
target="_blank"
sx={{ mt: 1, color: "#a78bfa" }}
>
Download Final Audio
</Button>
)}
{job.videoUrl && (
<Button
size="small"
startIcon={<VideocamIcon />}
href={job.videoUrl}
target="_blank"
sx={{ mt: 1, ml: 1, color: "#a78bfa" }}
>
Download Video
</Button>
)}
</Box>
<Chip
icon={getStatusIcon(job.status)}
label={job.status.charAt(0).toUpperCase() + job.status.slice(1)}
color={getStatusColor(job.status)}
size="small"
sx={{
textTransform: "capitalize",
minWidth: 100,
}}
/>
</Stack>
{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(255,255,255,0.1)" }} />
<Stack direction="row" spacing={1} justifyContent="flex-end">
{job.status === "idle" && (
<>
<SecondaryButton
onClick={() => runRender(job.sceneId, "preview")}
disabled={isBusy}
startIcon={<VolumeUpIcon />}
tooltip="Preview a sample to test voice and pacing before generating the full episode"
>
Preview Sample
</SecondaryButton>
<PrimaryButton
onClick={() => runRender(job.sceneId, "full")}
disabled={isBusy}
startIcon={<PlayArrowIcon />}
tooltip="Generate the complete, production-ready audio for this scene"
>
Generate Audio
</PrimaryButton>
</>
)}
{job.status === "completed" && (job.previewUrl || job.finalUrl) && (
<Stack spacing={1} sx={{ width: "100%" }}>
<InlineAudioPlayer audioUrl={job.finalUrl || job.previewUrl || ""} title={job.title} />
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => {
const link = document.createElement("a");
link.href = job.finalUrl || job.previewUrl || "";
link.download = `${job.title.replace(/\s+/g, "-")}.mp3`;
link.click();
}}
>
Download
</Button>
<Button
size="small"
variant="outlined"
startIcon={<ShareIcon />}
onClick={async () => {
if (navigator.share && job.finalUrl) {
try {
await navigator.share({
title: job.title,
text: `Check out this podcast episode: ${job.title}`,
url: job.finalUrl,
});
} catch (err) {
// User cancelled or error
}
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(job.finalUrl || job.previewUrl || "");
alert("Audio URL copied to clipboard!");
}
}}
>
Share
</Button>
</Stack>
</Stack>
)}
{job.status === "failed" && (
<Button variant="outlined" color="warning" onClick={() => runRender(job.sceneId, "full")} startIcon={<RefreshIcon />}>
Retry
</Button>
)}
</Stack>
</Stack>
</GlassyCard>
<SceneCard
key={scene.id}
scene={scene}
job={job}
rendering={rendering}
generatingImage={generatingImage}
isBusy={isBusy}
avatarImageUrl={avatarImageUrl}
onRender={runRender}
onImageGenerate={runImageGeneration}
onVideoRender={runVideoRender}
onDownloadAudio={handleDownloadAudio}
onDownloadVideo={handleDownloadVideo}
onShare={handleShare}
onError={onError}
/>
);
})}
</Stack>
<Box sx={{ mt: 3, display: "flex", justifyContent: "flex-end" }}>
<SecondaryButton onClick={onBack}>Done</SecondaryButton>
</Box>
{/* 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>
{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 or download them.
</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>
);
};