AI podcast maker performance optimizations
This commit is contained in:
@@ -508,6 +508,7 @@ const ContentLifecyclePillars: React.FC = () => {
|
||||
borderRadius: 2,
|
||||
mb: 4,
|
||||
position: 'relative', // For hero section positioning
|
||||
minHeight: '200px', // Reserve space for hero section to prevent layout shift
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -84,15 +84,17 @@ const MainDashboard: React.FC = () => {
|
||||
initializeWorkflow();
|
||||
}, [generateDailyWorkflow]);
|
||||
|
||||
// Debug logging for workflow state
|
||||
// Debug logging for workflow state (only in development)
|
||||
React.useEffect(() => {
|
||||
console.log('Workflow Debug:', {
|
||||
currentWorkflow,
|
||||
workflowProgress,
|
||||
isWorkflowActive: currentWorkflow?.workflowStatus === 'in_progress',
|
||||
workflowStatus: currentWorkflow?.workflowStatus,
|
||||
hasWorkflow: !!currentWorkflow
|
||||
});
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Workflow Debug:', {
|
||||
currentWorkflow,
|
||||
workflowProgress,
|
||||
isWorkflowActive: currentWorkflow?.workflowStatus === 'in_progress',
|
||||
workflowStatus: currentWorkflow?.workflowStatus,
|
||||
hasWorkflow: !!currentWorkflow
|
||||
});
|
||||
}
|
||||
}, [currentWorkflow, workflowProgress]);
|
||||
|
||||
// State to track if we need to start a newly generated workflow
|
||||
@@ -166,42 +168,50 @@ const MainDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolClick = (tool: Tool) => {
|
||||
console.log('Navigating to tool:', tool.path);
|
||||
const handleToolClick = useCallback((tool: Tool) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Navigating to tool:', tool.path);
|
||||
}
|
||||
if (tool.path) {
|
||||
navigate(tool.path);
|
||||
return;
|
||||
}
|
||||
showSnackbar(`Launching ${tool.name}...`, 'info');
|
||||
};
|
||||
}, [navigate, showSnackbar]);
|
||||
|
||||
// Handle category click to open modal
|
||||
const handleCategoryClick = (categoryName: string | null, categoryData?: any) => {
|
||||
const handleCategoryClick = useCallback((categoryName: string | null, categoryData?: any) => {
|
||||
setModalCategoryName(categoryName);
|
||||
setModalCategory(categoryData);
|
||||
setToolsModalOpen(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoize search results computation
|
||||
const searchResultsMemo = useMemo(() => {
|
||||
if (!searchQuery || searchQuery.length < 2) return [];
|
||||
|
||||
// Get all tools from all categories that match search
|
||||
const allTools: Tool[] = [];
|
||||
Object.values(toolCategories).forEach(category => {
|
||||
if (category) {
|
||||
const tools = getToolsForCategory(category, null);
|
||||
allTools.push(...tools);
|
||||
}
|
||||
});
|
||||
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return allTools.filter(tool =>
|
||||
tool.name.toLowerCase().includes(queryLower) ||
|
||||
tool.description.toLowerCase().includes(queryLower) ||
|
||||
tool.features.some(feature => feature.toLowerCase().includes(queryLower))
|
||||
);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle search to show results in modal with debouncing
|
||||
React.useEffect(() => {
|
||||
if (searchQuery && searchQuery.length >= 2) { // Only search after 2+ characters
|
||||
if (searchQuery && searchQuery.length >= 2) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Get all tools from all categories that match search
|
||||
const allTools: Tool[] = [];
|
||||
Object.values(toolCategories).forEach(category => {
|
||||
if (category) {
|
||||
const tools = getToolsForCategory(category, null);
|
||||
allTools.push(...tools);
|
||||
}
|
||||
});
|
||||
|
||||
const filtered = allTools.filter(tool =>
|
||||
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.features.some(feature => feature.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
setSearchResults(filtered);
|
||||
setSearchResults(searchResultsMemo);
|
||||
setModalCategoryName(null);
|
||||
setModalCategory(null);
|
||||
setToolsModalOpen(true);
|
||||
@@ -212,10 +222,10 @@ const MainDashboard: React.FC = () => {
|
||||
// Close modal if search query is too short
|
||||
setToolsModalOpen(false);
|
||||
}
|
||||
}, [searchQuery]);
|
||||
}, [searchQuery, searchResultsMemo]);
|
||||
|
||||
// Close modal and clear search
|
||||
const handleCloseModal = () => {
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setToolsModalOpen(false);
|
||||
setModalCategoryName(null);
|
||||
setModalCategory(null);
|
||||
@@ -223,7 +233,7 @@ const MainDashboard: React.FC = () => {
|
||||
if (searchQuery) {
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
}, [searchQuery, setSearchQuery]);
|
||||
|
||||
// Note: filteredCategories removed as it's not used in the current implementation
|
||||
|
||||
@@ -242,19 +252,21 @@ const MainDashboard: React.FC = () => {
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
|
||||
padding: theme.spacing(4),
|
||||
position: 'relative',
|
||||
overflow: 'hidden', // Prevent layout shifts from pseudo-elements
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
position: 'fixed', // Changed from absolute to fixed to prevent layout shifts
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform', // Optimize for animations
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
position: 'fixed', // Changed from absolute to fixed to prevent layout shifts
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '600px',
|
||||
@@ -263,6 +275,7 @@ const MainDashboard: React.FC = () => {
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
willChange: 'transform', // Optimize for animations
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -55,6 +55,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
justifyContent: 'center',
|
||||
borderRadius: 2, // Match the parent container's border radius
|
||||
px: 2, // Add horizontal padding to constrain width
|
||||
minHeight: '200px', // Reserve space to prevent layout shift
|
||||
willChange: 'transform', // Optimize for animations
|
||||
}}
|
||||
>
|
||||
{/* Hero Content - Full Coverage */}
|
||||
@@ -130,6 +132,7 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
style={{ width: '100%' }} // Prevent width changes
|
||||
>
|
||||
{/* Main Heading with Rocket */}
|
||||
<Box sx={{
|
||||
@@ -137,7 +140,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
mb: 2
|
||||
mb: 2,
|
||||
minHeight: '48px', // Reserve space for heading to prevent layout shift
|
||||
}}>
|
||||
<Typography
|
||||
variant={isMobile ? "h5" : "h4"}
|
||||
@@ -149,6 +153,7 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
lineHeight: 1.2, // Fixed line height to prevent shift
|
||||
}}
|
||||
>
|
||||
Grow Your Business Now
|
||||
@@ -220,6 +225,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent)',
|
||||
animation: 'shimmer 2.5s infinite',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none', // Prevent layout impact
|
||||
willChange: 'left', // Optimize animation
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
@@ -233,6 +240,8 @@ const WorkflowHeroSection: React.FC<WorkflowHeroSectionProps> = ({
|
||||
borderRadius: 'inherit',
|
||||
zIndex: -1,
|
||||
animation: 'borderGlow 3s ease-in-out infinite',
|
||||
pointerEvents: 'none', // Prevent layout impact
|
||||
willChange: 'background-position', // Optimize animation
|
||||
},
|
||||
'@keyframes shimmer': {
|
||||
'0%': { left: '-100%' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, Paper, alpha } from "@mui/material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon } from "@mui/icons-material";
|
||||
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon } from "@mui/icons-material";
|
||||
import { PodcastAnalysis } from "./types";
|
||||
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
@@ -92,6 +92,82 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegene
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{analysis.exaSuggestedConfig && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Exa Research Suggestions
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
|
||||
{analysis.exaSuggestedConfig.exa_search_type && (
|
||||
<Chip
|
||||
label={`Search: ${analysis.exaSuggestedConfig.exa_search_type}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{analysis.exaSuggestedConfig.exa_category && (
|
||||
<Chip
|
||||
label={`Category: ${analysis.exaSuggestedConfig.exa_category}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{analysis.exaSuggestedConfig.date_range && (
|
||||
<Chip
|
||||
label={`Date: ${analysis.exaSuggestedConfig.date_range}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{typeof analysis.exaSuggestedConfig.include_statistics === "boolean" && (
|
||||
<Chip
|
||||
label={analysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
{analysis.exaSuggestedConfig.max_sources && (
|
||||
<Chip
|
||||
label={`Max sources: ${analysis.exaSuggestedConfig.max_sources}`}
|
||||
size="small"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{(analysis.exaSuggestedConfig.exa_include_domains?.length || analysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
{analysis.exaSuggestedConfig.exa_include_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Prefer domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{analysis.exaSuggestedConfig.exa_include_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{analysis.exaSuggestedConfig.exa_exclude_domains?.length ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
Avoid domains
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{analysis.exaSuggestedConfig.exa_exclude_domains.map((d) => (
|
||||
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip } from "@mui/material";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip, IconButton } from "@mui/material";
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Info as InfoIcon,
|
||||
HelpOutline as HelpOutlineIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { CreateProjectPayload, Knobs } from "./types";
|
||||
import { PrimaryButton, SecondaryButton } from "./ui";
|
||||
@@ -16,17 +18,25 @@ interface CreateModalProps {
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
// Rotating placeholder examples for topic ideas
|
||||
const TOPIC_PLACEHOLDERS = [
|
||||
"How AI is transforming content marketing in 2024",
|
||||
"The future of remote work: trends and predictions",
|
||||
"Sustainable business practices for modern companies",
|
||||
];
|
||||
|
||||
export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaultKnobs, isSubmitting = false }) => {
|
||||
const { subscription } = useSubscription();
|
||||
const [idea, setIdea] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [showAIDetailsButton, setShowAIDetailsButton] = useState(false);
|
||||
const [speakers, setSpeakers] = useState<number>(1);
|
||||
const [duration, setDuration] = useState<number>(10);
|
||||
const [duration, setDuration] = useState<number>(1);
|
||||
const [budgetCap, setBudgetCap] = useState<number>(50);
|
||||
const [voiceFile, setVoiceFile] = useState<File | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
|
||||
// Determine subscription tier restrictions
|
||||
const tier = subscription?.tier || 'free';
|
||||
@@ -35,6 +45,16 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
const canUseHD = !isFreeTier && !isBasicTier; // HD only for pro/enterprise
|
||||
const canUseMultiSpeaker = !isFreeTier; // Multi-speaker for basic+ tiers
|
||||
|
||||
// Rotate placeholder every 3 seconds
|
||||
useEffect(() => {
|
||||
if (!idea && !url) {
|
||||
const interval = setInterval(() => {
|
||||
setPlaceholderIndex((prev) => (prev + 1) % TOPIC_PLACEHOLDERS.length);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [idea, url]);
|
||||
|
||||
// Reset HD quality if user downgrades
|
||||
useEffect(() => {
|
||||
if (!canUseHD && knobs.bitrate === 'hd') {
|
||||
@@ -49,11 +69,42 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}
|
||||
}, [canUseMultiSpeaker]);
|
||||
|
||||
// Ensure duration and speakers are within limits
|
||||
useEffect(() => {
|
||||
if (duration > 10) {
|
||||
setDuration(10);
|
||||
}
|
||||
if (speakers > 2) {
|
||||
setSpeakers(2);
|
||||
}
|
||||
}, [duration, speakers]);
|
||||
|
||||
// Show AI details button when user starts typing
|
||||
useEffect(() => {
|
||||
setShowAIDetailsButton(idea.trim().length > 0);
|
||||
}, [idea]);
|
||||
|
||||
// Calculate estimated cost
|
||||
const estimatedCost = useMemo(() => {
|
||||
const chars = Math.max(1000, duration * 900); // ~900 chars per minute
|
||||
const scenes = Math.ceil((duration * 60) / (knobs.scene_length_target || 45));
|
||||
const secs = duration * 60;
|
||||
|
||||
const ttsCost = (chars / 1000) * 0.05;
|
||||
const avatarCost = speakers * 0.15;
|
||||
const videoRate = knobs.bitrate === 'hd' ? 0.06 : 0.03;
|
||||
const videoCost = secs * videoRate;
|
||||
const researchCost = 0.3; // Fixed research cost
|
||||
|
||||
return {
|
||||
ttsCost: +ttsCost.toFixed(2),
|
||||
avatarCost: +avatarCost.toFixed(2),
|
||||
videoCost: +videoCost.toFixed(2),
|
||||
researchCost: +researchCost.toFixed(2),
|
||||
total: +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2),
|
||||
};
|
||||
}, [duration, speakers, knobs.bitrate, knobs.scene_length_target]);
|
||||
|
||||
const canSubmit = Boolean(idea || url);
|
||||
|
||||
const submit = () => {
|
||||
@@ -72,11 +123,22 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
setIdea("");
|
||||
setUrl("");
|
||||
setSpeakers(1);
|
||||
setDuration(10);
|
||||
setDuration(1);
|
||||
setBudgetCap(50);
|
||||
setVoiceFile(null);
|
||||
setAvatarFile(null);
|
||||
setKnobs({ ...defaultKnobs });
|
||||
setPlaceholderIndex(0);
|
||||
};
|
||||
|
||||
const handleDurationChange = (value: number) => {
|
||||
const clamped = Math.min(10, Math.max(1, value));
|
||||
setDuration(clamped);
|
||||
};
|
||||
|
||||
const handleSpeakersChange = (value: number) => {
|
||||
const clamped = Math.min(2, Math.max(1, value));
|
||||
setSpeakers(clamped);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -84,49 +146,227 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
border: "1px solid rgba(15, 23, 42, 0.08)",
|
||||
background: "#ffffff",
|
||||
boxShadow: "0 6px 20px rgba(15, 23, 42, 0.08)",
|
||||
p: { xs: 3, md: 4 },
|
||||
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.08)",
|
||||
p: { xs: 3, md: 4.5 },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" flexWrap="wrap">
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<AutoAwesomeIcon sx={{ color: "#667eea" }} />
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ color: "#0f172a", fontWeight: 800 }}>
|
||||
Create New Podcast Episode
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Provide either a topic idea or a blog post URL. We start AI analysis only after you click “Analyze & Continue”.
|
||||
</Typography>
|
||||
<Stack spacing={3.5}>
|
||||
{/* Header Section */}
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" sx={{ flex: 1, minWidth: { xs: "100%", md: "60%" } }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ color: "#667eea", fontSize: "1.75rem" }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: "1.5rem", md: "1.75rem" },
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
Create New Podcast Episode
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Tips for best results:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem" }}>
|
||||
• Provide one clear topic OR a single blog URL (we won't auto-run anything).<br />
|
||||
• Keep it concise—one sentence topic works best.<br />
|
||||
• We start analysis only after you confirm, so you stay in control.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: "#0f172a",
|
||||
color: "#ffffff",
|
||||
maxWidth: 300,
|
||||
fontSize: "0.875rem",
|
||||
p: 1.5,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: "#0f172a",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
color: "#64748b",
|
||||
"&:hover": {
|
||||
color: "#667eea",
|
||||
backgroundColor: alpha("#667eea", 0.08),
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HelpOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Chip label={`Plan: ${subscription?.tier || "free"}`} size="small" color="default" />
|
||||
<Chip label={`Duration: ${duration} min`} size="small" color="default" />
|
||||
<Chip label={`${speakers} speaker${speakers > 1 ? "s" : ""}`} size="small" color="default" />
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ alignItems: "center" }}>
|
||||
<Tooltip
|
||||
title={`Your current subscription plan: ${subscription?.tier || "free"}. Upgrade for more features.`}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`Plan: ${subscription?.tier || "free"}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={`Podcast duration: ${duration} minutes. Maximum duration is 10 minutes. Recommended: 5-10 minutes for best results.`}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`Duration: ${duration} min`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#0f172a", 0.06),
|
||||
color: "#0f172a",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(15, 23, 42, 0.12)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={`Number of speakers: ${speakers}. Supports 1-2 speakers. Each additional speaker adds avatar generation cost.`}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Chip
|
||||
label={`${speakers} speaker${speakers > 1 ? "s" : ""}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#0f172a", 0.06),
|
||||
color: "#0f172a",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(15, 23, 42, 0.12)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Estimated Cost Breakdown:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontSize: "0.875rem", lineHeight: 1.6 }}>
|
||||
• Audio Generation: ${estimatedCost.ttsCost}<br />
|
||||
• Avatar Creation: ${estimatedCost.avatarCost}<br />
|
||||
• Video Rendering: ${estimatedCost.videoCost}<br />
|
||||
• Research: ${estimatedCost.researchCost}<br />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.5, pt: 0.5, borderTop: "1px solid rgba(255,255,255,0.2)" }}>
|
||||
Total: ${estimatedCost.total}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontSize: "0.75rem", opacity: 0.9, mt: 0.5, display: "block" }}>
|
||||
Based on {duration} min, {speakers} speaker{speakers > 1 ? "s" : ""}, {knobs.bitrate === "hd" ? "HD" : "standard"} quality
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: "#0f172a",
|
||||
color: "#ffffff",
|
||||
maxWidth: 280,
|
||||
fontSize: "0.875rem",
|
||||
p: 1.5,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: "#0f172a",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label={`Est. $${estimatedCost.total}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)",
|
||||
color: "#059669",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(16, 185, 129, 0.2)",
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
cursor: "help",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Alert severity="info" sx={{ background: "#eef2ff", border: "1px solid #e0e7ff" }}>
|
||||
<Typography variant="body2" sx={{ color: "#4338ca" }}>
|
||||
Tips for best results:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#4338ca" }}>
|
||||
• Provide one clear topic OR a single blog URL (we won’t auto-run anything).<br />
|
||||
• Keep it concise—one sentence topic works best.<br />
|
||||
• We start analysis only after you confirm, so you stay in control.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
|
||||
{/* Topic Idea Section */}
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
Topic Idea
|
||||
</Typography>
|
||||
{/* Input Section */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
background: alpha("#f8fafc", 0.5),
|
||||
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={3} alignItems="stretch">
|
||||
{/* Topic Idea Section */}
|
||||
<Box flex={1}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Topic Idea
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Tooltip
|
||||
title="Enter a concise idea. We will expand it into an outline only after you click Analyze."
|
||||
arrow
|
||||
@@ -136,7 +376,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
placeholder="e.g., 'How AI is transforming content marketing in 2024'"
|
||||
placeholder={!idea && !url ? `e.g., "${TOPIC_PLACEHOLDERS[placeholderIndex]}"` : ""}
|
||||
inputProps={{
|
||||
sx: {
|
||||
"&::placeholder": { color: "#94a3b8", opacity: 1 },
|
||||
@@ -152,25 +392,38 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
helperText="We will not start analysis until you click Analyze."
|
||||
helperText="Enter a clear, concise topic. We'll expand it into a full script after you click Analyze."
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
},
|
||||
"& .MuiOutlinedInput-input": {
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: 1.5,
|
||||
fontSize: "0.9375rem",
|
||||
lineHeight: 1.6,
|
||||
color: "#0f172a",
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
"& .MuiInputBase-input::placeholder": {
|
||||
color: "#94a3b8",
|
||||
opacity: 1,
|
||||
fontWeight: 400,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#475569",
|
||||
color: "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 400,
|
||||
mt: 0.75,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
@@ -189,8 +442,11 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 1.5,
|
||||
color: "#667eea",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
borderColor: "#5568d3",
|
||||
backgroundColor: alpha("#667eea", 0.08),
|
||||
@@ -203,20 +459,67 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Center OR divider */}
|
||||
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 1 } }}>
|
||||
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", md: "block" }, borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Divider sx={{ display: { xs: "block", md: "none" }, borderColor: "rgba(0,0,0,0.08)", my: 1 }} />
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
|
||||
OR
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* Center OR divider */}
|
||||
<Stack alignItems="center" justifyContent="center" sx={{ px: { xs: 0, md: 2 } }}>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
borderColor: "rgba(15, 23, 42, 0.1)",
|
||||
borderWidth: 1,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "flex", md: "none" },
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
my: 2,
|
||||
}}
|
||||
>
|
||||
<Divider sx={{ flex: 1, borderColor: "rgba(15, 23, 42, 0.1)" }} />
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: 2,
|
||||
background: alpha("#ffffff", 0.8),
|
||||
border: "1px solid rgba(15, 23, 42, 0.1)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
|
||||
OR
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ flex: 1, borderColor: "rgba(15, 23, 42, 0.1)" }} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "none", md: "flex" },
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: alpha("#ffffff", 0.9),
|
||||
border: "1px solid rgba(15, 23, 42, 0.1)",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 700, fontSize: "0.75rem" }}>
|
||||
OR
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Blog URL Section */}
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
Blog Post URL
|
||||
</Typography>
|
||||
{/* Blog URL Section */}
|
||||
<Box flex={1}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Blog Post URL
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Tooltip
|
||||
title="Paste a single article URL. We’ll fetch insights only after you click Analyze."
|
||||
arrow
|
||||
@@ -252,63 +555,246 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#0f172a",
|
||||
fontSize: "0.9375rem",
|
||||
fontWeight: 400,
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#64748b",
|
||||
fontSize: "0.9375rem",
|
||||
"&.Mui-focused": {
|
||||
color: "#667eea",
|
||||
},
|
||||
},
|
||||
"& .MuiInputBase-input::placeholder": {
|
||||
color: "#94a3b8",
|
||||
opacity: 1,
|
||||
fontWeight: 400,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#475569",
|
||||
color: "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 400,
|
||||
mt: 0.75,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Quick settings for duration and speakers */}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Math.max(1, Number(e.target.value) || 0))}
|
||||
InputProps={{ inputProps: { min: 1, max: 60 } }}
|
||||
size="small"
|
||||
helperText="Typical podcasts: 5-20 minutes"
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": { backgroundColor: "#f1f5f9" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Number of speakers"
|
||||
type="number"
|
||||
value={speakers}
|
||||
onChange={(e) => setSpeakers(Math.min(4, Math.max(1, Number(e.target.value) || 1)))}
|
||||
InputProps={{ inputProps: { min: 1, max: 4 } }}
|
||||
size="small"
|
||||
helperText="Supports single or panel style"
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": { backgroundColor: "#f1f5f9" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{/* Settings Section */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
background: alpha("#f8fafc", 0.5),
|
||||
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, color: "#0f172a", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
Podcast Settings
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="flex-start">
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => handleDurationChange(Number(e.target.value) || 1)}
|
||||
InputProps={{ inputProps: { min: 1, max: 10 } }}
|
||||
size="small"
|
||||
helperText={duration > 10 ? "Maximum duration is 10 minutes" : `Recommended: 1-3 minutes for quick tests (currently: ${duration} min)`}
|
||||
error={duration > 10}
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#64748b",
|
||||
"&.Mui-focused": {
|
||||
color: "#667eea",
|
||||
},
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Number of speakers"
|
||||
type="number"
|
||||
value={speakers}
|
||||
onChange={(e) => handleSpeakersChange(Number(e.target.value) || 1)}
|
||||
InputProps={{ inputProps: { min: 1, max: 2 } }}
|
||||
size="small"
|
||||
helperText={speakers > 2 ? "Maximum 2 speakers supported" : `Supports 1-2 speakers (currently: ${speakers})`}
|
||||
error={speakers > 2}
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1.5px solid rgba(15, 23, 42, 0.12)",
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused": {
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#64748b",
|
||||
"&.Mui-focused": {
|
||||
color: "#667eea",
|
||||
},
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#64748b",
|
||||
fontSize: "0.8125rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Cost Breakdown Panel - positioned in empty space */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)",
|
||||
border: "1.5px solid rgba(16, 185, 129, 0.2)",
|
||||
borderRadius: 2,
|
||||
minWidth: { xs: "100%", sm: 300 },
|
||||
flex: { xs: "none", sm: "0 0 auto" },
|
||||
boxShadow: "0 2px 8px rgba(16, 185, 129, 0.08)",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AttachMoneyIcon sx={{ fontSize: "1.125rem", color: "#059669" }} />
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Estimated Cost
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
color: "#059669",
|
||||
fontWeight: 700,
|
||||
fontSize: "1.75rem",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
${estimatedCost.total}
|
||||
</Typography>
|
||||
<Stack spacing={0.75} sx={{ mt: 0.5 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
|
||||
Audio Generation
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
|
||||
${estimatedCost.ttsCost}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
|
||||
Avatar Creation
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
|
||||
${estimatedCost.avatarCost}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
|
||||
Video Rendering
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
|
||||
${estimatedCost.videoCost}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.8125rem", fontWeight: 400 }}>
|
||||
Research
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a", fontSize: "0.8125rem", fontWeight: 600 }}>
|
||||
${estimatedCost.researchCost}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
pt: 1.5,
|
||||
borderTop: "1.5px solid rgba(16, 185, 129, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontSize: "0.75rem", fontWeight: 500 }}>
|
||||
{duration} min • {speakers} speaker{speakers > 1 ? "s" : ""} • {knobs.bitrate === "hd" ? "HD" : "Standard"} quality
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ background: "#ecfeff", border: "1px solid #bae6fd", borderRadius: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.9rem", color: "#0ea5e9" }}>
|
||||
You can provide either a topic idea or a blog post URL. We won’t make any external AI calls until you click “Analyze & Continue”.
|
||||
{/* Info Banner */}
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<InfoIcon sx={{ color: "#6366f1", fontSize: "1.125rem" }} />}
|
||||
sx={{
|
||||
background: alpha("#f0f4ff", 0.6),
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 3px rgba(99, 102, 241, 0.08)",
|
||||
"& .MuiAlert-message": {
|
||||
width: "100%",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.875rem", color: "#475569", lineHeight: 1.6, fontWeight: 400 }}>
|
||||
You can provide either a topic idea or a blog post URL. We won't make any external AI calls until you click "Analyze & Continue".
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Paper, Stack, Typography, IconButton, Tooltip, alpha } from "@mui/material";
|
||||
import { Paper, Stack, Typography, IconButton, Tooltip, alpha, Alert } from "@mui/material";
|
||||
import { VolumeUp as VolumeUpIcon, PlayCircle as PlayCircleIcon, PauseCircle as PauseCircleIcon, Download as DownloadIcon } from "@mui/icons-material";
|
||||
import { aiApiClient } from "../../api/client";
|
||||
|
||||
interface InlineAudioPlayerProps {
|
||||
audioUrl: string;
|
||||
@@ -11,37 +12,140 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioRef = React.useRef<HTMLAudioElement>(null);
|
||||
|
||||
// Load audio as blob if it's an authenticated endpoint
|
||||
useEffect(() => {
|
||||
if (!audioUrl) {
|
||||
setBlobUrl(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a podcast audio endpoint that requires authentication
|
||||
const isPodcastAudio = audioUrl.includes('/api/podcast/audio/') || audioUrl.includes('/api/story/audio/');
|
||||
|
||||
if (!isPodcastAudio) {
|
||||
// Regular URL, use directly
|
||||
setBlobUrl(audioUrl);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch as blob for authenticated endpoints
|
||||
let isMounted = true;
|
||||
const currentAudioUrl = audioUrl;
|
||||
|
||||
const loadAudioBlob = async () => {
|
||||
try {
|
||||
// Normalize path
|
||||
let audioPath = currentAudioUrl.startsWith('/') ? currentAudioUrl : `/${currentAudioUrl}`;
|
||||
|
||||
// Convert /api/story/audio/ to /api/podcast/audio/ if needed
|
||||
if (audioPath.includes('/api/story/audio/')) {
|
||||
const filename = audioPath.split('/api/story/audio/').pop() || '';
|
||||
audioPath = `/api/podcast/audio/${filename}`;
|
||||
}
|
||||
|
||||
// Ensure it's a podcast audio endpoint
|
||||
if (!audioPath.includes('/api/podcast/audio/')) {
|
||||
const filename = audioPath.split('/').pop() || currentAudioUrl;
|
||||
audioPath = `/api/podcast/audio/${filename}`;
|
||||
}
|
||||
|
||||
// Remove query parameters if present
|
||||
audioPath = audioPath.split('?')[0];
|
||||
|
||||
const response = await aiApiClient.get(audioPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
if (!isMounted || audioUrl !== currentAudioUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = response.data;
|
||||
const newBlobUrl = URL.createObjectURL(blob);
|
||||
|
||||
setBlobUrl((prevBlobUrl) => {
|
||||
// Clean up previous blob URL if exists
|
||||
if (prevBlobUrl && prevBlobUrl !== newBlobUrl) {
|
||||
URL.revokeObjectURL(prevBlobUrl);
|
||||
}
|
||||
return newBlobUrl;
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load audio blob:', err);
|
||||
if (isMounted && audioUrl === currentAudioUrl) {
|
||||
setError('Failed to load audio. Please try again.');
|
||||
setBlobUrl(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadAudioBlob();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
// Cleanup blob URL when component unmounts or URL changes
|
||||
setBlobUrl((prevBlobUrl) => {
|
||||
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(prevBlobUrl);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (!audio || !blobUrl) return;
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||
const updateDuration = () => setDuration(audio.duration);
|
||||
const handleEnd = () => setPlaying(false);
|
||||
const handleError = () => {
|
||||
setError('Audio playback error. Please try again.');
|
||||
setPlaying(false);
|
||||
};
|
||||
|
||||
audio.addEventListener("timeupdate", updateTime);
|
||||
audio.addEventListener("loadedmetadata", updateDuration);
|
||||
audio.addEventListener("ended", handleEnd);
|
||||
audio.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", updateTime);
|
||||
audio.removeEventListener("loadedmetadata", updateDuration);
|
||||
audio.removeEventListener("ended", handleEnd);
|
||||
audio.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [audioUrl]);
|
||||
}, [blobUrl]);
|
||||
|
||||
const togglePlay = () => {
|
||||
const togglePlay = async () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
if (!audio || !blobUrl) {
|
||||
setError('Audio not loaded. Please wait...');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
setPlaying(false);
|
||||
} else {
|
||||
await audio.play();
|
||||
setPlaying(true);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Playback error:', err);
|
||||
setError('Failed to play audio. Please try again.');
|
||||
setPlaying(false);
|
||||
}
|
||||
setPlaying(!playing);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
@@ -58,6 +162,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const effectiveAudioUrl = blobUrl || audioUrl;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
@@ -74,8 +180,26 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ py: 0.5 }}>
|
||||
<Typography variant="caption">{error}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!blobUrl && audioUrl && (
|
||||
<Alert severity="info" sx={{ py: 0.5 }}>
|
||||
<Typography variant="caption">Loading audio...</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<IconButton onClick={togglePlay} sx={{ color: "#a78bfa" }} size="large">
|
||||
<IconButton
|
||||
onClick={togglePlay}
|
||||
disabled={!effectiveAudioUrl || !!error}
|
||||
sx={{ color: "#a78bfa" }}
|
||||
size="large"
|
||||
>
|
||||
{playing ? <PauseCircleIcon fontSize="large" /> : <PlayCircleIcon fontSize="large" />}
|
||||
</IconButton>
|
||||
<Stack flex={1}>
|
||||
@@ -85,7 +209,8 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
style={{ width: "100%", cursor: "pointer" }}
|
||||
disabled={!effectiveAudioUrl}
|
||||
style={{ width: "100%", cursor: effectiveAudioUrl ? "pointer" : "not-allowed" }}
|
||||
/>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -99,18 +224,22 @@ export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl,
|
||||
<Tooltip title="Download audio">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (!effectiveAudioUrl) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = audioUrl;
|
||||
link.href = effectiveAudioUrl;
|
||||
link.download = title || "podcast-audio.mp3";
|
||||
link.click();
|
||||
}}
|
||||
disabled={!effectiveAudioUrl}
|
||||
sx={{ color: "rgba(255,255,255,0.7)" }}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||
{effectiveAudioUrl && (
|
||||
<audio ref={audioRef} src={effectiveAudioUrl} preload="metadata" />
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -1,79 +1,26 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Alert,
|
||||
Chip,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Divider,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
alpha,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Mic as MicIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Search as SearchIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Info as InfoIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Insights as InsightsIcon,
|
||||
LibraryMusic as LibraryMusicIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { ResearchProvider } from "../../services/blogWriterApi";
|
||||
import { podcastApi } from "../../services/podcastApi";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Box, Paper, Stack, Alert, Divider, CircularProgress, alpha } from "@mui/material";
|
||||
import { usePodcastProjectState } from "../../hooks/usePodcastProjectState";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateProjectPayload, Job, Knobs, Query, Research, Script } from "./types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
|
||||
import { Script } from "./types";
|
||||
import { CreateModal } from "./CreateModal";
|
||||
import { AnalysisPanel } from "./AnalysisPanel";
|
||||
import { FactCard } from "./FactCard";
|
||||
import { ScriptEditor } from "./ScriptEditor";
|
||||
import { RenderQueue } from "./RenderQueue";
|
||||
import { RecentEpisodesPreview } from "./RecentEpisodesPreview";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
import { usePreflightCheck } from "../../hooks/usePreflightCheck";
|
||||
import { useBudgetTracking } from "../../hooks/useBudgetTracking";
|
||||
import { PreflightBlockDialog } from "./PreflightBlockDialog";
|
||||
import HeaderControls from "../shared/HeaderControls";
|
||||
|
||||
/* ================= Helpers ================= */
|
||||
|
||||
const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
bitrate: "standard",
|
||||
};
|
||||
|
||||
const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
setAnnouncement(message);
|
||||
};
|
||||
|
||||
/* ================= Dashboard ================= */
|
||||
import {
|
||||
Header,
|
||||
ProgressStepper,
|
||||
EstimateCard,
|
||||
QuerySelection,
|
||||
ResearchSummary,
|
||||
usePodcastWorkflow,
|
||||
DEFAULT_KNOBS,
|
||||
getStepLabel,
|
||||
} from "./PodcastDashboard/index";
|
||||
|
||||
const PodcastDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const projectState = usePodcastProjectState();
|
||||
const [showProjectList, setShowProjectList] = useState(false);
|
||||
const {
|
||||
@@ -91,250 +38,39 @@ const PodcastDashboard: React.FC = () => {
|
||||
showScriptEditor,
|
||||
showRenderQueue,
|
||||
currentStep,
|
||||
setProject,
|
||||
setAnalysis,
|
||||
setQueries,
|
||||
setSelectedQueries,
|
||||
setResearch,
|
||||
setRawResearch,
|
||||
setEstimate,
|
||||
setScriptData,
|
||||
updateRenderJob,
|
||||
setKnobs,
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
setShowScriptEditor,
|
||||
setShowRenderQueue,
|
||||
initializeProject,
|
||||
setResearchProvider,
|
||||
updateRenderJob,
|
||||
resetState,
|
||||
loadProjectFromDb,
|
||||
setCurrentStep,
|
||||
} = projectState;
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [showResumeAlert, setShowResumeAlert] = useState(false);
|
||||
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
|
||||
const [preflightResponse, setPreflightResponse] = useState<any>(null);
|
||||
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
|
||||
|
||||
// Budget tracking
|
||||
const budgetTracking = useBudgetTracking(projectState.budgetCap || 50);
|
||||
|
||||
// Preflight check hook
|
||||
const preflightCheck = usePreflightCheck({
|
||||
onBlocked: (response) => {
|
||||
setPreflightResponse(response);
|
||||
setShowPreflightDialog(true);
|
||||
const workflow = usePodcastWorkflow({
|
||||
projectState,
|
||||
onError: (msg: string) => {
|
||||
// Error handling is done through workflow's own announcement system
|
||||
console.error("Workflow error:", msg);
|
||||
},
|
||||
});
|
||||
|
||||
// Update budget cap when project state changes
|
||||
useEffect(() => {
|
||||
if (projectState.budgetCap) {
|
||||
budgetTracking.setBudgetCap(projectState.budgetCap);
|
||||
}
|
||||
}, [projectState.budgetCap, budgetTracking]);
|
||||
|
||||
// Check if we have a saved project on mount
|
||||
useEffect(() => {
|
||||
if (project && currentStep && currentStep !== "create") {
|
||||
setShowResumeAlert(true);
|
||||
setTimeout(() => setShowResumeAlert(false), 5000);
|
||||
}
|
||||
}, []); // Only on mount
|
||||
|
||||
useEffect(() => {
|
||||
if (announcement) {
|
||||
const t = setTimeout(() => setAnnouncement(""), 4000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [announcement]);
|
||||
|
||||
const handleCreate = async (payload: CreateProjectPayload) => {
|
||||
// Prevent duplicate submits that can spam story setup API
|
||||
if (isAnalyzing) return;
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
setScriptData(null);
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setAnnouncement("Analyzing your idea — AI suggestions incoming");
|
||||
const result = await podcastApi.createProject(payload);
|
||||
await initializeProject(payload, result.projectId);
|
||||
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
|
||||
setAnalysis(result.analysis);
|
||||
setEstimate(result.estimate);
|
||||
setQueries(result.queries);
|
||||
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
|
||||
setKnobs(payload.knobs);
|
||||
setBudgetCap(payload.budgetCap);
|
||||
setAnnouncement("Analysis complete");
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunResearch = async () => {
|
||||
// Prevent duplicate research calls
|
||||
if (isResearching) return;
|
||||
if (!project) {
|
||||
setAnnouncement("Create a project first.");
|
||||
return;
|
||||
}
|
||||
if (selectedQueries.size === 0) {
|
||||
setAnnouncement("Select at least one query to research.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Preflight check before research
|
||||
setPreflightOperationName("Research");
|
||||
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: researchProvider === "exa" ? "exa" : "gemini",
|
||||
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
|
||||
tokens_requested: researchProvider === "exa" ? 0 : 1200,
|
||||
actual_provider_name: researchProvider || "google",
|
||||
});
|
||||
|
||||
if (!preflightResult.can_proceed) {
|
||||
return; // Dialog will be shown by onBlocked callback
|
||||
}
|
||||
|
||||
try {
|
||||
setIsResearching(true);
|
||||
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
setScriptData(null);
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
|
||||
try {
|
||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
||||
projectId: project.id,
|
||||
topic: project.idea,
|
||||
approvedQueries,
|
||||
provider: researchProvider,
|
||||
onProgress: (message) => {
|
||||
// Update announcement with progress messages
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
setResearch(mapped);
|
||||
setRawResearch(raw);
|
||||
setAnnouncement("Research complete — review fact cards below");
|
||||
} catch (researchError) {
|
||||
const errorMessage = researchError instanceof Error
|
||||
? researchError.message
|
||||
: "Research failed. Please try again or switch to Standard Research.";
|
||||
|
||||
// Provide helpful error messages
|
||||
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
|
||||
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
|
||||
} else if (errorMessage.includes("timeout")) {
|
||||
setAnnouncement("Research timed out. Please try again with fewer queries.");
|
||||
} else {
|
||||
setAnnouncement(`Research failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Log full error for debugging
|
||||
console.error("Research error:", researchError);
|
||||
throw researchError;
|
||||
}
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
} finally {
|
||||
setIsResearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateScript = async () => {
|
||||
// Avoid re-triggering script generation preflight
|
||||
if (showScriptEditor) return;
|
||||
if (!project || !research) {
|
||||
setAnnouncement("Project or research missing — cannot generate script");
|
||||
return;
|
||||
}
|
||||
|
||||
// Preflight check before script generation
|
||||
setPreflightOperationName("Script Generation");
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: "gemini",
|
||||
operation_type: "script_generation",
|
||||
tokens_requested: 2000,
|
||||
actual_provider_name: "gemini",
|
||||
});
|
||||
|
||||
if (!preflightResult.can_proceed) {
|
||||
return; // Dialog will be shown by onBlocked callback
|
||||
}
|
||||
|
||||
setScriptData(null);
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
};
|
||||
|
||||
const handleProceedToRendering = (script: Script) => {
|
||||
setScriptData(script);
|
||||
// Initialize render jobs if empty
|
||||
if (renderJobs.length === 0) {
|
||||
script.scenes.forEach((scene) => {
|
||||
updateRenderJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
status: "idle" as const,
|
||||
progress: 0,
|
||||
previewUrl: null,
|
||||
finalUrl: null,
|
||||
jobId: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
setShowRenderQueue(true);
|
||||
setShowScriptEditor(false);
|
||||
};
|
||||
|
||||
const selectedCount = selectedQueries.size;
|
||||
const canGenerateScript = Boolean(project && research && rawResearch);
|
||||
|
||||
const toggleQuery = (id: string) => {
|
||||
if (isResearching) return;
|
||||
const current = selectedQueries;
|
||||
const next = new Set<string>(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedQueries(next);
|
||||
};
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
if (showRenderQueue) return 3;
|
||||
if (showScriptEditor) return 2;
|
||||
if (research) return 1;
|
||||
if (analysis) return 0;
|
||||
return -1;
|
||||
}, [showRenderQueue, showScriptEditor, research, analysis]);
|
||||
|
||||
const steps = [
|
||||
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
|
||||
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
|
||||
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
|
||||
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
|
||||
];
|
||||
|
||||
const handleSelectProject = async (projectId: string) => {
|
||||
const handleSelectProject = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
await loadProjectFromDb(projectId);
|
||||
setShowProjectList(false);
|
||||
} catch (error) {
|
||||
setAnnouncement(`Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
const errorMsg = `Failed to load project: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
// Use workflow's setAnnouncement - workflow is stable from hook
|
||||
workflow.setAnnouncement(errorMsg);
|
||||
}
|
||||
};
|
||||
}, [loadProjectFromDb, workflow.setAnnouncement]);
|
||||
|
||||
const handleNewEpisode = useCallback(() => {
|
||||
resetState();
|
||||
setShowProjectList(false);
|
||||
}, [resetState]);
|
||||
|
||||
if (showProjectList) {
|
||||
return <ProjectList onSelectProject={handleSelectProject} />;
|
||||
@@ -362,147 +98,93 @@ const PodcastDashboard: React.FC = () => {
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
color: "#1e293b",
|
||||
fontWeight: 800,
|
||||
mb: 0.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
|
||||
AI Podcast Maker
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
|
||||
Help
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
|
||||
startIcon={<LibraryMusicIcon />}
|
||||
tooltip="View all podcast episodes in Asset Library"
|
||||
>
|
||||
My Episodes
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => setShowProjectList(true)}
|
||||
startIcon={<MicIcon />}
|
||||
tooltip="View and resume saved projects"
|
||||
>
|
||||
My Projects
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
resetState();
|
||||
setShowProjectList(false);
|
||||
}}
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
>
|
||||
New Episode
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Header onShowProjects={() => setShowProjectList(true)} onNewEpisode={handleNewEpisode} />
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
|
||||
{/* Progress Stepper */}
|
||||
{project && activeStep >= 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 2,
|
||||
{project && workflow.activeStep >= 0 && (
|
||||
<ProgressStepper
|
||||
activeStep={workflow.activeStep}
|
||||
completedSteps={[
|
||||
...(analysis ? [0] : []), // Analysis step
|
||||
...(research ? [1] : []), // Research step
|
||||
...(scriptData ? [2] : []), // Script step
|
||||
...(scriptData && renderJobs.length > 0 ? [3] : []), // Render step (if script exists and has jobs)
|
||||
]}
|
||||
onStepClick={(stepIndex) => {
|
||||
// Navigate to the clicked step
|
||||
// Step indices: 0 = Analysis, 1 = Research, 2 = Script, 3 = Render
|
||||
if (stepIndex === 0) {
|
||||
// Navigate to Analysis
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
setCurrentStep('analysis');
|
||||
} else if (stepIndex === 1) {
|
||||
// Navigate to Research
|
||||
if (!analysis) {
|
||||
workflow.setAnnouncement("Complete Analysis first to access Research.");
|
||||
return;
|
||||
}
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
setCurrentStep('research');
|
||||
} else if (stepIndex === 2) {
|
||||
// Navigate to Script
|
||||
if (!research) {
|
||||
workflow.setAnnouncement("Complete Research first to access Script Editor.");
|
||||
return;
|
||||
}
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
setCurrentStep('script');
|
||||
} else if (stepIndex === 3) {
|
||||
// Navigate to Render
|
||||
if (!scriptData) {
|
||||
workflow.setAnnouncement("Generate and approve script first to access Render Queue.");
|
||||
return;
|
||||
}
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(true);
|
||||
setCurrentStep('render');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stepper activeStep={activeStep} orientation="horizontal" sx={{ "& .MuiStepLabel-root": { cursor: "pointer" } }}>
|
||||
{steps.map((step, index) => (
|
||||
<Step key={step.label} completed={index < activeStep}>
|
||||
<StepLabel
|
||||
StepIconComponent={({ active, completed }) => (
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: completed
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: active
|
||||
? alpha("#667eea", 0.15)
|
||||
: "#e2e8f0",
|
||||
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
|
||||
color: completed || active ? "#fff" : "#64748b",
|
||||
}}
|
||||
>
|
||||
{completed ? <CheckCircleIcon /> : step.icon}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Typography variant="subtitle2">{step.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{step.description}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Resume Alert */}
|
||||
{showResumeAlert && project && (
|
||||
{workflow.showResumeAlert && project && (
|
||||
<Alert
|
||||
severity="success"
|
||||
onClose={() => setShowResumeAlert(false)}
|
||||
onClose={() => workflow.setShowResumeAlert(false)}
|
||||
sx={{
|
||||
background: "#d1fae5",
|
||||
border: "1px solid #a7f3d0",
|
||||
"& .MuiAlert-icon": { color: "#10b981" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>Project Restored:</strong> Resuming from{" "}
|
||||
{currentStep === "analysis"
|
||||
? "Analysis"
|
||||
: currentStep === "research"
|
||||
? "Research"
|
||||
: currentStep === "script"
|
||||
? "Script Editing"
|
||||
: "Rendering"}{" "}
|
||||
step. Your progress has been saved.
|
||||
</Typography>
|
||||
<Box component="span" sx={{ fontSize: "0.875rem" }}>
|
||||
<strong>Project Restored:</strong> Resuming from {getStepLabel(currentStep)} step. Your progress has been saved.
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Announcements */}
|
||||
{announcement && (
|
||||
{workflow.announcement && (
|
||||
<Alert
|
||||
severity="info"
|
||||
onClose={() => setAnnouncement("")}
|
||||
onClose={() => workflow.setAnnouncement("")}
|
||||
sx={{
|
||||
background: "#dbeafe",
|
||||
border: "1px solid #bfdbfe",
|
||||
"& .MuiAlert-icon": { color: "#3b82f6" },
|
||||
}}
|
||||
>
|
||||
{announcement}
|
||||
{workflow.announcement}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(isAnalyzing || isResearching) && (
|
||||
{(workflow.isAnalyzing || workflow.isResearching) && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
icon={<CircularProgress size={20} />}
|
||||
@@ -511,17 +193,21 @@ const PodcastDashboard: React.FC = () => {
|
||||
border: "1px solid #fde68a",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
|
||||
</Typography>
|
||||
<Box component="span" sx={{ fontSize: "0.875rem" }}>
|
||||
{workflow.isAnalyzing ? "Analyzing your idea with AI..." : "Running research... This may take a moment."}
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{!project && (
|
||||
<>
|
||||
<CreateModal open onCreate={handleCreate} defaultKnobs={DEFAULT_KNOBS} isSubmitting={isAnalyzing} />
|
||||
{/* Recent Episodes Preview */}
|
||||
<CreateModal
|
||||
open
|
||||
onCreate={workflow.handleCreate}
|
||||
defaultKnobs={DEFAULT_KNOBS}
|
||||
isSubmitting={workflow.isAnalyzing}
|
||||
/>
|
||||
<RecentEpisodesPreview onSelectEpisode={() => {}} />
|
||||
</>
|
||||
)}
|
||||
@@ -531,217 +217,32 @@ const PodcastDashboard: React.FC = () => {
|
||||
{analysis && !showScriptEditor && !showRenderQueue && (
|
||||
<AnalysisPanel
|
||||
analysis={analysis}
|
||||
onRegenerate={() => setAnalysis({ ...analysis })}
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{estimate && !showScriptEditor && !showRenderQueue && (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
aria-label="estimate"
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<InsightsIcon />
|
||||
Estimated Cost
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
|
||||
${estimate.total.toFixed(2)}
|
||||
</Typography>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Voice narration cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Avatar/video cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Research: $${estimate.researchCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Research and fact-checking cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
<EstimateCard estimate={estimate} />
|
||||
)}
|
||||
|
||||
{queries.length > 0 && !showScriptEditor && !showRenderQueue && (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<SearchIcon />
|
||||
Research Queries
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select
|
||||
value={researchProvider}
|
||||
onChange={(e) => setResearchProvider(e.target.value as ResearchProvider)}
|
||||
label="Provider"
|
||||
disabled={isResearching}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="google">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SearchIcon fontSize="small" />
|
||||
<span>Standard Research</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="exa">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<AutoAwesomeIcon fontSize="small" />
|
||||
<span>Deep Research</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Chip
|
||||
label={`${selectedCount} / ${queries.length} selected`}
|
||||
size="small"
|
||||
color={selectedCount > 0 ? "primary" : "default"}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
researchProvider === "google"
|
||||
? "Standard Research: Fast, fact-checked results with source citations"
|
||||
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
background: "#e0f2fe",
|
||||
border: "1px solid #bae6fd",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a" }}>
|
||||
{researchProvider === "google"
|
||||
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
|
||||
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Tooltip>
|
||||
|
||||
<List>
|
||||
{queries.map((q) => (
|
||||
<ListItem key={q.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => toggleQuery(q.id)}
|
||||
disabled={isResearching}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
background: "#f8fafc",
|
||||
"&:hover": { background: alpha("#667eea", 0.08) },
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
|
||||
<ListItemText
|
||||
primary={q.query}
|
||||
secondary={q.rationale}
|
||||
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
|
||||
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<PrimaryButton
|
||||
onClick={handleRunResearch}
|
||||
disabled={!project || selectedCount === 0 || isResearching}
|
||||
loading={isResearching}
|
||||
startIcon={<SearchIcon />}
|
||||
tooltip={
|
||||
selectedCount === 0
|
||||
? "Select at least one query to run research"
|
||||
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
|
||||
}
|
||||
>
|
||||
{isResearching ? "Running Research..." : "Run Research"}
|
||||
</PrimaryButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
<QuerySelection
|
||||
queries={queries}
|
||||
selectedQueries={selectedQueries}
|
||||
researchProvider={researchProvider}
|
||||
isResearching={workflow.isResearching}
|
||||
onToggleQuery={workflow.toggleQuery}
|
||||
onProviderChange={setResearchProvider}
|
||||
onRunResearch={workflow.handleRunResearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{research && !showScriptEditor && !showRenderQueue && (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
|
||||
<InsightsIcon />
|
||||
Research Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{research.summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={handleGenerateScript}
|
||||
disabled={!canGenerateScript}
|
||||
startIcon={<EditNoteIcon />}
|
||||
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
|
||||
>
|
||||
Generate Script
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
{research.factCards.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Fact Cards ({research.factCards.length})
|
||||
</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
{research.factCards.map((fact) => (
|
||||
<FactCard key={fact.id} fact={fact} />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
<ResearchSummary
|
||||
research={research}
|
||||
canGenerateScript={workflow.canGenerateScript}
|
||||
onGenerateScript={workflow.handleGenerateScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScriptEditor && project && research && rawResearch && (
|
||||
@@ -756,8 +257,8 @@ const PodcastDashboard: React.FC = () => {
|
||||
script={scriptData}
|
||||
onScriptChange={(s) => setScriptData(s)}
|
||||
onBackToResearch={() => setShowScriptEditor(false)}
|
||||
onProceedToRendering={(s) => handleProceedToRendering(s)}
|
||||
onError={(msg) => setAnnouncement(msg)}
|
||||
onProceedToRendering={(s) => workflow.handleProceedToRendering(s)}
|
||||
onError={(msg) => workflow.setAnnouncement(msg)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -776,11 +277,12 @@ const PodcastDashboard: React.FC = () => {
|
||||
budgetCap={projectState.budgetCap}
|
||||
avatarImageUrl={null}
|
||||
onUpdateJob={updateRenderJob}
|
||||
onUpdateScript={(updatedScript) => setScriptData(updatedScript)}
|
||||
onBack={() => {
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
}}
|
||||
onError={(msg) => setAnnouncement(msg)}
|
||||
onError={(msg) => workflow.setAnnouncement(msg)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -789,13 +291,13 @@ const PodcastDashboard: React.FC = () => {
|
||||
|
||||
{/* Preflight Block Dialog */}
|
||||
<PreflightBlockDialog
|
||||
open={showPreflightDialog}
|
||||
open={workflow.showPreflightDialog}
|
||||
onClose={() => {
|
||||
setShowPreflightDialog(false);
|
||||
setPreflightResponse(null);
|
||||
workflow.setShowPreflightDialog(false);
|
||||
workflow.setPreflightResponse(null);
|
||||
}}
|
||||
response={preflightResponse}
|
||||
operationName={preflightOperationName}
|
||||
response={workflow.preflightResponse}
|
||||
operationName={workflow.preflightOperationName}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { Stack, Typography, Chip, Divider } from "@mui/material";
|
||||
import { Insights as InsightsIcon } from "@mui/icons-material";
|
||||
import { PodcastEstimate } from "../types";
|
||||
import { GlassyCard, glassyCardSx } from "../ui";
|
||||
|
||||
interface EstimateCardProps {
|
||||
estimate: PodcastEstimate;
|
||||
}
|
||||
|
||||
export const EstimateCard: React.FC<EstimateCardProps> = ({ estimate }) => {
|
||||
return (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
aria-label="estimate"
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<InsightsIcon />
|
||||
Estimated Cost
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ color: "#4f46e5", fontWeight: 800 }}>
|
||||
${estimate.total.toFixed(2)}
|
||||
</Typography>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Voice narration cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Avatar/video cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Research: $${estimate.researchCost.toFixed(2)}`}
|
||||
size="small"
|
||||
title="Research and fact-checking cost"
|
||||
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import {
|
||||
Mic as MicIcon,
|
||||
Info as InfoIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
LibraryMusic as LibraryMusicIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
import HeaderControls from "../../shared/HeaderControls";
|
||||
|
||||
interface HeaderProps {
|
||||
onShowProjects: () => void;
|
||||
onNewEpisode: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
color: "#1e293b",
|
||||
fontWeight: 800,
|
||||
mb: 0.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<MicIcon fontSize="large" sx={{ color: "#667eea" }} />
|
||||
AI Podcast Maker
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Create professional podcast episodes with AI-powered research, smart scriptwriting, and natural voice narration
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
<SecondaryButton onClick={() => window.open("/docs", "_blank")} startIcon={<InfoIcon />}>
|
||||
Help
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
|
||||
startIcon={<LibraryMusicIcon />}
|
||||
tooltip="View all podcast episodes in Asset Library"
|
||||
>
|
||||
My Episodes
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={onShowProjects}
|
||||
startIcon={<MicIcon />}
|
||||
tooltip="View and resume saved projects"
|
||||
>
|
||||
My Projects
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={onNewEpisode} startIcon={<AutoAwesomeIcon />}>
|
||||
New Episode
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from "react";
|
||||
import { Box, Paper, Stepper, Step, StepLabel, Typography, alpha } from "@mui/material";
|
||||
import {
|
||||
Psychology as PsychologyIcon,
|
||||
Search as SearchIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
interface ProgressStepperProps {
|
||||
activeStep: number;
|
||||
completedSteps?: number[]; // Steps that have been completed (have data)
|
||||
onStepClick?: (stepIndex: number) => void;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ label: "Analysis", icon: <PsychologyIcon />, description: "AI analyzes your idea" },
|
||||
{ label: "Research", icon: <SearchIcon />, description: "Gather facts and citations" },
|
||||
{ label: "Script", icon: <EditNoteIcon />, description: "Edit and approve scenes" },
|
||||
{ label: "Render", icon: <PlayArrowIcon />, description: "Generate audio files" },
|
||||
];
|
||||
|
||||
export const ProgressStepper: React.FC<ProgressStepperProps> = ({ activeStep, completedSteps = [], onStepClick }) => {
|
||||
if (activeStep < 0) return null;
|
||||
|
||||
const handleStepClick = (stepIndex: number) => {
|
||||
// Allow navigation to any completed step (has data), not just steps before active step
|
||||
const isCompleted = completedSteps.includes(stepIndex);
|
||||
if (isCompleted && onStepClick) {
|
||||
onStepClick(stepIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stepper activeStep={activeStep} orientation="horizontal">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = completedSteps.includes(index);
|
||||
const isClickable = isCompleted && onStepClick !== undefined;
|
||||
|
||||
return (
|
||||
<Step key={step.label} completed={isCompleted}>
|
||||
<StepLabel
|
||||
onClick={() => handleStepClick(index)}
|
||||
sx={{
|
||||
cursor: isClickable ? "pointer" : "default",
|
||||
"&:hover": isClickable
|
||||
? {
|
||||
"& .MuiStepLabel-label": {
|
||||
color: "#667eea",
|
||||
},
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
StepIconComponent={({ active, completed }) => (
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: completed
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: active
|
||||
? alpha("#667eea", 0.15)
|
||||
: "#e2e8f0",
|
||||
border: active ? "2px solid #667eea" : "1px solid rgba(0,0,0,0.1)",
|
||||
color: completed || active ? "#fff" : "#64748b",
|
||||
transition: "all 0.2s ease",
|
||||
...(isClickable && {
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
transform: "scale(1.05)",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{completed ? <CheckCircleIcon /> : step.icon}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<Typography variant="subtitle2">{step.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{step.description}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Box,
|
||||
alpha,
|
||||
} from "@mui/material";
|
||||
import { Search as SearchIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material";
|
||||
import { ResearchProvider } from "../../../services/blogWriterApi";
|
||||
import { Query } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
|
||||
interface QuerySelectionProps {
|
||||
queries: Query[];
|
||||
selectedQueries: Set<string>;
|
||||
researchProvider: ResearchProvider;
|
||||
isResearching: boolean;
|
||||
onToggleQuery: (id: string) => void;
|
||||
onProviderChange: (provider: ResearchProvider) => void;
|
||||
onRunResearch: () => void;
|
||||
}
|
||||
|
||||
export const QuerySelection: React.FC<QuerySelectionProps> = ({
|
||||
queries,
|
||||
selectedQueries,
|
||||
researchProvider,
|
||||
isResearching,
|
||||
onToggleQuery,
|
||||
onProviderChange,
|
||||
onRunResearch,
|
||||
}) => {
|
||||
const selectedCount = selectedQueries.size;
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
<SearchIcon />
|
||||
Research Queries
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select
|
||||
value={researchProvider}
|
||||
onChange={(e) => onProviderChange(e.target.value as ResearchProvider)}
|
||||
label="Provider"
|
||||
disabled={isResearching}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="google">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SearchIcon fontSize="small" />
|
||||
<span>Standard Research</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="exa">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<AutoAwesomeIcon fontSize="small" />
|
||||
<span>Deep Research</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Chip
|
||||
label={`${selectedCount} / ${queries.length} selected`}
|
||||
size="small"
|
||||
color={selectedCount > 0 ? "primary" : "default"}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
researchProvider === "google"
|
||||
? "Standard Research: Fast, fact-checked results with source citations"
|
||||
: "Deep Research: Comprehensive analysis with competitor insights and trending topics"
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
background: "#e0f2fe",
|
||||
border: "1px solid #bae6fd",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: "#0f172a" }}>
|
||||
{researchProvider === "google"
|
||||
? "Select at least one query (recommended: 3+ for balanced coverage). Standard research provides fact-checked results with source citations."
|
||||
: "Select queries for deep research. This mode provides comprehensive analysis with competitor insights and trending topics."}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Tooltip>
|
||||
|
||||
<List>
|
||||
{queries.map((q) => (
|
||||
<ListItem key={q.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => onToggleQuery(q.id)}
|
||||
disabled={isResearching}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
background: "#f8fafc",
|
||||
"&:hover": { background: alpha("#667eea", 0.08) },
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selectedQueries.has(q.id)} edge="start" />
|
||||
<ListItemText
|
||||
primary={q.query}
|
||||
secondary={q.rationale}
|
||||
primaryTypographyProps={{ variant: "body2", fontWeight: 600, color: "#0f172a" }}
|
||||
secondaryTypographyProps={{ variant: "caption", sx: { color: "#475569" } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<PrimaryButton
|
||||
onClick={onRunResearch}
|
||||
disabled={selectedCount === 0 || isResearching}
|
||||
loading={isResearching}
|
||||
startIcon={<SearchIcon />}
|
||||
tooltip={
|
||||
selectedCount === 0
|
||||
? "Select at least one query to run research"
|
||||
: `Run research with ${selectedCount} selected ${selectedCount === 1 ? "query" : "queries"}`
|
||||
}
|
||||
>
|
||||
{isResearching ? "Running Research..." : "Run Research"}
|
||||
</PrimaryButton>
|
||||
</Box>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import { Stack, Typography, Chip, Divider, Box, alpha } from "@mui/material";
|
||||
import {
|
||||
Insights as InsightsIcon,
|
||||
Search as SearchIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
EditNote as EditNoteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Research } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { FactCard } from "../FactCard";
|
||||
|
||||
interface ResearchSummaryProps {
|
||||
research: Research;
|
||||
canGenerateScript: boolean;
|
||||
onGenerateScript: () => void;
|
||||
}
|
||||
|
||||
export const ResearchSummary: React.FC<ResearchSummaryProps> = ({
|
||||
research,
|
||||
canGenerateScript,
|
||||
onGenerateScript,
|
||||
}) => {
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||
<InsightsIcon />
|
||||
Research Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, lineHeight: 1.7 }}>
|
||||
{research.summary}
|
||||
</Typography>
|
||||
|
||||
{/* Research Metadata */}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
|
||||
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||
<Chip
|
||||
icon={<SearchIcon />}
|
||||
label={`${research.searchQueries.length} search${research.searchQueries.length > 1 ? "es" : ""}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#667eea", 0.1),
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{research.searchType && (
|
||||
<Chip
|
||||
label={`${research.searchType.charAt(0).toUpperCase() + research.searchType.slice(1)} search`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#10b981", 0.1),
|
||||
color: "#059669",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(16, 185, 129, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{research.sourceCount !== undefined && (
|
||||
<Chip
|
||||
label={`${research.sourceCount} source${research.sourceCount !== 1 ? "s" : ""}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#6366f1", 0.1),
|
||||
color: "#4f46e5",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(99, 102, 241, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{research.cost !== undefined && (
|
||||
<Chip
|
||||
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||
label={`$${research.cost.toFixed(3)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha("#f59e0b", 0.1),
|
||||
color: "#d97706",
|
||||
fontWeight: 600,
|
||||
border: "1px solid rgba(245, 158, 11, 0.2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Search Queries Used */}
|
||||
{research.searchQueries && research.searchQueries.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 600 }}>
|
||||
Search Queries Used
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{research.searchQueries.map((query, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={query}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: "rgba(102, 126, 234, 0.3)",
|
||||
color: "#475569",
|
||||
background: alpha("#f8fafc", 0.8),
|
||||
fontSize: "0.8125rem",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={onGenerateScript}
|
||||
disabled={!canGenerateScript}
|
||||
startIcon={<EditNoteIcon />}
|
||||
tooltip={!canGenerateScript ? "Complete research to generate script" : "Generate AI-powered script from research"}
|
||||
>
|
||||
Generate Script
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
|
||||
{research.factCards.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.08)" }} />
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600 }}>
|
||||
Research Sources & Facts ({research.factCards.length})
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
Click any card to view source details
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", lg: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
{research.factCards.map((fact) => (
|
||||
<FactCard key={fact.id} fact={fact} />
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Header } from "./Header";
|
||||
export { ProgressStepper } from "./ProgressStepper";
|
||||
export { EstimateCard } from "./EstimateCard";
|
||||
export { QuerySelection } from "./QuerySelection";
|
||||
export { ResearchSummary } from "./ResearchSummary";
|
||||
export { usePodcastWorkflow } from "./usePodcastWorkflow";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { ResearchProvider, ResearchConfig } from "../../../services/blogWriterApi";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { usePreflightCheck } from "../../../hooks/usePreflightCheck";
|
||||
import { useBudgetTracking } from "../../../hooks/useBudgetTracking";
|
||||
import { CreateProjectPayload, Query, Research, Script, Job } from "../types";
|
||||
import { usePodcastProjectState } from "../../../hooks/usePodcastProjectState";
|
||||
import { sanitizeExaConfig, announceError, getStepLabel } from "./utils";
|
||||
|
||||
type PodcastProjectStateReturn = ReturnType<typeof usePodcastProjectState>;
|
||||
|
||||
interface UsePodcastWorkflowProps {
|
||||
projectState: PodcastProjectStateReturn;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export const usePodcastWorkflow = ({ projectState, onError }: UsePodcastWorkflowProps) => {
|
||||
const {
|
||||
project,
|
||||
analysis,
|
||||
queries,
|
||||
selectedQueries,
|
||||
research,
|
||||
rawResearch,
|
||||
researchProvider,
|
||||
showScriptEditor,
|
||||
showRenderQueue,
|
||||
currentStep,
|
||||
renderJobs,
|
||||
budgetCap,
|
||||
setProject,
|
||||
setAnalysis,
|
||||
setQueries,
|
||||
setSelectedQueries,
|
||||
setResearch,
|
||||
setRawResearch,
|
||||
setEstimate,
|
||||
setScriptData,
|
||||
setShowScriptEditor,
|
||||
setShowRenderQueue,
|
||||
setKnobs,
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
updateRenderJob,
|
||||
initializeProject,
|
||||
} = projectState;
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [showResumeAlert, setShowResumeAlert] = useState(false);
|
||||
const [showPreflightDialog, setShowPreflightDialog] = useState(false);
|
||||
const [preflightResponse, setPreflightResponse] = useState<any>(null);
|
||||
const [preflightOperationName, setPreflightOperationName] = useState<string>("");
|
||||
|
||||
const budgetTracking = useBudgetTracking(budgetCap || 50);
|
||||
const preflightCheck = usePreflightCheck({
|
||||
onBlocked: (response) => {
|
||||
setPreflightResponse(response);
|
||||
setShowPreflightDialog(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Update budget cap when project state changes
|
||||
useEffect(() => {
|
||||
if (budgetCap) {
|
||||
budgetTracking.setBudgetCap(budgetCap);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [budgetCap]);
|
||||
|
||||
// Check if we have a saved project on mount
|
||||
useEffect(() => {
|
||||
if (project && currentStep && currentStep !== "create") {
|
||||
setShowResumeAlert(true);
|
||||
setTimeout(() => setShowResumeAlert(false), 5000);
|
||||
}
|
||||
}, []); // Only on mount
|
||||
|
||||
useEffect(() => {
|
||||
if (announcement) {
|
||||
const t = setTimeout(() => setAnnouncement(""), 4000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [announcement]);
|
||||
|
||||
const handleCreate = useCallback(async (payload: CreateProjectPayload) => {
|
||||
if (isAnalyzing) return;
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
setScriptData(null);
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setAnnouncement("Analyzing your idea — AI suggestions incoming");
|
||||
const result = await podcastApi.createProject(payload);
|
||||
await initializeProject(payload, result.projectId);
|
||||
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
|
||||
setAnalysis(result.analysis);
|
||||
setEstimate(result.estimate);
|
||||
setQueries(result.queries);
|
||||
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
|
||||
setKnobs(payload.knobs);
|
||||
setBudgetCap(payload.budgetCap);
|
||||
setAnnouncement("Analysis complete");
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 429 || error?.response?.data?.detail) {
|
||||
const errorDetail = error.response.data.detail;
|
||||
if (typeof errorDetail === 'object' && errorDetail.error && errorDetail.error.includes('limit')) {
|
||||
const usageInfo = errorDetail.usage_info || {};
|
||||
const blockedResponse = {
|
||||
can_proceed: false,
|
||||
estimated_cost: 0,
|
||||
operations: [{
|
||||
provider: errorDetail.provider || 'huggingface',
|
||||
operation_type: 'ai_text_generation',
|
||||
cost: 0,
|
||||
allowed: false,
|
||||
limit_info: usageInfo.limit_info || null,
|
||||
message: errorDetail.message || errorDetail.error || 'Subscription limit exceeded',
|
||||
}],
|
||||
total_cost: 0,
|
||||
usage_summary: usageInfo.usage_summary || null,
|
||||
cached: false,
|
||||
};
|
||||
setPreflightResponse(blockedResponse);
|
||||
setPreflightOperationName('Podcast Analysis');
|
||||
setShowPreflightDialog(true);
|
||||
setAnnouncement("Subscription limit reached. Please upgrade to continue.");
|
||||
} else {
|
||||
const message = typeof errorDetail === 'string' ? errorDetail : errorDetail.message || errorDetail.error || 'Request limit exceeded';
|
||||
announceError(setAnnouncement, new Error(message));
|
||||
}
|
||||
} else {
|
||||
announceError(setAnnouncement, error);
|
||||
}
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [isAnalyzing, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue, initializeProject, setProject, setAnalysis, setEstimate, setQueries, setSelectedQueries, setKnobs, setBudgetCap]);
|
||||
|
||||
const handleRunResearch = useCallback(async () => {
|
||||
if (isResearching) return;
|
||||
if (!project) {
|
||||
setAnnouncement("Create a project first.");
|
||||
return;
|
||||
}
|
||||
if (selectedQueries.size === 0) {
|
||||
setAnnouncement("Select at least one query to research.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPreflightOperationName("Research");
|
||||
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: researchProvider === "exa" ? "exa" : "gemini",
|
||||
operation_type: researchProvider === "exa" ? "exa_neural_search" : "google_grounding",
|
||||
tokens_requested: researchProvider === "exa" ? 0 : 1200,
|
||||
actual_provider_name: researchProvider || "exa",
|
||||
});
|
||||
|
||||
if (!preflightResult.can_proceed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsResearching(true);
|
||||
setAnnouncement(`Starting ${researchProvider === "exa" ? "deep" : "standard"} research — this may take a moment...`);
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
setScriptData(null);
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
|
||||
try {
|
||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
||||
projectId: project.id,
|
||||
topic: project.idea,
|
||||
approvedQueries,
|
||||
provider: researchProvider,
|
||||
exaConfig: sanitizeExaConfig(analysis?.exaSuggestedConfig),
|
||||
onProgress: (message) => {
|
||||
setAnnouncement(message);
|
||||
},
|
||||
});
|
||||
setResearch(mapped);
|
||||
setRawResearch(raw);
|
||||
setAnnouncement("Research complete — review fact cards below");
|
||||
} catch (researchError) {
|
||||
const errorMessage = researchError instanceof Error
|
||||
? researchError.message
|
||||
: "Research failed. Please try again or switch to Standard Research.";
|
||||
|
||||
if (errorMessage.includes("Exa") || errorMessage.includes("exa")) {
|
||||
setAnnouncement(`Deep research failed: ${errorMessage}. Try Standard Research instead.`);
|
||||
} else if (errorMessage.includes("timeout")) {
|
||||
setAnnouncement("Research timed out. Please try again with fewer queries.");
|
||||
} else {
|
||||
setAnnouncement(`Research failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
console.error("Research error:", researchError);
|
||||
throw researchError;
|
||||
}
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
} finally {
|
||||
setIsResearching(false);
|
||||
}
|
||||
}, [isResearching, project, selectedQueries, queries, researchProvider, preflightCheck, analysis, setResearch, setRawResearch, setScriptData, setShowScriptEditor, setShowRenderQueue]);
|
||||
|
||||
const handleGenerateScript = useCallback(async () => {
|
||||
if (showScriptEditor) return;
|
||||
if (!project || !research) {
|
||||
setAnnouncement("Project or research missing — cannot generate script");
|
||||
return;
|
||||
}
|
||||
|
||||
setPreflightOperationName("Script Generation");
|
||||
const preflightResult = await preflightCheck.check({
|
||||
provider: "gemini",
|
||||
operation_type: "script_generation",
|
||||
tokens_requested: 2000,
|
||||
actual_provider_name: "gemini",
|
||||
});
|
||||
|
||||
if (!preflightResult.can_proceed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScriptData(null);
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
}, [showScriptEditor, project, research, preflightCheck, setScriptData, setShowRenderQueue, setShowScriptEditor]);
|
||||
|
||||
const handleProceedToRendering = useCallback((script: Script) => {
|
||||
setScriptData(script);
|
||||
if (renderJobs.length === 0) {
|
||||
script.scenes.forEach((scene) => {
|
||||
const hasExistingAudio = Boolean(scene.audioUrl);
|
||||
updateRenderJob(scene.id, {
|
||||
sceneId: scene.id,
|
||||
title: scene.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? scene.audioUrl : null,
|
||||
jobId: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
setShowRenderQueue(true);
|
||||
setShowScriptEditor(false);
|
||||
}, [renderJobs.length, setScriptData, updateRenderJob, setShowRenderQueue, setShowScriptEditor]);
|
||||
|
||||
const toggleQuery = useCallback((id: string) => {
|
||||
if (isResearching) return;
|
||||
const current = selectedQueries;
|
||||
const next = new Set<string>(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedQueries(next);
|
||||
}, [isResearching, selectedQueries, setSelectedQueries]);
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
if (showRenderQueue) return 3;
|
||||
if (showScriptEditor) return 2;
|
||||
if (research) return 1;
|
||||
if (analysis) return 0;
|
||||
return -1;
|
||||
}, [showRenderQueue, showScriptEditor, research, analysis]);
|
||||
|
||||
const canGenerateScript = Boolean(project && research && rawResearch);
|
||||
|
||||
return {
|
||||
// State
|
||||
isAnalyzing,
|
||||
isResearching,
|
||||
announcement,
|
||||
showResumeAlert,
|
||||
showPreflightDialog,
|
||||
preflightResponse,
|
||||
preflightOperationName,
|
||||
activeStep,
|
||||
canGenerateScript,
|
||||
// Handlers
|
||||
handleCreate,
|
||||
handleRunResearch,
|
||||
handleGenerateScript,
|
||||
handleProceedToRendering,
|
||||
toggleQuery,
|
||||
setAnnouncement,
|
||||
setShowResumeAlert,
|
||||
setShowPreflightDialog,
|
||||
setPreflightResponse,
|
||||
setResearchProvider,
|
||||
getStepLabel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ResearchConfig, DateRange } from "../../../services/blogWriterApi";
|
||||
import { CreateProjectPayload, Knobs } from "../types";
|
||||
|
||||
export const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
bitrate: "standard",
|
||||
};
|
||||
|
||||
export const allowedDateRanges: DateRange[] = [
|
||||
"last_week",
|
||||
"last_month",
|
||||
"last_3_months",
|
||||
"last_6_months",
|
||||
"last_year",
|
||||
"all_time",
|
||||
];
|
||||
|
||||
export const sanitizeExaConfig = (
|
||||
exa?: CreateProjectPayload["knobs"] & any & { exa_suggested_config?: any } | any
|
||||
): ResearchConfig | undefined => {
|
||||
if (!exa) return undefined;
|
||||
const cfg = exa as {
|
||||
exa_search_type?: "auto" | "keyword" | "neural";
|
||||
exa_category?: string;
|
||||
exa_include_domains?: string[];
|
||||
exa_exclude_domains?: string[];
|
||||
max_sources?: number;
|
||||
include_statistics?: boolean;
|
||||
date_range?: string;
|
||||
};
|
||||
|
||||
const searchType: ResearchConfig["exa_search_type"] =
|
||||
cfg.exa_search_type && ["auto", "keyword", "neural"].includes(cfg.exa_search_type)
|
||||
? cfg.exa_search_type
|
||||
: undefined;
|
||||
|
||||
const dateRange: DateRange | undefined = cfg.date_range && allowedDateRanges.includes(cfg.date_range as DateRange)
|
||||
? (cfg.date_range as DateRange)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
provider: "exa",
|
||||
exa_search_type: searchType,
|
||||
exa_category: cfg.exa_category,
|
||||
exa_include_domains: cfg.exa_include_domains,
|
||||
exa_exclude_domains: cfg.exa_exclude_domains,
|
||||
max_sources: cfg.max_sources,
|
||||
include_statistics: cfg.include_statistics,
|
||||
date_range: dateRange,
|
||||
};
|
||||
};
|
||||
|
||||
export const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||
setAnnouncement(message);
|
||||
};
|
||||
|
||||
export const getStepLabel = (step: string | null): string => {
|
||||
switch (step) {
|
||||
case "analysis":
|
||||
return "Analysis";
|
||||
case "research":
|
||||
return "Research";
|
||||
case "script":
|
||||
return "Script Editing";
|
||||
case "render":
|
||||
return "Rendering";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, Button, CircularProgress, alpha } from "@mui/material";
|
||||
import React, { useCallback } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
|
||||
import {
|
||||
PlayArrow as PlayArrowIcon,
|
||||
ArrowBack as ArrowBackIcon,
|
||||
VolumeUp as VolumeUpIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||
Info as InfoIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Download as DownloadIcon,
|
||||
Share as ShareIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Videocam as VideocamIcon,
|
||||
Cancel as CancelIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "./types";
|
||||
import { podcastApi } from "../../services/podcastApi";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
|
||||
import { InlineAudioPlayer } from "./InlineAudioPlayer";
|
||||
import { Script, Knobs, Job } from "./types";
|
||||
import { SecondaryButton } from "./ui";
|
||||
import { SceneCard } from "./RenderQueue/SceneCard";
|
||||
import { SummaryStats } from "./RenderQueue/SummaryStats";
|
||||
import { GuidancePanel } from "./RenderQueue/GuidancePanel";
|
||||
import { useRenderQueue } from "./RenderQueue/useRenderQueue";
|
||||
|
||||
interface RenderQueueProps {
|
||||
projectId: string;
|
||||
@@ -27,307 +19,81 @@ interface RenderQueueProps {
|
||||
budgetCap?: number;
|
||||
avatarImageUrl?: string | null;
|
||||
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
|
||||
onUpdateScript?: (script: Script) => void;
|
||||
onBack: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral";
|
||||
export const RenderQueue: React.FC<RenderQueueProps> = ({
|
||||
projectId,
|
||||
script,
|
||||
knobs,
|
||||
jobs,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
onUpdateScript,
|
||||
onBack,
|
||||
onError,
|
||||
}) => {
|
||||
const {
|
||||
rendering,
|
||||
generatingImage,
|
||||
isBusy,
|
||||
runRender,
|
||||
runImageGeneration,
|
||||
runVideoRender,
|
||||
} = useRenderQueue({
|
||||
script,
|
||||
jobs,
|
||||
knobs,
|
||||
projectId,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
onUpdateScript,
|
||||
onError,
|
||||
});
|
||||
|
||||
export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, knobs, jobs, budgetCap, avatarImageUrl, onUpdateJob, onBack, onError }) => {
|
||||
const [rendering, setRendering] = useState<string | null>(null);
|
||||
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const isBusy = Boolean(rendering);
|
||||
|
||||
// Cleanup polling intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pollingIntervals.current.forEach((interval) => clearInterval(interval));
|
||||
pollingIntervals.current.clear();
|
||||
};
|
||||
const handleDownloadAudio = useCallback((audioUrl: string, title: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = audioUrl;
|
||||
link.download = `${title.replace(/\s+/g, "-")}.mp3`;
|
||||
link.click();
|
||||
}, []);
|
||||
|
||||
// Initialize jobs if empty
|
||||
useEffect(() => {
|
||||
if (jobs.length === 0 && script.scenes.length > 0) {
|
||||
const initialJobs: Job[] = script.scenes.map((s) => ({
|
||||
sceneId: s.id,
|
||||
title: s.title,
|
||||
status: "idle" as const,
|
||||
progress: 0,
|
||||
previewUrl: null,
|
||||
finalUrl: null,
|
||||
jobId: null,
|
||||
}));
|
||||
// Update all jobs at once
|
||||
initialJobs.forEach((job) => {
|
||||
onUpdateJob(job.sceneId, job);
|
||||
});
|
||||
}
|
||||
}, [script.scenes.length, jobs.length, onUpdateJob]);
|
||||
const handleDownloadVideo = useCallback((videoUrl: string, title: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = videoUrl;
|
||||
link.download = `${title.replace(/\s+/g, "-")}.mp4`;
|
||||
link.click();
|
||||
}, []);
|
||||
|
||||
const getScene = (sceneId: string) => script.scenes.find((s) => s.id === sceneId);
|
||||
|
||||
const pollTaskStatus = async (taskId: string, sceneId: string) => {
|
||||
try {
|
||||
const status: TaskStatus = await podcastApi.pollTaskStatus(taskId);
|
||||
|
||||
onUpdateJob(sceneId, {
|
||||
progress: status.progress ?? 0,
|
||||
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
|
||||
});
|
||||
|
||||
if (status.status === "completed" && status.result) {
|
||||
const result = status.result;
|
||||
const updates: Partial<Job> = {
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
videoUrl: result.video_url,
|
||||
cost: result.cost,
|
||||
};
|
||||
onUpdateJob(sceneId, updates);
|
||||
|
||||
// Clear polling interval
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
} else if (status.status === "failed") {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
|
||||
// Clear polling interval
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
|
||||
onError(status.error || "Video generation failed");
|
||||
}
|
||||
|
||||
return status.status === "completed" || status.status === "failed";
|
||||
} catch (error) {
|
||||
console.error("Error polling task status:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = (taskId: string, sceneId: string) => {
|
||||
// Clear any existing interval for this scene
|
||||
const existingInterval = pollingIntervals.current.get(sceneId);
|
||||
if (existingInterval) {
|
||||
clearInterval(existingInterval);
|
||||
}
|
||||
|
||||
// Poll every 3 seconds
|
||||
const interval = setInterval(async () => {
|
||||
const isComplete = await pollTaskStatus(taskId, sceneId);
|
||||
if (isComplete) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
pollingIntervals.current.set(sceneId, interval);
|
||||
};
|
||||
|
||||
const cancelRender = async (sceneId: string) => {
|
||||
const job = jobs.find((j) => j.sceneId === sceneId);
|
||||
if (job?.taskId) {
|
||||
const handleShare = useCallback(async (audioUrl: string, title: string) => {
|
||||
if (navigator.share && audioUrl) {
|
||||
try {
|
||||
await podcastApi.cancelTask(job.taskId);
|
||||
onUpdateJob(sceneId, { status: "cancelled", progress: 0 });
|
||||
|
||||
// Clear polling interval
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cancelling task:", error);
|
||||
onError("Failed to cancel render job");
|
||||
await navigator.share({
|
||||
title,
|
||||
text: `Check out this podcast episode: ${title}`,
|
||||
url: audioUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
// User cancelled or error
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
await navigator.clipboard.writeText(audioUrl);
|
||||
alert("Audio URL copied to clipboard!");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runRender = async (sceneId: string, mode: "preview" | "full") => {
|
||||
// Prevent double-fire while another render is in-flight
|
||||
if (rendering && rendering !== sceneId) return;
|
||||
const job = jobs.find((j) => j.sceneId === sceneId);
|
||||
if (job && job.status !== "idle") return;
|
||||
const scene = getScene(sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
// Estimate cost (rough estimate: ~$0.05 per 1000 chars)
|
||||
const textLength = scene.lines.map((l) => l.text).join(" ").length;
|
||||
const estimatedCost = (textLength / 1000) * 0.05;
|
||||
|
||||
// Check budget cap if provided
|
||||
if (budgetCap && budgetCap > 0) {
|
||||
const currentSpent = jobs
|
||||
.filter((j) => j.status === "completed" && j.cost)
|
||||
.reduce((sum, j) => sum + (j.cost || 0), 0);
|
||||
|
||||
if (currentSpent + estimatedCost > budgetCap) {
|
||||
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(4)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setRendering(sceneId);
|
||||
onUpdateJob(sceneId, {
|
||||
status: mode === "preview" ? "previewing" : "running",
|
||||
progress: mode === "preview" ? 25 : 40,
|
||||
});
|
||||
try {
|
||||
const result: RenderJobResult = await podcastApi.renderSceneAudio({
|
||||
scene,
|
||||
voiceId: "Wise_Woman",
|
||||
emotion: getSceneVoiceEmotion(knobs),
|
||||
speed: knobs.voice_speed,
|
||||
});
|
||||
|
||||
const updates: Partial<Job> = {
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
cost: result.cost,
|
||||
provider: result.provider,
|
||||
voiceId: result.voiceId,
|
||||
fileSize: result.fileSize,
|
||||
};
|
||||
|
||||
if (mode === "preview") {
|
||||
updates.previewUrl = result.audioUrl;
|
||||
window.open(result.audioUrl, "_blank");
|
||||
} else {
|
||||
updates.finalUrl = result.audioUrl;
|
||||
|
||||
// Save to asset library when final render completes
|
||||
try {
|
||||
await podcastApi.saveAudioToAssetLibrary({
|
||||
audioUrl: result.audioUrl,
|
||||
filename: result.audioFilename,
|
||||
title: `${script.scenes.find((s) => s.id === sceneId)?.title || "Scene"} - ${projectId}`,
|
||||
description: `Podcast episode scene audio: ${scene.title}`,
|
||||
projectId,
|
||||
sceneId,
|
||||
cost: result.cost,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
fileSize: result.fileSize,
|
||||
});
|
||||
} catch (assetError) {
|
||||
console.error("Failed to save to asset library:", assetError);
|
||||
// Don't fail the render if asset save fails
|
||||
}
|
||||
}
|
||||
|
||||
onUpdateJob(sceneId, updates);
|
||||
} catch (error) {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const message = error instanceof Error ? error.message : "Render failed";
|
||||
onError(message);
|
||||
} finally {
|
||||
setRendering(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runVideoRender = async (sceneId: string) => {
|
||||
// Prevent double-fire while another render is in-flight
|
||||
if (rendering && rendering !== sceneId) return;
|
||||
const scene = getScene(sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
if (!avatarImageUrl) {
|
||||
onError("Avatar image is required for video generation. Please upload an avatar image in project settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobs.find((j) => j.sceneId === sceneId);
|
||||
if (!job?.finalUrl) {
|
||||
onError("Please generate audio first before creating video.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Estimate cost (video generation is ~$0.30 per 5 seconds at 720p)
|
||||
const estimatedCost = 0.30; // Base cost per video
|
||||
|
||||
// Check budget cap if provided
|
||||
if (budgetCap && budgetCap > 0) {
|
||||
const currentSpent = jobs
|
||||
.filter((j) => j.status === "completed" && j.cost)
|
||||
.reduce((sum, j) => sum + (j.cost || 0), 0);
|
||||
|
||||
if (currentSpent + estimatedCost > budgetCap) {
|
||||
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(2)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setRendering(sceneId);
|
||||
onUpdateJob(sceneId, {
|
||||
status: "running",
|
||||
progress: 5,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await podcastApi.generateVideo({
|
||||
projectId,
|
||||
sceneId,
|
||||
sceneTitle: scene.title,
|
||||
audioUrl: job.finalUrl,
|
||||
avatarImageUrl: avatarImageUrl,
|
||||
resolution: knobs.resolution || "720p",
|
||||
});
|
||||
|
||||
// Start polling for video generation status
|
||||
onUpdateJob(sceneId, {
|
||||
taskId: result.taskId,
|
||||
status: "running",
|
||||
progress: 5,
|
||||
});
|
||||
|
||||
startPolling(result.taskId, sceneId);
|
||||
} catch (error) {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const message = error instanceof Error ? error.message : "Video generation failed";
|
||||
onError(message);
|
||||
} finally {
|
||||
setRendering(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Job["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "failed":
|
||||
return "error";
|
||||
case "running":
|
||||
case "previewing":
|
||||
return "info";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: Job["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircleIcon />;
|
||||
case "failed":
|
||||
return <InfoIcon />;
|
||||
case "running":
|
||||
case "previewing":
|
||||
return <CircularProgress size={16} />;
|
||||
default:
|
||||
return <RadioButtonUncheckedIcon />;
|
||||
}
|
||||
};
|
||||
const allScenesCompleted =
|
||||
(jobs.length > 0 && jobs.every((j) => j.status === "completed" && j.imageUrl)) ||
|
||||
(script.scenes.length > 0 && script.scenes.every((s) => s.audioUrl && s.imageUrl));
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
|
||||
Back to Script
|
||||
@@ -349,204 +115,99 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({ projectId, script, kno
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert severity="info" sx={{ mb: 3, background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Audio Generation:</strong> Preview creates a quick sample to test voice and pacing. Full render generates the complete, production-ready audio file for your episode.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<SummaryStats jobs={jobs} scenes={script.scenes} />
|
||||
|
||||
{/* Empty State */}
|
||||
{jobs.length === 0 && script.scenes.length === 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
|
||||
border: "2px dashed rgba(102, 126, 234, 0.3)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, mb: 1 }}>
|
||||
No scenes to render
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mb: 3 }}>
|
||||
Go back to the script editor to generate and approve scenes first.
|
||||
</Typography>
|
||||
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
|
||||
Back to Script Editor
|
||||
</SecondaryButton>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Guidance Panel */}
|
||||
{script.scenes.length > 0 && <GuidancePanel scenes={script.scenes} />}
|
||||
|
||||
{/* Scene Cards */}
|
||||
<Stack spacing={2}>
|
||||
{jobs.map((job) => {
|
||||
const scene = getScene(job.sceneId);
|
||||
const initials = job.title
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
{script.scenes.map((scene) => {
|
||||
const job = jobs.find((j) => j.sceneId === scene.id);
|
||||
return (
|
||||
<GlassyCard key={job.sceneId} sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<Paper
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: alpha("#667eea", 0.2),
|
||||
border: "1px solid rgba(102,126,234,0.3)",
|
||||
fontWeight: 700,
|
||||
fontSize: "1.2rem",
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Paper>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6" sx={{ mb: 0.5 }}>
|
||||
{job.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Chip label={`Scene ${job.sceneId.slice(-4)}`} size="small" variant="outlined" />
|
||||
{job.cost != null && (
|
||||
<Chip
|
||||
label={`$${job.cost.toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
|
||||
title="Generation cost"
|
||||
/>
|
||||
)}
|
||||
{job.fileSize && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{(job.fileSize / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
{job.finalUrl && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
href={job.finalUrl}
|
||||
target="_blank"
|
||||
sx={{ mt: 1, color: "#a78bfa" }}
|
||||
>
|
||||
Download Final Audio
|
||||
</Button>
|
||||
)}
|
||||
{job.videoUrl && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<VideocamIcon />}
|
||||
href={job.videoUrl}
|
||||
target="_blank"
|
||||
sx={{ mt: 1, ml: 1, color: "#a78bfa" }}
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Chip
|
||||
icon={getStatusIcon(job.status)}
|
||||
label={job.status.charAt(0).toUpperCase() + job.status.slice(1)}
|
||||
color={getStatusColor(job.status)}
|
||||
size="small"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
minWidth: 100,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{job.status !== "idle" && job.status !== "completed" && (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Progress
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{job.progress}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={job.progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: alpha("#fff", 0.1),
|
||||
"& .MuiLinearProgress-bar": {
|
||||
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
|
||||
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||
{job.status === "idle" && (
|
||||
<>
|
||||
<SecondaryButton
|
||||
onClick={() => runRender(job.sceneId, "preview")}
|
||||
disabled={isBusy}
|
||||
startIcon={<VolumeUpIcon />}
|
||||
tooltip="Preview a sample to test voice and pacing before generating the full episode"
|
||||
>
|
||||
Preview Sample
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={() => runRender(job.sceneId, "full")}
|
||||
disabled={isBusy}
|
||||
startIcon={<PlayArrowIcon />}
|
||||
tooltip="Generate the complete, production-ready audio for this scene"
|
||||
>
|
||||
Generate Audio
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
{job.status === "completed" && (job.previewUrl || job.finalUrl) && (
|
||||
<Stack spacing={1} sx={{ width: "100%" }}>
|
||||
<InlineAudioPlayer audioUrl={job.finalUrl || job.previewUrl || ""} title={job.title} />
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => {
|
||||
const link = document.createElement("a");
|
||||
link.href = job.finalUrl || job.previewUrl || "";
|
||||
link.download = `${job.title.replace(/\s+/g, "-")}.mp3`;
|
||||
link.click();
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<ShareIcon />}
|
||||
onClick={async () => {
|
||||
if (navigator.share && job.finalUrl) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: job.title,
|
||||
text: `Check out this podcast episode: ${job.title}`,
|
||||
url: job.finalUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
// User cancelled or error
|
||||
}
|
||||
} else {
|
||||
// Fallback: copy to clipboard
|
||||
await navigator.clipboard.writeText(job.finalUrl || job.previewUrl || "");
|
||||
alert("Audio URL copied to clipboard!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
{job.status === "failed" && (
|
||||
<Button variant="outlined" color="warning" onClick={() => runRender(job.sceneId, "full")} startIcon={<RefreshIcon />}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
job={job}
|
||||
rendering={rendering}
|
||||
generatingImage={generatingImage}
|
||||
isBusy={isBusy}
|
||||
avatarImageUrl={avatarImageUrl}
|
||||
onRender={runRender}
|
||||
onImageGenerate={runImageGeneration}
|
||||
onVideoRender={runVideoRender}
|
||||
onDownloadAudio={handleDownloadAudio}
|
||||
onDownloadVideo={handleDownloadVideo}
|
||||
onShare={handleShare}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ mt: 3, display: "flex", justifyContent: "flex-end" }}>
|
||||
<SecondaryButton onClick={onBack}>Done</SecondaryButton>
|
||||
</Box>
|
||||
{/* Footer - Video Generation Focus */}
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 3,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" spacing={2}>
|
||||
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
|
||||
Back to Script
|
||||
</SecondaryButton>
|
||||
{allScenesCompleted ? (
|
||||
<Stack spacing={1} alignItems="flex-end">
|
||||
<Typography variant="body1" sx={{ color: "#10b981", fontWeight: 700, fontSize: "1rem" }}>
|
||||
🎉 All scenes ready for video generation!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Generate videos for individual scenes or download them.
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "#64748b" }}>
|
||||
Complete audio and image generation for all scenes to enable video generation.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, alpha } from "@mui/material";
|
||||
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
||||
import { Script } from "../types";
|
||||
|
||||
interface GuidancePanelProps {
|
||||
scenes: Script["scenes"];
|
||||
}
|
||||
|
||||
export const GuidancePanel: React.FC<GuidancePanelProps> = ({ scenes }) => {
|
||||
const scenesNeedingAudio = scenes.filter((s) => !s.audioUrl).length;
|
||||
const allScenesHaveAudio = scenes.length > 0 && scenesNeedingAudio === 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%)",
|
||||
border: "2px solid rgba(102, 126, 234, 0.3)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700, display: "flex", alignItems: "center", gap: 1.5, fontSize: "1.125rem" }}>
|
||||
<PlayArrowIcon sx={{ color: "#667eea", fontSize: "1.5rem" }} />
|
||||
What's Next? Generate Audio for Your Scenes
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7, fontSize: "0.9375rem" }}>
|
||||
<strong>For each scene below:</strong>
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ m: 0, pl: 2.5, color: "#475569" }}>
|
||||
<Typography component="li" variant="body2" sx={{ mb: 1, lineHeight: 1.7 }}>
|
||||
<strong>If audio is missing:</strong> Click <strong style={{ color: "#667eea" }}>"Generate Audio"</strong> to create the audio file for that scene
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" sx={{ mb: 1, lineHeight: 1.7 }}>
|
||||
<strong>If audio exists:</strong> The scene is ready! You can download it or proceed to video generation
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
<strong>Optional:</strong> Use <strong>"Preview Sample"</strong> to test voice and pacing before full generation
|
||||
</Typography>
|
||||
</Box>
|
||||
{scenesNeedingAudio > 0 && (
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mt: 1,
|
||||
background: alpha("#3b82f6", 0.1),
|
||||
border: "1px solid rgba(59,130,246,0.3)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#3b82f6",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#1e40af", fontWeight: 600 }}>
|
||||
📢 {scenesNeedingAudio} scene{scenesNeedingAudio !== 1 ? "s" : ""} need{scenesNeedingAudio === 1 ? "s" : ""} audio generation. Scroll down and click the <strong>"Generate Audio"</strong> buttons below!
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{allScenesHaveAudio && (
|
||||
<Alert
|
||||
severity="success"
|
||||
sx={{
|
||||
mt: 1,
|
||||
background: alpha("#10b981", 0.1),
|
||||
border: "1px solid rgba(16,185,129,0.3)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#10b981",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 600 }}>
|
||||
✅ All scenes have audio! Your podcast is ready. You can download individual scenes or proceed to video generation.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from "react";
|
||||
import { Stack } from "@mui/material";
|
||||
import {
|
||||
VolumeUp as VolumeUpIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Image as ImageIcon,
|
||||
Videocam as VideocamIcon,
|
||||
Download as DownloadIcon,
|
||||
Share as ShareIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Job } from "../types";
|
||||
import { PrimaryButton, SecondaryButton } from "../ui";
|
||||
|
||||
interface SceneActionButtonsProps {
|
||||
scene: Scene;
|
||||
job?: Job;
|
||||
hasAudio: boolean;
|
||||
hasImage: boolean;
|
||||
hasVideo: boolean;
|
||||
audioUrl: string;
|
||||
rendering: string | null;
|
||||
generatingImage: string | null;
|
||||
isBusy: boolean;
|
||||
onRender: (sceneId: string, mode: "preview" | "full") => void;
|
||||
onImageGenerate: (sceneId: string) => void;
|
||||
onVideoRender: (sceneId: string) => void;
|
||||
onDownloadAudio: (audioUrl: string, title: string) => void;
|
||||
onDownloadVideo: (videoUrl: string, title: string) => void;
|
||||
onShare: (audioUrl: string, title: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export const SceneActionButtons: React.FC<SceneActionButtonsProps> = ({
|
||||
scene,
|
||||
job,
|
||||
hasAudio,
|
||||
hasImage,
|
||||
hasVideo,
|
||||
audioUrl,
|
||||
rendering,
|
||||
generatingImage,
|
||||
isBusy,
|
||||
onRender,
|
||||
onImageGenerate,
|
||||
onVideoRender,
|
||||
onDownloadAudio,
|
||||
onDownloadVideo,
|
||||
onShare,
|
||||
onError,
|
||||
}) => {
|
||||
const isGeneratingImage = generatingImage === scene.id;
|
||||
const needsAudio = !hasAudio && (!job || job.status === "idle");
|
||||
|
||||
// No audio - show generate buttons
|
||||
if (needsAudio) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||
<SecondaryButton
|
||||
onClick={() => onRender(scene.id, "preview")}
|
||||
disabled={isBusy}
|
||||
startIcon={<VolumeUpIcon />}
|
||||
tooltip="Preview a sample to test voice and pacing"
|
||||
>
|
||||
Preview Sample
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={() => onRender(scene.id, "full")}
|
||||
disabled={isBusy}
|
||||
startIcon={<PlayArrowIcon />}
|
||||
tooltip="Generate the complete, production-ready audio for this scene"
|
||||
>
|
||||
Generate Audio
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Failed - show retry
|
||||
if (job?.status === "failed") {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||
<SecondaryButton
|
||||
onClick={() => onRender(scene.id, "full")}
|
||||
startIcon={<RefreshIcon />}
|
||||
tooltip="Retry audio generation"
|
||||
>
|
||||
Retry
|
||||
</SecondaryButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Has audio - show all action buttons
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end" flexWrap="wrap" useFlexGap>
|
||||
{/* Generate Image */}
|
||||
<PrimaryButton
|
||||
onClick={() => onImageGenerate(scene.id)}
|
||||
disabled={isGeneratingImage || hasImage}
|
||||
loading={isGeneratingImage}
|
||||
startIcon={<ImageIcon />}
|
||||
tooltip={
|
||||
hasImage
|
||||
? "Image already generated for this scene"
|
||||
: isGeneratingImage
|
||||
? "Generating image..."
|
||||
: "Generate image for video (optional)"
|
||||
}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
{isGeneratingImage ? "Generating..." : hasImage ? "Image Ready" : "Generate Image"}
|
||||
</PrimaryButton>
|
||||
|
||||
{/* Generate Video */}
|
||||
<PrimaryButton
|
||||
onClick={() => onVideoRender(scene.id)}
|
||||
disabled={isBusy || !hasImage || hasVideo}
|
||||
startIcon={<VideocamIcon />}
|
||||
tooltip={
|
||||
hasVideo
|
||||
? "Video already generated"
|
||||
: !hasImage
|
||||
? "Generate an image first to create video"
|
||||
: isBusy
|
||||
? "Another operation in progress"
|
||||
: "Generate video for this scene"
|
||||
}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
{hasVideo ? "Video Ready" : "Generate Video"}
|
||||
</PrimaryButton>
|
||||
|
||||
{/* Download Video */}
|
||||
{hasVideo && job?.videoUrl && (
|
||||
<SecondaryButton
|
||||
onClick={() => onDownloadVideo(job.videoUrl!, scene.title)}
|
||||
startIcon={<VideocamIcon />}
|
||||
tooltip="Download video file"
|
||||
>
|
||||
Download Video
|
||||
</SecondaryButton>
|
||||
)}
|
||||
|
||||
{/* Download Audio */}
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
if (!audioUrl) {
|
||||
onError("Audio URL not found. Please regenerate audio.");
|
||||
return;
|
||||
}
|
||||
onDownloadAudio(audioUrl, scene.title);
|
||||
}}
|
||||
startIcon={<DownloadIcon />}
|
||||
tooltip={hasAudio ? "Download this scene's audio file" : "No audio available. Generate audio first."}
|
||||
disabled={!hasAudio}
|
||||
>
|
||||
Download Audio
|
||||
</SecondaryButton>
|
||||
|
||||
{/* Share */}
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
if (!audioUrl) {
|
||||
onError("Audio URL not found. Please regenerate audio.");
|
||||
return;
|
||||
}
|
||||
onShare(audioUrl, scene.title);
|
||||
}}
|
||||
startIcon={<ShareIcon />}
|
||||
tooltip={hasAudio ? "Share this scene's audio" : "No audio available. Generate audio first."}
|
||||
disabled={!hasAudio}
|
||||
>
|
||||
Share
|
||||
</SecondaryButton>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
415
frontend/src/components/PodcastMaker/RenderQueue/SceneCard.tsx
Normal file
415
frontend/src/components/PodcastMaker/RenderQueue/SceneCard.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, CircularProgress, alpha } from "@mui/material";
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||
Info as InfoIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Videocam as VideocamIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Job } from "../types";
|
||||
import { GlassyCard, glassyCardSx } from "../ui";
|
||||
import { InlineAudioPlayer } from "../InlineAudioPlayer";
|
||||
import { SceneActionButtons } from "./SceneActionButtons";
|
||||
import { aiApiClient } from "../../../api/client";
|
||||
|
||||
interface SceneCardProps {
|
||||
scene: Scene;
|
||||
job?: Job;
|
||||
rendering: string | null;
|
||||
generatingImage: string | null;
|
||||
isBusy: boolean;
|
||||
avatarImageUrl?: string | null;
|
||||
onRender: (sceneId: string, mode: "preview" | "full") => void;
|
||||
onImageGenerate: (sceneId: string) => void;
|
||||
onVideoRender: (sceneId: string) => void;
|
||||
onDownloadAudio: (audioUrl: string, title: string) => void;
|
||||
onDownloadVideo: (videoUrl: string, title: string) => void;
|
||||
onShare: (audioUrl: string, title: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const getInitials = (title: string): string => {
|
||||
return title
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Job["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "failed":
|
||||
return "error";
|
||||
case "running":
|
||||
case "previewing":
|
||||
return "info";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: Job["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircleIcon />;
|
||||
case "failed":
|
||||
return <InfoIcon />;
|
||||
case "running":
|
||||
case "previewing":
|
||||
return <CircularProgress size={16} />;
|
||||
default:
|
||||
return <RadioButtonUncheckedIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
export const SceneCard: React.FC<SceneCardProps> = ({
|
||||
scene,
|
||||
job,
|
||||
rendering,
|
||||
generatingImage,
|
||||
isBusy,
|
||||
avatarImageUrl,
|
||||
onRender,
|
||||
onImageGenerate,
|
||||
onVideoRender,
|
||||
onDownloadAudio,
|
||||
onDownloadVideo,
|
||||
onShare,
|
||||
onError,
|
||||
}) => {
|
||||
const hasAudio = Boolean(scene.audioUrl || job?.finalUrl || job?.previewUrl);
|
||||
const hasImage = Boolean(scene.imageUrl || job?.imageUrl);
|
||||
const hasVideo = Boolean(job?.videoUrl);
|
||||
const audioUrl = job?.finalUrl || job?.previewUrl || scene.audioUrl || "";
|
||||
const imageUrl = job?.imageUrl || scene.imageUrl || "";
|
||||
const status = job?.status || (hasAudio ? "completed" : "idle");
|
||||
const initials = getInitials(scene.title);
|
||||
|
||||
// Load image as blob if it's an authenticated endpoint
|
||||
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
setImageBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SceneCard] Loading image:', { imageUrl, hasImage, sceneId: scene.id });
|
||||
|
||||
// Check if this is a podcast image endpoint that requires authentication
|
||||
const isPodcastImage = imageUrl.includes('/api/podcast/images/') || imageUrl.includes('/api/story/images/');
|
||||
|
||||
if (!isPodcastImage) {
|
||||
// Regular URL (external), use directly
|
||||
console.log('[SceneCard] Using external image URL directly');
|
||||
setImageBlobUrl(imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch as blob for authenticated endpoints
|
||||
let isMounted = true;
|
||||
const currentImageUrl = imageUrl;
|
||||
|
||||
const loadImageBlob = async () => {
|
||||
try {
|
||||
// Normalize path
|
||||
let imagePath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
|
||||
|
||||
// Convert /api/story/images/ to /api/podcast/images/ if needed
|
||||
if (imagePath.includes('/api/story/images/')) {
|
||||
const filename = imagePath.split('/api/story/images/').pop() || '';
|
||||
imagePath = `/api/podcast/images/${filename}`;
|
||||
}
|
||||
|
||||
// Ensure it's a podcast image endpoint
|
||||
if (!imagePath.includes('/api/podcast/images/')) {
|
||||
const filename = imagePath.split('/').pop() || currentImageUrl;
|
||||
imagePath = `/api/podcast/images/${filename}`;
|
||||
}
|
||||
|
||||
// Remove query parameters if present
|
||||
imagePath = imagePath.split('?')[0];
|
||||
|
||||
console.log('[SceneCard] Fetching image blob from:', imagePath);
|
||||
|
||||
const response = await aiApiClient.get(imagePath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
if (!isMounted || imageUrl !== currentImageUrl) {
|
||||
console.log('[SceneCard] Component unmounted or URL changed, skipping blob URL set');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = response.data;
|
||||
const newBlobUrl = URL.createObjectURL(blob);
|
||||
|
||||
console.log('[SceneCard] Image blob loaded successfully, created blob URL');
|
||||
|
||||
setImageBlobUrl((prevBlobUrl) => {
|
||||
// Clean up previous blob URL if exists
|
||||
if (prevBlobUrl && prevBlobUrl !== newBlobUrl && prevBlobUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(prevBlobUrl);
|
||||
}
|
||||
return newBlobUrl;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[SceneCard] Failed to load image blob:', err);
|
||||
if (isMounted && imageUrl === currentImageUrl) {
|
||||
// Try adding query token as fallback
|
||||
try {
|
||||
// Normalize path again for fallback
|
||||
let fallbackPath = currentImageUrl.startsWith('/') ? currentImageUrl : `/${currentImageUrl}`;
|
||||
|
||||
// Convert /api/story/images/ to /api/podcast/images/ if needed
|
||||
if (fallbackPath.includes('/api/story/images/')) {
|
||||
const filename = fallbackPath.split('/api/story/images/').pop() || '';
|
||||
fallbackPath = `/api/podcast/images/${filename}`;
|
||||
}
|
||||
|
||||
// Ensure it's a podcast image endpoint
|
||||
if (!fallbackPath.includes('/api/podcast/images/')) {
|
||||
const filename = fallbackPath.split('/').pop() || currentImageUrl;
|
||||
fallbackPath = `/api/podcast/images/${filename}`;
|
||||
}
|
||||
|
||||
// Remove query parameters if present
|
||||
fallbackPath = fallbackPath.split('?')[0];
|
||||
|
||||
// Get auth token from localStorage or use aiApiClient's default token
|
||||
const token = localStorage.getItem('clerk_dashboard_token') || '';
|
||||
if (token) {
|
||||
const urlWithToken = `${fallbackPath}?token=${encodeURIComponent(token)}`;
|
||||
console.log('[SceneCard] Trying URL with query token');
|
||||
setImageBlobUrl(urlWithToken);
|
||||
} else {
|
||||
// Fallback to original URL
|
||||
console.log('[SceneCard] No token available, using original URL');
|
||||
setImageBlobUrl(imageUrl);
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
console.error('[SceneCard] Fallback also failed:', fallbackErr);
|
||||
setImageBlobUrl(imageUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadImageBlob();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
// Cleanup blob URL when component unmounts or URL changes
|
||||
setImageBlobUrl((prevBlobUrl) => {
|
||||
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(prevBlobUrl);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, [imageUrl, hasImage, scene.id]);
|
||||
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<Paper
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: alpha("#667eea", 0.2),
|
||||
border: "1px solid rgba(102,126,234,0.3)",
|
||||
fontWeight: 700,
|
||||
fontSize: "1.2rem",
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Paper>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6" sx={{ mb: 0.5, color: "#0f172a", fontWeight: 600 }}>
|
||||
{scene.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Chip label={`Scene ${scene.id.slice(-4)}`} size="small" variant="outlined" />
|
||||
{job?.cost != null && (
|
||||
<Chip
|
||||
label={`$${job.cost.toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{ background: alpha("#10b981", 0.2), color: "#6ee7b7" }}
|
||||
title="Generation cost"
|
||||
/>
|
||||
)}
|
||||
{job?.fileSize && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{(job.fileSize / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
)}
|
||||
{!job && (
|
||||
<Chip
|
||||
label={hasAudio ? "Audio Ready" : "Needs Audio"}
|
||||
size="small"
|
||||
color={hasAudio ? "success" : "warning"}
|
||||
sx={{
|
||||
background: hasAudio ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
|
||||
color: hasAudio ? "#059669" : "#d97706",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{job?.finalUrl && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box
|
||||
component="a"
|
||||
href={job.finalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption">Download Final Audio</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{hasVideo && job?.videoUrl && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box
|
||||
component="a"
|
||||
href={job.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ color: "#a78bfa", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<VideocamIcon sx={{ fontSize: 16 }} />
|
||||
<Typography variant="caption">Download Video</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{job && (
|
||||
<Chip
|
||||
icon={getStatusIcon(status)}
|
||||
label={status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
color={getStatusColor(status)}
|
||||
size="small"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
minWidth: 100,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{job && job.status !== "idle" && job.status !== "completed" && (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Progress
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{job.progress}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={job.progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: alpha("#fff", 0.1),
|
||||
"& .MuiLinearProgress-bar": {
|
||||
background: "linear-gradient(90deg, #667eea 0%, #764ba2 100%)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)" }} />
|
||||
|
||||
{/* Success Alert for Pre-generated Audio */}
|
||||
{hasAudio && !job && (
|
||||
<Alert severity="success" sx={{ width: "100%", background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)" }}>
|
||||
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
|
||||
✅ Audio already generated in Script Editor. Ready to use!
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Audio Player */}
|
||||
{hasAudio && audioUrl && (
|
||||
<InlineAudioPlayer audioUrl={audioUrl} title={scene.title} />
|
||||
)}
|
||||
|
||||
{/* Image Preview */}
|
||||
{hasImage && (imageBlobUrl || imageUrl) && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(102,126,234,0.2)",
|
||||
background: alpha("#667eea", 0.05),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageBlobUrl || imageUrl}
|
||||
alt={scene.title}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
maxHeight: 400,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('[SceneCard] Image failed to load:', {
|
||||
src: e.currentTarget.src,
|
||||
imageUrl,
|
||||
imageBlobUrl,
|
||||
hasImage,
|
||||
});
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('[SceneCard] Image loaded successfully:', {
|
||||
src: imageBlobUrl || imageUrl,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<SceneActionButtons
|
||||
scene={scene}
|
||||
job={job}
|
||||
hasAudio={hasAudio}
|
||||
hasImage={hasImage}
|
||||
hasVideo={hasVideo}
|
||||
audioUrl={audioUrl}
|
||||
rendering={rendering}
|
||||
generatingImage={generatingImage}
|
||||
isBusy={isBusy}
|
||||
onRender={onRender}
|
||||
onImageGenerate={onImageGenerate}
|
||||
onVideoRender={onVideoRender}
|
||||
onDownloadAudio={onDownloadAudio}
|
||||
onDownloadVideo={onDownloadVideo}
|
||||
onShare={onShare}
|
||||
onError={onError}
|
||||
/>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Typography, Paper } from "@mui/material";
|
||||
import { Script, Job } from "../types";
|
||||
|
||||
interface SummaryStatsProps {
|
||||
jobs: Job[];
|
||||
scenes: Script["scenes"];
|
||||
}
|
||||
|
||||
export const SummaryStats: React.FC<SummaryStatsProps> = ({ jobs, scenes }) => {
|
||||
const totalScenes = jobs.length > 0 ? jobs.length : scenes.length;
|
||||
const readyToRender = jobs.length > 0
|
||||
? jobs.filter((j) => j.status === "idle").length
|
||||
: scenes.filter((s) => !s.audioUrl).length;
|
||||
const completed = jobs.length > 0
|
||||
? jobs.filter((j) => j.status === "completed").length
|
||||
: scenes.filter((s) => s.audioUrl).length;
|
||||
const inProgress = jobs.length > 0
|
||||
? jobs.filter((j) => j.status === "running" || j.status === "previewing").length
|
||||
: 0;
|
||||
|
||||
if (totalScenes === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.5,
|
||||
mb: 3,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)",
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={3} flexWrap="wrap" useFlexGap>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
|
||||
Total Scenes
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 700 }}>
|
||||
{totalScenes}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
|
||||
Ready to Render
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: "#667eea", fontWeight: 700 }}>
|
||||
{readyToRender}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
|
||||
Completed
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: "#10b981", fontWeight: 700 }}>
|
||||
{completed}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, display: "block", mb: 0.5 }}>
|
||||
In Progress
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: "#3b82f6", fontWeight: 700 }}>
|
||||
{inProgress}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export { SceneCard } from "./SceneCard";
|
||||
export { SceneActionButtons } from "./SceneActionButtons";
|
||||
export { SummaryStats } from "./SummaryStats";
|
||||
export { GuidancePanel } from "./GuidancePanel";
|
||||
export { useRenderQueue } from "./useRenderQueue";
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Script, Knobs, Job, RenderJobResult, TaskStatus } from "../types";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
|
||||
interface UseRenderQueueProps {
|
||||
script: Script;
|
||||
jobs: Job[];
|
||||
knobs: Knobs;
|
||||
projectId: string;
|
||||
budgetCap?: number;
|
||||
avatarImageUrl?: string | null;
|
||||
onUpdateJob: (sceneId: string, updates: Partial<Job>) => void;
|
||||
onUpdateScript?: (script: Script) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral";
|
||||
|
||||
export const useRenderQueue = ({
|
||||
script,
|
||||
jobs,
|
||||
knobs,
|
||||
projectId,
|
||||
budgetCap,
|
||||
avatarImageUrl,
|
||||
onUpdateJob,
|
||||
onUpdateScript,
|
||||
onError,
|
||||
}: UseRenderQueueProps) => {
|
||||
const [rendering, setRendering] = useState<string | null>(null);
|
||||
const [generatingImage, setGeneratingImage] = useState<string | null>(null);
|
||||
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||
url: string;
|
||||
filename: string;
|
||||
duration: number;
|
||||
sceneCount: number;
|
||||
} | null>(null);
|
||||
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
|
||||
// Cleanup polling intervals on unmount
|
||||
useEffect(() => {
|
||||
const intervals = pollingIntervals.current;
|
||||
return () => {
|
||||
intervals.forEach((interval) => clearInterval(interval));
|
||||
intervals.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize jobs if empty
|
||||
useEffect(() => {
|
||||
if (jobs.length === 0 && script.scenes.length > 0) {
|
||||
const initialJobs: Job[] = script.scenes.map((s) => {
|
||||
const hasExistingAudio = Boolean(s.audioUrl);
|
||||
return {
|
||||
sceneId: s.id,
|
||||
title: s.title,
|
||||
status: hasExistingAudio ? ("completed" as const) : ("idle" as const),
|
||||
progress: hasExistingAudio ? 100 : 0,
|
||||
previewUrl: null,
|
||||
finalUrl: hasExistingAudio ? s.audioUrl || null : null,
|
||||
imageUrl: s.imageUrl || null, // Include existing imageUrl from scene
|
||||
jobId: null,
|
||||
};
|
||||
});
|
||||
initialJobs.forEach((job) => {
|
||||
onUpdateJob(job.sceneId, job);
|
||||
});
|
||||
}
|
||||
}, [script.scenes.length, jobs.length, onUpdateJob, script.scenes]);
|
||||
|
||||
const getScene = useCallback((sceneId: string) => script.scenes.find((s) => s.id === sceneId), [script.scenes]);
|
||||
|
||||
const pollTaskStatus = useCallback(async (taskId: string, sceneId: string) => {
|
||||
try {
|
||||
const status: TaskStatus = await podcastApi.pollTaskStatus(taskId);
|
||||
|
||||
onUpdateJob(sceneId, {
|
||||
progress: status.progress ?? 0,
|
||||
status: status.status === "completed" ? "completed" : status.status === "failed" ? "failed" : "running",
|
||||
});
|
||||
|
||||
if (status.status === "completed" && status.result) {
|
||||
const result = status.result;
|
||||
onUpdateJob(sceneId, {
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
videoUrl: result.video_url,
|
||||
cost: result.cost,
|
||||
});
|
||||
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
} else if (status.status === "failed") {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const interval = pollingIntervals.current.get(sceneId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
onError(status.error || "Video generation failed");
|
||||
}
|
||||
|
||||
return status.status === "completed" || status.status === "failed";
|
||||
} catch (error) {
|
||||
console.error("Error polling task status:", error);
|
||||
return false;
|
||||
}
|
||||
}, [onUpdateJob, onError]);
|
||||
|
||||
const startPolling = useCallback((taskId: string, sceneId: string) => {
|
||||
const existingInterval = pollingIntervals.current.get(sceneId);
|
||||
if (existingInterval) {
|
||||
clearInterval(existingInterval);
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const isComplete = await pollTaskStatus(taskId, sceneId);
|
||||
if (isComplete) {
|
||||
clearInterval(interval);
|
||||
pollingIntervals.current.delete(sceneId);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
pollingIntervals.current.set(sceneId, interval);
|
||||
}, [pollTaskStatus]);
|
||||
|
||||
const runRender = useCallback(async (sceneId: string, mode: "preview" | "full") => {
|
||||
if (rendering && rendering !== sceneId) return;
|
||||
const job = jobs.find((j) => j.sceneId === sceneId);
|
||||
if (job && job.status !== "idle") return;
|
||||
const scene = getScene(sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
const textLength = scene.lines.map((l) => l.text).join(" ").length;
|
||||
const estimatedCost = (textLength / 1000) * 0.05;
|
||||
|
||||
if (budgetCap && budgetCap > 0) {
|
||||
const currentSpent = jobs
|
||||
.filter((j) => j.status === "completed" && j.cost)
|
||||
.reduce((sum, j) => sum + (j.cost || 0), 0);
|
||||
|
||||
if (currentSpent + estimatedCost > budgetCap) {
|
||||
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(4)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setRendering(sceneId);
|
||||
onUpdateJob(sceneId, {
|
||||
status: mode === "preview" ? "previewing" : "running",
|
||||
progress: mode === "preview" ? 25 : 40,
|
||||
});
|
||||
|
||||
try {
|
||||
const result: RenderJobResult = await podcastApi.renderSceneAudio({
|
||||
scene,
|
||||
voiceId: "Wise_Woman",
|
||||
emotion: scene.emotion || getSceneVoiceEmotion(knobs),
|
||||
speed: knobs.voice_speed,
|
||||
});
|
||||
|
||||
const updates: Partial<Job> = {
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
cost: result.cost,
|
||||
provider: result.provider,
|
||||
voiceId: result.voiceId,
|
||||
fileSize: result.fileSize,
|
||||
};
|
||||
|
||||
if (mode === "preview") {
|
||||
updates.previewUrl = result.audioUrl;
|
||||
window.open(result.audioUrl, "_blank");
|
||||
} else {
|
||||
updates.finalUrl = result.audioUrl;
|
||||
try {
|
||||
await podcastApi.saveAudioToAssetLibrary({
|
||||
audioUrl: result.audioUrl,
|
||||
filename: result.audioFilename,
|
||||
title: `${scene.title} - ${projectId}`,
|
||||
description: `Podcast episode scene audio: ${scene.title}`,
|
||||
projectId,
|
||||
sceneId,
|
||||
cost: result.cost,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
fileSize: result.fileSize,
|
||||
});
|
||||
} catch (assetError) {
|
||||
console.error("Failed to save to asset library:", assetError);
|
||||
}
|
||||
}
|
||||
|
||||
onUpdateJob(sceneId, updates);
|
||||
} catch (error) {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const message = error instanceof Error ? error.message : "Render failed";
|
||||
onError(message);
|
||||
} finally {
|
||||
setRendering(null);
|
||||
}
|
||||
}, [rendering, jobs, getScene, knobs, budgetCap, projectId, onUpdateJob, onError]);
|
||||
|
||||
const runImageGeneration = useCallback(async (sceneId: string) => {
|
||||
if (generatingImage && generatingImage !== sceneId) return;
|
||||
const scene = getScene(sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
setGeneratingImage(sceneId);
|
||||
try {
|
||||
const sceneContent = scene.lines.map((line) => line.text).join(" ");
|
||||
const result = await podcastApi.generateSceneImage({
|
||||
sceneId: scene.id,
|
||||
sceneTitle: scene.title,
|
||||
sceneContent: sceneContent,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
});
|
||||
|
||||
// Update job with image URL
|
||||
onUpdateJob(sceneId, {
|
||||
imageUrl: result.image_url,
|
||||
});
|
||||
|
||||
// Also update the scene's imageUrl so it persists
|
||||
if (onUpdateScript) {
|
||||
const updatedScenes = script.scenes.map((s) =>
|
||||
s.id === sceneId ? { ...s, imageUrl: result.image_url } : s
|
||||
);
|
||||
onUpdateScript({ ...script, scenes: updatedScenes });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Image generation failed";
|
||||
onError(message);
|
||||
} finally {
|
||||
setGeneratingImage(null);
|
||||
}
|
||||
}, [generatingImage, getScene, onUpdateJob, onError]);
|
||||
|
||||
const runVideoRender = useCallback(async (sceneId: string) => {
|
||||
if (rendering && rendering !== sceneId) return;
|
||||
const scene = getScene(sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
const sceneImageUrl = scene.imageUrl || avatarImageUrl;
|
||||
if (!sceneImageUrl) {
|
||||
onError("Scene image is required for video generation. Please generate images for scenes first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobs.find((j) => j.sceneId === sceneId);
|
||||
if (!job?.finalUrl) {
|
||||
onError("Please generate audio first before creating video.");
|
||||
return;
|
||||
}
|
||||
|
||||
const estimatedCost = 0.30;
|
||||
if (budgetCap && budgetCap > 0) {
|
||||
const currentSpent = jobs
|
||||
.filter((j) => j.status === "completed" && j.cost)
|
||||
.reduce((sum, j) => sum + (j.cost || 0), 0);
|
||||
|
||||
if (currentSpent + estimatedCost > budgetCap) {
|
||||
onError(`Budget cap exceeded. Estimated cost: $${estimatedCost.toFixed(2)}, Budget remaining: $${(budgetCap - currentSpent).toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setRendering(sceneId);
|
||||
onUpdateJob(sceneId, {
|
||||
status: "running",
|
||||
progress: 5,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await podcastApi.generateVideo({
|
||||
projectId,
|
||||
sceneId,
|
||||
sceneTitle: scene.title,
|
||||
audioUrl: job.finalUrl,
|
||||
avatarImageUrl: sceneImageUrl,
|
||||
resolution: knobs.resolution || "720p",
|
||||
});
|
||||
|
||||
onUpdateJob(sceneId, {
|
||||
taskId: result.taskId,
|
||||
status: "running",
|
||||
progress: 5,
|
||||
});
|
||||
|
||||
startPolling(result.taskId, sceneId);
|
||||
} catch (error) {
|
||||
onUpdateJob(sceneId, { status: "failed", progress: 0 });
|
||||
const message = error instanceof Error ? error.message : "Video generation failed";
|
||||
onError(message);
|
||||
} finally {
|
||||
setRendering(null);
|
||||
}
|
||||
}, [rendering, getScene, avatarImageUrl, jobs, budgetCap, projectId, knobs, onUpdateJob, onError, startPolling]);
|
||||
|
||||
const combineAudio = useCallback(async () => {
|
||||
try {
|
||||
setCombiningAudio(true);
|
||||
|
||||
const sceneIds: string[] = [];
|
||||
const sceneAudioUrls: string[] = [];
|
||||
|
||||
script.scenes.forEach((scene) => {
|
||||
if (scene.audioUrl) {
|
||||
// Ensure we're using the correct URL format (not blob URLs)
|
||||
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
|
||||
if (audioUrl) {
|
||||
sceneIds.push(scene.id);
|
||||
sceneAudioUrls.push(audioUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
jobs.forEach((job) => {
|
||||
// Prefer finalUrl over previewUrl, and ensure it's not a blob URL
|
||||
const audioUrl = job.finalUrl || job.previewUrl;
|
||||
if (audioUrl && !audioUrl.startsWith('blob:') && !sceneAudioUrls.includes(audioUrl)) {
|
||||
sceneIds.push(job.sceneId);
|
||||
sceneAudioUrls.push(audioUrl);
|
||||
}
|
||||
});
|
||||
|
||||
if (sceneIds.length === 0) {
|
||||
onError("No audio files found to combine.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await podcastApi.combineAudio({
|
||||
projectId,
|
||||
sceneIds,
|
||||
sceneAudioUrls,
|
||||
});
|
||||
|
||||
// Store combined audio result for preview
|
||||
setCombinedAudioResult({
|
||||
url: result.combined_audio_url,
|
||||
filename: result.combined_audio_filename,
|
||||
duration: result.total_duration,
|
||||
sceneCount: result.scene_count,
|
||||
});
|
||||
|
||||
// Auto-download the combined audio
|
||||
const link = document.createElement("a");
|
||||
link.href = result.combined_audio_url;
|
||||
link.download = `podcast-episode-${projectId.slice(-8)}.mp3`;
|
||||
link.click();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to combine audio";
|
||||
onError(`Failed to combine audio: ${message}`);
|
||||
} finally {
|
||||
setCombiningAudio(false);
|
||||
}
|
||||
}, [script.scenes, jobs, projectId, onError]);
|
||||
|
||||
return {
|
||||
rendering,
|
||||
generatingImage,
|
||||
combiningAudio,
|
||||
combinedAudioResult,
|
||||
isBusy: Boolean(rendering),
|
||||
runRender,
|
||||
runImageGeneration,
|
||||
runVideoRender,
|
||||
combineAudio,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Stack, Box, Typography, TextField, Button, Chip, CircularProgress, alpha } from "@mui/material";
|
||||
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
|
||||
import { Stack, Box, Typography, TextField, Button, Chip, alpha } from "@mui/material";
|
||||
import { Line } from "../types";
|
||||
import { GlassyCard, glassyCardSx } from "../ui";
|
||||
|
||||
interface LineEditorProps {
|
||||
line: Line;
|
||||
onChange: (l: Line) => void;
|
||||
onPreview: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
|
||||
}
|
||||
|
||||
export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPreview }) => {
|
||||
export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState(line.text);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
useEffect(() => setText(line.text), [line.text]);
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -21,33 +18,37 @@ export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPrevie
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setPreviewing(true);
|
||||
try {
|
||||
const res = await onPreview(text);
|
||||
if (res.audioUrl) {
|
||||
window.open(res.audioUrl, "_blank");
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
whileHover={{ y: -2 }}
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
p: 2,
|
||||
transition: "all 0.2s",
|
||||
p: 2.5,
|
||||
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
borderLeft: "3px solid transparent",
|
||||
"&:hover": {
|
||||
borderLeftColor: "#667eea",
|
||||
boxShadow: "0 4px 6px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box flex={1}>
|
||||
<Chip label={line.speaker} size="small" sx={{ mb: 1, background: alpha("#667eea", 0.2), color: "#a78bfa" }} />
|
||||
<Chip
|
||||
label={line.speaker}
|
||||
size="small"
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%)",
|
||||
color: "#667eea",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
height: 24,
|
||||
border: "1px solid rgba(102, 126, 234, 0.2)",
|
||||
boxShadow: "0 1px 2px rgba(102, 126, 234, 0.05)",
|
||||
}}
|
||||
/>
|
||||
{editing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
@@ -57,47 +58,97 @@ export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPrevie
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
color: "white",
|
||||
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
color: "#0f172a",
|
||||
backgroundColor: "#f8fafc",
|
||||
borderRadius: 2,
|
||||
"& fieldset": {
|
||||
borderColor: "rgba(15, 23, 42, 0.12)",
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
"&:hover fieldset": {
|
||||
borderColor: "rgba(102, 126, 234, 0.4)",
|
||||
},
|
||||
"&.Mui-focused fieldset": {
|
||||
borderColor: "#667eea",
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#0f172a",
|
||||
fontWeight: 400,
|
||||
fontSize: "0.9375rem",
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7, color: "rgba(255,255,255,0.9)" }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
lineHeight: 1.75,
|
||||
color: "#0f172a",
|
||||
fontWeight: 400,
|
||||
fontSize: "0.9375rem",
|
||||
letterSpacing: "0.01em",
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</Typography>
|
||||
)}
|
||||
{line.usedFactIds && line.usedFactIds.length > 0 && (
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 1.5 }} flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.75rem" }}>
|
||||
Facts:
|
||||
</Typography>
|
||||
{line.usedFactIds.map((id) => (
|
||||
<Chip key={id} label={id} size="small" variant="outlined" sx={{ fontSize: "0.65rem", height: 20 }} />
|
||||
<Chip
|
||||
key={id}
|
||||
label={id}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: "0.6875rem",
|
||||
height: 22,
|
||||
color: "#64748b",
|
||||
borderColor: "rgba(15, 23, 42, 0.12)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Stack spacing={1} sx={{ ml: 2 }}>
|
||||
<Box sx={{ ml: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={editing ? "contained" : "outlined"}
|
||||
onClick={editing ? handleSave : () => setEditing(true)}
|
||||
sx={{ minWidth: 80 }}
|
||||
sx={{
|
||||
minWidth: 85,
|
||||
color: editing ? "white" : "#667eea",
|
||||
borderColor: editing ? "transparent" : "#667eea",
|
||||
backgroundColor: editing
|
||||
? "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
: "transparent",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.8125rem",
|
||||
textTransform: "none",
|
||||
borderRadius: 2,
|
||||
borderWidth: editing ? 0 : 1.5,
|
||||
boxShadow: editing ? "0 2px 4px rgba(102, 126, 234, 0.2)" : "none",
|
||||
"&:hover": {
|
||||
borderColor: editing ? "transparent" : "#5568d3",
|
||||
backgroundColor: editing
|
||||
? "linear-gradient(135deg, #5568d3 0%, #6b3fa0 100%)"
|
||||
: alpha("#667eea", 0.08),
|
||||
boxShadow: editing ? "0 4px 8px rgba(102, 126, 234, 0.3)" : "none",
|
||||
},
|
||||
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
>
|
||||
{editing ? "Save" : "Edit"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={previewing ? <CircularProgress size={14} /> : <VolumeUpIcon />}
|
||||
onClick={handlePreview}
|
||||
disabled={previewing || editing}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
Preview TTS
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
|
||||
@@ -1,84 +1,377 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha } from "@mui/material";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha, CircularProgress } from "@mui/material";
|
||||
import {
|
||||
EditNote as EditNoteIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||
VolumeUp as VolumeUpIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Image as ImageIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Line } from "../types";
|
||||
import { Scene, Line, Knobs } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { LineEditor } from "./LineEditor";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { aiApiClient } from "../../../api/client";
|
||||
|
||||
interface SceneEditorProps {
|
||||
scene: Scene;
|
||||
onUpdateScene: (s: Scene) => void;
|
||||
onApprove: (id: string) => Promise<void>;
|
||||
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
|
||||
knobs: Knobs;
|
||||
approvingSceneId?: string | null;
|
||||
generatingAudioId?: string | null;
|
||||
onAudioGenerationStart?: (sceneId: string) => void;
|
||||
onAudioGenerated?: (sceneId: string, audioUrl: string) => void;
|
||||
idea?: string; // Podcast idea for image generation context
|
||||
}
|
||||
|
||||
export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
scene,
|
||||
onUpdateScene,
|
||||
onApprove,
|
||||
onPreviewLine,
|
||||
knobs,
|
||||
approvingSceneId,
|
||||
generatingAudioId,
|
||||
onAudioGenerationStart,
|
||||
onAudioGenerated,
|
||||
idea,
|
||||
}) => {
|
||||
const [localGenerating, setLocalGenerating] = useState(false);
|
||||
const [generatingImage, setGeneratingImage] = useState(false);
|
||||
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
|
||||
|
||||
// Load audio as blob when audioUrl is available
|
||||
useEffect(() => {
|
||||
if (!scene.audioUrl) {
|
||||
// Clean up blob URL if audioUrl is removed
|
||||
setAudioBlobUrl((currentBlobUrl) => {
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
const currentAudioUrl = scene.audioUrl; // Capture current value
|
||||
|
||||
const loadAudioBlob = async () => {
|
||||
try {
|
||||
// Normalize path
|
||||
let audioPath = currentAudioUrl.startsWith('/') ? currentAudioUrl : `/${currentAudioUrl}`;
|
||||
|
||||
// Convert /api/story/audio/ to /api/podcast/audio/ if needed
|
||||
if (audioPath.includes('/api/story/audio/')) {
|
||||
const filename = audioPath.split('/api/story/audio/').pop() || '';
|
||||
audioPath = `/api/podcast/audio/${filename}`;
|
||||
}
|
||||
|
||||
// Ensure it's a podcast audio endpoint
|
||||
if (!audioPath.includes('/api/podcast/audio/')) {
|
||||
const filename = audioPath.split('/').pop() || currentAudioUrl;
|
||||
audioPath = `/api/podcast/audio/${filename}`;
|
||||
}
|
||||
|
||||
// Remove query parameters if present
|
||||
audioPath = audioPath.split('?')[0];
|
||||
|
||||
const response = await aiApiClient.get(audioPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
if (!isMounted) {
|
||||
// Component unmounted or audioUrl changed, don't set blob URL
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-check that audioUrl hasn't changed
|
||||
if (scene.audioUrl !== currentAudioUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = response.data;
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
setAudioBlobUrl((prevBlobUrl) => {
|
||||
// Clean up previous blob URL if exists
|
||||
if (prevBlobUrl && prevBlobUrl !== blobUrl) {
|
||||
URL.revokeObjectURL(prevBlobUrl);
|
||||
}
|
||||
return blobUrl;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to load audio blob for scene ${scene.id}:`, error);
|
||||
// Don't set blob URL on error - will show error state
|
||||
}
|
||||
};
|
||||
|
||||
loadAudioBlob();
|
||||
|
||||
// Cleanup: only mark as unmounted, don't revoke blob URL here
|
||||
// The blob URL will be cleaned up when audioUrl changes (new effect) or component unmounts
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [scene.audioUrl, scene.id]);
|
||||
|
||||
const updateLine = (updatedLine: Line) => {
|
||||
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
|
||||
onUpdateScene(updated);
|
||||
};
|
||||
|
||||
const approving = approvingSceneId === scene.id;
|
||||
const generating = generatingAudioId === scene.id || localGenerating;
|
||||
const hasAudio = Boolean(scene.audioUrl && audioBlobUrl);
|
||||
const hasImage = Boolean(scene.imageUrl);
|
||||
|
||||
const handleApprove = async () => {
|
||||
await onApprove(scene.id);
|
||||
onUpdateScene({ ...scene, approved: true });
|
||||
const handleApproveAndGenerate = async () => {
|
||||
const wasAlreadyApproved = scene.approved;
|
||||
const sceneId = scene.id;
|
||||
|
||||
try {
|
||||
// Set generating state
|
||||
setLocalGenerating(true);
|
||||
if (onAudioGenerationStart) {
|
||||
onAudioGenerationStart(sceneId);
|
||||
}
|
||||
|
||||
// If scene is not approved yet, approve it first
|
||||
// This will update the parent script state
|
||||
if (!scene.approved) {
|
||||
await onApprove(sceneId);
|
||||
// The parent's approveScene already updated the script state
|
||||
// We need to wait for React to propagate the updated scene prop
|
||||
// For now, we'll update it locally too to ensure UI updates immediately
|
||||
onUpdateScene({ ...scene, approved: true });
|
||||
}
|
||||
|
||||
// Use the current scene (which should now be approved)
|
||||
// If scene prop hasn't updated yet, use the local update we just made
|
||||
const currentScene = { ...scene, approved: true };
|
||||
|
||||
// Generate audio
|
||||
const result = await podcastApi.renderSceneAudio({
|
||||
scene: currentScene,
|
||||
voiceId: "Wise_Woman",
|
||||
emotion: scene.emotion || knobs.voice_emotion || "neutral",
|
||||
speed: knobs.voice_speed || 1.0,
|
||||
});
|
||||
|
||||
// Update scene with audio URL and ensure approved state
|
||||
// This will sync with parent script state
|
||||
const updatedScene = { ...currentScene, audioUrl: result.audioUrl, approved: true };
|
||||
onUpdateScene(updatedScene);
|
||||
|
||||
if (onAudioGenerated) {
|
||||
onAudioGenerated(sceneId, result.audioUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to approve and generate audio:", error);
|
||||
// On error, revert approval only if we just approved it in this call
|
||||
if (!wasAlreadyApproved) {
|
||||
onUpdateScene({ ...scene, approved: false, audioUrl: undefined });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
setLocalGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateImage = async () => {
|
||||
const sceneId = scene.id;
|
||||
|
||||
try {
|
||||
setGeneratingImage(true);
|
||||
|
||||
// Build scene content from lines for context
|
||||
const sceneContent = scene.lines.map((line) => line.text).join(" ");
|
||||
|
||||
const result = await podcastApi.generateSceneImage({
|
||||
sceneId: scene.id,
|
||||
sceneTitle: scene.title,
|
||||
sceneContent: sceneContent,
|
||||
idea: idea,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
});
|
||||
|
||||
// Update scene with image URL
|
||||
const updatedScene = { ...scene, imageUrl: result.image_url };
|
||||
onUpdateScene(updatedScene);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate image:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
setGeneratingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={2.5}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
|
||||
<EditNoteIcon fontSize="small" />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mb: 1,
|
||||
color: "#0f172a",
|
||||
fontWeight: 600,
|
||||
fontSize: "1.25rem",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
|
||||
{scene.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||
<Chip
|
||||
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
|
||||
label={scene.approved ? "Approved" : "Pending Approval"}
|
||||
size="small"
|
||||
color={scene.approved ? "success" : "warning"}
|
||||
sx={{
|
||||
background: scene.approved ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
|
||||
color: scene.approved ? "#6ee7b7" : "#fbbf24",
|
||||
border: scene.approved ? "1px solid rgba(16,185,129,0.3)" : "1px solid rgba(245,158,11,0.3)",
|
||||
background: scene.approved
|
||||
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
|
||||
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
|
||||
color: scene.approved ? "#059669" : "#d97706",
|
||||
border: scene.approved
|
||||
? "1px solid rgba(16, 185, 129, 0.25)"
|
||||
: "1px solid rgba(245, 158, 11, 0.25)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
|
||||
Duration: {scene.duration}s
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={handleApprove}
|
||||
disabled={scene.approved || approving}
|
||||
loading={approving}
|
||||
startIcon={scene.approved ? <CheckCircleIcon /> : undefined}
|
||||
tooltip={scene.approved ? "Scene is approved and ready for rendering" : "Approve this scene to enable rendering"}
|
||||
>
|
||||
{scene.approved ? "Approved" : approving ? "Approving..." : "Approve Scene"}
|
||||
</PrimaryButton>
|
||||
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
|
||||
<PrimaryButton
|
||||
onClick={handleApproveAndGenerate}
|
||||
disabled={approving || generating}
|
||||
loading={approving || generating}
|
||||
startIcon={
|
||||
hasAudio && !generating ? (
|
||||
<VolumeUpIcon />
|
||||
) : generating ? (
|
||||
<CircularProgress size={16} sx={{ color: "white" }} />
|
||||
) : (
|
||||
<PlayArrowIcon />
|
||||
)
|
||||
}
|
||||
tooltip={
|
||||
hasAudio && !generating
|
||||
? "Regenerate audio for this scene"
|
||||
: generating
|
||||
? "Generating audio..."
|
||||
: scene.approved
|
||||
? "Generate audio for this scene"
|
||||
: "Approve scene and generate audio"
|
||||
}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
}}
|
||||
>
|
||||
{hasAudio && !generating
|
||||
? "Regenerate Audio"
|
||||
: generating
|
||||
? "Generating Audio..."
|
||||
: scene.approved
|
||||
? "Generate Audio"
|
||||
: "Approve & Generate Audio"}
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleGenerateImage}
|
||||
disabled={generatingImage}
|
||||
loading={generatingImage}
|
||||
startIcon={
|
||||
hasImage && !generatingImage ? (
|
||||
<ImageIcon />
|
||||
) : generatingImage ? (
|
||||
<CircularProgress size={16} sx={{ color: "white" }} />
|
||||
) : (
|
||||
<ImageIcon />
|
||||
)
|
||||
}
|
||||
tooltip={
|
||||
hasImage
|
||||
? "Regenerate image for this scene"
|
||||
: generatingImage
|
||||
? "Generating image..."
|
||||
: "Generate image for video (optional)"
|
||||
}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
background: hasImage
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
"&:hover": {
|
||||
background: hasImage
|
||||
? "linear-gradient(135deg, #059669 0%, #047857 100%)"
|
||||
: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{hasImage && !generatingImage
|
||||
? "Regenerate Image"
|
||||
: generatingImage
|
||||
? "Generating Image..."
|
||||
: "Generate Image"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
|
||||
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1 }} />
|
||||
|
||||
<Stack spacing={2}>
|
||||
{scene.lines.map((line) => (
|
||||
<LineEditor key={line.id} line={line} onChange={updateLine} onPreview={(text) => onPreviewLine(text)} />
|
||||
<LineEditor key={line.id} line={line} onChange={updateLine} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{scene.audioUrl && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: "rgba(15, 23, 42, 0.08)", borderWidth: 1, mt: 1 }} />
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
background: hasAudio
|
||||
? "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)"
|
||||
: "linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.08) 100%)",
|
||||
borderRadius: 2,
|
||||
border: hasAudio
|
||||
? "1px solid rgba(16, 185, 129, 0.2)"
|
||||
: "1px solid rgba(245, 158, 11, 0.2)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}>
|
||||
<VolumeUpIcon sx={{ color: hasAudio ? "#059669" : "#d97706", fontSize: "1.25rem" }} />
|
||||
<Typography variant="subtitle2" sx={{ color: hasAudio ? "#059669" : "#d97706", fontWeight: 600 }}>
|
||||
{hasAudio ? "Audio Generated" : "Loading Audio..."}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{hasAudio && audioBlobUrl ? (
|
||||
<audio controls style={{ width: "100%", borderRadius: 8 }}>
|
||||
<source src={audioBlobUrl} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", py: 2 }}>
|
||||
<CircularProgress size={24} sx={{ color: "#d97706" }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon } from "@mui/icons-material";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
import { Script, Knobs, Scene } from "../types";
|
||||
import { BlogResearchResponse } from "../../../services/blogWriterApi";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
|
||||
import { SceneEditor } from "./SceneEditor";
|
||||
import { InlineAudioPlayer } from "../InlineAudioPlayer";
|
||||
import { aiApiClient } from "../../../api/client";
|
||||
|
||||
interface ScriptEditorProps {
|
||||
projectId: string;
|
||||
@@ -40,6 +42,15 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(true);
|
||||
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||
url: string;
|
||||
filename: string;
|
||||
duration: number;
|
||||
sceneCount: number;
|
||||
} | null>(null);
|
||||
|
||||
// Sync with parent state
|
||||
useEffect(() => {
|
||||
@@ -90,26 +101,32 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
|
||||
|
||||
const updateScene = (updated: Scene) => {
|
||||
if (!script) return;
|
||||
const updatedScript = { ...script, scenes: script.scenes.map((s) => (s.id === updated.id ? updated : s)) };
|
||||
setScript(updatedScript);
|
||||
onScriptChange(updatedScript);
|
||||
// Use functional update to ensure we're working with latest state
|
||||
setScript((currentScript) => {
|
||||
if (!currentScript) return currentScript;
|
||||
const updatedScript = {
|
||||
...currentScript,
|
||||
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
|
||||
};
|
||||
onScriptChange(updatedScript);
|
||||
return updatedScript;
|
||||
});
|
||||
};
|
||||
|
||||
const approveScene = async (sceneId: string) => {
|
||||
try {
|
||||
setApprovingSceneId(sceneId);
|
||||
await podcastApi.approveScene({ projectId, sceneId });
|
||||
const updatedScript = script
|
||||
? {
|
||||
...script,
|
||||
scenes: script.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
|
||||
}
|
||||
: null;
|
||||
if (updatedScript) {
|
||||
setScript(updatedScript);
|
||||
// Use functional update to ensure we're working with latest state
|
||||
setScript((currentScript) => {
|
||||
if (!currentScript) return currentScript;
|
||||
const updatedScript = {
|
||||
...currentScript,
|
||||
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
|
||||
};
|
||||
onScriptChange(updatedScript);
|
||||
}
|
||||
return updatedScript;
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to approve scene";
|
||||
setError(message);
|
||||
@@ -123,47 +140,405 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
const allApproved = script && script.scenes.every((s) => s.approved);
|
||||
const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0;
|
||||
const totalScenes = script ? script.scenes.length : 0;
|
||||
|
||||
// Check if all scenes have both audio and images (required for video rendering)
|
||||
const allScenesHaveAudioAndImages = script && script.scenes.every((s) => s.audioUrl && s.imageUrl);
|
||||
const scenesWithAudio = script ? script.scenes.filter((s) => s.audioUrl).length : 0;
|
||||
const allScenesHaveAudio = script && script.scenes.every((s) => s.audioUrl);
|
||||
|
||||
const combineAudio = useCallback(async () => {
|
||||
if (!script || !projectId) return;
|
||||
|
||||
try {
|
||||
setCombiningAudio(true);
|
||||
|
||||
const sceneIds: string[] = [];
|
||||
const sceneAudioUrls: string[] = [];
|
||||
|
||||
script.scenes.forEach((scene) => {
|
||||
if (scene.audioUrl) {
|
||||
// Ensure we're using the correct URL format (not blob URLs)
|
||||
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
|
||||
if (audioUrl) {
|
||||
sceneIds.push(scene.id);
|
||||
sceneAudioUrls.push(audioUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (sceneIds.length === 0) {
|
||||
onError("No audio files found to combine.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await podcastApi.combineAudio({
|
||||
projectId,
|
||||
sceneIds,
|
||||
sceneAudioUrls,
|
||||
});
|
||||
|
||||
// Store combined audio result for preview
|
||||
setCombinedAudioResult({
|
||||
url: result.combined_audio_url,
|
||||
filename: result.combined_audio_filename,
|
||||
duration: result.total_duration,
|
||||
sceneCount: result.scene_count,
|
||||
});
|
||||
|
||||
// Download the combined audio as blob (for authenticated endpoints)
|
||||
try {
|
||||
// Normalize path
|
||||
let audioPath = result.combined_audio_url.startsWith('/')
|
||||
? result.combined_audio_url
|
||||
: `/${result.combined_audio_url}`;
|
||||
|
||||
// Ensure it's a podcast audio endpoint
|
||||
if (!audioPath.includes('/api/podcast/audio/')) {
|
||||
const filename = audioPath.split('/').pop() || result.combined_audio_filename;
|
||||
audioPath = `/api/podcast/audio/${filename}`;
|
||||
}
|
||||
|
||||
// Remove query parameters if present
|
||||
audioPath = audioPath.split('?')[0];
|
||||
|
||||
// Fetch as blob using authenticated client
|
||||
const response = await aiApiClient.get(audioPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// Create blob URL and download
|
||||
const blob = response.data;
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = result.combined_audio_filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up blob URL after a delay
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, 100);
|
||||
} catch (downloadError) {
|
||||
console.error('Failed to download combined audio:', downloadError);
|
||||
onError('Failed to download audio file. You can try downloading again from the preview.');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to combine audio";
|
||||
onError(`Failed to combine audio: ${message}`);
|
||||
} finally {
|
||||
setCombiningAudio(false);
|
||||
}
|
||||
}, [script, projectId, onError]);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
|
||||
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
|
||||
Back to Research
|
||||
</SecondaryButton>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
fontWeight: 800,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon />
|
||||
Script Editor
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.02em",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
fontSize: { xs: "1.75rem", md: "2rem" },
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
||||
Script Editor
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
||||
Review and refine your podcast script before rendering
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{loading && (
|
||||
<Alert severity="info" icon={<CircularProgress size={20} />} sx={{ mb: 3 }}>
|
||||
<Typography variant="body2">Generating script with AI... This may take a moment.</Typography>
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<CircularProgress size={20} />}
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.2)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 2px rgba(99, 102, 241, 0.05)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#6366f1",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500 }}>
|
||||
Generating script with AI... This may take a moment.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: "linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.2)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 2px rgba(239, 68, 68, 0.05)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#ef4444",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{script && (
|
||||
<Stack spacing={3}>
|
||||
<Alert severity="info" sx={{ background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
|
||||
{/* Script Format Explanation Panel */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 2px 8px rgba(99, 102, 241, 0.08)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 2px 8px rgba(102, 126, 234, 0.3)",
|
||||
}}
|
||||
>
|
||||
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
Why This Script Format?
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
|
||||
Understanding how your script creates natural, human-like audio
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
|
||||
sx={{
|
||||
color: "#6366f1",
|
||||
"&:hover": {
|
||||
background: "rgba(99, 102, 241, 0.1)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={showScriptFormatInfo}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8, mb: 2 }}>
|
||||
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>.
|
||||
The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
1
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Natural Pauses & Rhythm
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns
|
||||
and conversation flow, just like real human speech. Without these pauses, the audio would sound rushed and robotic.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
2
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Emphasis Markers
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
Lines marked with emphasis help highlight important points, statistics, or key insights. The AI voice will naturally
|
||||
stress these parts, making your podcast more engaging and easier to follow—just like a real host would emphasize important information.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
3
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Short, Conversational Sentences
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script uses shorter sentences (15-20 words) written in a conversational style. This matches how people actually
|
||||
speak, making the audio sound more natural. Long, complex sentences would sound awkward when spoken aloud.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
4
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Scene-Specific Emotions
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
Each scene has an emotional tone (excited, serious, curious, etc.) that guides the AI voice's delivery. This creates
|
||||
variety and keeps listeners engaged, just like a real podcast host would vary their tone based on the topic.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 32,
|
||||
height: 32,
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>
|
||||
5
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>
|
||||
Optimized for Podcast Narration
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
The script is optimized with slightly slower pacing and natural pronunciation settings specifically for podcast narration.
|
||||
This ensures clarity and makes the content easy to understand, even when listeners are multitasking.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mt: 1,
|
||||
background: "rgba(99, 102, 241, 0.06)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#6366f1",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
|
||||
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences.
|
||||
The format will be preserved when rendering, ensuring your audio still sounds natural and professional.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.2)",
|
||||
borderRadius: 2,
|
||||
boxShadow: "0 1px 2px rgba(99, 102, 241, 0.05)",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#6366f1",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", fontWeight: 500, lineHeight: 1.6 }}>
|
||||
<strong style={{ fontWeight: 600 }}>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
@@ -179,8 +554,27 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
scene={scene}
|
||||
onUpdateScene={updateScene}
|
||||
onApprove={approveScene}
|
||||
onPreviewLine={(text) => podcastApi.previewLine(text)}
|
||||
knobs={knobs}
|
||||
approvingSceneId={approvingSceneId}
|
||||
generatingAudioId={generatingAudioId}
|
||||
onAudioGenerationStart={(sceneId) => {
|
||||
setGeneratingAudioId(sceneId);
|
||||
}}
|
||||
onAudioGenerated={async (sceneId, audioUrl) => {
|
||||
setGeneratingAudioId(null);
|
||||
// Use functional update to ensure we're working with latest state
|
||||
// Ensure scene is marked as approved and has audioUrl
|
||||
setScript((currentScript) => {
|
||||
if (!currentScript) return currentScript;
|
||||
const updatedScenes = currentScript.scenes.map((s) =>
|
||||
s.id === sceneId ? { ...s, audioUrl, approved: true } : s
|
||||
);
|
||||
const updatedScript = { ...currentScript, scenes: updatedScenes };
|
||||
onScriptChange(updatedScript);
|
||||
return updatedScript;
|
||||
});
|
||||
}}
|
||||
idea={idea}
|
||||
/>
|
||||
</GlassyCard>
|
||||
))}
|
||||
@@ -188,39 +582,187 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: alpha("#1e293b", 0.6),
|
||||
border: allApproved ? "2px solid rgba(16,185,129,0.4)" : "1px solid rgba(255,255,255,0.1)",
|
||||
p: 3.5,
|
||||
background: allApproved
|
||||
? "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%)"
|
||||
: "#ffffff",
|
||||
border: allApproved
|
||||
? "2px solid rgba(16, 185, 129, 0.25)"
|
||||
: "1px solid rgba(15, 23, 42, 0.08)",
|
||||
borderRadius: 3,
|
||||
boxShadow: allApproved
|
||||
? "0 4px 6px rgba(16, 185, 129, 0.08), 0 8px 24px rgba(16, 185, 129, 0.06)"
|
||||
: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
|
||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<CheckCircleIcon fontSize="small" color={allApproved ? "success" : "disabled"} />
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
<CheckCircleIcon fontSize="small" sx={{ color: allApproved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />
|
||||
Approval Status
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
|
||||
{approvedCount} of {totalScenes} scenes approved
|
||||
{!allApproved && " — Approve all scenes to enable rendering"}
|
||||
{allScenesHaveAudioAndImages && " • All scenes ready for video rendering"}
|
||||
{!allScenesHaveAudioAndImages && allApproved && " • Generate images for all scenes to enable video rendering"}
|
||||
{!allApproved && " — Approve all scenes first"}
|
||||
</Typography>
|
||||
{!allApproved && (
|
||||
{!allScenesHaveAudioAndImages && (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(approvedCount / totalScenes) * 100}
|
||||
value={
|
||||
allScenesHaveAudioAndImages
|
||||
? 100
|
||||
: script
|
||||
? (script.scenes.filter((s) => s.audioUrl && s.imageUrl).length / totalScenes) * 100
|
||||
: 0
|
||||
}
|
||||
sx={{ mt: 1, height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={() => script && onProceedToRendering(script)}
|
||||
disabled={!allApproved}
|
||||
disabled={!allScenesHaveAudioAndImages}
|
||||
startIcon={<PlayArrowIcon />}
|
||||
tooltip={!allApproved ? "Approve all scenes to proceed to rendering" : "Start rendering all approved scenes"}
|
||||
tooltip={
|
||||
!allScenesHaveAudioAndImages
|
||||
? "Generate audio and images for all scenes to proceed to video rendering"
|
||||
: "Proceed to video rendering (all scenes have audio and images)"
|
||||
}
|
||||
>
|
||||
Proceed to Rendering
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Download Audio-Only Podcast Section */}
|
||||
{allScenesHaveAudio && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)",
|
||||
border: "1px solid rgba(102, 126, 234, 0.15)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600 }}>
|
||||
Download Audio-Only Podcast
|
||||
</Typography>
|
||||
|
||||
{!combinedAudioResult ? (
|
||||
<>
|
||||
<PrimaryButton
|
||||
onClick={combineAudio}
|
||||
disabled={combiningAudio}
|
||||
loading={combiningAudio}
|
||||
startIcon={<DownloadIcon />}
|
||||
tooltip="Combine all scene audio files into a single podcast episode"
|
||||
sx={{
|
||||
minWidth: 280,
|
||||
fontSize: "1rem",
|
||||
py: 1.5,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{combiningAudio ? "Combining Audio..." : "Download Audio-Only Podcast"}
|
||||
</PrimaryButton>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontStyle: "italic" }}>
|
||||
This will combine all {scenesWithAudio} scene audio files into one complete podcast episode.
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{/* Success Alert */}
|
||||
<Alert
|
||||
severity="success"
|
||||
sx={{
|
||||
background: alpha("#10b981", 0.1),
|
||||
border: "1px solid rgba(16,185,129,0.3)",
|
||||
"& .MuiAlert-icon": { color: "#10b981" },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>
|
||||
✅ Combined audio generated successfully! ({combinedAudioResult.sceneCount} scenes,{" "}
|
||||
{Math.round(combinedAudioResult.duration)}s)
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Combined Audio Preview */}
|
||||
<InlineAudioPlayer audioUrl={combinedAudioResult.url} title="Complete Podcast Episode" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<SecondaryButton
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Normalize path
|
||||
let audioPath = combinedAudioResult.url.startsWith('/')
|
||||
? combinedAudioResult.url
|
||||
: `/${combinedAudioResult.url}`;
|
||||
|
||||
// Ensure it's a podcast audio endpoint
|
||||
if (!audioPath.includes('/api/podcast/audio/')) {
|
||||
const filename = audioPath.split('/').pop() || combinedAudioResult.filename;
|
||||
audioPath = `/api/podcast/audio/${filename}`;
|
||||
}
|
||||
|
||||
// Remove query parameters if present
|
||||
audioPath = audioPath.split('?')[0];
|
||||
|
||||
// Fetch as blob using authenticated client
|
||||
const response = await aiApiClient.get(audioPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// Create blob URL and download
|
||||
const blob = response.data;
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = combinedAudioResult.filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up blob URL after a delay
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to download audio:', error);
|
||||
onError('Failed to download audio file. Please try again.');
|
||||
}
|
||||
}}
|
||||
startIcon={<DownloadIcon />}
|
||||
tooltip="Download the combined audio file again"
|
||||
>
|
||||
Download Again
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
setCombinedAudioResult(null);
|
||||
combineAudio();
|
||||
}}
|
||||
disabled={combiningAudio}
|
||||
loading={combiningAudio}
|
||||
startIcon={<RefreshIcon />}
|
||||
tooltip="Regenerate combined audio (useful if scenes were updated)"
|
||||
>
|
||||
Regenerate
|
||||
</SecondaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -30,6 +30,11 @@ export type Research = {
|
||||
why: string;
|
||||
mappedFactIds: string[];
|
||||
}[];
|
||||
searchQueries?: string[];
|
||||
searchType?: string;
|
||||
provider?: string;
|
||||
cost?: number;
|
||||
sourceCount?: number;
|
||||
};
|
||||
|
||||
export type Line = {
|
||||
@@ -37,6 +42,7 @@ export type Line = {
|
||||
speaker: string;
|
||||
text: string;
|
||||
usedFactIds?: string[];
|
||||
emphasis?: boolean; // Mark lines that need vocal emphasis
|
||||
};
|
||||
|
||||
export type Scene = {
|
||||
@@ -45,6 +51,9 @@ export type Scene = {
|
||||
duration: number;
|
||||
lines: Line[];
|
||||
approved?: boolean;
|
||||
emotion?: string; // Scene-specific emotion
|
||||
audioUrl?: string; // Generated audio URL for this scene
|
||||
imageUrl?: string; // Generated image URL for this scene (for video generation)
|
||||
};
|
||||
|
||||
export type Script = {
|
||||
@@ -75,6 +84,7 @@ export type Job = {
|
||||
voiceId?: string | null;
|
||||
fileSize?: number | null;
|
||||
avatarImageUrl?: string | null;
|
||||
imageUrl?: string | null; // Scene-specific image URL
|
||||
};
|
||||
|
||||
export type PodcastAnalysis = {
|
||||
@@ -84,6 +94,15 @@ export type PodcastAnalysis = {
|
||||
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
|
||||
suggestedKnobs: Knobs;
|
||||
titleSuggestions: string[];
|
||||
exaSuggestedConfig?: {
|
||||
exa_search_type?: "auto" | "keyword" | "neural";
|
||||
exa_category?: string;
|
||||
exa_include_domains?: string[];
|
||||
exa_exclude_domains?: string[];
|
||||
max_sources?: number;
|
||||
include_statistics?: boolean;
|
||||
date_range?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PodcastEstimate = {
|
||||
|
||||
@@ -5,10 +5,16 @@ import { Paper, alpha } from "@mui/material";
|
||||
export const GlassyCard = motion(Paper);
|
||||
|
||||
export const glassyCardSx = {
|
||||
borderRadius: 2,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
borderRadius: 3,
|
||||
border: "1px solid rgba(15, 23, 42, 0.06)",
|
||||
background: "#ffffff",
|
||||
p: 2.5,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
p: 3,
|
||||
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06), 0 4px 12px rgba(15, 23, 42, 0.04)",
|
||||
color: "#0f172a",
|
||||
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
"&:hover": {
|
||||
boxShadow: "0 4px 6px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(15, 23, 42, 0.06)",
|
||||
borderColor: "rgba(15, 23, 42, 0.1)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Button, CircularProgress, Tooltip, alpha } from "@mui/material";
|
||||
import { Button, CircularProgress, Tooltip, alpha, SxProps, Theme } from "@mui/material";
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
children: React.ReactNode;
|
||||
@@ -9,6 +9,7 @@ interface PrimaryButtonProps {
|
||||
startIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
@@ -19,6 +20,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
startIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
sx,
|
||||
}) => {
|
||||
const button = (
|
||||
<Button
|
||||
@@ -41,6 +43,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
background: alpha("#9ca3af", 0.3),
|
||||
color: alpha("#fff", 0.5),
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from "react";
|
||||
import { Button, Tooltip, alpha } from "@mui/material";
|
||||
import { Button, Tooltip, CircularProgress, alpha } from "@mui/material";
|
||||
|
||||
interface SecondaryButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
startIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
@@ -14,6 +15,7 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
startIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
@@ -22,8 +24,8 @@ export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
startIcon={startIcon}
|
||||
disabled={disabled || loading}
|
||||
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
|
||||
aria-label={ariaLabel}
|
||||
sx={{
|
||||
borderColor: "rgba(255,255,255,0.2)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -6,10 +6,17 @@ import {
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
TrendingDown as TrendingDownIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
LineChart,
|
||||
LazyLineChart,
|
||||
LazyAreaChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
@@ -17,13 +24,8 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
AreaChart
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
ChartLoadingFallback
|
||||
} from '../../utils/lazyRecharts';
|
||||
|
||||
// Types
|
||||
import { UsageTrends as UsageTrendsType, CostProjections } from '../../types/billing';
|
||||
@@ -113,7 +115,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
{/* Header */}
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold', mb: 2 }}>
|
||||
<TrendingUp size={20} />
|
||||
<TrendingUpIcon fontSize="small" />
|
||||
Usage Trends & Projections
|
||||
</Typography>
|
||||
</CardContent>
|
||||
@@ -138,9 +140,9 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{costGrowth >= 0 ? (
|
||||
<TrendingUp size={16} color="#22c55e" />
|
||||
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
|
||||
) : (
|
||||
<TrendingDown size={16} color="#ef4444" />
|
||||
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cost Growth
|
||||
@@ -176,9 +178,9 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mb: 1 }}>
|
||||
{callsGrowth >= 0 ? (
|
||||
<TrendingUp size={16} color="#22c55e" />
|
||||
<TrendingUpIcon sx={{ fontSize: 16, color: '#22c55e' }} />
|
||||
) : (
|
||||
<TrendingDown size={16} color="#ef4444" />
|
||||
<TrendingDownIcon sx={{ fontSize: 16, color: '#ef4444' }} />
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Calls Growth
|
||||
@@ -205,7 +207,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
</Typography>
|
||||
<Box sx={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<LazyAreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#667eea" stopOpacity={0.3}/>
|
||||
@@ -234,7 +237,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
fillOpacity={1}
|
||||
fill="url(#costGradient)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</LazyAreaChart>
|
||||
</Suspense>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -246,7 +250,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
</Typography>
|
||||
<Box sx={{ height: 150 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<Suspense fallback={<ChartLoadingFallback />}>
|
||||
<LazyLineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
@@ -267,7 +272,8 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
dot={{ fill: '#764ba2', strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: '#764ba2', strokeWidth: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</LazyLineChart>
|
||||
</Suspense>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -282,7 +288,7 @@ const UsageTrends: React.FC<UsageTrendsProps> = ({
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Calendar size={16} />
|
||||
<CalendarIcon fontSize="small" />
|
||||
Monthly Projections
|
||||
</Typography>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user