WIP: AI Podcast Maker and YouTube Creator Studio integration
This commit is contained in:
@@ -12,6 +12,7 @@ import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||
import StoryWriter from './components/StoryWriter/StoryWriter';
|
||||
import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator';
|
||||
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
|
||||
import { ProductMarketingDashboard } from './components/ProductMarketing';
|
||||
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
|
||||
@@ -453,6 +454,7 @@ const App: React.FC = () => {
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
|
||||
<Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} />
|
||||
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
|
||||
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
|
||||
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
@@ -120,6 +121,12 @@ const getStatusChip = (status: string) => {
|
||||
};
|
||||
|
||||
export const AssetLibrary: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Initialize filters from URL params if present
|
||||
const urlSourceModule = searchParams.get('source_module');
|
||||
const urlAssetType = searchParams.get('asset_type');
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [idSearch, setIdSearch] = useState('');
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
@@ -127,7 +134,13 @@ export const AssetLibrary: React.FC = () => {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); // Default to list like reference
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [filterType, setFilterType] = useState(() => {
|
||||
// Set filter type from URL if present
|
||||
if (urlAssetType) {
|
||||
return urlAssetType === 'audio' ? 'audio' : urlAssetType === 'image' ? 'images' : urlAssetType === 'video' ? 'videos' : urlAssetType === 'text' ? 'text' : 'all';
|
||||
}
|
||||
return 'all';
|
||||
});
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedAssets, setSelectedAssets] = useState<Set<number>>(new Set());
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -156,6 +169,11 @@ export const AssetLibrary: React.FC = () => {
|
||||
offset: page * pageSize,
|
||||
};
|
||||
|
||||
// Apply source_module from URL if present
|
||||
if (urlSourceModule) {
|
||||
baseFilters.source_module = urlSourceModule as any;
|
||||
}
|
||||
|
||||
// Combine all search terms
|
||||
const searchTerms: string[] = [];
|
||||
if (debouncedSearch) searchTerms.push(debouncedSearch);
|
||||
@@ -179,7 +197,7 @@ export const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
return baseFilters;
|
||||
}, [debouncedSearch, idSearch, modelSearch, filterType, tabValue, page, pageSize]);
|
||||
}, [debouncedSearch, idSearch, modelSearch, filterType, tabValue, page, pageSize, urlSourceModule]);
|
||||
|
||||
const { assets, loading, error, total, toggleFavorite, deleteAsset, trackUsage, refetch } = useContentAssets(filters);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { motion, type Variants, type Easing } from 'framer-motion';
|
||||
import { useTransformStudio } from '../../hooks/useTransformStudio';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { OperationButton } from '../shared/OperationButton';
|
||||
import { PreflightOperation } from '../../services/billingService';
|
||||
|
||||
const MotionPaper = motion(Paper);
|
||||
const MotionCard = motion(Card);
|
||||
@@ -146,6 +147,19 @@ export const TransformStudio: React.FC = () => {
|
||||
return imageBase64 && audioBase64;
|
||||
}, [imageBase64, audioBase64]);
|
||||
|
||||
// Define preflight operations for cost estimation
|
||||
const imageToVideoOperation: PreflightOperation = useMemo(() => ({
|
||||
provider: 'wavespeed',
|
||||
model: 'alibaba/wan-2.5/image-to-video',
|
||||
operation_type: 'image-to-video',
|
||||
}), []);
|
||||
|
||||
const talkingAvatarOperation: PreflightOperation = useMemo(() => ({
|
||||
provider: 'wavespeed',
|
||||
model: 'wavespeed-ai/infinitetalk',
|
||||
operation_type: 'talking-avatar',
|
||||
}), []);
|
||||
|
||||
const handleEstimateCost = useCallback(async () => {
|
||||
if (tabValue === 0) {
|
||||
// Image-to-video
|
||||
@@ -510,13 +524,13 @@ export const TransformStudio: React.FC = () => {
|
||||
Estimate Cost
|
||||
</Button>
|
||||
<OperationButton
|
||||
operation={imageToVideoOperation}
|
||||
label="Generate Video"
|
||||
onClick={handleGenerate}
|
||||
disabled={!canGenerateImageToVideo || isGenerating}
|
||||
loading={isGenerating}
|
||||
fullWidth
|
||||
>
|
||||
Generate Video
|
||||
</OperationButton>
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</MotionCard>
|
||||
@@ -583,7 +597,6 @@ export const TransformStudio: React.FC = () => {
|
||||
startIcon={<Upload />}
|
||||
fullWidth
|
||||
sx={{ py: 2 }}
|
||||
required
|
||||
>
|
||||
{audioBase64 ? 'Change Audio' : 'Upload Audio (Required)'}
|
||||
</Button>
|
||||
@@ -709,13 +722,13 @@ export const TransformStudio: React.FC = () => {
|
||||
Estimate Cost
|
||||
</Button>
|
||||
<OperationButton
|
||||
operation={talkingAvatarOperation}
|
||||
label="Generate Avatar"
|
||||
onClick={handleGenerate}
|
||||
disabled={!canGenerateTalkingAvatar || isGenerating}
|
||||
loading={isGenerating}
|
||||
fullWidth
|
||||
>
|
||||
Generate Avatar
|
||||
</OperationButton>
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</MotionCard>
|
||||
|
||||
@@ -269,9 +269,14 @@ const GenerateChip: React.FC<{
|
||||
|
||||
const IconComponent = chip.icon;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
if (chip.label === 'Today' && onTodayClick) {
|
||||
onTodayClick();
|
||||
} else if (chip.label === 'Video') {
|
||||
// Navigate to YouTube Creator
|
||||
navigate('/youtube-creator');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -446,6 +451,8 @@ const GeneratePillarChips: React.FC<{
|
||||
index: number;
|
||||
isHovered?: boolean;
|
||||
}> = ({ index, isHovered = false }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Generate pillar Today tasks
|
||||
const generateTodayTasks: TodayTask[] = [
|
||||
{
|
||||
@@ -461,7 +468,7 @@ const GeneratePillarChips: React.FC<{
|
||||
icon: FacebookIcon,
|
||||
color: '#1877F2',
|
||||
enabled: true,
|
||||
action: () => console.log('Navigate to Facebook writer')
|
||||
action: () => navigate('/facebook-writer')
|
||||
},
|
||||
{
|
||||
id: 'blog-post',
|
||||
@@ -491,7 +498,22 @@ const GeneratePillarChips: React.FC<{
|
||||
icon: LinkedInIcon,
|
||||
color: '#0077B5',
|
||||
enabled: true,
|
||||
action: () => console.log('Navigate to LinkedIn writer')
|
||||
action: () => navigate('/linkedin-writer')
|
||||
},
|
||||
{
|
||||
id: 'youtube-video',
|
||||
pillarId: 'generate',
|
||||
title: 'Create YouTube Video with AI',
|
||||
description: 'Generate AI-powered YouTube videos from your ideas',
|
||||
status: 'pending' as const,
|
||||
priority: 'high' as const,
|
||||
estimatedTime: 25,
|
||||
actionType: 'navigate' as const,
|
||||
actionUrl: '/youtube-creator',
|
||||
icon: VideoIcon,
|
||||
color: '#E91E63',
|
||||
enabled: true,
|
||||
action: () => navigate('/youtube-creator')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -546,6 +568,16 @@ const GeneratePillarChips: React.FC<{
|
||||
delay={index * 5}
|
||||
/>
|
||||
|
||||
{/* Video Chip - Always Visible (Primary Action) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
<GenerateChip chip={generateChips.video} delay={index * 5 + 1} />
|
||||
</motion.div>
|
||||
|
||||
{/* More Options Indicator */}
|
||||
{!isHovered && (
|
||||
<motion.div
|
||||
@@ -591,13 +623,6 @@ const GeneratePillarChips: React.FC<{
|
||||
>
|
||||
<GenerateChip chip={generateChips.audio} delay={index * 5 + 3} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<GenerateChip chip={generateChips.video} delay={index * 5 + 4} />
|
||||
</motion.div>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
157
frontend/src/components/PodcastMaker/AnalysisPanel.tsx
Normal file
157
frontend/src/components/PodcastMaker/AnalysisPanel.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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 { PodcastAnalysis } from "./types";
|
||||
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||
import { Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
|
||||
interface AnalysisPanelProps {
|
||||
analysis: PodcastAnalysis | null;
|
||||
onRegenerate?: () => void;
|
||||
}
|
||||
|
||||
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, onRegenerate }) => {
|
||||
if (!analysis) return null;
|
||||
return (
|
||||
<GlassyCard
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28 }}
|
||||
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="analysis-panel"
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: "#0f172a",
|
||||
fontWeight: 800,
|
||||
mb: 0.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<PsychologyIcon />
|
||||
AI Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Insights derived from AI analysis of your topic and content preferences
|
||||
</Typography>
|
||||
</Box>
|
||||
<SecondaryButton onClick={onRegenerate} startIcon={<RefreshIcon />} tooltip="Regenerate analysis with different parameters">
|
||||
Regenerate
|
||||
</SecondaryButton>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<InsightsIcon fontSize="small" sx={{ color: "#4f46e5" }} />
|
||||
Target Audience
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a" }}>
|
||||
{analysis.audience}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Content Type</Typography>
|
||||
<Chip label={analysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Top Keywords</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{analysis.topKeywords.map((k) => (
|
||||
<Chip
|
||||
key={k}
|
||||
label={k}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: "rgba(0,0,0,0.1)",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{analysis.suggestedOutlines.map((o) => (
|
||||
<Paper
|
||||
key={o.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
background: "#f8fafc",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5, color: "#0f172a", wordBreak: "break-word" }}>
|
||||
{o.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#475569", display: "block", wordBreak: "break-word" }}>
|
||||
{o.segments.join(" • ")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Title Suggestions</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{analysis.titleSuggestions.map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
size="small"
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: "#0f172a",
|
||||
background: "#f8fafc",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "normal",
|
||||
height: "auto",
|
||||
lineHeight: 1.3,
|
||||
"& .MuiChip-label": {
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
textAlign: "left",
|
||||
paddingTop: 0.25,
|
||||
paddingBottom: 0.25,
|
||||
},
|
||||
"&:hover": {
|
||||
background: alpha("#667eea", 0.15),
|
||||
border: "1px solid rgba(102,126,234,0.35)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
333
frontend/src/components/PodcastMaker/CreateModal.tsx
Normal file
333
frontend/src/components/PodcastMaker/CreateModal.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, Box, Typography, TextField, Divider, Button, Alert, alpha, Tooltip, Paper, Chip } from "@mui/material";
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Info as InfoIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { CreateProjectPayload, Knobs } from "./types";
|
||||
import { PrimaryButton, SecondaryButton } from "./ui";
|
||||
import { useSubscription } from "../../contexts/SubscriptionContext";
|
||||
|
||||
interface CreateModalProps {
|
||||
onCreate: (payload: CreateProjectPayload) => void;
|
||||
open: boolean;
|
||||
defaultKnobs: Knobs;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
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 [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 });
|
||||
|
||||
// Determine subscription tier restrictions
|
||||
const tier = subscription?.tier || 'free';
|
||||
const isFreeTier = tier === 'free';
|
||||
const isBasicTier = tier === 'basic';
|
||||
const canUseHD = !isFreeTier && !isBasicTier; // HD only for pro/enterprise
|
||||
const canUseMultiSpeaker = !isFreeTier; // Multi-speaker for basic+ tiers
|
||||
|
||||
// Reset HD quality if user downgrades
|
||||
useEffect(() => {
|
||||
if (!canUseHD && knobs.bitrate === 'hd') {
|
||||
setKnobs({ ...knobs, bitrate: 'standard' });
|
||||
}
|
||||
}, [canUseHD]);
|
||||
|
||||
// Reset multi-speaker if user downgrades
|
||||
useEffect(() => {
|
||||
if (!canUseMultiSpeaker && speakers > 1) {
|
||||
setSpeakers(1);
|
||||
}
|
||||
}, [canUseMultiSpeaker]);
|
||||
|
||||
// Show AI details button when user starts typing
|
||||
useEffect(() => {
|
||||
setShowAIDetailsButton(idea.trim().length > 0);
|
||||
}, [idea]);
|
||||
|
||||
const canSubmit = Boolean(idea || url);
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit || isSubmitting) return;
|
||||
onCreate({
|
||||
ideaOrUrl: idea || url,
|
||||
speakers,
|
||||
duration,
|
||||
knobs,
|
||||
budgetCap,
|
||||
files: { voiceFile, avatarFile },
|
||||
});
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setIdea("");
|
||||
setUrl("");
|
||||
setSpeakers(1);
|
||||
setDuration(10);
|
||||
setBudgetCap(50);
|
||||
setVoiceFile(null);
|
||||
setAvatarFile(null);
|
||||
setKnobs({ ...defaultKnobs });
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: "1px solid rgba(0,0,0,0.08)",
|
||||
background: "#ffffff",
|
||||
boxShadow: "0 6px 20px rgba(15, 23, 42, 0.08)",
|
||||
p: { xs: 3, md: 4 },
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<Tooltip
|
||||
title="Enter a concise idea. We will expand it into an outline only after you click Analyze."
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
placeholder="e.g., 'How AI is transforming content marketing in 2024'"
|
||||
inputProps={{
|
||||
sx: {
|
||||
"&::placeholder": { color: "#94a3b8", opacity: 1 },
|
||||
color: "#0f172a",
|
||||
},
|
||||
}}
|
||||
value={idea}
|
||||
onChange={(e) => {
|
||||
setIdea(e.target.value);
|
||||
// Clear URL when typing idea
|
||||
if (e.target.value.trim().length > 0) {
|
||||
setUrl("");
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
helperText="We will not start analysis until you click Analyze."
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
},
|
||||
"& .MuiOutlinedInput-input": {
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: 1.5,
|
||||
color: "#0f172a",
|
||||
},
|
||||
},
|
||||
"& .MuiInputBase-input::placeholder": {
|
||||
color: "#94a3b8",
|
||||
opacity: 1,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#475569",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* Add details with AI button - appears when user types */}
|
||||
{showAIDetailsButton && (
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
onClick={() => {
|
||||
// TODO: Implement AI details functionality
|
||||
console.log("Add details with AI clicked");
|
||||
}}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: "0.875rem",
|
||||
borderColor: "#667eea",
|
||||
color: "#667eea",
|
||||
"&:hover": {
|
||||
borderColor: "#5568d3",
|
||||
backgroundColor: alpha("#667eea", 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add details with AI
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Blog URL Section */}
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", fontWeight: 700 }}>
|
||||
Blog Post URL
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Paste a single article URL. We’ll fetch insights only after you click Analyze."
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Paste blog post URL"
|
||||
placeholder="https://yourblog.com/article"
|
||||
inputProps={{
|
||||
sx: {
|
||||
"&::placeholder": { color: "#94a3b8", opacity: 1 },
|
||||
color: "#0f172a",
|
||||
},
|
||||
}}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
// Clear idea when entering URL
|
||||
if (e.target.value.trim().length > 0) {
|
||||
setIdea("");
|
||||
setShowAIDetailsButton(false);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
helperText="We won’t trigger analysis until you confirm."
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<Tooltip title="One URL is enough—keep it focused to reduce retries." arrow>
|
||||
<InfoIcon sx={{ color: "action.disabled", fontSize: 18, ml: 1 }} />
|
||||
</Tooltip>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#f8fafc",
|
||||
"&:hover": {
|
||||
backgroundColor: "#f1f5f9",
|
||||
},
|
||||
},
|
||||
"& .MuiInputBase-input::placeholder": {
|
||||
color: "#94a3b8",
|
||||
opacity: 1,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
color: "#475569",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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”.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1}>
|
||||
<SecondaryButton onClick={reset} startIcon={<RefreshIcon />}>
|
||||
Reset
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={submit}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
tooltip={!canSubmit ? "Enter an idea or URL to continue" : "We’ll start AI analysis after this click"}
|
||||
>
|
||||
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
92
frontend/src/components/PodcastMaker/FactCard.tsx
Normal file
92
frontend/src/components/PodcastMaker/FactCard.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Stack, Typography, Divider, Chip, Tooltip, IconButton, alpha } from "@mui/material";
|
||||
import { OpenInNew as OpenInNewIcon, ContentCopy as ContentCopyIcon } from "@mui/icons-material";
|
||||
import { Fact } from "./types";
|
||||
import { GlassyCard, glassyCardSx } from "./ui";
|
||||
|
||||
interface FactCardProps {
|
||||
fact: Fact;
|
||||
}
|
||||
|
||||
export const FactCard: React.FC<FactCardProps> = ({ fact }) => {
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(fact.url).hostname;
|
||||
} catch {
|
||||
return fact.url;
|
||||
}
|
||||
}, [fact.url]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(fact.quote);
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassyCard
|
||||
whileHover={{ y: -4 }}
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
p: 2,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102,126,234,0.25)",
|
||||
boxShadow: "0 12px 28px rgba(15,23,42,0.08)",
|
||||
},
|
||||
background: "#ffffff",
|
||||
border: "1px solid rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.6, color: "#0f172a" }}>
|
||||
{fact.quote}
|
||||
</Typography>
|
||||
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={1} alignItems="center" flex={1}>
|
||||
<OpenInNewIcon fontSize="small" sx={{ color: "rgba(15,23,42,0.6)" }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="a"
|
||||
href={fact.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{
|
||||
color: "#4f46e5",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{hostname || "source"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Tooltip title="Copy citation">
|
||||
<IconButton size="small" onClick={handleCopy} sx={{ color: "rgba(15,23,42,0.65)" }}>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Chip
|
||||
label={`${(fact.confidence * 100).toFixed(0)}% confidence`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: "0.65rem",
|
||||
background: alpha("#22c55e", 0.15),
|
||||
color: "#15803d",
|
||||
border: "1px solid rgba(34,197,94,0.35)",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "#475569" }}>
|
||||
{fact.date}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
118
frontend/src/components/PodcastMaker/InlineAudioPlayer.tsx
Normal file
118
frontend/src/components/PodcastMaker/InlineAudioPlayer.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Paper, Stack, Typography, IconButton, Tooltip, alpha } from "@mui/material";
|
||||
import { VolumeUp as VolumeUpIcon, PlayCircle as PlayCircleIcon, PauseCircle as PauseCircleIcon, Download as DownloadIcon } from "@mui/icons-material";
|
||||
|
||||
interface InlineAudioPlayerProps {
|
||||
audioUrl: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const InlineAudioPlayer: React.FC<InlineAudioPlayerProps> = ({ audioUrl, title }) => {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const audioRef = React.useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||
const updateDuration = () => setDuration(audio.duration);
|
||||
const handleEnd = () => setPlaying(false);
|
||||
|
||||
audio.addEventListener("timeupdate", updateTime);
|
||||
audio.addEventListener("loadedmetadata", updateDuration);
|
||||
audio.addEventListener("ended", handleEnd);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", updateTime);
|
||||
audio.removeEventListener("loadedmetadata", updateDuration);
|
||||
audio.removeEventListener("ended", handleEnd);
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
setPlaying(!playing);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const newTime = parseFloat(e.target.value);
|
||||
audio.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
background: alpha("#1e293b", 0.6),
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5}>
|
||||
{title && (
|
||||
<Typography variant="subtitle2" sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<VolumeUpIcon fontSize="small" />
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<IconButton onClick={togglePlay} sx={{ color: "#a78bfa" }} size="large">
|
||||
{playing ? <PauseCircleIcon fontSize="large" /> : <PlayCircleIcon fontSize="large" />}
|
||||
</IconButton>
|
||||
<Stack flex={1}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
style={{ width: "100%", cursor: "pointer" }}
|
||||
/>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatTime(currentTime)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatTime(duration)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Tooltip title="Download audio">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
const link = document.createElement("a");
|
||||
link.href = audioUrl;
|
||||
link.download = title || "podcast-audio.mp3";
|
||||
link.click();
|
||||
}}
|
||||
sx={{ color: "rgba(255,255,255,0.7)" }}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
134
frontend/src/components/PodcastMaker/PreflightBlockDialog.tsx
Normal file
134
frontend/src/components/PodcastMaker/PreflightBlockDialog.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
Alert,
|
||||
Stack,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Block as BlockIcon,
|
||||
Upgrade as UpgradeIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { PreflightCheckResponse } from '../../services/billingService';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface PreflightBlockDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
response: PreflightCheckResponse | null;
|
||||
operationName?: string;
|
||||
}
|
||||
|
||||
export const PreflightBlockDialog: React.FC<PreflightBlockDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
response,
|
||||
operationName = 'This operation',
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!response) return null;
|
||||
|
||||
const blockedOperation = response.operations.find((op) => !op.allowed);
|
||||
const message = blockedOperation?.message || 'Operation blocked by subscription limits';
|
||||
const limitInfo = blockedOperation?.limit_info;
|
||||
|
||||
const handleUpgrade = () => {
|
||||
navigate('/pricing');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: alpha('#0f172a', 0.95),
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<BlockIcon sx={{ color: '#ef4444', fontSize: 32 }} />
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
Operation Blocked
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{operationName} cannot proceed
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="error" sx={{ background: alpha('#ef4444', 0.1), border: '1px solid rgba(239,68,68,0.3)' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{message}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{limitInfo && (
|
||||
<Box sx={{ p: 2, background: alpha('#667eea', 0.1), borderRadius: 2, border: '1px solid rgba(102,126,234,0.3)' }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<InfoIcon fontSize="small" />
|
||||
Usage Limits
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Current: {limitInfo.current_usage.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Limit: {limitInfo.limit.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Remaining: {limitInfo.remaining.toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{response.estimated_cost > 0 && (
|
||||
<Box sx={{ p: 2, background: alpha('#f59e0b', 0.1), borderRadius: 2, border: '1px solid rgba(245,158,11,0.3)' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Estimated Cost: ${response.estimated_cost.toFixed(4)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, pt: 2 }}>
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpgrade}
|
||||
variant="contained"
|
||||
startIcon={<UpgradeIcon />}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5568d3 0%, #6a4190 100%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
343
frontend/src/components/PodcastMaker/ProjectList.tsx
Normal file
343
frontend/src/components/PodcastMaker/ProjectList.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
alpha,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Mic as MicIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Delete as DeleteIcon,
|
||||
Star as StarIcon,
|
||||
StarBorder as StarBorderIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Search as SearchIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { podcastApi } from "../../services/podcastApi";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton, SecondaryButton } from "./ui";
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
project_id: string;
|
||||
idea: string;
|
||||
duration: number;
|
||||
speakers: number;
|
||||
current_step: string | null;
|
||||
status: string;
|
||||
is_favorite: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ProjectListProps {
|
||||
onSelectProject: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const ProjectList: React.FC<ProjectListProps> = ({ onSelectProject }) => {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await podcastApi.listProjects({
|
||||
order_by: "updated_at",
|
||||
limit: 50,
|
||||
});
|
||||
setProjects(response.projects);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load projects");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectToDelete) return;
|
||||
try {
|
||||
await podcastApi.deleteProject(projectToDelete);
|
||||
await loadProjects();
|
||||
setDeleteDialogOpen(false);
|
||||
setProjectToDelete(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete project");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (projectId: string, currentFavorite: boolean) => {
|
||||
try {
|
||||
await podcastApi.toggleFavorite(projectId);
|
||||
await loadProjects();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update favorite");
|
||||
}
|
||||
};
|
||||
|
||||
const getStepLabel = (step: string | null) => {
|
||||
switch (step) {
|
||||
case "analysis":
|
||||
return "Analysis";
|
||||
case "research":
|
||||
return "Research";
|
||||
case "script":
|
||||
return "Script";
|
||||
case "render":
|
||||
return "Rendering";
|
||||
default:
|
||||
return "Draft";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "in_progress":
|
||||
return "info";
|
||||
case "draft":
|
||||
return "default";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProjects = projects.filter((project) =>
|
||||
project.idea.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
project.project_id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)",
|
||||
p: { xs: 2, md: 4 },
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: "auto",
|
||||
borderRadius: 4,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: alpha("#0f172a", 0.7),
|
||||
backdropFilter: "blur(25px)",
|
||||
p: { xs: 3, md: 4 },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" flexWrap="wrap" gap={2}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
fontWeight: 800,
|
||||
mb: 0.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<MicIcon fontSize="large" />
|
||||
My Podcast Projects
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Resume your work or start a new episode
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<SecondaryButton onClick={loadProjects} startIcon={<RefreshIcon />} disabled={loading}>
|
||||
Refresh
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={() => navigate("/podcast-maker")} startIcon={<PlayArrowIcon />}>
|
||||
New Episode
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Search */}
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: <SearchIcon sx={{ color: "rgba(255,255,255,0.5)", mr: 1 }} />,
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
color: "white",
|
||||
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
"&:hover fieldset": { borderColor: "rgba(255,255,255,0.3)" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Projects List */}
|
||||
{!loading && filteredProjects.length === 0 && (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2} alignItems="center" sx={{ p: 4 }}>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{searchQuery ? "No projects match your search" : "No projects yet"}
|
||||
</Typography>
|
||||
<PrimaryButton onClick={() => navigate("/podcast-maker")} startIcon={<PlayArrowIcon />}>
|
||||
Create Your First Episode
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
)}
|
||||
|
||||
{!loading && filteredProjects.length > 0 && (
|
||||
<Stack spacing={2}>
|
||||
{filteredProjects.map((project) => (
|
||||
<GlassyCard
|
||||
key={project.project_id}
|
||||
sx={{
|
||||
...glassyCardSx,
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102,126,234,0.4)",
|
||||
transform: "translateY(-2px)",
|
||||
},
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
onClick={() => onSelectProject(project.project_id)}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{project.idea.length > 100 ? `${project.idea.substring(0, 100)}...` : project.idea}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label={getStepLabel(project.current_step)}
|
||||
size="small"
|
||||
color={getStatusColor(project.status)}
|
||||
sx={{ background: alpha("#667eea", 0.2), color: "#a78bfa" }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{project.speakers} {project.speakers === 1 ? "speaker" : "speakers"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{project.duration} min
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Updated {new Date(project.updated_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title={project.is_favorite ? "Remove from favorites" : "Add to favorites"}>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite(project.project_id, project.is_favorite);
|
||||
}}
|
||||
sx={{ color: project.is_favorite ? "#fbbf24" : "rgba(255,255,255,0.5)" }}
|
||||
>
|
||||
{project.is_favorite ? <StarIcon /> : <StarBorderIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete project">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setProjectToDelete(project.project_id);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
sx={{ color: "rgba(255,255,255,0.5)" }}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setProjectToDelete(null);
|
||||
}}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: alpha("#0f172a", 0.95),
|
||||
backdropFilter: "blur(20px)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ color: "white" }}>Delete Project?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ color: "rgba(255,255,255,0.7)" }}>
|
||||
Are you sure you want to delete this project? This action cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<SecondaryButton onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setProjectToDelete(null);
|
||||
}}>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={handleDelete} startIcon={<DeleteIcon />}>
|
||||
Delete
|
||||
</PrimaryButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Paper, Chip, alpha } from "@mui/material";
|
||||
import { LibraryMusic as LibraryMusicIcon, OpenInNew as OpenInNewIcon, VolumeUp as VolumeUpIcon } from "@mui/icons-material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useContentAssets } from "../../hooks/useContentAssets";
|
||||
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
|
||||
|
||||
interface RecentEpisodesPreviewProps {
|
||||
onSelectEpisode: (assetId: number) => void;
|
||||
}
|
||||
|
||||
export const RecentEpisodesPreview: React.FC<RecentEpisodesPreviewProps> = ({ onSelectEpisode }) => {
|
||||
const navigate = useNavigate();
|
||||
const { assets, loading } = useContentAssets({
|
||||
asset_type: "audio",
|
||||
source_module: "podcast_maker",
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
if (loading || assets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<LibraryMusicIcon />
|
||||
Recent Episodes
|
||||
</Typography>
|
||||
<SecondaryButton
|
||||
onClick={() => navigate("/asset-library?source_module=podcast_maker&asset_type=audio")}
|
||||
startIcon={<OpenInNewIcon />}
|
||||
>
|
||||
View All
|
||||
</SecondaryButton>
|
||||
</Stack>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
{assets.slice(0, 6).map((asset) => (
|
||||
<Paper
|
||||
key={asset.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
background: alpha("#1e293b", 0.5),
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(102,126,234,0.4)",
|
||||
background: alpha("#1e293b", 0.7),
|
||||
},
|
||||
}}
|
||||
onClick={() => onSelectEpisode(asset.id)}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{asset.title || "Untitled Episode"}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<VolumeUpIcon fontSize="small" sx={{ color: "rgba(255,255,255,0.5)" }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(asset.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{asset.cost > 0 && (
|
||||
<Chip label={`$${asset.cost.toFixed(2)}`} size="small" sx={{ width: "fit-content", fontSize: "0.65rem", height: 20 }} />
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
552
frontend/src/components/PodcastMaker/RenderQueue.tsx
Normal file
552
frontend/src/components/PodcastMaker/RenderQueue.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, Chip, Divider, LinearProgress, Button, CircularProgress, 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";
|
||||
|
||||
interface RenderQueueProps {
|
||||
projectId: string;
|
||||
script: Script;
|
||||
knobs: Knobs;
|
||||
jobs: Job[];
|
||||
budgetCap?: number;
|
||||
avatarImageUrl?: string | null;
|
||||
onUpdateJob: (sceneId: string, updates: Partial<Job>) => 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, 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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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 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) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||
<SecondaryButton onClick={onBack} startIcon={<ArrowBackIcon />}>
|
||||
Back to Script
|
||||
</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,
|
||||
}}
|
||||
>
|
||||
<PlayArrowIcon />
|
||||
Render Queue
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<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>
|
||||
|
||||
<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();
|
||||
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ mt: 3, display: "flex", justifyContent: "flex-end" }}>
|
||||
<SecondaryButton onClick={onBack}>Done</SecondaryButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
106
frontend/src/components/PodcastMaker/ScriptEditor/LineEditor.tsx
Normal file
106
frontend/src/components/PodcastMaker/ScriptEditor/LineEditor.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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 { 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 }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState(line.text);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
useEffect(() => setText(line.text), [line.text]);
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({ ...line, text });
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<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" }} />
|
||||
{editing ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
color: "white",
|
||||
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7, color: "rgba(255,255,255,0.9)" }}>
|
||||
{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">
|
||||
Facts:
|
||||
</Typography>
|
||||
{line.usedFactIds.map((id) => (
|
||||
<Chip key={id} label={id} size="small" variant="outlined" sx={{ fontSize: "0.65rem", height: 20 }} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Stack spacing={1} sx={{ ml: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={editing ? "contained" : "outlined"}
|
||||
onClick={editing ? handleSave : () => setEditing(true)}
|
||||
sx={{ minWidth: 80 }}
|
||||
>
|
||||
{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>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Divider, Chip, alpha } from "@mui/material";
|
||||
import {
|
||||
EditNote as EditNoteIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene, Line } from "../types";
|
||||
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
|
||||
import { LineEditor } from "./LineEditor";
|
||||
|
||||
interface SceneEditorProps {
|
||||
scene: Scene;
|
||||
onUpdateScene: (s: Scene) => void;
|
||||
onApprove: (id: string) => Promise<void>;
|
||||
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
|
||||
approvingSceneId?: string | null;
|
||||
}
|
||||
|
||||
export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
scene,
|
||||
onUpdateScene,
|
||||
onApprove,
|
||||
onPreviewLine,
|
||||
approvingSceneId,
|
||||
}) => {
|
||||
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 handleApprove = async () => {
|
||||
await onApprove(scene.id);
|
||||
onUpdateScene({ ...scene, approved: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassyCard sx={glassyCardSx}>
|
||||
<Stack spacing={2}>
|
||||
<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" />
|
||||
{scene.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<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)",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
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>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
|
||||
|
||||
<Stack spacing={2}>
|
||||
{scene.lines.map((line) => (
|
||||
<LineEditor key={line.id} line={line} onChange={updateLine} onPreview={(text) => onPreviewLine(text)} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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 { 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";
|
||||
|
||||
interface ScriptEditorProps {
|
||||
projectId: string;
|
||||
idea: string;
|
||||
research: any; // Research type
|
||||
rawResearch: BlogResearchResponse | null;
|
||||
knobs: Knobs;
|
||||
speakers: number;
|
||||
durationMinutes: number;
|
||||
script: Script | null;
|
||||
onScriptChange: (script: Script) => void;
|
||||
onBackToResearch: () => void;
|
||||
onProceedToRendering: (script: Script) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
projectId,
|
||||
idea,
|
||||
research,
|
||||
rawResearch,
|
||||
knobs,
|
||||
speakers,
|
||||
durationMinutes,
|
||||
script: initialScript,
|
||||
onScriptChange,
|
||||
onBackToResearch,
|
||||
onProceedToRendering,
|
||||
onError,
|
||||
}) => {
|
||||
const [script, setScript] = useState<Script | null>(initialScript);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||
|
||||
// Sync with parent state
|
||||
useEffect(() => {
|
||||
if (initialScript) {
|
||||
setScript(initialScript);
|
||||
}
|
||||
}, [initialScript]);
|
||||
|
||||
useEffect(() => {
|
||||
// If script already exists, don't regenerate
|
||||
if (script) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only generate if we have research data
|
||||
if (!rawResearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
podcastApi
|
||||
.generateScript({
|
||||
projectId,
|
||||
idea,
|
||||
research: rawResearch,
|
||||
knobs,
|
||||
speakers,
|
||||
durationMinutes,
|
||||
})
|
||||
.then((res) => {
|
||||
if (mounted) {
|
||||
setScript(res);
|
||||
onScriptChange(res);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to generate script";
|
||||
setError(message);
|
||||
onError(message);
|
||||
})
|
||||
.finally(() => mounted && setLoading(false));
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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);
|
||||
onScriptChange(updatedScript);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to approve scene";
|
||||
setError(message);
|
||||
onError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setApprovingSceneId((current) => (current === sceneId ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</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.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{script.scenes.map((scene, idx) => (
|
||||
<GlassyCard
|
||||
key={scene.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: idx * 0.1 }}
|
||||
>
|
||||
<SceneEditor
|
||||
scene={scene}
|
||||
onUpdateScene={updateScene}
|
||||
onApprove={approveScene}
|
||||
onPreviewLine={(text) => podcastApi.previewLine(text)}
|
||||
approvingSceneId={approvingSceneId}
|
||||
/>
|
||||
</GlassyCard>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
<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"} />
|
||||
Approval Status
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{approvedCount} of {totalScenes} scenes approved
|
||||
{!allApproved && " — Approve all scenes to enable rendering"}
|
||||
</Typography>
|
||||
{!allApproved && (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(approvedCount / totalScenes) * 100}
|
||||
sx={{ mt: 1, height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={() => script && onProceedToRendering(script)}
|
||||
disabled={!allApproved}
|
||||
startIcon={<PlayArrowIcon />}
|
||||
tooltip={!allApproved ? "Approve all scenes to proceed to rendering" : "Start rendering all approved scenes"}
|
||||
>
|
||||
Proceed to Rendering
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { LineEditor } from "./LineEditor";
|
||||
export { SceneEditor } from "./SceneEditor";
|
||||
export { ScriptEditor } from "./ScriptEditor";
|
||||
|
||||
@@ -67,11 +67,14 @@ export type Job = {
|
||||
progress: number;
|
||||
previewUrl?: string | null;
|
||||
finalUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
jobId?: string | null;
|
||||
taskId?: string | null;
|
||||
cost?: number | null;
|
||||
provider?: string | null;
|
||||
voiceId?: string | null;
|
||||
fileSize?: number | null;
|
||||
avatarImageUrl?: string | null;
|
||||
};
|
||||
|
||||
export type PodcastAnalysis = {
|
||||
@@ -115,5 +118,18 @@ export type RenderJobResult = {
|
||||
cost: number;
|
||||
voiceId: string;
|
||||
fileSize: number;
|
||||
videoUrl?: string;
|
||||
videoFilename?: string;
|
||||
};
|
||||
|
||||
export type TaskStatus = {
|
||||
task_id: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
progress?: number;
|
||||
message?: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
|
||||
14
frontend/src/components/PodcastMaker/ui/GlassyCard.tsx
Normal file
14
frontend/src/components/PodcastMaker/ui/GlassyCard.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
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)",
|
||||
background: "#ffffff",
|
||||
p: 2.5,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
};
|
||||
|
||||
58
frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx
Normal file
58
frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { Button, CircularProgress, Tooltip, alpha } from "@mui/material";
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
startIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
startIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const button = (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
startIcon={loading ? <CircularProgress size={16} /> : startIcon}
|
||||
aria-label={ariaLabel}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
textTransform: "none",
|
||||
px: 3,
|
||||
py: 1,
|
||||
"&:hover": {
|
||||
background: "linear-gradient(135deg, #764ba2 0%, #667eea 100%)",
|
||||
},
|
||||
"&:disabled": {
|
||||
background: alpha("#9ca3af", 0.3),
|
||||
color: alpha("#fff", 0.5),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip} arrow>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
};
|
||||
|
||||
52
frontend/src/components/PodcastMaker/ui/SecondaryButton.tsx
Normal file
52
frontend/src/components/PodcastMaker/ui/SecondaryButton.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { Button, Tooltip, alpha } from "@mui/material";
|
||||
|
||||
interface SecondaryButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
startIcon?: React.ReactNode;
|
||||
tooltip?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const SecondaryButton: React.FC<SecondaryButtonProps> = ({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
startIcon,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const button = (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
startIcon={startIcon}
|
||||
aria-label={ariaLabel}
|
||||
sx={{
|
||||
borderColor: "rgba(255,255,255,0.2)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
textTransform: "none",
|
||||
px: 2.5,
|
||||
py: 0.75,
|
||||
"&:hover": {
|
||||
borderColor: "rgba(255,255,255,0.4)",
|
||||
background: alpha("#fff", 0.05),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip} arrow>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
};
|
||||
|
||||
4
frontend/src/components/PodcastMaker/ui/index.ts
Normal file
4
frontend/src/components/PodcastMaker/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { GlassyCard, glassyCardSx } from "./GlassyCard";
|
||||
export { PrimaryButton } from "./PrimaryButton";
|
||||
export { SecondaryButton } from "./SecondaryButton";
|
||||
|
||||
@@ -477,10 +477,12 @@ export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCa
|
||||
variant="contained"
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isCreatingBlueprint ||
|
||||
isGeneratingProposals ||
|
||||
isValidatingPreflight ||
|
||||
(preflightResult && !preflightResult.can_proceed)
|
||||
Boolean(
|
||||
isCreatingBlueprint ||
|
||||
isGeneratingProposals ||
|
||||
isValidatingPreflight ||
|
||||
(preflightResult ? !preflightResult.can_proceed : false)
|
||||
)
|
||||
}
|
||||
startIcon={
|
||||
isCreatingBlueprint || isGeneratingProposals ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, TextField, Stack, Typography } from '@mui/material';
|
||||
import { Product as ProductIcon } from '@mui/icons-material';
|
||||
import { Inventory2 as ProductIcon } from '@mui/icons-material';
|
||||
|
||||
interface ProductInfoFormProps {
|
||||
productName: string;
|
||||
|
||||
453
frontend/src/components/YouTubeCreator/YouTubeCreator.tsx
Normal file
453
frontend/src/components/YouTubeCreator/YouTubeCreator.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* YouTube Creator Studio Component
|
||||
*
|
||||
* AI-first YouTube video creation tool with persona integration.
|
||||
* Three-phase workflow: Plan → Scenes → Render
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Paper,
|
||||
Button,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { youtubeApi, type VideoPlan, type Scene } from '../../services/youtubeApi';
|
||||
import { STEPS, YT_RED, YT_BG, YT_BORDER, YT_TEXT, type Resolution, type DurationType } from './constants';
|
||||
import { PlanStep } from './components/PlanStep';
|
||||
import { ScenesStep } from './components/ScenesStep';
|
||||
import { RenderStep } from './components/RenderStep';
|
||||
import { useRenderPolling } from './hooks/useRenderPolling';
|
||||
import { useCostEstimate } from './hooks/useCostEstimate';
|
||||
import HeaderControls from '../shared/HeaderControls';
|
||||
|
||||
const YouTubeCreator: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Step 1: Plan
|
||||
const [userIdea, setUserIdea] = useState('');
|
||||
const [durationType, setDurationType] = useState<DurationType>('medium');
|
||||
const [referenceImage, setReferenceImage] = useState('');
|
||||
const [videoPlan, setVideoPlan] = useState<VideoPlan | null>(null);
|
||||
|
||||
// Step 2: Scenes
|
||||
const [scenes, setScenes] = useState<Scene[]>([]);
|
||||
const [editingSceneId, setEditingSceneId] = useState<number | null>(null);
|
||||
const [editedScene, setEditedScene] = useState<Partial<Scene> | null>(null);
|
||||
|
||||
// Step 3: Render
|
||||
const [renderTaskId, setRenderTaskId] = useState<string | null>(null);
|
||||
const [renderStatus, setRenderStatus] = useState<any>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const [resolution, setResolution] = useState<Resolution>('720p');
|
||||
const [combineScenes, setCombineScenes] = useState(true);
|
||||
|
||||
// Custom hooks
|
||||
const { renderStatus: polledStatus, renderProgress: polledProgress, error: pollingError } = useRenderPolling(
|
||||
renderTaskId,
|
||||
() => setSuccess('Video rendered successfully!'),
|
||||
(err) => setError(err)
|
||||
);
|
||||
|
||||
// Update local state from polling hook
|
||||
React.useEffect(() => {
|
||||
if (polledStatus) {
|
||||
setRenderStatus(polledStatus);
|
||||
}
|
||||
if (polledProgress !== undefined) {
|
||||
setRenderProgress(polledProgress);
|
||||
}
|
||||
if (pollingError) {
|
||||
setError(pollingError);
|
||||
}
|
||||
}, [polledStatus, polledProgress, pollingError]);
|
||||
|
||||
const { costEstimate, loadingCostEstimate } = useCostEstimate({
|
||||
activeStep,
|
||||
scenes,
|
||||
resolution,
|
||||
renderTaskId,
|
||||
});
|
||||
|
||||
// Memoized computed values
|
||||
const enabledScenesCount = useMemo(
|
||||
() => scenes.filter(s => s.enabled !== false).length,
|
||||
[scenes]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleGeneratePlan = useCallback(async () => {
|
||||
if (!userIdea.trim()) {
|
||||
setError('Please enter your video idea');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await youtubeApi.createPlan({
|
||||
user_idea: userIdea,
|
||||
duration_type: durationType,
|
||||
reference_image_description: referenceImage || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.plan) {
|
||||
setVideoPlan(response.plan);
|
||||
setSuccess('Video plan generated successfully!');
|
||||
setTimeout(() => {
|
||||
setActiveStep(1);
|
||||
setSuccess(null);
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(response.message || 'Failed to generate plan');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to generate video plan');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userIdea, durationType, referenceImage]);
|
||||
|
||||
const handleBuildScenes = useCallback(async () => {
|
||||
if (!videoPlan) {
|
||||
setError('Please generate a plan first');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await youtubeApi.buildScenes(videoPlan);
|
||||
|
||||
if (response.success && response.scenes) {
|
||||
setScenes(response.scenes.map(s => ({ ...s, enabled: s.enabled !== false })));
|
||||
setSuccess(`Built ${response.scenes.length} scenes successfully!`);
|
||||
setTimeout(() => {
|
||||
setActiveStep(2);
|
||||
setSuccess(null);
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(response.message || 'Failed to build scenes');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to build scenes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [videoPlan]);
|
||||
|
||||
const handleEditScene = useCallback((scene: Scene) => {
|
||||
setEditingSceneId(scene.scene_number);
|
||||
setEditedScene({
|
||||
narration: scene.narration,
|
||||
visual_prompt: scene.visual_prompt,
|
||||
duration_estimate: scene.duration_estimate,
|
||||
enabled: scene.enabled !== false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSaveScene = useCallback(async () => {
|
||||
if (!editingSceneId || !editedScene) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await youtubeApi.updateScene(editingSceneId, {
|
||||
narration: editedScene.narration,
|
||||
visual_description: editedScene.visual_prompt,
|
||||
duration_estimate: editedScene.duration_estimate,
|
||||
enabled: editedScene.enabled,
|
||||
});
|
||||
|
||||
if (response.success && response.scene) {
|
||||
setScenes(scenes.map(s =>
|
||||
s.scene_number === editingSceneId ? { ...s, ...response.scene } : s
|
||||
));
|
||||
setEditingSceneId(null);
|
||||
setEditedScene(null);
|
||||
setSuccess('Scene updated successfully!');
|
||||
} else {
|
||||
setError(response.message || 'Failed to update scene');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update scene');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [editingSceneId, editedScene, scenes]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingSceneId(null);
|
||||
setEditedScene(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleScene = useCallback((sceneNumber: number) => {
|
||||
setScenes(scenes.map(s =>
|
||||
s.scene_number === sceneNumber ? { ...s, enabled: !s.enabled } : s
|
||||
));
|
||||
}, [scenes]);
|
||||
|
||||
const handleStartRender = useCallback(async () => {
|
||||
if (scenes.length === 0) {
|
||||
setError('Please build scenes first');
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledScenes = scenes.filter(s => s.enabled !== false);
|
||||
if (enabledScenes.length === 0) {
|
||||
setError('Please enable at least one scene to render');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!videoPlan) {
|
||||
setError('Video plan is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await youtubeApi.startRender({
|
||||
scenes: enabledScenes,
|
||||
video_plan: videoPlan,
|
||||
resolution,
|
||||
combine_scenes: combineScenes,
|
||||
});
|
||||
|
||||
if (response.success && response.task_id) {
|
||||
setRenderTaskId(response.task_id);
|
||||
setRenderProgress(0);
|
||||
setSuccess('Video rendering started!');
|
||||
} else {
|
||||
setError(response.message || 'Failed to start render');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to start render');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scenes, videoPlan, resolution, combineScenes]);
|
||||
|
||||
const getVideoUrl = useCallback(() => {
|
||||
if (renderStatus?.result?.final_video_url) {
|
||||
return renderStatus.result.final_video_url;
|
||||
}
|
||||
if (renderStatus?.result?.scene_results?.[0]?.video_url) {
|
||||
return renderStatus.result.scene_results[0].video_url;
|
||||
}
|
||||
return null;
|
||||
}, [renderStatus]);
|
||||
|
||||
const handleStepNavigation = useCallback((targetStep: number) => {
|
||||
if (targetStep === activeStep) return;
|
||||
|
||||
// Always allow going back
|
||||
if (targetStep < activeStep) {
|
||||
setActiveStep(targetStep);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward navigation with guards
|
||||
if (targetStep === 1) {
|
||||
if (!videoPlan) {
|
||||
setError('Please generate a plan first.');
|
||||
return;
|
||||
}
|
||||
setActiveStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetStep === 2) {
|
||||
if (!videoPlan) {
|
||||
setError('Please generate a plan first.');
|
||||
return;
|
||||
}
|
||||
if (scenes.length === 0) {
|
||||
setError('Please build scenes before rendering.');
|
||||
return;
|
||||
}
|
||||
if (enabledScenesCount === 0) {
|
||||
setError('Enable at least one scene to render.');
|
||||
return;
|
||||
}
|
||||
setActiveStep(2);
|
||||
return;
|
||||
}
|
||||
}, [activeStep, videoPlan, scenes.length, enabledScenesCount]);
|
||||
|
||||
const handleResetRender = useCallback(() => {
|
||||
setRenderTaskId(null);
|
||||
setRenderStatus(null);
|
||||
setRenderProgress(0);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleRetryFailedScenes = useCallback((failedScenes: any[]) => {
|
||||
if (failedScenes.length > 0) {
|
||||
const sceneNumbers = failedScenes.map((f: any) => f.scene_number);
|
||||
const updatedScenes = scenes.map(s =>
|
||||
sceneNumbers.includes(s.scene_number)
|
||||
? { ...s, enabled: true }
|
||||
: s
|
||||
);
|
||||
setScenes(updatedScenes);
|
||||
handleResetRender();
|
||||
}
|
||||
}, [scenes, handleResetRender]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
py: 4,
|
||||
backgroundColor: YT_BG,
|
||||
color: YT_TEXT,
|
||||
minHeight: '100vh',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/dashboard')}
|
||||
variant="outlined"
|
||||
sx={{ borderColor: YT_BORDER, color: YT_TEXT, backgroundColor: 'white' }}
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
🎥 YouTube Creator Studio
|
||||
</Typography>
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
</Box>
|
||||
|
||||
{/* Stepper */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
backgroundColor: 'white',
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
sx={{
|
||||
'& .MuiStepIcon-root.Mui-active': { color: YT_RED },
|
||||
'& .MuiStepIcon-root.Mui-completed': { color: YT_RED },
|
||||
}}
|
||||
>
|
||||
{STEPS.map((label, idx) => (
|
||||
<Step key={label} completed={idx < activeStep}>
|
||||
<StepLabel
|
||||
onClick={() => handleStepNavigation(idx)}
|
||||
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
|
||||
{/* Success Alert */}
|
||||
<AnimatePresence>
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Alert severity="success" sx={{ mb: 3 }} onClose={() => setSuccess(null)}>
|
||||
{success}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Step Components */}
|
||||
{activeStep === 0 && (
|
||||
<PlanStep
|
||||
userIdea={userIdea}
|
||||
durationType={durationType}
|
||||
referenceImage={referenceImage}
|
||||
loading={loading}
|
||||
onIdeaChange={setUserIdea}
|
||||
onDurationChange={setDurationType}
|
||||
onReferenceImageChange={setReferenceImage}
|
||||
onGeneratePlan={handleGeneratePlan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === 1 && videoPlan && (
|
||||
<ScenesStep
|
||||
videoPlan={videoPlan}
|
||||
scenes={scenes}
|
||||
editingSceneId={editingSceneId}
|
||||
editedScene={editedScene}
|
||||
loading={loading}
|
||||
onBuildScenes={handleBuildScenes}
|
||||
onEditScene={handleEditScene}
|
||||
onSaveScene={handleSaveScene}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onEditChange={setEditedScene}
|
||||
onToggleScene={handleToggleScene}
|
||||
onBack={() => setActiveStep(0)}
|
||||
onNext={() => setActiveStep(2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === 2 && (
|
||||
<RenderStep
|
||||
renderTaskId={renderTaskId}
|
||||
renderStatus={renderStatus}
|
||||
renderProgress={renderProgress}
|
||||
resolution={resolution}
|
||||
combineScenes={combineScenes}
|
||||
enabledScenesCount={enabledScenesCount}
|
||||
costEstimate={costEstimate}
|
||||
loadingCostEstimate={loadingCostEstimate}
|
||||
loading={loading}
|
||||
onResolutionChange={setResolution}
|
||||
onCombineScenesChange={setCombineScenes}
|
||||
onStartRender={handleStartRender}
|
||||
onBack={() => setActiveStep(1)}
|
||||
onReset={handleResetRender}
|
||||
onRetryFailedScenes={handleRetryFailedScenes}
|
||||
getVideoUrl={getVideoUrl}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeCreator;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Plan Details Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Paper, Typography, Stack, Box, Grid, Chip } from '@mui/material';
|
||||
import { VideoPlan } from '../../../services/youtubeApi';
|
||||
import { YT_BORDER, YT_TEXT } from '../constants';
|
||||
|
||||
interface PlanDetailsProps {
|
||||
plan: VideoPlan;
|
||||
}
|
||||
|
||||
export const PlanDetails: React.FC<PlanDetailsProps> = React.memo(({ plan }) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 3,
|
||||
p: 2.5,
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: YT_TEXT }}>
|
||||
Plan Details
|
||||
</Typography>
|
||||
<Stack spacing={1.25}>
|
||||
{plan.video_summary && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.video_summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{plan.target_audience && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Target Audience
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.target_audience}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{plan.video_goal && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Goal
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.video_goal}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid container spacing={2}>
|
||||
{plan.key_message && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Key Message
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.key_message}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{plan.call_to_action && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Call to Action
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.call_to_action}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid container spacing={2}>
|
||||
{plan.hook_strategy && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Hook Strategy
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.hook_strategy}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Style & Tone
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Visual Style: {plan.visual_style || '—'} | Tone: {plan.tone || '—'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{plan.seo_keywords && plan.seo_keywords.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT, mb: 0.5 }}>
|
||||
SEO Keywords
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{plan.seo_keywords.map((kw, idx) => (
|
||||
<Chip key={`${kw}-${idx}`} label={kw} size="small" />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{plan.content_outline && plan.content_outline.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT, mb: 0.5 }}>
|
||||
Content Outline
|
||||
</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
{plan.content_outline.map((item, idx) => (
|
||||
<Typography key={idx} variant="body2" color="text.secondary">
|
||||
• {item.section || `Section ${idx + 1}`} — {item.description || 'Description missing'} ({item.duration_estimate || 0}s)
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
PlanDetails.displayName = 'PlanDetails';
|
||||
|
||||
126
frontend/src/components/YouTubeCreator/components/PlanStep.tsx
Normal file
126
frontend/src/components/YouTubeCreator/components/PlanStep.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Plan Step Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Stack,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormHelperText,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { PlayArrow } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { inputSx, labelSx, helperSx, selectSx } from '../styles';
|
||||
import { DurationType } from '../constants';
|
||||
|
||||
interface PlanStepProps {
|
||||
userIdea: string;
|
||||
durationType: DurationType;
|
||||
referenceImage: string;
|
||||
loading: boolean;
|
||||
onIdeaChange: (idea: string) => void;
|
||||
onDurationChange: (duration: DurationType) => void;
|
||||
onReferenceImageChange: (image: string) => void;
|
||||
onGeneratePlan: () => void;
|
||||
}
|
||||
|
||||
export const PlanStep: React.FC<PlanStepProps> = React.memo(({
|
||||
userIdea,
|
||||
durationType,
|
||||
referenceImage,
|
||||
loading,
|
||||
onIdeaChange,
|
||||
onDurationChange,
|
||||
onReferenceImageChange,
|
||||
onGeneratePlan,
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e5e5',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
1️⃣ Plan Your Video
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
label="What's your video about?"
|
||||
placeholder="Example: 'AI explains black holes in 60 seconds' or 'Budget travel guide for Tokyo'"
|
||||
value={userIdea}
|
||||
onChange={(e) => onIdeaChange(e.target.value)}
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
required
|
||||
helperText="Describe the story in one to two sentences. Include audience, outcome, and hook. Tip: name the platform goal (views, subs, clicks)."
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
FormHelperTextProps={{ sx: helperSx }}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel sx={labelSx}>Video Duration</InputLabel>
|
||||
<Select
|
||||
value={durationType}
|
||||
label="Video Duration"
|
||||
onChange={(e) => onDurationChange(e.target.value as DurationType)}
|
||||
sx={selectSx}
|
||||
>
|
||||
<MenuItem value="shorts">Shorts (15-60 seconds)</MenuItem>
|
||||
<MenuItem value="medium">Medium (1-4 minutes)</MenuItem>
|
||||
<MenuItem value="long">Long (4-10 minutes)</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Shorts = vertical bite-sized (≤60s). Medium = quick explainers. Long = deep dives.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Reference Image Description (Optional)"
|
||||
placeholder="Example: 'neon-lit Tokyo alley, rainy night, cinematic bokeh' or paste image keywords"
|
||||
value={referenceImage}
|
||||
onChange={(e) => onReferenceImageChange(e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
helperText="Optional: Describe visual cues or style you want the visuals to follow."
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
FormHelperTextProps={{ sx: helperSx }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
onClick={onGeneratePlan}
|
||||
disabled={loading || !userIdea.trim()}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
|
||||
sx={{ alignSelf: 'flex-start', px: 4 }}
|
||||
>
|
||||
{loading ? 'Generating Plan...' : 'Generate Video Plan'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
PlanStep.displayName = 'PlanStep';
|
||||
|
||||
339
frontend/src/components/YouTubeCreator/components/RenderStep.tsx
Normal file
339
frontend/src/components/YouTubeCreator/components/RenderStep.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Render Step Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Stack,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Button,
|
||||
Box,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Typography as MuiTypography,
|
||||
} from '@mui/material';
|
||||
import { PlayArrow, Download, Refresh } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { TaskStatus, CostEstimate } from '../../../services/youtubeApi';
|
||||
import { YT_BORDER, RESOLUTIONS, type Resolution } from '../constants';
|
||||
|
||||
interface RenderStepProps {
|
||||
renderTaskId: string | null;
|
||||
renderStatus: TaskStatus | null;
|
||||
renderProgress: number;
|
||||
resolution: Resolution;
|
||||
combineScenes: boolean;
|
||||
enabledScenesCount: number;
|
||||
costEstimate: CostEstimate | null;
|
||||
loadingCostEstimate: boolean;
|
||||
loading: boolean;
|
||||
onResolutionChange: (resolution: Resolution) => void;
|
||||
onCombineScenesChange: (combine: boolean) => void;
|
||||
onStartRender: () => void;
|
||||
onBack: () => void;
|
||||
onReset: () => void;
|
||||
onRetryFailedScenes: (failedScenes: any[]) => void;
|
||||
getVideoUrl: () => string | null;
|
||||
}
|
||||
|
||||
export const RenderStep: React.FC<RenderStepProps> = React.memo(({
|
||||
renderTaskId,
|
||||
renderStatus,
|
||||
renderProgress,
|
||||
resolution,
|
||||
combineScenes,
|
||||
enabledScenesCount,
|
||||
costEstimate,
|
||||
loadingCostEstimate,
|
||||
loading,
|
||||
onResolutionChange,
|
||||
onCombineScenesChange,
|
||||
onStartRender,
|
||||
onBack,
|
||||
onReset,
|
||||
onRetryFailedScenes,
|
||||
getVideoUrl,
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
backgroundColor: 'white',
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
3️⃣ Render Video
|
||||
</Typography>
|
||||
|
||||
{!renderTaskId ? (
|
||||
<Stack spacing={3}>
|
||||
<Alert severity="info">
|
||||
Configure render settings and start generating your video. This may take several minutes.
|
||||
</Alert>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Video Resolution</InputLabel>
|
||||
<Select
|
||||
value={resolution}
|
||||
label="Video Resolution"
|
||||
onChange={(e) => onResolutionChange(e.target.value as Resolution)}
|
||||
>
|
||||
{RESOLUTIONS.map((res) => (
|
||||
<MenuItem key={res} value={res}>
|
||||
{res === '480p' && '480p (Lower cost, faster)'}
|
||||
{res === '720p' && '720p (Recommended)'}
|
||||
{res === '1080p' && '1080p (Highest quality)'}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={combineScenes}
|
||||
onChange={(e) => onCombineScenesChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Combine scenes into single video"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ p: 2, bgcolor: '#f4f4f4', borderRadius: 1, border: `1px solid ${YT_BORDER}` }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Render Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
• {enabledScenesCount} scenes will be rendered
|
||||
<br />
|
||||
• Resolution: {resolution}
|
||||
<br />
|
||||
• {combineScenes ? 'Scenes will be combined into one video' : 'Each scene will be a separate video'}
|
||||
<br />
|
||||
</Typography>
|
||||
|
||||
{/* Cost Estimate */}
|
||||
{loadingCostEstimate ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Calculating cost estimate...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : costEstimate ? (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'primary.light', borderRadius: 1, border: '1px solid', borderColor: 'primary.main' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: 'primary.dark' }}>
|
||||
💰 Estimated Cost
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 1, color: 'primary.dark' }}>
|
||||
${costEstimate.total_cost.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Range: ${costEstimate.estimated_cost_range.min.toFixed(2)} - ${costEstimate.estimated_cost_range.max.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
• {costEstimate.num_scenes} scenes × ${costEstimate.price_per_second.toFixed(2)}/second
|
||||
<br />
|
||||
• Total duration: ~{Math.round(costEstimate.total_duration_seconds)} seconds
|
||||
<br />
|
||||
• Price per second: ${costEstimate.price_per_second.toFixed(2)} ({costEstimate.resolution})
|
||||
</Typography>
|
||||
{costEstimate.scene_costs.length > 0 && (
|
||||
<Box sx={{ mt: 1, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
Per Scene Breakdown:
|
||||
</Typography>
|
||||
{costEstimate.scene_costs.slice(0, 5).map((sceneCost) => (
|
||||
<Typography key={sceneCost.scene_number} variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
Scene {sceneCost.scene_number}: {sceneCost.actual_duration}s = ${sceneCost.cost.toFixed(2)}
|
||||
</Typography>
|
||||
))}
|
||||
{costEstimate.scene_costs.length > 5 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
... and {costEstimate.scene_costs.length - 5} more scenes
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
Unable to calculate cost estimate. Please check your scenes and try again.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button variant="outlined" onClick={onBack}>
|
||||
Back to Scenes
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
onClick={onStartRender}
|
||||
disabled={loading || enabledScenesCount === 0}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
{loading ? 'Starting Render...' : 'Start Video Render'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{renderStatus && (
|
||||
<>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{renderStatus.message || 'Processing...'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(renderProgress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={renderProgress} sx={{ height: 8, borderRadius: 1 }} />
|
||||
</Box>
|
||||
|
||||
{renderStatus.status === 'completed' && renderStatus.result && (
|
||||
<Alert severity="success">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Video Rendered Successfully!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Total cost: ${renderStatus.result.total_cost?.toFixed(2) || '0.00'}
|
||||
<br />
|
||||
Scenes rendered: {renderStatus.result.num_scenes || 0}
|
||||
</Typography>
|
||||
{getVideoUrl() && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<video
|
||||
controls
|
||||
src={getVideoUrl()!}
|
||||
style={{ width: '100%', maxHeight: '500px', borderRadius: 8 }}
|
||||
/>
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
href={getVideoUrl()!}
|
||||
download
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={onReset}
|
||||
>
|
||||
Render Another
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{renderStatus.status === 'failed' && (
|
||||
<Alert severity="error">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Render Failed
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{renderStatus.error || 'An error occurred during rendering'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Refresh />}
|
||||
onClick={onReset}
|
||||
>
|
||||
Retry Render
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onReset}
|
||||
>
|
||||
Start Over
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{renderStatus.status === 'completed' && renderStatus.result?.partial_success && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Partial Success
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{renderStatus.result.num_scenes} scenes rendered successfully, but{' '}
|
||||
{renderStatus.result.num_failed} scene(s) failed.
|
||||
{renderStatus.result.failed_scenes && renderStatus.result.failed_scenes.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<strong>Failed Scenes:</strong>
|
||||
{renderStatus.result.failed_scenes.map((failed: any, idx: number) => (
|
||||
<Box key={idx} sx={{ mt: 1, p: 1, bgcolor: 'error.light', borderRadius: 1 }}>
|
||||
<Typography variant="caption">
|
||||
Scene {failed.scene_number}: {failed.error || 'Unknown error'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => {
|
||||
const failedScenes = renderStatus.result?.failed_scenes || [];
|
||||
onRetryFailedScenes(failedScenes);
|
||||
}}
|
||||
>
|
||||
Retry Failed Scenes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onReset}
|
||||
>
|
||||
View Successful Scenes
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
RenderStep.displayName = 'RenderStep';
|
||||
|
||||
180
frontend/src/components/YouTubeCreator/components/SceneCard.tsx
Normal file
180
frontend/src/components/YouTubeCreator/components/SceneCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Scene Card Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Stack,
|
||||
Chip,
|
||||
Box,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
IconButton,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { Edit, Check, Close } from '@mui/icons-material';
|
||||
import { Scene } from '../../../services/youtubeApi';
|
||||
import { inputSx, labelSx } from '../styles';
|
||||
|
||||
interface SceneCardProps {
|
||||
scene: Scene;
|
||||
isEditing: boolean;
|
||||
editedScene: Partial<Scene> | null;
|
||||
onToggle: (sceneNumber: number) => void;
|
||||
onEdit: (scene: Scene) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onEditChange: (updates: Partial<Scene>) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const SceneCard: React.FC<SceneCardProps> = React.memo(({
|
||||
scene,
|
||||
isEditing,
|
||||
editedScene,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onEditChange,
|
||||
loading,
|
||||
}) => {
|
||||
const sceneData = isEditing && editedScene ? { ...scene, ...editedScene } : scene;
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
opacity: sceneData.enabled === false ? 0.6 : 1,
|
||||
border: sceneData.enabled === false ? '1px dashed' : '1px solid',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Scene {scene.scene_number}: {sceneData.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
{sceneData.emphasis_tags?.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
size="small"
|
||||
color={
|
||||
tag === 'hook' ? 'primary' :
|
||||
tag === 'cta' ? 'secondary' : 'default'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Chip
|
||||
label={`~${sceneData.duration_estimate}s`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={sceneData.enabled !== false}
|
||||
onChange={() => onToggle(scene.scene_number)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label="Enable"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onEdit(scene)}
|
||||
color="primary"
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isEditing ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Narration"
|
||||
value={sceneData.narration}
|
||||
onChange={(e) => onEditChange({ narration: e.target.value })}
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
/>
|
||||
<TextField
|
||||
label="Visual Prompt"
|
||||
value={sceneData.visual_prompt}
|
||||
onChange={(e) => onEditChange({ visual_prompt: e.target.value })}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
/>
|
||||
<TextField
|
||||
label="Duration (seconds)"
|
||||
type="number"
|
||||
value={sceneData.duration_estimate}
|
||||
onChange={(e) => onEditChange({ duration_estimate: parseFloat(e.target.value) || 5 })}
|
||||
inputProps={{ min: 1, max: 10, step: 0.5 }}
|
||||
fullWidth
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Check />}
|
||||
onClick={onSave}
|
||||
disabled={loading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Close />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
"{sceneData.narration}"
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Visual: {sceneData.visual_prompt}
|
||||
</Typography>
|
||||
{sceneData.visual_cues && sceneData.visual_cues.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Cues: {sceneData.visual_cues.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
SceneCard.displayName = 'SceneCard';
|
||||
|
||||
140
frontend/src/components/YouTubeCreator/components/ScenesStep.tsx
Normal file
140
frontend/src/components/YouTubeCreator/components/ScenesStep.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Scenes Step Component
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
Box,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { PlayArrow, VideoLibrary } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { VideoPlan, Scene } from '../../../services/youtubeApi';
|
||||
import { PlanDetails } from './PlanDetails';
|
||||
import { SceneCard } from './SceneCard';
|
||||
import { YT_BORDER } from '../constants';
|
||||
|
||||
interface ScenesStepProps {
|
||||
videoPlan: VideoPlan;
|
||||
scenes: Scene[];
|
||||
editingSceneId: number | null;
|
||||
editedScene: Partial<Scene> | null;
|
||||
loading: boolean;
|
||||
onBuildScenes: () => void;
|
||||
onEditScene: (scene: Scene) => void;
|
||||
onSaveScene: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onEditChange: (updates: Partial<Scene>) => void;
|
||||
onToggleScene: (sceneNumber: number) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const ScenesStep: React.FC<ScenesStepProps> = React.memo(({
|
||||
videoPlan,
|
||||
scenes,
|
||||
editingSceneId,
|
||||
editedScene,
|
||||
loading,
|
||||
onBuildScenes,
|
||||
onEditScene,
|
||||
onSaveScene,
|
||||
onCancelEdit,
|
||||
onEditChange,
|
||||
onToggleScene,
|
||||
onBack,
|
||||
onNext,
|
||||
}) => {
|
||||
const enabledScenesCount = useMemo(
|
||||
() => scenes.filter(s => s.enabled !== false).length,
|
||||
[scenes]
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
backgroundColor: 'white',
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
2️⃣ Review & Edit Scenes
|
||||
</Typography>
|
||||
{scenes.length === 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={onBuildScenes}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
|
||||
>
|
||||
{loading ? 'Building Scenes...' : 'Build Scenes from Plan'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<PlanDetails plan={videoPlan} />
|
||||
|
||||
{scenes.length > 0 ? (
|
||||
<Stack spacing={2}>
|
||||
{scenes.map((scene) => (
|
||||
<SceneCard
|
||||
key={scene.scene_number}
|
||||
scene={scene}
|
||||
isEditing={editingSceneId === scene.scene_number}
|
||||
editedScene={editedScene}
|
||||
onToggle={onToggleScene}
|
||||
onEdit={onEditScene}
|
||||
onSave={onSaveScene}
|
||||
onCancel={onCancelEdit}
|
||||
onEditChange={onEditChange}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<VideoLibrary sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Click "Build Scenes from Plan" to generate scene-by-scene breakdown
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{scenes.length > 0 && (
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'space-between' }}>
|
||||
<Button variant="outlined" onClick={onBack}>
|
||||
Back to Plan
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}>
|
||||
{enabledScenesCount} of {scenes.length} scenes enabled
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={onNext}
|
||||
disabled={enabledScenesCount === 0}
|
||||
>
|
||||
Proceed to Render ({enabledScenesCount} scenes)
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
ScenesStep.displayName = 'ScenesStep';
|
||||
|
||||
19
frontend/src/components/YouTubeCreator/constants.ts
Normal file
19
frontend/src/components/YouTubeCreator/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Constants for YouTube Creator Studio
|
||||
*/
|
||||
|
||||
export const YT_RED = '#FF0000';
|
||||
export const YT_BG = '#f9f9f9';
|
||||
export const YT_BORDER = '#e5e5e5';
|
||||
export const YT_TEXT = '#0f0f0f';
|
||||
|
||||
export const STEPS = ['Plan Your Video', 'Review Scenes', 'Render Video'] as const;
|
||||
|
||||
export const RESOLUTIONS = ['480p', '720p', '1080p'] as const;
|
||||
export type Resolution = typeof RESOLUTIONS[number];
|
||||
|
||||
export const DURATION_TYPES = ['shorts', 'medium', 'long'] as const;
|
||||
export type DurationType = typeof DURATION_TYPES[number];
|
||||
|
||||
export const POLLING_INTERVAL_MS = 2000; // 2 seconds
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Custom hook for fetching cost estimates
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { youtubeApi, type Scene, type CostEstimate } from '../../../services/youtubeApi';
|
||||
import { type Resolution } from '../constants';
|
||||
|
||||
interface UseCostEstimateParams {
|
||||
activeStep: number;
|
||||
scenes: Scene[];
|
||||
resolution: Resolution;
|
||||
renderTaskId: string | null;
|
||||
}
|
||||
|
||||
export const useCostEstimate = ({ activeStep, scenes, resolution, renderTaskId }: UseCostEstimateParams) => {
|
||||
const [costEstimate, setCostEstimate] = useState<CostEstimate | null>(null);
|
||||
const [loadingCostEstimate, setLoadingCostEstimate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeStep === 2 && scenes.length > 0 && !renderTaskId) {
|
||||
const fetchCostEstimate = async () => {
|
||||
setLoadingCostEstimate(true);
|
||||
try {
|
||||
const enabledScenes = scenes.filter(s => s.enabled !== false);
|
||||
const response = await youtubeApi.estimateCost({
|
||||
scenes: enabledScenes,
|
||||
resolution: resolution,
|
||||
});
|
||||
if (response.success && response.estimate) {
|
||||
setCostEstimate(response.estimate);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error estimating cost:', err);
|
||||
setCostEstimate(null);
|
||||
} finally {
|
||||
setLoadingCostEstimate(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCostEstimate();
|
||||
}
|
||||
}, [activeStep, scenes, resolution, renderTaskId]);
|
||||
|
||||
return { costEstimate, loadingCostEstimate };
|
||||
};
|
||||
|
||||
126
frontend/src/components/YouTubeCreator/hooks/useRenderPolling.ts
Normal file
126
frontend/src/components/YouTubeCreator/hooks/useRenderPolling.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Custom hook for polling render task status
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { youtubeApi, type TaskStatus } from '../../../services/youtubeApi';
|
||||
import { POLLING_INTERVAL_MS } from '../constants';
|
||||
|
||||
interface UseRenderPollingResult {
|
||||
renderStatus: TaskStatus | null;
|
||||
renderProgress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const useRenderPolling = (
|
||||
renderTaskId: string | null,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): UseRenderPollingResult => {
|
||||
const [renderStatus, setRenderStatus] = useState<TaskStatus | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (!renderTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start polling
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await youtubeApi.getRenderStatus(renderTaskId);
|
||||
setRenderStatus(status);
|
||||
setRenderProgress(status.progress || 0);
|
||||
|
||||
// Stop polling if task is completed or failed
|
||||
if (status.status === 'completed' || status.status === 'failed') {
|
||||
console.log(`[YouTubeCreator] Task ${renderTaskId} finished with status: ${status.status}`);
|
||||
|
||||
// Clear interval immediately
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
onSuccess?.();
|
||||
} else if (status.status === 'failed') {
|
||||
// Extract error message from status
|
||||
const errorMessage = status.error ||
|
||||
status.message ||
|
||||
(typeof status.result === 'object' && status.result?.error) ||
|
||||
'Video rendering failed. Please try again.';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
console.error(`[YouTubeCreator] Render task failed:`, status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to poll render status:', err);
|
||||
|
||||
// Handle 404 - task not found
|
||||
const is404 = err.response?.status === 404 ||
|
||||
err.message?.includes('Task not found') ||
|
||||
err.response?.data?.detail?.error === 'Task not found';
|
||||
|
||||
if (is404) {
|
||||
console.warn(`[YouTubeCreator] Task ${renderTaskId} not found, stopping polling`);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
const errorDetail = err.response?.data?.detail;
|
||||
const errorMessage = errorDetail?.message ||
|
||||
errorDetail?.error ||
|
||||
'Render task not found. This may happen if the server restarted or the task expired. Please try rendering again.';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// For 500 errors (server errors), stop polling
|
||||
const is500 = err.response?.status === 500;
|
||||
if (is500) {
|
||||
console.error(`[YouTubeCreator] Server error while polling, stopping`);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
const errorMessage = 'Server error occurred while checking render status. Please try rendering again.';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, continue polling but log them
|
||||
console.warn(`[YouTubeCreator] Polling error (non-critical), will retry:`, err.message);
|
||||
}
|
||||
}, POLLING_INTERVAL_MS);
|
||||
|
||||
intervalRef.current = interval;
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [renderTaskId, onSuccess, onError]);
|
||||
|
||||
return {
|
||||
renderStatus,
|
||||
renderProgress,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
38
frontend/src/components/YouTubeCreator/styles.ts
Normal file
38
frontend/src/components/YouTubeCreator/styles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Shared styles for YouTube Creator Studio
|
||||
*/
|
||||
|
||||
import { YT_RED, YT_TEXT } from './constants';
|
||||
|
||||
export const inputSx = {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#fff',
|
||||
color: YT_TEXT,
|
||||
borderRadius: 1,
|
||||
'& fieldset': {
|
||||
borderColor: '#c6c6c6',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: YT_RED,
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: YT_RED,
|
||||
boxShadow: '0 0 0 2px rgba(255,0,0,0.08)',
|
||||
},
|
||||
'& input::placeholder, & textarea::placeholder': {
|
||||
color: '#5f6368',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const selectSx = {
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#c6c6c6' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: YT_RED },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: YT_RED },
|
||||
'& .MuiSelect-select': { color: YT_TEXT, backgroundColor: '#fff' },
|
||||
};
|
||||
|
||||
export const labelSx = { color: '#5f6368', '&.Mui-focused': { color: YT_RED } };
|
||||
export const helperSx = { color: '#5f6368' };
|
||||
|
||||
@@ -76,10 +76,12 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
|
||||
buttonProps = {},
|
||||
}) => {
|
||||
const preflightOptions: UsePreflightCheckOptions = {
|
||||
operation,
|
||||
enabled: checkOnHover || checkOnMount,
|
||||
debounceMs: 300,
|
||||
cacheTtl: 5000,
|
||||
onBlocked: (response) => {
|
||||
// Handle blocked response if needed
|
||||
},
|
||||
onAllowed: (response) => {
|
||||
// Handle allowed response if needed
|
||||
},
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -88,20 +90,19 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
|
||||
limitInfo,
|
||||
loading: preflightLoading,
|
||||
error: preflightError,
|
||||
checkOnHover: triggerCheckOnHover,
|
||||
checkNow: triggerCheckNow,
|
||||
check: triggerCheck,
|
||||
} = usePreflightCheck(preflightOptions);
|
||||
|
||||
// Check on mount if requested
|
||||
React.useEffect(() => {
|
||||
if (checkOnMount) {
|
||||
triggerCheckNow();
|
||||
triggerCheck(operation);
|
||||
}
|
||||
}, [checkOnMount, triggerCheckNow]);
|
||||
}, [checkOnMount, triggerCheck, operation]);
|
||||
|
||||
// Notify parent of pre-flight result changes
|
||||
React.useEffect(() => {
|
||||
if (onPreflightResult) {
|
||||
if (onPreflightResult && canProceed !== null) {
|
||||
onPreflightResult(canProceed);
|
||||
}
|
||||
}, [canProceed, onPreflightResult]);
|
||||
@@ -129,7 +130,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
|
||||
|
||||
// Determine if button should be disabled
|
||||
const isDisabled = useMemo(() => {
|
||||
return externalDisabled || externalLoading || preflightLoading || !canProceed;
|
||||
return externalDisabled || externalLoading || preflightLoading || (canProceed !== null && !canProceed);
|
||||
}, [externalDisabled, externalLoading, preflightLoading, canProceed]);
|
||||
|
||||
// Build tooltip content
|
||||
@@ -155,7 +156,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
|
||||
content.push(
|
||||
<Box key="limits" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{canProceed ? '✅ Operation Allowed' : '❌ Operation Blocked'}
|
||||
{(canProceed === null || canProceed) ? '✅ Operation Allowed' : '❌ Operation Blocked'}
|
||||
</Typography>
|
||||
{isUnlimited ? (
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
@@ -189,20 +190,20 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
|
||||
// Handle hover
|
||||
const handleMouseEnter = () => {
|
||||
if (checkOnHover) {
|
||||
triggerCheckOnHover();
|
||||
triggerCheck(operation);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click
|
||||
const handleClick = () => {
|
||||
if (!isDisabled && canProceed) {
|
||||
if (!isDisabled && (canProceed === null || canProceed)) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
// Determine button color based on state
|
||||
const buttonColor = useMemo(() => {
|
||||
if (!canProceed) {
|
||||
if (canProceed !== null && !canProceed) {
|
||||
return 'error';
|
||||
}
|
||||
return color;
|
||||
@@ -219,7 +220,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
|
||||
if (showLoading && !externalLoading) {
|
||||
return 'Checking...';
|
||||
}
|
||||
if (!canProceed && preflightError) {
|
||||
if (canProceed !== null && !canProceed && preflightError) {
|
||||
return preflightError;
|
||||
}
|
||||
return buttonLabel;
|
||||
@@ -234,7 +235,7 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
|
||||
startIcon={
|
||||
showLoading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : !canProceed ? (
|
||||
) : (canProceed !== null && !canProceed) ? (
|
||||
<WarningIcon fontSize="small" />
|
||||
) : (
|
||||
startIcon
|
||||
|
||||
@@ -49,11 +49,11 @@ export const toolCategories: ToolCategories = {
|
||||
},
|
||||
{
|
||||
name: 'Podcast Maker',
|
||||
description: 'Generate research-grounded podcast scripts and audio',
|
||||
description: 'Create professional podcast episodes with AI-powered research, scriptwriting, and voice narration',
|
||||
icon: React.createElement(AudioIcon),
|
||||
status: 'beta',
|
||||
path: '/podcast-maker',
|
||||
features: ['Research Workflow', 'Editable Script', 'Scene Approvals', 'WaveSpeed Audio'],
|
||||
features: ['AI Research', 'Smart Scripting', 'Voice Narration', 'Export & Share', 'Episode Library'],
|
||||
isHighlighted: true
|
||||
},
|
||||
{
|
||||
@@ -305,12 +305,12 @@ export const toolCategories: ToolCategories = {
|
||||
features: ['Visual Descriptions', 'Hashtag Strategy', 'Story Content']
|
||||
},
|
||||
{
|
||||
name: 'YouTube Content Writer',
|
||||
description: 'Video scripts and descriptions',
|
||||
name: 'YouTube Creator Studio',
|
||||
description: 'AI-powered YouTube video creation with scenes and rendering',
|
||||
icon: React.createElement(SocialIcon),
|
||||
status: 'premium',
|
||||
path: '/youtube-writer',
|
||||
features: ['Video Scripts', 'SEO Descriptions', 'Engagement Hooks']
|
||||
status: 'active',
|
||||
path: '/youtube-creator',
|
||||
features: ['Video Planning', 'Scene Generation', 'AI Video Rendering', 'Cost Estimation']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
71
frontend/src/hooks/useBudgetTracking.ts
Normal file
71
frontend/src/hooks/useBudgetTracking.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface BudgetTrackingState {
|
||||
totalSpent: number;
|
||||
budgetCap: number;
|
||||
operations: Array<{ id: string; cost: number; timestamp: string; description: string }>;
|
||||
}
|
||||
|
||||
export const useBudgetTracking = (initialBudgetCap: number = 50) => {
|
||||
const [budget, setBudget] = useState<BudgetTrackingState>({
|
||||
totalSpent: 0,
|
||||
budgetCap: initialBudgetCap,
|
||||
operations: [],
|
||||
});
|
||||
|
||||
const addCost = useCallback((cost: number, description: string) => {
|
||||
setBudget((prev) => {
|
||||
const newTotal = prev.totalSpent + cost;
|
||||
const operation = {
|
||||
id: `${Date.now()}_${Math.random()}`,
|
||||
cost,
|
||||
timestamp: new Date().toISOString(),
|
||||
description,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
totalSpent: newTotal,
|
||||
operations: [...prev.operations, operation],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBudgetCap = useCallback((cap: number) => {
|
||||
setBudget((prev) => ({ ...prev, budgetCap: cap }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setBudget({
|
||||
totalSpent: 0,
|
||||
budgetCap: initialBudgetCap,
|
||||
operations: [],
|
||||
});
|
||||
}, [initialBudgetCap]);
|
||||
|
||||
const canAfford = useCallback((estimatedCost: number): boolean => {
|
||||
return budget.totalSpent + estimatedCost <= budget.budgetCap;
|
||||
}, [budget.totalSpent, budget.budgetCap]);
|
||||
|
||||
const getRemaining = useCallback((): number => {
|
||||
return Math.max(0, budget.budgetCap - budget.totalSpent);
|
||||
}, [budget.budgetCap, budget.totalSpent]);
|
||||
|
||||
const getUsagePercentage = useCallback((): number => {
|
||||
if (budget.budgetCap === 0) return 0;
|
||||
return Math.min(100, (budget.totalSpent / budget.budgetCap) * 100);
|
||||
}, [budget.totalSpent, budget.budgetCap]);
|
||||
|
||||
return {
|
||||
totalSpent: budget.totalSpent,
|
||||
budgetCap: budget.budgetCap,
|
||||
remaining: getRemaining(),
|
||||
usagePercentage: getUsagePercentage(),
|
||||
operations: budget.operations,
|
||||
addCost,
|
||||
setBudgetCap,
|
||||
canAfford,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
|
||||
export interface ContentAsset {
|
||||
@@ -49,40 +49,100 @@ const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:800
|
||||
export const useContentAssets = (filters: AssetFilters = {}) => {
|
||||
const { getToken } = useAuth();
|
||||
const [assets, setAssets] = useState<ContentAsset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const isFetchingRef = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Memoize filters to create stable reference - only changes when actual values change
|
||||
const stableFilters = useMemo(() => {
|
||||
return {
|
||||
asset_type: filters.asset_type,
|
||||
source_module: filters.source_module,
|
||||
search: filters.search,
|
||||
tags: filters.tags,
|
||||
favorites_only: filters.favorites_only,
|
||||
limit: filters.limit,
|
||||
offset: filters.offset,
|
||||
};
|
||||
}, [
|
||||
filters.asset_type,
|
||||
filters.source_module,
|
||||
filters.search,
|
||||
filters.tags?.join(','),
|
||||
filters.favorites_only,
|
||||
filters.limit,
|
||||
filters.offset,
|
||||
]);
|
||||
|
||||
// Create stable filter key for comparison
|
||||
const filterKey = useMemo(() => {
|
||||
return JSON.stringify(stableFilters);
|
||||
}, [stableFilters]);
|
||||
|
||||
// Store latest filters in ref for use in fetch function
|
||||
const filtersRef = useRef(stableFilters);
|
||||
useEffect(() => {
|
||||
filtersRef.current = stableFilters;
|
||||
}, [stableFilters]);
|
||||
|
||||
// Fetch function - exposed for manual retry, not called automatically on errors
|
||||
const fetchAssets = useCallback(async () => {
|
||||
// Prevent concurrent fetches
|
||||
if (isFetchingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this request
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
isFetchingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
setLoading(false);
|
||||
isFetchingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ref to get latest filters
|
||||
const currentFilters = filtersRef.current;
|
||||
const params = new URLSearchParams();
|
||||
if (filters.asset_type) params.append('asset_type', filters.asset_type);
|
||||
if (filters.source_module) params.append('source_module', filters.source_module);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.tags && filters.tags.length > 0) params.append('tags', filters.tags.join(','));
|
||||
if (filters.favorites_only) params.append('favorites_only', 'true');
|
||||
params.append('limit', String(filters.limit || 100));
|
||||
params.append('offset', String(filters.offset || 0));
|
||||
|
||||
// Add cache busting for fresh data
|
||||
params.append('_t', String(Date.now()));
|
||||
if (currentFilters.asset_type) params.append('asset_type', currentFilters.asset_type);
|
||||
if (currentFilters.source_module) params.append('source_module', currentFilters.source_module);
|
||||
if (currentFilters.search) params.append('search', currentFilters.search);
|
||||
if (currentFilters.tags && currentFilters.tags.length > 0) params.append('tags', currentFilters.tags.join(','));
|
||||
if (currentFilters.favorites_only) params.append('favorites_only', 'true');
|
||||
params.append('limit', String(currentFilters.limit || 100));
|
||||
params.append('offset', String(currentFilters.offset || 0));
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/?${params.toString()}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
setError('Rate limit exceeded. Please try again later.');
|
||||
setAssets([]);
|
||||
setTotal(0);
|
||||
setLoading(false);
|
||||
isFetchingRef.current = false;
|
||||
return;
|
||||
}
|
||||
throw new Error(`Failed to fetch assets: ${response.statusText}`);
|
||||
}
|
||||
|
||||
@@ -90,16 +150,34 @@ export const useContentAssets = (filters: AssetFilters = {}) => {
|
||||
setAssets(data.assets);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch assets');
|
||||
// Don't set error for aborted requests
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
if (err instanceof TypeError && err.message.includes('fetch')) {
|
||||
setError('Network error. Please check your connection.');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch assets');
|
||||
}
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isFetchingRef.current = false;
|
||||
}
|
||||
}, [getToken, filters]);
|
||||
}, [getToken]); // Only depend on getToken, use ref for filters
|
||||
|
||||
// Fetch on mount and when filters change - but only once per filter change
|
||||
// NO automatic retry on errors - user must call refetch() manually
|
||||
useEffect(() => {
|
||||
fetchAssets();
|
||||
}, [fetchAssets]);
|
||||
|
||||
// Cleanup: abort on unmount or filter change
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filterKey, fetchAssets]); // Include fetchAssets but it's stable due to ref usage
|
||||
|
||||
const toggleFavorite = useCallback(async (assetId: number) => {
|
||||
try {
|
||||
|
||||
372
frontend/src/hooks/usePodcastProjectState.ts
Normal file
372
frontend/src/hooks/usePodcastProjectState.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
PodcastAnalysis,
|
||||
PodcastEstimate,
|
||||
Query,
|
||||
Research,
|
||||
Script,
|
||||
Knobs,
|
||||
Job,
|
||||
CreateProjectPayload,
|
||||
} from '../components/PodcastMaker/types';
|
||||
import { BlogResearchResponse, ResearchProvider } from '../services/blogWriterApi';
|
||||
import { podcastApi } from '../services/podcastApi';
|
||||
|
||||
export interface PodcastProjectState {
|
||||
// Project metadata
|
||||
project: { id: string; idea: string; duration: number; speakers: number } | null;
|
||||
|
||||
// Step results
|
||||
analysis: PodcastAnalysis | null;
|
||||
queries: Query[];
|
||||
selectedQueries: Set<string>;
|
||||
research: Research | null;
|
||||
rawResearch: BlogResearchResponse | null;
|
||||
estimate: PodcastEstimate | null;
|
||||
scriptData: Script | null;
|
||||
|
||||
// Render jobs
|
||||
renderJobs: Job[];
|
||||
|
||||
// Settings
|
||||
knobs: Knobs;
|
||||
researchProvider: ResearchProvider;
|
||||
budgetCap: number;
|
||||
|
||||
// UI state
|
||||
showScriptEditor: boolean;
|
||||
showRenderQueue: boolean;
|
||||
|
||||
// Current step tracking
|
||||
currentStep: 'create' | 'analysis' | 'research' | 'script' | 'render' | null;
|
||||
|
||||
// Timestamps
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
bitrate: "standard",
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: PodcastProjectState = {
|
||||
project: null,
|
||||
analysis: null,
|
||||
queries: [],
|
||||
selectedQueries: new Set(),
|
||||
research: null,
|
||||
rawResearch: null,
|
||||
estimate: null,
|
||||
scriptData: null,
|
||||
renderJobs: [],
|
||||
knobs: DEFAULT_KNOBS,
|
||||
researchProvider: "google",
|
||||
budgetCap: 50,
|
||||
showScriptEditor: false,
|
||||
showRenderQueue: false,
|
||||
currentStep: null,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'podcast_project_state';
|
||||
|
||||
export const usePodcastProjectState = () => {
|
||||
const [state, setState] = useState<PodcastProjectState>(() => {
|
||||
// Initialize from localStorage if available
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
// Restore Sets from arrays
|
||||
const restoredState: PodcastProjectState = {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
selectedQueries: parsed.selectedQueries ? new Set(parsed.selectedQueries) : new Set(),
|
||||
renderJobs: parsed.renderJobs || [],
|
||||
};
|
||||
|
||||
return restoredState;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading podcast project state from localStorage:', error);
|
||||
}
|
||||
return DEFAULT_STATE;
|
||||
});
|
||||
|
||||
// Debounce ref for database sync
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Persist state to localStorage on every change
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Convert Sets to arrays for JSON serialization
|
||||
const serializableState = {
|
||||
...state,
|
||||
selectedQueries: Array.from(state.selectedQueries),
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializableState));
|
||||
} catch (error) {
|
||||
console.error('Error saving podcast project state to localStorage:', error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Sync to database after major steps (debounced)
|
||||
useEffect(() => {
|
||||
if (!state.project || !state.project.id) return;
|
||||
|
||||
// Capture project ID to avoid closure issues
|
||||
const projectId = state.project.id;
|
||||
|
||||
// Clear existing timeout
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce database sync (wait 2 seconds after last change)
|
||||
syncTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const dbState = {
|
||||
analysis: state.analysis,
|
||||
queries: state.queries,
|
||||
selected_queries: Array.from(state.selectedQueries),
|
||||
research: state.research,
|
||||
raw_research: state.rawResearch,
|
||||
estimate: state.estimate,
|
||||
script_data: state.scriptData,
|
||||
render_jobs: state.renderJobs,
|
||||
knobs: state.knobs,
|
||||
research_provider: state.researchProvider,
|
||||
show_script_editor: state.showScriptEditor,
|
||||
show_render_queue: state.showRenderQueue,
|
||||
current_step: state.currentStep,
|
||||
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress',
|
||||
};
|
||||
|
||||
await podcastApi.saveProject(projectId, dbState);
|
||||
} catch (error) {
|
||||
console.error('Error syncing project to database:', error);
|
||||
// Don't throw - localStorage is still working
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
state.project,
|
||||
state.analysis,
|
||||
state.queries,
|
||||
state.selectedQueries,
|
||||
state.research,
|
||||
state.rawResearch,
|
||||
state.estimate,
|
||||
state.scriptData,
|
||||
state.renderJobs,
|
||||
state.knobs,
|
||||
state.researchProvider,
|
||||
state.showScriptEditor,
|
||||
state.showRenderQueue,
|
||||
state.currentStep,
|
||||
]);
|
||||
|
||||
// Setters
|
||||
const setProject = useCallback((project: PodcastProjectState['project']) => {
|
||||
setState((prev) => ({ ...prev, project, currentStep: project ? 'analysis' : null, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
analysis,
|
||||
currentStep: analysis ? 'research' : prev.currentStep,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setQueries = useCallback((queries: Query[]) => {
|
||||
setState((prev) => ({ ...prev, queries, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setSelectedQueries = useCallback((selectedQueries: Set<string> | ((prev: Set<string>) => Set<string>)) => {
|
||||
setState((prev) => {
|
||||
const newQueries = typeof selectedQueries === 'function' ? selectedQueries(prev.selectedQueries) : selectedQueries;
|
||||
return { ...prev, selectedQueries: newQueries, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setResearch = useCallback((research: PodcastProjectState['research']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
research,
|
||||
currentStep: research ? 'script' : prev.currentStep,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setRawResearch = useCallback((rawResearch: PodcastProjectState['rawResearch']) => {
|
||||
setState((prev) => ({ ...prev, rawResearch, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setEstimate = useCallback((estimate: PodcastProjectState['estimate']) => {
|
||||
setState((prev) => ({ ...prev, estimate, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setScriptData = useCallback((scriptData: PodcastProjectState['scriptData']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
scriptData,
|
||||
currentStep: scriptData ? 'render' : prev.currentStep,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setRenderJobs = useCallback((renderJobs: Job[]) => {
|
||||
setState((prev) => ({ ...prev, renderJobs, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const updateRenderJob = useCallback((sceneId: string, updates: Partial<Job>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
renderJobs: prev.renderJobs.map((job) =>
|
||||
job.sceneId === sceneId ? { ...job, ...updates } : job
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setKnobs = useCallback((knobs: Knobs) => {
|
||||
setState((prev) => ({ ...prev, knobs, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setResearchProvider = useCallback((provider: ResearchProvider) => {
|
||||
setState((prev) => ({ ...prev, researchProvider: provider, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setBudgetCap = useCallback((cap: number) => {
|
||||
setState((prev) => ({ ...prev, budgetCap: cap, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setShowScriptEditor = useCallback((show: boolean) => {
|
||||
setState((prev) => ({ ...prev, showScriptEditor: show, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setShowRenderQueue = useCallback((show: boolean) => {
|
||||
setState((prev) => ({ ...prev, showRenderQueue: show, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setCurrentStep = useCallback((step: PodcastProjectState['currentStep']) => {
|
||||
setState((prev) => ({ ...prev, currentStep: step, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
// Reset state
|
||||
const resetState = useCallback(() => {
|
||||
setState(DEFAULT_STATE);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
// Initialize project from payload
|
||||
const initializeProject = useCallback(async (payload: CreateProjectPayload, projectId: string) => {
|
||||
// Create project in database
|
||||
try {
|
||||
await podcastApi.createProjectInDb({
|
||||
project_id: projectId,
|
||||
idea: payload.ideaOrUrl,
|
||||
duration: payload.duration,
|
||||
speakers: payload.speakers,
|
||||
budget_cap: payload.budgetCap,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating project in database:', error);
|
||||
// Continue anyway - localStorage fallback
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
project: {
|
||||
id: projectId,
|
||||
idea: payload.ideaOrUrl,
|
||||
duration: payload.duration,
|
||||
speakers: payload.speakers,
|
||||
},
|
||||
knobs: payload.knobs,
|
||||
budgetCap: payload.budgetCap,
|
||||
currentStep: 'analysis',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Load project from database
|
||||
const loadProjectFromDb = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
const dbProject = await podcastApi.loadProject(projectId);
|
||||
|
||||
// Restore state from database
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
project: {
|
||||
id: dbProject.project_id,
|
||||
idea: dbProject.idea,
|
||||
duration: dbProject.duration,
|
||||
speakers: dbProject.speakers,
|
||||
},
|
||||
analysis: dbProject.analysis,
|
||||
queries: dbProject.queries || [],
|
||||
selectedQueries: new Set(dbProject.selected_queries || []),
|
||||
research: dbProject.research,
|
||||
rawResearch: dbProject.raw_research,
|
||||
estimate: dbProject.estimate,
|
||||
scriptData: dbProject.script_data,
|
||||
renderJobs: dbProject.render_jobs || [],
|
||||
knobs: dbProject.knobs || DEFAULT_KNOBS,
|
||||
researchProvider: dbProject.research_provider || 'google',
|
||||
budgetCap: dbProject.budget_cap || 50,
|
||||
showScriptEditor: dbProject.show_script_editor || false,
|
||||
showRenderQueue: dbProject.show_render_queue || false,
|
||||
currentStep: dbProject.current_step || null,
|
||||
createdAt: dbProject.created_at,
|
||||
updatedAt: dbProject.updated_at,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading project from database:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Setters
|
||||
setProject,
|
||||
setAnalysis,
|
||||
setQueries,
|
||||
setSelectedQueries,
|
||||
setResearch,
|
||||
setRawResearch,
|
||||
setEstimate,
|
||||
setScriptData,
|
||||
setRenderJobs,
|
||||
updateRenderJob,
|
||||
setKnobs,
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
setShowScriptEditor,
|
||||
setShowRenderQueue,
|
||||
setCurrentStep,
|
||||
|
||||
// Helpers
|
||||
resetState,
|
||||
initializeProject,
|
||||
loadProjectFromDb,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,257 +1,82 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
checkPreflight,
|
||||
PreflightOperation,
|
||||
PreflightCheckResponse,
|
||||
PreflightLimitInfo,
|
||||
} from '../services/billingService';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { checkPreflight, PreflightOperation, PreflightCheckResponse } from '../services/billingService';
|
||||
|
||||
export interface UsePreflightCheckOptions {
|
||||
operation: PreflightOperation;
|
||||
enabled?: boolean; // Whether to perform check on hover
|
||||
debounceMs?: number; // Debounce delay (default: 300ms)
|
||||
cacheTtl?: number; // Cache TTL in ms (default: 5000ms)
|
||||
onBlocked?: (response: PreflightCheckResponse) => void;
|
||||
onAllowed?: (response: PreflightCheckResponse) => void;
|
||||
}
|
||||
|
||||
export interface UsePreflightCheckResult {
|
||||
canProceed: boolean;
|
||||
estimatedCost: number;
|
||||
limitInfo: PreflightLimitInfo | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
checkOnHover: () => void;
|
||||
checkNow: () => void; // Immediate check
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: PreflightCheckResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for pre-flight checking operations with cost estimation.
|
||||
*
|
||||
* Features:
|
||||
* - Debounced hover checks (300ms default)
|
||||
* - In-memory caching (5s default TTL)
|
||||
* - Request cancellation on unmount
|
||||
*/
|
||||
export const usePreflightCheck = (
|
||||
options: UsePreflightCheckOptions
|
||||
): UsePreflightCheckResult => {
|
||||
const {
|
||||
operation,
|
||||
enabled = true,
|
||||
debounceMs = 300,
|
||||
cacheTtl = 5000,
|
||||
} = options;
|
||||
|
||||
const [canProceed, setCanProceed] = useState<boolean>(true);
|
||||
const [estimatedCost, setEstimatedCost] = useState<number>(0);
|
||||
const [limitInfo, setLimitInfo] = useState<PreflightLimitInfo | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
export const usePreflightCheck = (options?: UsePreflightCheckOptions) => {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [lastCheck, setLastCheck] = useState<PreflightCheckResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Cache for pre-flight check results
|
||||
const cacheRef = useRef<Map<string, CacheEntry>>(new Map());
|
||||
|
||||
// Debounce timer ref
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Abort controller for request cancellation
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Generate cache key from operation
|
||||
const getCacheKey = useCallback(() => {
|
||||
return JSON.stringify(operation);
|
||||
}, [operation]);
|
||||
|
||||
// Check if cached result is still valid
|
||||
const getCachedResult = useCallback((): PreflightCheckResponse | null => {
|
||||
const cacheKey = getCacheKey();
|
||||
const cached = cacheRef.current.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.timestamp;
|
||||
if (age < cacheTtl) {
|
||||
return cached.data;
|
||||
}
|
||||
// Cache expired, remove it
|
||||
cacheRef.current.delete(cacheKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [getCacheKey, cacheTtl]);
|
||||
|
||||
// Store result in cache
|
||||
const setCache = useCallback((data: PreflightCheckResponse) => {
|
||||
const cacheKey = getCacheKey();
|
||||
cacheRef.current.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, [getCacheKey]);
|
||||
|
||||
// Perform actual pre-flight check
|
||||
const performCheck = useCallback(async (): Promise<void> => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedResult();
|
||||
if (cached) {
|
||||
updateState(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
const currentAbortController = abortControllerRef.current;
|
||||
|
||||
setLoading(true);
|
||||
const check = useCallback(async (operation: PreflightOperation): Promise<PreflightCheckResponse> => {
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const response = await checkPreflight(operation);
|
||||
setLastCheck(response);
|
||||
|
||||
// Check if request was cancelled
|
||||
if (currentAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
setCache(response);
|
||||
|
||||
// Update state
|
||||
updateState(response);
|
||||
} catch (err: any) {
|
||||
// Check if request was cancelled
|
||||
if (currentAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err?.message || 'Pre-flight check failed';
|
||||
setError(errorMessage);
|
||||
setCanProceed(false);
|
||||
setEstimatedCost(0);
|
||||
setLimitInfo(null);
|
||||
} finally {
|
||||
if (!currentAbortController.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [operation, enabled, getCachedResult, setCache]);
|
||||
|
||||
// Update state from response
|
||||
const updateState = useCallback((response: PreflightCheckResponse) => {
|
||||
setCanProceed(response.can_proceed);
|
||||
setEstimatedCost(response.estimated_cost);
|
||||
|
||||
// Get limit info from first operation (for single operation checks)
|
||||
const firstOp = response.operations[0];
|
||||
if (firstOp) {
|
||||
setLimitInfo(firstOp.limit_info);
|
||||
if (!response.can_proceed && firstOp.message) {
|
||||
setError(firstOp.message);
|
||||
if (!response.can_proceed) {
|
||||
setError(response.operations[0]?.message || 'Operation blocked by subscription limits');
|
||||
options?.onBlocked?.(response);
|
||||
} else {
|
||||
setError(null);
|
||||
options?.onAllowed?.(response);
|
||||
}
|
||||
} else {
|
||||
setLimitInfo(null);
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.detail || err?.message || 'Preflight check failed';
|
||||
setError(errorMessage);
|
||||
|
||||
// Return blocked response on error
|
||||
const blockedResponse: PreflightCheckResponse = {
|
||||
can_proceed: false,
|
||||
estimated_cost: 0,
|
||||
operations: [{
|
||||
provider: operation.provider,
|
||||
operation_type: operation.operation_type,
|
||||
cost: 0,
|
||||
allowed: false,
|
||||
limit_info: null,
|
||||
message: errorMessage,
|
||||
}],
|
||||
total_cost: 0,
|
||||
usage_summary: null,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
setLastCheck(blockedResponse);
|
||||
options?.onBlocked?.(blockedResponse);
|
||||
return blockedResponse;
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, []);
|
||||
}, [options]);
|
||||
|
||||
// Debounced check for hover events
|
||||
const checkOnHover = useCallback(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Check cache first (no debounce for cache hits)
|
||||
const cached = getCachedResult();
|
||||
if (cached) {
|
||||
updateState(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce the actual API call
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
performCheck();
|
||||
}, debounceMs);
|
||||
}, [enabled, debounceMs, getCachedResult, updateState, performCheck]);
|
||||
|
||||
// Immediate check (no debounce)
|
||||
const checkNow = useCallback(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending debounced check
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
performCheck();
|
||||
}, [enabled, performCheck]);
|
||||
|
||||
// Reset state
|
||||
const reset = useCallback(() => {
|
||||
setCanProceed(true);
|
||||
setEstimatedCost(0);
|
||||
setLimitInfo(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
|
||||
// Clear debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// Extract useful properties from lastCheck
|
||||
const estimatedCost = lastCheck?.estimated_cost ?? 0;
|
||||
const limitInfo = lastCheck?.operations?.[0]?.limit_info ?? null;
|
||||
|
||||
return {
|
||||
canProceed,
|
||||
check,
|
||||
isChecking,
|
||||
lastCheck,
|
||||
error,
|
||||
canProceed: lastCheck?.can_proceed ?? null,
|
||||
estimatedCost,
|
||||
limitInfo,
|
||||
loading,
|
||||
error,
|
||||
checkOnHover,
|
||||
checkNow,
|
||||
reset,
|
||||
loading: isChecking,
|
||||
// For backward compatibility with OperationButton
|
||||
checkOnHover: () => {}, // No-op for now, can be implemented if needed
|
||||
checkNow: () => check(lastCheck?.operations?.[0] ? {
|
||||
provider: lastCheck.operations[0].provider,
|
||||
operation_type: lastCheck.operations[0].operation_type,
|
||||
} as PreflightOperation : {
|
||||
provider: 'gemini',
|
||||
operation_type: 'unknown',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../components/PodcastMaker/types";
|
||||
import { checkPreflight, PreflightOperation } from "./billingService";
|
||||
import { TaskStatusResponse } from "./blogWriterApi";
|
||||
import { TaskStatus } from "./storyWriterApi";
|
||||
|
||||
type WaitForTaskFn = (taskId: string) => Promise<TaskStatusResponse>;
|
||||
|
||||
@@ -44,7 +45,9 @@ const createId = (prefix: string) => {
|
||||
return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
||||
};
|
||||
|
||||
const deriveSegments = (option?: StorySetupGenerationResponse["options"][0]): string[] => {
|
||||
type OptionLike = StorySetupGenerationResponse["options"][0] | { plot_elements?: string; premise?: string };
|
||||
|
||||
const deriveSegments = (option?: OptionLike): string[] => {
|
||||
const segments: string[] = [];
|
||||
if (option?.plot_elements) {
|
||||
option.plot_elements
|
||||
@@ -53,7 +56,7 @@ const deriveSegments = (option?: StorySetupGenerationResponse["options"][0]): st
|
||||
.filter(Boolean)
|
||||
.forEach((p) => segments.push(p));
|
||||
}
|
||||
if (!segments.length && option?.premise) {
|
||||
if (!segments.length && "premise" in (option || {}) && (option as any)?.premise) {
|
||||
segments.push("Intro", "Key Takeaways", "Examples", "CTA");
|
||||
}
|
||||
return segments.slice(0, 5);
|
||||
@@ -65,19 +68,21 @@ const estimateCosts = ({
|
||||
chars,
|
||||
quality,
|
||||
avatars,
|
||||
queryCount = 3,
|
||||
}: {
|
||||
minutes: number;
|
||||
scenes: number;
|
||||
chars: number;
|
||||
quality: string;
|
||||
avatars: number;
|
||||
queryCount?: number;
|
||||
}): PodcastEstimate => {
|
||||
const secs = Math.max(60, minutes * 60);
|
||||
const ttsCost = (chars / 1000) * 0.05;
|
||||
const avatarCost = avatars * 0.15;
|
||||
const videoRate = quality === "hd" ? 0.06 : 0.03;
|
||||
const videoCost = secs * videoRate;
|
||||
const researchCost = 0.5;
|
||||
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
|
||||
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
|
||||
return {
|
||||
ttsCost: +ttsCost.toFixed(2),
|
||||
@@ -89,25 +94,35 @@ const estimateCosts = ({
|
||||
};
|
||||
|
||||
const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => {
|
||||
const keywords = persona?.suggested_keywords?.length ? persona.suggested_keywords : seed.split(/\s+/).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
return [
|
||||
{
|
||||
id: createId("q"),
|
||||
query: seed || "ai marketing small business",
|
||||
rationale: "Seed query derived from idea/topic",
|
||||
needsRecentStats: true,
|
||||
},
|
||||
];
|
||||
const baseIdea = seed || "AI marketing for small businesses";
|
||||
const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || [];
|
||||
const angles = persona?.research_angles ?? [];
|
||||
const generated: Query[] = [];
|
||||
|
||||
const addQuery = (q: string, why: string, needsRecent = false) => {
|
||||
if (!q.trim()) return;
|
||||
generated.push({
|
||||
id: createId("q"),
|
||||
query: q.trim(),
|
||||
rationale: why,
|
||||
needsRecentStats: needsRecent,
|
||||
});
|
||||
};
|
||||
|
||||
if (personaKeywords.length) {
|
||||
personaKeywords.slice(0, 4).forEach((k, idx) =>
|
||||
addQuery(k, angles[idx % Math.max(1, angles.length)] || "Persona-aligned query", /202[45]|latest|trend/i.test(k))
|
||||
);
|
||||
}
|
||||
|
||||
const angles = persona?.research_angles ?? [];
|
||||
return keywords.slice(0, 6).map((keyword, idx) => ({
|
||||
id: createId("q"),
|
||||
query: `${keyword}`.trim(),
|
||||
rationale: angles[idx % angles.length] || "High-impact persona angle",
|
||||
needsRecentStats: /202[45]|latest|trend/i.test(keyword),
|
||||
}));
|
||||
if (!generated.length) {
|
||||
addQuery(`How is ${baseIdea} evolving in 2024?`, "Trend + outcome focus", true);
|
||||
addQuery(`Best practices for ${baseIdea}`, "Actionable guidance", false);
|
||||
addQuery(`${baseIdea} case studies with ROI`, "Proof and outcomes", true);
|
||||
addQuery(`${baseIdea} risks and objections`, "Address listener concerns", false);
|
||||
}
|
||||
|
||||
return generated.slice(0, 6);
|
||||
};
|
||||
|
||||
const mapSourcesToFacts = (sources: BlogResearchResponse["sources"]): Fact[] => {
|
||||
@@ -191,20 +206,40 @@ const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StorySc
|
||||
return [];
|
||||
};
|
||||
|
||||
const waitForTaskCompletion = async (taskId: string, poll: WaitForTaskFn): Promise<any> => {
|
||||
const waitForTaskCompletion = async (
|
||||
taskId: string,
|
||||
poll: WaitForTaskFn,
|
||||
onProgress?: (status: { status: string; progress?: number; message?: string }) => void
|
||||
): Promise<any> => {
|
||||
let attempts = 0;
|
||||
while (attempts < 120) {
|
||||
const status = await poll(taskId);
|
||||
|
||||
// Report progress if callback provided
|
||||
if (onProgress) {
|
||||
// Extract latest progress message if available
|
||||
const latestMessage = status.progress_messages && status.progress_messages.length > 0
|
||||
? status.progress_messages[status.progress_messages.length - 1].message
|
||||
: undefined;
|
||||
|
||||
onProgress({
|
||||
status: status.status,
|
||||
progress: undefined, // TaskStatusResponse doesn't have progress field
|
||||
message: latestMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (status.status === "completed") {
|
||||
return status.result;
|
||||
}
|
||||
if (status.status === "failed") {
|
||||
throw new Error(status.error || "Task failed");
|
||||
const errorMsg = status.error || "Task failed";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
await sleep(2500);
|
||||
attempts += 1;
|
||||
}
|
||||
throw new Error("Task polling timed out");
|
||||
throw new Error("Task polling timed out after 5 minutes");
|
||||
};
|
||||
|
||||
const ensurePreflight = async (operation: PreflightOperation) => {
|
||||
@@ -219,27 +254,27 @@ const ensurePreflight = async (operation: PreflightOperation) => {
|
||||
export const podcastApi = {
|
||||
async createProject(payload: CreateProjectPayload): Promise<CreateProjectResult> {
|
||||
const storyIdea = payload.ideaOrUrl || "AI marketing for small businesses";
|
||||
const setup = await storyWriterApi.generateStorySetup({ story_idea: storyIdea });
|
||||
const primary = setup.options?.[0];
|
||||
|
||||
const suggestedOutlines = [
|
||||
{
|
||||
id: "primary",
|
||||
title: primary?.premise?.slice(0, 60) || "Episode Outline",
|
||||
segments: deriveSegments(primary),
|
||||
},
|
||||
];
|
||||
// Podcast-specific analysis (not story setup)
|
||||
const analysisResp = await aiApiClient.post("/api/podcast/analyze", {
|
||||
idea: storyIdea,
|
||||
duration: payload.duration,
|
||||
speakers: payload.speakers,
|
||||
});
|
||||
|
||||
const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({
|
||||
id: o.id || `outline-${idx + 1}`,
|
||||
title: o.title || `Outline ${idx + 1}`,
|
||||
segments: Array.isArray(o.segments) ? o.segments : deriveSegments({ plot_elements: o.segments }),
|
||||
}));
|
||||
|
||||
const analysis: PodcastAnalysis = {
|
||||
audience: primary?.audience_age_group || "Growth-minded pros",
|
||||
contentType: primary?.persona || "How-to podcast",
|
||||
topKeywords: suggestedOutlines[0].segments.slice(0, 3),
|
||||
suggestedOutlines,
|
||||
audience: analysisResp.data?.audience || "Growth-minded pros",
|
||||
contentType: analysisResp.data?.content_type || "Podcast interview",
|
||||
topKeywords: analysisResp.data?.top_keywords || outlines[0]?.segments?.slice(0, 3) || [],
|
||||
suggestedOutlines: outlines,
|
||||
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
|
||||
titleSuggestions: [
|
||||
primary?.premise?.slice(0, 80),
|
||||
`${primary?.persona || "AI Host"} on ${primary?.story_setting || "automation"}`,
|
||||
].filter(Boolean) as string[],
|
||||
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
|
||||
};
|
||||
|
||||
const researchConfig = await getResearchConfig().catch(() => null);
|
||||
@@ -252,6 +287,7 @@ export const podcastApi = {
|
||||
chars: Math.max(1000, payload.duration * 900),
|
||||
quality: payload.knobs.bitrate || "standard",
|
||||
avatars: payload.speakers,
|
||||
queryCount: queries.length || 3,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -267,6 +303,7 @@ export const podcastApi = {
|
||||
topic: string;
|
||||
approvedQueries: Query[];
|
||||
provider?: ResearchProvider;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ research: Research; raw: BlogResearchResponse }> {
|
||||
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
@@ -291,7 +328,29 @@ export const podcastApi = {
|
||||
});
|
||||
|
||||
const { task_id } = await blogWriterApi.startResearch(researchPayload);
|
||||
const result = (await waitForTaskCompletion(task_id, blogWriterApi.pollResearchStatus)) as BlogResearchResponse;
|
||||
let lastProgressMessage = "";
|
||||
const result = (await waitForTaskCompletion(
|
||||
task_id,
|
||||
blogWriterApi.pollResearchStatus,
|
||||
(status) => {
|
||||
// Extract latest progress message and notify caller
|
||||
if (status.message && status.message !== lastProgressMessage) {
|
||||
lastProgressMessage = status.message;
|
||||
if (params.onProgress) {
|
||||
params.onProgress(status.message);
|
||||
}
|
||||
} else if (status.status === "running" && !status.message) {
|
||||
// Provide default status messages if none available
|
||||
const defaultMessage = params.provider === "exa"
|
||||
? "Deep research in progress..."
|
||||
: "Gathering research sources...";
|
||||
if (params.onProgress && lastProgressMessage !== defaultMessage) {
|
||||
lastProgressMessage = defaultMessage;
|
||||
params.onProgress(defaultMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
)) as BlogResearchResponse;
|
||||
const mapped = mapResearchResponse(result);
|
||||
return { research: mapped, raw: result };
|
||||
},
|
||||
@@ -311,28 +370,34 @@ export const podcastApi = {
|
||||
actual_provider_name: "gemini",
|
||||
});
|
||||
|
||||
const premise =
|
||||
params.research?.keyword_analysis?.summary ||
|
||||
params.research?.keyword_analysis?.key_insights?.join(" ") ||
|
||||
params.idea;
|
||||
const response = await aiApiClient.post("/api/podcast/script", {
|
||||
idea: params.idea,
|
||||
duration_minutes: params.durationMinutes,
|
||||
speakers: params.speakers,
|
||||
research: params.research,
|
||||
});
|
||||
|
||||
const storyRequest: StoryGenerationRequest = {
|
||||
persona: "AI Podcast Host",
|
||||
story_setting: "Modern marketing studio",
|
||||
character_input: "Host and guest conversation",
|
||||
plot_elements: params.research?.suggested_angles?.join(", ") || params.idea,
|
||||
writing_style: "Conversational",
|
||||
story_tone: "Informative",
|
||||
narrative_pov: "first-person",
|
||||
audience_age_group: "Adults",
|
||||
content_rating: "G",
|
||||
ending_preference: "Call to action",
|
||||
story_length: params.durationMinutes > 15 ? "Long" : "Medium",
|
||||
};
|
||||
|
||||
const outlineResponse = await storyWriterApi.generateOutline(premise, storyRequest);
|
||||
const storyScenes = ensureScenes(outlineResponse.outline);
|
||||
const scriptScenes = storyScenes.map((scene) => storySceneToPodcastScene(scene, params.knobs, params.speakers));
|
||||
const scenes = response.data?.scenes || [];
|
||||
const scriptScenes: Scene[] = scenes.map((scene: any) => ({
|
||||
id: scene.id || createId("scene"),
|
||||
title: scene.title || "Scene",
|
||||
duration: scene.duration || Math.max(20, params.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
|
||||
lines:
|
||||
Array.isArray(scene.lines) && scene.lines.length
|
||||
? scene.lines.map((l: any) => ({
|
||||
id: createId("line"),
|
||||
speaker: l.speaker || "Host",
|
||||
text: l.text || "",
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: createId("line"),
|
||||
speaker: "Host",
|
||||
text: "Let's dive into today's topic.",
|
||||
},
|
||||
],
|
||||
approved: false,
|
||||
}));
|
||||
|
||||
return { scenes: scriptScenes };
|
||||
},
|
||||
@@ -377,8 +442,8 @@ export const podcastApi = {
|
||||
actual_provider_name: "wavespeed",
|
||||
});
|
||||
|
||||
const response = await storyWriterApi.generateAIAudio({
|
||||
scene_number: Number(params.scene.id.replace(/\D+/g, "")) || 0,
|
||||
const response = await aiApiClient.post("/api/podcast/audio", {
|
||||
scene_id: params.scene.id,
|
||||
scene_title: params.scene.title,
|
||||
text,
|
||||
voice_id: params.voiceId || "Wise_Woman",
|
||||
@@ -386,18 +451,14 @@ export const podcastApi = {
|
||||
emotion: params.emotion || "neutral",
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || "Render failed");
|
||||
}
|
||||
|
||||
return {
|
||||
audioUrl: response.audio_url,
|
||||
audioFilename: response.audio_filename,
|
||||
provider: response.provider,
|
||||
model: response.model,
|
||||
cost: response.cost,
|
||||
voiceId: response.voice_id,
|
||||
fileSize: response.file_size,
|
||||
audioUrl: response.data.audio_url,
|
||||
audioFilename: response.data.audio_filename,
|
||||
provider: response.data.provider,
|
||||
model: response.data.model,
|
||||
cost: response.data.cost,
|
||||
voiceId: response.data.voice_id,
|
||||
fileSize: response.data.file_size,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -409,6 +470,123 @@ export const podcastApi = {
|
||||
notes: params.notes,
|
||||
});
|
||||
},
|
||||
|
||||
// Project persistence endpoints
|
||||
async saveProject(projectId: string, state: any): Promise<void> {
|
||||
try {
|
||||
await aiApiClient.put(`/api/podcast/projects/${projectId}`, state);
|
||||
} catch (error) {
|
||||
console.error("Failed to save project to database:", error);
|
||||
// Don't throw - localStorage fallback is acceptable
|
||||
}
|
||||
},
|
||||
|
||||
async loadProject(projectId: string): Promise<any> {
|
||||
const response = await aiApiClient.get(`/api/podcast/projects/${projectId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async listProjects(params?: {
|
||||
status?: string;
|
||||
favorites_only?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order_by?: "updated_at" | "created_at";
|
||||
}): Promise<{ projects: any[]; total: number; limit: number; offset: number }> {
|
||||
const response = await aiApiClient.get("/api/podcast/projects", { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createProjectInDb(params: {
|
||||
project_id: string;
|
||||
idea: string;
|
||||
duration: number;
|
||||
speakers: number;
|
||||
budget_cap: number;
|
||||
}): Promise<any> {
|
||||
const response = await aiApiClient.post("/api/podcast/projects", params);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteProject(projectId: string): Promise<void> {
|
||||
await aiApiClient.delete(`/api/podcast/projects/${projectId}`);
|
||||
},
|
||||
|
||||
async toggleFavorite(projectId: string): Promise<any> {
|
||||
const response = await aiApiClient.post(`/api/podcast/projects/${projectId}/favorite`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async saveAudioToAssetLibrary(params: {
|
||||
audioUrl: string;
|
||||
filename: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId: string;
|
||||
sceneId?: string;
|
||||
cost?: number;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
fileSize?: number;
|
||||
}): Promise<{ assetId: number }> {
|
||||
const response = await aiApiClient.post("/api/content-assets/", {
|
||||
asset_type: "audio",
|
||||
source_module: "podcast_maker",
|
||||
filename: params.filename,
|
||||
file_url: params.audioUrl,
|
||||
title: params.title,
|
||||
description: params.description || `Podcast episode audio: ${params.title}`,
|
||||
tags: ["podcast", "audio", params.projectId],
|
||||
asset_metadata: {
|
||||
project_id: params.projectId,
|
||||
scene_id: params.sceneId,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
},
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
cost: params.cost || 0,
|
||||
file_size: params.fileSize,
|
||||
mime_type: "audio/mpeg",
|
||||
});
|
||||
return { assetId: response.data.id };
|
||||
},
|
||||
|
||||
async generateVideo(params: {
|
||||
projectId: string;
|
||||
sceneId: string;
|
||||
sceneTitle: string;
|
||||
audioUrl: string;
|
||||
avatarImageUrl?: string;
|
||||
resolution?: string;
|
||||
prompt?: string;
|
||||
}): Promise<{ taskId: string; status: string; message: string }> {
|
||||
const response = await aiApiClient.post("/api/podcast/render/video", {
|
||||
project_id: params.projectId,
|
||||
scene_id: params.sceneId,
|
||||
scene_title: params.sceneTitle,
|
||||
audio_url: params.audioUrl,
|
||||
avatar_image_url: params.avatarImageUrl,
|
||||
resolution: params.resolution || "720p",
|
||||
prompt: params.prompt,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async pollTaskStatus(taskId: string): Promise<TaskStatus> {
|
||||
const response = await aiApiClient.get(`/api/podcast/task/${taskId}/status`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async cancelTask(taskId: string): Promise<void> {
|
||||
// Note: Task cancellation may not be fully supported by backend yet
|
||||
// This is a placeholder for future implementation
|
||||
try {
|
||||
await aiApiClient.post(`/api/story/task/${taskId}/cancel`);
|
||||
} catch (error) {
|
||||
console.warn("Task cancellation not supported:", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export type PodcastApi = typeof podcastApi;
|
||||
|
||||
189
frontend/src/services/youtubeApi.ts
Normal file
189
frontend/src/services/youtubeApi.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// YouTube Creator Studio API Client
|
||||
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
const API_BASE = '/api/youtube';
|
||||
|
||||
export interface VideoPlanRequest {
|
||||
user_idea: string;
|
||||
duration_type: 'shorts' | 'medium' | 'long';
|
||||
reference_image_description?: string;
|
||||
source_content_id?: string;
|
||||
source_content_type?: 'blog' | 'story';
|
||||
}
|
||||
|
||||
export interface VideoPlan {
|
||||
video_summary: string;
|
||||
target_audience: string;
|
||||
video_goal?: string;
|
||||
key_message?: string;
|
||||
content_outline: Array<{
|
||||
section: string;
|
||||
description: string;
|
||||
duration_estimate: number;
|
||||
}>;
|
||||
hook_strategy: string;
|
||||
call_to_action?: string;
|
||||
cta_ideas?: string[];
|
||||
visual_style: string;
|
||||
tone?: string;
|
||||
seo_keywords: string[];
|
||||
duration_type: string;
|
||||
estimated_duration?: string;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
scene_number: number;
|
||||
title: string;
|
||||
narration: string;
|
||||
visual_prompt: string;
|
||||
enhanced_visual_prompt?: string;
|
||||
duration_estimate: number;
|
||||
visual_cues: string[];
|
||||
emphasis_tags: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoRenderRequest {
|
||||
scenes: Scene[];
|
||||
video_plan: VideoPlan;
|
||||
resolution?: '480p' | '720p' | '1080p';
|
||||
combine_scenes?: boolean;
|
||||
voice_id?: string;
|
||||
}
|
||||
|
||||
export interface TaskStatus {
|
||||
task_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
message?: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CostEstimateRequest {
|
||||
scenes: Scene[];
|
||||
resolution: '480p' | '720p' | '1080p';
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
resolution: string;
|
||||
price_per_second: number;
|
||||
num_scenes: number;
|
||||
total_duration_seconds: number;
|
||||
scene_costs: Array<{
|
||||
scene_number: number;
|
||||
duration_estimate: number;
|
||||
actual_duration: number;
|
||||
cost: number;
|
||||
}>;
|
||||
total_cost: number;
|
||||
estimated_cost_range: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CostEstimateResponse {
|
||||
success: boolean;
|
||||
estimate?: CostEstimate;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const youtubeApi = {
|
||||
/**
|
||||
* Generate a video plan from user input.
|
||||
*/
|
||||
async createPlan(request: VideoPlanRequest): Promise<{ success: boolean; plan?: VideoPlan; message: string }> {
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/plan`, request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to create video plan';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Build scenes from a video plan.
|
||||
*/
|
||||
async buildScenes(videoPlan: VideoPlan, customScript?: string): Promise<{ success: boolean; scenes?: Scene[]; message: string }> {
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/scenes`, {
|
||||
video_plan: videoPlan,
|
||||
custom_script: customScript || undefined,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to build scenes';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a single scene.
|
||||
*/
|
||||
async updateScene(
|
||||
sceneId: number,
|
||||
updates: {
|
||||
narration?: string;
|
||||
visual_description?: string;
|
||||
duration_estimate?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
): Promise<{ success: boolean; scene?: Scene; message: string }> {
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/scenes/${sceneId}/update`, updates);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to update scene';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start rendering a video.
|
||||
*/
|
||||
async startRender(request: VideoRenderRequest): Promise<{ success: boolean; task_id?: string; message: string }> {
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/render`, request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to start render';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get render task status.
|
||||
*/
|
||||
async getRenderStatus(taskId: string): Promise<TaskStatus> {
|
||||
try {
|
||||
const response = await apiClient.get(`${API_BASE}/render/${taskId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to get render status';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Estimate the cost of rendering a video before rendering.
|
||||
*/
|
||||
async estimateCost(request: CostEstimateRequest): Promise<CostEstimateResponse> {
|
||||
try {
|
||||
const response = await apiClient.post(`${API_BASE}/estimate-cost`, request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Failed to estimate cost';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get video URL for a generated video.
|
||||
*/
|
||||
getVideoUrl(filename: string): string {
|
||||
return `${API_BASE}/videos/${filename}`;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user