WIP: AI Podcast Maker and YouTube Creator Studio integration
This commit is contained in:
453
frontend/src/components/YouTubeCreator/YouTubeCreator.tsx
Normal file
453
frontend/src/components/YouTubeCreator/YouTubeCreator.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* YouTube Creator Studio Component
|
||||
*
|
||||
* AI-first YouTube video creation tool with persona integration.
|
||||
* Three-phase workflow: Plan → Scenes → Render
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Paper,
|
||||
Button,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { youtubeApi, type VideoPlan, type Scene } from '../../services/youtubeApi';
|
||||
import { STEPS, YT_RED, YT_BG, YT_BORDER, YT_TEXT, type Resolution, type DurationType } from './constants';
|
||||
import { PlanStep } from './components/PlanStep';
|
||||
import { ScenesStep } from './components/ScenesStep';
|
||||
import { RenderStep } from './components/RenderStep';
|
||||
import { useRenderPolling } from './hooks/useRenderPolling';
|
||||
import { useCostEstimate } from './hooks/useCostEstimate';
|
||||
import HeaderControls from '../shared/HeaderControls';
|
||||
|
||||
const YouTubeCreator: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Step 1: Plan
|
||||
const [userIdea, setUserIdea] = useState('');
|
||||
const [durationType, setDurationType] = useState<DurationType>('medium');
|
||||
const [referenceImage, setReferenceImage] = useState('');
|
||||
const [videoPlan, setVideoPlan] = useState<VideoPlan | null>(null);
|
||||
|
||||
// Step 2: Scenes
|
||||
const [scenes, setScenes] = useState<Scene[]>([]);
|
||||
const [editingSceneId, setEditingSceneId] = useState<number | null>(null);
|
||||
const [editedScene, setEditedScene] = useState<Partial<Scene> | null>(null);
|
||||
|
||||
// Step 3: Render
|
||||
const [renderTaskId, setRenderTaskId] = useState<string | null>(null);
|
||||
const [renderStatus, setRenderStatus] = useState<any>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const [resolution, setResolution] = useState<Resolution>('720p');
|
||||
const [combineScenes, setCombineScenes] = useState(true);
|
||||
|
||||
// Custom hooks
|
||||
const { renderStatus: polledStatus, renderProgress: polledProgress, error: pollingError } = useRenderPolling(
|
||||
renderTaskId,
|
||||
() => setSuccess('Video rendered successfully!'),
|
||||
(err) => setError(err)
|
||||
);
|
||||
|
||||
// Update local state from polling hook
|
||||
React.useEffect(() => {
|
||||
if (polledStatus) {
|
||||
setRenderStatus(polledStatus);
|
||||
}
|
||||
if (polledProgress !== undefined) {
|
||||
setRenderProgress(polledProgress);
|
||||
}
|
||||
if (pollingError) {
|
||||
setError(pollingError);
|
||||
}
|
||||
}, [polledStatus, polledProgress, pollingError]);
|
||||
|
||||
const { costEstimate, loadingCostEstimate } = useCostEstimate({
|
||||
activeStep,
|
||||
scenes,
|
||||
resolution,
|
||||
renderTaskId,
|
||||
});
|
||||
|
||||
// Memoized computed values
|
||||
const enabledScenesCount = useMemo(
|
||||
() => scenes.filter(s => s.enabled !== false).length,
|
||||
[scenes]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleGeneratePlan = useCallback(async () => {
|
||||
if (!userIdea.trim()) {
|
||||
setError('Please enter your video idea');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await youtubeApi.createPlan({
|
||||
user_idea: userIdea,
|
||||
duration_type: durationType,
|
||||
reference_image_description: referenceImage || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.plan) {
|
||||
setVideoPlan(response.plan);
|
||||
setSuccess('Video plan generated successfully!');
|
||||
setTimeout(() => {
|
||||
setActiveStep(1);
|
||||
setSuccess(null);
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(response.message || 'Failed to generate plan');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to generate video plan');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userIdea, durationType, referenceImage]);
|
||||
|
||||
const handleBuildScenes = useCallback(async () => {
|
||||
if (!videoPlan) {
|
||||
setError('Please generate a plan first');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await youtubeApi.buildScenes(videoPlan);
|
||||
|
||||
if (response.success && response.scenes) {
|
||||
setScenes(response.scenes.map(s => ({ ...s, enabled: s.enabled !== false })));
|
||||
setSuccess(`Built ${response.scenes.length} scenes successfully!`);
|
||||
setTimeout(() => {
|
||||
setActiveStep(2);
|
||||
setSuccess(null);
|
||||
}, 1000);
|
||||
} else {
|
||||
setError(response.message || 'Failed to build scenes');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to build scenes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [videoPlan]);
|
||||
|
||||
const handleEditScene = useCallback((scene: Scene) => {
|
||||
setEditingSceneId(scene.scene_number);
|
||||
setEditedScene({
|
||||
narration: scene.narration,
|
||||
visual_prompt: scene.visual_prompt,
|
||||
duration_estimate: scene.duration_estimate,
|
||||
enabled: scene.enabled !== false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSaveScene = useCallback(async () => {
|
||||
if (!editingSceneId || !editedScene) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await youtubeApi.updateScene(editingSceneId, {
|
||||
narration: editedScene.narration,
|
||||
visual_description: editedScene.visual_prompt,
|
||||
duration_estimate: editedScene.duration_estimate,
|
||||
enabled: editedScene.enabled,
|
||||
});
|
||||
|
||||
if (response.success && response.scene) {
|
||||
setScenes(scenes.map(s =>
|
||||
s.scene_number === editingSceneId ? { ...s, ...response.scene } : s
|
||||
));
|
||||
setEditingSceneId(null);
|
||||
setEditedScene(null);
|
||||
setSuccess('Scene updated successfully!');
|
||||
} else {
|
||||
setError(response.message || 'Failed to update scene');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update scene');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [editingSceneId, editedScene, scenes]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingSceneId(null);
|
||||
setEditedScene(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleScene = useCallback((sceneNumber: number) => {
|
||||
setScenes(scenes.map(s =>
|
||||
s.scene_number === sceneNumber ? { ...s, enabled: !s.enabled } : s
|
||||
));
|
||||
}, [scenes]);
|
||||
|
||||
const handleStartRender = useCallback(async () => {
|
||||
if (scenes.length === 0) {
|
||||
setError('Please build scenes first');
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledScenes = scenes.filter(s => s.enabled !== false);
|
||||
if (enabledScenes.length === 0) {
|
||||
setError('Please enable at least one scene to render');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!videoPlan) {
|
||||
setError('Video plan is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await youtubeApi.startRender({
|
||||
scenes: enabledScenes,
|
||||
video_plan: videoPlan,
|
||||
resolution,
|
||||
combine_scenes: combineScenes,
|
||||
});
|
||||
|
||||
if (response.success && response.task_id) {
|
||||
setRenderTaskId(response.task_id);
|
||||
setRenderProgress(0);
|
||||
setSuccess('Video rendering started!');
|
||||
} else {
|
||||
setError(response.message || 'Failed to start render');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to start render');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scenes, videoPlan, resolution, combineScenes]);
|
||||
|
||||
const getVideoUrl = useCallback(() => {
|
||||
if (renderStatus?.result?.final_video_url) {
|
||||
return renderStatus.result.final_video_url;
|
||||
}
|
||||
if (renderStatus?.result?.scene_results?.[0]?.video_url) {
|
||||
return renderStatus.result.scene_results[0].video_url;
|
||||
}
|
||||
return null;
|
||||
}, [renderStatus]);
|
||||
|
||||
const handleStepNavigation = useCallback((targetStep: number) => {
|
||||
if (targetStep === activeStep) return;
|
||||
|
||||
// Always allow going back
|
||||
if (targetStep < activeStep) {
|
||||
setActiveStep(targetStep);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward navigation with guards
|
||||
if (targetStep === 1) {
|
||||
if (!videoPlan) {
|
||||
setError('Please generate a plan first.');
|
||||
return;
|
||||
}
|
||||
setActiveStep(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetStep === 2) {
|
||||
if (!videoPlan) {
|
||||
setError('Please generate a plan first.');
|
||||
return;
|
||||
}
|
||||
if (scenes.length === 0) {
|
||||
setError('Please build scenes before rendering.');
|
||||
return;
|
||||
}
|
||||
if (enabledScenesCount === 0) {
|
||||
setError('Enable at least one scene to render.');
|
||||
return;
|
||||
}
|
||||
setActiveStep(2);
|
||||
return;
|
||||
}
|
||||
}, [activeStep, videoPlan, scenes.length, enabledScenesCount]);
|
||||
|
||||
const handleResetRender = useCallback(() => {
|
||||
setRenderTaskId(null);
|
||||
setRenderStatus(null);
|
||||
setRenderProgress(0);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleRetryFailedScenes = useCallback((failedScenes: any[]) => {
|
||||
if (failedScenes.length > 0) {
|
||||
const sceneNumbers = failedScenes.map((f: any) => f.scene_number);
|
||||
const updatedScenes = scenes.map(s =>
|
||||
sceneNumbers.includes(s.scene_number)
|
||||
? { ...s, enabled: true }
|
||||
: s
|
||||
);
|
||||
setScenes(updatedScenes);
|
||||
handleResetRender();
|
||||
}
|
||||
}, [scenes, handleResetRender]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
py: 4,
|
||||
backgroundColor: YT_BG,
|
||||
color: YT_TEXT,
|
||||
minHeight: '100vh',
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/dashboard')}
|
||||
variant="outlined"
|
||||
sx={{ borderColor: YT_BORDER, color: YT_TEXT, backgroundColor: 'white' }}
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
🎥 YouTube Creator Studio
|
||||
</Typography>
|
||||
<HeaderControls colorMode="light" showAlerts={true} showUser={true} />
|
||||
</Box>
|
||||
|
||||
{/* Stepper */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
backgroundColor: 'white',
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
sx={{
|
||||
'& .MuiStepIcon-root.Mui-active': { color: YT_RED },
|
||||
'& .MuiStepIcon-root.Mui-completed': { color: YT_RED },
|
||||
}}
|
||||
>
|
||||
{STEPS.map((label, idx) => (
|
||||
<Step key={label} completed={idx < activeStep}>
|
||||
<StepLabel
|
||||
onClick={() => handleStepNavigation(idx)}
|
||||
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
|
||||
{/* Success Alert */}
|
||||
<AnimatePresence>
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Alert severity="success" sx={{ mb: 3 }} onClose={() => setSuccess(null)}>
|
||||
{success}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Step Components */}
|
||||
{activeStep === 0 && (
|
||||
<PlanStep
|
||||
userIdea={userIdea}
|
||||
durationType={durationType}
|
||||
referenceImage={referenceImage}
|
||||
loading={loading}
|
||||
onIdeaChange={setUserIdea}
|
||||
onDurationChange={setDurationType}
|
||||
onReferenceImageChange={setReferenceImage}
|
||||
onGeneratePlan={handleGeneratePlan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === 1 && videoPlan && (
|
||||
<ScenesStep
|
||||
videoPlan={videoPlan}
|
||||
scenes={scenes}
|
||||
editingSceneId={editingSceneId}
|
||||
editedScene={editedScene}
|
||||
loading={loading}
|
||||
onBuildScenes={handleBuildScenes}
|
||||
onEditScene={handleEditScene}
|
||||
onSaveScene={handleSaveScene}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onEditChange={setEditedScene}
|
||||
onToggleScene={handleToggleScene}
|
||||
onBack={() => setActiveStep(0)}
|
||||
onNext={() => setActiveStep(2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === 2 && (
|
||||
<RenderStep
|
||||
renderTaskId={renderTaskId}
|
||||
renderStatus={renderStatus}
|
||||
renderProgress={renderProgress}
|
||||
resolution={resolution}
|
||||
combineScenes={combineScenes}
|
||||
enabledScenesCount={enabledScenesCount}
|
||||
costEstimate={costEstimate}
|
||||
loadingCostEstimate={loadingCostEstimate}
|
||||
loading={loading}
|
||||
onResolutionChange={setResolution}
|
||||
onCombineScenesChange={setCombineScenes}
|
||||
onStartRender={handleStartRender}
|
||||
onBack={() => setActiveStep(1)}
|
||||
onReset={handleResetRender}
|
||||
onRetryFailedScenes={handleRetryFailedScenes}
|
||||
getVideoUrl={getVideoUrl}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeCreator;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Plan Details Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Paper, Typography, Stack, Box, Grid, Chip } from '@mui/material';
|
||||
import { VideoPlan } from '../../../services/youtubeApi';
|
||||
import { YT_BORDER, YT_TEXT } from '../constants';
|
||||
|
||||
interface PlanDetailsProps {
|
||||
plan: VideoPlan;
|
||||
}
|
||||
|
||||
export const PlanDetails: React.FC<PlanDetailsProps> = React.memo(({ plan }) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 3,
|
||||
p: 2.5,
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: YT_TEXT }}>
|
||||
Plan Details
|
||||
</Typography>
|
||||
<Stack spacing={1.25}>
|
||||
{plan.video_summary && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.video_summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{plan.target_audience && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Target Audience
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.target_audience}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{plan.video_goal && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Goal
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.video_goal}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid container spacing={2}>
|
||||
{plan.key_message && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Key Message
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.key_message}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{plan.call_to_action && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Call to Action
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.call_to_action}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid container spacing={2}>
|
||||
{plan.hook_strategy && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Hook Strategy
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{plan.hook_strategy}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT }}>
|
||||
Style & Tone
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Visual Style: {plan.visual_style || '—'} | Tone: {plan.tone || '—'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{plan.seo_keywords && plan.seo_keywords.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT, mb: 0.5 }}>
|
||||
SEO Keywords
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{plan.seo_keywords.map((kw, idx) => (
|
||||
<Chip key={`${kw}-${idx}`} label={kw} size="small" />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{plan.content_outline && plan.content_outline.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: YT_TEXT, mb: 0.5 }}>
|
||||
Content Outline
|
||||
</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
{plan.content_outline.map((item, idx) => (
|
||||
<Typography key={idx} variant="body2" color="text.secondary">
|
||||
• {item.section || `Section ${idx + 1}`} — {item.description || 'Description missing'} ({item.duration_estimate || 0}s)
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
PlanDetails.displayName = 'PlanDetails';
|
||||
|
||||
126
frontend/src/components/YouTubeCreator/components/PlanStep.tsx
Normal file
126
frontend/src/components/YouTubeCreator/components/PlanStep.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Plan Step Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Stack,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormHelperText,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { PlayArrow } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { inputSx, labelSx, helperSx, selectSx } from '../styles';
|
||||
import { DurationType } from '../constants';
|
||||
|
||||
interface PlanStepProps {
|
||||
userIdea: string;
|
||||
durationType: DurationType;
|
||||
referenceImage: string;
|
||||
loading: boolean;
|
||||
onIdeaChange: (idea: string) => void;
|
||||
onDurationChange: (duration: DurationType) => void;
|
||||
onReferenceImageChange: (image: string) => void;
|
||||
onGeneratePlan: () => void;
|
||||
}
|
||||
|
||||
export const PlanStep: React.FC<PlanStepProps> = React.memo(({
|
||||
userIdea,
|
||||
durationType,
|
||||
referenceImage,
|
||||
loading,
|
||||
onIdeaChange,
|
||||
onDurationChange,
|
||||
onReferenceImageChange,
|
||||
onGeneratePlan,
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e5e5',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
1️⃣ Plan Your Video
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
label="What's your video about?"
|
||||
placeholder="Example: 'AI explains black holes in 60 seconds' or 'Budget travel guide for Tokyo'"
|
||||
value={userIdea}
|
||||
onChange={(e) => onIdeaChange(e.target.value)}
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
required
|
||||
helperText="Describe the story in one to two sentences. Include audience, outcome, and hook. Tip: name the platform goal (views, subs, clicks)."
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
FormHelperTextProps={{ sx: helperSx }}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel sx={labelSx}>Video Duration</InputLabel>
|
||||
<Select
|
||||
value={durationType}
|
||||
label="Video Duration"
|
||||
onChange={(e) => onDurationChange(e.target.value as DurationType)}
|
||||
sx={selectSx}
|
||||
>
|
||||
<MenuItem value="shorts">Shorts (15-60 seconds)</MenuItem>
|
||||
<MenuItem value="medium">Medium (1-4 minutes)</MenuItem>
|
||||
<MenuItem value="long">Long (4-10 minutes)</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Shorts = vertical bite-sized (≤60s). Medium = quick explainers. Long = deep dives.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Reference Image Description (Optional)"
|
||||
placeholder="Example: 'neon-lit Tokyo alley, rainy night, cinematic bokeh' or paste image keywords"
|
||||
value={referenceImage}
|
||||
onChange={(e) => onReferenceImageChange(e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
helperText="Optional: Describe visual cues or style you want the visuals to follow."
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
FormHelperTextProps={{ sx: helperSx }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
onClick={onGeneratePlan}
|
||||
disabled={loading || !userIdea.trim()}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
|
||||
sx={{ alignSelf: 'flex-start', px: 4 }}
|
||||
>
|
||||
{loading ? 'Generating Plan...' : 'Generate Video Plan'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
PlanStep.displayName = 'PlanStep';
|
||||
|
||||
339
frontend/src/components/YouTubeCreator/components/RenderStep.tsx
Normal file
339
frontend/src/components/YouTubeCreator/components/RenderStep.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Render Step Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Stack,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Button,
|
||||
Box,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Typography as MuiTypography,
|
||||
} from '@mui/material';
|
||||
import { PlayArrow, Download, Refresh } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { TaskStatus, CostEstimate } from '../../../services/youtubeApi';
|
||||
import { YT_BORDER, RESOLUTIONS, type Resolution } from '../constants';
|
||||
|
||||
interface RenderStepProps {
|
||||
renderTaskId: string | null;
|
||||
renderStatus: TaskStatus | null;
|
||||
renderProgress: number;
|
||||
resolution: Resolution;
|
||||
combineScenes: boolean;
|
||||
enabledScenesCount: number;
|
||||
costEstimate: CostEstimate | null;
|
||||
loadingCostEstimate: boolean;
|
||||
loading: boolean;
|
||||
onResolutionChange: (resolution: Resolution) => void;
|
||||
onCombineScenesChange: (combine: boolean) => void;
|
||||
onStartRender: () => void;
|
||||
onBack: () => void;
|
||||
onReset: () => void;
|
||||
onRetryFailedScenes: (failedScenes: any[]) => void;
|
||||
getVideoUrl: () => string | null;
|
||||
}
|
||||
|
||||
export const RenderStep: React.FC<RenderStepProps> = React.memo(({
|
||||
renderTaskId,
|
||||
renderStatus,
|
||||
renderProgress,
|
||||
resolution,
|
||||
combineScenes,
|
||||
enabledScenesCount,
|
||||
costEstimate,
|
||||
loadingCostEstimate,
|
||||
loading,
|
||||
onResolutionChange,
|
||||
onCombineScenesChange,
|
||||
onStartRender,
|
||||
onBack,
|
||||
onReset,
|
||||
onRetryFailedScenes,
|
||||
getVideoUrl,
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
backgroundColor: 'white',
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||
3️⃣ Render Video
|
||||
</Typography>
|
||||
|
||||
{!renderTaskId ? (
|
||||
<Stack spacing={3}>
|
||||
<Alert severity="info">
|
||||
Configure render settings and start generating your video. This may take several minutes.
|
||||
</Alert>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Video Resolution</InputLabel>
|
||||
<Select
|
||||
value={resolution}
|
||||
label="Video Resolution"
|
||||
onChange={(e) => onResolutionChange(e.target.value as Resolution)}
|
||||
>
|
||||
{RESOLUTIONS.map((res) => (
|
||||
<MenuItem key={res} value={res}>
|
||||
{res === '480p' && '480p (Lower cost, faster)'}
|
||||
{res === '720p' && '720p (Recommended)'}
|
||||
{res === '1080p' && '1080p (Highest quality)'}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={combineScenes}
|
||||
onChange={(e) => onCombineScenesChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Combine scenes into single video"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ p: 2, bgcolor: '#f4f4f4', borderRadius: 1, border: `1px solid ${YT_BORDER}` }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Render Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
• {enabledScenesCount} scenes will be rendered
|
||||
<br />
|
||||
• Resolution: {resolution}
|
||||
<br />
|
||||
• {combineScenes ? 'Scenes will be combined into one video' : 'Each scene will be a separate video'}
|
||||
<br />
|
||||
</Typography>
|
||||
|
||||
{/* Cost Estimate */}
|
||||
{loadingCostEstimate ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Calculating cost estimate...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : costEstimate ? (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'primary.light', borderRadius: 1, border: '1px solid', borderColor: 'primary.main' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: 'primary.dark' }}>
|
||||
💰 Estimated Cost
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 1, color: 'primary.dark' }}>
|
||||
${costEstimate.total_cost.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Range: ${costEstimate.estimated_cost_range.min.toFixed(2)} - ${costEstimate.estimated_cost_range.max.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
• {costEstimate.num_scenes} scenes × ${costEstimate.price_per_second.toFixed(2)}/second
|
||||
<br />
|
||||
• Total duration: ~{Math.round(costEstimate.total_duration_seconds)} seconds
|
||||
<br />
|
||||
• Price per second: ${costEstimate.price_per_second.toFixed(2)} ({costEstimate.resolution})
|
||||
</Typography>
|
||||
{costEstimate.scene_costs.length > 0 && (
|
||||
<Box sx={{ mt: 1, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
Per Scene Breakdown:
|
||||
</Typography>
|
||||
{costEstimate.scene_costs.slice(0, 5).map((sceneCost) => (
|
||||
<Typography key={sceneCost.scene_number} variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
Scene {sceneCost.scene_number}: {sceneCost.actual_duration}s = ${sceneCost.cost.toFixed(2)}
|
||||
</Typography>
|
||||
))}
|
||||
{costEstimate.scene_costs.length > 5 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
... and {costEstimate.scene_costs.length - 5} more scenes
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
Unable to calculate cost estimate. Please check your scenes and try again.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button variant="outlined" onClick={onBack}>
|
||||
Back to Scenes
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
onClick={onStartRender}
|
||||
disabled={loading || enabledScenesCount === 0}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
{loading ? 'Starting Render...' : 'Start Video Render'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{renderStatus && (
|
||||
<>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{renderStatus.message || 'Processing...'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(renderProgress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress variant="determinate" value={renderProgress} sx={{ height: 8, borderRadius: 1 }} />
|
||||
</Box>
|
||||
|
||||
{renderStatus.status === 'completed' && renderStatus.result && (
|
||||
<Alert severity="success">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Video Rendered Successfully!
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Total cost: ${renderStatus.result.total_cost?.toFixed(2) || '0.00'}
|
||||
<br />
|
||||
Scenes rendered: {renderStatus.result.num_scenes || 0}
|
||||
</Typography>
|
||||
{getVideoUrl() && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<video
|
||||
controls
|
||||
src={getVideoUrl()!}
|
||||
style={{ width: '100%', maxHeight: '500px', borderRadius: 8 }}
|
||||
/>
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
href={getVideoUrl()!}
|
||||
download
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={onReset}
|
||||
>
|
||||
Render Another
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{renderStatus.status === 'failed' && (
|
||||
<Alert severity="error">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Render Failed
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{renderStatus.error || 'An error occurred during rendering'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Refresh />}
|
||||
onClick={onReset}
|
||||
>
|
||||
Retry Render
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onReset}
|
||||
>
|
||||
Start Over
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{renderStatus.status === 'completed' && renderStatus.result?.partial_success && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Partial Success
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{renderStatus.result.num_scenes} scenes rendered successfully, but{' '}
|
||||
{renderStatus.result.num_failed} scene(s) failed.
|
||||
{renderStatus.result.failed_scenes && renderStatus.result.failed_scenes.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<strong>Failed Scenes:</strong>
|
||||
{renderStatus.result.failed_scenes.map((failed: any, idx: number) => (
|
||||
<Box key={idx} sx={{ mt: 1, p: 1, bgcolor: 'error.light', borderRadius: 1 }}>
|
||||
<Typography variant="caption">
|
||||
Scene {failed.scene_number}: {failed.error || 'Unknown error'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => {
|
||||
const failedScenes = renderStatus.result?.failed_scenes || [];
|
||||
onRetryFailedScenes(failedScenes);
|
||||
}}
|
||||
>
|
||||
Retry Failed Scenes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onReset}
|
||||
>
|
||||
View Successful Scenes
|
||||
</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
RenderStep.displayName = 'RenderStep';
|
||||
|
||||
180
frontend/src/components/YouTubeCreator/components/SceneCard.tsx
Normal file
180
frontend/src/components/YouTubeCreator/components/SceneCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Scene Card Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Stack,
|
||||
Chip,
|
||||
Box,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
IconButton,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { Edit, Check, Close } from '@mui/icons-material';
|
||||
import { Scene } from '../../../services/youtubeApi';
|
||||
import { inputSx, labelSx } from '../styles';
|
||||
|
||||
interface SceneCardProps {
|
||||
scene: Scene;
|
||||
isEditing: boolean;
|
||||
editedScene: Partial<Scene> | null;
|
||||
onToggle: (sceneNumber: number) => void;
|
||||
onEdit: (scene: Scene) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onEditChange: (updates: Partial<Scene>) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const SceneCard: React.FC<SceneCardProps> = React.memo(({
|
||||
scene,
|
||||
isEditing,
|
||||
editedScene,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onEditChange,
|
||||
loading,
|
||||
}) => {
|
||||
const sceneData = isEditing && editedScene ? { ...scene, ...editedScene } : scene;
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
opacity: sceneData.enabled === false ? 0.6 : 1,
|
||||
border: sceneData.enabled === false ? '1px dashed' : '1px solid',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Scene {scene.scene_number}: {sceneData.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
{sceneData.emphasis_tags?.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
size="small"
|
||||
color={
|
||||
tag === 'hook' ? 'primary' :
|
||||
tag === 'cta' ? 'secondary' : 'default'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Chip
|
||||
label={`~${sceneData.duration_estimate}s`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={sceneData.enabled !== false}
|
||||
onChange={() => onToggle(scene.scene_number)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label="Enable"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onEdit(scene)}
|
||||
color="primary"
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isEditing ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Narration"
|
||||
value={sceneData.narration}
|
||||
onChange={(e) => onEditChange({ narration: e.target.value })}
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
/>
|
||||
<TextField
|
||||
label="Visual Prompt"
|
||||
value={sceneData.visual_prompt}
|
||||
onChange={(e) => onEditChange({ visual_prompt: e.target.value })}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
/>
|
||||
<TextField
|
||||
label="Duration (seconds)"
|
||||
type="number"
|
||||
value={sceneData.duration_estimate}
|
||||
onChange={(e) => onEditChange({ duration_estimate: parseFloat(e.target.value) || 5 })}
|
||||
inputProps={{ min: 1, max: 10, step: 0.5 }}
|
||||
fullWidth
|
||||
sx={inputSx}
|
||||
InputLabelProps={{ sx: labelSx }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Check />}
|
||||
onClick={onSave}
|
||||
disabled={loading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Close />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
"{sceneData.narration}"
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Visual: {sceneData.visual_prompt}
|
||||
</Typography>
|
||||
{sceneData.visual_cues && sceneData.visual_cues.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Cues: {sceneData.visual_cues.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
SceneCard.displayName = 'SceneCard';
|
||||
|
||||
140
frontend/src/components/YouTubeCreator/components/ScenesStep.tsx
Normal file
140
frontend/src/components/YouTubeCreator/components/ScenesStep.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Scenes Step Component
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
Box,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { PlayArrow, VideoLibrary } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { VideoPlan, Scene } from '../../../services/youtubeApi';
|
||||
import { PlanDetails } from './PlanDetails';
|
||||
import { SceneCard } from './SceneCard';
|
||||
import { YT_BORDER } from '../constants';
|
||||
|
||||
interface ScenesStepProps {
|
||||
videoPlan: VideoPlan;
|
||||
scenes: Scene[];
|
||||
editingSceneId: number | null;
|
||||
editedScene: Partial<Scene> | null;
|
||||
loading: boolean;
|
||||
onBuildScenes: () => void;
|
||||
onEditScene: (scene: Scene) => void;
|
||||
onSaveScene: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onEditChange: (updates: Partial<Scene>) => void;
|
||||
onToggleScene: (sceneNumber: number) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const ScenesStep: React.FC<ScenesStepProps> = React.memo(({
|
||||
videoPlan,
|
||||
scenes,
|
||||
editingSceneId,
|
||||
editedScene,
|
||||
loading,
|
||||
onBuildScenes,
|
||||
onEditScene,
|
||||
onSaveScene,
|
||||
onCancelEdit,
|
||||
onEditChange,
|
||||
onToggleScene,
|
||||
onBack,
|
||||
onNext,
|
||||
}) => {
|
||||
const enabledScenesCount = useMemo(
|
||||
() => scenes.filter(s => s.enabled !== false).length,
|
||||
[scenes]
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
backgroundColor: 'white',
|
||||
border: `1px solid ${YT_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
2️⃣ Review & Edit Scenes
|
||||
</Typography>
|
||||
{scenes.length === 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={onBuildScenes}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
|
||||
>
|
||||
{loading ? 'Building Scenes...' : 'Build Scenes from Plan'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<PlanDetails plan={videoPlan} />
|
||||
|
||||
{scenes.length > 0 ? (
|
||||
<Stack spacing={2}>
|
||||
{scenes.map((scene) => (
|
||||
<SceneCard
|
||||
key={scene.scene_number}
|
||||
scene={scene}
|
||||
isEditing={editingSceneId === scene.scene_number}
|
||||
editedScene={editedScene}
|
||||
onToggle={onToggleScene}
|
||||
onEdit={onEditScene}
|
||||
onSave={onSaveScene}
|
||||
onCancel={onCancelEdit}
|
||||
onEditChange={onEditChange}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<VideoLibrary sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Click "Build Scenes from Plan" to generate scene-by-scene breakdown
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{scenes.length > 0 && (
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'space-between' }}>
|
||||
<Button variant="outlined" onClick={onBack}>
|
||||
Back to Plan
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}>
|
||||
{enabledScenesCount} of {scenes.length} scenes enabled
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={onNext}
|
||||
disabled={enabledScenesCount === 0}
|
||||
>
|
||||
Proceed to Render ({enabledScenesCount} scenes)
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
ScenesStep.displayName = 'ScenesStep';
|
||||
|
||||
19
frontend/src/components/YouTubeCreator/constants.ts
Normal file
19
frontend/src/components/YouTubeCreator/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Constants for YouTube Creator Studio
|
||||
*/
|
||||
|
||||
export const YT_RED = '#FF0000';
|
||||
export const YT_BG = '#f9f9f9';
|
||||
export const YT_BORDER = '#e5e5e5';
|
||||
export const YT_TEXT = '#0f0f0f';
|
||||
|
||||
export const STEPS = ['Plan Your Video', 'Review Scenes', 'Render Video'] as const;
|
||||
|
||||
export const RESOLUTIONS = ['480p', '720p', '1080p'] as const;
|
||||
export type Resolution = typeof RESOLUTIONS[number];
|
||||
|
||||
export const DURATION_TYPES = ['shorts', 'medium', 'long'] as const;
|
||||
export type DurationType = typeof DURATION_TYPES[number];
|
||||
|
||||
export const POLLING_INTERVAL_MS = 2000; // 2 seconds
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Custom hook for fetching cost estimates
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { youtubeApi, type Scene, type CostEstimate } from '../../../services/youtubeApi';
|
||||
import { type Resolution } from '../constants';
|
||||
|
||||
interface UseCostEstimateParams {
|
||||
activeStep: number;
|
||||
scenes: Scene[];
|
||||
resolution: Resolution;
|
||||
renderTaskId: string | null;
|
||||
}
|
||||
|
||||
export const useCostEstimate = ({ activeStep, scenes, resolution, renderTaskId }: UseCostEstimateParams) => {
|
||||
const [costEstimate, setCostEstimate] = useState<CostEstimate | null>(null);
|
||||
const [loadingCostEstimate, setLoadingCostEstimate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeStep === 2 && scenes.length > 0 && !renderTaskId) {
|
||||
const fetchCostEstimate = async () => {
|
||||
setLoadingCostEstimate(true);
|
||||
try {
|
||||
const enabledScenes = scenes.filter(s => s.enabled !== false);
|
||||
const response = await youtubeApi.estimateCost({
|
||||
scenes: enabledScenes,
|
||||
resolution: resolution,
|
||||
});
|
||||
if (response.success && response.estimate) {
|
||||
setCostEstimate(response.estimate);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error estimating cost:', err);
|
||||
setCostEstimate(null);
|
||||
} finally {
|
||||
setLoadingCostEstimate(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCostEstimate();
|
||||
}
|
||||
}, [activeStep, scenes, resolution, renderTaskId]);
|
||||
|
||||
return { costEstimate, loadingCostEstimate };
|
||||
};
|
||||
|
||||
126
frontend/src/components/YouTubeCreator/hooks/useRenderPolling.ts
Normal file
126
frontend/src/components/YouTubeCreator/hooks/useRenderPolling.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Custom hook for polling render task status
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { youtubeApi, type TaskStatus } from '../../../services/youtubeApi';
|
||||
import { POLLING_INTERVAL_MS } from '../constants';
|
||||
|
||||
interface UseRenderPollingResult {
|
||||
renderStatus: TaskStatus | null;
|
||||
renderProgress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const useRenderPolling = (
|
||||
renderTaskId: string | null,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): UseRenderPollingResult => {
|
||||
const [renderStatus, setRenderStatus] = useState<TaskStatus | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (!renderTaskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start polling
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await youtubeApi.getRenderStatus(renderTaskId);
|
||||
setRenderStatus(status);
|
||||
setRenderProgress(status.progress || 0);
|
||||
|
||||
// Stop polling if task is completed or failed
|
||||
if (status.status === 'completed' || status.status === 'failed') {
|
||||
console.log(`[YouTubeCreator] Task ${renderTaskId} finished with status: ${status.status}`);
|
||||
|
||||
// Clear interval immediately
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
onSuccess?.();
|
||||
} else if (status.status === 'failed') {
|
||||
// Extract error message from status
|
||||
const errorMessage = status.error ||
|
||||
status.message ||
|
||||
(typeof status.result === 'object' && status.result?.error) ||
|
||||
'Video rendering failed. Please try again.';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
console.error(`[YouTubeCreator] Render task failed:`, status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to poll render status:', err);
|
||||
|
||||
// Handle 404 - task not found
|
||||
const is404 = err.response?.status === 404 ||
|
||||
err.message?.includes('Task not found') ||
|
||||
err.response?.data?.detail?.error === 'Task not found';
|
||||
|
||||
if (is404) {
|
||||
console.warn(`[YouTubeCreator] Task ${renderTaskId} not found, stopping polling`);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
const errorDetail = err.response?.data?.detail;
|
||||
const errorMessage = errorDetail?.message ||
|
||||
errorDetail?.error ||
|
||||
'Render task not found. This may happen if the server restarted or the task expired. Please try rendering again.';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// For 500 errors (server errors), stop polling
|
||||
const is500 = err.response?.status === 500;
|
||||
if (is500) {
|
||||
console.error(`[YouTubeCreator] Server error while polling, stopping`);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
const errorMessage = 'Server error occurred while checking render status. Please try rendering again.';
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, continue polling but log them
|
||||
console.warn(`[YouTubeCreator] Polling error (non-critical), will retry:`, err.message);
|
||||
}
|
||||
}, POLLING_INTERVAL_MS);
|
||||
|
||||
intervalRef.current = interval;
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [renderTaskId, onSuccess, onError]);
|
||||
|
||||
return {
|
||||
renderStatus,
|
||||
renderProgress,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
38
frontend/src/components/YouTubeCreator/styles.ts
Normal file
38
frontend/src/components/YouTubeCreator/styles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Shared styles for YouTube Creator Studio
|
||||
*/
|
||||
|
||||
import { YT_RED, YT_TEXT } from './constants';
|
||||
|
||||
export const inputSx = {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#fff',
|
||||
color: YT_TEXT,
|
||||
borderRadius: 1,
|
||||
'& fieldset': {
|
||||
borderColor: '#c6c6c6',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: YT_RED,
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: YT_RED,
|
||||
boxShadow: '0 0 0 2px rgba(255,0,0,0.08)',
|
||||
},
|
||||
'& input::placeholder, & textarea::placeholder': {
|
||||
color: '#5f6368',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const selectSx = {
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#c6c6c6' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: YT_RED },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: YT_RED },
|
||||
'& .MuiSelect-select': { color: YT_TEXT, backgroundColor: '#fff' },
|
||||
};
|
||||
|
||||
export const labelSx = { color: '#5f6368', '&.Mui-focused': { color: YT_RED } };
|
||||
export const helperSx = { color: '#5f6368' };
|
||||
|
||||
Reference in New Issue
Block a user