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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user