Files
ALwrity/frontend/src/components/YouTubeCreator/components/RenderStep.tsx
ajaysi 05e84d6089 fix(01-code-splitting): convert StoryWriter, YouTubeCreator MUI icons
- Converted barrel imports to individual imports across 22 files
- StoryWriter (3), YouTubeCreator (19)
2026-05-14 09:11:51 +05:30

500 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Render Step Component
*
* Main component for the render step in YouTube Creator workflow.
* Orchestrates scene overview, settings, cost estimation, and render status.
*/
import React, { useMemo, useState } from 'react';
import {
Paper,
Typography,
Stack,
Button,
Box,
Alert,
CircularProgress,
Chip,
IconButton,
Tooltip,
} from '@mui/material';
import PlayArrow from '@mui/icons-material/PlayArrow';
import CheckCircle from '@mui/icons-material/CheckCircle';
import Warning from '@mui/icons-material/Warning';
import ArrowBack from '@mui/icons-material/ArrowBack';
import Visibility from '@mui/icons-material/Visibility';
import ImageIcon from '@mui/icons-material/Image';
import VolumeUp from '@mui/icons-material/VolumeUp';
import { motion } from 'framer-motion';
import { TaskStatus, CostEstimate, VideoPlan, Scene } from '../../../services/youtubeApi';
import { YT_BORDER, type Resolution } from '../constants';
import { CombinedSceneOverview } from './CombinedSceneOverview';
import { CostEstimateCard } from './CostEstimateCard';
import { RenderSettings } from './RenderSettings';
import { RenderStatusDisplay } from './RenderStatusDisplay';
import { ScenePreviewModal } from './ScenePreviewModal';
import { useYouTubeRenderQueue } from '../hooks/useYouTubeRenderQueue';
import Snackbar from '@mui/material/Snackbar';
import MuiAlert, { AlertColor } from '@mui/material/Alert';
interface RenderStepProps {
renderTaskId: string | null;
renderStatus: TaskStatus | null;
renderProgress: number;
resolution: Resolution;
combineScenes: boolean;
enabledScenesCount: number;
costEstimate: CostEstimate | null;
loadingCostEstimate: boolean;
loading: boolean;
scenes: Scene[];
videoPlan: VideoPlan | null;
onResolutionChange: (resolution: Resolution) => void;
onCombineScenesChange: (combine: boolean) => void;
onStartRender: () => void;
onBack: () => void;
onReset: () => void;
onRetryFailedScenes: (failedScenes: any[]) => void;
onScenesUpdate: (updatedScenes: Scene[]) => void;
getVideoUrl: () => string | null;
}
export const RenderStep: React.FC<RenderStepProps> = React.memo(({
renderTaskId,
renderStatus,
renderProgress,
resolution,
combineScenes,
enabledScenesCount,
costEstimate,
loadingCostEstimate,
loading,
scenes,
videoPlan,
onResolutionChange,
onCombineScenesChange,
onStartRender,
onBack,
onReset,
onRetryFailedScenes,
getVideoUrl,
onScenesUpdate,
}) => {
const [snackbar, setSnackbar] = React.useState<{ open: boolean; message: string; severity: AlertColor }>({
open: false,
message: '',
severity: 'info',
});
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewScene, setPreviewScene] = useState<Scene | null>(null);
const showSnackbar = (message: string, severity: AlertColor = 'info') => {
setSnackbar({ open: true, message, severity });
};
const handlePreviewScene = (scene: Scene) => {
setPreviewScene(scene);
setPreviewModalOpen(true);
};
const {
sceneStatuses,
finalVideoUrl,
combining,
combiningProgress,
combiningMessage,
runSceneVideo,
combineVideos,
} = useYouTubeRenderQueue({
scenes,
videoPlan,
resolution,
onScenesUpdate,
onError: (msg) => showSnackbar(msg, 'error'),
onSuccess: (msg) => showSnackbar(msg, 'success'),
onInfo: (msg) => showSnackbar(msg, 'info'),
});
const canStartRender = enabledScenesCount > 0 && !loading;
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 }}>
4 Render Final Video
</Typography>
{!renderTaskId ? (
<Stack spacing={3}>
<Alert severity="info" icon={<CheckCircle />}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Ready to create your video!
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
All scenes have their images and audio. Configure your render settings below and start the video generation process.
</Typography>
</Alert>
{/* Combined Scene Statistics & Timeline */}
{scenes.length > 0 && (
<CombinedSceneOverview scenes={scenes} />
)}
{/* Scene-wise Video Generation */}
{scenes.length > 0 && (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'between', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e293b', fontSize: '1.1rem' }}>
🎬 Scene Video Generation
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
Generate video for each scene individually. Videos are created using your scene images and audio narration.
You can preview assets and retry failed scenes.
</Typography>
<Stack spacing={2}>
{scenes.filter(s => s.enabled !== false).map((scene) => {
const st = sceneStatuses[scene.scene_number] || { status: 'idle', progress: 0 };
const hasAssets = !!scene.imageUrl && !!scene.audioUrl;
const running = st.status === 'running';
const failed = st.status === 'failed';
const completed = st.status === 'completed';
return (
<Paper
key={scene.scene_number}
elevation={0}
sx={{
p: 3,
border: completed ? '2px solid #10b981' : failed ? '2px solid #ef4444' : '2px solid #e2e8f0',
borderRadius: 2,
bgcolor: completed ? '#f0fdf4' : failed ? '#fef2f2' : 'white',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
},
}}
>
<Stack spacing={2}>
{/* Header Row */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#1e293b', mb: 0.5 }}>
Scene {scene.scene_number}: {scene.title}
</Typography>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip
label={`${scene.duration_estimate}s`}
size="small"
sx={{
fontSize: '0.75rem',
fontWeight: 600,
bgcolor: '#eff6ff',
color: '#1e40af',
}}
/>
{/* Asset Status Chips */}
<Tooltip
title={scene.imageUrl ? "Image ready - click to preview" : "Image not generated yet"}
arrow
>
<Chip
icon={<ImageIcon sx={{ fontSize: 14 }} />}
label="Image"
size="small"
onClick={scene.imageUrl ? () => handlePreviewScene(scene) : undefined}
sx={{
fontSize: '0.75rem',
fontWeight: 500,
bgcolor: scene.imageUrl ? '#d1fae5' : '#fee2e2',
color: scene.imageUrl ? '#065f46' : '#991b1b',
cursor: scene.imageUrl ? 'pointer' : 'default',
'&:hover': scene.imageUrl ? {
bgcolor: '#a7f3d0',
} : {},
}}
/>
</Tooltip>
<Tooltip
title={scene.audioUrl ? "Audio ready - click to preview" : "Audio not generated yet"}
arrow
>
<Chip
icon={<VolumeUp sx={{ fontSize: 14 }} />}
label="Audio"
size="small"
onClick={scene.audioUrl ? () => handlePreviewScene(scene) : undefined}
sx={{
fontSize: '0.75rem',
fontWeight: 500,
bgcolor: scene.audioUrl ? '#d1fae5' : '#fee2e2',
color: scene.audioUrl ? '#065f46' : '#991b1b',
cursor: scene.audioUrl ? 'pointer' : 'default',
'&:hover': scene.audioUrl ? {
bgcolor: '#a7f3d0',
} : {},
}}
/>
</Tooltip>
{/* Status Indicator */}
{completed && (
<Chip
icon={<CheckCircle sx={{ fontSize: 14 }} />}
label="Video Ready"
size="small"
color="success"
sx={{ fontWeight: 600, fontSize: '0.75rem' }}
/>
)}
{failed && (
<Chip
label="Failed"
size="small"
color="error"
sx={{ fontWeight: 600, fontSize: '0.75rem' }}
/>
)}
</Stack>
</Box>
{/* Action Buttons */}
<Stack direction="row" spacing={1} alignItems="center">
{running && st.progress > 0 && st.progress < 100 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress
size={32}
variant="determinate"
value={Math.min(100, st.progress)}
sx={{ color: '#667eea' }}
/>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600 }}>
{Math.round(st.progress)}%
</Typography>
</Box>
)}
{hasAssets && (
<Tooltip title="Preview scene assets" arrow>
<IconButton
size="small"
onClick={() => handlePreviewScene(scene)}
sx={{
color: '#667eea',
'&:hover': {
bgcolor: '#eff6ff',
},
}}
>
<Visibility />
</IconButton>
</Tooltip>
)}
<Button
variant={completed ? "outlined" : "contained"}
color={completed ? "success" : "primary"}
onClick={() => runSceneVideo(scene)}
disabled={!hasAssets || running}
startIcon={running ? <CircularProgress size={16} sx={{ color: 'white' }} /> : undefined}
sx={{
textTransform: 'none',
fontWeight: 700,
minWidth: 120,
px: 2.5,
}}
>
{running ? 'Generating' : failed ? 'Retry Video' : completed ? 'Regenerate' : 'Generate Video'}
</Button>
</Stack>
</Box>
{/* Progress/Error Message */}
{st.status !== 'idle' && st.status !== 'completed' && (
<Box
sx={{
px: 2,
py: 1,
bgcolor: failed ? '#fef2f2' : '#f8fafc',
borderRadius: 1,
border: `1px solid ${failed ? '#fecaca' : '#e2e8f0'}`,
}}
>
<Typography
variant="body2"
sx={{
color: failed ? '#991b1b' : '#475569',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
{running
? `Generating video... This may take 1-2 minutes.`
: failed
? `${st.error || 'Generation failed. Please retry.'}`
: 'Processing...'}
</Typography>
</Box>
)}
</Stack>
</Paper>
);
})}
</Stack>
</Box>
)}
{/* Combine Scene Videos (Optional) */}
{combineScenes && scenes.filter(s => s.enabled !== false && s.videoUrl).length >= 2 && (
<Box sx={{ mb: 3, p: 2.5, bgcolor: '#f0fdf4', borderRadius: 2, border: '2px solid #10b981' }}>
<Typography variant="h6" sx={{ mb: 1, fontWeight: 600, color: '#065f46' }}>
🎞 Combine Scene Videos
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
All scene videos are ready! Combine them into one final video.
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<Button
variant="contained"
color="success"
onClick={combineVideos}
disabled={combining}
startIcon={combining ? <CircularProgress size={20} sx={{ color: 'white' }} /> : undefined}
sx={{ textTransform: 'none', fontWeight: 700 }}
>
{combining ? 'Combining Videos...' : 'Combine Into Final Video'}
</Button>
{combining && (
<Typography variant="body2" color="text.secondary">
{combiningMessage} ({combiningProgress.toFixed(0)}%)
</Typography>
)}
{finalVideoUrl && (
<Chip
label="✅ Final video ready"
color="success"
sx={{ fontWeight: 600 }}
/>
)}
</Stack>
</Box>
)}
{/* Render Settings */}
<RenderSettings
resolution={resolution}
combineScenes={combineScenes}
enabledScenesCount={enabledScenesCount}
onResolutionChange={onResolutionChange}
onCombineScenesChange={onCombineScenesChange}
/>
{/* Cost Estimate */}
<CostEstimateCard
costEstimate={costEstimate}
loadingCostEstimate={loadingCostEstimate}
scenes={scenes}
/>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={onBack}
>
Back to Assets
</Button>
<Tooltip
title={
enabledScenesCount === 0
? "Please enable at least one scene"
: loading
? "Video render in progress"
: `Generate videos for ${enabledScenesCount} scene${enabledScenesCount !== 1 ? 's' : ''}. Estimated cost includes video generation and processing.`
}
arrow
placement="top"
>
<span>
<Button
variant="contained"
color="error"
size="large"
startIcon={<PlayArrow />}
onClick={onStartRender}
disabled={loading || enabledScenesCount === 0}
sx={{
px: 4,
fontWeight: 600,
'&:disabled': {
opacity: 0.6,
},
}}
>
{loading ? (
<>
Rendering...
<CircularProgress size={16} sx={{ ml: 1 }} color="inherit" />
</>
) : (
`Start Video Render ${costEstimate?.total_cost ? `($${costEstimate.total_cost.toFixed(2)})` : ''}`
)}
</Button>
</span>
</Tooltip>
</Box>
</Stack>
) : (
<RenderStatusDisplay
renderStatus={renderStatus}
renderProgress={renderProgress}
getVideoUrl={getVideoUrl}
onReset={onReset}
onRetryFailedScenes={onRetryFailedScenes}
/>
)}
</Paper>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<MuiAlert
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
severity={snackbar.severity}
elevation={6}
variant="filled"
sx={{ width: '100%' }}
>
{snackbar.message}
</MuiAlert>
</Snackbar>
{/* Scene Preview Modal */}
{previewScene && (
<ScenePreviewModal
open={previewModalOpen}
onClose={() => {
setPreviewModalOpen(false);
setPreviewScene(null);
}}
sceneTitle={previewScene.title}
sceneNumber={previewScene.scene_number}
imageUrl={previewScene.imageUrl}
audioUrl={previewScene.audioUrl}
/>
)}
</motion.div>
);
});
RenderStep.displayName = 'RenderStep';