AI podcast maker performance optimizations

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

View File

@@ -508,6 +508,7 @@ const ContentLifecyclePillars: React.FC = () => {
borderRadius: 2,
mb: 4,
position: 'relative', // For hero section positioning
minHeight: '200px', // Reserve space for hero section to prevent layout shift
}}
>
<Container maxWidth="xl">

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Container,
@@ -84,15 +84,17 @@ const MainDashboard: React.FC = () => {
initializeWorkflow();
}, [generateDailyWorkflow]);
// Debug logging for workflow state
// Debug logging for workflow state (only in development)
React.useEffect(() => {
console.log('Workflow Debug:', {
currentWorkflow,
workflowProgress,
isWorkflowActive: currentWorkflow?.workflowStatus === 'in_progress',
workflowStatus: currentWorkflow?.workflowStatus,
hasWorkflow: !!currentWorkflow
});
if (process.env.NODE_ENV === 'development') {
console.log('Workflow Debug:', {
currentWorkflow,
workflowProgress,
isWorkflowActive: currentWorkflow?.workflowStatus === 'in_progress',
workflowStatus: currentWorkflow?.workflowStatus,
hasWorkflow: !!currentWorkflow
});
}
}, [currentWorkflow, workflowProgress]);
// State to track if we need to start a newly generated workflow
@@ -166,42 +168,50 @@ const MainDashboard: React.FC = () => {
}
};
const handleToolClick = (tool: Tool) => {
console.log('Navigating to tool:', tool.path);
const handleToolClick = useCallback((tool: Tool) => {
if (process.env.NODE_ENV === 'development') {
console.log('Navigating to tool:', tool.path);
}
if (tool.path) {
navigate(tool.path);
return;
}
showSnackbar(`Launching ${tool.name}...`, 'info');
};
}, [navigate, showSnackbar]);
// Handle category click to open modal
const handleCategoryClick = (categoryName: string | null, categoryData?: any) => {
const handleCategoryClick = useCallback((categoryName: string | null, categoryData?: any) => {
setModalCategoryName(categoryName);
setModalCategory(categoryData);
setToolsModalOpen(true);
};
}, []);
// Memoize search results computation
const searchResultsMemo = useMemo(() => {
if (!searchQuery || searchQuery.length < 2) return [];
// Get all tools from all categories that match search
const allTools: Tool[] = [];
Object.values(toolCategories).forEach(category => {
if (category) {
const tools = getToolsForCategory(category, null);
allTools.push(...tools);
}
});
const queryLower = searchQuery.toLowerCase();
return allTools.filter(tool =>
tool.name.toLowerCase().includes(queryLower) ||
tool.description.toLowerCase().includes(queryLower) ||
tool.features.some(feature => feature.toLowerCase().includes(queryLower))
);
}, [searchQuery]);
// Handle search to show results in modal with debouncing
React.useEffect(() => {
if (searchQuery && searchQuery.length >= 2) { // Only search after 2+ characters
if (searchQuery && searchQuery.length >= 2) {
const timeoutId = setTimeout(() => {
// Get all tools from all categories that match search
const allTools: Tool[] = [];
Object.values(toolCategories).forEach(category => {
if (category) {
const tools = getToolsForCategory(category, null);
allTools.push(...tools);
}
});
const filtered = allTools.filter(tool =>
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
tool.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
tool.features.some(feature => feature.toLowerCase().includes(searchQuery.toLowerCase()))
);
setSearchResults(filtered);
setSearchResults(searchResultsMemo);
setModalCategoryName(null);
setModalCategory(null);
setToolsModalOpen(true);
@@ -212,10 +222,10 @@ const MainDashboard: React.FC = () => {
// Close modal if search query is too short
setToolsModalOpen(false);
}
}, [searchQuery]);
}, [searchQuery, searchResultsMemo]);
// Close modal and clear search
const handleCloseModal = () => {
const handleCloseModal = useCallback(() => {
setToolsModalOpen(false);
setModalCategoryName(null);
setModalCategory(null);
@@ -223,7 +233,7 @@ const MainDashboard: React.FC = () => {
if (searchQuery) {
setSearchQuery('');
}
};
}, [searchQuery, setSearchQuery]);
// Note: filteredCategories removed as it's not used in the current implementation
@@ -242,19 +252,21 @@ const MainDashboard: React.FC = () => {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
padding: theme.spacing(4),
position: 'relative',
overflow: 'hidden', // Prevent layout shifts from pseudo-elements
'&::before': {
content: '""',
position: 'absolute',
position: 'fixed', // Changed from absolute to fixed to prevent layout shifts
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
pointerEvents: 'none',
willChange: 'transform', // Optimize for animations
},
'&::after': {
content: '""',
position: 'absolute',
position: 'fixed', // Changed from absolute to fixed to prevent layout shifts
top: '50%',
left: '50%',
width: '600px',
@@ -263,6 +275,7 @@ const MainDashboard: React.FC = () => {
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 0,
willChange: 'transform', // Optimize for animations
},
}}
>

View File

@@ -55,6 +55,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
justifyContent: 'center',
borderRadius: 2, // Match the parent container's border radius
px: 2, // Add horizontal padding to constrain width
minHeight: '200px', // Reserve space to prevent layout shift
willChange: 'transform', // Optimize for animations
}}
>
{/* Hero Content - Full Coverage */}
@@ -130,6 +132,7 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
style={{ width: '100%' }} // Prevent width changes
>
{/* Main Heading with Rocket */}
<Box sx={{
@@ -137,7 +140,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
alignItems: 'center',
justifyContent: 'center',
gap: 2,
mb: 2
mb: 2,
minHeight: '48px', // Reserve space for heading to prevent layout shift
}}>
<Typography
variant={isMobile ? "h5" : "h4"}
@@ -149,6 +153,7 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
lineHeight: 1.2, // Fixed line height to prevent shift
}}
>
Grow Your Business Now
@@ -220,6 +225,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent)',
animation: 'shimmer 2.5s infinite',
zIndex: 1,
pointerEvents: 'none', // Prevent layout impact
willChange: 'left', // Optimize animation
},
'&::after': {
content: '""',
@@ -233,6 +240,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
borderRadius: 'inherit',
zIndex: -1,
animation: 'borderGlow 3s ease-in-out infinite',
pointerEvents: 'none', // Prevent layout impact
willChange: 'background-position', // Optimize animation
},
'@keyframes shimmer': {
'0%': { left: '-100%' },

View File

@@ -1,6 +1,6 @@
import React from "react";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha } from "@mui/material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon } from "@mui/icons-material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon } from "@mui/icons-material";
import { PodcastAnalysis } from "./types";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
import { Refresh as RefreshIcon } from "@mui/icons-material";
@@ -92,6 +92,82 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegene
</Stack>
<Stack spacing={2}>
{analysis.exaSuggestedConfig && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
Exa Research Suggestions
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
{analysis.exaSuggestedConfig.exa_search_type && (
<Chip
label={`Search: ${analysis.exaSuggestedConfig.exa_search_type}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.exa_category && (
<Chip
label={`Category: ${analysis.exaSuggestedConfig.exa_category}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.date_range && (
<Chip
label={`Date: ${analysis.exaSuggestedConfig.date_range}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{typeof analysis.exaSuggestedConfig.include_statistics === "boolean" && (
<Chip
label={analysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{analysis.exaSuggestedConfig.max_sources && (
<Chip
label={`Max sources: ${analysis.exaSuggestedConfig.max_sources}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
</Stack>
{(analysis.exaSuggestedConfig.exa_include_domains?.length || analysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
{analysis.exaSuggestedConfig.exa_include_domains?.length ? (
<Box>
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
Prefer domains
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.exaSuggestedConfig.exa_include_domains.map((d) => (
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} />
))}
</Stack>
</Box>
) : null}
{analysis.exaSuggestedConfig.exa_exclude_domains?.length ? (
<Box>
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
Avoid domains
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{analysis.exaSuggestedConfig.exa_exclude_domains.map((d) => (
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} />
))}
</Stack>
</Box>
) : null}
</Stack>
)}
</Box>
)}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography>
<Stack spacing={1.5}>

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip } from "@mui/material";
import React, { useState, useEffect, useMemo } from "react";
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip, IconButton } from "@mui/material";
import {
AutoAwesome as AutoAwesomeIcon,
Refresh as RefreshIcon,
Info as InfoIcon,
HelpOutline as HelpOutlineIcon,
AttachMoney as AttachMoneyIcon,
} from "@mui/icons-material";
import { CreateProjectPayload, Knobs } from "./types";
import { PrimaryButton, SecondaryButton } from "./ui";
@@ -16,17 +18,25 @@ interface CreateModalProps {
isSubmitting?: boolean;
}
// Rotating placeholder examples for topic ideas
const TOPIC_PLACEHOLDERS = [
"How AI is transforming content marketing in 2024",
"The future of remote work: trends and predictions",
"Sustainable business practices for modern companies",
];
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false }) => {
const { subscription } = useSubscription();
const [idea, setIdea] = useState("");
const [url, setUrl] = useState("");
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
const [speakers, setSpeakers] = useState<number>(1);
const [duration, setDuration] = useState<number>(10);
const [duration, setDuration] = useState<number>(1);
const [budgetCap, setBudgetCap] = useState<number>(50);
const [voiceFile, setVoiceFile] = useState<File | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
const [placeholderIndex, setPlaceholderIndex] = useState(0);
// Determine subscription tier restrictions
const tier = subscription?.tier || 'free';
@@ -35,6 +45,16 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const canUseHD = !isFreeTier && !isBasicTier; // HD only for pro/enterprise
const canUseMultiSpeaker = !isFreeTier; // Multi-speaker for basic+ tiers
// Rotate placeholder every 3 seconds
useEffect(() => {
if (!idea && !url) {
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % TOPIC_PLACEHOLDERS.length);
}, 3000);
return () => clearInterval(interval);
}
}, [idea, url]);
// Reset HD quality if user downgrades
useEffect(() => {
if (!canUseHD && knobs.bitrate === 'hd') {
@@ -49,11 +69,42 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
}
}, [canUseMultiSpeaker]);
// Ensure duration and speakers are within limits
useEffect(() => {
if (duration > 10) {
setDuration(10);
}
if (speakers > 2) {
setSpeakers(2);
}
}, [duration, speakers]);
// Show AI details button when user starts typing
useEffect(() => {
setShowAIDetailsButton(idea.trim().length > 0);
}, [idea]);
// Calculate estimated cost
const estimatedCost = useMemo(() => {
const chars = Math.max(1000, duration * 900); // ~900 chars per minute
const scenes = Math.ceil((duration * 60) / (knobs.scene_length_target || 45));
const secs = duration * 60;
const ttsCost = (chars / 1000) * 0.05;
const avatarCost = speakers * 0.15;
const videoRate = knobs.bitrate === 'hd' ? 0.06 : 0.03;
const videoCost = secs * videoRate;
const researchCost = 0.3; // Fixed research cost
return {
ttsCost: +ttsCost.toFixed(2),
avatarCost: +avatarCost.toFixed(2),
videoCost: +videoCost.toFixed(2),
researchCost: +researchCost.toFixed(2),
total: +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2),
};
}, [duration, speakers, knobs.bitrate, knobs.scene_length_target]);
const canSubmit = Boolean(idea || url);
const submit = () => {
@@ -72,11 +123,22 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setIdea("");
setUrl("");
setSpeakers(1);
setDuration(10);
setDuration(1);
setBudgetCap(50);
setVoiceFile(null);
setAvatarFile(null);
setKnobs({ ...defaultKnobs });
setPlaceholderIndex(0);
};
const handleDurationChange = (value: number) => {
const clamped = Math.min(10, Math.max(1, value));
setDuration(clamped);
};
const handleSpeakersChange = (value: number) => {
const clamped = Math.min(2, Math.max(1, value));
setSpeakers(clamped);
};
return (
@@ -84,49 +146,227 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
elevation={0}
sx={{
borderRadius: 3,
border: "1px solid rgba(0,0,0,0.08)",
border: "1px solid rgba(15, 23, 42, 0.08)",
background: "#ffffff",
boxShadow: "0 6px 20px rgba(15, 23, 42, 0.08)",
p: { xs: 3, md: 4 },
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.08)",
p: { xs: 3, md: 4.5 },
}}
>
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" flexWrap="wrap">
<Stack direction="row" spacing={2} alignItems="center">
<AutoAwesomeIcon sx={{ color: "#667eea" }} />
<Box>
<Typography variant="h5" sx={{ color: "#0f172a", fontWeight: 800 }}>
Create New Podcast Episode
</Typography>
<Typography variant="body2" color="text.secondary">
Provide either a topic idea or a blog post URL. We start AI analysis only after you click Analyze & Continue.
</Typography>
<Stack spacing={3.5}>
{/* Header Section */}
<Stack direction="row" spacing={2} alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={2}>
<Stack direction="row" spacing={2} alignItems="flex-start" sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1.75rem" }} />
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
<Typography
variant="h5"
sx={{
color: "#0f172a",
fontWeight: 700,
fontSize: { xs: "1.5rem", md: "1.75rem" },
letterSpacing: "-0.02em",
lineHeight: 1.2,
}}
>
Create New Podcast Episode
</Typography>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Tips for best results:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem" }}>
Provide one clear topic OR a single blog URL (we won't auto-run anything).<br />
• Keep it concise—one sentence topic works best.<br />
• We start analysis only after you confirm, so you stay in control.
</Typography>
</Box>
}
arrow
placement="top"
componentsProps={{
tooltip: {
sx: {
bgcolor: "#0f172a",
color: "#ffffff",
maxWidth: 300,
fontSize: "0.875rem",
p: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
},
arrow: {
sx: {
color: "#0f172a",
},
},
}}
>
<IconButton
size="small"
sx={{
color: "#64748b",
"&:hover": {
color: "#667eea",
backgroundColor: alpha("#667eea", 0.08),
}
}}
>
<HelpOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
</Stack>
<Stack direction="row" spacing={1}>
<Chip label={`Plan: ${subscription?.tier || "free"}`} size="small" color="default" />
<Chip label={`Duration: ${duration} min`} size="small" color="default" />
<Chip label={`${speakers} speaker${speakers > 1 ? "s" : ""}`} size="small" color="default" />
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ alignItems: "center" }}>
<Tooltip
title={`Your current subscription plan: ${subscription?.tier || "free"}. Upgrade for more features.`}
arrow
placement="top"
>
<Chip
label={`Plan: ${subscription?.tier || "free"}`}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
color: "#667eea",
fontWeight: 600,
border: "1px solid rgba(102, 126, 234, 0.2)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={`Podcast duration: ${duration} minutes. Maximum duration is 10 minutes. Recommended: 5-10 minutes for best results.`}
arrow
placement="top"
>
<Chip
label={`Duration: ${duration} min`}
size="small"
sx={{
background: alpha("#0f172a", 0.06),
color: "#0f172a",
fontWeight: 600,
border: "1px solid rgba(15, 23, 42, 0.12)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={`Number of speakers: ${speakers}. Supports 1-2 speakers. Each additional speaker adds avatar generation cost.`}
arrow
placement="top"
>
<Chip
label={`${speakers} speaker${speakers > 1 ? "s" : ""}`}
size="small"
sx={{
background: alpha("#0f172a", 0.06),
color: "#0f172a",
fontWeight: 600,
border: "1px solid rgba(15, 23, 42, 0.12)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Estimated Cost Breakdown:
</Typography>
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
• Audio Generation: ${estimatedCost.ttsCost}<br />
• Avatar Creation: ${estimatedCost.avatarCost}<br />
• Video Rendering: ${estimatedCost.videoCost}<br />
• Research: ${estimatedCost.researchCost}<br />
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.5, pt: 0.5, borderTop: "1px solid rgba(255,255,255,0.2)" }}>
Total: ${estimatedCost.total}
</Typography>
<Typography variant="caption" sx={{ fontSize: "0.75rem", opacity: 0.9, mt: 0.5, display: "block" }}>
Based on {duration} min, {speakers} speaker{speakers > 1 ? "s" : ""}, {knobs.bitrate === "hd" ? "HD" : "standard"} quality
</Typography>
</Typography>
</Box>
}
arrow
placement="top"
componentsProps={{
tooltip: {
sx: {
bgcolor: "#0f172a",
color: "#ffffff",
maxWidth: 280,
fontSize: "0.875rem",
p: 1.5,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
},
},
arrow: {
sx: {
color: "#0f172a",
},
},
}}
>
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={`Est. $${estimatedCost.total}`}
size="small"
sx={{
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)",
color: "#059669",
fontWeight: 600,
border: "1px solid rgba(16, 185, 129, 0.2)",
fontSize: "0.75rem",
height: 26,
cursor: "help",
}}
/>
</Tooltip>
</Stack>
</Stack>
<Alert severity="info" sx={{ background: "#eef2ff", border: "1px solid #e0e7ff" }}>
<Typography variant="body2" sx={{ color: "#4338ca" }}>
Tips for best results:
</Typography>
<Typography variant="body2" sx={{ color: "#4338ca" }}>
Provide one clear topic OR a single blog URL (we wont auto-run anything).<br />
Keep it conciseone sentence topic works best.<br />
We start analysis only after you confirm, so you stay in control.
</Typography>
</Alert>
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
{/* Topic Idea Section */}
<Box flex={1}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
Topic Idea
</Typography>
{/* Input Section */}
<Box
sx={{
p: 3,
borderRadius: 2,
background: alpha("#f8fafc", 0.5),
border: "1px solid rgba(15, 23, 42, 0.06)",
}}
>
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
{/* Topic Idea Section */}
<Box flex={1}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Topic Idea
</Typography>
</Stack>
<Tooltip
title="Enter a concise idea. We will expand it into an outline only after you click Analyze."
arrow
@@ -136,7 +376,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
fullWidth
multiline
rows={5}
placeholder="e.g., 'How AI is transforming content marketing in 2024'"
placeholder={!idea && !url ? `e.g., "${TOPIC_PLACEHOLDERS[placeholderIndex]}"` : ""}
inputProps={{
sx: {
"&::placeholder": { color: "#94a3b8", opacity: 1 },
@@ -152,25 +392,38 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
}
}}
size="small"
helperText="We will not start analysis until you click Analyze."
helperText="Enter a clear, concise topic. We'll expand it into a full script after you click Analyze."
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
backgroundColor: "#ffffff",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
"&:hover": {
backgroundColor: "#f1f5f9",
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: "#667eea",
borderWidth: 2,
},
"& .MuiOutlinedInput-input": {
fontSize: "0.95rem",
lineHeight: 1.5,
fontSize: "0.9375rem",
lineHeight: 1.6,
color: "#0f172a",
fontWeight: 400,
},
},
"& .MuiInputBase-input::placeholder": {
color: "#94a3b8",
opacity: 1,
fontWeight: 400,
},
"& .MuiFormHelperText-root": {
color: "#475569",
color: "#64748b",
fontSize: "0.8125rem",
fontWeight: 400,
mt: 0.75,
},
}}
/>
@@ -189,8 +442,11 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
sx={{
textTransform: "none",
fontSize: "0.875rem",
fontWeight: 600,
borderColor: "#667eea",
borderWidth: 1.5,
color: "#667eea",
borderRadius: 2,
"&:hover": {
borderColor: "#5568d3",
backgroundColor: alpha("#667eea", 0.08),
@@ -203,20 +459,67 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
)}
</Box>
{/* Center OR divider */}
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 1 } }}>
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", md: "block" }, borderColor: "rgba(0,0,0,0.08)" }} />
<Divider sx={{ display: { xs: "block", md: "none" }, borderColor: "rgba(0,0,0,0.08)", my: 1 }} />
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
OR
</Typography>
</Stack>
{/* Center OR divider */}
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 2 } }}>
<Divider
orientation="vertical"
flexItem
sx={{
display: { xs: "none", md: "block" },
borderColor: "rgba(15, 23, 42, 0.1)",
borderWidth: 1,
}}
/>
<Box
sx={{
display: { xs: "flex", md: "none" },
alignItems: "center",
width: "100%",
my: 2,
}}
>
<Divider sx={{ flex: 1, borderColor: "rgba(15, 23, 42, 0.1)" }} />
<Box
sx={{
px: 2,
py: 0.5,
borderRadius: 2,
background: alpha("#ffffff", 0.8),
border: "1px solid rgba(15, 23, 42, 0.1)",
}}
>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
OR
</Typography>
</Box>
<Divider sx={{ flex: 1, borderColor: "rgba(15, 23, 42, 0.1)" }} />
</Box>
<Box
sx={{
display: { xs: "none", md: "flex" },
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: "50%",
background: alpha("#ffffff", 0.9),
border: "1px solid rgba(15, 23, 42, 0.1)",
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
}}
>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
OR
</Typography>
</Box>
</Stack>
{/* Blog URL Section */}
<Box flex={1}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
Blog Post URL
</Typography>
{/* Blog URL Section */}
<Box flex={1}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
Blog Post URL
</Typography>
</Stack>
<Tooltip
title="Paste a single article URL. Well fetch insights only after you click Analyze."
arrow
@@ -252,63 +555,246 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
}}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
backgroundColor: "#ffffff",
border: "1.5px solid rgba(15, 23, 42, 0.12)",
borderRadius: 2,
"&:hover": {
backgroundColor: "#f1f5f9",
backgroundColor: "#ffffff",
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused": {
backgroundColor: "#ffffff",
borderColor: "#667eea",
borderWidth: 2,
},
},
"& .MuiInputBase-input": {
color: "#0f172a",
fontSize: "0.9375rem",
fontWeight: 400,
},
"& .MuiInputLabel-root": {
color: "#64748b",
fontSize: "0.9375rem",
"&.Mui-focused": {
color: "#667eea",
},
},
"& .MuiInputBase-input::placeholder": {
color: "#94a3b8",
opacity: 1,
fontWeight: 400,
},
"& .MuiFormHelperText-root": {
color: "#475569",
color: "#64748b",
fontSize: "0.8125rem",
fontWeight: 400,
mt: 0.75,
},
}}
/>
</Tooltip>
</Box>
</Stack>
</Stack>
</Box>
{/* Quick settings for duration and speakers */}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Duration (minutes)"
type="number"
value={duration}
onChange={(e) => setDuration(Math.max(1, Number(e.target.value) || 0))}
InputProps={{ inputProps: { min: 1, max: 60 } }}
size="small"
helperText="Typical podcasts: 5-20 minutes"
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": { backgroundColor: "#f1f5f9" },
},
}}
/>
<TextField
label="Number of speakers"
type="number"
value={speakers}
onChange={(e) => setSpeakers(Math.min(4, Math.max(1, Number(e.target.value) || 1)))}
InputProps={{ inputProps: { min: 1, max: 4 } }}
size="small"
helperText="Supports single or panel style"
sx={{
maxWidth: 220,
"& .MuiOutlinedInput-root": {
backgroundColor: "#f8fafc",
"&:hover": { backgroundColor: "#f1f5f9" },
},
}}
/>
</Stack>
{/* Settings Section */}
<Box
sx={{
p: 3,
borderRadius: 2,
background: alpha("#f8fafc", 0.5),
border: "1px solid 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}
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)",
borderRadius: 2,
minWidth: { xs: "100%", sm: 300 },
flex: { xs: "none", sm: "0 0 auto" },
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.08)",
}}
>
<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,
}}
>
${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>
<Box
sx={{
mt: 1,
pt: 1.5,
borderTop: "1.5px solid rgba(16, 185, 129, 0.15)",
}}
>
<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>
</Box>
</Stack>
</Paper>
</Stack>
</Box>
<Alert severity="info" sx={{ background: "#ecfeff", border: "1px solid #bae6fd", borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontSize: "0.9rem", color: "#0ea5e9" }}>
You can provide either a topic idea or a blog post URL. We wont make any external AI calls until you click Analyze & Continue.
{/* Info Banner */}
<Alert
severity="info"
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
sx={{
background: alpha("#f0f4ff", 0.6),
border: "1px solid rgba(99, 102, 241, 0.15)",
borderRadius: 2,
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
"& .MuiAlert-message": {
width: "100%",
},
}}
>
<Typography variant="body2" sx={{ fontSize: "0.875rem", color: "#475569", lineHeight: 1.6, fontWeight: 400 }}>
You can provide either a topic idea or a blog post URL. We won't make any external AI calls until you click "Analyze & Continue".
</Typography>
</Alert>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react";
import { Paper, Stack, Typography, IconButton, Tooltip, alpha } from "@mui/material";
import { Paper, Stack, Typography, IconButton, Tooltip, alpha, Alert } from "@mui/material";
import { VolumeUp as VolumeUpIcon, PlayCircle as PlayCircleIcon, PauseCircle as PauseCircleIcon, Download as DownloadIcon } from "@mui/icons-material";
import { aiApiClient } from "../../api/client";
interface InlineAudioPlayerProps {
audioUrl: string;
@@ -11,37 +12,140 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const audioRef = React.useRef<HTMLAudioElement>(null);
// Load audio as blob if it's an authenticated endpoint
useEffect(() => {
if (!audioUrl) {
setBlobUrl(null);
setError(null);
return;
}
// Check if this is a podcast audio endpoint that requires authentication
const isPodcastAudio = audioUrl.includes('/api/podcast/audio/') || audioUrl.includes('/api/story/audio/');
if (!isPodcastAudio) {
// Regular URL, use directly
setBlobUrl(audioUrl);
setError(null);
return;
}
// Fetch as blob for authenticated endpoints
let isMounted = true;
const currentAudioUrl = audioUrl;
const loadAudioBlob = async () => {
try {
// Normalize path
let audioPath = currentAudioUrl.startsWith('/') ? currentAudioUrl : `/${currentAudioUrl}`;
// Convert /api/story/audio/ to /api/podcast/audio/ if needed
if (audioPath.includes('/api/story/audio/')) {
const filename = audioPath.split('/api/story/audio/').pop() || '';
audioPath = `/api/podcast/audio/${filename}`;
}
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || currentAudioUrl;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
if (!isMounted || audioUrl !== currentAudioUrl) {
return;
}
const blob = response.data;
const newBlobUrl = URL.createObjectURL(blob);
setBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== newBlobUrl) {
URL.revokeObjectURL(prevBlobUrl);
}
return newBlobUrl;
});
setError(null);
} catch (err) {
console.error('Failed to load audio blob:', err);
if (isMounted && audioUrl === currentAudioUrl) {
setError('Failed to load audio. Please try again.');
setBlobUrl(null);
}
}
};
loadAudioBlob();
return () => {
isMounted = false;
// Cleanup blob URL when component unmounts or URL changes
setBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
};
}, [audioUrl]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (!audio || !blobUrl) return;
const updateTime = () => setCurrentTime(audio.currentTime);
const updateDuration = () => setDuration(audio.duration);
const handleEnd = () => setPlaying(false);
const handleError = () => {
setError('Audio playback error. Please try again.');
setPlaying(false);
};
audio.addEventListener("timeupdate", updateTime);
audio.addEventListener("loadedmetadata", updateDuration);
audio.addEventListener("ended", handleEnd);
audio.addEventListener("error", handleError);
return () => {
audio.removeEventListener("timeupdate", updateTime);
audio.removeEventListener("loadedmetadata", updateDuration);
audio.removeEventListener("ended", handleEnd);
audio.removeEventListener("error", handleError);
};
}, [audioUrl]);
}, [blobUrl]);
const togglePlay = () => {
const togglePlay = async () => {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
} else {
audio.play();
if (!audio || !blobUrl) {
setError('Audio not loaded. Please wait...');
return;
}
try {
if (playing) {
audio.pause();
setPlaying(false);
} else {
await audio.play();
setPlaying(true);
setError(null);
}
} catch (err) {
console.error('Playback error:', err);
setError('Failed to play audio. Please try again.');
setPlaying(false);
}
setPlaying(!playing);
};
const formatTime = (seconds: number) => {
@@ -58,6 +162,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
setCurrentTime(newTime);
};
const effectiveAudioUrl = blobUrl || audioUrl;
return (
<Paper
sx={{
@@ -74,8 +180,26 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
{title}
</Typography>
)}
{error && (
<Alert severity="error" sx={{ py: 0.5 }}>
<Typography variant="caption">{error}</Typography>
</Alert>
)}
{!blobUrl && audioUrl && (
<Alert severity="info" sx={{ py: 0.5 }}>
<Typography variant="caption">Loading audio...</Typography>
</Alert>
)}
<Stack direction="row" spacing={2} alignItems="center">
<IconButton onClick={togglePlay} sx={{ color: "#a78bfa" }} size="large">
<IconButton
onClick={togglePlay}
disabled={!effectiveAudioUrl || !!error}
sx={{ color: "#a78bfa" }}
size="large"
>
{playing ? <PauseCircleIcon fontSize="large" /> : <PlayCircleIcon fontSize="large" />}
</IconButton>
<Stack flex={1}>
@@ -85,7 +209,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
max={duration || 0}
value={currentTime}
onChange={handleSeek}
style={{ width: "100%", cursor: "pointer" }}
disabled={!effectiveAudioUrl}
style={{ width: "100%", cursor: effectiveAudioUrl ? "pointer" : "not-allowed" }}
/>
<Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
@@ -99,18 +224,22 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
<Tooltip title="Download audio">
<IconButton
onClick={() => {
if (!effectiveAudioUrl) return;
const link = document.createElement("a");
link.href = audioUrl;
link.href = effectiveAudioUrl;
link.download = title || "podcast-audio.mp3";
link.click();
}}
disabled={!effectiveAudioUrl}
sx={{ color: "rgba(255,255,255,0.7)" }}
>
<DownloadIcon />
</IconButton>
</Tooltip>
</Stack>
<audio ref={audioRef} src={audioUrl} preload="metadata" />
{effectiveAudioUrl && (
<audio ref={audioRef} src={effectiveAudioUrl} preload="metadata" />
)}
</Stack>
</Paper>
);

View File

@@ -1,79 +1,26 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Paper,
Stack,
Typography,
Alert,
Chip,
Tooltip,
LinearProgress,
Stepper,
Step,
StepLabel,
Divider,
FormControl,
InputLabel,
Select,
MenuItem,
List,
ListItem,
ListItemButton,
ListItemText,
Checkbox,
CircularProgress,
alpha,
} from "@mui/material";
import {
Mic as MicIcon,
Psychology as PsychologyIcon,
Search as SearchIcon,
EditNote as EditNoteIcon,
PlayArrow as PlayArrowIcon,
CheckCircle as CheckCircleIcon,
Info as InfoIcon,
AutoAwesome as AutoAwesomeIcon,
Insights as InsightsIcon,
LibraryMusic as LibraryMusicIcon,
} from "@mui/icons-material";
import { ResearchProvider } from "../../services/blogWriterApi";
import { podcastApi } from "../../services/podcastApi";
import React, { useState, useCallback } from "react";
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material";
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
import { useNavigate } from "react-router-dom";
import { CreateProjectPayload, Job, Knobs, Query, Research, Script } from "./types";
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
import { Script } from "./types";
import { CreateModal } from "./CreateModal";
import { AnalysisPanel } from "./AnalysisPanel";
import { FactCard } from "./FactCard";
import { ScriptEditor } from "./ScriptEditor";
import { RenderQueue } from "./RenderQueue";
import { RecentEpisodesPreview } from "./RecentEpisodesPreview";
import { ProjectList } from "./ProjectList";
import { usePreflightCheck } from "../../hooks/usePreflightCheck";
import { useBudgetTracking } from "../../hooks/useBudgetTracking";
import { PreflightBlockDialog } from "./PreflightBlockDialog";
import HeaderControls from "../shared/HeaderControls";
/* ================= Helpers ================= */
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
const message = error instanceof Error ? error.message : "Unexpected error";
setAnnouncement(message);
};
/* ================= Dashboard ================= */
import {
Header,
ProgressStepper,
EstimateCard,
QuerySelection,
ResearchSummary,
usePodcastWorkflow,
DEFAULT_KNOBS,
getStepLabel,
} from "./PodcastDashboard/index";
const PodcastDashboard: React.FC = () => {
const navigate = useNavigate();
const projectState = usePodcastProjectState();
const [showProjectList, setShowProjectList] = useState(false);
const {
@@ -91,250 +38,39 @@ const PodcastDashboard: React.FC = () => {
showScriptEditor,
showRenderQueue,
currentStep,
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData,
updateRenderJob,
setKnobs,
setResearchProvider,
setBudgetCap,
setShowScriptEditor,
setShowRenderQueue,
initializeProject,
setResearchProvider,
updateRenderJob,
resetState,
loadProjectFromDb,
setCurrentStep,
} = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isResearching, setIsResearching] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [showResumeAlert, setShowResumeAlert] = useState(false);
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
const [preflightResponse, setPreflightResponse] = useState<any>(null);
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
// Budget tracking
const budgetTracking = useBudgetTracking(projectState.budgetCap || 50);
// Preflight check hook
const preflightCheck = usePreflightCheck({
onBlocked: (response) => {
setPreflightResponse(response);
setShowPreflightDialog(true);
const workflow = usePodcastWorkflow({
projectState,
onError: (msg: string) => {
// Error handling is done through workflow's own announcement system
console.error("Workflow error:", msg);
},
});
// Update budget cap when project state changes
useEffect(() => {
if (projectState.budgetCap) {
budgetTracking.setBudgetCap(projectState.budgetCap);
}
}, [projectState.budgetCap, budgetTracking]);
// Check if we have a saved project on mount
useEffect(() => {
if (project && currentStep && currentStep !== "create") {
setShowResumeAlert(true);
setTimeout(() => setShowResumeAlert(false), 5000);
}
}, []); // Only on mount
useEffect(() => {
if (announcement) {
const t = setTimeout(() => setAnnouncement(""), 4000);
return () => clearTimeout(t);
}
return undefined;
}, [announcement]);
const handleCreate = async (payload: CreateProjectPayload) => {
// Prevent duplicate submits that can spam story setup API
if (isAnalyzing) return;
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
setIsAnalyzing(true);
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload);
await initializeProject(payload, result.projectId);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
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");
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsAnalyzing(false);
}
};
const handleRunResearch = async () => {
// Prevent duplicate research calls
if (isResearching) return;
if (!project) {
setAnnouncement("Create a project first.");
return;
}
if (selectedQueries.size === 0) {
setAnnouncement("Select at least one query to research.");
return;
}
// Preflight check before research
setPreflightOperationName("Research");
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
const preflightResult = await preflightCheck.check({
provider: researchProvider === "exa" ? "exa" : "gemini",
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
tokens_requested: researchProvider === "exa" ? 0 : 1200,
actual_provider_name: researchProvider || "google",
});
if (!preflightResult.can_proceed) {
return; // Dialog will be shown by onBlocked callback
}
try {
setIsResearching(true);
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
const { research: mapped, raw } = await podcastApi.runResearch({
projectId: project.id,
topic: project.idea,
approvedQueries,
provider: researchProvider,
onProgress: (message) => {
// Update announcement with progress messages
setAnnouncement(message);
},
});
setResearch(mapped);
setRawResearch(raw);
setAnnouncement("Research complete — review fact cards below");
} catch (researchError) {
const errorMessage = researchError instanceof Error
? researchError.message
: "Research failed. Please try again or switch to Standard Research.";
// Provide helpful error messages
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
} else if (errorMessage.includes("timeout")) {
setAnnouncement("Research timed out. Please try again with fewer queries.");
} else {
setAnnouncement(`Research failed: ${errorMessage}`);
}
// Log full error for debugging
console.error("Research error:", researchError);
throw researchError;
}
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsResearching(false);
}
};
const handleGenerateScript = async () => {
// Avoid re-triggering script generation preflight
if (showScriptEditor) return;
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
// Preflight check before script generation
setPreflightOperationName("Script Generation");
const preflightResult = await preflightCheck.check({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) {
return; // Dialog will be shown by onBlocked callback
}
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
};
const handleProceedToRendering = (script: Script) => {
setScriptData(script);
// Initialize render jobs if empty
if (renderJobs.length === 0) {
script.scenes.forEach((scene) => {
updateRenderJob(scene.id, {
sceneId: scene.id,
title: scene.title,
status: "idle" as const,
progress: 0,
previewUrl: null,
finalUrl: null,
jobId: null,
});
});
}
setShowRenderQueue(true);
setShowScriptEditor(false);
};
const selectedCount = selectedQueries.size;
const canGenerateScript = Boolean(project && research && rawResearch);
const toggleQuery = (id: string) => {
if (isResearching) return;
const current = selectedQueries;
const next = new Set<string>(current);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedQueries(next);
};
const activeStep = useMemo(() => {
if (showRenderQueue) return 3;
if (showScriptEditor) return 2;
if (research) return 1;
if (analysis) return 0;
return -1;
}, [showRenderQueue, showScriptEditor, research, analysis]);
const steps = [
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
];
const handleSelectProject = async (projectId: string) => {
const handleSelectProject = useCallback(async (projectId: string) => {
try {
await loadProjectFromDb(projectId);
setShowProjectList(false);
} catch (error) {
setAnnouncement(`Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`);
const errorMsg = `Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`;
// Use workflow's setAnnouncement - workflow is stable from hook
workflow.setAnnouncement(errorMsg);
}
};
}, [loadProjectFromDb, workflow.setAnnouncement]);
const handleNewEpisode = useCallback(() => {
resetState();
setShowProjectList(false);
}, [resetState]);
if (showProjectList) {
return <ProjectList onSelectProject={handleSelectProject} />;
@@ -362,147 +98,93 @@ const PodcastDashboard: React.FC = () => {
>
<Stack spacing={3}>
{/* Header */}
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography
variant="h3"
sx={{
color: "#1e293b",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
AI Podcast Maker
</Typography>
<Typography variant="body2" color="text.secondary">
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
>
My Episodes
</SecondaryButton>
<SecondaryButton
onClick={() => setShowProjectList(true)}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
>
My Projects
</SecondaryButton>
<PrimaryButton
onClick={() => {
resetState();
setShowProjectList(false);
}}
startIcon={<AutoAwesomeIcon />}
>
New Episode
</PrimaryButton>
</Stack>
</Stack>
<Header onShowProjects={() => setShowProjectList(true)} onNewEpisode={handleNewEpisode} />
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
{/* Progress Stepper */}
{project && activeStep >= 0 && (
<Paper
sx={{
p: 2.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 2,
{project && workflow.activeStep >= 0 && (
<ProgressStepper
activeStep={workflow.activeStep}
completedSteps={[
...(analysis ? [0] : []), // Analysis step
...(research ? [1] : []), // Research step
...(scriptData ? [2] : []), // Script step
...(scriptData && renderJobs.length > 0 ? [3] : []), // Render step (if script exists and has jobs)
]}
onStepClick={(stepIndex) => {
// Navigate to the clicked step
// Step indices: 0 = Analysis, 1 = Research, 2 = Script, 3 = Render
if (stepIndex === 0) {
// Navigate to Analysis
setShowScriptEditor(false);
setShowRenderQueue(false);
setCurrentStep('analysis');
} else if (stepIndex === 1) {
// Navigate to Research
if (!analysis) {
workflow.setAnnouncement("Complete Analysis first to access Research.");
return;
}
setShowScriptEditor(false);
setShowRenderQueue(false);
setCurrentStep('research');
} else if (stepIndex === 2) {
// Navigate to Script
if (!research) {
workflow.setAnnouncement("Complete Research first to access Script Editor.");
return;
}
setShowRenderQueue(false);
setShowScriptEditor(true);
setCurrentStep('script');
} else if (stepIndex === 3) {
// Navigate to Render
if (!scriptData) {
workflow.setAnnouncement("Generate and approve script first to access Render Queue.");
return;
}
setShowScriptEditor(false);
setShowRenderQueue(true);
setCurrentStep('render');
}
}}
>
<Stepper activeStep={activeStep} orientation="horizontal" sx={{ "& .MuiStepLabel-root": { cursor: "pointer" } }}>
{steps.map((step, index) => (
<Step key={step.label} completed={index < activeStep}>
<StepLabel
StepIconComponent={({ active, completed }) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: completed
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: active
? alpha("#667eea", 0.15)
: "#e2e8f0",
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
color: completed || active ? "#fff" : "#64748b",
}}
>
{completed ? <CheckCircleIcon /> : step.icon}
</Box>
)}
>
<Typography variant="subtitle2">{step.label}</Typography>
<Typography variant="caption" color="text.secondary">
{step.description}
</Typography>
</StepLabel>
</Step>
))}
</Stepper>
</Paper>
/>
)}
{/* Resume Alert */}
{showResumeAlert && project && (
{workflow.showResumeAlert && project && (
<Alert
severity="success"
onClose={() => setShowResumeAlert(false)}
onClose={() => workflow.setShowResumeAlert(false)}
sx={{
background: "#d1fae5",
border: "1px solid #a7f3d0",
"& .MuiAlert-icon": { color: "#10b981" },
}}
>
<Typography variant="body2">
<strong>Project Restored:</strong> Resuming from{" "}
{currentStep === "analysis"
? "Analysis"
: currentStep === "research"
? "Research"
: currentStep === "script"
? "Script Editing"
: "Rendering"}{" "}
step. Your progress has been saved.
</Typography>
<Box component="span" sx={{ fontSize: "0.875rem" }}>
<strong>Project Restored:</strong> Resuming from {getStepLabel(currentStep)} step. Your progress has been saved.
</Box>
</Alert>
)}
{/* Announcements */}
{announcement && (
{workflow.announcement && (
<Alert
severity="info"
onClose={() => setAnnouncement("")}
onClose={() => workflow.setAnnouncement("")}
sx={{
background: "#dbeafe",
border: "1px solid #bfdbfe",
"& .MuiAlert-icon": { color: "#3b82f6" },
}}
>
{announcement}
{workflow.announcement}
</Alert>
)}
{(isAnalyzing || isResearching) && (
{(workflow.isAnalyzing || workflow.isResearching) && (
<Alert
severity="warning"
icon={<CircularProgress size={20} />}
@@ -511,17 +193,21 @@ const PodcastDashboard: React.FC = () => {
border: "1px solid #fde68a",
}}
>
<Typography variant="body2">
{isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
</Typography>
<Box component="span" sx={{ fontSize: "0.875rem" }}>
{workflow.isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
</Box>
</Alert>
)}
{/* Create Modal */}
{!project && (
<>
<CreateModal open onCreate={handleCreate} defaultKnobs={DEFAULT_KNOBS} isSubmitting={isAnalyzing} />
{/* Recent Episodes Preview */}
<CreateModal
open
onCreate={workflow.handleCreate}
defaultKnobs={DEFAULT_KNOBS}
isSubmitting={workflow.isAnalyzing}
/>
<RecentEpisodesPreview onSelectEpisode={() => {}} />
</>
)}
@@ -531,217 +217,32 @@ const PodcastDashboard: React.FC = () => {
{analysis && !showScriptEditor && !showRenderQueue && (
<AnalysisPanel
analysis={analysis}
onRegenerate={() => setAnalysis({ ...analysis })}
onRegenerate={() => {}}
/>
)}
{estimate && !showScriptEditor && !showRenderQueue && (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
aria-label="estimate"
>
<Stack spacing={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<InsightsIcon />
Estimated Cost
</Typography>
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
${estimate.total.toFixed(2)}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
title="Voice narration cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
title="Avatar/video cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
title="Research and fact-checking cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
</Stack>
</Stack>
</GlassyCard>
<EstimateCard estimate={estimate} />
)}
{queries.length > 0 && !showScriptEditor && !showRenderQueue && (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<SearchIcon />
Research Queries
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Provider</InputLabel>
<Select
value={researchProvider}
onChange={(e) => setResearchProvider(e.target.value as ResearchProvider)}
label="Provider"
disabled={isResearching}
size="small"
sx={{
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
}}
>
<MenuItem value="google">
<Stack direction="row" spacing={1} alignItems="center">
<SearchIcon fontSize="small" />
<span>Standard Research</span>
</Stack>
</MenuItem>
<MenuItem value="exa">
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesomeIcon fontSize="small" />
<span>Deep Research</span>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Chip
label={`${selectedCount} / ${queries.length} selected`}
size="small"
color={selectedCount > 0 ? "primary" : "default"}
/>
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Tooltip
title={
researchProvider === "google"
? "Standard Research: Fast, fact-checked results with source citations"
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
}
arrow
>
<Alert
severity="info"
sx={{
background: "#e0f2fe",
border: "1px solid #bae6fd",
color: "#0f172a",
}}
>
<Typography variant="caption" sx={{ color: "#0f172a" }}>
{researchProvider === "google"
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
</Typography>
</Alert>
</Tooltip>
<List>
{queries.map((q) => (
<ListItem key={q.id} disablePadding>
<ListItemButton
onClick={() => toggleQuery(q.id)}
disabled={isResearching}
sx={{
borderRadius: 2,
mb: 1,
border: "1px solid rgba(0,0,0,0.08)",
background: "#f8fafc",
"&:hover": { background: alpha("#667eea", 0.08) },
}}
>
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
<ListItemText
primary={q.query}
secondary={q.rationale}
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<PrimaryButton
onClick={handleRunResearch}
disabled={!project || selectedCount === 0 || isResearching}
loading={isResearching}
startIcon={<SearchIcon />}
tooltip={
selectedCount === 0
? "Select at least one query to run research"
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
}
>
{isResearching ? "Running Research..." : "Run Research"}
</PrimaryButton>
</Box>
</Stack>
</GlassyCard>
<QuerySelection
queries={queries}
selectedQueries={selectedQueries}
researchProvider={researchProvider}
isResearching={workflow.isResearching}
onToggleQuery={workflow.toggleQuery}
onProviderChange={setResearchProvider}
onRunResearch={workflow.handleRunResearch}
/>
)}
{research && !showScriptEditor && !showRenderQueue && (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
<Box>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<InsightsIcon />
Research Summary
</Typography>
<Typography variant="body2" color="text.secondary">
{research.summary}
</Typography>
</Box>
<PrimaryButton
onClick={handleGenerateScript}
disabled={!canGenerateScript}
startIcon={<EditNoteIcon />}
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
>
Generate Script
</PrimaryButton>
</Stack>
{research.factCards.length > 0 && (
<>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Fact Cards ({research.factCards.length})
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}
</Box>
</>
)}
</Stack>
</GlassyCard>
<ResearchSummary
research={research}
canGenerateScript={workflow.canGenerateScript}
onGenerateScript={workflow.handleGenerateScript}
/>
)}
{showScriptEditor && project && research && rawResearch && (
@@ -756,8 +257,8 @@ const PodcastDashboard: React.FC = () => {
script={scriptData}
onScriptChange={(s) => setScriptData(s)}
onBackToResearch={() => setShowScriptEditor(false)}
onProceedToRendering={(s) => handleProceedToRendering(s)}
onError={(msg) => setAnnouncement(msg)}
onProceedToRendering={(s) => workflow.handleProceedToRendering(s)}
onError={(msg) => workflow.setAnnouncement(msg)}
/>
)}
@@ -776,11 +277,12 @@ const PodcastDashboard: React.FC = () => {
budgetCap={projectState.budgetCap}
avatarImageUrl={null}
onUpdateJob={updateRenderJob}
onUpdateScript={(updatedScript) => setScriptData(updatedScript)}
onBack={() => {
setShowRenderQueue(false);
setShowScriptEditor(true);
}}
onError={(msg) => setAnnouncement(msg)}
onError={(msg) => workflow.setAnnouncement(msg)}
/>
)}
</Stack>
@@ -789,13 +291,13 @@ const PodcastDashboard: React.FC = () => {
{/* Preflight Block Dialog */}
<PreflightBlockDialog
open={showPreflightDialog}
open={workflow.showPreflightDialog}
onClose={() => {
setShowPreflightDialog(false);
setPreflightResponse(null);
workflow.setShowPreflightDialog(false);
workflow.setPreflightResponse(null);
}}
response={preflightResponse}
operationName={preflightOperationName}
response={workflow.preflightResponse}
operationName={workflow.preflightOperationName}
/>
</Box>
);

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Stack, Typography, Chip, Divider } from "@mui/material";
import { Insights as InsightsIcon } from "@mui/icons-material";
import { PodcastEstimate } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
interface EstimateCardProps {
estimate: PodcastEstimate;
}
export const EstimateCard: React.FC<EstimateCardProps> = ({ estimate }) => {
return (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
aria-label="estimate"
>
<Stack spacing={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<InsightsIcon />
Estimated Cost
</Typography>
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
${estimate.total.toFixed(2)}
</Typography>
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
title="Voice narration cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
title="Avatar/video cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
title="Research and fact-checking cost"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,68 @@
import React from "react";
import { Box, Stack, Typography } from "@mui/material";
import {
Mic as MicIcon,
Info as InfoIcon,
AutoAwesome as AutoAwesomeIcon,
LibraryMusic as LibraryMusicIcon,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { PrimaryButton, SecondaryButton } from "../ui";
import HeaderControls from "../../shared/HeaderControls";
interface HeaderProps {
onShowProjects: () => void;
onNewEpisode: () => void;
}
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>
<Typography
variant="h3"
sx={{
color: "#1e293b",
fontWeight: 800,
mb: 0.5,
display: "flex",
alignItems: "center",
gap: 1.5,
}}
>
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
AI Podcast Maker
</Typography>
<Typography variant="body2" color="text.secondary">
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
Help
</SecondaryButton>
<SecondaryButton
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
startIcon={<LibraryMusicIcon />}
tooltip="View all podcast episodes in Asset Library"
>
My Episodes
</SecondaryButton>
<SecondaryButton
onClick={onShowProjects}
startIcon={<MicIcon />}
tooltip="View and resume saved projects"
>
My Projects
</SecondaryButton>
<PrimaryButton onClick={onNewEpisode} startIcon={<AutoAwesomeIcon />}>
New Episode
</PrimaryButton>
</Stack>
</Stack>
);
};

View File

@@ -0,0 +1,105 @@
import React from "react";
import { Box, Paper, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
import {
Psychology as PsychologyIcon,
Search as SearchIcon,
EditNote as EditNoteIcon,
PlayArrow as PlayArrowIcon,
CheckCircle as CheckCircleIcon,
} from "@mui/icons-material";
interface ProgressStepperProps {
activeStep: number;
completedSteps?: number[]; // Steps that have been completed (have data)
onStepClick?: (stepIndex: number) => void;
}
const steps = [
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
];
export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, completedSteps = [], onStepClick }) => {
if (activeStep < 0) return null;
const handleStepClick = (stepIndex: number) => {
// Allow navigation to any completed step (has data), not just steps before active step
const isCompleted = completedSteps.includes(stepIndex);
if (isCompleted && onStepClick) {
onStepClick(stepIndex);
}
};
return (
<Paper
sx={{
p: 2.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 2,
}}
>
<Stepper activeStep={activeStep} orientation="horizontal">
{steps.map((step, index) => {
const isCompleted = completedSteps.includes(index);
const isClickable = isCompleted && onStepClick !== undefined;
return (
<Step key={step.label} completed={isCompleted}>
<StepLabel
onClick={() => handleStepClick(index)}
sx={{
cursor: isClickable ? "pointer" : "default",
"&:hover": isClickable
? {
"& .MuiStepLabel-label": {
color: "#667eea",
},
}
: {},
}}
StepIconComponent={({ active, completed }) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: completed
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: active
? alpha("#667eea", 0.15)
: "#e2e8f0",
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
color: completed || active ? "#fff" : "#64748b",
transition: "all 0.2s ease",
...(isClickable && {
cursor: "pointer",
"&:hover": {
transform: "scale(1.05)",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
},
}),
}}
>
{completed ? <CheckCircleIcon /> : step.icon}
</Box>
)}
>
<Typography variant="subtitle2">{step.label}</Typography>
<Typography variant="caption" color="text.secondary">
{step.description}
</Typography>
</StepLabel>
</Step>
);
})}
</Stepper>
</Paper>
);
};

View File

@@ -0,0 +1,169 @@
import React from "react";
import {
Stack,
Typography,
Chip,
Tooltip,
Alert,
List,
ListItem,
ListItemButton,
ListItemText,
Checkbox,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
alpha,
} from "@mui/material";
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
import { ResearchProvider } from "../../../services/blogWriterApi";
import { Query } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
interface QuerySelectionProps {
queries: Query[];
selectedQueries: Set<string>;
researchProvider: ResearchProvider;
isResearching: boolean;
onToggleQuery: (id: string) => void;
onProviderChange: (provider: ResearchProvider) => void;
onRunResearch: () => void;
}
export const QuerySelection: React.FC<QuerySelectionProps> = ({
queries,
selectedQueries,
researchProvider,
isResearching,
onToggleQuery,
onProviderChange,
onRunResearch,
}) => {
const selectedCount = selectedQueries.size;
return (
<GlassyCard
sx={{
...glassyCardSx,
background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a",
}}
>
<Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
<SearchIcon />
Research Queries
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>Provider</InputLabel>
<Select
value={researchProvider}
onChange={(e) => onProviderChange(e.target.value as ResearchProvider)}
label="Provider"
disabled={isResearching}
size="small"
sx={{
backgroundColor: "#f8fafc",
"&:hover": {
backgroundColor: "#f1f5f9",
},
}}
>
<MenuItem value="google">
<Stack direction="row" spacing={1} alignItems="center">
<SearchIcon fontSize="small" />
<span>Standard Research</span>
</Stack>
</MenuItem>
<MenuItem value="exa">
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesomeIcon fontSize="small" />
<span>Deep Research</span>
</Stack>
</MenuItem>
</Select>
</FormControl>
<Chip
label={`${selectedCount} / ${queries.length} selected`}
size="small"
color={selectedCount > 0 ? "primary" : "default"}
/>
</Stack>
</Stack>
<Tooltip
title={
researchProvider === "google"
? "Standard Research: Fast, fact-checked results with source citations"
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
}
arrow
>
<Alert
severity="info"
sx={{
background: "#e0f2fe",
border: "1px solid #bae6fd",
color: "#0f172a",
}}
>
<Typography variant="caption" sx={{ color: "#0f172a" }}>
{researchProvider === "google"
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
</Typography>
</Alert>
</Tooltip>
<List>
{queries.map((q) => (
<ListItem key={q.id} disablePadding>
<ListItemButton
onClick={() => onToggleQuery(q.id)}
disabled={isResearching}
sx={{
borderRadius: 2,
mb: 1,
border: "1px solid rgba(0,0,0,0.08)",
background: "#f8fafc",
"&:hover": { background: alpha("#667eea", 0.08) },
}}
>
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
<ListItemText
primary={q.query}
secondary={q.rationale}
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<PrimaryButton
onClick={onRunResearch}
disabled={selectedCount === 0 || isResearching}
loading={isResearching}
startIcon={<SearchIcon />}
tooltip={
selectedCount === 0
? "Select at least one query to run research"
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
}
>
{isResearching ? "Running Research..." : "Run Research"}
</PrimaryButton>
</Box>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,148 @@
import React from "react";
import { Stack, Typography, Chip, Divider, Box, alpha } from "@mui/material";
import {
Insights as InsightsIcon,
Search as SearchIcon,
AttachMoney as AttachMoneyIcon,
EditNote as EditNoteIcon,
} from "@mui/icons-material";
import { Research } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { FactCard } from "../FactCard";
interface ResearchSummaryProps {
research: Research;
canGenerateScript: boolean;
onGenerateScript: () => void;
}
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
research,
canGenerateScript,
onGenerateScript,
}) => {
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 }}>
<InsightsIcon />
Research Summary
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, lineHeight: 1.7 }}>
{research.summary}
</Typography>
{/* Research Metadata */}
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
{research.searchQueries && research.searchQueries.length > 0 && (
<Chip
icon={<SearchIcon />}
label={`${research.searchQueries.length} search${research.searchQueries.length > 1 ? "es" : ""}`}
size="small"
sx={{
background: alpha("#667eea", 0.1),
color: "#667eea",
fontWeight: 600,
border: "1px solid rgba(102, 126, 234, 0.2)",
}}
/>
)}
{research.searchType && (
<Chip
label={`${research.searchType.charAt(0).toUpperCase() + research.searchType.slice(1)} search`}
size="small"
sx={{
background: alpha("#10b981", 0.1),
color: "#059669",
fontWeight: 600,
border: "1px solid rgba(16, 185, 129, 0.2)",
}}
/>
)}
{research.sourceCount !== undefined && (
<Chip
label={`${research.sourceCount} source${research.sourceCount !== 1 ? "s" : ""}`}
size="small"
sx={{
background: alpha("#6366f1", 0.1),
color: "#4f46e5",
fontWeight: 600,
border: "1px solid rgba(99, 102, 241, 0.2)",
}}
/>
)}
{research.cost !== undefined && (
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
label={`$${research.cost.toFixed(3)}`}
size="small"
sx={{
background: alpha("#f59e0b", 0.1),
color: "#d97706",
fontWeight: 600,
border: "1px solid rgba(245, 158, 11, 0.2)",
}}
/>
)}
</Stack>
{/* Search Queries Used */}
{research.searchQueries && research.searchQueries.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600 }}>
Search Queries Used
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{research.searchQueries.map((query, idx) => (
<Chip
key={idx}
label={query}
size="small"
variant="outlined"
sx={{
borderColor: "rgba(102, 126, 234, 0.3)",
color: "#475569",
background: alpha("#f8fafc", 0.8),
fontSize: "0.8125rem",
}}
/>
))}
</Stack>
</Box>
)}
</Box>
<PrimaryButton
onClick={onGenerateScript}
disabled={!canGenerateScript}
startIcon={<EditNoteIcon />}
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
>
Generate Script
</PrimaryButton>
</Stack>
{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 }}>
<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>
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}
</Box>
</>
)}
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,8 @@
export { Header } from "./Header";
export { ProgressStepper } from "./ProgressStepper";
export { EstimateCard } from "./EstimateCard";
export { QuerySelection } from "./QuerySelection";
export { ResearchSummary } from "./ResearchSummary";
export { usePodcastWorkflow } from "./usePodcastWorkflow";
export * from "./utils";

View File

@@ -0,0 +1,302 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { ResearchProvider, ResearchConfig } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
import { CreateProjectPayload, Query, Research, Script, Job } from "../types";
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
interface UsePodcastWorkflowProps {
projectState: PodcastProjectStateReturn;
onError: (message: string) => void;
}
export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflowProps) => {
const {
project,
analysis,
queries,
selectedQueries,
research,
rawResearch,
researchProvider,
showScriptEditor,
showRenderQueue,
currentStep,
renderJobs,
budgetCap,
setProject,
setAnalysis,
setQueries,
setSelectedQueries,
setResearch,
setRawResearch,
setEstimate,
setScriptData,
setShowScriptEditor,
setShowRenderQueue,
setKnobs,
setResearchProvider,
setBudgetCap,
updateRenderJob,
initializeProject,
} = projectState;
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isResearching, setIsResearching] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [showResumeAlert, setShowResumeAlert] = useState(false);
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
const [preflightResponse, setPreflightResponse] = useState<any>(null);
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
const budgetTracking = useBudgetTracking(budgetCap || 50);
const preflightCheck = usePreflightCheck({
onBlocked: (response) => {
setPreflightResponse(response);
setShowPreflightDialog(true);
},
});
// Update budget cap when project state changes
useEffect(() => {
if (budgetCap) {
budgetTracking.setBudgetCap(budgetCap);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [budgetCap]);
// Check if we have a saved project on mount
useEffect(() => {
if (project && currentStep && currentStep !== "create") {
setShowResumeAlert(true);
setTimeout(() => setShowResumeAlert(false), 5000);
}
}, []); // Only on mount
useEffect(() => {
if (announcement) {
const t = setTimeout(() => setAnnouncement(""), 4000);
return () => clearTimeout(t);
}
return undefined;
}, [announcement]);
const handleCreate = useCallback(async (payload: CreateProjectPayload) => {
if (isAnalyzing) return;
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
setIsAnalyzing(true);
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload);
await initializeProject(payload, result.projectId);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
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");
} catch (error: any) {
if (error?.response?.status === 429 || error?.response?.data?.detail) {
const errorDetail = error.response.data.detail;
if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) {
const usageInfo = errorDetail.usage_info || {};
const blockedResponse = {
can_proceed: false,
estimated_cost: 0,
operations: [{
provider: errorDetail.provider || 'huggingface',
operation_type: 'ai_text_generation',
cost: 0,
allowed: false,
limit_info: usageInfo.limit_info || null,
message: errorDetail.message || errorDetail.error || 'Subscription limit exceeded',
}],
total_cost: 0,
usage_summary: usageInfo.usage_summary || null,
cached: false,
};
setPreflightResponse(blockedResponse);
setPreflightOperationName('Podcast Analysis');
setShowPreflightDialog(true);
setAnnouncement("Subscription limit reached. Please upgrade to continue.");
} else {
const message = typeof errorDetail === 'string' ? errorDetail : errorDetail.message || errorDetail.error || 'Request limit exceeded';
announceError(setAnnouncement, new Error(message));
}
} else {
announceError(setAnnouncement, error);
}
} finally {
setIsAnalyzing(false);
}
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap]);
const handleRunResearch = useCallback(async () => {
if (isResearching) return;
if (!project) {
setAnnouncement("Create a project first.");
return;
}
if (selectedQueries.size === 0) {
setAnnouncement("Select at least one query to research.");
return;
}
setPreflightOperationName("Research");
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
const preflightResult = await preflightCheck.check({
provider: researchProvider === "exa" ? "exa" : "gemini",
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
tokens_requested: researchProvider === "exa" ? 0 : 1200,
actual_provider_name: researchProvider || "exa",
});
if (!preflightResult.can_proceed) {
return;
}
try {
setIsResearching(true);
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
setResearch(null);
setRawResearch(null);
setScriptData(null);
setShowScriptEditor(false);
setShowRenderQueue(false);
try {
const { research: mapped, raw } = await podcastApi.runResearch({
projectId: project.id,
topic: project.idea,
approvedQueries,
provider: researchProvider,
exaConfig: sanitizeExaConfig(analysis?.exaSuggestedConfig),
onProgress: (message) => {
setAnnouncement(message);
},
});
setResearch(mapped);
setRawResearch(raw);
setAnnouncement("Research complete — review fact cards below");
} catch (researchError) {
const errorMessage = researchError instanceof Error
? researchError.message
: "Research failed. Please try again or switch to Standard Research.";
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
} else if (errorMessage.includes("timeout")) {
setAnnouncement("Research timed out. Please try again with fewer queries.");
} else {
setAnnouncement(`Research failed: ${errorMessage}`);
}
console.error("Research error:", researchError);
throw researchError;
}
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setIsResearching(false);
}
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue]);
const handleGenerateScript = useCallback(async () => {
if (showScriptEditor) return;
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
setPreflightOperationName("Script Generation");
const preflightResult = await preflightCheck.check({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
if (!preflightResult.can_proceed) {
return;
}
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor]);
const handleProceedToRendering = useCallback((script: Script) => {
setScriptData(script);
if (renderJobs.length === 0) {
script.scenes.forEach((scene) => {
const hasExistingAudio = Boolean(scene.audioUrl);
updateRenderJob(scene.id, {
sceneId: scene.id,
title: scene.title,
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
progress: hasExistingAudio ? 100 : 0,
previewUrl: null,
finalUrl: hasExistingAudio ? scene.audioUrl : null,
jobId: null,
});
});
}
setShowRenderQueue(true);
setShowScriptEditor(false);
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
const toggleQuery = useCallback((id: string) => {
if (isResearching) return;
const current = selectedQueries;
const next = new Set<string>(current);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedQueries(next);
}, [isResearching, selectedQueries, setSelectedQueries]);
const activeStep = useMemo(() => {
if (showRenderQueue) return 3;
if (showScriptEditor) return 2;
if (research) return 1;
if (analysis) return 0;
return -1;
}, [showRenderQueue, showScriptEditor, research, analysis]);
const canGenerateScript = Boolean(project && research && rawResearch);
return {
// State
isAnalyzing,
isResearching,
announcement,
showResumeAlert,
showPreflightDialog,
preflightResponse,
preflightOperationName,
activeStep,
canGenerateScript,
// Handlers
handleCreate,
handleRunResearch,
handleGenerateScript,
handleProceedToRendering,
toggleQuery,
setAnnouncement,
setShowResumeAlert,
setShowPreflightDialog,
setPreflightResponse,
setResearchProvider,
getStepLabel,
};
};

View File

@@ -0,0 +1,76 @@
import { ResearchConfig, DateRange } from "../../../services/blogWriterApi";
import { CreateProjectPayload, Knobs } from "../types";
export const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
export const allowedDateRanges: DateRange[] = [
"last_week",
"last_month",
"last_3_months",
"last_6_months",
"last_year",
"all_time",
];
export const sanitizeExaConfig = (
exa?: CreateProjectPayload["knobs"] & any & { exa_suggested_config?: any } | any
): ResearchConfig | undefined => {
if (!exa) return undefined;
const cfg = exa as {
exa_search_type?: "auto" | "keyword" | "neural";
exa_category?: string;
exa_include_domains?: string[];
exa_exclude_domains?: string[];
max_sources?: number;
include_statistics?: boolean;
date_range?: string;
};
const searchType: ResearchConfig["exa_search_type"] =
cfg.exa_search_type && ["auto", "keyword", "neural"].includes(cfg.exa_search_type)
? cfg.exa_search_type
: undefined;
const dateRange: DateRange | undefined = cfg.date_range && allowedDateRanges.includes(cfg.date_range as DateRange)
? (cfg.date_range as DateRange)
: undefined;
return {
provider: "exa",
exa_search_type: searchType,
exa_category: cfg.exa_category,
exa_include_domains: cfg.exa_include_domains,
exa_exclude_domains: cfg.exa_exclude_domains,
max_sources: cfg.max_sources,
include_statistics: cfg.include_statistics,
date_range: dateRange,
};
};
export const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
const message = error instanceof Error ? error.message : "Unexpected error";
setAnnouncement(message);
};
export const getStepLabel = (step: string | null): string => {
switch (step) {
case "analysis":
return "Analysis";
case "research":
return "Research";
case "script":
return "Script Editing";
case "render":
return "Rendering";
default:
return "Unknown";
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,16 @@
import React, { useEffect, useState } from "react";
import { Stack, Box, Typography, TextField, Button, Chip, CircularProgress, alpha } from "@mui/material";
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
import { Stack, Box, Typography, TextField, Button, Chip, alpha } from "@mui/material";
import { Line } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
interface LineEditorProps {
line: Line;
onChange: (l: Line) => void;
onPreview: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
}
export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPreview }) => {
export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange }) => {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(line.text);
const [previewing, setPreviewing] = useState(false);
useEffect(() => setText(line.text), [line.text]);
const handleSave = () => {
@@ -21,33 +18,37 @@ export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPrevie
setEditing(false);
};
const handlePreview = async () => {
setPreviewing(true);
try {
const res = await onPreview(text);
if (res.audioUrl) {
window.open(res.audioUrl, "_blank");
} else {
alert(res.message);
}
} finally {
setPreviewing(false);
}
};
return (
<GlassyCard
whileHover={{ y: -2 }}
sx={{
...glassyCardSx,
p: 2,
transition: "all 0.2s",
p: 2.5,
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
borderLeft: "3px solid transparent",
"&:hover": {
borderLeftColor: "#667eea",
boxShadow: "0 4px 6px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06)",
},
}}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box flex={1}>
<Chip label={line.speaker} size="small" sx={{ mb: 1, background: alpha("#667eea", 0.2), color: "#a78bfa" }} />
<Chip
label={line.speaker}
size="small"
sx={{
mb: 1.5,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
color: "#667eea",
fontWeight: 600,
fontSize: "0.75rem",
height: 24,
border: "1px solid rgba(102, 126, 234, 0.2)",
boxShadow: "0 1px 2px rgba(102, 126, 234, 0.05)",
}}
/>
{editing ? (
<TextField
fullWidth
@@ -57,47 +58,97 @@ export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPrevie
onChange={(e) => setText(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
color: "#0f172a",
backgroundColor: "#f8fafc",
borderRadius: 2,
"& fieldset": {
borderColor: "rgba(15, 23, 42, 0.12)",
borderWidth: 1.5,
},
"&:hover fieldset": {
borderColor: "rgba(102, 126, 234, 0.4)",
},
"&.Mui-focused fieldset": {
borderColor: "#667eea",
borderWidth: 2,
},
},
"& .MuiInputBase-input": {
color: "#0f172a",
fontWeight: 400,
fontSize: "0.9375rem",
lineHeight: 1.6,
},
}}
/>
) : (
<Typography variant="body2" sx={{ lineHeight: 1.7, color: "rgba(255,255,255,0.9)" }}>
<Typography
variant="body2"
sx={{
lineHeight: 1.75,
color: "#0f172a",
fontWeight: 400,
fontSize: "0.9375rem",
letterSpacing: "0.01em",
}}
>
{line.text}
</Typography>
)}
{line.usedFactIds && line.usedFactIds.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary">
<Stack direction="row" spacing={0.5} sx={{ mt: 1.5 }} flexWrap="wrap" useFlexGap>
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.75rem" }}>
Facts:
</Typography>
{line.usedFactIds.map((id) => (
<Chip key={id} label={id} size="small" variant="outlined" sx={{ fontSize: "0.65rem", height: 20 }} />
<Chip
key={id}
label={id}
size="small"
variant="outlined"
sx={{
fontSize: "0.6875rem",
height: 22,
color: "#64748b",
borderColor: "rgba(15, 23, 42, 0.12)",
fontWeight: 500,
}}
/>
))}
</Stack>
)}
</Box>
<Stack spacing={1} sx={{ ml: 2 }}>
<Box sx={{ ml: 2 }}>
<Button
size="small"
variant={editing ? "contained" : "outlined"}
onClick={editing ? handleSave : () => setEditing(true)}
sx={{ minWidth: 80 }}
sx={{
minWidth: 85,
color: editing ? "white" : "#667eea",
borderColor: editing ? "transparent" : "#667eea",
backgroundColor: editing
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
: "transparent",
fontWeight: 600,
fontSize: "0.8125rem",
textTransform: "none",
borderRadius: 2,
borderWidth: editing ? 0 : 1.5,
boxShadow: editing ? "0 2px 4px rgba(102, 126, 234, 0.2)" : "none",
"&:hover": {
borderColor: editing ? "transparent" : "#5568d3",
backgroundColor: editing
? "linear-gradient(135deg, #5568d3 0%, #6b3fa0 100%)"
: alpha("#667eea", 0.08),
boxShadow: editing ? "0 4px 8px rgba(102, 126, 234, 0.3)" : "none",
},
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
{editing ? "Save" : "Edit"}
</Button>
<Button
size="small"
variant="outlined"
startIcon={previewing ? <CircularProgress size={14} /> : <VolumeUpIcon />}
onClick={handlePreview}
disabled={previewing || editing}
sx={{ minWidth: 120 }}
>
Preview TTS
</Button>
</Stack>
</Box>
</Stack>
</Stack>
</GlassyCard>

View File

@@ -1,84 +1,377 @@
import React from "react";
import { Stack, Box, Typography, Divider, Chip, alpha } from "@mui/material";
import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
VolumeUp as VolumeUpIcon,
PlayArrow as PlayArrowIcon,
Image as ImageIcon,
} from "@mui/icons-material";
import { Scene, Line } from "../types";
import { Scene, Line, Knobs } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor";
import { podcastApi } from "../../../services/podcastApi";
import { aiApiClient } from "../../../api/client";
interface SceneEditorProps {
scene: Scene;
onUpdateScene: (s: Scene) => void;
onApprove: (id: string) => Promise<void>;
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
knobs: Knobs;
approvingSceneId?: string | null;
generatingAudioId?: string | null;
onAudioGenerationStart?: (sceneId: string) => void;
onAudioGenerated?: (sceneId: string, audioUrl: string) => void;
idea?: string; // Podcast idea for image generation context
}
export const SceneEditor: React.FC<SceneEditorProps> = ({
scene,
onUpdateScene,
onApprove,
onPreviewLine,
knobs,
approvingSceneId,
generatingAudioId,
onAudioGenerationStart,
onAudioGenerated,
idea,
}) => {
const [localGenerating, setLocalGenerating] = useState(false);
const [generatingImage, setGeneratingImage] = useState(false);
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
// Load audio as blob when audioUrl is available
useEffect(() => {
if (!scene.audioUrl) {
// Clean up blob URL if audioUrl is removed
setAudioBlobUrl((currentBlobUrl) => {
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
return null;
});
return;
}
let isMounted = true;
const currentAudioUrl = scene.audioUrl; // Capture current value
const loadAudioBlob = async () => {
try {
// Normalize path
let audioPath = currentAudioUrl.startsWith('/') ? currentAudioUrl : `/${currentAudioUrl}`;
// Convert /api/story/audio/ to /api/podcast/audio/ if needed
if (audioPath.includes('/api/story/audio/')) {
const filename = audioPath.split('/api/story/audio/').pop() || '';
audioPath = `/api/podcast/audio/${filename}`;
}
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || currentAudioUrl;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
if (!isMounted) {
// Component unmounted or audioUrl changed, don't set blob URL
return;
}
// Double-check that audioUrl hasn't changed
if (scene.audioUrl !== currentAudioUrl) {
return;
}
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
setAudioBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl) {
URL.revokeObjectURL(prevBlobUrl);
}
return blobUrl;
});
} catch (error) {
console.error(`Failed to load audio blob for scene ${scene.id}:`, error);
// Don't set blob URL on error - will show error state
}
};
loadAudioBlob();
// Cleanup: only mark as unmounted, don't revoke blob URL here
// The blob URL will be cleaned up when audioUrl changes (new effect) or component unmounts
return () => {
isMounted = false;
};
}, [scene.audioUrl, scene.id]);
const updateLine = (updatedLine: Line) => {
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
onUpdateScene(updated);
};
const approving = approvingSceneId === scene.id;
const generating = generatingAudioId === scene.id || localGenerating;
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
const hasImage = Boolean(scene.imageUrl);
const handleApprove = async () => {
await onApprove(scene.id);
onUpdateScene({ ...scene, approved: true });
const handleApproveAndGenerate = async () => {
const wasAlreadyApproved = scene.approved;
const sceneId = scene.id;
try {
// Set generating state
setLocalGenerating(true);
if (onAudioGenerationStart) {
onAudioGenerationStart(sceneId);
}
// If scene is not approved yet, approve it first
// This will update the parent script state
if (!scene.approved) {
await onApprove(sceneId);
// The parent's approveScene already updated the script state
// We need to wait for React to propagate the updated scene prop
// For now, we'll update it locally too to ensure UI updates immediately
onUpdateScene({ ...scene, approved: true });
}
// Use the current scene (which should now be approved)
// If scene prop hasn't updated yet, use the local update we just made
const currentScene = { ...scene, approved: true };
// Generate audio
const result = await podcastApi.renderSceneAudio({
scene: currentScene,
voiceId: "Wise_Woman",
emotion: scene.emotion || knobs.voice_emotion || "neutral",
speed: knobs.voice_speed || 1.0,
});
// Update scene with audio URL and ensure approved state
// This will sync with parent script state
const updatedScene = { ...currentScene, audioUrl: result.audioUrl, approved: true };
onUpdateScene(updatedScene);
if (onAudioGenerated) {
onAudioGenerated(sceneId, result.audioUrl);
}
} catch (error) {
console.error("Failed to approve and generate audio:", error);
// On error, revert approval only if we just approved it in this call
if (!wasAlreadyApproved) {
onUpdateScene({ ...scene, approved: false, audioUrl: undefined });
}
throw error;
} finally {
setLocalGenerating(false);
}
};
const handleGenerateImage = async () => {
const sceneId = scene.id;
try {
setGeneratingImage(true);
// Build scene content from lines for context
const sceneContent = scene.lines.map((line) => line.text).join(" ");
const result = await podcastApi.generateSceneImage({
sceneId: scene.id,
sceneTitle: scene.title,
sceneContent: sceneContent,
idea: idea,
width: 1024,
height: 1024,
});
// Update scene with image URL
const updatedScene = { ...scene, imageUrl: result.image_url };
onUpdateScene(updatedScene);
} catch (error) {
console.error("Failed to generate image:", error);
throw error;
} finally {
setGeneratingImage(false);
}
};
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
<Stack spacing={2.5}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<EditNoteIcon fontSize="small" />
<Box sx={{ flex: 1 }}>
<Typography
variant="h6"
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
mb: 1,
color: "#0f172a",
fontWeight: 600,
fontSize: "1.25rem",
letterSpacing: "-0.01em",
}}
>
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
<Chip
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
label={scene.approved ? "Approved" : "Pending Approval"}
size="small"
color={scene.approved ? "success" : "warning"}
sx={{
background: scene.approved ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
color: scene.approved ? "#6ee7b7" : "#fbbf24",
border: scene.approved ? "1px solid rgba(16,185,129,0.3)" : "1px solid rgba(245,158,11,0.3)",
background: scene.approved
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
color: scene.approved ? "#059669" : "#d97706",
border: scene.approved
? "1px solid rgba(16, 185, 129, 0.25)"
: "1px solid rgba(245, 158, 11, 0.25)",
fontWeight: 600,
fontSize: "0.75rem",
height: 26,
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
}}
/>
<Typography variant="caption" color="text.secondary">
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
Duration: {scene.duration}s
</Typography>
</Stack>
</Box>
<PrimaryButton
onClick={handleApprove}
disabled={scene.approved || approving}
loading={approving}
startIcon={scene.approved ? <CheckCircleIcon /> : undefined}
tooltip={scene.approved ? "Scene is approved and ready for rendering" : "Approve this scene to enable rendering"}
>
{scene.approved ? "Approved" : approving ? "Approving..." : "Approve Scene"}
</PrimaryButton>
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
<PrimaryButton
onClick={handleApproveAndGenerate}
disabled={approving || generating}
loading={approving || generating}
startIcon={
hasAudio && !generating ? (
<VolumeUpIcon />
) : generating ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<PlayArrowIcon />
)
}
tooltip={
hasAudio && !generating
? "Regenerate audio for this scene"
: generating
? "Generating audio..."
: scene.approved
? "Generate audio for this scene"
: "Approve scene and generate audio"
}
sx={{
minWidth: 200,
}}
>
{hasAudio && !generating
? "Regenerate Audio"
: generating
? "Generating Audio..."
: scene.approved
? "Generate Audio"
: "Approve & Generate Audio"}
</PrimaryButton>
<PrimaryButton
onClick={handleGenerateImage}
disabled={generatingImage}
loading={generatingImage}
startIcon={
hasImage && !generatingImage ? (
<ImageIcon />
) : generatingImage ? (
<CircularProgress size={16} sx={{ color: "white" }} />
) : (
<ImageIcon />
)
}
tooltip={
hasImage
? "Regenerate image for this scene"
: generatingImage
? "Generating image..."
: "Generate image for video (optional)"
}
sx={{
minWidth: 180,
background: hasImage
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"&:hover": {
background: hasImage
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
}}
>
{hasImage && !generatingImage
? "Regenerate Image"
: generatingImage
? "Generating Image..."
: "Generate Image"}
</PrimaryButton>
</Stack>
</Stack>
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1 }} />
<Stack spacing={2}>
{scene.lines.map((line) => (
<LineEditor key={line.id} line={line} onChange={updateLine} onPreview={(text) => onPreviewLine(text)} />
<LineEditor key={line.id} line={line} onChange={updateLine} />
))}
</Stack>
{scene.audioUrl && (
<>
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
<Box
sx={{
p: 2,
background: hasAudio
? "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)"
: "linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.08) 100%)",
borderRadius: 2,
border: hasAudio
? "1px solid rgba(16, 185, 129, 0.2)"
: "1px solid rgba(245, 158, 11, 0.2)",
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
<VolumeUpIcon sx={{ color: hasAudio ? "#059669" : "#d97706", fontSize: "1.25rem" }} />
<Typography variant="subtitle2" sx={{ color: hasAudio ? "#059669" : "#d97706", fontWeight: 600 }}>
{hasAudio ? "Audio Generated" : "Loading Audio..."}
</Typography>
</Stack>
{hasAudio && audioBlobUrl ? (
<audio controls style={{ width: "100%", borderRadius: 8 }}>
<source src={audioBlobUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
) : (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", py: 2 }}>
<CircularProgress size={24} sx={{ color: "#d97706" }} />
</Box>
)}
</Box>
</>
)}
</Stack>
</GlassyCard>
);

View File

@@ -1,11 +1,13 @@
import React, { useEffect, useState } from "react";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon } from "@mui/icons-material";
import React, { useEffect, useState, useCallback } from "react";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
import { Script, Knobs, Scene } from "../types";
import { BlogResearchResponse } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor";
import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { aiApiClient } from "../../../api/client";
interface ScriptEditorProps {
projectId: string;
@@ -40,6 +42,15 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true);
const [combiningAudio, setCombiningAudio] = useState(false);
const [combinedAudioResult, setCombinedAudioResult] = useState<{
url: string;
filename: string;
duration: number;
sceneCount: number;
} | null>(null);
// Sync with parent state
useEffect(() => {
@@ -90,26 +101,32 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
const updateScene = (updated: Scene) => {
if (!script) return;
const updatedScript = { ...script, scenes: script.scenes.map((s) => (s.id === updated.id ? updated : s)) };
setScript(updatedScript);
onScriptChange(updatedScript);
// Use functional update to ensure we're working with latest state
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScript = {
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
};
onScriptChange(updatedScript);
return updatedScript;
});
};
const approveScene = async (sceneId: string) => {
try {
setApprovingSceneId(sceneId);
await podcastApi.approveScene({ projectId, sceneId });
const updatedScript = script
? {
...script,
scenes: script.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
}
: null;
if (updatedScript) {
setScript(updatedScript);
// Use functional update to ensure we're working with latest state
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScript = {
...currentScript,
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
};
onScriptChange(updatedScript);
}
return updatedScript;
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to approve scene";
setError(message);
@@ -123,47 +140,405 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
const allApproved = script && script.scenes.every((s) => s.approved);
const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0;
const totalScenes = script ? script.scenes.length : 0;
// Check if all scenes have both audio and images (required for video rendering)
const allScenesHaveAudioAndImages = script && script.scenes.every((s) => s.audioUrl && s.imageUrl);
const scenesWithAudio = script ? script.scenes.filter((s) => s.audioUrl).length : 0;
const allScenesHaveAudio = script && script.scenes.every((s) => s.audioUrl);
const combineAudio = useCallback(async () => {
if (!script || !projectId) return;
try {
setCombiningAudio(true);
const sceneIds: string[] = [];
const sceneAudioUrls: string[] = [];
script.scenes.forEach((scene) => {
if (scene.audioUrl) {
// Ensure we're using the correct URL format (not blob URLs)
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
if (audioUrl) {
sceneIds.push(scene.id);
sceneAudioUrls.push(audioUrl);
}
}
});
if (sceneIds.length === 0) {
onError("No audio files found to combine.");
return;
}
const result = await podcastApi.combineAudio({
projectId,
sceneIds,
sceneAudioUrls,
});
// Store combined audio result for preview
setCombinedAudioResult({
url: result.combined_audio_url,
filename: result.combined_audio_filename,
duration: result.total_duration,
sceneCount: result.scene_count,
});
// Download the combined audio as blob (for authenticated endpoints)
try {
// Normalize path
let audioPath = result.combined_audio_url.startsWith('/')
? result.combined_audio_url
: `/${result.combined_audio_url}`;
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || result.combined_audio_filename;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
// Fetch as blob using authenticated client
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
// Create blob URL and download
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = result.combined_audio_filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL after a delay
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (downloadError) {
console.error('Failed to download combined audio:', downloadError);
onError('Failed to download audio file. You can try downloading again from the preview.');
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to combine audio";
onError(`Failed to combine audio: ${message}`);
} finally {
setCombiningAudio(false);
}
}, [script, projectId, onError]);
return (
<Box sx={{ mt: 3 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<EditNoteIcon />
Script Editor
</Typography>
<Box sx={{ flex: 1 }}>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 700,
letterSpacing: "-0.02em",
display: "flex",
alignItems: "center",
gap: 1.5,
fontSize: { xs: "1.75rem", md: "2rem" },
}}
>
<EditNoteIcon sx={{ fontSize: "2rem" }} />
Script Editor
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
Review and refine your podcast script before rendering
</Typography>
</Box>
</Stack>
{loading && (
<Alert severity="info" icon={<CircularProgress size={20} />} sx={{ mb: 3 }}>
<Typography variant="body2">Generating script with AI... This may take a moment.</Typography>
<Alert
severity="info"
icon={<CircularProgress size={20} />}
sx={{
mb: 3,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(99, 102, 241, 0.05)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500 }}>
Generating script with AI... This may take a moment.
</Typography>
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
<Alert
severity="error"
sx={{
mb: 3,
background: "linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%)",
border: "1px solid rgba(239, 68, 68, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(239, 68, 68, 0.05)",
"& .MuiAlert-icon": {
color: "#ef4444",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500 }}>
{error}
</Typography>
</Alert>
)}
{script && (
<Stack spacing={3}>
<Alert severity="info" sx={{ background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2">
<strong>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
{/* Script Format Explanation Panel */}
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
border: "1px solid rgba(99, 102, 241, 0.15)",
borderRadius: 2,
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.08)",
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
}}
>
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
</Box>
<Box>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
Why This Script Format?
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
Understanding how your script creates natural, human-like audio
</Typography>
</Box>
</Stack>
<IconButton
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
sx={{
color: "#6366f1",
"&:hover": {
background: "rgba(99, 102, 241, 0.1)",
},
}}
>
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Stack>
<Collapse in={showScriptFormatInfo}>
<Stack spacing={2.5}>
<Box>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8, mb: 2 }}>
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>.
The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
</Typography>
</Box>
<Stack spacing={2}>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
1
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Natural Pauses & Rhythm
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns
and conversation flow, just like real human speech. Without these pauses, the audio would sound rushed and robotic.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
2
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Emphasis Markers
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Lines marked with emphasis help highlight important points, statistics, or key insights. The AI voice will naturally
stress these parts, making your podcast more engaging and easier to followjust like a real host would emphasize important information.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
3
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Short, Conversational Sentences
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script uses shorter sentences (15-20 words) written in a conversational style. This matches how people actually
speak, making the audio sound more natural. Long, complex sentences would sound awkward when spoken aloud.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
4
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Scene-Specific Emotions
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Each scene has an emotional tone (excited, serious, curious, etc.) that guides the AI voice's delivery. This creates
variety and keeps listeners engaged, just like a real podcast host would vary their tone based on the topic.
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box
sx={{
minWidth: 32,
height: 32,
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
5
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
Optimized for Podcast Narration
</Typography>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
The script is optimized with slightly slower pacing and natural pronunciation settings specifically for podcast narration.
This ensures clarity and makes the content easy to understand, even when listeners are multitasking.
</Typography>
</Box>
</Box>
</Stack>
<Alert
severity="info"
sx={{
mt: 1,
background: "rgba(99, 102, 241, 0.06)",
border: "1px solid rgba(99, 102, 241, 0.15)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences.
The format will be preserved when rendering, ensuring your audio still sounds natural and professional.
</Typography>
</Alert>
</Stack>
</Collapse>
</Paper>
<Alert
severity="info"
sx={{
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: 2,
boxShadow: "0 1px 2px rgba(99, 102, 241, 0.05)",
"& .MuiAlert-icon": {
color: "#6366f1",
},
}}
>
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500, lineHeight: 1.6 }}>
<strong style={{ fontWeight: 600 }}>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
</Typography>
</Alert>
@@ -179,8 +554,27 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
scene={scene}
onUpdateScene={updateScene}
onApprove={approveScene}
onPreviewLine={(text) => podcastApi.previewLine(text)}
knobs={knobs}
approvingSceneId={approvingSceneId}
generatingAudioId={generatingAudioId}
onAudioGenerationStart={(sceneId) => {
setGeneratingAudioId(sceneId);
}}
onAudioGenerated={async (sceneId, audioUrl) => {
setGeneratingAudioId(null);
// Use functional update to ensure we're working with latest state
// Ensure scene is marked as approved and has audioUrl
setScript((currentScript) => {
if (!currentScript) return currentScript;
const updatedScenes = currentScript.scenes.map((s) =>
s.id === sceneId ? { ...s, audioUrl, approved: true } : s
);
const updatedScript = { ...currentScript, scenes: updatedScenes };
onScriptChange(updatedScript);
return updatedScript;
});
}}
idea={idea}
/>
</GlassyCard>
))}
@@ -188,39 +582,187 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
<Paper
sx={{
p: 3,
background: alpha("#1e293b", 0.6),
border: allApproved ? "2px solid rgba(16,185,129,0.4)" : "1px solid rgba(255,255,255,0.1)",
p: 3.5,
background: allApproved
? "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%)"
: "#ffffff",
border: allApproved
? "2px solid rgba(16, 185, 129, 0.25)"
: "1px solid rgba(15, 23, 42, 0.08)",
borderRadius: 3,
boxShadow: allApproved
? "0 4px 6px rgba(16, 185, 129, 0.08), 0 8px 24px rgba(16, 185, 129, 0.06)"
: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5, display: "flex", alignItems: "center", gap: 1 }}>
<CheckCircleIcon fontSize="small" color={allApproved ? "success" : "disabled"} />
<Typography variant="subtitle1" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
<CheckCircleIcon fontSize="small" sx={{ color: allApproved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />
Approval Status
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
{approvedCount} of {totalScenes} scenes approved
{!allApproved && " Approve all scenes to enable rendering"}
{allScenesHaveAudioAndImages && " All scenes ready for video rendering"}
{!allScenesHaveAudioAndImages && allApproved && " • Generate images for all scenes to enable video rendering"}
{!allApproved && " — Approve all scenes first"}
</Typography>
{!allApproved && (
{!allScenesHaveAudioAndImages && (
<LinearProgress
variant="determinate"
value={(approvedCount / totalScenes) * 100}
value={
allScenesHaveAudioAndImages
? 100
: script
? (script.scenes.filter((s) => s.audioUrl && s.imageUrl).length / totalScenes) * 100
: 0
}
sx={{ mt: 1, height: 6, borderRadius: 3 }}
/>
)}
</Box>
<PrimaryButton
onClick={() => script && onProceedToRendering(script)}
disabled={!allApproved}
disabled={!allScenesHaveAudioAndImages}
startIcon={<PlayArrowIcon />}
tooltip={!allApproved ? "Approve all scenes to proceed to rendering" : "Start rendering all approved scenes"}
tooltip={
!allScenesHaveAudioAndImages
? "Generate audio and images for all scenes to proceed to video rendering"
: "Proceed to video rendering (all scenes have audio and images)"
}
>
Proceed to Rendering
</PrimaryButton>
</Stack>
</Paper>
{/* Download Audio-Only Podcast Section */}
{allScenesHaveAudio && (
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
border: "1px solid rgba(102, 126, 234, 0.15)",
borderRadius: 2,
}}
>
<Stack spacing={3}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600 }}>
Download Audio-Only Podcast
</Typography>
{!combinedAudioResult ? (
<>
<PrimaryButton
onClick={combineAudio}
disabled={combiningAudio}
loading={combiningAudio}
startIcon={<DownloadIcon />}
tooltip="Combine all scene audio files into a single podcast episode"
sx={{
minWidth: 280,
fontSize: "1rem",
py: 1.5,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"&:hover": {
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
},
}}
>
{combiningAudio ? "Combining Audio..." : "Download Audio-Only Podcast"}
</PrimaryButton>
<Typography variant="caption" sx={{ color: "#64748b", fontStyle: "italic" }}>
This will combine all {scenesWithAudio} scene audio files into one complete podcast episode.
</Typography>
</>
) : (
<Stack spacing={2}>
{/* Success Alert */}
<Alert
severity="success"
sx={{
background: alpha("#10b981", 0.1),
border: "1px solid rgba(16,185,129,0.3)",
"& .MuiAlert-icon": { color: "#10b981" },
}}
>
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
Combined audio generated successfully! ({combinedAudioResult.sceneCount} scenes,{" "}
{Math.round(combinedAudioResult.duration)}s)
</Typography>
</Alert>
{/* Combined Audio Preview */}
<InlineAudioPlayer audioUrl={combinedAudioResult.url} title="Complete Podcast Episode" />
{/* Action Buttons */}
<Stack direction="row" spacing={2}>
<SecondaryButton
onClick={async () => {
try {
// Normalize path
let audioPath = combinedAudioResult.url.startsWith('/')
? combinedAudioResult.url
: `/${combinedAudioResult.url}`;
// Ensure it's a podcast audio endpoint
if (!audioPath.includes('/api/podcast/audio/')) {
const filename = audioPath.split('/').pop() || combinedAudioResult.filename;
audioPath = `/api/podcast/audio/${filename}`;
}
// Remove query parameters if present
audioPath = audioPath.split('?')[0];
// Fetch as blob using authenticated client
const response = await aiApiClient.get(audioPath, {
responseType: 'blob',
});
// Create blob URL and download
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = combinedAudioResult.filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up blob URL after a delay
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 100);
} catch (error) {
console.error('Failed to download audio:', error);
onError('Failed to download audio file. Please try again.');
}
}}
startIcon={<DownloadIcon />}
tooltip="Download the combined audio file again"
>
Download Again
</SecondaryButton>
<SecondaryButton
onClick={() => {
setCombinedAudioResult(null);
combineAudio();
}}
disabled={combiningAudio}
loading={combiningAudio}
startIcon={<RefreshIcon />}
tooltip="Regenerate combined audio (useful if scenes were updated)"
>
Regenerate
</SecondaryButton>
</Stack>
</Stack>
)}
</Stack>
</Paper>
)}
</Stack>
)}
</Box>

View File

@@ -30,6 +30,11 @@ export type Research = {
why: string;
mappedFactIds: string[];
}[];
searchQueries?: string[];
searchType?: string;
provider?: string;
cost?: number;
sourceCount?: number;
};
export type Line = {
@@ -37,6 +42,7 @@ export type Line = {
speaker: string;
text: string;
usedFactIds?: string[];
emphasis?: boolean; // Mark lines that need vocal emphasis
};
export type Scene = {
@@ -45,6 +51,9 @@ export type Scene = {
duration: number;
lines: Line[];
approved?: boolean;
emotion?: string; // Scene-specific emotion
audioUrl?: string; // Generated audio URL for this scene
imageUrl?: string; // Generated image URL for this scene (for video generation)
};
export type Script = {
@@ -75,6 +84,7 @@ export type Job = {
voiceId?: string | null;
fileSize?: number | null;
avatarImageUrl?: string | null;
imageUrl?: string | null; // Scene-specific image URL
};
export type PodcastAnalysis = {
@@ -84,6 +94,15 @@ export type PodcastAnalysis = {
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
suggestedKnobs: Knobs;
titleSuggestions: string[];
exaSuggestedConfig?: {
exa_search_type?: "auto" | "keyword" | "neural";
exa_category?: string;
exa_include_domains?: string[];
exa_exclude_domains?: string[];
max_sources?: number;
include_statistics?: boolean;
date_range?: string;
};
};
export type PodcastEstimate = {

View File

@@ -5,10 +5,16 @@ import { Paper, alpha } from "@mui/material";
export const GlassyCard = motion(Paper);
export const glassyCardSx = {
borderRadius: 2,
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 3,
border: "1px solid rgba(15, 23, 42, 0.06)",
background: "#ffffff",
p: 2.5,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
p: 3,
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
color: "#0f172a",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
"&:hover": {
boxShadow: "0 4px 6px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06)",
borderColor: "rgba(15, 23, 42, 0.1)",
},
};

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Button, CircularProgress, Tooltip, alpha } from "@mui/material";
import { Button, CircularProgress, Tooltip, alpha, SxProps, Theme } from "@mui/material";
interface PrimaryButtonProps {
children: React.ReactNode;
@@ -9,6 +9,7 @@ interface PrimaryButtonProps {
startIcon?: React.ReactNode;
tooltip?: string;
ariaLabel?: string;
sx?: SxProps<Theme>;
}
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
@@ -19,6 +20,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
startIcon,
tooltip,
ariaLabel,
sx,
}) => {
const button = (
<Button
@@ -41,6 +43,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
background: alpha("#9ca3af", 0.3),
color: alpha("#fff", 0.5),
},
...sx,
}}
>
{children}

View File

@@ -1,10 +1,11 @@
import React from "react";
import { Button, Tooltip, alpha } from "@mui/material";
import { Button, Tooltip, CircularProgress, alpha } from "@mui/material";
interface SecondaryButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
startIcon?: React.ReactNode;
tooltip?: string;
ariaLabel?: string;
@@ -14,6 +15,7 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
children,
onClick,
disabled = false,
loading = false,
startIcon,
tooltip,
ariaLabel,
@@ -22,8 +24,8 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
<Button
variant="outlined"
onClick={onClick}
disabled={disabled}
startIcon={startIcon}
disabled={disabled || loading}
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
aria-label={ariaLabel}
sx={{
borderColor: "rgba(255,255,255,0.2)",

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { Suspense } from 'react';
import {
Card,
CardContent,
@@ -6,10 +6,17 @@ import {
Box,
Grid,
Chip,
CircularProgress,
} from '@mui/material';
import {
TrendingUp as TrendingUpIcon,
TrendingDown as TrendingDownIcon,
CalendarToday as CalendarIcon,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import {
LineChart,
LazyLineChart,
LazyAreaChart,
Line,
XAxis,
YAxis,
@@ -17,13 +24,8 @@ import {
Tooltip,
ResponsiveContainer,
Area,
AreaChart
} from 'recharts';
import {
TrendingUp,
TrendingDown,
Calendar
} from 'lucide-react';
ChartLoadingFallback
} from '../../utils/lazyRecharts';
// Types
import { UsageTrends as UsageTrendsType, CostProjections } from '../../types/billing';
@@ -113,7 +115,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
{/* Header */}
<CardContent sx={{ pb: 1 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', mb: 2 }}>
<TrendingUp size={20} />
<TrendingUpIcon fontSize="small" />
Usage Trends & Projections
</Typography>
</CardContent>
@@ -138,9 +140,9 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{costGrowth >= 0 ? (
<TrendingUp size={16} color="#22c55e" />
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
) : (
<TrendingDown size={16} color="#ef4444" />
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
)}
<Typography variant="body2" color="text.secondary">
Cost Growth
@@ -176,9 +178,9 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
{callsGrowth >= 0 ? (
<TrendingUp size={16} color="#22c55e" />
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
) : (
<TrendingDown size={16} color="#ef4444" />
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
)}
<Typography variant="body2" color="text.secondary">
Calls Growth
@@ -205,7 +207,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
</Typography>
<Box sx={{ height: 200 }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<Suspense fallback={<ChartLoadingFallback />}>
<LazyAreaChart data={chartData}>
<defs>
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#667eea" stopOpacity={0.3}/>
@@ -234,7 +237,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
fillOpacity={1}
fill="url(#costGradient)"
/>
</AreaChart>
</LazyAreaChart>
</Suspense>
</ResponsiveContainer>
</Box>
</Box>
@@ -246,7 +250,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
</Typography>
<Box sx={{ height: 150 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<Suspense fallback={<ChartLoadingFallback />}>
<LazyLineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="period"
@@ -267,7 +272,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
dot={{ fill: '#764ba2', strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, stroke: '#764ba2', strokeWidth: 2 }}
/>
</LineChart>
</LazyLineChart>
</Suspense>
</ResponsiveContainer>
</Box>
</Box>
@@ -282,7 +288,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
}}
>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<Calendar size={16} />
<CalendarIcon fontSize="small" />
Monthly Projections
</Typography>

View File

@@ -65,7 +65,7 @@ const DEFAULT_STATE: PodcastProjectState = {
scriptData: null,
renderJobs: [],
knobs: DEFAULT_KNOBS,
researchProvider: "google",
researchProvider: "exa",
budgetCap: 50,
showScriptEditor: false,
showRenderQueue: false,
@@ -327,7 +327,7 @@ export const usePodcastProjectState = () => {
scriptData: dbProject.script_data,
renderJobs: dbProject.render_jobs || [],
knobs: dbProject.knobs || DEFAULT_KNOBS,
researchProvider: dbProject.research_provider || 'google',
researchProvider: dbProject.research_provider || 'exa',
budgetCap: dbProject.budget_cap || 50,
showScriptEditor: dbProject.show_script_editor || false,
showRenderQueue: dbProject.show_render_queue || false,

View File

@@ -1,7 +1,6 @@
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse, ResearchProvider } from "./blogWriterApi";
import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
import {
storyWriterApi,
StoryGenerationRequest,
StoryScene,
StorySetupGenerationResponse,
} from "./storyWriterApi";
@@ -22,11 +21,8 @@ import {
Script,
} from "../components/PodcastMaker/types";
import { checkPreflight, PreflightOperation } from "./billingService";
import { TaskStatusResponse } from "./blogWriterApi";
import { TaskStatus } from "./storyWriterApi";
type WaitForTaskFn = (taskId: string) => Promise<TaskStatusResponse>;
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
@@ -36,7 +32,7 @@ const DEFAULT_KNOBS: Knobs = {
bitrate: "standard",
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createId = (prefix: string) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -125,33 +121,53 @@ const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string):
return generated.slice(0, 6);
};
const mapSourcesToFacts = (sources: BlogResearchResponse["sources"]): Fact[] => {
const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
if (!sources || !sources.length) return [];
return sources.slice(0, 12).map((source, idx) => ({
return sources.slice(0, 12).map((source: ExaSource, idx: number) => ({
id: source.url || createId("fact"),
quote: source.excerpt || source.title || "Insight",
url: source.url || "",
date: source.published_at || "Unknown",
confidence: typeof source.credibility_score === "number" ? source.credibility_score : 0.8 - idx * 0.02,
confidence: typeof (source as any).credibility_score === "number" ? (source as any).credibility_score : Math.max(0.5, 0.85 - idx * 0.02),
}));
};
const mapResearchResponse = (response: BlogResearchResponse): Research => {
type ExaSource = {
title?: string;
url?: string;
excerpt?: string;
published_at?: string;
highlights?: string[];
summary?: string;
source_type?: string;
index?: number;
};
type ExaResearchResult = {
sources: ExaSource[];
search_queries?: string[];
cost?: { total?: number };
search_type?: string;
provider?: string;
content?: string;
};
const mapExaResearchResponse = (response: ExaResearchResult): Research => {
const factCards = mapSourcesToFacts(response.sources);
const summary =
response.keyword_analysis?.summary ||
response.keyword_analysis?.key_insights?.join(" • ") ||
"Research completed. Review fact cards for details.";
const mappedAngles =
response.suggested_angles?.map((angle, idx) => ({
title: angle,
why: response.keyword_analysis?.angle_breakdown?.[angle]?.reason || "High priority topic from research insights.",
mappedFactIds: factCards.slice(idx, idx + 2).map((fact) => fact.id),
})) || [];
response.content?.slice(0, 1200) ||
(response.search_queries && response.search_queries.length
? `Research completed for queries: ${response.search_queries.join(", ")}`
: "Research completed. Review fact cards for details.");
return {
summary,
factCards,
mappedAngles,
mappedAngles: [],
searchQueries: response.search_queries,
searchType: response.search_type,
provider: response.provider || "exa",
cost: response.cost?.total,
sourceCount: response.sources?.length || 0,
};
};
@@ -176,71 +192,36 @@ const splitIntoLines = (text: string, speakers: number): Line[] => {
}));
};
const storySceneToPodcastScene = (scene: StoryScene, knobs: Knobs, speakers: number): Scene => {
const text = scene.description || scene.audio_narration || scene.image_prompt || scene.title || "Narration";
return {
id: `scene-${scene.scene_number || createId("scene")}`,
title: scene.title || `Scene ${scene.scene_number}`,
duration: Math.max(20, knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
lines: splitIntoLines(text, Math.max(1, speakers)),
approved: false,
};
};
// Unused helper functions - kept for reference but not currently used
// const storySceneToPodcastScene = (scene: StoryScene, knobs: Knobs, speakers: number): Scene => {
// const text = scene.description || scene.audio_narration || scene.image_prompt || scene.title || "Narration";
// return {
// id: `scene-${scene.scene_number || createId("scene")}`,
// title: scene.title || `Scene ${scene.scene_number}`,
// duration: Math.max(20, knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
// lines: splitIntoLines(text, Math.max(1, speakers)),
// approved: false,
// };
// };
const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StoryScene[] | string | undefined): StoryScene[] => {
if (!outline) return [];
if (typeof outline === "string") {
return [
{
scene_number: 1,
title: outline.slice(0, 60),
description: outline,
image_prompt: outline,
audio_narration: outline,
} as StoryScene,
];
}
if (Array.isArray(outline)) {
return outline as StoryScene[];
}
return [];
};
const waitForTaskCompletion = async (
taskId: string,
poll: WaitForTaskFn,
onProgress?: (status: { status: string; progress?: number; message?: string }) => void
): Promise<any> => {
let attempts = 0;
while (attempts < 120) {
const status = await poll(taskId);
// Report progress if callback provided
if (onProgress) {
// Extract latest progress message if available
const latestMessage = status.progress_messages && status.progress_messages.length > 0
? status.progress_messages[status.progress_messages.length - 1].message
: undefined;
onProgress({
status: status.status,
progress: undefined, // TaskStatusResponse doesn't have progress field
message: latestMessage,
});
}
if (status.status === "completed") {
return status.result;
}
if (status.status === "failed") {
const errorMsg = status.error || "Task failed";
throw new Error(errorMsg);
}
await sleep(2500);
attempts += 1;
}
throw new Error("Task polling timed out after 5 minutes");
};
// const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StoryScene[] | string | undefined): StoryScene[] => {
// if (!outline) return [];
// if (typeof outline === "string") {
// return [
// {
// scene_number: 1,
// title: outline.slice(0, 60),
// description: outline,
// image_prompt: outline,
// audio_narration: outline,
// } as StoryScene,
// ];
// }
// if (Array.isArray(outline)) {
// return outline as StoryScene[];
// }
// return [];
// };
const ensurePreflight = async (operation: PreflightOperation) => {
const result = await checkPreflight(operation);
@@ -275,6 +256,7 @@ export const podcastApi = {
suggestedOutlines: outlines,
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
};
const researchConfig = await getResearchConfig().catch(() => null);
@@ -303,62 +285,53 @@ export const podcastApi = {
topic: string;
approvedQueries: Query[];
provider?: ResearchProvider;
exaConfig?: ResearchConfig;
onProgress?: (message: string) => void;
}): Promise<{ research: Research; raw: BlogResearchResponse }> {
}): Promise<{ research: Research; raw: any }> {
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
if (!keywords.length) {
throw new Error("At least one query must be approved for research.");
}
const researchPayload: BlogResearchRequest = {
keywords,
topic: params.topic || keywords[0],
research_mode: "basic",
config: {
provider: params.provider || "google",
include_statistics: params.approvedQueries.some((q) => q.needsRecentStats),
},
};
// Ensure Exa payload respects API constraint: when requesting contents, only one of includeDomains or excludeDomains.
let sanitizedExaConfig: ResearchConfig | undefined = params.exaConfig;
if (sanitizedExaConfig && sanitizedExaConfig.exa_include_domains?.length) {
sanitizedExaConfig = {
...sanitizedExaConfig,
exa_exclude_domains: undefined,
};
} else if (sanitizedExaConfig && sanitizedExaConfig.exa_exclude_domains?.length) {
sanitizedExaConfig = {
...sanitizedExaConfig,
exa_include_domains: undefined,
};
}
await ensurePreflight({
provider: params.provider === "exa" ? "exa" : "gemini",
operation_type: params.provider === "exa" ? "exa_neural_search" : "google_grounding",
tokens_requested: params.provider === "exa" ? 0 : 1200,
actual_provider_name: params.provider || "google",
provider: "exa",
operation_type: "exa_neural_search",
tokens_requested: 0,
actual_provider_name: "exa",
});
const { task_id } = await blogWriterApi.startResearch(researchPayload);
let lastProgressMessage = "";
const result = (await waitForTaskCompletion(
task_id,
blogWriterApi.pollResearchStatus,
(status) => {
// Extract latest progress message and notify caller
if (status.message && status.message !== lastProgressMessage) {
lastProgressMessage = status.message;
if (params.onProgress) {
params.onProgress(status.message);
}
} else if (status.status === "running" && !status.message) {
// Provide default status messages if none available
const defaultMessage = params.provider === "exa"
? "Deep research in progress..."
: "Gathering research sources...";
if (params.onProgress && lastProgressMessage !== defaultMessage) {
lastProgressMessage = defaultMessage;
params.onProgress(defaultMessage);
}
}
}
)) as BlogResearchResponse;
const mapped = mapResearchResponse(result);
return { research: mapped, raw: result };
const response = await aiApiClient.post("/api/podcast/research/exa", {
topic: params.topic || keywords[0],
queries: keywords,
exa_config: sanitizedExaConfig,
});
const exaResult = response.data as ExaResearchResult;
if (params.onProgress) {
params.onProgress("Deep research completed with Exa.");
}
const mapped = mapExaResearchResponse(exaResult);
return { research: mapped, raw: exaResult };
},
async generateScript(params: {
projectId: string;
idea: string;
research?: BlogResearchResponse | null;
research?: ExaResearchResult | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
@@ -433,22 +406,96 @@ export const podcastApi = {
};
},
async renderSceneAudio(params: { scene: Scene; voiceId?: string; emotion?: string; speed?: number }): Promise<RenderJobResult> {
const text = params.scene.lines.map((line) => line.text).join(" ");
async renderSceneAudio(params: {
scene: Scene;
voiceId?: string;
emotion?: string; // Fallback if scene doesn't have emotion
speed?: number;
}): Promise<RenderJobResult> {
// Use scene-specific emotion if available, otherwise fallback to provided/default
const sceneEmotion = params.scene.emotion || params.emotion || "neutral";
// Optimize text for Minimax Speech-02-HD TTS
// - Strip markdown formatting (bold, italic, etc.) - TTS reads it literally
// - Use pause markers <#x#> for natural speech rhythm
// - Add longer pauses for speaker changes
// - Preserve punctuation for natural breathing
// - Add emphasis pauses for important points
const text = params.scene.lines
.map((line, idx) => {
let lineText = line.text.trim();
// Strip markdown formatting - TTS reads asterisks and other markdown literally
// Remove bold (**text** or __text__)
lineText = lineText.replace(/\*\*([^*]+)\*\*/g, '$1'); // **bold**
lineText = lineText.replace(/\*([^*]+)\*/g, '$1'); // *bold* (single asterisk)
lineText = lineText.replace(/__([^_]+)__/g, '$1'); // __bold__
lineText = lineText.replace(/_([^_]+)_/g, '$1'); // _italic_ (single underscore)
// Remove any remaining stray asterisks or underscores
lineText = lineText.replace(/\*+/g, ''); // Remove any remaining asterisks
lineText = lineText.replace(/_+/g, ''); // Remove any remaining underscores
// Clean up extra spaces
lineText = lineText.replace(/\s+/g, ' ').trim();
// Preserve punctuation (Minimax uses it for natural breathing)
// Don't strip punctuation - it helps TTS understand natural pauses
// Add emphasis pause after lines marked with emphasis
if (line.emphasis) {
// Minimal pause after emphasized content (0.15s for subtle emphasis)
lineText = `${lineText}<#0.15#>`;
}
// Check for speaker change (longer pause for natural conversation flow)
const prevLine = idx > 0 ? params.scene.lines[idx - 1] : null;
const isSpeakerChange = prevLine && prevLine.speaker !== line.speaker;
if (isSpeakerChange) {
// Short pause for speaker changes (0.2s - enough for natural transition)
lineText = `<#0.2#>${lineText}`;
}
// Add minimal pause between lines (only between regular lines, very short)
if (idx < params.scene.lines.length - 1) {
if (!line.emphasis && !isSpeakerChange) {
// Very short pause between lines (0.08s - barely noticeable but helps flow)
lineText = `${lineText}<#0.08#>`;
}
// If emphasis or speaker change, the pause is already added above
}
return lineText;
})
.join(" ");
// Validate character limit (Minimax max: 10,000 characters)
const MAX_CHARS = 10000;
let textToUse = text;
if (text.length > MAX_CHARS) {
console.warn(
`[Podcast] Scene "${params.scene.title}" exceeds ${MAX_CHARS} character limit (${text.length} chars). Truncating...`
);
// Truncate at word boundary to avoid cutting mid-word
const truncated = text.substring(0, MAX_CHARS);
const lastSpace = truncated.lastIndexOf(" ");
textToUse = lastSpace > 0 ? truncated.substring(0, lastSpace) : truncated;
}
await ensurePreflight({
provider: "audio",
operation_type: "tts_full_render",
tokens_requested: text.length,
tokens_requested: textToUse.length,
actual_provider_name: "wavespeed",
});
const response = await aiApiClient.post("/api/podcast/audio", {
scene_id: params.scene.id,
scene_title: params.scene.title,
text,
text: textToUse,
voice_id: params.voiceId || "Wise_Woman",
speed: params.speed || 1.0,
emotion: params.emotion || "neutral",
speed: params.speed || 1.0, // Normal speed (was 0.9, but too slow - causing duration issues)
emotion: sceneEmotion,
english_normalization: true, // Better number reading for statistics
});
return {
@@ -578,6 +625,35 @@ export const podcastApi = {
return response.data;
},
async generateSceneImage(params: {
sceneId: string;
sceneTitle: string;
sceneContent?: string;
idea?: string;
width?: number;
height?: number;
}): Promise<{
scene_id: string;
scene_title: string;
image_filename: string;
image_url: string;
width: number;
height: number;
provider: string;
model?: string;
cost: number;
}> {
const response = await aiApiClient.post("/api/podcast/image", {
scene_id: params.sceneId,
scene_title: params.sceneTitle,
scene_content: params.sceneContent,
idea: params.idea,
width: params.width || 1024,
height: params.height || 1024,
});
return response.data;
},
async cancelTask(taskId: string): Promise<void> {
// Note: Task cancellation may not be fully supported by backend yet
// This is a placeholder for future implementation
@@ -587,6 +663,25 @@ export const podcastApi = {
console.warn("Task cancellation not supported:", error);
}
},
async combineAudio(params: {
projectId: string;
sceneIds: string[];
sceneAudioUrls: string[];
}): Promise<{
combined_audio_url: string;
combined_audio_filename: string;
total_duration: number;
file_size: number;
scene_count: number;
}> {
const response = await aiApiClient.post("/api/podcast/combine-audio", {
project_id: params.projectId,
scene_ids: params.sceneIds,
scene_audio_urls: params.sceneAudioUrls,
});
return response.data;
},
};
export type PodcastApi = typeof podcastApi;

View File

@@ -1,5 +1,6 @@
/* Global Styles for Alwrity Onboarding */
/* Optimized font loading with font-display: swap for better performance */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
/* Smooth scrolling */

View File

@@ -0,0 +1,77 @@
/**
* Lazy-loaded Recharts wrapper
*
* Recharts is a large library (~200+ KiB). This wrapper lazy-loads it
* only when charts are actually needed, reducing initial bundle size.
*
* Usage:
* import { LazyLineChart, Line, XAxis, YAxis } from '../../utils/lazyRecharts';
* import { Suspense } from 'react';
*
* <Suspense fallback={<ChartSkeleton />}>
* <LazyLineChart data={data}>
* <Line />
* </LazyLineChart>
* </Suspense>
*/
import React, { Suspense, lazy } from 'react';
import { Box, CircularProgress } from '@mui/material';
// Loading fallback for charts
export const ChartLoadingFallback: React.FC = () => (
<Box
display="flex"
alignItems="center"
justifyContent="center"
minHeight="200px"
sx={{ bgcolor: 'rgba(0,0,0,0.02)', borderRadius: 1 }}
>
<CircularProgress size={40} />
</Box>
);
// Lazy load recharts components - these are the heavy ones
export const LazyLineChart = lazy(() =>
import('recharts').then(module => ({ default: module.LineChart }))
);
export const LazyBarChart = lazy(() =>
import('recharts').then(module => ({ default: module.BarChart }))
);
export const LazyPieChart = lazy(() =>
import('recharts').then(module => ({ default: module.PieChart }))
);
export const LazyAreaChart = lazy(() =>
import('recharts').then(module => ({ default: module.AreaChart }))
);
export const LazyRadarChart = lazy(() =>
import('recharts').then(module => ({ default: module.RadarChart }))
);
export const LazyComposedChart = lazy(() =>
import('recharts').then(module => ({ default: module.ComposedChart }))
);
// These are lightweight, can be imported directly
export {
Line,
Bar,
Pie,
Area,
Radar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis
} from 'recharts';

View File

@@ -0,0 +1,10 @@
/**
* Lazy-loaded Wix SDK wrapper
*
* Wix SDK is only used in a few pages (WixTestPage, WixCallbackPage).
* This wrapper lazy-loads it only when needed.
*/
export const lazyWixSDK = () => import('@wix/sdk');
export const lazyWixBlog = () => import('@wix/blog');