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

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