WIP: AI Podcast Maker and YouTube Creator Studio integration

This commit is contained in:
ajaysi
2025-12-10 09:37:55 +05:30
parent 31f078c763
commit 81590cf4db
75 changed files with 11879 additions and 1380 deletions

View File

@@ -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>} />

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>
)}

View 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>
);
};

View 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 wont auto-run anything).<br />
Keep it conciseone 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. Well 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 wont 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 wont 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" : "Well start AI analysis after this click"}
>
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
</PrimaryButton>
</Stack>
</Stack>
</Paper>
);
};

View 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>
);
};

View 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

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,4 @@
export { LineEditor } from "./LineEditor";
export { SceneEditor } from "./SceneEditor";
export { ScriptEditor } from "./ScriptEditor";

View File

@@ -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;
};

View 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)",
};

View 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
);
};

View 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
);
};

View File

@@ -0,0 +1,4 @@
export { GlassyCard, glassyCardSx } from "./GlassyCard";
export { PrimaryButton } from "./PrimaryButton";
export { SecondaryButton } from "./SecondaryButton";

View File

@@ -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 ? (

View File

@@ -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;

View 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;

View File

@@ -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';

View 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';

View 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';

View 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';

View 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';

View 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

View File

@@ -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 };
};

View 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,
};
};

View 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' };

View File

@@ -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

View File

@@ -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']
}
]
},

View 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,
};
};

View File

@@ -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 {

View 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,
};
};

View File

@@ -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',
}),
};
};

View File

@@ -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;

View 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}`;
},
};