AI podcast project

This commit is contained in:
ajaysi
2025-12-16 16:25:52 +05:30
parent eba5210577
commit 1d745c9bc8
50 changed files with 7637 additions and 2813 deletions

View File

@@ -1,16 +1,97 @@
import React from "react";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha } from "@mui/material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon } from "@mui/icons-material";
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress } from "@mui/material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
import { PodcastAnalysis } from "./types";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
import { Refresh as RefreshIcon } from "@mui/icons-material";
import { aiApiClient } from "../../api/client";
interface AnalysisPanelProps {
analysis: PodcastAnalysis | null;
idea?: string;
duration?: number;
speakers?: number;
avatarUrl?: string | null;
avatarPrompt?: string | null;
onRegenerate?: () => void;
}
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegenerate }) => {
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, duration, speakers, avatarUrl, avatarPrompt, onRegenerate }) => {
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const [avatarLoading, setAvatarLoading] = useState(false);
const [avatarError, setAvatarError] = useState(false);
// Load avatar image as blob for authenticated URLs
useEffect(() => {
if (!avatarUrl) {
setAvatarBlobUrl(null);
setAvatarError(false);
return;
}
// Check if it's already a blob URL
if (avatarUrl.startsWith('blob:')) {
setAvatarBlobUrl(avatarUrl);
return;
}
// Check if it's an authenticated endpoint
const isAuthenticatedEndpoint = avatarUrl.includes('/api/podcast/images/') || avatarUrl.includes('/api/podcast/avatar/');
let currentBlobUrl: string | null = null;
if (isAuthenticatedEndpoint) {
setAvatarLoading(true);
setAvatarError(false);
const loadAvatarBlob = async () => {
try {
const response = await aiApiClient.get(avatarUrl, { responseType: 'blob' });
const blobUrl = URL.createObjectURL(response.data);
currentBlobUrl = blobUrl;
setAvatarBlobUrl(blobUrl);
setAvatarError(false);
} catch (error) {
console.error('[AnalysisPanel] Failed to load avatar as blob:', error);
// Fallback: try with query token
try {
const token = localStorage.getItem('clerk_dashboard_token') || '';
if (token) {
const urlWithToken = `${avatarUrl}?token=${encodeURIComponent(token)}`;
setAvatarBlobUrl(urlWithToken);
} else {
setAvatarError(true);
}
} catch (fallbackError) {
console.error('[AnalysisPanel] Fallback avatar loading failed:', fallbackError);
setAvatarError(true);
}
} finally {
setAvatarLoading(false);
}
};
loadAvatarBlob();
// Cleanup blob URL on unmount or when avatarUrl changes
return () => {
if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentBlobUrl);
}
// Also cleanup any previous blob URL from state
setAvatarBlobUrl((prev) => {
if (prev && prev.startsWith('blob:') && prev !== currentBlobUrl) {
URL.revokeObjectURL(prev);
}
return null;
});
};
} else {
// Direct URL, use as-is
setAvatarBlobUrl(avatarUrl);
}
}, [avatarUrl]);
if (!analysis) return null;
return (
<GlassyCard
@@ -54,6 +135,229 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegene
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
{/* Inputs Section */}
{(idea || duration || speakers || avatarUrl || avatarPrompt) && (
<>
<Box>
<Typography
variant="subtitle1"
sx={{
color: "#0f172a",
fontWeight: 700,
mb: 1.5,
display: "flex",
alignItems: "center",
gap: 0.5,
}}
>
Your Inputs
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: avatarUrl ? "1fr 1fr" : "1fr" },
gap: 3,
alignItems: "flex-start",
}}
>
{/* Left Column: Text Inputs */}
<Stack spacing={1.5}>
{idea && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Podcast Idea
</Typography>
<Typography variant="body2" sx={{ color: "#0f172a", wordBreak: "break-word" }}>
{idea}
</Typography>
</Box>
)}
<Stack direction="row" spacing={2} flexWrap="wrap">
{duration !== undefined && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Duration
</Typography>
<Chip
label={`${duration} minutes`}
size="small"
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
/>
</Box>
)}
{speakers !== undefined && (
<Box>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 600, display: "block", mb: 0.5 }}>
Speakers
</Typography>
<Chip
label={`${speakers} ${speakers === 1 ? "speaker" : "speakers"}`}
size="small"
sx={{ background: "#f1f5f9", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }}
/>
</Box>
)}
</Stack>
{/* AI Prompt Used for Avatar Generation */}
{avatarUrl && (
<Box>
<Typography
variant="caption"
sx={{
color: "#64748b",
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 0.5,
mb: 0.75,
}}
>
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
AI Generation Prompt
</Typography>
{avatarPrompt ? (
<Paper
sx={{
p: 1.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 1.5,
maxHeight: 200,
overflow: "auto",
}}
>
<Typography
variant="caption"
sx={{
color: "#475569",
fontFamily: "monospace",
fontSize: "0.75rem",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
display: "block",
}}
>
{avatarPrompt}
</Typography>
</Paper>
) : (
<Paper
sx={{
p: 1.5,
background: "#f1f5f9",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 1.5,
}}
>
<Typography
variant="caption"
sx={{
color: "#64748b",
fontStyle: "italic",
fontSize: "0.75rem",
}}
>
Prompt not available (avatar was uploaded or generated before this feature was added)
</Typography>
</Paper>
)}
</Box>
)}
</Stack>
{/* Right Column: Presenter Avatar */}
{avatarUrl && (
<Box>
<Typography
variant="caption"
sx={{
color: "#64748b",
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 0.5,
mb: 1,
}}
>
<PersonIcon sx={{ fontSize: 16 }} />
Presenter Avatar
</Typography>
<Box
sx={{
width: "100%",
maxWidth: { xs: "100%", md: 300 },
borderRadius: 2,
overflow: "hidden",
border: "1px solid rgba(102,126,234,0.2)",
background: alpha("#667eea", 0.05),
position: "relative",
aspectRatio: "1",
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
}}
>
{avatarLoading ? (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
background: "#f8fafc",
}}
>
<CircularProgress size={40} />
</Box>
) : avatarError ? (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
background: "#fef2f2",
color: "#dc2626",
p: 2,
}}
>
<Typography variant="caption" sx={{ textAlign: "center" }}>
Failed to load avatar
</Typography>
</Box>
) : avatarBlobUrl ? (
<Box
component="img"
src={avatarBlobUrl}
alt="Podcast Presenter"
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
onError={(e) => {
console.error('[AnalysisPanel] Avatar image failed to load:', {
src: e.currentTarget.src,
avatarUrl,
avatarBlobUrl,
});
setAvatarError(true);
}}
onLoad={() => {
console.log('[AnalysisPanel] Avatar image loaded successfully');
}}
/>
) : null}
</Box>
</Box>
)}
</Box>
</Box>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
</>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
<Stack spacing={2}>
<Box>

View File

@@ -6,6 +6,9 @@ import {
Info as InfoIcon,
HelpOutline as HelpOutlineIcon,
AttachMoney as AttachMoneyIcon,
CloudUpload as CloudUploadIcon,
Person as PersonIcon,
Delete as DeleteIcon,
} from "@mui/icons-material";
import { CreateProjectPayload, Knobs } from "./types";
import { PrimaryButton, SecondaryButton } from "./ui";
@@ -35,6 +38,9 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const [budgetCap, setBudgetCap] = useState<number>(50);
const [voiceFile, setVoiceFile] = useState<File | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null); // Store uploaded avatar URL
const [makingPresentable, setMakingPresentable] = useState(false);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
const [placeholderIndex, setPlaceholderIndex] = useState(0);
@@ -107,8 +113,22 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const canSubmit = Boolean(idea || url);
const submit = () => {
const submit = async () => {
if (!canSubmit || isSubmitting) return;
// If avatar was uploaded but not yet uploaded to server, upload it now
let finalAvatarUrl: string | null = avatarUrl;
if (avatarFile && !avatarUrl) {
try {
const { podcastApi } = await import("../../services/podcastApi");
const uploadResult = await podcastApi.uploadAvatar(avatarFile);
finalAvatarUrl = uploadResult.avatar_url;
} catch (error) {
console.error('Avatar upload failed:', error);
// Continue without avatar
}
}
onCreate({
ideaOrUrl: idea || url,
speakers,
@@ -116,6 +136,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
knobs,
budgetCap,
files: { voiceFile, avatarFile },
avatarUrl: finalAvatarUrl,
});
};
@@ -127,6 +148,9 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setBudgetCap(50);
setVoiceFile(null);
setAvatarFile(null);
setAvatarPreview(null);
setAvatarUrl(null);
setMakingPresentable(false);
setKnobs({ ...defaultKnobs });
setPlaceholderIndex(0);
};
@@ -141,6 +165,68 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setSpeakers(clamped);
};
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
console.error('Please select an image file');
return;
}
// Validate file size (e.g., max 5MB)
if (file.size > 5 * 1024 * 1024) {
console.error('Image file size must be less than 5MB');
return;
}
setAvatarFile(file);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
// Upload image immediately to get URL (for "Make Presentable" feature)
try {
const { podcastApi } = await import("../../services/podcastApi");
const uploadResult = await podcastApi.uploadAvatar(file);
setAvatarUrl(uploadResult.avatar_url);
} catch (error) {
console.error('Avatar upload failed:', error);
// Continue with local preview - upload will happen on submit
}
}
};
const handleRemoveAvatar = () => {
setAvatarFile(null);
setAvatarPreview(null);
setAvatarUrl(null);
setMakingPresentable(false);
};
const handleMakePresentable = async () => {
if (!avatarUrl || makingPresentable) return;
try {
setMakingPresentable(true);
const { podcastApi } = await import("../../services/podcastApi");
const result = await podcastApi.makeAvatarPresentable(avatarUrl);
// Fetch the transformed image as blob to display
const { aiApiClient } = await import("../../api/client");
const response = await aiApiClient.get(result.avatar_url, { responseType: 'blob' });
const blobUrl = URL.createObjectURL(response.data);
setAvatarPreview(blobUrl);
setAvatarUrl(result.avatar_url);
} catch (error) {
console.error('Failed to make avatar presentable:', error);
// Could show error message to user
} finally {
setMakingPresentable(false);
}
};
return (
<Paper
elevation={0}
@@ -601,181 +687,372 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
{/* Settings Section */}
<Box
sx={{
p: 3,
borderRadius: 2,
background: alpha("#f8fafc", 0.5),
border: "1px solid rgba(15, 23, 42, 0.06)",
p: 3.5,
borderRadius: 2.5,
background: "linear-gradient(135deg, rgba(248, 250, 252, 0.8) 0%, rgba(241, 245, 249, 0.8) 100%)",
border: "1.5px solid rgba(15, 23, 42, 0.08)",
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.06)",
}}
>
<Typography variant="subtitle2" sx={{ mb: 2, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Podcast Settings
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="flex-start">
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ flex: 1 }}>
<TextField
label="Duration (minutes)"
type="number"
value={duration}
onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
InputProps={{ inputProps: { min: 1, max: 10 } }}
size="small"
helperText={duration > 10 ? "Maximum duration is 10 minutes" : `Recommended: 1-3 minutes for quick tests (currently: ${duration} min)`}
error={duration > 10}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 3 }}>
<Box
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#ffffff",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
"&:hover": {
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused": {
borderColor: "#667eea",
borderWidth: 2,
},
},
"& .MuiInputLabel-root": {
color: "#64748b",
"&.Mui-focused": {
color: "#667eea",
},
},
"& .MuiFormHelperText-root": {
color: "#64748b",
fontSize: "0.8125rem",
},
}}
/>
<TextField
label="Number of speakers"
type="number"
value={speakers}
onChange={(e) => handleSpeakersChange(Number(e.target.value) || 1)}
InputProps={{ inputProps: { min: 1, max: 2 } }}
size="small"
helperText={speakers > 2 ? "Maximum 2 speakers supported" : `Supports 1-2 speakers (currently: ${speakers})`}
error={speakers > 2}
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#ffffff",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
"&:hover": {
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused": {
borderColor: "#667eea",
borderWidth: 2,
},
},
"& .MuiInputLabel-root": {
color: "#64748b",
"&.Mui-focused": {
color: "#667eea",
},
},
"& .MuiFormHelperText-root": {
color: "#64748b",
fontSize: "0.8125rem",
},
}}
/>
</Stack>
{/* Cost Breakdown Panel - positioned in empty space */}
<Paper
elevation={0}
sx={{
p: 2.5,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)",
border: "1.5px solid rgba(16, 185, 129, 0.2)",
width: 40,
height: 40,
borderRadius: 2,
minWidth: { xs: "100%", sm: 300 },
flex: { xs: "none", sm: "0 0 auto" },
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.08)",
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Stack spacing={1.5}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<AttachMoneyIcon sx={{ fontSize: "1.125rem", color: "#059669" }} />
</Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.875rem" }}>
Estimated Cost
</Typography>
</Stack>
<Typography
variant="h5"
sx={{
color: "#059669",
fontWeight: 700,
fontSize: "1.75rem",
lineHeight: 1.2,
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1.25rem" }} />
</Box>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700, fontSize: "1.125rem", letterSpacing: "-0.01em" }}>
Podcast Settings
</Typography>
</Stack>
<Stack direction={{ xs: "column", lg: "row" }} spacing={3} alignItems="flex-start">
{/* Duration and Speakers in vertical column */}
<Box
sx={{
flex: { xs: "1 1 auto", lg: "0 0 280px" },
width: { xs: "100%", lg: "280px" },
p: 2.5,
borderRadius: 2,
background: "#ffffff",
border: "1px solid rgba(15, 23, 42, 0.08)",
boxShadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
}}
>
<Typography variant="subtitle2" sx={{ mb: 2, color: "#0f172a", fontWeight: 600, fontSize: "0.875rem" }}>
Basic Configuration
</Typography>
<Stack spacing={2.5}>
<TextField
label="Duration (minutes)"
type="number"
value={duration}
onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
InputProps={{ inputProps: { min: 1, max: 10 } }}
size="small"
helperText={duration > 10 ? "Maximum duration is 10 minutes" : `Recommended: 1-3 minutes for quick tests`}
error={duration > 10}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
transition: "all 0.2s",
"&:hover": {
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: "#667eea",
borderWidth: 2,
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.12)",
},
},
"& .MuiInputLabel-root": {
color: "#64748b",
fontWeight: 500,
"&.Mui-focused": {
color: "#667eea",
fontWeight: 600,
},
},
"& .MuiFormHelperText-root": {
color: duration > 10 ? "#dc2626" : "#64748b",
fontSize: "0.8125rem",
mt: 0.75,
},
}}
>
${estimatedCost.total}
</Typography>
<Stack spacing={0.75} sx={{ mt: 0.5 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
Audio Generation
</Typography>
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
${estimatedCost.ttsCost}
</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
Avatar Creation
</Typography>
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
${estimatedCost.avatarCost}
</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
Video Rendering
</Typography>
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
${estimatedCost.videoCost}
</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
Research
</Typography>
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
${estimatedCost.researchCost}
</Typography>
</Box>
</Stack>
/>
<TextField
label="Number of speakers"
type="number"
value={speakers}
onChange={(e) => handleSpeakersChange(Number(e.target.value) || 1)}
InputProps={{ inputProps: { min: 1, max: 2 } }}
size="small"
helperText={speakers > 2 ? "Maximum 2 speakers supported" : `Supports 1-2 speakers`}
error={speakers > 2}
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
transition: "all 0.2s",
"&:hover": {
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: "#667eea",
borderWidth: 2,
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.12)",
},
},
"& .MuiInputLabel-root": {
color: "#64748b",
fontWeight: 500,
"&.Mui-focused": {
color: "#667eea",
fontWeight: 600,
},
},
"& .MuiFormHelperText-root": {
color: speakers > 2 ? "#dc2626" : "#64748b",
fontSize: "0.8125rem",
mt: 0.75,
},
}}
/>
</Stack>
</Box>
{/* Avatar Upload Section - replacing Estimated Cost */}
<Box
sx={{
flex: 1,
minWidth: 0,
p: 2.5,
borderRadius: 2,
background: "#ffffff",
border: "1px solid rgba(15, 23, 42, 0.08)",
boxShadow: "0 1px 2px rgba(15, 23, 42, 0.04)",
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
<Box
sx={{
mt: 1,
pt: 1.5,
borderTop: "1.5px solid rgba(16, 185, 129, 0.15)",
width: 36,
height: 36,
borderRadius: 1.5,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem", fontWeight: 500 }}>
{duration} min {speakers} speaker{speakers > 1 ? "s" : ""} {knobs.bitrate === "hd" ? "HD" : "Standard"} quality
</Typography>
<PersonIcon fontSize="small" sx={{ color: "#667eea" }} />
</Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Podcast Presenter Avatar
</Typography>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Avatar Options:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
<strong>Upload your photo:</strong> We'll enhance it into a professional podcast presenter using AI. Click "Make Presentable" after upload.<br/><br/>
<strong>Skip upload:</strong> After analysis completes, we'll generate professional presenter images based on your podcast topic, audience, and speaker count.
</Typography>
</Box>
}
arrow
placement="top"
componentsProps={{
tooltip: {
sx: {
bgcolor: "#0f172a",
color: "#ffffff",
maxWidth: 320,
fontSize: "0.875rem",
p: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
},
arrow: {
sx: {
color: "#0f172a",
},
},
}}
>
<InfoIcon fontSize="small" sx={{ color: "#94a3b8", cursor: "help" }} />
</Tooltip>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2.5} alignItems="flex-start">
{avatarPreview ? (
<Stack spacing={1.5} sx={{ flexShrink: 0 }}>
<Box sx={{ position: "relative", display: "inline-block" }}>
<Box
component="img"
src={avatarPreview}
alt="Avatar preview"
sx={{
width: 140,
height: 140,
objectFit: "cover",
borderRadius: 2.5,
border: "2px solid #e2e8f0",
boxShadow: "0 2px 8px rgba(15, 23, 42, 0.08)",
}}
/>
<IconButton
size="small"
onClick={handleRemoveAvatar}
sx={{
position: "absolute",
top: -8,
right: -8,
bgcolor: "white",
border: "1.5px solid #e2e8f0",
boxShadow: "0 2px 4px rgba(15, 23, 42, 0.1)",
"&:hover": {
bgcolor: "#f8fafc",
borderColor: "#dc2626",
color: "#dc2626",
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{avatarUrl && (
<Tooltip
title="Transform your uploaded photo into a professional podcast presenter. This AI enhancement optimizes your photo for video generation while maintaining your appearance and identity."
arrow
placement="top"
>
<Box>
<SecondaryButton
onClick={handleMakePresentable}
disabled={makingPresentable}
loading={makingPresentable}
startIcon={!makingPresentable ? <AutoAwesomeIcon fontSize="small" /> : undefined}
sx={{
fontSize: "0.8125rem",
py: 0.75,
width: "100%",
background: makingPresentable ? undefined : "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
border: makingPresentable ? undefined : "1px solid rgba(102, 126, 234, 0.2)",
color: makingPresentable ? undefined : "#667eea",
fontWeight: 600,
"&:hover": {
background: makingPresentable ? undefined : "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
},
}}
>
{makingPresentable ? "Transforming..." : "Make Presentable"}
</SecondaryButton>
</Box>
</Tooltip>
)}
</Stack>
) : (
<Box
component="label"
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: { xs: "100%", sm: 200 },
minHeight: 140,
border: "2px dashed #cbd5e1",
borderRadius: 2.5,
bgcolor: "#f8fafc",
cursor: "pointer",
transition: "all 0.2s",
flexShrink: 0,
"&:hover": {
borderColor: "#667eea",
bgcolor: "#f1f5f9",
borderWidth: "2.5px",
boxShadow: "0 0 0 3px rgba(102, 126, 234, 0.08)",
},
}}
>
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
style={{ display: "none" }}
/>
<CloudUploadIcon sx={{ color: "#94a3b8", fontSize: 36, mb: 1.5 }} />
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600, mb: 0.5 }}>
Upload Your Photo
</Typography>
<Typography variant="caption" sx={{ color: "#94a3b8", textAlign: "center", px: 2, lineHeight: 1.5 }}>
Optional - We'll enhance it with AI or generate one after analysis
</Typography>
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Stack spacing={1.5}>
<Box>
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.9375rem", lineHeight: 1.7, fontWeight: 500, mb: 1 }}>
Choose Your Avatar Option:
</Typography>
<Stack spacing={1.5}>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f0f4ff", 0.6),
border: "1px solid rgba(99, 102, 241, 0.2)",
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea" }} />
Upload Your Photo (Recommended)
</Typography>
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
Upload your photo and we'll enhance it into a professional podcast presenter using AI. After upload, click <strong>"Make Presentable"</strong> to transform your photo into a podcast-ready avatar that maintains your appearance while optimizing it for video generation.
</Typography>
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f8fafc", 0.8),
border: "1px solid rgba(15, 23, 42, 0.1)",
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontSize: "0.875rem", fontWeight: 600, mb: 0.5, display: "flex", alignItems: "center", gap: 0.5 }}>
<PersonIcon fontSize="small" sx={{ color: "#64748b" }} />
Let ALwrity Generate (Alternative)
</Typography>
<Typography variant="body2" sx={{ color: "#475569", fontSize: "0.8125rem", lineHeight: 1.6 }}>
If you skip upload, we'll automatically generate professional presenter images <strong>after the AI analysis completes</strong>. The generated presenters will be tailored to your podcast topic, target audience, content type, and speaker count for the best fit.
</Typography>
</Box>
</Stack>
</Box>
<Box
sx={{
p: 1.5,
borderRadius: 1.5,
background: alpha("#f0f4ff", 0.5),
border: "1px solid rgba(99, 102, 241, 0.15)",
}}
>
<Typography variant="caption" sx={{ color: "#6366f1", fontSize: "0.8125rem", fontWeight: 500, display: "flex", alignItems: "center", gap: 0.5 }}>
<InfoIcon fontSize="inherit" />
Supported formats: JPG, PNG, WebP (max 5MB)
</Typography>
</Box>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
</Stack>
</Box>

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from "react";
import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha } from "@mui/material";
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon } from "@mui/icons-material";
import React, { useMemo, useState } from "react";
import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha, Box } from "@mui/material";
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material";
import { Fact } from "./types";
import { GlassyCard, glassyCardSx } from "./ui";
@@ -8,7 +8,10 @@ interface FactCardProps {
fact: Fact;
}
const MAX_PREVIEW_LENGTH = 200; // Characters to show before truncation
export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
const [expanded, setExpanded] = useState(false);
const hostname = useMemo(() => {
try {
return new URL(fact.url).hostname;
@@ -21,30 +24,77 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
navigator.clipboard.writeText(fact.quote);
};
const shouldTruncate = fact.quote.length > MAX_PREVIEW_LENGTH;
const previewText = shouldTruncate ? fact.quote.slice(0, MAX_PREVIEW_LENGTH).trim() + "..." : fact.quote;
const fullText = fact.quote;
return (
<GlassyCard
whileHover={{ y: -4 }}
whileHover={{ y: -2 }}
sx={{
...glassyCardSx,
p: 2,
p: 1.5,
cursor: "pointer",
transition: "all 0.2s",
height: "100%",
display: "flex",
flexDirection: "column",
"&:hover": {
borderColor: "rgba(102,126,234,0.25)",
boxShadow: "0 12px 28px rgba(15,23,42,0.08)",
boxShadow: "0 8px 20px rgba(15,23,42,0.08)",
},
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
}}
>
<Stack spacing={1.5}>
<Typography variant="body2" sx={{ lineHeight: 1.6, color: "#0f172a" }}>
{fact.quote}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={1} alignItems="center" flex={1}>
<OpenInNewIcon fontSize="small" sx={{ color: "rgba(15,23,42,0.6)" }} />
<Stack spacing={1} sx={{ flex: 1, minHeight: 0 }}>
{/* Quote Text - Truncated with expand option */}
<Box sx={{ flex: 1, minHeight: 0 }}>
<Typography
variant="body2"
sx={{
lineHeight: 1.5,
color: "#0f172a",
fontSize: "0.8125rem",
display: "-webkit-box",
WebkitLineClamp: expanded ? "none" : 4,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
mb: shouldTruncate ? 0.5 : 0,
}}
>
{expanded ? fullText : previewText}
</Typography>
{shouldTruncate && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
sx={{
p: 0.25,
mt: 0.25,
color: "#4f46e5",
"&:hover": { background: alpha("#4f46e5", 0.1) },
}}
>
{expanded ? (
<ExpandLessIcon fontSize="small" />
) : (
<ExpandMoreIcon fontSize="small" />
)}
</IconButton>
)}
</Box>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)", my: 0.5 }} />
{/* Source and Actions */}
<Stack direction="row" spacing={0.75} alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={0.5} alignItems="center" flex={1} minWidth={0}>
<OpenInNewIcon fontSize="small" sx={{ color: "rgba(15,23,42,0.5)", flexShrink: 0 }} />
<Typography
variant="caption"
component="a"
@@ -55,34 +105,49 @@ export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
color: "#4f46e5",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.7rem",
}}
>
{hostname || "source"}
</Typography>
</Stack>
<Tooltip title="Copy citation">
<IconButton size="small" onClick={handleCopy} sx={{ color: "rgba(15,23,42,0.65)" }}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleCopy();
}}
sx={{
color: "rgba(15,23,42,0.6)",
p: 0.5,
"&:hover": { background: alpha("#4f46e5", 0.1) },
}}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Stack direction="row" spacing={2}>
{/* Confidence and Date */}
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Chip
label={`${(fact.confidence * 100).toFixed(0)}% confidence`}
label={`${(fact.confidence * 100).toFixed(0)}%`}
size="small"
sx={{
height: 20,
height: 18,
fontSize: "0.65rem",
background: alpha("#22c55e", 0.15),
color: "#15803d",
border: "1px solid rgba(34,197,94,0.35)",
fontWeight: 600,
}}
/>
<Typography variant="caption" sx={{ color: "#475569" }}>
{fact.date}
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{fact.date !== "Unknown" ? new Date(fact.date).toLocaleDateString("en-US", { month: "short", year: "numeric" }) : fact.date}
</Typography>
</Stack>
</Stack>

View File

@@ -217,6 +217,11 @@ const PodcastDashboard: React.FC = () => {
{analysis && !showScriptEditor && !showRenderQueue && (
<AnalysisPanel
analysis={analysis}
idea={project?.idea}
duration={project?.duration}
speakers={project?.speakers}
avatarUrl={project?.avatarUrl}
avatarPrompt={project?.avatarPrompt}
onRegenerate={() => {}}
/>
)}
@@ -259,6 +264,7 @@ const PodcastDashboard: React.FC = () => {
onBackToResearch={() => setShowScriptEditor(false)}
onProceedToRendering={(s) => workflow.handleProceedToRendering(s)}
onError={(msg) => workflow.setAnnouncement(msg)}
avatarUrl={project?.avatarUrl}
/>
)}

View File

@@ -19,8 +19,15 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
const navigate = useNavigate();
return (
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
flexWrap="wrap"
gap={2}
sx={{ width: "100%", minWidth: 0 }} // Ensure full width and allow wrapping
>
<Box sx={{ minWidth: 0, flex: { xs: "1 1 100%", md: "0 1 auto" } }}>
<Typography
variant="h3"
sx={{
@@ -30,24 +37,61 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
display: "flex",
alignItems: "center",
gap: 1.5,
fontSize: { xs: "1.5rem", md: "2rem" },
}}
>
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
AI Podcast Maker
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: "none", sm: "block" } }}>
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Stack
direction="row"
spacing={1}
alignItems="center"
flexWrap="wrap"
useFlexGap
sx={{
justifyContent: { xs: "flex-start", md: "flex-end" },
gap: { xs: 0.5, md: 1 },
minWidth: 0,
width: { xs: "100%", md: "auto" }, // Full width on mobile to allow wrapping
flex: { xs: "1 1 100%", md: "0 1 auto" }, // Take full width on mobile
}}
>
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
<SecondaryButton
onClick={() => window.open("/docs", "_blank")}
startIcon={<InfoIcon />}
sx={{
display: { xs: "none", lg: "flex" },
// Override for light theme
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
}}
>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
sx={{
display: { xs: "none", xl: "flex" },
// Override for light theme
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
}}
>
My Episodes
</SecondaryButton>
@@ -55,10 +99,30 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode })
onClick={onShowProjects}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
sx={{
flexShrink: 0,
display: "flex !important", // Always show "My Projects" - force display
order: { xs: 1, md: 0 }, // Show first on mobile
// Override button colors for light theme
borderColor: "rgba(102, 126, 234, 0.3) !important",
color: "#667eea !important",
"&:hover": {
borderColor: "rgba(102, 126, 234, 0.5) !important",
background: "rgba(102, 126, 234, 0.1) !important",
},
}}
>
My Projects
</SecondaryButton>
<PrimaryButton onClick={onNewEpisode} startIcon={<AutoAwesomeIcon />}>
<PrimaryButton
onClick={onNewEpisode}
startIcon={<AutoAwesomeIcon />}
sx={{
flexShrink: 0,
display: "flex", // Always show "New Episode"
order: { xs: 0, md: 1 }, // Show first on mobile
}}
>
New Episode
</PrimaryButton>
</Stack>

View File

@@ -1,10 +1,11 @@
import React from "react";
import { Stack, Typography, Chip, Divider, Box, alpha } from "@mui/material";
import React, { useMemo } from "react";
import { Stack, Typography, Chip, Divider, Box, alpha, Paper } from "@mui/material";
import {
Insights as InsightsIcon,
Search as SearchIcon,
AttachMoney as AttachMoneyIcon,
EditNote as EditNoteIcon,
Article as ArticleIcon,
} from "@mui/icons-material";
import { Research } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
@@ -21,17 +22,68 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
canGenerateScript,
onGenerateScript,
}) => {
// Extract key insights from summary if it's long
const summaryParts = useMemo(() => {
const fullSummary = research.summary || "";
if (fullSummary.length > 500) {
// Try to split into paragraphs or sentences
const sentences = fullSummary.split(/[.!?]\s+/).filter(s => s.trim().length > 20);
const keyPoints = sentences.slice(0, 3);
const remainingText = sentences.slice(3).join(". ") + (sentences.length > 3 ? "." : "");
return { keyPoints, remainingText };
}
return { keyPoints: [], remainingText: fullSummary };
}, [research.summary]);
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<Box sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1.5 }}>
<InsightsIcon />
Research Summary
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, lineHeight: 1.7 }}>
{research.summary}
{/* Key Insights */}
{summaryParts.keyPoints.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600, display: "flex", alignItems: "center", gap: 0.5 }}>
<ArticleIcon fontSize="small" />
Key Insights
</Typography>
<Stack spacing={1}>
{summaryParts.keyPoints.map((point, idx) => (
<Paper
key={idx}
sx={{
p: 1.25,
background: alpha("#667eea", 0.05),
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 1.5,
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.6, fontSize: "0.875rem" }}>
{point}
</Typography>
</Paper>
))}
</Stack>
</Box>
)}
{/* Full Summary Text */}
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 2,
lineHeight: 1.7,
fontSize: "0.875rem",
color: "#475569",
}}
>
{summaryParts.remainingText || research.summary}
</Typography>
{/* Research Metadata */}
@@ -126,15 +178,23 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{research.factCards.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5, flexWrap: "wrap", gap: 1 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600 }}>
Research Sources & Facts ({research.factCards.length})
</Typography>
<Typography variant="caption" sx={{ color: "#64748b" }}>
Click any card to view source details
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem" }}>
Click to expand Hover to see source
</Typography>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", md: "repeat(3, 1fr)", lg: "repeat(4, 1fr)" },
gap: 1.5,
width: "100%",
overflow: "hidden",
}}
>
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}

View File

@@ -94,17 +94,66 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
setShowRenderQueue(false);
try {
setIsAnalyzing(true);
// Upload avatar if provided, or generate presenters
let avatarUrl: string | null = null;
if (payload.files.avatarFile) {
try {
setAnnouncement("Uploading presenter avatar...");
const uploadResponse = await podcastApi.uploadAvatar(payload.files.avatarFile);
avatarUrl = uploadResponse.avatar_url;
} catch (error) {
console.error('Avatar upload failed:', error);
// Continue without avatar - will generate one later
}
}
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload);
const result = await podcastApi.createProject({ ...payload, avatarUrl });
await initializeProject(payload, result.projectId);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers, avatarUrl });
setAnalysis(result.analysis);
setEstimate(result.estimate);
setQueries(result.queries);
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
setKnobs(payload.knobs);
setBudgetCap(payload.budgetCap);
setAnnouncement("Analysis complete");
// Generate presenters AFTER analysis completes (to use analysis insights)
// This happens only if no avatar was uploaded
if (!avatarUrl && payload.speakers > 0 && result.analysis) {
try {
setAnnouncement("Generating presenter avatars using AI insights...");
const presentersResponse = await podcastApi.generatePresenters(
payload.speakers,
result.projectId,
result.analysis.audience,
result.analysis.contentType,
result.analysis.topKeywords
);
if (presentersResponse.avatars && presentersResponse.avatars.length > 0) {
// Store the first presenter avatar URL and prompt
const firstAvatar = presentersResponse.avatars[0];
const prompt = firstAvatar.prompt || null;
setProject({
id: result.projectId,
idea: payload.ideaOrUrl,
duration: payload.duration,
speakers: payload.speakers,
avatarUrl: firstAvatar.avatar_url,
avatarPrompt: prompt,
avatarPersonaId: firstAvatar.persona_id || presentersResponse.persona_id || null,
});
setAnnouncement("Analysis complete - Presenter avatars generated");
}
} catch (error) {
console.error('Presenter generation failed:', error);
setAnnouncement("Analysis complete - Avatar generation will happen later");
// Continue without presenters - can generate later
}
} else {
setAnnouncement("Analysis complete");
}
} catch (error: any) {
if (error?.response?.status === 429 || error?.response?.data?.detail) {
const errorDetail = error.response.data.detail;

View File

@@ -1,8 +1,11 @@
import React, { useCallback } from "react";
import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
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";
@@ -10,6 +13,7 @@ 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;
@@ -36,6 +40,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
onBack,
onError,
}) => {
const [localError, setLocalError] = useState<string>("");
const {
rendering,
generatingImage,
@@ -43,6 +48,10 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
runRender,
runImageGeneration,
runVideoRender,
combiningVideos,
combiningProgress,
finalVideoUrl,
combineFinalVideo,
} = useRenderQueue({
script,
jobs,
@@ -52,7 +61,10 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
avatarImageUrl,
onUpdateJob,
onUpdateScript,
onError,
onError: (msg) => {
setLocalError(msg);
onError(msg);
},
});
const handleDownloadAudio = useCallback((audioUrl: string, title: string) => {
@@ -76,11 +88,11 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
title,
text: `Check out this podcast episode: ${title}`,
url: audioUrl,
});
});
} catch (err) {
// User cancelled or error
}
} else {
} else {
// Fallback: copy to clipboard
await navigator.clipboard.writeText(audioUrl);
alert("Audio URL copied to clipboard!");
@@ -91,6 +103,28 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
(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 */}
@@ -115,6 +149,24 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
</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>
)}
{/* 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">
@@ -127,21 +179,21 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
{/* Empty State */}
{jobs.length === 0 && script.scenes.length === 0 && (
<Paper
sx={{
<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>
<Typography variant="body2" sx={{ color: "#64748b", mb: 3 }}>
Go back to the script editor to generate and approve scenes first.
</Typography>
</Typography>
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script Editor
</SecondaryButton>
@@ -166,7 +218,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
avatarImageUrl={avatarImageUrl}
onRender={runRender}
onImageGenerate={runImageGeneration}
onVideoRender={runVideoRender}
onVideoGenerate={(sceneId, settings) => runVideoRender(sceneId, settings)}
onDownloadAudio={handleDownloadAudio}
onDownloadVideo={handleDownloadVideo}
onShare={handleShare}
@@ -176,6 +228,224 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
})}
</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={{
@@ -191,13 +461,22 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
Back to Script
</SecondaryButton>
{allScenesCompleted ? (
{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 or download them.
Generate videos for individual scenes above.
</Typography>
</Stack>
) : (

View File

@@ -92,6 +92,9 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
}
// Has audio - show all action buttons
const videoInProgress = rendering !== null;
const isCurrentVideo = rendering === scene.id;
return (
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
{/* Generate Image */}
@@ -114,21 +117,29 @@ export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
{/* Generate Video */}
<PrimaryButton
onClick={() => onVideoRender(scene.id)}
disabled={isBusy || !hasImage || hasVideo}
onClick={() => {
onVideoRender(scene.id);
}}
disabled={isBusy || videoInProgress || !hasImage || hasVideo}
startIcon={<VideocamIcon />}
tooltip={
hasVideo
? "Video already generated"
: !hasImage
? "Generate an image first to create video"
: videoInProgress
? "A video generation is already running. Please wait..."
: isBusy
? "Another operation in progress"
: "Generate video for this scene"
}
sx={{ minWidth: 160 }}
sx={{ minWidth: 180 }}
>
{hasVideo ? "Video Ready" : "Generate Video"}
{videoInProgress && isCurrentVideo
? "Generating Video..."
: hasVideo
? "Video Ready"
: "Generate Video"}
</PrimaryButton>
{/* Download Video */}

View File

@@ -7,11 +7,13 @@ import {
OpenInNew as OpenInNewIcon,
Videocam as VideocamIcon,
} from "@mui/icons-material";
import { Scene, Job } from "../types";
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 { fetchMediaBlobUrl } from "../../../utils/fetchMediaBlobUrl";
import { VideoRegenerateModal } from "./VideoRegenerateModal";
interface SceneCardProps {
scene: Scene;
@@ -22,7 +24,7 @@ interface SceneCardProps {
avatarImageUrl?: string | null;
onRender: (sceneId: string, mode: "preview" | "full") => void;
onImageGenerate: (sceneId: string) => void;
onVideoRender: (sceneId: string) => void;
onVideoGenerate: (sceneId: string, settings: VideoGenerationSettings) => void;
onDownloadAudio: (audioUrl: string, title: string) => void;
onDownloadVideo: (videoUrl: string, title: string) => void;
onShare: (audioUrl: string, title: string) => void;
@@ -75,7 +77,7 @@ export const SceneCard: React.FC<SceneCardProps> = ({
avatarImageUrl,
onRender,
onImageGenerate,
onVideoRender,
onVideoGenerate,
onDownloadAudio,
onDownloadVideo,
onShare,
@@ -89,8 +91,27 @@ export const SceneCard: React.FC<SceneCardProps> = ({
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);
const [videoBlobUrl, setVideoBlobUrl] = useState<string | null>(null);
const [showVideoModal, setShowVideoModal] = useState(false);
const [initialVideoPrompt, setInitialVideoPrompt] = useState<string>("");
// Prepare a simple default prompt based on the scene title/description
useEffect(() => {
const baseTitle = (scene.title || "").trim();
const description = (scene as any).description as string | undefined;
const descSnippet = (description || "").split(".")[0]?.trim();
let prompt = baseTitle;
if (!prompt && descSnippet) {
prompt = descSnippet;
}
if (!prompt) {
prompt = "Professional podcast scene with subtle movement";
}
setInitialVideoPrompt(prompt);
}, [scene]);
useEffect(() => {
if (!imageUrl) {
@@ -98,14 +119,11 @@ export const SceneCard: React.FC<SceneCardProps> = ({
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;
}
@@ -134,22 +152,17 @@ export const SceneCard: React.FC<SceneCardProps> = ({
// 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:')) {
@@ -184,11 +197,9 @@ export const SceneCard: React.FC<SceneCardProps> = ({
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) {
@@ -213,6 +224,39 @@ export const SceneCard: React.FC<SceneCardProps> = ({
};
}, [imageUrl, hasImage, scene.id]);
// Load video as blob when videoUrl changes (using Story Writer's utility)
useEffect(() => {
if (!job?.videoUrl) {
setVideoBlobUrl(null);
return;
}
let currentBlobUrl: string | null = null;
fetchMediaBlobUrl(job.videoUrl)
.then((blobUrl) => {
if (blobUrl) {
currentBlobUrl = blobUrl;
setVideoBlobUrl(blobUrl);
} else {
// File not found (404) - clear the blob URL
console.warn('[SceneCard] Video file not found (404):', job.videoUrl);
setVideoBlobUrl(null);
}
})
.catch((err) => {
console.error('[SceneCard] Failed to load video blob:', err);
setVideoBlobUrl(null);
});
return () => {
// Cleanup blob URL when component unmounts or URL changes
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
};
}, [job?.videoUrl]);
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
@@ -279,13 +323,12 @@ export const SceneCard: React.FC<SceneCardProps> = ({
</Box>
</Box>
)}
{hasVideo && job?.videoUrl && (
{hasVideo && videoBlobUrl && (
<Box sx={{ mt: 1 }}>
<Box
component="a"
href={job.videoUrl}
target="_blank"
rel="noopener noreferrer"
href={videoBlobUrl}
download={`${scene.title.replace(/[^a-z0-9]/gi, '_')}_video.mp4`}
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<VideocamIcon sx={{ fontSize: 16 }} />
@@ -350,8 +393,57 @@ export const SceneCard: React.FC<SceneCardProps> = ({
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
)}
{/* Image Preview */}
{hasImage && (imageBlobUrl || imageUrl) && (
{/* Video Preview - Show video if available, otherwise show image */}
{hasVideo && videoBlobUrl ? (
<Box
sx={{
width: "100%",
borderRadius: 2,
overflow: "hidden",
border: "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: "cover",
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>
</Box>
) : hasImage && (imageBlobUrl || imageUrl) ? (
<Box
sx={{
width: "100%",
@@ -373,21 +465,14 @@ export const SceneCard: React.FC<SceneCardProps> = ({
objectFit: "cover",
}}
onError={(e) => {
console.error('[SceneCard] Image failed to load:', {
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>
)}
) : null}
{/* Action Buttons */}
<SceneActionButtons
@@ -402,12 +487,25 @@ export const SceneCard: React.FC<SceneCardProps> = ({
isBusy={isBusy}
onRender={onRender}
onImageGenerate={onImageGenerate}
onVideoRender={onVideoRender}
onVideoRender={() => setShowVideoModal(true)}
onDownloadAudio={onDownloadAudio}
onDownloadVideo={onDownloadVideo}
onShare={onShare}
onError={onError}
/>
{/* Video Generation Settings Modal */}
<VideoRegenerateModal
open={showVideoModal}
onClose={() => setShowVideoModal(false)}
onGenerate={(settings: VideoGenerationSettings) => {
setShowVideoModal(false);
onVideoGenerate(scene.id, settings);
}}
initialPrompt={initialVideoPrompt}
initialResolution="480p"
initialSeed={-1}
/>
</Stack>
</GlassyCard>
);

View File

@@ -0,0 +1,228 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Box,
Typography,
TextField,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
Tooltip,
} from "@mui/material";
import { Info as InfoIcon } from "@mui/icons-material";
import { PrimaryButton, SecondaryButton } from "../ui";
import type { VideoGenerationSettings } from "../types";
interface VideoRegenerateModalProps {
open: boolean;
onClose: () => void;
onGenerate: (settings: VideoGenerationSettings) => void;
initialPrompt: string;
initialResolution?: "480p" | "720p";
initialSeed?: number | null;
}
export const VideoRegenerateModal: React.FC<VideoRegenerateModalProps> = ({
open,
onClose,
onGenerate,
initialPrompt,
initialResolution = "480p",
initialSeed = -1,
}) => {
const [prompt, setPrompt] = useState(initialPrompt);
const [resolution, setResolution] = useState<"480p" | "720p">(initialResolution);
const [seed, setSeed] = useState<string>(initialSeed != null && initialSeed !== -1 ? String(initialSeed) : "");
const [maskImageUrl, setMaskImageUrl] = useState<string>("");
useEffect(() => {
setPrompt(initialPrompt);
setResolution(initialResolution);
}, [initialResolution, initialPrompt]);
const handleGenerate = () => {
const parsedSeed = seed.trim() === "" ? undefined : Number.isNaN(Number(seed)) ? undefined : Number(seed);
const settings: VideoGenerationSettings = {
prompt: prompt.trim(),
resolution,
seed: parsedSeed,
maskImageUrl: maskImageUrl.trim() || undefined,
};
onGenerate(settings);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: "rgba(15, 23, 42, 0.96)",
backdropFilter: "blur(18px)",
borderRadius: 4,
border: "1px solid rgba(148, 163, 184, 0.4)",
},
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ color: "white", fontWeight: 600 }}>
Configure Video Generation
</Typography>
<Tooltip title="Adjust how your talking-head video is rendered. These settings control resolution, prompt, and animation seed.">
<InfoIcon sx={{ color: "rgba(148,163,184,0.9)" }} />
</Tooltip>
</Stack>
<Typography variant="body2" sx={{ color: "rgba(148,163,184,0.9)", mt: 1 }}>
Fine-tune how this scene is animated. InfiniteTalk is audio-driven, so use the prompt to describe the visual
look and feel you want while keeping it concise.
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Prompt */}
<Box>
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Visual prompt</FormLabel>
<TextField
multiline
minRows={3}
maxRows={6}
fullWidth
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Short description of how the scene should look (lighting, mood, camera feel, etc.)"
variant="outlined"
InputProps={{
sx: {
bgcolor: "rgba(15,23,42,0.9)",
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(148,163,184,0.4)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(125,211,252,0.8)",
},
},
}}
InputLabelProps={{
sx: { color: "rgba(148,163,184,0.9)" },
}}
/>
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5, display: "block" }}>
Example: &quot;Modern podcast studio with soft lighting, the host framed center, gentle camera movement.&quot;
</Typography>
</Box>
{/* Resolution */}
<Box>
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 1 }}>Resolution &amp; quality</FormLabel>
<RadioGroup
row
value={resolution}
onChange={(e) => setResolution(e.target.value as "480p" | "720p")}
>
<FormControlLabel
value="480p"
control={<Radio color="primary" />}
label={
<Box>
<Typography variant="body2">480p (Recommended)</Typography>
<Typography variant="caption" color="text.secondary">
Faster render, lower cost, great for previews &amp; social
</Typography>
</Box>
}
/>
<FormControlLabel
value="720p"
control={<Radio color="primary" />}
label={
<Box>
<Typography variant="body2">720p (Higher quality)</Typography>
<Typography variant="caption" color="text.secondary">
Sharper video, slightly higher cost and render time
</Typography>
</Box>
}
/>
</RadioGroup>
</Box>
{/* Seed & advanced options */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<FormControl fullWidth>
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Seed (optional)</FormLabel>
<TextField
type="number"
value={seed}
onChange={(e) => setSeed(e.target.value)}
placeholder="Random each time if left empty"
InputProps={{
sx: {
bgcolor: "rgba(15,23,42,0.9)",
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(148,163,184,0.4)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(125,211,252,0.8)",
},
},
}}
/>
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5 }}>
Use the same seed to get a similar animation style across multiple scenes.
</Typography>
</FormControl>
<FormControl full-width="true">
<FormLabel sx={{ color: "rgba(248,250,252,0.9)", mb: 0.5 }}>Mask image URL (optional)</FormLabel>
<TextField
value={maskImageUrl}
onChange={(e) => setMaskImageUrl(e.target.value)}
placeholder="e.g. /api/podcast/images/your_avatar_mask.png"
InputProps={{
sx: {
bgcolor: "rgba(15,23,42,0.9)",
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(148,163,184,0.4)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(125,211,252,0.8)",
},
},
}}
/>
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)", mt: 0.5 }}>
Optional: limit animation to a specific region (e.g. face) by providing a mask image URL. Leave empty to
animate the whole frame.
</Typography>
</FormControl>
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 2.5, pt: 0, justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: "rgba(148,163,184,0.9)" }}>
Estimated cost at 480p is lower than 720p. You&apos;ll only be billed for successful renders.
</Typography>
<Stack direction="row" spacing={1}>
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleGenerate}>Generate Video</PrimaryButton>
</Stack>
</DialogActions>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "../types";
import { Script, Knobs, Job, RenderJobResult, TaskStatus, VideoGenerationSettings } from "../types";
import { podcastApi } from "../../../services/podcastApi";
interface UseRenderQueueProps {
@@ -36,7 +36,11 @@ export const useRenderQueue = ({
duration: number;
sceneCount: number;
} | null>(null);
const [combiningVideos, setCombiningVideos] = useState(false);
const [finalVideoUrl, setFinalVideoUrl] = useState<string | null>(null);
const [combiningProgress, setCombiningProgress] = useState<{ progress: number; message: string } | null>(null);
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
const pollingErrorCounts = useRef<Map<string, number>>(new Map());
// Cleanup polling intervals on unmount
useEffect(() => {
@@ -44,10 +48,11 @@ export const useRenderQueue = ({
return () => {
intervals.forEach((interval) => clearInterval(interval));
intervals.clear();
pollingErrorCounts.current.clear();
};
}, []);
// Initialize jobs if empty
// Initialize jobs if empty (audio/image only)
useEffect(() => {
if (jobs.length === 0 && script.scenes.length > 0) {
const initialJobs: Job[] = script.scenes.map((s) => {
@@ -59,7 +64,7 @@ export const useRenderQueue = ({
progress: hasExistingAudio ? 100 : 0,
previewUrl: null,
finalUrl: hasExistingAudio ? s.audioUrl || null : null,
imageUrl: s.imageUrl || null, // Include existing imageUrl from scene
imageUrl: s.imageUrl || null,
jobId: null,
};
});
@@ -67,25 +72,201 @@ export const useRenderQueue = ({
onUpdateJob(job.sceneId, job);
});
}
}, [script.scenes.length, jobs.length, onUpdateJob, script.scenes]);
}, [jobs.length, script.scenes.length, onUpdateJob, script.scenes]);
// Load final video URL from project on mount (for persistence across reloads)
useEffect(() => {
if (!projectId) return;
podcastApi
.loadProject(projectId)
.then((project) => {
if (project.final_video_url) {
console.log("[useRenderQueue] Loaded final video URL from project:", project.final_video_url);
setFinalVideoUrl(project.final_video_url);
}
})
.catch((error) => {
console.error("[useRenderQueue] Failed to load project for final video URL:", error);
// Don't show error to user - this is just for restoring state
});
}, [projectId]);
// Always try to attach existing videos to scenes (even after reloads)
useEffect(() => {
if (script.scenes.length === 0) return;
podcastApi
.listVideos(projectId)
.then((result) => {
const videoMap = new Map<number, string>();
result.videos.forEach((video) => {
// Use the most recent video for each scene number
if (!videoMap.has(video.scene_number)) {
// Store the raw video URL - SceneCard will handle authentication via blob loading
videoMap.set(video.scene_number, video.video_url);
}
});
script.scenes.forEach((scene) => {
const sceneNumberMatch = scene.id.match(/\d+/);
const sceneNumber = sceneNumberMatch ? parseInt(sceneNumberMatch[0], 10) : null;
if (sceneNumber === null) return;
const videoUrl = videoMap.get(sceneNumber);
if (!videoUrl) return;
const job = jobs.find((j) => j.sceneId === scene.id);
// Avoid redundant updates
if (job?.videoUrl === videoUrl) return;
onUpdateJob(scene.id, {
sceneId: scene.id,
title: scene.title,
videoUrl,
status: "completed" as const,
progress: 100,
});
});
})
.catch((error) => {
console.error("[useRenderQueue] Failed to list existing videos:", error);
});
}, [projectId, script.scenes, jobs, onUpdateJob]);
// Periodic check to rescue videos that were generated but not detected by polling
useEffect(() => {
if (rendering && script.scenes.length > 0) {
const rescueInterval = setInterval(async () => {
// Check for videos every 2 minutes while rendering is active
try {
const videoList = await podcastApi.listVideos(projectId);
const videoMap = new Map<number, string>();
videoList.videos.forEach((video) => {
if (!videoMap.has(video.scene_number)) {
// Store the raw video URL - SceneCard will handle authentication via blob loading
videoMap.set(video.scene_number, video.video_url);
}
});
// Update jobs for scenes that have videos but no videoUrl set
script.scenes.forEach((scene) => {
const sceneNumberMatch = scene.id.match(/\d+/);
const sceneNumber = sceneNumberMatch ? parseInt(sceneNumberMatch[0], 10) : null;
if (sceneNumber !== null) {
const videoUrl = videoMap.get(sceneNumber);
const job = jobs.find((j) => j.sceneId === scene.id);
if (videoUrl) {
if (!job) {
onUpdateJob(scene.id, {
sceneId: scene.id,
title: scene.title,
status: "completed" as const,
progress: 100,
videoUrl,
});
} else if (!job.videoUrl) {
onUpdateJob(scene.id, { videoUrl, status: "completed" as const, progress: 100 });
// If this was the rendering scene, stop rendering
if (rendering === scene.id) {
setRendering(null);
}
}
}
}
});
} catch (error) {
console.error("[useRenderQueue] Failed to rescue videos:", error);
}
}, 120000); // Check every 2 minutes
return () => clearInterval(rescueInterval);
}
}, [rendering, script.scenes, jobs, projectId, onUpdateJob]);
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);
const status: TaskStatus | null = await podcastApi.pollTaskStatus(taskId);
// Handle null response (task not found)
if (!status) {
const errorCount = (pollingErrorCounts.current.get(sceneId) || 0) + 1;
pollingErrorCounts.current.set(sceneId, errorCount);
// Stop polling after 3 consecutive "task not found" errors
if (errorCount >= 3) {
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 task not found. The task may have expired or been cancelled.");
return true; // Stop polling
}
return false; // Continue polling (might be transient)
}
// Reset error count on successful poll
pollingErrorCounts.current.delete(sceneId);
onUpdateJob(sceneId, {
progress: status.progress ?? 0,
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
});
if (status.status === "completed" && status.result) {
// 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,
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: result.video_url,
videoUrl,
cost: result.cost,
});
@@ -94,20 +275,62 @@ export const useRenderQueue = ({
clearInterval(interval);
pollingIntervals.current.delete(sceneId);
}
setRendering(null);
return true; // Stop polling
} else if (status.status === "failed") {
// Extract user-friendly error message
let errorMessage = "Video generation failed";
if (status.error) {
// Try to extract meaningful error from various formats
const errorStr = status.error;
if (errorStr.includes("Insufficient credits")) {
errorMessage = "Video generation failed: Insufficient WaveSpeed credits. Please top up your account.";
} else if (errorStr.includes("HTTPException") || errorStr.includes("502")) {
// Extract the actual error message from HTTPException details
const match = errorStr.match(/message[":\s]+"([^"]+)"/i) || errorStr.match(/detail[":\s]+"([^"]+)"/i);
if (match && match[1]) {
errorMessage = `Video generation failed: ${match[1]}`;
} else {
errorMessage = `Video generation failed: ${errorStr}`;
}
} else {
errorMessage = `Video generation failed: ${errorStr}`;
}
}
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");
pollingErrorCounts.current.delete(sceneId);
setRendering(null);
onError(errorMessage);
return true; // Stop polling
}
return status.status === "completed" || status.status === "failed";
return false; // Continue polling
} catch (error) {
console.error("Error polling task status:", error);
return false;
const errorCount = (pollingErrorCounts.current.get(sceneId) || 0) + 1;
pollingErrorCounts.current.set(sceneId, errorCount);
// Stop polling after 5 consecutive network errors
if (errorCount >= 5) {
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);
const errorMsg = error instanceof Error ? error.message : String(error);
onError(`Video generation failed: Unable to check status. ${errorMsg}`);
return true; // Stop polling
}
return false; // Continue polling (might be transient network error)
}
}, [onUpdateJob, onError]);
@@ -217,6 +440,7 @@ export const useRenderQueue = ({
sceneId: scene.id,
sceneTitle: scene.title,
sceneContent: sceneContent,
baseAvatarUrl: avatarImageUrl || undefined, // Use base avatar if available
width: 1024,
height: 1024,
});
@@ -239,68 +463,112 @@ export const useRenderQueue = ({
} finally {
setGeneratingImage(null);
}
}, [generatingImage, getScene, onUpdateJob, onError]);
}, [generatingImage, getScene, avatarImageUrl, onUpdateJob, onError, script]);
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)}`);
const runVideoRender = useCallback(
async (sceneId: string, settings?: VideoGenerationSettings) => {
if (rendering && rendering !== sceneId) {
return;
}
}
setRendering(sceneId);
onUpdateJob(sceneId, {
status: "running",
progress: 5,
});
const scene = getScene(sceneId);
if (!scene) {
return;
}
try {
const result = await podcastApi.generateVideo({
projectId,
sceneId,
sceneTitle: scene.title,
audioUrl: job.finalUrl,
avatarImageUrl: sceneImageUrl,
resolution: knobs.resolution || "720p",
});
// Guard: require image and audio before calling expensive video gen
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);
// Use job.finalUrl if available, otherwise fall back to scene.audioUrl (from Script Editor)
const audioUrl = job?.finalUrl || scene.audioUrl;
if (!audioUrl || audioUrl.startsWith("blob:")) {
onError("Please generate audio first before creating video.");
return;
}
// Guard: ensure every scene has audio and image before enabling render queue video
const allScenesHaveAudio = script.scenes.every((s) => s.audioUrl && !s.audioUrl.startsWith("blob:"));
const allScenesHaveImage = script.scenes.every((s) => s.imageUrl);
if (!allScenesHaveAudio || !allScenesHaveImage) {
onError("Please ensure all scenes have both audio and image before generating video.");
return;
}
// Resolution & simple cost heuristic (default 480p for lower cost)
const targetResolution: "480p" | "720p" =
settings?.resolution || (knobs.resolution as "480p" | "720p") || "480p";
const baseCost = 0.3; // 5s at 720p
const estimatedCost = targetResolution === "480p" ? baseCost / 2 : baseCost;
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, {
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]);
try {
console.log("[useRenderQueue] Starting video generation", {
sceneId,
sceneTitle: scene.title,
audioUrl,
avatarImageUrl: sceneImageUrl,
resolution: targetResolution,
prompt: settings?.prompt,
seed: settings?.seed,
maskImageUrl: settings?.maskImageUrl,
});
const result = await podcastApi.generateVideo({
projectId,
sceneId,
sceneTitle: scene.title,
audioUrl,
avatarImageUrl: sceneImageUrl,
resolution: targetResolution,
prompt: settings?.prompt || undefined,
seed: settings?.seed ?? -1,
maskImageUrl: settings?.maskImageUrl || undefined,
});
if (!result.taskId) {
throw new Error("Backend did not return a task ID. Response: " + JSON.stringify(result));
}
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);
}
},
[rendering, getScene, avatarImageUrl, jobs, budgetCap, projectId, knobs, onUpdateJob, onError, script.scenes, startPolling]
);
const combineAudio = useCallback(async () => {
try {
@@ -361,16 +629,151 @@ export const useRenderQueue = ({
}
}, [script.scenes, jobs, projectId, onError]);
const combineFinalVideo = useCallback(async () => {
try {
setCombiningVideos(true);
onError("");
// Collect all scene video URLs
const sceneVideoUrls: string[] = [];
for (const scene of script.scenes) {
const job = jobs.find((j) => j.sceneId === scene.id);
if (!job?.videoUrl) {
throw new Error(`Scene "${scene.title}" is missing a video. Please generate videos for all scenes first.`);
}
// Remove blob URLs and query params - use the API path only
let videoUrl = job.videoUrl;
if (videoUrl.startsWith("blob:")) {
throw new Error(`Scene "${scene.title}" has a blob URL. Cannot combine blob URLs. Please use API URLs.`);
}
videoUrl = videoUrl.split("?")[0]; // Remove query params
sceneVideoUrls.push(videoUrl);
}
console.log("[combineFinalVideo] Starting combination with", sceneVideoUrls.length, "videos");
// Start combination task
const result = await podcastApi.combineVideos({
projectId,
sceneVideoUrls,
podcastTitle: script.scenes[0]?.title || "Podcast",
});
console.log("[combineFinalVideo] Task created:", result.taskId);
// Poll for completion
const taskId = result.taskId;
let done = false;
let pollCount = 0;
const maxPolls = 300; // 10 minutes max (300 * 2 seconds) - encoding can take time
let lastProgress = 0;
let lastMessage = "Starting video combination...";
while (!done && pollCount < maxPolls) {
await new Promise((r) => setTimeout(r, 2000)); // Poll every 2 seconds
pollCount++;
const status = await podcastApi.pollTaskStatus(taskId);
// Update progress and message for user feedback
if (status) {
const currentProgress = status.progress ?? 0;
const currentMessage = status.message || "Processing...";
// Update UI with progress
setCombiningProgress({
progress: currentProgress,
message: currentMessage,
});
// Only log if progress or message changed to reduce noise
if (currentProgress !== lastProgress || currentMessage !== lastMessage) {
console.log(
`[combineFinalVideo] Poll ${pollCount}: ${status.status} | ` +
`Progress: ${currentProgress.toFixed(1)}% | Message: ${currentMessage}`
);
lastProgress = currentProgress;
lastMessage = currentMessage;
}
} else {
console.log(`[combineFinalVideo] Poll ${pollCount}: No status yet...`);
}
if (!status) {
// Don't fail immediately - task might still be initializing
if (pollCount < 10) {
continue; // Wait up to 20 seconds for task to appear
}
console.error("[combineFinalVideo] Task not found after 10 polls");
throw new Error("Task not found. Video combination may have failed on the server. Please try again.");
}
if (status.status === "completed") {
done = true;
const videoUrl = status.result?.video_url;
if (!videoUrl) {
console.error("[combineFinalVideo] No video URL in result:", status.result);
throw new Error("Final video URL not found in result. Please contact support.");
}
console.log("[combineFinalVideo] Success! Video URL:", videoUrl);
setFinalVideoUrl(videoUrl);
// Save final video URL to project for persistence across reloads
try {
await podcastApi.saveProject(projectId, { final_video_url: videoUrl });
console.log("[combineFinalVideo] Saved final video URL to project");
} catch (error) {
console.warn("[combineFinalVideo] Failed to save final video URL to project:", error);
// Don't fail the operation if project save fails - video is still available
}
} else if (status.status === "failed") {
const errorMsg = status.error || status.message || "Video combination failed";
console.error("[combineFinalVideo] Task failed:", errorMsg);
throw new Error(`Video combination failed: ${errorMsg}`);
}
}
if (pollCount >= maxPolls) {
throw new Error("Video combination timed out after 10 minutes. The video may still be processing. Please check back in a few minutes or try again.");
}
} catch (error: any) {
console.error("[combineFinalVideo] Error:", error);
// Extract detailed error message
let message = "Failed to combine videos";
if (error?.response?.data?.detail) {
// Backend error with detail
message = error.response.data.detail;
} else if (error?.message) {
// Standard error message
message = error.message;
} else if (typeof error === "string") {
message = error;
}
console.error("[combineFinalVideo] Displaying error to user:", message);
onError(message);
} finally {
setCombiningVideos(false);
setCombiningProgress(null);
}
}, [script.scenes, jobs, projectId, onError]);
return {
rendering,
generatingImage,
combiningAudio,
combinedAudioResult,
combiningVideos,
combiningProgress,
finalVideoUrl,
isBusy: Boolean(rendering),
runRender,
runImageGeneration,
runVideoRender,
combineAudio,
combineFinalVideo,
};
};

View File

@@ -0,0 +1,464 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Box,
Typography,
Slider,
Select,
MenuItem,
FormControl,
InputLabel,
FormControlLabel,
Checkbox,
Tooltip,
IconButton,
alpha,
TextField,
} from "@mui/material";
import { HelpOutline as HelpOutlineIcon, Close as CloseIcon } from "@mui/icons-material";
import { PrimaryButton, SecondaryButton } from "../ui";
export type AudioGenerationSettings = {
voiceId: string;
speed: number;
volume: number;
pitch: number;
emotion: string;
englishNormalization: boolean;
sampleRate?: number;
bitrate?: number;
channel?: "1" | "2";
format?: "mp3" | "wav" | "pcm" | "flac";
languageBoost?: string;
};
interface AudioRegenerateModalProps {
open: boolean;
onClose: () => void;
onRegenerate: (settings: AudioGenerationSettings) => void;
initialSettings: AudioGenerationSettings;
isGenerating?: boolean;
}
const VOICE_OPTIONS = [
"Wise_Woman",
"Friendly_Person",
"Inspirational_girl",
"Deep_Voice_Man",
"Calm_Woman",
"Casual_Guy",
"Lively_Girl",
"Patient_Man",
"Young_Knight",
"Determined_Man",
"Lovely_Girl",
"Decent_Boy",
"Imposing_Manner",
"Elegant_Man",
"Abbess",
"Sweet_Girl_2",
"Exuberant_Girl",
];
const EMOTION_OPTIONS = ["happy", "sad", "angry", "fearful", "disgusted", "surprised", "neutral"];
const SAMPLE_RATE_OPTIONS = [8000, 16000, 22050, 24000, 32000, 44100];
const BITRATE_OPTIONS = [32000, 64000, 128000, 256000];
const LANGUAGE_BOOST_OPTIONS = [
"auto",
"English",
"Chinese",
"Chinese,Yue",
"Arabic",
"Russian",
"Spanish",
"French",
"Portuguese",
"German",
"Turkish",
"Dutch",
"Ukrainian",
"Vietnamese",
"Indonesian",
"Japanese",
"Italian",
"Korean",
"Thai",
"Polish",
"Romanian",
"Greek",
"Czech",
"Finnish",
"Hindi",
];
export const AudioRegenerateModal: React.FC<AudioRegenerateModalProps> = ({
open,
onClose,
onRegenerate,
initialSettings,
isGenerating = false,
}) => {
const [settings, setSettings] = useState<AudioGenerationSettings>(initialSettings);
useEffect(() => {
setSettings(initialSettings);
}, [initialSettings]);
const handleRegenerate = () => {
onRegenerate(settings);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: alpha("#0f172a", 0.95),
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 4,
},
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ color: "white", fontWeight: 600 }}>
Regenerate Audio with Custom Settings
</Typography>
<IconButton onClick={onClose} size="small" sx={{ color: "rgba(255,255,255,0.7)" }}>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.6)", mt: 1 }}>
Adjust voice, speed, tone, and quality. Changes apply only to this scene.
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Voice */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Voice
</Typography>
<Tooltip title="Choose a system voice or your custom trained voice ID." arrow>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={settings.voiceId}
onChange={(e) => setSettings({ ...settings, voiceId: e.target.value })}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused .MuiOutlinedInput-notchedOutline": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
}}
>
{VOICE_OPTIONS.map((v) => (
<MenuItem key={v} value={v}>
{v}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{/* Speed / Volume / Pitch */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 600 }}>
Speed (0.5-2.0)
</Typography>
<Tooltip title="Control how fast the voice speaks. 1.0 is normal." arrow>
<HelpOutlineIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
</Tooltip>
</Stack>
<Slider
value={settings.speed}
min={0.5}
max={2.0}
step={0.05}
onChange={(_, v) => setSettings({ ...settings, speed: v as number })}
sx={{ color: "#6366f1" }}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Slower (narrative) Faster (conversational). Impacts duration.
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 600 }}>
Volume (0.1-10)
</Typography>
<Tooltip title="Loudness of the voice. 1.0 is normal loudness." arrow>
<HelpOutlineIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
</Tooltip>
</Stack>
<Slider
value={settings.volume}
min={0.1}
max={10}
step={0.1}
onChange={(_, v) => setSettings({ ...settings, volume: v as number })}
sx={{ color: "#10b981" }}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Lower for soft tone; higher for punchier delivery.
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 600 }}>
Pitch (-12 to 12)
</Typography>
<Tooltip title="Tone of the voice. 0 is neutral. Negative is deeper; positive is brighter." arrow>
<HelpOutlineIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
</Tooltip>
</Stack>
<Slider
value={settings.pitch}
min={-12}
max={12}
step={0.5}
onChange={(_, v) => setSettings({ ...settings, pitch: v as number })}
sx={{ color: "#f97316" }}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Use small adjustments (±2) for natural results.
</Typography>
</Box>
</Stack>
{/* Emotion */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Emotion
</Typography>
<Tooltip title="Sets the vocal mood: happy, neutral, sad, etc." arrow>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={settings.emotion}
onChange={(e) => setSettings({ ...settings, emotion: e.target.value })}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused .MuiOutlinedInput-notchedOutline": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
}}
>
{EMOTION_OPTIONS.map((e) => (
<MenuItem key={e} value={e}>
{e}
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", mt: 0.5, display: "block" }}>
Tip: happy/neutral for most podcasts; sad/angry for dramatic or critical segments.
</Typography>
</Box>
{/* Normalization & Language */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ flex: 1 }}>
<FormControlLabel
control={
<Checkbox
checked={settings.englishNormalization}
onChange={(e) => setSettings({ ...settings, englishNormalization: e.target.checked })}
sx={{ color: "rgba(255,255,255,0.7)" }}
/>
}
label={
<Typography variant="body2" sx={{ color: "white" }}>
English normalization (better numbers/dates)
</Typography>
}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Improves pronunciation of numbers/dates (recommended for stats-heavy scenes).
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Language boost"
value={settings.languageBoost || "auto"}
onChange={(e) => setSettings({ ...settings, languageBoost: e.target.value })}
SelectProps={{ native: false }}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
{LANGUAGE_BOOST_OPTIONS.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</TextField>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", mt: 0.5, display: "block" }}>
Helps with language-specific pronunciation and accent.
</Typography>
</Box>
</Stack>
{/* Quality & Format */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Sample rate"
value={settings.sampleRate || 24000}
onChange={(e) => setSettings({ ...settings, sampleRate: Number(e.target.value) })}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
{SAMPLE_RATE_OPTIONS.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt} Hz
</MenuItem>
))}
</TextField>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", mt: 0.5, display: "block" }}>
Higher sample rate = higher fidelity (24k+ recommended for podcast voice).
</Typography>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Bitrate"
value={settings.bitrate || 64000}
onChange={(e) => setSettings({ ...settings, bitrate: Number(e.target.value) })}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
{BITRATE_OPTIONS.map((opt) => (
<MenuItem key={opt} value={opt}>
{opt / 1000} kbps
</MenuItem>
))}
</TextField>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", mt: 0.5, display: "block" }}>
Higher bitrate = larger file but clearer audio. 64128 kbps is great for voice.
</Typography>
</Box>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Channel"
value={settings.channel || "1"}
onChange={(e) => setSettings({ ...settings, channel: e.target.value as "1" | "2" })}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
<MenuItem value="1">Mono (smaller, voice-focused)</MenuItem>
<MenuItem value="2">Stereo (wider, more presence)</MenuItem>
</TextField>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
select
fullWidth
label="Format"
value={settings.format || "mp3"}
onChange={(e) => setSettings({ ...settings, format: e.target.value as "mp3" | "wav" | "pcm" | "flac" })}
InputLabelProps={{ sx: { color: "rgba(255,255,255,0.7)" } }}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
"&.Mui-focused fieldset": { borderColor: "#667eea" },
"& .MuiSvgIcon-root": { color: "rgba(255,255,255,0.7)" },
},
}}
>
<MenuItem value="mp3">mp3 (small, universal)</MenuItem>
<MenuItem value="wav">wav (uncompressed)</MenuItem>
<MenuItem value="pcm">pcm (raw)</MenuItem>
<MenuItem value="flac">flac (lossless)</MenuItem>
</TextField>
</Box>
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 2 }}>
<SecondaryButton onClick={onClose} disabled={isGenerating}>
Cancel
</SecondaryButton>
<PrimaryButton onClick={handleRegenerate} loading={isGenerating} disabled={isGenerating}>
{isGenerating ? "Generating..." : "Apply & Regenerate"}
</PrimaryButton>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,563 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
Box,
Typography,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Divider,
alpha,
Tooltip,
IconButton,
Paper,
} from "@mui/material";
import {
Info as InfoIcon,
HelpOutline as HelpOutlineIcon,
Close as CloseIcon,
} from "@mui/icons-material";
import { PrimaryButton, SecondaryButton } from "../ui";
type PresetKey = "studioNeutral" | "warmBroadcast" | "techModern";
const PRESETS: Record<
PresetKey,
{
title: string;
subtitle: string;
prompt: string;
style: "Auto" | "Fiction" | "Realistic";
renderingSpeed: "Default" | "Turbo" | "Quality";
aspectRatio: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
}
> = {
studioNeutral: {
title: "Studio Neutral",
subtitle: "Clean, well-lit studio, neutral background",
prompt:
"Professional podcast studio, neutral light grey backdrop, soft key + fill lighting, subtle depth of field, clear microphone framing",
style: "Realistic",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
warmBroadcast: {
title: "Warm Broadcast",
subtitle: "Warm tones, friendly and inviting broadcast desk",
prompt:
"Warm broadcast desk, soft amber lighting, cozy ambience, gentle vignette, inviting expression, polished but approachable look",
style: "Realistic",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
techModern: {
title: "Tech Modern",
subtitle: "Crisp, modern look with cool accent lighting",
prompt:
"Modern tech podcast set, cool accent lights (teal/purple), minimal backdrop, crisp highlights, premium camera look, subtle bokeh",
style: "Auto",
renderingSpeed: "Quality",
aspectRatio: "16:9",
},
};
export interface ImageGenerationSettings {
prompt: string;
style: "Auto" | "Fiction" | "Realistic";
renderingSpeed: "Default" | "Turbo" | "Quality";
aspectRatio: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
}
interface ImageRegenerateModalProps {
open: boolean;
onClose: () => void;
onRegenerate: (settings: ImageGenerationSettings) => void;
initialPrompt: string;
initialStyle?: "Auto" | "Fiction" | "Realistic";
initialRenderingSpeed?: "Default" | "Turbo" | "Quality";
initialAspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
isGenerating?: boolean;
}
export const ImageRegenerateModal: React.FC<ImageRegenerateModalProps> = ({
open,
onClose,
onRegenerate,
initialPrompt,
initialStyle = "Realistic",
initialRenderingSpeed = "Quality",
initialAspectRatio = "16:9",
isGenerating = false,
}) => {
const [prompt, setPrompt] = useState(initialPrompt);
const [style, setStyle] = useState<"Auto" | "Fiction" | "Realistic">(initialStyle);
const [renderingSpeed, setRenderingSpeed] = useState<"Default" | "Turbo" | "Quality">(initialRenderingSpeed);
const [aspectRatio, setAspectRatio] = useState<"1:1" | "16:9" | "9:16" | "4:3" | "3:4">(initialAspectRatio);
// Update state when initial values change
useEffect(() => {
setPrompt(initialPrompt);
setStyle(initialStyle);
setRenderingSpeed(initialRenderingSpeed);
setAspectRatio(initialAspectRatio);
}, [initialPrompt, initialStyle, initialRenderingSpeed, initialAspectRatio]);
const handleRegenerate = () => {
onRegenerate({
prompt,
style,
renderingSpeed,
aspectRatio,
});
};
const applyPreset = (presetKey: PresetKey) => {
const p = PRESETS[presetKey];
// Combine the preset prompt with current scene prompt context
setPrompt((current) => {
// If user already customized, append; otherwise replace with preset
if (!current || current.trim() === "" || current.trim() === initialPrompt.trim()) {
return `${initialPrompt}\n${p.prompt}`.trim();
}
return `${current}\n${p.prompt}`.trim();
});
setStyle(p.style);
setRenderingSpeed(p.renderingSpeed);
setAspectRatio(p.aspectRatio);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: alpha("#0f172a", 0.95),
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 4,
},
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h6" sx={{ color: "white", fontWeight: 600 }}>
Regenerate Image with Custom Settings
</Typography>
<IconButton
onClick={onClose}
size="small"
sx={{ color: "rgba(255,255,255,0.7)" }}
>
<CloseIcon />
</IconButton>
</Stack>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.6)", mt: 1 }}>
Customize the image generation parameters to get the perfect result for your scene
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Presets */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Podcast-ready presets
</Typography>
<Tooltip
title="Quickly apply a podcast-friendly look. Each preset adjusts lighting, background, and ratio while keeping your base avatar consistent."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
{(
Object.entries(PRESETS) as Array<[PresetKey, (typeof PRESETS)[PresetKey]]>
).map(([key, p]) => (
<Paper
key={key}
onClick={() => applyPreset(key)}
sx={{
p: 1.5,
flex: 1,
cursor: "pointer",
backgroundColor: alpha("#ffffff", 0.04),
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 2,
transition: "all 0.2s ease",
"&:hover": {
borderColor: "rgba(102,126,234,0.7)",
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
backgroundColor: alpha("#667eea", 0.08),
},
}}
>
<Typography variant="subtitle2" sx={{ color: "white", fontWeight: 700 }}>
{p.title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.5, mb: 0.75 }}>
{p.subtitle}
</Typography>
<Stack direction="row" spacing={1} sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.8rem" }}>
<Typography variant="caption">Style: {p.style}</Typography>
<Typography variant="caption">Speed: {p.renderingSpeed}</Typography>
<Typography variant="caption">AR: {p.aspectRatio}</Typography>
</Stack>
</Paper>
))}
</Stack>
</Box>
{/* Prompt Section */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Generation Prompt
</Typography>
<Tooltip
title="The prompt describes what you want to see in the generated image. It should include scene context, visual elements, and style preferences. The AI will use this along with your base avatar to create a consistent character in the scene."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<TextField
fullWidth
multiline
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the scene, visual elements, and style..."
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& fieldset": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover fieldset": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused fieldset": {
borderColor: "#667eea",
},
},
"& .MuiInputBase-input": {
color: "white",
},
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.5)", mt: 0.5, display: "block" }}>
This prompt will be combined with scene context to generate your image. Be specific about visual elements, mood, and composition.
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
{/* Style Selection */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Character Style
</Typography>
<Tooltip
title="Determines the artistic style of the character generation. Auto lets the AI choose, Fiction creates more stylized/artistic characters, and Realistic produces photorealistic results."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={style}
onChange={(e) => setStyle(e.target.value as "Auto" | "Fiction" | "Realistic")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="Auto">
<Stack>
<Typography sx={{ color: "white" }}>Auto</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
AI automatically selects the best style
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Fiction">
<Stack>
<Typography sx={{ color: "white" }}>Fiction</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Stylized, artistic character appearance
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Realistic">
<Stack>
<Typography sx={{ color: "white" }}>Realistic</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Photorealistic, professional appearance
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#667eea", 0.1),
border: "1px solid rgba(102,126,234,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#667eea", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Style Impact:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>Auto:</strong> Best for most cases, balances realism and style<br />
<strong>Fiction:</strong> Great for creative, artistic podcasts with stylized visuals<br />
<strong>Realistic:</strong> Ideal for professional, corporate, or news-style podcasts
</Typography>
</Box>
</Stack>
</Paper>
</Box>
{/* Rendering Speed */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Rendering Speed
</Typography>
<Tooltip
title="Controls the balance between generation speed, cost, and quality. Turbo is fastest and cheapest but lower quality. Quality is slowest and most expensive but produces the best results. Default provides a balanced approach."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={renderingSpeed}
onChange={(e) => setRenderingSpeed(e.target.value as "Default" | "Turbo" | "Quality")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="Turbo">
<Stack>
<Typography sx={{ color: "white" }}>Turbo </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Fastest (~10-20s) Cheapest Lower quality
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Default">
<Stack>
<Typography sx={{ color: "white" }}>Default </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Balanced (~30-60s) Moderate cost Good quality
</Typography>
</Stack>
</MenuItem>
<MenuItem value="Quality">
<Stack>
<Typography sx={{ color: "white" }}>Quality </Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Slowest (~60-120s) Most expensive Highest quality
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#10b981", 0.1),
border: "1px solid rgba(16,185,129,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#10b981", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Speed vs Quality Trade-off:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>Turbo:</strong> Use for quick iterations and testing (~$0.02/image)<br />
<strong>Default:</strong> Best balance for most production use (~$0.04/image)<br />
<strong>Quality:</strong> Use for final, high-quality outputs (~$0.08/image)
</Typography>
</Box>
</Stack>
</Paper>
</Box>
{/* Aspect Ratio */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "white", fontWeight: 600 }}>
Aspect Ratio
</Typography>
<Tooltip
title="The width-to-height ratio of the generated image. Choose based on your video format: 16:9 for standard widescreen, 9:16 for vertical/social media, 1:1 for square formats, or 4:3 for traditional formats."
arrow
>
<IconButton size="small" sx={{ color: "rgba(255,255,255,0.5)" }}>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<FormControl fullWidth>
<Select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value as "1:1" | "16:9" | "9:16" | "4:3" | "3:4")}
sx={{
backgroundColor: alpha("#ffffff", 0.05),
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.2)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255,255,255,0.3)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "#667eea",
},
"& .MuiSvgIcon-root": {
color: "rgba(255,255,255,0.7)",
},
}}
>
<MenuItem value="16:9">
<Stack>
<Typography sx={{ color: "white" }}>16:9 (Widescreen)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Standard video format, best for YouTube, web
</Typography>
</Stack>
</MenuItem>
<MenuItem value="9:16">
<Stack>
<Typography sx={{ color: "white" }}>9:16 (Vertical)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Mobile/social media format (TikTok, Instagram Stories)
</Typography>
</Stack>
</MenuItem>
<MenuItem value="1:1">
<Stack>
<Typography sx={{ color: "white" }}>1:1 (Square)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Instagram posts, profile images
</Typography>
</Stack>
</MenuItem>
<MenuItem value="4:3">
<Stack>
<Typography sx={{ color: "white" }}>4:3 (Traditional)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Classic TV format, presentations
</Typography>
</Stack>
</MenuItem>
<MenuItem value="3:4">
<Stack>
<Typography sx={{ color: "white" }}>3:4 (Portrait)</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)" }}>
Portrait orientation, mobile apps
</Typography>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Paper
sx={{
mt: 1.5,
p: 1.5,
backgroundColor: alpha("#f59e0b", 0.1),
border: "1px solid rgba(245,158,11,0.3)",
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1}>
<InfoIcon sx={{ color: "#f59e0b", fontSize: "1.2rem", mt: 0.1 }} />
<Box>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.9)", fontWeight: 500, mb: 0.5 }}>
Format Recommendation:
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", lineHeight: 1.6 }}>
<strong>16:9</strong> is recommended for most podcast videos as it matches standard video player dimensions and provides optimal viewing experience.
</Typography>
</Box>
</Stack>
</Paper>
</Box>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 2 }}>
<SecondaryButton onClick={onClose} disabled={isGenerating}>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={handleRegenerate}
loading={isGenerating}
disabled={!prompt.trim() || isGenerating}
>
{isGenerating ? "Generating..." : "Regenerate Image"}
</PrimaryButton>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress } from "@mui/material";
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress, LinearProgress } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
@@ -11,6 +11,8 @@ import {
import { Scene, Line, Knobs } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor";
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
import { podcastApi } from "../../../services/podcastApi";
import { aiApiClient } from "../../../api/client";
@@ -24,6 +26,7 @@ interface SceneEditorProps {
onAudioGenerationStart?: (sceneId: string) => void;
onAudioGenerated?: (sceneId: string, audioUrl: string) => void;
idea?: string; // Podcast idea for image generation context
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
}
export const SceneEditor: React.FC<SceneEditorProps> = ({
@@ -36,10 +39,30 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
onAudioGenerationStart,
onAudioGenerated,
idea,
avatarUrl,
}) => {
const [localGenerating, setLocalGenerating] = useState(false);
const [generatingImage, setGeneratingImage] = useState(false);
const [imageGenerationStatus, setImageGenerationStatus] = useState<string>("");
const [imageGenerationProgress, setImageGenerationProgress] = useState<number>(0);
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
const [imageLoading, setImageLoading] = useState(false);
const [showRegenerateModal, setShowRegenerateModal] = useState(false);
const [showAudioModal, setShowAudioModal] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
voiceId: "Wise_Woman",
speed: 1.0,
volume: 1.0,
pitch: 0.0,
emotion: scene.emotion || "neutral",
englishNormalization: true,
sampleRate: 24000,
bitrate: 64000,
channel: "1",
format: "mp3",
languageBoost: "auto",
});
// Load audio as blob when audioUrl is available
useEffect(() => {
@@ -116,6 +139,99 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
};
}, [scene.audioUrl, scene.id]);
// Load image as blob when imageUrl is available
useEffect(() => {
if (!scene.imageUrl) {
// Clean up blob URL if imageUrl is removed
setImageBlobUrl((currentBlobUrl) => {
if (currentBlobUrl && currentBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentBlobUrl);
}
return null;
});
return;
}
let isMounted = true;
const currentImageUrl = scene.imageUrl; // Capture current value
const loadImageBlob = async () => {
try {
setImageLoading(true);
// 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];
const response = await aiApiClient.get(imagePath, {
responseType: 'blob',
});
if (!isMounted) {
return;
}
// Double-check that imageUrl hasn't changed
if (scene.imageUrl !== currentImageUrl) {
return;
}
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
setImageBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return blobUrl;
});
} catch (error) {
console.error('[SceneEditor] Failed to load image blob:', error);
// Fallback: try with query token
try {
const token = localStorage.getItem('clerk_dashboard_token') || '';
if (token) {
const urlWithToken = `${currentImageUrl}?token=${encodeURIComponent(token)}`;
setImageBlobUrl(urlWithToken);
}
} catch (fallbackError) {
console.error('[SceneEditor] Fallback image loading failed:', fallbackError);
}
} finally {
if (isMounted) {
setImageLoading(false);
}
}
};
loadImageBlob();
return () => {
isMounted = false;
// Cleanup blob URL on unmount or when imageUrl changes
setImageBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
};
}, [scene.imageUrl]);
const updateLine = (updatedLine: Line) => {
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
onUpdateScene(updated);
@@ -126,7 +242,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
const hasImage = Boolean(scene.imageUrl);
const handleApproveAndGenerate = async () => {
const handleApproveAndGenerate = async (settings?: AudioGenerationSettings) => {
const wasAlreadyApproved = scene.approved;
const sceneId = scene.id;
@@ -152,11 +268,20 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const currentScene = { ...scene, approved: true };
// Generate audio
const effectiveSettings = settings || audioSettings;
const result = await podcastApi.renderSceneAudio({
scene: currentScene,
voiceId: "Wise_Woman",
emotion: scene.emotion || knobs.voice_emotion || "neutral",
speed: knobs.voice_speed || 1.0,
voiceId: effectiveSettings.voiceId || "Wise_Woman",
emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral",
speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0,
volume: effectiveSettings.volume ?? 1.0,
pitch: effectiveSettings.pitch ?? 0.0,
englishNormalization: effectiveSettings.englishNormalization ?? true,
sampleRate: effectiveSettings.sampleRate,
bitrate: effectiveSettings.bitrate,
channel: effectiveSettings.channel,
format: effectiveSettings.format,
languageBoost: effectiveSettings.languageBoost,
});
// Update scene with audio URL and ensure approved state
@@ -179,35 +304,138 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
}
};
const handleGenerateImage = async () => {
const handleGenerateImage = async (settings?: ImageGenerationSettings) => {
const sceneId = scene.id;
const startTime = Date.now();
let progressInterval: NodeJS.Timeout | null = null;
try {
setGeneratingImage(true);
setShowRegenerateModal(false);
setImageGenerationStatus("Submitting image generation request...");
setImageGenerationProgress(10);
// Build scene content from lines for context
const sceneContent = scene.lines.map((line) => line.text).join(" ");
// Log avatar URL for debugging
console.log("[SceneEditor] Generating image with avatarUrl:", avatarUrl);
console.log("[SceneEditor] Custom settings:", settings);
// Simulate progress updates during API call
progressInterval = setInterval(() => {
const elapsed = Date.now() - startTime;
const seconds = Math.floor(elapsed / 1000);
// Update status based on elapsed time
if (seconds < 5) {
setImageGenerationStatus("Submitting request to AI service...");
setImageGenerationProgress(15);
} else if (seconds < 15) {
setImageGenerationStatus("AI is generating your image...");
setImageGenerationProgress(30);
} else if (seconds < 30) {
setImageGenerationStatus("Creating character-consistent scene image...");
setImageGenerationProgress(50);
} else if (seconds < 60) {
setImageGenerationStatus("Rendering image details...");
setImageGenerationProgress(70);
} else {
setImageGenerationStatus(`Processing... (${seconds}s elapsed)`);
setImageGenerationProgress(Math.min(90, 50 + (seconds - 30) / 2));
}
}, 1000);
const result = await podcastApi.generateSceneImage({
sceneId: scene.id,
sceneTitle: scene.title,
sceneContent: sceneContent,
baseAvatarUrl: avatarUrl || undefined, // Pass base avatar URL for character consistency
idea: idea,
width: 1024,
height: 1024,
// Pass custom settings if provided
customPrompt: settings?.prompt,
style: settings?.style,
renderingSpeed: settings?.renderingSpeed,
aspectRatio: settings?.aspectRatio,
});
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
setImageGenerationStatus("Finalizing image...");
setImageGenerationProgress(95);
// Update scene with image URL
const updatedScene = { ...scene, imageUrl: result.image_url };
onUpdateScene(updatedScene);
} catch (error) {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setImageGenerationStatus(`Image generated successfully in ${elapsed}s`);
setImageGenerationProgress(100);
// Clear status after a moment
setTimeout(() => {
setImageGenerationStatus("");
setImageGenerationProgress(0);
}, 2000);
} catch (error: any) {
// Clear interval on error
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
console.error("Failed to generate image:", error);
// Extract error message from response if available
const errorMessage = error?.response?.data?.detail?.message
|| error?.response?.data?.detail?.error
|| error?.response?.data?.detail
|| error?.message
|| "Failed to generate image. Please try again.";
console.error("Error details:", {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
message: errorMessage,
});
setImageGenerationStatus(`Error: ${errorMessage}`);
setImageGenerationProgress(0);
// Show user-friendly error message
alert(`Image generation failed: ${errorMessage}`);
throw error;
} finally {
// Ensure interval is cleared
if (progressInterval) {
clearInterval(progressInterval);
}
setGeneratingImage(false);
}
};
const handleRegenerateClick = () => {
setShowRegenerateModal(true);
};
const handleAudioRegenerateClick = () => {
if (hasAudio) {
setShowAudioModal(true);
} else {
handleApproveAndGenerate(audioSettings);
}
};
const handleAudioRegenerate = (settings: AudioGenerationSettings) => {
setAudioSettings(settings);
setShowAudioModal(false);
handleApproveAndGenerate(settings);
};
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2.5}>
@@ -256,7 +484,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
</Box>
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
<PrimaryButton
onClick={handleApproveAndGenerate}
onClick={handleAudioRegenerateClick}
disabled={approving || generating}
loading={approving || generating}
startIcon={
@@ -270,7 +498,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
}
tooltip={
hasAudio && !generating
? "Regenerate audio for this scene"
? "Regenerate audio for this scene with custom settings"
: generating
? "Generating audio..."
: scene.approved
@@ -290,7 +518,7 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
: "Approve & Generate Audio"}
</PrimaryButton>
<PrimaryButton
onClick={handleGenerateImage}
onClick={hasImage ? handleRegenerateClick : () => handleGenerateImage()}
disabled={generatingImage}
loading={generatingImage}
startIcon={
@@ -372,7 +600,157 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
</Box>
</>
)}
{/* Image Generation Progress - Show when generating */}
{generatingImage && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
borderRadius: 2,
border: "1px solid rgba(102, 126, 234, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<ImageIcon sx={{ color: "#667eea", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: "#667eea", fontWeight: 600 }}>
Generating Image...
</Typography>
</Stack>
{/* Progress Bar */}
<Box sx={{ mb: 1.5 }}>
<LinearProgress
variant="determinate"
value={imageGenerationProgress}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: alpha("#667eea", 0.1),
"& .MuiLinearProgress-bar": {
backgroundColor: "#667eea",
borderRadius: 4,
}
}}
/>
<Typography variant="caption" sx={{ color: "#667eea", mt: 0.5, display: "block", textAlign: "right" }}>
{imageGenerationProgress}%
</Typography>
</Box>
{/* Status Message */}
{imageGenerationStatus && (
<Typography variant="body2" sx={{ color: "#667eea", fontSize: "0.875rem", lineHeight: 1.6, mb: 1 }}>
{imageGenerationStatus}
</Typography>
)}
{/* Spinner */}
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", mt: 1 }}>
<CircularProgress size={32} sx={{ color: "#667eea" }} />
</Box>
</Box>
</>
)}
{/* Generated Image Display - Show when image exists and not generating */}
{scene.imageUrl && !generatingImage && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: imageBlobUrl && !imageLoading
? "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.08) 100%)",
borderRadius: 2,
border: imageBlobUrl && !imageLoading
? "1px solid rgba(102, 126, 234, 0.2)"
: "1px solid rgba(245, 158, 11, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<ImageIcon sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: imageBlobUrl && !imageLoading ? "#667eea" : "#d97706", fontWeight: 600 }}>
{imageBlobUrl && !imageLoading ? "Image Generated" : "Loading Image..."}
</Typography>
</Stack>
{imageBlobUrl && !imageLoading ? (
<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}
alt={scene.title}
sx={{
width: "100%",
height: "auto",
display: "block",
maxHeight: 400,
objectFit: "cover",
}}
onError={(e) => {
console.error('[SceneEditor] Image failed to load:', {
src: e.currentTarget.src,
imageUrl: scene.imageUrl,
imageBlobUrl,
});
}}
onLoad={() => {
console.log('[SceneEditor] Image loaded successfully');
}}
/>
</Box>
) : (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", py: 2 }}>
<CircularProgress size={24} sx={{ color: "#d97706" }} />
</Box>
)}
</Box>
</>
)}
</Stack>
{/* Image Regeneration Modal */}
<ImageRegenerateModal
open={showRegenerateModal}
onClose={() => setShowRegenerateModal(false)}
onRegenerate={handleGenerateImage}
initialPrompt={(() => {
const promptParts = [
`Scene: ${scene.title}`,
"Professional podcast recording studio",
"Modern microphone setup",
"Clean background, professional lighting",
"16:9 aspect ratio, video-optimized composition"
];
if (idea) {
promptParts.push(`Topic: ${idea.substring(0, 60)}`);
}
return promptParts.join(", ");
})()}
initialStyle="Realistic"
initialRenderingSpeed="Quality"
initialAspectRatio="16:9"
isGenerating={generatingImage}
/>
<AudioRegenerateModal
open={showAudioModal}
onClose={() => setShowAudioModal(false)}
onRegenerate={handleAudioRegenerate}
initialSettings={audioSettings}
isGenerating={generating}
/>
</GlassyCard>
);
};

View File

@@ -22,6 +22,7 @@ interface ScriptEditorProps {
onBackToResearch: () => void;
onProceedToRendering: (script: Script) => void;
onError: (message: string) => void;
avatarUrl?: string | null; // Base avatar URL for consistent scene image generation
}
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
@@ -37,6 +38,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
onBackToResearch,
onProceedToRendering,
onError,
avatarUrl,
}) => {
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
@@ -52,6 +54,12 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
sceneCount: number;
} | null>(null);
// Defer upward script updates to avoid setState during render warnings
const emitScriptChange = useCallback(
(next: Script) => Promise.resolve().then(() => onScriptChange(next)),
[onScriptChange]
);
// Sync with parent state
useEffect(() => {
if (initialScript) {
@@ -85,7 +93,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
.then((res) => {
if (mounted) {
setScript(res);
onScriptChange(res);
emitScriptChange(res);
setError(null);
}
})
@@ -108,7 +116,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
};
onScriptChange(updatedScript);
emitScriptChange(updatedScript);
return updatedScript;
});
};
@@ -124,7 +132,7 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
};
onScriptChange(updatedScript);
emitScriptChange(updatedScript);
return updatedScript;
});
} catch (err) {
@@ -570,11 +578,12 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
s.id === sceneId ? { ...s, audioUrl, approved: true } : s
);
const updatedScript = { ...currentScript, scenes: updatedScenes };
onScriptChange(updatedScript);
emitScriptChange(updatedScript);
return updatedScript;
});
}}
idea={idea}
avatarUrl={avatarUrl}
/>
</GlassyCard>
))}

View File

@@ -120,6 +120,7 @@ export type CreateProjectPayload = {
knobs: Knobs;
budgetCap: number;
files: { voiceFile?: File | null; avatarFile?: File | null };
avatarUrl?: string | null;
};
export type CreateProjectResult = {
@@ -141,6 +142,13 @@ export type RenderJobResult = {
videoFilename?: string;
};
export interface VideoGenerationSettings {
prompt: string;
resolution: "480p" | "720p";
seed?: number | null;
maskImageUrl?: string | null;
}
export type TaskStatus = {
task_id: string;
status: "pending" | "processing" | "completed" | "failed";

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Button, Tooltip, CircularProgress, alpha } from "@mui/material";
import { Button, Tooltip, CircularProgress, alpha, SxProps, Theme } from "@mui/material";
interface SecondaryButtonProps {
children: React.ReactNode;
@@ -9,6 +9,7 @@ interface SecondaryButtonProps {
startIcon?: React.ReactNode;
tooltip?: string;
ariaLabel?: string;
sx?: SxProps<Theme>;
}
export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
@@ -19,6 +20,7 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
startIcon,
tooltip,
ariaLabel,
sx,
}) => {
const button = (
<Button
@@ -27,17 +29,20 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
disabled={disabled || loading}
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
aria-label={ariaLabel}
sx={{
borderColor: "rgba(255,255,255,0.2)",
color: "rgba(255,255,255,0.9)",
textTransform: "none",
px: 2.5,
py: 0.75,
"&:hover": {
borderColor: "rgba(255,255,255,0.4)",
background: alpha("#fff", 0.05),
sx={[
{
borderColor: "rgba(255,255,255,0.2)",
color: "rgba(255,255,255,0.9)",
textTransform: "none",
px: 2.5,
py: 0.75,
"&:hover": {
borderColor: "rgba(255,255,255,0.4)",
background: alpha("#fff", 0.05),
},
},
}}
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
]}
>
{children}
</Button>