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

@@ -17,6 +17,7 @@ export interface VoiceCloneResponse {
voice_name?: string;
preview_audio_url?: string;
asset_id?: number;
engine?: string;
message?: string;
error?: string;
}

View File

@@ -461,9 +461,11 @@ aiApiClient.interceptors.response.use(
}
if (error.response.status >= 500) {
openBackendCooldown(`http_${error.response.status}`);
// Do NOT trigger cooldown for application-level 500 errors (e.g. TTS failures).
// Cooldown should only block for network connectivity issues (handled above).
// Application 500s should be handled by individual callers.
return Promise.reject(
new ConnectionError('Backend server is experiencing issues. Please try again later.')
new ConnectionError(`Server error ${error.response.status}: ${error.response.statusText || 'Internal Server Error'}`)
);
}

View File

@@ -5,7 +5,9 @@ import { useSubscription } from "../../contexts/SubscriptionContext";
import { podcastApi } from "../../services/podcastApi";
import { fetchMediaBlobUrl, clearMediaCache } from "../../utils/fetchMediaBlobUrl";
import { getLatestBrandAvatar } from "../../api/brandAssets";
import { VoiceSelector } from "../shared/VoiceSelector";
import { VoiceSelector, VOICE_CLONE_ID } from "../shared/VoiceSelector";
import { getLatestVoiceClone } from "../../api/brandAssets";
import { setCachedVoiceCloneInfo } from "../../services/podcastApi";
// Imported Components
import { TopicUrlInput, TOPIC_PLACEHOLDERS } from "./CreateStep/TopicUrlInput";
@@ -316,9 +318,43 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
}
// Include selected voice in knobs
const finalKnobs = {
// If voice clone is selected, include voice clone metadata
const isVoiceClone = selectedVoiceId === VOICE_CLONE_ID || knobs.custom_voice_id === selectedVoiceId;
let voiceSampleUrl: string | undefined;
let voiceCloneEngine: string | undefined;
let customVoiceId: string | undefined;
if (isVoiceClone) {
try {
const voiceCloneInfo = await getLatestVoiceClone();
if (voiceCloneInfo?.success && voiceCloneInfo.custom_voice_id) {
customVoiceId = voiceCloneInfo.custom_voice_id;
voiceSampleUrl = voiceCloneInfo.preview_audio_url;
voiceCloneEngine = voiceCloneInfo.engine || "qwen3";
// Cache for reuse across scenes
setCachedVoiceCloneInfo({
customVoiceId,
voiceSampleUrl,
engine: voiceCloneEngine,
isVoiceClone: true,
});
}
} catch (e) {
console.warn("[CreateModal] Could not fetch voice clone info:", e);
}
} else {
// Clear cache if system voice selected
setCachedVoiceCloneInfo({ isVoiceClone: false });
}
const finalKnobs: Knobs = {
...knobs,
voice_id: selectedVoiceId,
voice_id: isVoiceClone ? "Wise_Woman" : selectedVoiceId,
custom_voice_id: customVoiceId,
is_voice_clone: isVoiceClone,
voice_sample_url: voiceSampleUrl,
voice_clone_engine: voiceCloneEngine,
};
try {

View File

@@ -384,6 +384,11 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [analysisStarted, setAnalysisStarted] = useState(false);
const [progressIndex, setProgressIndex] = useState(0);
// Track previous isSubmitting value at component level (not inside effect)
const prevIsSubmittingRef = useRef(isSubmitting);
const [analysisCompleteRef, setAnalysisCompleteRef] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@@ -393,10 +398,6 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
}, []);
// Close modal only AFTER analysis fully completes (wait for project/analysis to be set)
// Use a ref to track previous isSubmitting to detect the transition from true to false
const prevIsSubmittingRef = useRef(isSubmitting);
const [analysisCompleteRef, setAnalysisCompleteRef] = useState(false);
useEffect(() => {
// Track if analysis transitioned from true to false (completed)
const wasSubmitting = prevIsSubmittingRef.current;
@@ -424,7 +425,7 @@ export const CreateActions: React.FC<CreateActionsProps> = ({ reset, submit, can
if (error && showAnalysisModal) {
console.warn('[CreateActions] Error detected — keeping modal open:', error);
}
}, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error, analysisCompleteRef]);
}, [isSubmitting, showAnalysisModal, analysisStarted, onAnnouncementClear, error]);
// Sequential progress - increment every few seconds
useEffect(() => {

View File

@@ -20,6 +20,7 @@ import {
DEFAULT_KNOBS,
getStepLabel,
} from "./PodcastDashboard/index";
import { ScriptGenerationProgressView } from "./PodcastDashboard/ScriptGenerationProgressView";
const PodcastDashboard: React.FC = () => {
useEffect(() => {
@@ -400,6 +401,69 @@ const PodcastDashboard: React.FC = () => {
</Button>
</DialogActions>
</Dialog>
{/* Script Generation Progress Modal */}
<Dialog
open={workflow.showScriptGenModal}
disableEscapeKeyDown={workflow.isGeneratingScript}
onClose={(event, reason) => {
// Only allow closing if NOT generating and generation hasn't started
if (!workflow.isGeneratingScript && !workflow.scriptGenStarted) {
workflow.setShowScriptGenModal(false);
}
}}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
border: "1px solid rgba(52, 211, 153, 0.3)",
borderRadius: 3,
},
}}
>
<DialogTitle sx={{ color: "#fff", display: "flex", alignItems: "center", gap: 1, fontSize: "1.25rem" }}>
{workflow.isGeneratingScript ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CircularProgress size={20} sx={{ color: "#34d399" }} />
Generating Your Script
</Box>
) : (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
Script Complete
</Box>
)}
</DialogTitle>
<DialogContent sx={{ color: "rgba(255,255,255,0.8)" }}>
<ScriptGenerationProgressView
currentMessage={workflow.announcement}
progressIndex={workflow.scriptGenProgressIndex}
idea={projectState.project?.idea}
analysis={projectState.analysis}
research={projectState.research}
sourceCount={projectState.research?.sourceCount}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
{workflow.isGeneratingScript ? (
<Button
onClick={() => workflow.setShowScriptGenModal(false)}
disabled={workflow.isGeneratingScript}
sx={{ color: "rgba(255,255,255,0.6)" }}
>
Cancel
</Button>
) : (
<Button
onClick={() => workflow.setShowScriptGenModal(false)}
variant="contained"
sx={{ bgcolor: "#34d399", "&:hover": { bgcolor: "#10b981" } }}
>
Continue to Editor
</Button>
)}
</DialogActions>
</Dialog>
</Box>
);
};

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,

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Script, Knobs, Job, RenderJobResult, TaskStatus, VideoGenerationSettings } from "../types";
import { podcastApi } from "../../../services/podcastApi";
import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi";
interface UseRenderQueueProps {
script: Script;
@@ -427,9 +427,14 @@ export const useRenderQueue = ({
});
try {
const cachedClone = getCachedVoiceCloneInfo();
const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene,
voiceId: "Wise_Woman",
voiceId: knobs.voice_id || "Wise_Woman",
customVoiceId: knobs.custom_voice_id || cachedClone?.customVoiceId,
useVoiceClone: knobs.is_voice_clone || cachedClone?.isVoiceClone || false,
voiceSampleUrl: knobs.voice_sample_url || cachedClone?.voiceSampleUrl || undefined,
voiceCloneEngine: knobs.voice_clone_engine || cachedClone?.engine || undefined,
emotion: scene.emotion || getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed,
});

View File

@@ -26,6 +26,9 @@ import { VoiceSelector } from "../../shared/VoiceSelector";
export type AudioGenerationSettings = {
voiceId: string;
customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
speed: number;
volume: number;
pitch: number;

View File

@@ -16,7 +16,7 @@ import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor";
import { ImageRegenerateModal, ImageGenerationSettings } from "./ImageRegenerateModal";
import { AudioRegenerateModal, AudioGenerationSettings } from "./AudioRegenerateModal";
import { podcastApi } from "../../../services/podcastApi";
import { podcastApi, getCachedVoiceCloneInfo } from "../../../services/podcastApi";
import { aiApiClient } from "../../../api/client";
import { getCachedMedia, setCachedMedia } from "../../../utils/mediaCache";
@@ -68,6 +68,9 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
voiceId: knobs.voice_id || "Wise_Woman",
customVoiceId: knobs.custom_voice_id || undefined,
useVoiceClone: knobs.is_voice_clone || false,
voiceSampleUrl: knobs.voice_sample_url || undefined,
voiceCloneEngine: knobs.voice_clone_engine || undefined,
speed: knobs.voice_speed ?? 1.0,
volume: 1.0,
pitch: 0.0,
@@ -308,10 +311,14 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
// Generate audio
const effectiveSettings = settings || audioSettings;
const cachedClone = getCachedVoiceCloneInfo();
const result = await podcastApi.renderSceneAudio({
scene: currentScene,
voiceId: effectiveSettings.voiceId || knobs.voice_id || "Wise_Woman",
customVoiceId: effectiveSettings.customVoiceId || knobs.custom_voice_id,
customVoiceId: effectiveSettings.customVoiceId || knobs.custom_voice_id || cachedClone?.customVoiceId,
useVoiceClone: effectiveSettings.useVoiceClone || knobs.is_voice_clone || cachedClone?.isVoiceClone || false,
voiceSampleUrl: effectiveSettings.voiceSampleUrl || knobs.voice_sample_url || cachedClone?.voiceSampleUrl || undefined,
voiceCloneEngine: effectiveSettings.voiceCloneEngine || knobs.voice_clone_engine || cachedClone?.engine || undefined,
emotion: effectiveSettings.emotion || scene.emotion || knobs.voice_emotion || "neutral",
speed: effectiveSettings.speed ?? knobs.voice_speed ?? 1.0,
volume: effectiveSettings.volume ?? 1.0,

View File

@@ -7,8 +7,9 @@ import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor";
import { InlineAudioPlayer } from "../InlineAudioPlayer";
import { aiApiClient } from "../../../api/client";
import { aiApiClient, getApiUrl } from "../../../api/client";
import { BrollInfoPanel } from "./parts/BrollInfoPanel";
import { ScriptEditorProvider } from "./ScriptEditorContext";
interface ScriptEditorProps {
projectId: string;
@@ -75,49 +76,8 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
}
}, [initialScript]);
useEffect(() => {
// If script already exists, don't regenerate
if (script) {
return;
}
// Only generate if we have research data
if (!rawResearch) {
return;
}
let mounted = true;
setLoading(true);
setError(null);
podcastApi
.generateScript({
projectId,
idea,
research: rawResearch,
knobs,
speakers,
durationMinutes,
podcastMode,
analysis,
outline,
})
.then((res) => {
if (mounted) {
setScript(res);
emitScriptChange(res);
setError(null);
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to generate script";
setError(message);
onError(message);
})
.finally(() => mounted && setLoading(false));
return () => {
mounted = false;
};
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, podcastMode, analysis, outline, emitScriptChange, onError, script]);
// Note: Script generation is now handled by ScriptEditorProvider
// to ensure BrollInfoPanel and other child components have access to context
const updateScene = (updated: Scene) => {
// Use functional update to ensure we're working with latest state
@@ -309,14 +269,20 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
chart_type: scene.chart_data.type || "bar_comparison",
title: scene.title,
});
console.log(`[ChartPreview] Scene ${scene.id}: type=${scene.chart_data.type || 'bar_comparison'}, data=`, scene.chart_data);
const toFullUrl = (url: string) => {
if (/^https?:\/\//i.test(url)) return url;
return `${getApiUrl()}${url.startsWith("/") ? url : `/${url}`}`;
};
return {
...scene,
broll_preview_url: result.preview_url,
broll_preview_url: toFullUrl(result.preview_url),
chart_id: result.chart_id,
};
} catch (error) {
console.error(`Failed to generate chart preview for scene ${scene.id}:`, error);
console.error(`[ChartPreview] Failed for scene ${scene.id}:`, error);
return scene;
}
})
@@ -379,11 +345,28 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
}, [script, emitScriptChange]);
return (
<Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<ScriptEditorProvider
projectId={projectId}
idea={idea}
rawResearch={rawResearch}
knobs={knobs}
speakers={speakers}
durationMinutes={durationMinutes}
initialScript={script}
podcastMode={podcastMode}
analysis={analysis}
outline={outline}
onScriptChange={(s) => {
setScript(s);
onScriptChange(s);
}}
onError={onError}
>
<Box sx={{ mt: 4 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<Box sx={{ flex: 1 }}>
<Typography
variant="h4"
@@ -945,5 +928,6 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
</Stack>
)}
</Box>
</ScriptEditorProvider>
);
};

View File

@@ -320,6 +320,9 @@ export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
scenes: sceneData,
voiceId: knobs.voice_id,
customVoiceId: knobs.custom_voice_id,
useVoiceClone: knobs.is_voice_clone,
voiceSampleUrl: knobs.voice_sample_url,
voiceCloneEngine: knobs.voice_clone_engine,
speed: knobs.voice_speed,
emotion: knobs.voice_emotion,
englishNormalization: true,

View File

@@ -1,6 +1,6 @@
import React from "react";
import { Stack, Box, Typography, Alert, Paper, Button, CircularProgress, Chip } from "@mui/material";
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon } from "@mui/icons-material";
import { Stack, Box, Typography, Paper, Button, CircularProgress, Chip, IconButton, Tooltip } from "@mui/material";
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon, Fullscreen as FullscreenIcon, Visibility as VisibilityIcon } from "@mui/icons-material";
import { useScriptEditor } from "../ScriptEditorContext";
import { Script } from "../../types";
@@ -42,119 +42,246 @@ export const BrollInfoPanel: React.FC<BrollInfoPanelProps> = (props) => {
return (
<Paper
sx={{
p: 3,
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%)",
p: 2.5,
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.03) 0%, rgba(16, 185, 129, 0.03) 100%)",
border: "1px solid rgba(34, 197, 94, 0.15)",
borderRadius: 2,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}>
<Box sx={{ width: 40, height: 40, borderRadius: "50%", background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<BarChartIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
B-Roll Charts
</Typography>
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
Programmatic charts extracted from research data
</Typography>
</Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box sx={{
p: 0.75,
borderRadius: 1.5,
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}>
<BarChartIcon sx={{ fontSize: 18, color: "#fff" }} />
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: "#0f172a", lineHeight: 1.2 }}>
B-Roll Charts
</Typography>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{resolvedScenesWithCharts} chart{resolvedScenesWithCharts !== 1 ? 's' : ''} for visual storytelling
</Typography>
</Box>
</Stack>
{hasChartData && (
<Chip
label={`${resolvedScenesWithCharts} scene${resolvedScenesWithCharts > 1 ? 's' : ''} with charts`}
<Button
variant="contained"
size="small"
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a", fontWeight: 600 }}
/>
startIcon={resolvedGeneratingChartId ? <CircularProgress size={14} color="inherit" /> : <AutoAwesomeIcon sx={{ fontSize: 16 }} />}
onClick={resolvedGenerateChartPreviews}
disabled={!!resolvedGeneratingChartId}
sx={{
background: "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)",
fontSize: "0.75rem",
py: 0.5,
px: 1.5,
textTransform: "none",
fontWeight: 600,
boxShadow: "0 2px 8px rgba(34, 197, 94, 0.3)",
"&:hover": {
background: "linear-gradient(135deg, #16a34a 0%, #15803d 100%)",
},
"&:disabled": {
background: "rgba(34, 197, 94, 0.5)",
}
}}
>
{resolvedGeneratingChartId ? "Generating..." : "Generate Charts"}
</Button>
)}
</Stack>
{!hasChartData ? (
<Alert severity="info" sx={{ background: "rgba(34, 197, 94, 0.06)", border: "1px solid rgba(34, 197, 94, 0.15)", "& .MuiAlert-icon": { color: "#22c55e" } }}>
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
<strong style={{ fontWeight: 600 }}>No charts detected.</strong> If your research contains statistics or metrics, the script generation will automatically extract chart data for B-roll visualization.
</Typography>
</Alert>
) : (
<Stack spacing={2}>
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
Your script contains <strong style={{ fontWeight: 600 }}>{scenesWithData.length}</strong> scene(s) with chart data.
Click below to generate chart previews for the Write phase.
</Typography>
<Stack direction="row" spacing={2}>
<Button
variant="contained"
startIcon={resolvedGeneratingChartId ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
onClick={resolvedGenerateChartPreviews}
disabled={!!resolvedGeneratingChartId || !resolvedGenerateChartPreviews}
sx={{
background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)",
"&:hover": { background: "linear-gradient(135deg, #16a34a 0%, #059669 100%)" },
textTransform: "none",
fontWeight: 600,
}}
>
{resolvedGeneratingChartId ? "Generating..." : "Generate Chart Previews"}
</Button>
</Stack>
{scenesWithData.map((scene) => (
<Box
key={scene.id}
sx={{
p: 2,
background: "rgba(0,0,0,0.02)",
borderRadius: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{scene.title}
</Typography>
<Typography variant="caption" sx={{ color: "#64748b" }}>
{scene.chart_data?.type || "chart"} {scene.chart_data?.labels?.length || 0} data points
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{resolvedGeneratingChartId === scene.id ? (
<CircularProgress size={20} />
) : scene.broll_preview_url ? (
<>
<Chip
label="Preview Ready"
size="small"
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a" }}
{hasChartData ? (
<Stack spacing={1.5}>
{scenesWithData.map((scene) => {
const chartData = scene.chart_data;
const hasPreview = !!scene.broll_preview_url;
return (
<Box
key={scene.id}
sx={{
p: 1.5,
background: "#fff",
borderRadius: 1.5,
border: "1px solid rgba(0,0,0,0.06)",
display: "flex",
alignItems: "center",
gap: 2,
transition: "all 0.2s ease",
"&:hover": {
borderColor: "rgba(34, 197, 94, 0.3)",
boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
}
}}
>
{/* Thumbnail */}
<Box
sx={{
width: 72,
height: 48,
flexShrink: 0,
borderRadius: 1,
overflow: "hidden",
background: hasPreview ? "rgba(0,0,0,0.04)" : "linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: hasPreview ? "pointer" : "default",
transition: "all 0.2s ease",
"&:hover": hasPreview ? {
transform: "scale(1.05)",
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
} : {}
}}
>
{resolvedGeneratingChartId === scene.id ? (
<CircularProgress size={24} sx={{ color: "#22c55e" }} />
) : hasPreview && scene.broll_preview_url ? (
<Box
component="img"
src={scene.broll_preview_url}
alt={`Chart for ${scene.title}`}
sx={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
onClick={() => window.open(scene.broll_preview_url, '_blank')}
/>
<Button
size="small"
startIcon={<RefreshIcon />}
) : (
<BarChartIcon sx={{ fontSize: 20, color: "#94a3b8" }} />
)}
</Box>
{/* Chart Info */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{
fontWeight: 600,
color: "#1e293b",
fontSize: "0.8rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.25 }}>
<Chip
label={chartData?.type || "chart"}
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
background: "rgba(34, 197, 94, 0.1)",
color: "#16a34a",
fontWeight: 600,
}}
/>
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.7rem" }}>
{chartData?.labels?.length || 0} labels
</Typography>
{hasPreview && (
<Chip
label="Ready"
size="small"
sx={{
height: 18,
fontSize: "0.65rem",
background: "rgba(34, 197, 94, 0.15)",
color: "#16a34a",
fontWeight: 600,
}}
/>
)}
</Stack>
</Box>
{/* Takeaway */}
{chartData?.takeaway && (
<Box sx={{
flex: 1.5,
display: { xs: "none", md: "block" },
px: 1,
py: 0.5,
background: "rgba(34, 197, 94, 0.04)",
borderRadius: 1,
}}>
<Typography variant="caption" sx={{
color: "#475569",
fontSize: "0.7rem",
fontStyle: "italic",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}>
"{chartData.takeaway}"
</Typography>
</Box>
)}
{/* Actions */}
<Stack direction="row" spacing={0.5}>
{hasPreview && (
<Tooltip title="View fullsize">
<IconButton
size="small"
onClick={() => scene.broll_preview_url && window.open(scene.broll_preview_url, '_blank')}
sx={{
color: "#64748b",
"&:hover": { color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }
}}
>
<FullscreenIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
)}
<Tooltip title="Regenerate">
<IconButton
size="small"
onClick={() => resolvedRegenerateChart?.(scene.id)}
disabled={!resolvedRegenerateChart}
disabled={!resolvedRegenerateChart || !!resolvedGeneratingChartId}
sx={{
color: "#64748b",
"&:hover": { color: "#f59e0b", background: "rgba(245, 158, 11, 0.1)" }
}}
>
Regenerate
</Button>
<Button
size="small"
startIcon={<DeleteIcon />}
<RefreshIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Remove chart">
<IconButton
size="small"
onClick={() => resolvedRemoveChart?.(scene.id)}
disabled={!resolvedRemoveChart}
sx={{ color: "#ef4444" }}
sx={{
color: "#64748b",
"&:hover": { color: "#ef4444", background: "rgba(239, 68, 68, 0.1)" }
}}
>
Remove
</Button>
</>
) : null}
</Stack>
</Box>
))}
<DeleteIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Stack>
</Box>
);
})}
</Stack>
) : (
<Box sx={{ py: 3, textAlign: "center" }}>
<BarChartIcon sx={{ fontSize: 36, color: "#cbd5e1", mb: 1 }} />
<Typography variant="body2" sx={{ color: "#64748b", fontSize: "0.8rem" }}>
No chart data yet. Add chart data to scenes to generate B-roll visuals.
</Typography>
</Box>
)}
</Paper>
);
};
};

View File

@@ -3,6 +3,9 @@ export type Knobs = {
voice_speed: number;
voice_id: string;
custom_voice_id?: string;
is_voice_clone?: boolean;
voice_sample_url?: string;
voice_clone_engine?: string;
resolution: string;
scene_length_target: number;
sample_rate: number;

View File

@@ -5,12 +5,40 @@ interface GlassyCardProps {
children?: React.ReactNode;
sx?: SxProps<Theme>;
onClick?: () => void;
[key: string]: any; // Allow other props for framer-motion
// Allow motion props (framer-motion) - they'll be filtered out to avoid DOM warnings
whileHover?: any;
whileTap?: any;
initial?: any;
animate?: any;
exit?: any;
transition?: any;
variants?: any;
layout?: any;
layoutId?: any;
className?: string;
'aria-label'?: string;
}
export const GlassyCard: React.FC<GlassyCardProps> = ({ children, sx, ...props }) => {
// Filter out motion props to avoid DOM warnings - these won't work with MUI Paper anyway
const {
whileHover,
whileTap,
initial,
animate,
exit,
transition,
variants,
layout,
layoutId,
className,
'aria-label': ariaLabel,
...filteredProps
} = props;
return (
<Paper
className={className}
aria-label={ariaLabel}
sx={{
borderRadius: 3,
border: "1px solid rgba(15, 23, 42, 0.06)",
@@ -25,7 +53,7 @@ export const GlassyCard: React.FC<GlassyCardProps> = ({ children, sx, ...props }
},
...sx
}}
{...props}
{...filteredProps}
>
{children}
</Paper>

View File

@@ -34,6 +34,10 @@ try {
export type AudioGenerationSettings = {
voiceId: string;
customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
speed: number;
volume: number;
pitch: number;

View File

@@ -136,7 +136,7 @@ const PREDEFINED_VOICES: VoiceOption[] = [
{ id: "Exuberant_Girl", name: "Exuberant Girl", personality: "Joyful, expressive female voice - ideal for celebrations", previewUrl: VOICE_PREVIEW_MAP.Exuberant_Girl, gender: "female", category: "creative" },
];
const VOICE_CLONE_ID = "MY_VOICE_CLONE";
export const VOICE_CLONE_ID = "MY_VOICE_CLONE";
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
value,

View File

@@ -60,6 +60,9 @@ export interface PodcastProjectState {
// Backend project creation status — prevents 404 sync calls before project exists
backendProjectCreated?: boolean;
// Track last synced phase to prevent duplicate syncs
lastSyncedPhase?: string | null;
}
const DEFAULT_KNOBS: Knobs = {
@@ -162,21 +165,28 @@ export const usePodcastProjectState = () => {
}
}, [state]);
// Sync to database after major steps (debounced)
// Sync to database ONLY on phase transitions (not on every state change)
// This ensures we sync at: Create → Analyze → Research → Script → Render
useEffect(() => {
if (!state.project || !state.project.id || !state.backendProjectCreated) return;
if (!state.currentStep) return;
// Skip if already synced this phase (handles duplicate calls from handleCreate/etc)
if (state.currentStep === state.lastSyncedPhase) {
return;
}
// Capture project ID to avoid closure issues
const projectId = state.project.id;
// Clear existing timeout
// Debounce - wait for state to settle before syncing
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// Debounce database sync (wait 2 seconds after last change)
syncTimeoutRef.current = setTimeout(async () => {
try {
console.log(`[Sync] Saving project at phase: ${state.currentStep}`);
const dbState = {
analysis: state.analysis,
queries: state.queries,
@@ -195,39 +205,37 @@ export const usePodcastProjectState = () => {
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress',
};
await podcastApi.saveProject(projectId, dbState);
const saved = await podcastApi.saveProject(projectId, dbState);
if (saved) {
setState((prev) => ({ ...prev, lastSyncedPhase: prev.currentStep }));
console.log(`[Sync] Project saved successfully at phase: ${state.currentStep}`);
} else {
console.warn(`[Sync] Failed to save project at phase: ${state.currentStep} - will retry on next phase change`);
}
} catch (error) {
console.error('Error syncing project to database:', error);
// Don't throw - localStorage is still working
console.error('[Sync] Error saving project:', error);
}
}, 2000);
}, 1500);
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
}, [
state.project,
state.analysis,
state.queries,
state.selectedQueries,
state.research,
state.rawResearch,
state.estimate,
state.scriptData,
state.renderJobs,
state.knobs,
state.bible,
state.researchProvider,
state.showScriptEditor,
state.showRenderQueue,
state.currentStep,
]);
// Only sync when phase changes - not on every state field change
}, [state.currentStep, state.backendProjectCreated]);
// Setters
const setProject = useCallback((project: PodcastProjectState['project']) => {
setState((prev) => ({ ...prev, project, currentStep: project ? 'analysis' : null, updatedAt: new Date().toISOString() }));
const newStep = project ? 'analysis' : null;
setState((prev) => ({
...prev,
project,
currentStep: newStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => {
@@ -235,6 +243,7 @@ export const usePodcastProjectState = () => {
...prev,
analysis,
currentStep: analysis ? 'research' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
@@ -255,6 +264,7 @@ export const usePodcastProjectState = () => {
...prev,
research,
currentStep: research ? 'script' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);
@@ -272,6 +282,7 @@ export const usePodcastProjectState = () => {
...prev,
scriptData,
currentStep: scriptData ? 'render' : prev.currentStep,
lastSyncedPhase: prev.currentStep, // Mark previous phase as synced
updatedAt: new Date().toISOString()
}));
}, []);

View File

@@ -93,9 +93,14 @@ billingAPI.interceptors.response.use(
async (error) => {
const originalRequest = error.config;
// Handle network errors
// Handle network errors - but NOT timeouts (backend might just be slow)
if (!error.response) {
noteBackendUnavailable(error?.message || 'billing_network_error');
const errorMsg = error?.message || '';
const isTimeout = errorMsg.includes('timeout') || errorMsg.includes('ETIMEDOUT');
if (!isTimeout) {
noteBackendUnavailable(errorMsg || 'billing_network_error');
}
console.error('Billing API Network Error:', error.message);
return Promise.reject(error);
}

View File

@@ -1,3 +1,4 @@
import { noteBackendRecovered } from "../api/client";
import { ResearchProvider, ResearchConfig } from "./blogWriterApi";
import {
storyWriterApi,
@@ -28,12 +29,42 @@ const DEFAULT_KNOBS: Knobs = {
voice_speed: 1,
voice_id: "Wise_Woman",
custom_voice_id: undefined,
is_voice_clone: undefined,
voice_sample_url: undefined,
voice_clone_engine: undefined,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
// In-memory cache for voice clone info to avoid re-fetching per scene
let _voiceCloneCache: {
customVoiceId?: string;
voiceSampleUrl?: string;
engine?: string;
isVoiceClone?: boolean;
timestamp: number;
} | null = null;
const VOICE_CLONE_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
export function getCachedVoiceCloneInfo() {
if (_voiceCloneCache && Date.now() - _voiceCloneCache.timestamp < VOICE_CLONE_CACHE_TTL) {
return _voiceCloneCache;
}
_voiceCloneCache = null;
return null;
}
export function setCachedVoiceCloneInfo(info: {
customVoiceId?: string;
voiceSampleUrl?: string;
engine?: string;
isVoiceClone?: boolean;
}) {
_voiceCloneCache = { ...info, timestamp: Date.now() };
}
// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createId = (prefix: string) => {
@@ -244,9 +275,9 @@ const mapExaResearchResponse = (response: any): Research => {
};
const ensurePreflight = async (operation: PreflightOperation) => {
console.log('[podcastApi] Running preflight for:', operation);
console.log('[podcastApi] Running preflight for:', operation.operation_type);
const result = await checkPreflight(operation);
console.log('[podcastApi] Preflight result:', result);
console.log('[podcastApi] Preflight result: can_proceed=', result.can_proceed);
if (!result.can_proceed) {
const message = result.operations[0]?.message || "Pre-flight validation failed";
throw new Error(message);
@@ -379,7 +410,9 @@ export const podcastApi = {
bible: params.bible,
analysis: params.analysis,
}, { timeout: 300000 }); // 5 minute timeout for research
console.log('[podcastApi] Exa research response received:', response.status, response.data);
const sourceCount = response.data?.sources?.length || 0;
const insightCount = response.data?.key_insights?.length || 0;
console.log(`[podcastApi] Exa research response: status=${response.status}, sources=${sourceCount}, insights=${insightCount}`);
} catch (error: any) {
console.error('[podcastApi] Exa research error:', error?.response?.status, error?.response?.data, error?.message);
throw error;
@@ -497,6 +530,9 @@ export const podcastApi = {
scene: Scene;
voiceId?: string;
customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
emotion?: string; // Fallback if scene doesn't have emotion
speed?: number;
volume?: number;
@@ -600,7 +636,7 @@ export const podcastApi = {
channel: params.channel || null,
format: params.format || null,
language_boost: params.languageBoost || null,
});
}, { timeout: 300000 }); // 5 minute timeout for voice clone / TTS
return {
audioUrl: response.data.audio_url,
@@ -623,12 +659,14 @@ export const podcastApi = {
},
// Project persistence endpoints
async saveProject(projectId: string, state: any): Promise<void> {
async saveProject(projectId: string, state: any): Promise<boolean> {
try {
await aiApiClient.put(`/api/podcast/projects/${projectId}`, state);
return true;
} catch (error) {
console.error("Failed to save project to database:", error);
// Don't throw - localStorage fallback is acceptable
noteBackendRecovered();
return false;
}
},
@@ -952,6 +990,9 @@ export const podcastApi = {
scenes: { id: string; title: string; lines: { text: string }[] }[];
voiceId: string;
customVoiceId?: string;
useVoiceClone?: boolean;
voiceSampleUrl?: string;
voiceCloneEngine?: string;
speed: number;
emotion: string;
englishNormalization?: boolean;