import React, { useEffect, useState, useRef } from "react"; import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, Button, CircularProgress, 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"; interface RenderQueueProps { projectId: string; script: Script; knobs: Knobs; jobs: Job[]; budgetCap?: number; avatarImageUrl?: string | null; onUpdateJob: (sceneId: string, updates: Partial) => void; onBack: () => void; onError: (message: string) => void; } const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral"; export const RenderQueue: React.FC = ({ projectId, script, knobs, jobs, budgetCap, avatarImageUrl, onUpdateJob, onBack, onError }) => { const [rendering, setRendering] = useState(null); const pollingIntervals = useRef>(new Map()); const isBusy = Boolean(rendering); // Cleanup polling intervals on unmount useEffect(() => { return () => { pollingIntervals.current.forEach((interval) => clearInterval(interval)); pollingIntervals.current.clear(); }; }, []); // 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 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 = { 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) { 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"); } } }; 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 = { 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 ; case "failed": return ; case "running": case "previewing": return ; default: return ; } }; return ( }> Back to Script Render Queue Audio Generation: Preview creates a quick sample to test voice and pacing. Full render generates the complete, production-ready audio file for your episode. {jobs.map((job) => { const scene = getScene(job.sceneId); const initials = job.title .split(" ") .slice(0, 2) .map((s) => s[0]) .join("") .toUpperCase(); return ( {initials} {job.title} {job.cost != null && ( )} {job.fileSize && ( {(job.fileSize / 1024).toFixed(1)} KB )} {job.finalUrl && ( )} {job.videoUrl && ( )} {job.status !== "idle" && job.status !== "completed" && ( Progress {job.progress}% )} {job.status === "idle" && ( <> runRender(job.sceneId, "preview")} disabled={isBusy} startIcon={} tooltip="Preview a sample to test voice and pacing before generating the full episode" > Preview Sample runRender(job.sceneId, "full")} disabled={isBusy} startIcon={} tooltip="Generate the complete, production-ready audio for this scene" > Generate Audio )} {job.status === "completed" && (job.previewUrl || job.finalUrl) && ( )} {job.status === "failed" && ( )} ); })} Done ); };