feat: voice clone audio generation + podcast workspace architecture

- Voice clone integration: When user selects voice clone in Write phase,
  backend uses their uploaded voice sample + scene script text to generate
  audio via qwen3/minimax/cosyvoice voice clone APIs
- Multi-tenant workspace storage: All podcast assets (audio, video, images,
  charts) now use workspace-specific directories per user
- Chart preview improvements: Card-based B-Roll charts UI with thumbnails,
  takeaway text, and action buttons; public endpoint for image serving
- Voice clone caching: In-memory LRU cache for voice samples (avoids
  re-downloading per scene); frontend caches voice clone metadata
- Thread pool for voice clone: Audio generation uses ThreadPoolExecutor to
  avoid blocking the FastAPI event loop
- Auto-detect voice clone IDs (vc_*, MY_VOICE_CLONE) to route correctly
- DB fallback for voice sample URL: Fetches from ContentAsset if not passed
- Fixed API URL resolution for chart previews
- Fixed GlassyCard DOM warnings for motion props
- Fixed ScriptGenerationProgressView syntax error
- Fixed usePodcastWorkflow scriptData reference
This commit is contained in:
ajaysi
2026-04-21 19:38:50 +05:30
parent 7637babd7d
commit 91b2f996fd
33 changed files with 1642 additions and 457 deletions

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from "react";
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, CircularProgress } from "@mui/material";
import React, { useCallback } from "react";
import { Stack, Typography, Chip, Divider, Box, alpha, Paper, CircularProgress, Tooltip } from "@mui/material";
import {
Insights as InsightsIcon,
Search as SearchIcon,
@@ -7,8 +7,9 @@ import {
Article as ArticleIcon,
AutoAwesome as AutoAwesomeIcon,
ArrowForward as ArrowForwardIcon,
HelpOutline as HelpOutlineIcon,
} from "@mui/icons-material";
import { Research, ResearchInsight } from "../types";
import { Research, ResearchInsight, Fact } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { FactCard } from "../FactCard";
import { TextToSpeechButton } from "../../shared/TextToSpeechButton";
@@ -26,6 +27,27 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
onGenerateScript,
isGeneratingScript = false,
}) => {
const getSourceFact = (idx: number): Fact | undefined => {
const factCards = research.factCards || [];
return factCards.find(f => f.id === `source-${idx}`);
};
// Strip markdown for text-to-speech
const stripMarkdown = (text: string): string => {
if (!text) return '';
return text
.replace(/#{1,6}\s+/g, '') // Headers
.replace(/\*\*(.*?)\*\*/g, '$1') // Bold
.replace(/\*(.*?)\*/g, '$1') // Italic
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // Links
.replace(/`{1,3}(.*?)`{1,3}/g, '$1') // Code
.replace(/^\s*[-*+]\s+/gm, '') // List items
.replace(/^\s*\d+\.\s+/gm, '') // Numbered list
.replace(/\n{2,}/g, '. ') // Multiple newlines to periods
.replace(/\n/g, ' ') // Single newlines to spaces
.trim();
};
// Simple markdown-to-HTML converter
const renderMarkdown = useCallback((text: string) => {
if (!text) return null;
@@ -150,7 +172,7 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
<AutoAwesomeIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1rem" }} />
Executive Summary
<Box sx={{ ml: 'auto' }}>
<TextToSpeechButton text={research.summary} size="small" showSettings />
<TextToSpeechButton text={stripMarkdown(research.summary)} size="small" showSettings />
</Box>
</Typography>
<Box sx={{
@@ -187,28 +209,75 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
borderRadius: 2,
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5 }}>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700 }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5, width: '100%' }}>
<Typography variant="subtitle1" sx={{ color: "#0f172a", fontWeight: 700, flex: 1 }}>
{insight.title}
</Typography>
<TextToSpeechButton text={stripMarkdown(insight.content)} size="small" />
{insight.source_indices && insight.source_indices.length > 0 && (
<Stack direction="row" spacing={0.5}>
{insight.source_indices.map(sIdx => (
<Chip
key={sIdx}
label={`S${sIdx}`}
size="small"
variant="outlined"
sx={{
height: 18,
fontSize: '0.65rem',
fontWeight: 700,
borderColor: alpha("#667eea", 0.3),
color: "#667eea",
bgcolor: alpha("#667eea", 0.05)
}}
/>
))}
{insight.source_indices.map(sIdx => {
const source = research.sources?.[sIdx - 1];
const fact = getSourceFact(sIdx);
return (
<Tooltip
key={sIdx}
title={
<Box sx={{ p: 0.5 }}>
{fact ? (
<Box>
<Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#A5B4FC', mb: 0.5 }}>
Source S{sIdx}
</Typography>
<Typography variant="body2" sx={{ color: '#fff', fontStyle: 'italic', mb: 1, lineHeight: 1.4 }}>
"{fact.quote}"
</Typography>
{fact.author && (
<Typography variant="caption" sx={{ color: '#A5B4FC' }}>
{fact.author}
</Typography>
)}
</Box>
) : source ? (
<Box>
<Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#A5B4FC', mb: 0.5 }}>
Source S{sIdx}
</Typography>
<Typography variant="body2" fontWeight={600} sx={{ color: '#fff' }}>
{source.title}
</Typography>
</Box>
) : (
<Typography variant="body2" sx={{ color: '#A5B4FC' }}>No source details</Typography>
)}
</Box>
}
placement="right"
arrow
followCursor
>
<Chip
label={`S${sIdx}`}
size="small"
variant="outlined"
component="a"
href={source?.url || undefined}
target={source?.url ? "_blank" : undefined}
rel={source?.url ? "noopener noreferrer" : undefined}
sx={{
height: 18,
fontSize: '0.65rem',
fontWeight: 700,
borderColor: alpha("#667eea", 0.3),
color: "#667eea",
bgcolor: alpha("#667eea", 0.05),
cursor: source?.url ? 'pointer' : 'default',
textDecoration: 'none',
}}
/>
</Tooltip>
);
})}
</Stack>
)}
</Stack>
@@ -259,17 +328,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Expert Quotes */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
px: 1.5, py: 0.5,
borderRadius: 2,
background: 'linear-gradient(135deg, #8B5CF6 0%, #EC4899 100%)',
color: '#fff',
fontSize: '0.75rem',
fontWeight: 700
}}>
NEW
</Box>
Expert Quotes
<Tooltip title="Expert quotes extracted from research sources - factual statements from industry experts, studies, or authoritative sources that add credibility to your podcast content." placement="right">
<HelpOutlineIcon sx={{ fontSize: 18, color: "#94A3B8", cursor: 'pointer' }} />
</Tooltip>
</Typography>
{research.expertQuotes && research.expertQuotes.length > 0 ? (
<Stack spacing={1.5}>
@@ -286,38 +348,69 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
"{quote.quote}"
</Typography>
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}>
{sourceUrl ? (
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
{(() => {
const source = research.sources?.[quote.source_index - 1];
const fact = getSourceFact(quote.source_index);
if (fact) {
return (
<Box>
<Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#C4B5FD', mb: 0.5 }}>
Source S{quote.source_index}
</Typography>
<Typography variant="body2" sx={{ color: '#fff', fontStyle: 'italic', mb: 1, lineHeight: 1.4 }}>
"{fact.quote}"
</Typography>
{fact.author && (
<Typography variant="caption" sx={{ color: '#A78BFA' }}>
{fact.author}
</Typography>
)}
</Box>
);
}
if (source) {
return (
<Box>
<Typography variant="caption" display="block" fontWeight={700} sx={{ color: '#C4B5FD', mb: 0.5 }}>
Source S{quote.source_index}
</Typography>
<Typography variant="body2" fontWeight={600} sx={{ color: '#fff', mb: 0.5 }}>
{source.title}
</Typography>
</Box>
);
}
return <Typography variant="body2" sx={{ color: '#A78BFA' }}>No source details</Typography>;
})()}
</Box>
}
placement="right"
arrow
followCursor
>
<Chip
label={`Source S${quote.source_index}`}
size="small"
clickable
component="a"
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
href={sourceUrl || undefined}
target={sourceUrl ? "_blank" : undefined}
rel={sourceUrl ? "noopener noreferrer" : undefined}
sx={{
background: 'linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)',
color: '#fff',
fontWeight: 600,
fontSize: '0.7rem',
cursor: 'pointer',
cursor: sourceUrl ? 'pointer' : 'default',
textDecoration: 'none',
'&:hover': {
background: 'linear-gradient(135deg, #7C3AED 0%, #6D28D9 100%)',
},
}}
/>
) : (
<Chip
label={`Source S${quote.source_index}`}
size="small"
sx={{
background: 'linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)',
color: '#fff',
fontWeight: 600,
fontSize: '0.7rem',
}}
/>
)}
</Tooltip>
</Box>
</Paper>
);
@@ -333,17 +426,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Listener CTAs */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
px: 1.5, py: 0.5,
borderRadius: 2,
background: 'linear-gradient(135deg, #10B981 0%, #14B8A6 100%)',
color: '#fff',
fontSize: '0.75rem',
fontWeight: 700
}}>
NEW
</Box>
Listener CTAs
<Tooltip title="Call-to-action suggestions for your listeners - what action should they take after listening to your podcast (e.g., visit a website, subscribe, download resources)." placement="right">
<HelpOutlineIcon sx={{ fontSize: 18, color: "#94A3B8", cursor: 'pointer' }} />
</Tooltip>
</Typography>
{research.listenerCta && research.listenerCta.length > 0 ? (
<Stack spacing={1.5}>
@@ -370,17 +456,10 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
{/* Mapped Angles */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
px: 1.5, py: 0.5,
borderRadius: 2,
background: 'linear-gradient(135deg, #0EA5E9 0%, #06B6D4 100%)',
color: '#fff',
fontSize: '0.75rem',
fontWeight: 700
}}>
NEW
</Box>
Mapped Angles
<Tooltip title="Content angles derived from research - specific topics or viewpoints mapped to your target audience's interests and pain points to create engaging episodes." placement="right">
<HelpOutlineIcon sx={{ fontSize: 18, color: "#94A3B8", cursor: 'pointer' }} />
</Tooltip>
</Typography>
{research.mappedAngles && research.mappedAngles.length > 0 ? (
<Stack spacing={1.5}>
@@ -405,54 +484,7 @@ export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
No mapped angles available yet.
</Typography>
)}
</Box>
{/* Listener CTAs */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700 }}>
Listener CTAs
</Typography>
{research.listenerCta && research.listenerCta.length > 0 ? (
<Stack spacing={1}>
{research.listenerCta.slice(0, 4).map((cta, idx) => (
<Paper key={`cta-${idx}`} elevation={0} sx={{ p: 1.5, border: "1px solid rgba(0,0,0,0.06)", borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: "#334155", lineHeight: 1.55 }}>
{cta}
</Typography>
</Paper>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: "#64748b" }}>
No listener CTAs suggested yet.
</Typography>
)}
</Box>
{/* Mapped Angles */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5, color: "#0f172a", fontWeight: 700 }}>
Mapped Angles
</Typography>
{research.mappedAngles && research.mappedAngles.length > 0 ? (
<Stack spacing={1}>
{research.mappedAngles.slice(0, 4).map((angle, idx) => (
<Paper key={`angle-${idx}`} elevation={0} sx={{ p: 1.5, border: "1px solid rgba(0,0,0,0.06)", borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 700, mb: 0.5 }}>
{angle.title || `Angle ${idx + 1}`}
</Typography>
<Typography variant="body2" sx={{ color: "#334155", lineHeight: 1.55 }}>
{angle.why || "No rationale provided."}
</Typography>
</Paper>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: "#64748b" }}>
No mapped angles available yet.
</Typography>
)}
</Box>
</Box>
{/* Search Queries Used */}
{research.searchQueries && research.searchQueries.length > 0 && (

View File

@@ -0,0 +1,369 @@
import React from "react";
import {
Stack,
Typography,
CircularProgress,
LinearProgress,
Box,
Divider,
useMediaQuery,
useTheme,
} from "@mui/material";
import {
AutoAwesome as AutoAwesomeIcon,
CheckCircle as CheckCircleIcon,
Psychology as PsychologyIcon,
Insights as InsightsIcon,
Article as ArticleIcon,
Edit as EditIcon,
VolumeUp as VolumeUpIcon,
VideoLibrary as VideoLibraryIcon,
Lightbulb as LightbulbIcon,
Search as SearchIcon,
FactCheck as FactCheckIcon,
School as SchoolIcon,
Update as UpdateIcon,
Bolt as BoltIcon,
TheaterComedy as TheaterComedyIcon,
RecordVoiceOver as RecordVoiceOverIcon,
FormatListBulleted as FormatListBulletedIcon,
Chat as ChatIcon,
} from "@mui/icons-material";
const SCRIPT_GENERATION_MESSAGES = [
{ title: "Analyzing Research Data", message: "Extracting key insights, facts, and statistics from your research..." },
{ title: "Building Structure", message: "Creating podcast structure with scenes and segments..." },
{ title: "Writing Dialogue", message: "Writing AI-powered dialogue personalized to your audience..." },
{ title: "Finalizing Script", message: "Finalizing scenes with proper pacing for text-to-speech..." },
];
const SCRIPT_BENEFITS = [
{
title: "Research-Grounded Content",
description: "Your script cites real facts and sources from the research phase",
icon: <BoltIcon />,
color: "#10b981",
},
{
title: "Audience-Targeted",
description: "Dialogue written for your specific target audience",
icon: <PsychologyIcon />,
color: "#a78bfa",
},
{
title: "Optimized for TTS",
description: "Proper pacing and hints for natural text-to-speech output",
icon: <VolumeUpIcon />,
color: "#60a5fa",
},
];
const WHAT_IS_SCENE = {
title: "What is a Scene?",
definition: "A scene is a single section of your podcast episode. It contains dialogue from presenters and optional chart data for visuals.",
icon: <TheaterComedyIcon />,
color: "#34d399",
};
const PODCAST_CREATION_JOURNEY = [
{
phase: "Analyze",
icon: <AutoAwesomeIcon />,
color: "#a78bfa",
description: "AI understands your topic and target audience",
benefit: "Identifies key themes and angles"
},
{
phase: "Research",
icon: <SearchIcon />,
color: "#60a5fa",
description: "Gathers facts, statistics, and latest insights",
benefit: "Evidence-based content"
},
{
phase: "Write Script",
icon: <EditIcon />,
color: "#34d399",
description: "Transforms research into structured script",
benefit: "Factual, engaging content",
isCurrent: true,
},
{
phase: "Final Render",
icon: <VideoLibraryIcon />,
color: "#ef4444",
description: "Your ready-to-publish podcast episode",
benefit: "Professional output"
},
];
const SCRIPT_EDITOR_PREVIEW = [
{ label: "Edit Dialogue", description: "Click any line to modify the text", icon: <EditIcon /> },
{ label: "Approve Scenes", description: "Mark scenes as ready for rendering", icon: <CheckCircleIcon /> },
{ label: "Regenerate", description: "Regenerate specific scenes if needed", icon: <AutoAwesomeIcon /> },
{ label: "Add Charts", description: "Charts auto-generated from research facts", icon: <FormatListBulletedIcon /> },
];
interface ScriptGenerationProgressViewProps {
currentMessage?: string;
progressIndex: number;
idea?: string;
analysis?: any;
research?: any;
sourceCount?: number;
}
export const ScriptGenerationProgressView: React.FC<ScriptGenerationProgressViewProps> = ({
currentMessage,
progressIndex,
idea,
analysis,
research,
sourceCount,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const clampedIndex = Math.min(progressIndex, SCRIPT_GENERATION_MESSAGES.length - 1);
const audience = analysis?.audience || "General audience";
const keywords = analysis?.topKeywords?.slice(0, 5) || [];
const outlineTitle = analysis?.suggestedOutlines?.[0]?.title || "Not specified";
const factCards = research?.factCards || [];
const keyInsights = research?.keyInsights || [];
const searchQueries = research?.searchQueries || [];
return (
<Stack spacing={2}>
{/* Current Status */}
<Box sx={{ textAlign: "center" }}>
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#34d399" }} />
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
<EditIcon sx={{ color: "#34d399", fontSize: isMobile ? 20 : 24 }} />
</Box>
</Box>
<Typography variant="subtitle1" sx={{ color: "#34d399", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
{SCRIPT_GENERATION_MESSAGES[clampedIndex].title}
</Typography>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
{currentMessage || SCRIPT_GENERATION_MESSAGES[clampedIndex].message}
</Typography>
{currentMessage && (
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
{currentMessage}
</Typography>
)}
<LinearProgress
sx={{
width: "100%",
height: 4,
borderRadius: 2,
bgcolor: "rgba(255,255,255,0.1)",
mt: 2,
"& .MuiLinearProgress-bar": { bgcolor: "#34d399", borderRadius: 2 },
}}
/>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
Step {clampedIndex + 1} of {SCRIPT_GENERATION_MESSAGES.length}
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* How Prior Phases Are Used */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
How We're Personalizing Your Script
</Typography>
<Stack spacing={1}>
{/* Analysis Context */}
<Box sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "rgba(167, 139, 250, 0.1)", border: "1px solid rgba(167, 139, 250, 0.2)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ width: 24, height: 24, borderRadius: "50%", bgcolor: "rgba(167, 139, 250, 0.2)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: 14 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: "#a78bfa", fontWeight: 600, fontSize: "0.75rem", display: "block" }}>
From Analyze Phase
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.7rem", display: "block" }}>
<strong>Audience:</strong> {audience}
</Typography>
{keywords.length > 0 && (
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.7rem", display: "block" }}>
<strong>Keywords:</strong> {keywords.join(", ")}
</Typography>
)}
</Box>
</Stack>
</Box>
{/* Research Context */}
<Box sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "rgba(96, 165, 250, 0.1)", border: "1px solid rgba(96, 165, 250, 0.2)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ width: 24, height: 24, borderRadius: "50%", bgcolor: "rgba(96, 165, 250, 0.2)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<SearchIcon sx={{ color: "#60a5fa", fontSize: 14 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: "#60a5fa", fontWeight: 600, fontSize: "0.75rem", display: "block" }}>
From Research Phase
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.7rem", display: "block" }}>
<strong>{factCards.length} facts</strong>, <strong>{keyInsights.length} insights</strong>, <strong>{sourceCount || 0} sources</strong>
</Typography>
{searchQueries.length > 0 && (
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.7)", fontSize: "0.7rem", display: "block" }}>
From {searchQueries.length} research queries
</Typography>
)}
</Box>
</Stack>
</Box>
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* What is a Scene */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
{WHAT_IS_SCENE.title}
</Typography>
<Box sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "rgba(52, 211, 153, 0.1)", border: "1px solid rgba(52, 211, 153, 0.2)" }}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ width: 28, height: 28, borderRadius: "50%", bgcolor: "rgba(52, 211, 153, 0.2)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
{React.cloneElement(WHAT_IS_SCENE.icon, { sx: { color: WHAT_IS_SCENE.color, fontSize: 16 } })}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.8)", fontSize: "0.8rem", display: "block" }}>
{WHAT_IS_SCENE.definition}
</Typography>
</Box>
</Stack>
</Box>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Sequential Progress Steps */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Script Generation Progress
</Typography>
<Stack spacing={0.5}>
{SCRIPT_GENERATION_MESSAGES.map((msg, idx) => {
const isCompleted = idx < clampedIndex;
const isCurrent = idx === clampedIndex;
return (
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 18,
height: 18,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#34d399" : "rgba(255,255,255,0.1)",
flexShrink: 0,
}}>
{isCompleted ? (
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
) : isCurrent ? (
<CircularProgress size={10} sx={{ color: "#fff" }} />
) : (
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#34d399" : "rgba(255,255,255,0.6)",
fontWeight: isCurrent ? 600 : 400,
fontSize: "0.75rem",
textDecoration: isCompleted ? "line-through" : "none",
}}>
{msg.title}
</Typography>
</Box>
</Stack>
);
})}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* What to Expect in Script Editor */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
What's Next: Script Editor
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{SCRIPT_EDITOR_PREVIEW.map((item, idx) => (
<Box key={idx} sx={{ flex: "1 1 45%", minWidth: 100, p: 1.5, borderRadius: 1.5, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", textAlign: "center" }}>
<Stack spacing={0.5}>
<Box sx={{ color: "#a78bfa" }}>{React.cloneElement(item.icon, { sx: { fontSize: 18 } })}</Box>
<Typography variant="caption" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.7rem", display: "block" }}>
{item.label}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem", display: "block" }}>
{item.description}
</Typography>
</Stack>
</Box>
))}
</Stack>
</Box>
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
{/* Journey Overview */}
<Box sx={{ width: "100%" }}>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
Your Podcast Journey
</Typography>
<Stack spacing={1}>
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
<Box key={idx} sx={{
p: 1.5,
borderRadius: 2,
bgcolor: phase.isCurrent ? "rgba(52, 211, 153, 0.1)" : "rgba(255,255,255,0.05)",
border: `1px solid ${phase.isCurrent ? "rgba(52, 211, 153, 0.3)" : "rgba(255,255,255, 0.1)"}`
}}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{
width: 28,
height: 28,
borderRadius: "50%",
bgcolor: phase.isCurrent ? "rgba(52, 211, 153, 0.2)" : `${phase.color}20`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
{React.cloneElement(phase.icon, { sx: { color: phase.isCurrent ? "#34d399" : phase.color, fontSize: 16 } })}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: phase.isCurrent ? "#34d399" : "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
{phase.phase} {phase.isCurrent && "◀ In Progress"}
</Typography>
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
{phase.description}
</Typography>
<Typography variant="caption" sx={{ color: phase.isCurrent ? "#34d399" : phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
{phase.benefit}
</Typography>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
</Stack>
);
};

View File

@@ -61,6 +61,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [duplicateProjectInfo, setDuplicateProjectInfo] = useState<{projectId: string; idea: string}>({ projectId: "", idea: "" });
// Script Generation Modal State
const [showScriptGenModal, setShowScriptGenModal] = useState(false);
const [scriptGenStarted, setScriptGenStarted] = useState(false);
const [scriptGenProgressIndex, setScriptGenProgressIndex] = useState(0);
const budgetTracking = useBudgetTracking(budgetCap || 50);
const preflightCheck = usePreflightCheck({
@@ -94,6 +99,47 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
return undefined;
}, [announcement]);
const prevIsGeneratingScriptRef = useRef(false);
// Sequential progress for script generation modal
useEffect(() => {
if (!showScriptGenModal || !scriptGenStarted) {
setScriptGenProgressIndex(0);
return;
}
const interval = setInterval(() => {
setScriptGenProgressIndex((prev) => {
if (prev < 3) { // 4 steps total (0-3)
return prev + 1;
}
return prev;
});
}, 3000);
return () => clearInterval(interval);
}, [showScriptGenModal, scriptGenStarted]);
// Handle modal close when script generation completes
useEffect(() => {
const wasSubmitting = prevIsGeneratingScriptRef.current;
const nowNotSubmitting = !isGeneratingScript;
// Only close modal if:
// 1. Modal is still shown
// 2. scriptGenStarted is true
// 3. isGeneratingScript transitioned from true to false
// 4. AND we're not showing an error (scriptData is set on success)
if (showScriptGenModal && scriptGenStarted && wasSubmitting && nowNotSubmitting && !announcement.includes("failed")) {
setTimeout(() => {
setShowScriptGenModal(false);
}, 500);
}
// Update ref for next render
prevIsGeneratingScriptRef.current = isGeneratingScript;
}, [isGeneratingScript, showScriptGenModal, scriptGenStarted, announcement]);
const handleCreate = useCallback(async (payload: CreateProjectPayload, feedback?: string) => {
if (isAnalyzing) return;
setResearch(null);
@@ -327,20 +373,12 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
return;
}
setPreflightOperationName("Research");
// Note: Preflight is handled inside podcastApi.runExaResearch (ensurePreflight)
// No need to call it twice here
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
console.log('[Research] User selected queries:', Array.from(selectedQueries));
console.log('[Research] Filtered approvedQueries for API:', approvedQueries.map(q => q.query));
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);
@@ -395,45 +433,44 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally {
setIsResearching(false);
}
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setEstimate, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
}, [isResearching, project, selectedQueries, queries, researchProvider, analysis, setResearch, setRawResearch, setEstimate, setScriptData, setShowScriptEditor, setShowRenderQueue, projectState.bible]);
// Add a ref to track if we're currently generating to prevent double calls
const isGeneratingRef = useRef(false);
const handleGenerateScript = useCallback(async () => {
// Guard against double calls
if (isGeneratingRef.current) {
// CRITICAL: Guard against double calls - set IMMEDIATELY to prevent concurrent clicks
if (isGeneratingRef.current || isGeneratingScript) {
console.log('[ScriptGen] Already generating, skipping duplicate call');
return;
}
if (showScriptEditor) return;
// Prevent if script already exists or render phase started
if (showScriptEditor || projectState.scriptData) {
console.log('[ScriptGen] Script already exists, skipping');
return;
}
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
// Mark as generating immediately (both ref and state)
// Mark as generating immediately BEFORE any async calls (both ref and state)
isGeneratingRef.current = true;
setIsGeneratingScript(true);
// Show modal IMMEDIATELY to prevent duplicate clicks
setShowScriptGenModal(true);
setScriptGenStarted(true);
setScriptGenProgressIndex(0);
console.log('[ScriptGen] Modal shown, generating ref set');
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) {
isGeneratingRef.current = false; // Reset on preflight failure
setIsGeneratingScript(false); // Reset loading state on preflight failure
return;
}
// Note: Preflight is also called inside podcastApi.generateScript (ensurePreflight)
// No need to call it twice - the API layer handles it
setScriptData(null);
setShowRenderQueue(false);
setShowScriptEditor(true);
setAnnouncement("Generating script with AI... Creating scenes and dialogue based on your research...");
try {
@@ -464,6 +501,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
console.log('[ScriptGen] Script generated:', { sceneCount: result.scenes?.length });
setScriptData(result);
setShowScriptEditor(true); // Open editor after successful generation
setIsGeneratingScript(false);
setAnnouncement("Script generated! Review and edit your scenes below.");
} catch (error) {
@@ -472,7 +510,7 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
} finally {
isGeneratingRef.current = false; // Reset when done
}
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible, analysis])
}, [showScriptEditor, project, research, setScriptData, setShowRenderQueue, setShowScriptEditor, rawResearch, projectState.knobs, projectState.bible, analysis, setShowScriptGenModal, scriptGenStarted, setScriptGenProgressIndex, isGeneratingScript, projectState.scriptData, currentStep])
const handleProceedToRendering = useCallback((script: Script) => {
// Clear media cache for all scenes before proceeding to remove old blobs
@@ -608,6 +646,11 @@ export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflow
duplicateProjectInfo,
activeStep,
canGenerateScript,
// Script Generation Modal
showScriptGenModal,
setShowScriptGenModal,
scriptGenStarted,
scriptGenProgressIndex,
// Handlers
handleCreate,
handleRegenerate,