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:
@@ -17,6 +17,7 @@ export interface VoiceCloneResponse {
|
||||
voice_name?: string;
|
||||
preview_audio_url?: string;
|
||||
asset_id?: number;
|
||||
engine?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -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'}`)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,6 +34,10 @@ try {
|
||||
|
||||
export type AudioGenerationSettings = {
|
||||
voiceId: string;
|
||||
customVoiceId?: string;
|
||||
useVoiceClone?: boolean;
|
||||
voiceSampleUrl?: string;
|
||||
voiceCloneEngine?: string;
|
||||
speed: number;
|
||||
volume: number;
|
||||
pitch: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}));
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user