Added YouTube Creator scene building flow documentation

This commit is contained in:
ajaysi
2025-12-21 17:15:23 +05:30
parent 1d745c9bc8
commit 59913bffa9
51 changed files with 7478 additions and 631 deletions

View File

@@ -9,10 +9,13 @@ import {
CloudUpload as CloudUploadIcon,
Person as PersonIcon,
Delete as DeleteIcon,
Collections as CollectionsIcon,
} from "@mui/icons-material";
import { CreateProjectPayload, Knobs } from "./types";
import { PrimaryButton, SecondaryButton } from "./ui";
import { useSubscription } from "../../contexts/SubscriptionContext";
import { AssetLibraryImageModal } from "../shared/AssetLibraryImageModal";
import { ContentAsset } from "../../hooks/useContentAssets";
interface CreateModalProps {
onCreate: (payload: CreateProjectPayload) => void;
@@ -43,6 +46,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
const [makingPresentable, setMakingPresentable] = useState(false);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const [assetLibraryOpen, setAssetLibraryOpen] = useState(false);
// Determine subscription tier restrictions
const tier = subscription?.tier || 'free';
@@ -165,6 +169,14 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
setSpeakers(clamped);
};
const handleAvatarSelectFromLibrary = React.useCallback((asset: ContentAsset) => {
if (!asset?.file_url) return;
setAvatarFile(null);
setAvatarPreview(asset.file_url);
setAvatarUrl(asset.file_url);
setAssetLibraryOpen(false);
}, []);
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -948,6 +960,23 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
>
{makingPresentable ? "Transforming..." : "Make Presentable"}
</SecondaryButton>
<Button
variant="outlined"
startIcon={<CollectionsIcon />}
onClick={() => setAssetLibraryOpen(true)}
fullWidth
sx={{
mt: 1,
borderColor: "#d1d5db",
color: "#6b7280",
"&:hover": {
borderColor: "#9ca3af",
backgroundColor: "#f9fafc",
},
}}
>
Upload from Asset Library
</Button>
</Box>
</Tooltip>
)}
@@ -989,6 +1018,26 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
<Typography variant="caption" sx={{ color: "#94a3b8", textAlign: "center", px: 2, lineHeight: 1.5 }}>
Optional - We'll enhance it with AI or generate one after analysis
</Typography>
<Button
variant="outlined"
startIcon={<CollectionsIcon />}
onClick={(e) => {
e.preventDefault();
setAssetLibraryOpen(true);
}}
fullWidth
sx={{
mt: 1.5,
borderColor: "#d1d5db",
color: "#6b7280",
"&:hover": {
borderColor: "#9ca3af",
backgroundColor: "#f9fafb",
},
}}
>
Upload from Asset Library
</Button>
</Box>
)}
@@ -1075,6 +1124,16 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
</Typography>
</Alert>
{/* Asset Library Modal */}
<AssetLibraryImageModal
open={assetLibraryOpen}
onClose={() => setAssetLibraryOpen(false)}
onSelect={handleAvatarSelectFromLibrary}
title="Select Avatar from Asset Library"
sourceModule={undefined}
allowFavoritesOnly
/>
<Stack direction="row" justifyContent="flex-end" spacing={1}>
<SecondaryButton onClick={reset} startIcon={<RefreshIcon />}>
Reset

View File

@@ -392,7 +392,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
{combiningProgress && (
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 600 }}>
{combiningProgress.progress.toFixed(0)}%
</Typography>
</Typography>
)}
</Stack>
<LinearProgress

View File

@@ -5,7 +5,7 @@
* Three-phase workflow: Plan → Scenes → Render
*/
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import {
Box,
Container,
@@ -21,38 +21,60 @@ 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 { STEPS, YT_RED, YT_BG, YT_BORDER, YT_TEXT, type Resolution, type DurationType, type VideoType } 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';
import { useYouTubeCreatorState } from '../../hooks/useYouTubeCreatorState';
import { ContentAsset } from '../../hooks/useContentAssets';
const YouTubeCreator: React.FC = () => {
const navigate = useNavigate();
const [activeStep, setActiveStep] = useState(0);
const { state, updateState } = useYouTubeCreatorState();
// Extract state from hook
const {
userIdea,
durationType,
videoType,
targetAudience,
videoGoal,
brandStyle,
referenceImage,
avatarUrl,
videoPlan,
scenes,
editingSceneId,
editedScene,
renderTaskId,
renderStatus,
renderProgress,
resolution,
combineScenes,
activeStep: persistedActiveStep,
} = state;
// Local UI state (not persisted)
const [activeStep, setActiveStep] = useState(persistedActiveStep);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [makingPresentable, setMakingPresentable] = useState(false);
const [regeneratingAvatar, setRegeneratingAvatar] = useState(false);
// Step 1: Plan
const [userIdea, setUserIdea] = useState('');
const [durationType, setDurationType] = useState<DurationType>('medium');
const [referenceImage, setReferenceImage] = useState('');
const [videoPlan, setVideoPlan] = useState<VideoPlan | null>(null);
// Sync activeStep with persisted state on mount
useEffect(() => {
setActiveStep(persistedActiveStep);
}, [persistedActiveStep]);
// 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);
// Update persisted activeStep when local activeStep changes
useEffect(() => {
updateState({ activeStep });
}, [activeStep, updateState]);
// Custom hooks
const { renderStatus: polledStatus, renderProgress: polledProgress, error: pollingError } = useRenderPolling(
@@ -61,18 +83,22 @@ const YouTubeCreator: React.FC = () => {
(err) => setError(err)
);
// Update local state from polling hook
// Update local state from polling hook and persist to localStorage
React.useEffect(() => {
const updates: any = {};
if (polledStatus) {
setRenderStatus(polledStatus);
updates.renderStatus = polledStatus;
}
if (polledProgress !== undefined) {
setRenderProgress(polledProgress);
updates.renderProgress = polledProgress;
}
if (pollingError) {
setError(pollingError);
}
}, [polledStatus, polledProgress, pollingError]);
if (Object.keys(updates).length > 0) {
updateState(updates);
}
}, [polledStatus, polledProgress, pollingError, updateState]);
const { costEstimate, loadingCostEstimate } = useCostEstimate({
activeStep,
@@ -102,12 +128,28 @@ const YouTubeCreator: React.FC = () => {
const response = await youtubeApi.createPlan({
user_idea: userIdea,
duration_type: durationType,
video_type: videoType || undefined,
target_audience: targetAudience || undefined,
video_goal: videoGoal || undefined,
brand_style: brandStyle || undefined,
reference_image_description: referenceImage || undefined,
avatar_url: avatarUrl || undefined,
});
if (response.success && response.plan) {
setVideoPlan(response.plan);
setSuccess('Video plan generated successfully!');
// Update persisted state
const updates: any = { videoPlan: response.plan };
// If avatar was auto-generated, set it
if (response.plan.auto_generated_avatar_url) {
updates.avatarUrl = response.plan.auto_generated_avatar_url;
setSuccess('Video plan generated! Avatar auto-generated based on your plan.');
} else {
setSuccess('Video plan generated successfully!');
}
updateState(updates);
setTimeout(() => {
setActiveStep(1);
setSuccess(null);
@@ -120,7 +162,98 @@ const YouTubeCreator: React.FC = () => {
} finally {
setLoading(false);
}
}, [userIdea, durationType, referenceImage]);
}, [userIdea, durationType, videoType, targetAudience, videoGoal, brandStyle, referenceImage, avatarUrl]);
const handleAvatarUpload = useCallback(async (file: File) => {
setUploadingAvatar(true);
setError(null);
try {
// Note: avatarPreview is handled locally in PlanStep component
// We only persist avatarUrl (server URL)
const response = await youtubeApi.uploadAvatar(file);
updateState({ avatarUrl: response.avatar_url });
} catch (err: any) {
setError(err.message || 'Failed to upload avatar');
} finally {
setUploadingAvatar(false);
}
}, [updateState]);
const handleAvatarSelectFromLibrary = useCallback((asset: ContentAsset) => {
if (!asset?.file_url) return;
updateState({ avatarUrl: asset.file_url });
setError(null);
setSuccess('Avatar selected from Asset Library');
setTimeout(() => setSuccess(null), 2000);
}, [updateState]);
const handleRemoveAvatar = useCallback(() => {
updateState({ avatarUrl: null });
}, [updateState]);
const handleAvatarRegenerate = useCallback(async () => {
if (!videoPlan) {
setError('Please generate a plan first');
return;
}
setRegeneratingAvatar(true);
setError(null);
setSuccess(null);
try {
const response = await youtubeApi.regenerateCreatorAvatar(videoPlan);
if (response.avatar_url) {
updateState({
avatarUrl: response.avatar_url,
});
// Update the video plan with the new avatar prompt if provided
if (response.avatar_prompt && videoPlan) {
const updatedPlan = { ...videoPlan, avatar_prompt: response.avatar_prompt };
updateState({ videoPlan: updatedPlan });
}
setSuccess('Avatar regenerated successfully!');
setTimeout(() => setSuccess(null), 2000);
} else {
setError(response.message || 'Failed to regenerate avatar');
}
} catch (err: any) {
setError(err.message || 'Failed to regenerate avatar');
} finally {
setRegeneratingAvatar(false);
}
}, [videoPlan, updateState]);
const handleMakePresentable = useCallback(async () => {
if (!avatarUrl || makingPresentable) return;
setMakingPresentable(true);
setError(null);
setSuccess(null);
try {
const response = await youtubeApi.makeAvatarPresentable(
avatarUrl,
undefined, // projectId
videoType || undefined,
targetAudience || undefined,
videoGoal || undefined,
brandStyle || undefined
);
// Update avatarUrl - PlanStep will handle loading blob URL for preview
updateState({ avatarUrl: response.avatar_url });
setSuccess('✨ Avatar transformed successfully! Your photo has been optimized for YouTube.');
// Clear success message after 5 seconds
setTimeout(() => {
setSuccess(null);
}, 5000);
} catch (err: any) {
setError(err.message || 'Failed to optimize avatar');
} finally {
setMakingPresentable(false);
}
}, [avatarUrl, makingPresentable, videoType, targetAudience, videoGoal, brandStyle, updateState]);
const handleBuildScenes = useCallback(async () => {
if (!videoPlan) {
@@ -128,6 +261,14 @@ const YouTubeCreator: React.FC = () => {
return;
}
// Guard: Prevent duplicate calls if scenes already exist
// This prevents wasting AI calls during testing/development
if (scenes.length > 0) {
console.warn('[YouTubeCreator] Scenes already exist, skipping build to prevent duplicate AI calls');
setError('Scenes have already been generated. Please refresh the page if you want to regenerate.');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
@@ -136,12 +277,47 @@ const YouTubeCreator: React.FC = () => {
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!`);
const updatedScenes = response.scenes.map(s => ({ ...s, enabled: s.enabled !== false }));
// Calculate enhanced statistics for success message
const enabledScenes = updatedScenes.filter(s => s.enabled !== false);
const totalDuration = enabledScenes.reduce((sum, scene) => sum + scene.duration_estimate, 0);
// Group scenes by emphasis type
const sceneBreakdown = updatedScenes.reduce((acc, scene) => {
const type = scene.emphasis_tags?.[0] || 'main_content';
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Format duration
const formatDuration = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
// Create enhanced success message
const breakdownText = Object.entries(sceneBreakdown)
.map(([type, count]) => {
const typeLabel = type === 'hook' ? 'hook' : type === 'cta' ? 'CTA' : type === 'main_content' ? 'main content' : type;
return `${count} ${typeLabel}`;
})
.join(' • ');
const successMessage = `✅ Successfully built ${response.scenes.length} scenes\n⏱ Total duration: ${formatDuration(totalDuration)}\n📊 Breakdown: ${breakdownText}`;
updateState({ scenes: updatedScenes });
setSuccess(successMessage);
// Navigate immediately to Render step so user can see scenes and cost estimates
setActiveStep(2);
// Clear success message after a brief moment
setTimeout(() => {
setActiveStep(2);
setSuccess(null);
}, 1000);
}, 3000);
} else {
setError(response.message || 'Failed to build scenes');
}
@@ -150,17 +326,19 @@ const YouTubeCreator: React.FC = () => {
} finally {
setLoading(false);
}
}, [videoPlan]);
}, [videoPlan, scenes.length, updateState]);
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,
updateState({
editingSceneId: scene.scene_number,
editedScene: {
narration: scene.narration,
visual_prompt: scene.visual_prompt,
duration_estimate: scene.duration_estimate,
enabled: scene.enabled !== false,
},
});
}, []);
}, [updateState]);
const handleSaveScene = useCallback(async () => {
if (!editingSceneId || !editedScene) return;
@@ -177,11 +355,14 @@ const YouTubeCreator: React.FC = () => {
});
if (response.success && response.scene) {
setScenes(scenes.map(s =>
const updatedScenes = scenes.map(s =>
s.scene_number === editingSceneId ? { ...s, ...response.scene } : s
));
setEditingSceneId(null);
setEditedScene(null);
);
updateState({
scenes: updatedScenes,
editingSceneId: null,
editedScene: null,
});
setSuccess('Scene updated successfully!');
} else {
setError(response.message || 'Failed to update scene');
@@ -191,18 +372,24 @@ const YouTubeCreator: React.FC = () => {
} finally {
setLoading(false);
}
}, [editingSceneId, editedScene, scenes]);
}, [editingSceneId, editedScene, scenes, updateState]);
const handleCancelEdit = useCallback(() => {
setEditingSceneId(null);
setEditedScene(null);
}, []);
updateState({ editingSceneId: null, editedScene: null });
}, [updateState]);
const handleEditChange = useCallback((updates: Partial<Scene>) => {
if (editedScene) {
updateState({ editedScene: { ...editedScene, ...updates } });
}
}, [editedScene, updateState]);
const handleToggleScene = useCallback((sceneNumber: number) => {
setScenes(scenes.map(s =>
const updatedScenes = scenes.map(s =>
s.scene_number === sceneNumber ? { ...s, enabled: !s.enabled } : s
));
}, [scenes]);
);
updateState({ scenes: updatedScenes });
}, [scenes, updateState]);
const handleStartRender = useCallback(async () => {
if (scenes.length === 0) {
@@ -234,8 +421,11 @@ const YouTubeCreator: React.FC = () => {
});
if (response.success && response.task_id) {
setRenderTaskId(response.task_id);
setRenderProgress(0);
updateState({
renderTaskId: response.task_id,
renderProgress: 0,
renderStatus: null,
});
setSuccess('Video rendering started!');
} else {
setError(response.message || 'Failed to start render');
@@ -245,7 +435,7 @@ const YouTubeCreator: React.FC = () => {
} finally {
setLoading(false);
}
}, [scenes, videoPlan, resolution, combineScenes]);
}, [scenes, videoPlan, resolution, combineScenes, updateState]);
const getVideoUrl = useCallback(() => {
if (renderStatus?.result?.final_video_url) {
@@ -295,11 +485,13 @@ const YouTubeCreator: React.FC = () => {
}, [activeStep, videoPlan, scenes.length, enabledScenesCount]);
const handleResetRender = useCallback(() => {
setRenderTaskId(null);
setRenderStatus(null);
setRenderProgress(0);
updateState({
renderTaskId: null,
renderStatus: null,
renderProgress: 0,
});
setError(null);
}, []);
}, [updateState]);
const handleRetryFailedScenes = useCallback((failedScenes: any[]) => {
if (failedScenes.length > 0) {
@@ -309,10 +501,10 @@ const YouTubeCreator: React.FC = () => {
? { ...s, enabled: true }
: s
);
setScenes(updatedScenes);
updateState({ scenes: updatedScenes });
handleResetRender();
}
}, [scenes, handleResetRender]);
}, [scenes, handleResetRender, updateState]);
return (
<Container
@@ -399,12 +591,28 @@ const YouTubeCreator: React.FC = () => {
<PlanStep
userIdea={userIdea}
durationType={durationType}
videoType={videoType || undefined}
targetAudience={targetAudience}
videoGoal={videoGoal}
brandStyle={brandStyle}
referenceImage={referenceImage}
loading={loading}
onIdeaChange={setUserIdea}
onDurationChange={setDurationType}
onReferenceImageChange={setReferenceImage}
avatarPreview={avatarUrl}
avatarUrl={avatarUrl}
uploadingAvatar={uploadingAvatar}
makingPresentable={makingPresentable}
onIdeaChange={(value) => updateState({ userIdea: value })}
onDurationChange={(value) => updateState({ durationType: value })}
onVideoTypeChange={(value) => updateState({ videoType: value })}
onTargetAudienceChange={(value) => updateState({ targetAudience: value })}
onVideoGoalChange={(value) => updateState({ videoGoal: value })}
onBrandStyleChange={(value) => updateState({ brandStyle: value })}
onReferenceImageChange={(value) => updateState({ referenceImage: value })}
onGeneratePlan={handleGeneratePlan}
onAvatarUpload={handleAvatarUpload}
onRemoveAvatar={handleRemoveAvatar}
onMakePresentable={handleMakePresentable}
onAvatarSelectFromLibrary={handleAvatarSelectFromLibrary}
/>
)}
@@ -419,10 +627,12 @@ const YouTubeCreator: React.FC = () => {
onEditScene={handleEditScene}
onSaveScene={handleSaveScene}
onCancelEdit={handleCancelEdit}
onEditChange={setEditedScene}
onEditChange={(value) => updateState({ editedScene: value })}
onToggleScene={handleToggleScene}
onBack={() => setActiveStep(0)}
onNext={() => setActiveStep(2)}
onAvatarRegenerate={handleAvatarRegenerate}
regeneratingAvatar={regeneratingAvatar}
/>
)}
@@ -437,12 +647,21 @@ const YouTubeCreator: React.FC = () => {
costEstimate={costEstimate}
loadingCostEstimate={loadingCostEstimate}
loading={loading}
onResolutionChange={setResolution}
onCombineScenesChange={setCombineScenes}
scenes={scenes}
videoPlan={videoPlan}
editingSceneId={editingSceneId}
editedScene={editedScene}
onResolutionChange={(value) => updateState({ resolution: value })}
onCombineScenesChange={(value) => updateState({ combineScenes: value })}
onStartRender={handleStartRender}
onBack={() => setActiveStep(1)}
onReset={handleResetRender}
onRetryFailedScenes={handleRetryFailedScenes}
onEditScene={handleEditScene}
onSaveScene={handleSaveScene}
onCancelEdit={handleCancelEdit}
onEditChange={handleEditChange}
onToggleScene={handleToggleScene}
getVideoUrl={getVideoUrl}
/>
)}

View File

@@ -0,0 +1,272 @@
/**
* Avatar Card Component with Enlarge Modal
*/
import React, { useState } from 'react';
import { Box, Typography, Dialog, DialogContent, IconButton, Paper, Stack } from '@mui/material';
import { Close, ZoomIn, Refresh, AutoAwesome } from '@mui/icons-material';
import { PlanDetailsCard } from './PlanDetailsCard';
import { OperationButton } from '../../shared/OperationButton';
interface AvatarCardProps {
avatarUrl: string | null | undefined;
avatarBlobUrl: string | null;
avatarLoading: boolean;
avatarReused?: boolean;
avatarPrompt?: string;
onImageError?: () => void;
onRegenerate?: () => void;
regenerating?: boolean;
}
export const AvatarCard: React.FC<AvatarCardProps> = React.memo(({
avatarUrl,
avatarBlobUrl,
avatarLoading,
avatarReused = false,
avatarPrompt,
onImageError,
onRegenerate,
regenerating = false,
}) => {
const [modalOpen, setModalOpen] = useState(false);
if (!avatarUrl) {
return null;
}
const imageSrc = avatarBlobUrl || (avatarUrl.startsWith('data:') ? avatarUrl : undefined);
const canDisplayImage = avatarBlobUrl || avatarUrl.startsWith('data:');
return (
<>
<PlanDetailsCard title="Creator Avatar" fullHeight={false}>
<Box
sx={{
position: 'relative',
width: '100%',
maxWidth: 200,
aspectRatio: '1',
borderRadius: 2,
border: '2px solid #e5e7eb',
overflow: 'hidden',
bgcolor: '#f9fafb',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
'&:hover': {
borderColor: '#d1d5db',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
'& .zoom-icon': {
opacity: 1,
},
},
}}
onClick={() => setModalOpen(true)}
>
{avatarLoading ? (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#f9fafb',
}}
>
<Typography variant="body2" sx={{ color: '#6b7280', fontWeight: 500 }}>
Loading...
</Typography>
</Box>
) : (
<>
{canDisplayImage && (
<Box
component="img"
src={imageSrc}
alt="Generated creator avatar"
onError={onImageError}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
)}
<Box
className="zoom-icon"
sx={{
position: 'absolute',
top: 8,
right: 8,
bgcolor: 'rgba(0, 0, 0, 0.6)',
borderRadius: '50%',
p: 0.75,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ZoomIn sx={{ color: '#ffffff', fontSize: '1.25rem' }} />
</Box>
</>
)}
</Box>
{/* Regenerate Button and Avatar Prompt */}
<Stack spacing={1.5} sx={{ mt: 2 }}>
{onRegenerate && (
<Box>
<OperationButton
operation={{
provider: 'image_generation',
operation_type: 'image_generation',
tokens_requested: 0,
actual_provider_name: 'wavespeed',
}}
label="Regenerate Avatar"
variant="outlined"
size="small"
startIcon={<Refresh />}
onClick={onRegenerate}
disabled={regenerating}
loading={regenerating}
checkOnHover={true}
checkOnMount={false}
showCost={true}
fullWidth
/>
</Box>
)}
{/* AI Prompt Used for Avatar Generation */}
{avatarPrompt && (
<Box>
<Typography
variant="caption"
sx={{
color: "#64748b",
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 0.5,
mb: 0.75,
}}
>
<AutoAwesome sx={{ fontSize: 14 }} />
AI Generation Prompt
</Typography>
<Paper
sx={{
p: 1.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 1.5,
maxHeight: 150,
overflow: "auto",
}}
>
<Typography
variant="caption"
sx={{
color: "#475569",
fontFamily: "monospace",
fontSize: "0.75rem",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
display: "block",
}}
>
{avatarPrompt}
</Typography>
</Paper>
</Box>
)}
</Stack>
{avatarReused && (
<Typography
variant="caption"
sx={{
color: '#059669',
mt: 1,
display: 'block',
fontWeight: 500,
fontSize: '0.75rem',
}}
>
Reused from previous generation
</Typography>
)}
</PlanDetailsCard>
{/* Enlarge Modal */}
<Dialog
open={modalOpen}
onClose={() => setModalOpen(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 2,
bgcolor: '#000000',
},
}}
>
<DialogContent sx={{ p: 0, position: 'relative', bgcolor: '#000000' }}>
<IconButton
onClick={() => setModalOpen(false)}
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 1,
bgcolor: 'rgba(0, 0, 0, 0.6)',
color: '#ffffff',
'&:hover': {
bgcolor: 'rgba(0, 0, 0, 0.8)',
},
}}
>
<Close />
</IconButton>
{canDisplayImage ? (
<Box
component="img"
src={imageSrc}
alt="Generated creator avatar (full size)"
sx={{
width: '100%',
height: 'auto',
display: 'block',
maxHeight: '90vh',
objectFit: 'contain',
}}
/>
) : (
<Box
sx={{
width: '100%',
minHeight: 400,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
}}
>
<Typography>Loading image...</Typography>
</Box>
)}
</DialogContent>
</Dialog>
</>
);
});
AvatarCard.displayName = 'AvatarCard';

View File

@@ -0,0 +1,444 @@
/**
* Combined Scene Overview Component
*
* Displays scene statistics and timeline in a compact, combined view.
*/
import React, { useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Stack,
Box,
Grid,
Chip,
Divider,
Tooltip,
IconButton,
Alert,
} from '@mui/material';
import { HelpOutline, Timeline, BarChart, AccessTime, Movie, Info } from '@mui/icons-material';
import { Scene } from '../../../services/youtubeApi';
import { getSceneIcon, getSceneColor, getSceneTypeLabel, formatDuration } from '../utils/sceneHelpers';
interface CombinedSceneOverviewProps {
scenes: Scene[];
}
export const CombinedSceneOverview: React.FC<CombinedSceneOverviewProps> = React.memo(({ scenes }) => {
const stats = useMemo(() => {
const enabledScenes = scenes.filter(s => s.enabled !== false);
const totalDuration = enabledScenes.reduce((sum, scene) => sum + scene.duration_estimate, 0);
const averageDuration = enabledScenes.length > 0
? Math.round((totalDuration / enabledScenes.length) * 10) / 10
: 0;
const sceneBreakdown = enabledScenes.reduce((acc, scene) => {
const type = scene.emphasis_tags?.[0] || 'main_content';
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return {
totalScenes: scenes.length,
enabledScenes: enabledScenes.length,
totalDuration,
averageDuration,
sceneBreakdown,
enabledScenesList: enabledScenes,
};
}, [scenes]);
return (
<Card
elevation={0}
sx={{
border: '2px solid #e5e7eb',
borderRadius: 2,
bgcolor: '#ffffff',
mb: 3,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
}}
>
<CardContent sx={{ p: 2.5 }}>
{/* Header with Help Icon */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Timeline sx={{ color: '#6366f1', fontSize: 20 }} />
<Typography
variant="h6"
sx={{
fontWeight: 700,
fontSize: '1rem',
color: '#111827',
letterSpacing: '-0.01em',
}}
>
Scene Overview
</Typography>
</Box>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Scene Overview Explained
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
<strong>Statistics:</strong> Shows total scenes, duration, and breakdown by type (Hook, Content, CTA).
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
<strong>Sequence:</strong> Visual timeline showing scene order and flow. Hover over scenes for details.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Tip:</strong> Disable scenes you don't want to render to reduce cost and processing time.
</Typography>
</Box>
}
arrow
placement="left"
>
<IconButton size="small" sx={{ color: '#6b7280' }}>
<HelpOutline fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Grid container spacing={2}>
{/* Left Column: Statistics */}
<Grid item xs={12} md={6}>
<Box
sx={{
p: 2,
bgcolor: '#f9fafb',
borderRadius: 1.5,
border: '1px solid #e5e7eb',
height: '100%',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<BarChart sx={{ color: '#6366f1', fontSize: 18 }} />
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
fontSize: '0.875rem',
color: '#111827',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Statistics
</Typography>
</Box>
<Stack spacing={1.5}>
{/* Main Stats Row */}
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Tooltip
title="Total number of scenes generated. You can enable/disable individual scenes below."
arrow
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Movie sx={{ color: '#6b7280', fontSize: 16 }} />
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: '#111827',
fontSize: '0.875rem',
}}
>
<strong>{stats.enabledScenes}</strong>/{stats.totalScenes} scenes
</Typography>
</Box>
</Tooltip>
<Tooltip
title="Total video duration when all enabled scenes are rendered. This affects rendering cost."
arrow
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<AccessTime sx={{ color: '#6b7280', fontSize: 16 }} />
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: '#111827',
fontSize: '0.875rem',
}}
>
<strong>{formatDuration(stats.totalDuration)}</strong> total
</Typography>
</Box>
</Tooltip>
<Tooltip
title="Average duration per scene. Helps estimate pacing and engagement."
arrow
>
<Typography
variant="body2"
sx={{
color: '#6b7280',
fontSize: '0.875rem',
}}
>
Avg: <strong>{stats.averageDuration}s</strong>
</Typography>
</Tooltip>
</Box>
<Divider sx={{ my: 0.5 }} />
{/* Scene Type Breakdown */}
<Box>
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: '#6b7280',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
display: 'block',
mb: 1,
}}
>
Breakdown by Type
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{Object.entries(stats.sceneBreakdown).map(([type, count]) => (
<Tooltip
key={type}
title={
type === 'hook'
? 'Hook scenes grab attention in the first few seconds'
: type === 'cta'
? 'Call-to-action scenes encourage viewer engagement'
: type === 'transition'
? 'Transition scenes connect different topics smoothly'
: 'Main content scenes deliver the core message'
}
arrow
>
<Chip
label={`${getSceneTypeLabel(type)}: ${count}`}
size="small"
sx={{
fontWeight: 500,
fontSize: '0.75rem',
bgcolor: type === 'hook' ? '#eff6ff' : type === 'cta' ? '#f5f3ff' : '#f9fafb',
color: type === 'hook' ? '#1e40af' : type === 'cta' ? '#6b21a8' : '#374151',
border: `1px solid ${getSceneColor(type)}`,
'& .MuiChip-label': {
px: 1,
},
}}
/>
</Tooltip>
))}
</Stack>
</Box>
</Stack>
</Box>
</Grid>
{/* Right Column: Timeline */}
<Grid item xs={12} md={6}>
<Box
sx={{
p: 2,
bgcolor: '#f9fafb',
borderRadius: 1.5,
border: '1px solid #e5e7eb',
height: '100%',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<AccessTime sx={{ color: '#6366f1', fontSize: 18 }} />
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
fontSize: '0.875rem',
color: '#111827',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Sequence
</Typography>
</Box>
{/* Compact Timeline */}
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
{stats.enabledScenesList.map((scene, index) => (
<React.Fragment key={scene.scene_number}>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Scene {scene.scene_number}: {scene.title}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
{scene.narration?.substring(0, 80)}...
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
Duration: {scene.duration_estimate}s • Type: {getSceneTypeLabel(scene.emphasis_tags?.[0] || 'main_content')}
</Typography>
</Box>
}
arrow
placement="top"
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: 60,
p: 0.75,
borderRadius: 1,
border: `2px solid ${getSceneColor(scene.emphasis_tags?.[0] || 'main_content')}`,
bgcolor: 'white',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s ease-in-out',
cursor: 'pointer',
'&:hover': {
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-1px)',
},
}}
>
{getSceneIcon(scene.emphasis_tags?.[0] || 'main_content')}
<Typography
variant="caption"
sx={{
fontWeight: 700,
fontSize: '0.7rem',
mt: 0.25,
color: '#111827',
}}
>
{scene.scene_number}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: '#6b7280',
}}
>
{scene.duration_estimate}s
</Typography>
</Box>
</Tooltip>
{index < stats.enabledScenesList.length - 1 && (
<Box
sx={{
width: 16,
height: 1,
bgcolor: '#d1d5db',
position: 'relative',
mx: 0.25,
'&::after': {
content: '""',
position: 'absolute',
right: -3,
top: -1.5,
width: 0,
height: 0,
borderLeft: '3px solid #d1d5db',
borderTop: '2px solid transparent',
borderBottom: '2px solid transparent',
},
}}
/>
)}
</React.Fragment>
))}
</Box>
</Box>
{/* Legend */}
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mt: 1.5, pt: 1.5, borderTop: '1px solid #e5e7eb' }}>
<Typography
variant="caption"
sx={{
color: '#6b7280',
fontSize: '0.75rem',
fontWeight: 500,
mr: 0.5,
}}
>
Legend:
</Typography>
{['hook', 'main_content', 'cta'].map((type) => (
<Tooltip
key={type}
title={
type === 'hook'
? 'Hook scenes capture attention immediately'
: type === 'cta'
? 'CTA scenes drive viewer action'
: 'Main content delivers your message'
}
arrow
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: getSceneColor(type),
}}
/>
<Typography
variant="caption"
sx={{
color: '#6b7280',
fontSize: '0.75rem',
}}
>
{getSceneTypeLabel(type)}
</Typography>
</Box>
</Tooltip>
))}
</Box>
</Box>
</Grid>
</Grid>
{/* Info Alert */}
<Alert
severity="info"
icon={<Info fontSize="small" />}
sx={{
mt: 2,
bgcolor: '#eff6ff',
border: '1px solid #bfdbfe',
'& .MuiAlert-icon': {
color: '#3b82f6',
},
'& .MuiAlert-message': {
color: '#1e40af',
},
}}
>
<Typography variant="caption" sx={{ fontSize: '0.75rem', lineHeight: 1.5 }}>
<strong>Tip:</strong> Review scene details below to edit narration, visual prompts, or disable scenes you don't need.
This helps optimize cost and video quality.
</Typography>
</Alert>
</CardContent>
</Card>
);
});
CombinedSceneOverview.displayName = 'CombinedSceneOverview';

View File

@@ -0,0 +1,82 @@
/**
* Content Outline Card Component
*/
import React from 'react';
import { Stack, Box, Typography } from '@mui/material';
import { PlanDetailsCard } from './PlanDetailsCard';
import { VideoPlan } from '../../../services/youtubeApi';
interface ContentOutlineCardProps {
contentOutline: VideoPlan['content_outline'];
}
type ContentOutlineItem = VideoPlan['content_outline'][number];
export const ContentOutlineCard: React.FC<ContentOutlineCardProps> = React.memo(({
contentOutline,
}) => {
if (!contentOutline || contentOutline.length === 0) {
return null;
}
return (
<PlanDetailsCard title="Content Outline">
<Stack spacing={1.5}>
{contentOutline.map((item: ContentOutlineItem, idx: number) => (
<Box
key={idx}
sx={{
pl: 2.5,
borderLeft: '3px solid #e5e7eb',
py: 1,
transition: 'all 0.2s ease-in-out',
'&:hover': {
borderLeftColor: '#d1d5db',
bgcolor: '#f9fafb',
borderRadius: '0 4px 4px 0',
},
}}
>
<Typography
variant="body1"
sx={{
color: '#1a1a1a',
fontWeight: 600,
fontSize: '0.9375rem',
mb: 0.5,
}}
>
{item.section || `Section ${idx + 1}`}
<Box
component="span"
sx={{
ml: 1.5,
color: '#6b7280',
fontWeight: 500,
fontSize: '0.8125rem',
}}
>
({item.duration_estimate || 0}s)
</Box>
</Typography>
<Typography
variant="body2"
sx={{
color: '#4b5563',
lineHeight: 1.6,
fontSize: '0.875rem',
fontWeight: 400,
}}
>
{item.description || 'Description missing'}
</Typography>
</Box>
))}
</Stack>
</PlanDetailsCard>
);
});
ContentOutlineCard.displayName = 'ContentOutlineCard';

View File

@@ -0,0 +1,216 @@
/**
* Cost Estimate Card Component
*
* Displays professional cost estimate with breakdown and per-scene costs.
*/
import React from 'react';
import {
Box,
Typography,
Stack,
CircularProgress,
Alert,
} from '@mui/material';
import { CostEstimate } from '../../../services/youtubeApi';
interface CostEstimateCardProps {
costEstimate: CostEstimate | null;
loadingCostEstimate: boolean;
}
export const CostEstimateCard: React.FC<CostEstimateCardProps> = React.memo(({
costEstimate,
loadingCostEstimate,
}) => {
if (loadingCostEstimate) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Calculating cost estimate...
</Typography>
</Box>
);
}
if (!costEstimate) {
return (
<Alert severity="warning" sx={{ mt: 2 }}>
Unable to calculate cost estimate. Please check your scenes and try again.
</Alert>
);
}
return (
<Box
sx={{
mt: 3,
p: 3,
bgcolor: '#ffffff',
borderRadius: 2,
border: '2px solid #e5e7eb',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography
variant="h6"
sx={{
fontWeight: 700,
fontSize: '1rem',
color: '#111827',
letterSpacing: '-0.01em',
}}
>
Estimated Cost
</Typography>
</Box>
<Box sx={{ mb: 2.5 }}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
fontSize: '2rem',
color: '#111827',
lineHeight: 1.2,
mb: 0.5,
}}
>
${costEstimate.total_cost.toFixed(2)}
</Typography>
<Typography
variant="body2"
sx={{
color: '#6b7280',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Range: ${costEstimate.estimated_cost_range.min.toFixed(2)} - ${costEstimate.estimated_cost_range.max.toFixed(2)}
</Typography>
</Box>
<Box
sx={{
p: 2,
bgcolor: '#f9fafb',
borderRadius: 1.5,
border: '1px solid #e5e7eb',
mb: 2,
}}
>
<Typography
variant="body2"
sx={{
color: '#374151',
fontSize: '0.875rem',
lineHeight: 1.6,
mb: 0.5,
}}
>
<strong>{costEstimate.num_scenes} scenes</strong> × <strong>${costEstimate.price_per_second.toFixed(2)}/second</strong>
</Typography>
<Typography
variant="body2"
sx={{
color: '#374151',
fontSize: '0.875rem',
lineHeight: 1.6,
mb: 0.5,
}}
>
Total duration: <strong>~{Math.round(costEstimate.total_duration_seconds)} seconds</strong>
</Typography>
<Typography
variant="body2"
sx={{
color: '#374151',
fontSize: '0.875rem',
lineHeight: 1.6,
}}
>
Price per second: <strong>${costEstimate.price_per_second.toFixed(2)}</strong> ({costEstimate.resolution})
</Typography>
</Box>
{costEstimate.scene_costs.length > 0 && (
<Box
sx={{
pt: 2,
borderTop: '2px solid #e5e7eb',
}}
>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
fontSize: '0.875rem',
color: '#111827',
mb: 1.5,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Per Scene Breakdown
</Typography>
<Stack spacing={0.75}>
{costEstimate.scene_costs.slice(0, 5).map((sceneCost) => (
<Box
key={sceneCost.scene_number}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 0.75,
px: 1.5,
bgcolor: '#ffffff',
borderRadius: 1,
border: '1px solid #e5e7eb',
}}
>
<Typography
variant="body2"
sx={{
color: '#374151',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Scene {sceneCost.scene_number}: {sceneCost.actual_duration}s
</Typography>
<Typography
variant="body2"
sx={{
color: '#111827',
fontSize: '0.875rem',
fontWeight: 600,
}}
>
${sceneCost.cost.toFixed(2)}
</Typography>
</Box>
))}
{costEstimate.scene_costs.length > 5 && (
<Typography
variant="body2"
sx={{
color: '#6b7280',
fontSize: '0.875rem',
textAlign: 'center',
py: 0.5,
}}
>
... and {costEstimate.scene_costs.length - 5} more scenes
</Typography>
)}
</Stack>
</Box>
)}
</Box>
);
});
CostEstimateCard.displayName = 'CostEstimateCard';

View File

@@ -1,138 +1,191 @@
/**
* Plan Details Component
*
* Displays comprehensive video plan information in a professional card-based layout.
* Includes avatar display with enlarge modal, summary, and all plan details.
*/
import React from 'react';
import { Paper, Typography, Stack, Box, Grid, Chip } from '@mui/material';
import { Paper, Typography, Stack, Box, Grid } from '@mui/material';
import { VideoPlan } from '../../../services/youtubeApi';
import { YT_BORDER, YT_TEXT } from '../constants';
import { YT_BORDER } from '../constants';
import { useAvatarBlobUrl } from '../hooks/useAvatarBlobUrl';
import { PlanDetailsCard } from './PlanDetailsCard';
import { AvatarCard } from './AvatarCard';
import { ContentOutlineCard } from './ContentOutlineCard';
import { SEOKeywordsCard } from './SEOKeywordsCard';
interface PlanDetailsProps {
plan: VideoPlan;
onAvatarRegenerate?: () => void;
regeneratingAvatar?: boolean;
}
export const PlanDetails: React.FC<PlanDetailsProps> = React.memo(({ plan }) => {
// Typography styles constants
const SECTION_TITLE_STYLES = {
fontWeight: 700,
color: '#1a1a1a',
mb: 1.5,
fontSize: '0.875rem',
textTransform: 'uppercase' as const,
letterSpacing: '0.05em',
};
const CONTENT_TEXT_STYLES = {
color: '#374151',
lineHeight: 1.6,
fontSize: '0.9375rem',
fontWeight: 400,
};
const SUMMARY_TEXT_STYLES = {
...CONTENT_TEXT_STYLES,
lineHeight: 1.7,
};
/**
* PlanDetails Component
*
* Displays video plan information in a professional, card-based layout.
* Features:
* - Avatar display with enlarge modal
* - Summary and plan details in organized cards
* - SEO keywords and content outline
*/
export const PlanDetails: React.FC<PlanDetailsProps> = React.memo(({ plan, onAvatarRegenerate, regeneratingAvatar = false }) => {
const avatarUrl = plan.auto_generated_avatar_url;
const { avatarBlobUrl, avatarLoading } = useAvatarBlobUrl(avatarUrl);
const handleAvatarError = React.useCallback(() => {
console.warn('[PlanDetails] Avatar image failed to load');
}, []);
return (
<Paper
elevation={0}
sx={{
mb: 3,
p: 2.5,
p: 3,
border: `1px solid ${YT_BORDER}`,
backgroundColor: '#fff',
borderRadius: 2,
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: YT_TEXT }}>
<Typography
variant="h6"
sx={{
fontWeight: 700,
mb: 3,
color: '#1a1a1a',
fontSize: '1.125rem',
letterSpacing: '-0.01em',
}}
>
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>
<Stack spacing={3}>
{/* Avatar and Summary Section - Side by Side */}
{(avatarUrl || plan.video_summary) && (
<Grid container spacing={3}>
{avatarUrl && (
<Grid item xs={12} sm={4} md={3}>
<AvatarCard
avatarUrl={avatarUrl}
avatarBlobUrl={avatarBlobUrl}
avatarLoading={avatarLoading}
avatarReused={plan.avatar_reused}
avatarPrompt={plan.avatar_prompt}
onImageError={handleAvatarError}
onRegenerate={onAvatarRegenerate}
regenerating={regeneratingAvatar}
/>
</Grid>
)}
{plan.video_summary && (
<Grid item xs={12} sm={avatarUrl ? 8 : 12} md={avatarUrl ? 9 : 12}>
<PlanDetailsCard title="Summary">
<Typography variant="body1" sx={SUMMARY_TEXT_STYLES}>
{plan.video_summary}
</Typography>
</PlanDetailsCard>
</Grid>
)}
</Grid>
)}
<Grid container spacing={2}>
{/* Target Audience and Goal Cards */}
<Grid container spacing={3}>
{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>
<PlanDetailsCard title="Target Audience" fullHeight>
<Typography variant="body1" sx={CONTENT_TEXT_STYLES}>
{plan.target_audience}
</Typography>
</PlanDetailsCard>
</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>
<PlanDetailsCard title="Goal" fullHeight>
<Typography variant="body1" sx={CONTENT_TEXT_STYLES}>
{plan.video_goal}
</Typography>
</PlanDetailsCard>
</Grid>
)}
</Grid>
<Grid container spacing={2}>
{/* Key Message and Call to Action Cards */}
<Grid container spacing={3}>
{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>
<PlanDetailsCard title="Key Message" fullHeight>
<Typography variant="body1" sx={CONTENT_TEXT_STYLES}>
{plan.key_message}
</Typography>
</PlanDetailsCard>
</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>
<PlanDetailsCard title="Call to Action" fullHeight>
<Typography variant="body1" sx={CONTENT_TEXT_STYLES}>
{plan.call_to_action}
</Typography>
</PlanDetailsCard>
</Grid>
)}
</Grid>
<Grid container spacing={2}>
{/* Hook Strategy and Style & Tone Cards */}
<Grid container spacing={3}>
{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>
<PlanDetailsCard title="Hook Strategy" fullHeight>
<Typography variant="body1" sx={CONTENT_TEXT_STYLES}>
{plan.hook_strategy}
</Typography>
</PlanDetailsCard>
</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>
<PlanDetailsCard title="Style & Tone" fullHeight>
<Typography variant="body1" sx={CONTENT_TEXT_STYLES}>
Visual Style: {plan.visual_style || '—'} | Tone: {plan.tone || '—'}
</Typography>
</PlanDetailsCard>
</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>
)}
{/* SEO Keywords Card */}
<SEOKeywordsCard seoKeywords={plan.seo_keywords} />
{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>
)}
{/* Content Outline Card */}
<ContentOutlineCard contentOutline={plan.content_outline} />
</Stack>
</Paper>
);
});
PlanDetails.displayName = 'PlanDetails';

View File

@@ -0,0 +1,54 @@
/**
* Reusable Plan Details Card Component
*/
import React from 'react';
import { Card, CardContent, Typography } from '@mui/material';
interface PlanDetailsCardProps {
title: string;
children: React.ReactNode;
fullHeight?: boolean;
}
export const PlanDetailsCard: React.FC<PlanDetailsCardProps> = React.memo(({
title,
children,
fullHeight = false,
}) => {
return (
<Card
elevation={0}
sx={{
border: '1px solid #e5e7eb',
borderRadius: 2,
bgcolor: '#ffffff',
height: fullHeight ? '100%' : 'auto',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
>
<CardContent sx={{ p: 2.5 }}>
<Typography
variant="body2"
sx={{
fontWeight: 700,
color: '#1a1a1a',
mb: 1.5,
fontSize: '0.875rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{title}
</Typography>
{children}
</CardContent>
</Card>
);
});
PlanDetailsCard.displayName = 'PlanDetailsCard';

View File

@@ -2,122 +2,704 @@
* Plan Step Component
*/
import React from 'react';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import {
Paper,
Typography,
TextField,
Button,
Stack,
FormControl,
InputLabel,
Select,
MenuItem,
FormHelperText,
CircularProgress,
Box,
Tooltip,
IconButton,
Grid,
Button,
} from '@mui/material';
import { PlayArrow } from '@mui/icons-material';
import { PlayArrow, CloudUpload, AutoAwesome, Delete, InfoOutlined, Collections } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { inputSx, labelSx, helperSx, selectSx } from '../styles';
import { DurationType } from '../constants';
import {
inputSx,
labelSx,
helperSx,
selectSx,
selectMenuProps,
paperSx,
sectionTitleSx,
tooltipSx,
} from '../styles';
import {
DurationType,
VideoType,
VIDEO_TYPES,
VIDEO_TYPE_CONFIGS,
TARGET_AUDIENCE_OPTIONS,
VIDEO_GOAL_OPTIONS,
BRAND_STYLE_OPTIONS,
} from '../constants';
import { OperationButton } from '../../shared/OperationButton';
import { AssetLibraryImageModal } from '../../shared/AssetLibraryImageModal';
import { ContentAsset } from '../../../hooks/useContentAssets';
import { buildVideoPlanningOperation, buildImageEditingOperation } from '../utils/operationHelpers';
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
import { SelectWithCustom } from './SelectWithCustom';
interface PlanStepProps {
userIdea: string;
durationType: DurationType;
videoType?: VideoType;
targetAudience?: string;
videoGoal?: string;
brandStyle?: string;
referenceImage: string;
loading: boolean;
avatarPreview?: string | null;
avatarUrl?: string | null;
uploadingAvatar?: boolean;
makingPresentable?: boolean;
onIdeaChange: (idea: string) => void;
onDurationChange: (duration: DurationType) => void;
onVideoTypeChange: (type: VideoType | '') => void;
onTargetAudienceChange: (audience: string) => void;
onVideoGoalChange: (goal: string) => void;
onBrandStyleChange: (style: string) => void;
onReferenceImageChange: (image: string) => void;
onGeneratePlan: () => void;
onAvatarUpload: (file: File) => void;
onRemoveAvatar: () => void;
onMakePresentable: () => void;
onAvatarSelectFromLibrary: (asset: ContentAsset) => void;
}
export const PlanStep: React.FC<PlanStepProps> = React.memo(({
userIdea,
durationType,
videoType,
targetAudience,
videoGoal,
brandStyle,
referenceImage,
loading,
avatarPreview,
avatarUrl,
uploadingAvatar = false,
makingPresentable = false,
onIdeaChange,
onDurationChange,
onVideoTypeChange,
onTargetAudienceChange,
onVideoGoalChange,
onBrandStyleChange,
onReferenceImageChange,
onGeneratePlan,
onAvatarUpload,
onRemoveAvatar,
onMakePresentable,
onAvatarSelectFromLibrary,
}) => {
// Memoize operation objects to avoid recreating on every render
const videoPlanningOperation = useMemo(
() => buildVideoPlanningOperation(durationType),
[durationType]
);
const imageEditingOperation = useMemo(
() => buildImageEditingOperation(),
[] // No dependencies - always returns same object
);
// Load avatar as blob if it's an authenticated endpoint
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const [avatarLoading, setAvatarLoading] = useState(false);
useEffect(() => {
if (!avatarPreview) {
setAvatarBlobUrl(null);
setAvatarLoading(false);
return;
}
// If it's a data URL (from FileReader), use it directly
if (avatarPreview.startsWith('data:')) {
setAvatarBlobUrl(null);
setAvatarLoading(false);
return;
}
// If it's an authenticated YouTube image endpoint, load as blob
const isYouTubeImage = avatarPreview.includes('/api/youtube/images/') ||
avatarPreview.includes('/api/youtube/avatar/');
if (!isYouTubeImage) {
setAvatarBlobUrl(null);
setAvatarLoading(false);
return;
}
// Fetch as blob for authenticated endpoints
let isMounted = true;
const currentAvatarPreview = avatarPreview;
setAvatarLoading(true);
const loadAvatarBlob = async () => {
try {
// Normalize path
let imagePath = currentAvatarPreview.startsWith('/')
? currentAvatarPreview
: `/${currentAvatarPreview}`;
// Remove query parameters if present
imagePath = imagePath.split('?')[0];
const blobUrl = await fetchMediaBlobUrl(imagePath);
if (!isMounted || avatarPreview !== currentAvatarPreview) {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
return;
}
setAvatarBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return blobUrl;
});
setAvatarLoading(false);
} catch (err) {
console.error('[PlanStep] Failed to load avatar blob:', err);
if (isMounted && avatarPreview === currentAvatarPreview) {
setAvatarBlobUrl(null);
setAvatarLoading(false);
}
}
};
loadAvatarBlob();
return () => {
isMounted = false;
// Cleanup blob URL when component unmounts or URL changes
setAvatarBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
setAvatarLoading(false);
};
}, [avatarPreview]);
// State for custom values
const [customTargetAudience, setCustomTargetAudience] = useState('');
const [customVideoGoal, setCustomVideoGoal] = useState('');
const [customBrandStyle, setCustomBrandStyle] = useState('');
const [assetLibraryOpen, setAssetLibraryOpen] = useState(false);
// Initialize custom values from props if they're custom (not in predefined options)
useEffect(() => {
if (targetAudience && !TARGET_AUDIENCE_OPTIONS.some(opt => opt.value === targetAudience)) {
setCustomTargetAudience(targetAudience);
}
}, []); // Only on mount
useEffect(() => {
if (videoGoal && !VIDEO_GOAL_OPTIONS.some(opt => opt.value === videoGoal)) {
setCustomVideoGoal(videoGoal);
}
}, []); // Only on mount
useEffect(() => {
if (brandStyle && !BRAND_STYLE_OPTIONS.some(opt => opt.value === brandStyle)) {
setCustomBrandStyle(brandStyle);
}
}, []); // Only on mount
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onAvatarUpload(file);
}
};
const handleAssetLibrarySelect = useCallback(
(asset: ContentAsset) => {
if (!asset.file_url) return;
onAvatarSelectFromLibrary(asset);
setAssetLibraryOpen(false);
},
[onAvatarSelectFromLibrary]
);
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 }}>
<Paper sx={{ ...paperSx, p: { xs: 2.5, md: 3 } }}>
<Typography variant="h5" sx={sectionTitleSx}>
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 }}
<Stack spacing={2.5}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<InputLabel sx={labelSx} required>
What's your video about?
</InputLabel>
<Tooltip
title="Be specific! Include: 1) Your topic, 2) Target audience, 3) What viewers will learn/do, 4) Your goal (views, subscribers, sales). Example: 'Explain quantum computing to tech beginners, aiming for 10K views and 500 subscribers.'"
arrow
sx={tooltipSx}
>
<IconButton size="small" sx={{ ml: 0.5, p: 0.25, color: '#64748b' }}>
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<TextField
placeholder="Example: 'AI explains black holes in 60 seconds for science enthusiasts' or 'Budget travel guide for Tokyo targeting young professionals'"
value={userIdea}
onChange={(e) => onIdeaChange(e.target.value)}
multiline
rows={4}
fullWidth
required
helperText="Describe your video idea in 1-2 sentences. Include who it's for, what they'll learn, and your goal (views, subscribers, sales, etc.)."
sx={inputSx}
FormHelperTextProps={{ sx: helperSx }}
/>
</Box>
{/* Video Type */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<InputLabel sx={labelSx}>Video Type</InputLabel>
<Tooltip
title="Selecting a video type helps AI optimize the script structure, pacing, visuals, and avatar style. Each type has different best practices for engagement."
arrow
sx={tooltipSx}
>
<IconButton size="small" sx={{ ml: 0.5, p: 0.25, color: '#64748b' }}>
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<FormControl fullWidth>
<Select
value={videoType || ''}
onChange={(e) => onVideoTypeChange(e.target.value as VideoType | '')}
sx={selectSx}
displayEmpty
MenuProps={selectMenuProps}
>
<MenuItem value="">
<em>Select video type (Recommended)</em>
</MenuItem>
{VIDEO_TYPES.map((type) => {
const config = VIDEO_TYPE_CONFIGS[type];
return (
<MenuItem key={type} value={type}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a' }}>
{config.label}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.25 }}>
{config.description}
</Typography>
</Box>
</MenuItem>
);
})}
</Select>
<FormHelperText sx={helperSx}>
Helps optimize plan, visuals, and avatar for better results. Highly recommended!
</FormHelperText>
</FormControl>
</Box>
{/* Target Audience and Video Goal in a row on wider screens */}
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<SelectWithCustom
label="Target Audience"
value={targetAudience || ''}
options={TARGET_AUDIENCE_OPTIONS.map(opt => ({
value: opt.value,
label: opt.label,
description: opt.description,
}))}
customValue={customTargetAudience}
onSelectChange={(value) => {
onTargetAudienceChange(value);
// If selecting a predefined option, clear custom value
if (TARGET_AUDIENCE_OPTIONS.some(opt => opt.value === value)) {
setCustomTargetAudience('');
} else if (value) {
// If it's a custom value, store it
setCustomTargetAudience(value);
}
}}
onCustomChange={(value) => {
setCustomTargetAudience(value);
onTargetAudienceChange(value);
}}
tooltipText="Knowing your audience helps AI tailor the tone, pace, complexity, and visual style. Be specific: age range, interests, skill level, and what they care about."
placeholder="Example: 'Tech-savvy professionals aged 25-40, interested in productivity tools'"
helperText="Who is this video for? Helps tailor tone, pace, and style."
multiline
rows={2}
/>
</Grid>
<Grid item xs={12} md={6}>
<SelectWithCustom
label="Primary Goal"
value={videoGoal || ''}
options={VIDEO_GOAL_OPTIONS.map(opt => ({
value: opt.value,
label: opt.label,
description: opt.description,
}))}
customValue={customVideoGoal}
onSelectChange={(value) => {
onVideoGoalChange(value);
// If selecting a predefined option, clear custom value
if (VIDEO_GOAL_OPTIONS.some(opt => opt.value === value)) {
setCustomVideoGoal('');
} else if (value) {
// If it's a custom value, store it
setCustomVideoGoal(value);
}
}}
onCustomChange={(value) => {
setCustomVideoGoal(value);
onVideoGoalChange(value);
}}
tooltipText="What action should viewers take after watching? This shapes the call-to-action (CTA), content structure, and hook. Examples: Subscribe, Buy, Learn, Share, etc."
placeholder="Example: 'Educate viewers on AI basics and drive 500 subscribers'"
helperText="What should viewers do after watching? Shapes CTA and structure."
/>
</Grid>
</Grid>
{/* Brand Style */}
<SelectWithCustom
label="Brand Style / Visual Aesthetic"
value={brandStyle || ''}
options={BRAND_STYLE_OPTIONS.map(opt => ({
value: opt.value,
label: opt.label,
description: opt.description,
}))}
customValue={customBrandStyle}
onSelectChange={(value) => {
onBrandStyleChange(value);
// If selecting a predefined option, clear custom value
if (BRAND_STYLE_OPTIONS.some(opt => opt.value === value)) {
setCustomBrandStyle('');
} else if (value) {
// If it's a custom value, store it
setCustomBrandStyle(value);
}
}}
onCustomChange={(value) => {
setCustomBrandStyle(value);
onBrandStyleChange(value);
}}
tooltipText="The visual aesthetic influences avatar appearance, scene colors, transitions, and overall video feel. Choose a style that matches your brand identity and resonates with your target audience."
placeholder="Example: 'Modern minimalist, tech-forward, clean with blue accents'"
helperText="Visual style influences avatar, scenes, and overall video aesthetic."
/>
<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>
{/* Video Duration */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<InputLabel sx={labelSx}>Video Duration</InputLabel>
<Tooltip
title="Shorts (≤60s): Vertical format, quick hooks, high energy. Best for viral content. Medium (1-4min): Balanced explainers, tutorials. Long (4-10min): Deep dives, comprehensive guides. Choose based on your content complexity and audience attention span."
arrow
sx={tooltipSx}
>
<IconButton size="small" sx={{ ml: 0.5, p: 0.25, color: '#64748b' }}>
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<FormControl fullWidth>
<Select
value={durationType}
onChange={(e) => onDurationChange(e.target.value as DurationType)}
sx={selectSx}
MenuProps={selectMenuProps}
>
<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 sx={helperSx}>
Shorts = vertical bite-sized (60s). Medium = quick explainers. Long = deep dives.
</FormHelperText>
</FormControl>
</Box>
<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 }}
/>
{/* Avatar & Visual Style Section - Compact */}
<Paper variant="outlined" sx={{ p: 2, borderColor: '#d1d5db', borderRadius: 2, bgcolor: '#f9fafb' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#0f172a' }}>
Creator Avatar & Visual Style
</Typography>
<Stack spacing={2}>
{/* Visual Style Description */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<InputLabel sx={{ ...labelSx, fontSize: '0.875rem' }}>
Visual Style Guide (Optional)
</InputLabel>
<Tooltip
title="Describe the visual style, mood, or specific scenes you want for your video. Use descriptive keywords like colors, lighting, composition, atmosphere. This helps AI generate consistent visuals that match your vision. Examples: 'neon-lit Tokyo alley, rainy night, cinematic bokeh' or 'bright, clean, modern office space'"
arrow
sx={tooltipSx}
>
<IconButton size="small" sx={{ ml: 0.5, p: 0.25, color: '#64748b' }}>
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<TextField
placeholder="Example: 'neon-lit Tokyo alley, rainy night, cinematic bokeh' or 'bright, clean, modern office space'"
value={referenceImage}
onChange={(e) => onReferenceImageChange(e.target.value)}
multiline
rows={2}
fullWidth
size="small"
helperText="Optional: Describe visual style, mood, or scenes to guide AI-generated visuals."
sx={{ ...inputSx, '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
FormHelperTextProps={{ sx: { ...helperSx, fontSize: '0.75rem' } }}
/>
</Box>
<Button
{/* Avatar Upload Section */}
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1, color: '#475569' }}>
Creator Avatar
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', mb: 1.5, display: 'block' }}>
<strong>Option 1:</strong> Upload your photo Click "Make Presentable" to optimize it with AI<br />
<strong>Option 2:</strong> Skip upload AI will auto-generate a creator avatar in the next step
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems="flex-start">
{avatarPreview ? (
<>
<Box sx={{ position: 'relative', width: 120, flexShrink: 0 }}>
{avatarLoading ? (
<Box
sx={{
width: '100%',
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#f1f5f9',
borderRadius: 1.5,
border: '1px solid #e2e8f0',
}}
>
<Typography variant="caption" sx={{ color: '#64748b' }}>
Loading...
</Typography>
</Box>
) : (
<Box
component="img"
src={avatarBlobUrl || (avatarPreview.startsWith('data:') ? avatarPreview : undefined)}
alt="Avatar preview"
onError={(e) => {
// If blob URL fails, try to reload
console.warn('[PlanStep] Avatar image failed to load, will retry');
if (avatarPreview && !avatarPreview.startsWith('data:')) {
// Trigger reload by updating state
setAvatarBlobUrl(null);
}
}}
sx={{
width: '100%',
height: 120,
objectFit: 'cover',
borderRadius: 1.5,
border: '1px solid #e2e8f0',
display: avatarBlobUrl || avatarPreview.startsWith('data:') ? 'block' : 'none',
}}
/>
)}
<IconButton
size="small"
onClick={onRemoveAvatar}
sx={{
position: 'absolute',
top: -8,
right: -8,
bgcolor: 'white',
border: '1px solid #e2e8f0',
width: 24,
height: 24,
'&:hover': { bgcolor: '#f8fafc' },
'& svg': { fontSize: '0.875rem' },
}}
>
<Delete fontSize="small" />
</IconButton>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<OperationButton
operation={imageEditingOperation}
label="Make Presentable"
variant="contained"
size="medium"
color="primary"
startIcon={<AutoAwesome fontSize="small" />}
onClick={onMakePresentable}
disabled={makingPresentable}
loading={makingPresentable}
checkOnHover={true}
checkOnMount={false}
showCost={true}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontWeight: 600,
fontSize: '0.8125rem',
textTransform: 'none',
borderRadius: 1.5,
px: 2,
py: 0.875,
boxShadow: '0 2px 8px 0 rgba(102, 126, 234, 0.3)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover:not(:disabled)': {
background: 'linear-gradient(135deg, #764ba2 0%, #667eea 100%)',
boxShadow: '0 4px 12px 0 rgba(102, 126, 234, 0.4)',
transform: 'translateY(-1px)',
},
'&:disabled': {
background: 'linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%)',
color: '#64748b',
boxShadow: 'none',
},
'& .MuiButton-startIcon': {
marginRight: 0.75,
'& svg': { fontSize: '1rem' },
},
'& .MuiCircularProgress-root': { color: 'white' },
}}
buttonProps={{
children: makingPresentable ? 'Transforming...' : undefined,
}}
/>
<Typography variant="caption" sx={{ display: 'block', mt: 0.75, color: '#64748b', fontSize: '0.75rem' }}>
AI will optimize your photo using your video type, audience, and style preferences.
</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
variant="outlined"
startIcon={<Collections />}
onClick={() => setAssetLibraryOpen(true)}
fullWidth
sx={{
borderColor: '#d1d5db',
color: '#6b7280',
'&:hover': {
borderColor: '#9ca3af',
backgroundColor: '#f9fafb',
},
}}
>
Upload from Asset Library
</Button>
</Stack>
</Box>
</>
) : (
<Box
component="label"
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
minHeight: 100,
border: '2px dashed #cbd5e1',
borderRadius: 1.5,
bgcolor: '#f8fafc',
cursor: 'pointer',
py: 1.5,
transition: 'all 0.2s',
'&:hover': { borderColor: '#667eea', bgcolor: '#f1f5f9' },
}}
>
<input type="file" accept="image/*" onChange={handleFileChange} style={{ display: 'none' }} />
<CloudUpload sx={{ color: '#94a3b8', fontSize: 28, mb: 0.75 }} />
<Typography variant="body2" sx={{ color: '#475569', fontWeight: 600, fontSize: '0.875rem' }}>
{uploadingAvatar ? 'Uploading...' : 'Upload Your Photo (Optional)'}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', textAlign: 'center', px: 2, fontSize: '0.75rem' }}>
Max 5MB. JPG, PNG, WebP. Clear, front-facing photos work best.
</Typography>
<Button
variant="outlined"
startIcon={<Collections />}
onClick={() => setAssetLibraryOpen(true)}
fullWidth
sx={{
mt: 1.5,
borderColor: '#d1d5db',
color: '#6b7280',
'&:hover': {
borderColor: '#9ca3af',
backgroundColor: '#f9fafb',
},
}}
>
Upload from Asset Library
</Button>
</Box>
)}
</Stack>
</Box>
</Stack>
</Paper>
<OperationButton
operation={videoPlanningOperation}
label="Generate Video Plan"
variant="contained"
color="error"
size="large"
startIcon={<PlayArrow />}
onClick={onGeneratePlan}
disabled={loading || !userIdea.trim()}
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
loading={loading}
checkOnHover={true}
checkOnMount={false}
showCost={true}
sx={{ alignSelf: 'flex-start', px: 4 }}
>
{loading ? 'Generating Plan...' : 'Generate Video Plan'}
</Button>
/>
</Stack>
</Paper>
<AssetLibraryImageModal
open={assetLibraryOpen}
onClose={() => setAssetLibraryOpen(false)}
onSelect={handleAssetLibrarySelect}
title="Select Avatar from Asset Library"
sourceModule={undefined}
allowFavoritesOnly={false}
/>
</motion.div>
);
});

View File

@@ -0,0 +1,72 @@
/**
* Render Settings Component
*
* Configuration panel for video resolution and scene combination options.
*/
import React from 'react';
import {
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Box,
Typography,
} from '@mui/material';
import { YT_BORDER, RESOLUTIONS, type Resolution } from '../constants';
interface RenderSettingsProps {
resolution: Resolution;
combineScenes: boolean;
enabledScenesCount: number;
onResolutionChange: (resolution: Resolution) => void;
onCombineScenesChange: (combine: boolean) => void;
}
export const RenderSettings: React.FC<RenderSettingsProps> = React.memo(({
resolution,
combineScenes,
enabledScenesCount,
onResolutionChange,
onCombineScenesChange,
}) => {
return (
<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>
);
});
RenderSettings.displayName = 'RenderSettings';

View File

@@ -0,0 +1,173 @@
/**
* Render Status Display Component
*
* Displays render progress, completion status, errors, and video preview.
*/
import React from 'react';
import {
Stack,
Box,
Typography,
Alert,
LinearProgress,
Button,
} from '@mui/material';
import { Download, Refresh } from '@mui/icons-material';
import { TaskStatus } from '../../../services/youtubeApi';
interface RenderStatusDisplayProps {
renderStatus: TaskStatus | null;
renderProgress: number;
getVideoUrl: () => string | null;
onReset: () => void;
onRetryFailedScenes: (failedScenes: any[]) => void;
}
export const RenderStatusDisplay: React.FC<RenderStatusDisplayProps> = React.memo(({
renderStatus,
renderProgress,
getVideoUrl,
onReset,
onRetryFailedScenes,
}) => {
if (!renderStatus) {
return null;
}
return (
<Stack spacing={3}>
{/* Progress Bar */}
<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>
{/* Success Status */}
{renderStatus.status === 'completed' && renderStatus.result && !renderStatus.result.partial_success && (
<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>
)}
{/* Failed Status */}
{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>
)}
{/* Partial Success Status */}
{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>
);
});
RenderStatusDisplay.displayName = 'RenderStatusDisplay';

View File

@@ -1,5 +1,8 @@
/**
* Render Step Component
*
* Main component for the render step in YouTube Creator workflow.
* Orchestrates scene overview, settings, cost estimation, and render status.
*/
import React from 'react';
@@ -7,24 +10,20 @@ 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 { PlayArrow } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { TaskStatus, CostEstimate } from '../../../services/youtubeApi';
import { YT_BORDER, RESOLUTIONS, type Resolution } from '../constants';
import { TaskStatus, CostEstimate, VideoPlan, Scene } from '../../../services/youtubeApi';
import { YT_BORDER, type Resolution } from '../constants';
import { SceneCard } from './SceneCard';
import { CombinedSceneOverview } from './CombinedSceneOverview';
import { CostEstimateCard } from './CostEstimateCard';
import { RenderSettings } from './RenderSettings';
import { RenderStatusDisplay } from './RenderStatusDisplay';
interface RenderStepProps {
renderTaskId: string | null;
@@ -36,12 +35,21 @@ interface RenderStepProps {
costEstimate: CostEstimate | null;
loadingCostEstimate: boolean;
loading: boolean;
scenes: Scene[];
videoPlan: VideoPlan | null;
editingSceneId: number | null;
editedScene: Partial<Scene> | null;
onResolutionChange: (resolution: Resolution) => void;
onCombineScenesChange: (combine: boolean) => void;
onStartRender: () => void;
onBack: () => void;
onReset: () => void;
onRetryFailedScenes: (failedScenes: any[]) => void;
onEditScene: (scene: Scene) => void;
onSaveScene: () => void;
onCancelEdit: () => void;
onEditChange: (updates: Partial<Scene>) => void;
onToggleScene: (sceneNumber: number) => void;
getVideoUrl: () => string | null;
}
@@ -55,12 +63,20 @@ export const RenderStep: React.FC<RenderStepProps> = React.memo(({
costEstimate,
loadingCostEstimate,
loading,
scenes,
editingSceneId,
editedScene,
onResolutionChange,
onCombineScenesChange,
onStartRender,
onBack,
onReset,
onRetryFailedScenes,
onEditScene,
onSaveScene,
onCancelEdit,
onEditChange,
onToggleScene,
getVideoUrl,
}) => {
return (
@@ -82,41 +98,49 @@ export const RenderStep: React.FC<RenderStepProps> = React.memo(({
{!renderTaskId ? (
<Stack spacing={3}>
<Alert severity="info">
Configure render settings and start generating your video. This may take several minutes.
Review your scenes, 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>
{/* Combined Scene Statistics & Timeline */}
{scenes.length > 0 && (
<CombinedSceneOverview scenes={scenes} />
)}
{/* Scene Details - Full descriptions */}
{scenes.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#111827' }}>
Scene Details
</Typography>
<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>
)}
{/* Render Settings */}
<RenderSettings
resolution={resolution}
combineScenes={combineScenes}
enabledScenesCount={enabledScenesCount}
onResolutionChange={onResolutionChange}
onCombineScenesChange={onCombineScenesChange}
/>
{/* Render Summary and Cost Estimate */}
<Box sx={{ p: 2, bgcolor: '#f4f4f4', borderRadius: 1, border: `1px solid ${YT_BORDER}` }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Render Summary
@@ -130,57 +154,13 @@ export const RenderStep: React.FC<RenderStepProps> = React.memo(({
<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>
)}
<CostEstimateCard
costEstimate={costEstimate}
loadingCostEstimate={loadingCostEstimate}
/>
</Box>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="outlined" onClick={onBack}>
Back to Scenes
@@ -199,136 +179,13 @@ export const RenderStep: React.FC<RenderStepProps> = React.memo(({
</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>
<RenderStatusDisplay
renderStatus={renderStatus}
renderProgress={renderProgress}
getVideoUrl={getVideoUrl}
onReset={onReset}
onRetryFailedScenes={onRetryFailedScenes}
/>
)}
</Paper>
</motion.div>
@@ -336,4 +193,3 @@ export const RenderStep: React.FC<RenderStepProps> = React.memo(({
});
RenderStep.displayName = 'RenderStep';

View File

@@ -0,0 +1,48 @@
/**
* SEO Keywords Card Component
*/
import React from 'react';
import { Stack, Chip } from '@mui/material';
import { PlanDetailsCard } from './PlanDetailsCard';
import { VideoPlan } from '../../../services/youtubeApi';
interface SEOKeywordsCardProps {
seoKeywords: VideoPlan['seo_keywords'];
}
export const SEOKeywordsCard: React.FC<SEOKeywordsCardProps> = React.memo(({
seoKeywords,
}) => {
if (!seoKeywords || seoKeywords.length === 0) {
return null;
}
return (
<PlanDetailsCard title="SEO Keywords">
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
{seoKeywords.map((kw: string, idx: number) => (
<Chip
key={`${kw}-${idx}`}
label={kw}
size="medium"
sx={{
backgroundColor: '#f3f4f6',
color: '#1f2937',
fontWeight: 500,
fontSize: '0.8125rem',
height: 32,
border: '1px solid #e5e7eb',
'& .MuiChip-label': {
px: 1.5,
},
}}
/>
))}
</Stack>
</PlanDetailsCard>
);
});
SEOKeywordsCard.displayName = 'SEOKeywordsCard';

View File

@@ -15,8 +15,10 @@ import {
IconButton,
TextField,
Button,
Tooltip,
Alert,
} from '@mui/material';
import { Edit, Check, Close } from '@mui/icons-material';
import { Edit, Check, Close, Movie, Shuffle, CallMade, ArrowForward, HelpOutline, Info, RecordVoiceOver, Videocam, AutoAwesome } from '@mui/icons-material';
import { Scene } from '../../../services/youtubeApi';
import { inputSx, labelSx } from '../styles';
@@ -32,6 +34,52 @@ interface SceneCardProps {
loading: boolean;
}
// Helper function to get border color based on scene emphasis
const getSceneBorderColor = (emphasisTags?: string[]): string => {
if (!emphasisTags || emphasisTags.length === 0) return '#e5e7eb'; // Default gray
const primaryTag = emphasisTags[0];
switch (primaryTag) {
case 'hook':
return '#3b82f6'; // Blue for hook
case 'cta':
return '#8b5cf6'; // Purple for CTA
case 'transition':
return '#10b981'; // Green for transition
case 'main_content':
default:
return '#e5e7eb'; // Gray for main content
}
};
// Helper function to get icon for scene emphasis
const getSceneIcon = (emphasisTag: string) => {
switch (emphasisTag) {
case 'hook':
return <Movie fontSize="small" />;
case 'cta':
return <CallMade fontSize="small" />;
case 'transition':
return <Shuffle fontSize="small" />;
case 'main_content':
return <ArrowForward fontSize="small" />;
default:
return <ArrowForward fontSize="small" />;
}
};
// Helper function to get color for scene emphasis
const getSceneChipColor = (emphasisTag: string): 'primary' | 'secondary' | 'default' => {
switch (emphasisTag) {
case 'hook':
return 'primary';
case 'cta':
return 'secondary';
default:
return 'default';
}
};
export const SceneCard: React.FC<SceneCardProps> = React.memo(({
scene,
isEditing,
@@ -44,60 +92,151 @@ export const SceneCard: React.FC<SceneCardProps> = React.memo(({
loading,
}) => {
const sceneData = isEditing && editedScene ? { ...scene, ...editedScene } : scene;
const borderColor = getSceneBorderColor(sceneData.emphasis_tags);
return (
<Card
variant="outlined"
sx={{
opacity: sceneData.enabled === false ? 0.6 : 1,
border: sceneData.enabled === false ? '1px dashed' : '1px solid',
border: sceneData.enabled === false ? '1px dashed #e5e7eb' : `2px solid ${borderColor}`,
borderRadius: 2,
bgcolor: '#ffffff',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: sceneData.enabled !== false ? '0 4px 12px rgba(0, 0, 0, 0.1)' : 'none',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<CardContent sx={{ p: 3 }}>
{/* Header Section */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2.5 }}>
<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 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography
variant="h6"
sx={{
mb: 0,
fontWeight: 700,
fontSize: '1.125rem',
color: '#111827',
letterSpacing: '-0.01em',
}}
>
Scene {scene.scene_number}: {sceneData.title}
</Typography>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Scene Type: {sceneData.emphasis_tags?.[0]?.replace('_', ' ') || 'Main Content'}
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
{sceneData.emphasis_tags?.[0] === 'hook'
? 'Hook scenes capture attention in the first few seconds with compelling visuals or statements.'
: sceneData.emphasis_tags?.[0] === 'cta'
? 'Call-to-action scenes encourage viewers to like, subscribe, or take a specific action.'
: sceneData.emphasis_tags?.[0] === 'transition'
? 'Transition scenes smoothly connect different topics or segments.'
: 'Main content scenes deliver the core message and information.'}
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
Duration: {sceneData.duration_estimate}s This affects rendering cost.
</Typography>
</Box>
}
arrow
placement="top"
>
<IconButton size="small" sx={{ color: '#6b7280', p: 0.5 }}>
<HelpOutline fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Stack direction="row" spacing={1} sx={{ mb: 0 }} flexWrap="wrap" useFlexGap>
{sceneData.emphasis_tags?.map((tag) => (
<Chip
<Tooltip
key={tag}
label={tag}
size="small"
color={
tag === 'hook' ? 'primary' :
tag === 'cta' ? 'secondary' : 'default'
title={
tag === 'hook'
? 'Hook: Grabs viewer attention immediately'
: tag === 'cta'
? 'CTA: Encourages viewer action'
: tag === 'transition'
? 'Transition: Connects segments smoothly'
: 'Main Content: Core message delivery'
}
/>
arrow
>
<Chip
label={tag.replace('_', ' ')}
size="small"
color={getSceneChipColor(tag)}
icon={getSceneIcon(tag)}
sx={{
textTransform: 'capitalize',
fontWeight: 600,
fontSize: '0.75rem',
}}
/>
</Tooltip>
))}
<Chip
label={`~${sceneData.duration_estimate}s`}
size="small"
variant="outlined"
/>
<Tooltip
title="Estimated duration in seconds. Longer scenes cost more to render but provide more detail."
arrow
>
<Chip
label={`~${sceneData.duration_estimate}s`}
size="small"
variant="outlined"
sx={{
ml: 'auto',
fontWeight: 600,
fontSize: '0.75rem',
borderColor: '#d1d5db',
color: '#374151',
}}
/>
</Tooltip>
</Stack>
</Box>
<Box>
<FormControlLabel
control={
<Switch
checked={sceneData.enabled !== false}
onChange={() => onToggle(scene.scene_number)}
size="small"
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip
title={
sceneData.enabled !== false
? 'Disable this scene to exclude it from rendering and reduce cost'
: 'Enable this scene to include it in the final video'
}
label="Enable"
sx={{ mr: 1 }}
/>
arrow
>
<FormControlLabel
control={
<Switch
checked={sceneData.enabled !== false}
onChange={() => onToggle(scene.scene_number)}
size="small"
/>
}
label="Enable"
sx={{ mr: 0 }}
/>
</Tooltip>
{!isEditing && (
<IconButton
size="small"
onClick={() => onEdit(scene)}
color="primary"
>
<Edit fontSize="small" />
</IconButton>
<Tooltip title="Edit scene narration, visual prompt, or duration" arrow>
<IconButton
size="small"
onClick={() => onEdit(scene)}
color="primary"
sx={{
border: '1px solid #e5e7eb',
'&:hover': {
bgcolor: '#f9fafb',
},
}}
>
<Edit fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
@@ -155,21 +294,208 @@ export const SceneCard: React.FC<SceneCardProps> = React.memo(({
</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(', ')}
<Stack spacing={2.5}>
{/* Narration Section */}
<Box
sx={{
p: 2,
bgcolor: '#f9fafb',
borderRadius: 1.5,
border: '1px solid #e5e7eb',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<RecordVoiceOver sx={{ color: '#6366f1', fontSize: 18 }} />
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
fontSize: '0.875rem',
color: '#111827',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Narration
</Typography>
<Tooltip
title="The spoken text or voiceover for this scene. This is what will be narrated in the final video."
arrow
>
<IconButton size="small" sx={{ color: '#6b7280', p: 0.25, ml: 0.5 }}>
<HelpOutline fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Typography
variant="body1"
sx={{
fontStyle: 'italic',
color: '#374151',
fontSize: '0.9375rem',
lineHeight: 1.7,
fontWeight: 400,
pl: 0.5,
}}
>
"{sceneData.narration}"
</Typography>
</Box>
{/* Visual Prompt Section */}
<Box
sx={{
p: 2,
bgcolor: '#fef3c7',
borderRadius: 1.5,
border: '1px solid #fde68a',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Videocam sx={{ color: '#d97706', fontSize: 18 }} />
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
fontSize: '0.875rem',
color: '#92400e',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Visual Prompt
</Typography>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Visual Prompt Explained
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
This describes the visual content that will be generated for this scene. The AI uses this to create appropriate images or video clips.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Tip:</strong> More detailed prompts lead to better visual results. Include camera angles, lighting, and composition details.
</Typography>
</Box>
}
arrow
>
<IconButton size="small" sx={{ color: '#d97706', p: 0.25, ml: 0.5 }}>
<HelpOutline fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Typography
variant="body2"
sx={{
color: '#78350f',
fontSize: '0.875rem',
lineHeight: 1.7,
pl: 0.5,
fontWeight: 400,
}}
>
{sceneData.visual_prompt}
</Typography>
</Box>
{/* Visual Cues Section */}
{sceneData.visual_cues && sceneData.visual_cues.length > 0 && (
<Box
sx={{
p: 2,
bgcolor: '#f0f9ff',
borderRadius: 1.5,
border: '1px solid #bae6fd',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<AutoAwesome sx={{ color: '#0284c7', fontSize: 18 }} />
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
fontSize: '0.875rem',
color: '#0c4a6e',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Visual Cues
</Typography>
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Visual Cues Explained
</Typography>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
These are specific visual effects, camera techniques, or stylistic elements that will be applied to enhance the scene.
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
Examples: Quick Zoom, Sunlight Flare, Energetic Cut, Steady Cam Walk, etc.
</Typography>
</Box>
}
arrow
>
<IconButton size="small" sx={{ color: '#0284c7', p: 0.25, ml: 0.5 }}>
<HelpOutline fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
{sceneData.visual_cues.map((cue, idx) => (
<Tooltip
key={`${cue}-${idx}`}
title={`Visual effect: ${cue}`}
arrow
>
<Chip
label={cue}
size="small"
sx={{
fontSize: '0.75rem',
height: 28,
textTransform: 'capitalize',
borderColor: '#7dd3fc',
bgcolor: '#ffffff',
color: '#0c4a6e',
fontWeight: 500,
'&:hover': {
bgcolor: '#e0f2fe',
borderColor: '#0284c7',
},
}}
/>
</Tooltip>
))}
</Stack>
</Box>
)}
</>
{/* Info Alert for Editing */}
<Alert
severity="info"
icon={<Info fontSize="small" />}
sx={{
bgcolor: '#eff6ff',
border: '1px solid #bfdbfe',
'& .MuiAlert-icon': {
color: '#3b82f6',
},
'& .MuiAlert-message': {
color: '#1e40af',
},
}}
>
<Typography variant="caption" sx={{ fontSize: '0.75rem', lineHeight: 1.5 }}>
<strong>Tip:</strong> Click the edit icon above to modify narration, visual prompt, or duration.
Disable scenes you don't need to reduce rendering cost.
</Typography>
</Alert>
</Stack>
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,195 @@
/**
* Scene Statistics Card Component
*
* Displays summary statistics about generated scenes including
* total count, duration, and breakdown by scene type.
*/
import React, { useMemo } from 'react';
import {
Card,
CardContent,
Typography,
Stack,
Box,
Chip,
Divider,
} from '@mui/material';
import { AccessTime, Movie, Timeline } from '@mui/icons-material';
import { Scene } from '../../../services/youtubeApi';
interface SceneStatisticsCardProps {
scenes: Scene[];
}
export const SceneStatisticsCard: React.FC<SceneStatisticsCardProps> = React.memo(({
scenes,
}) => {
const stats = useMemo(() => {
const totalScenes = scenes.length;
const enabledScenes = scenes.filter(s => s.enabled !== false);
const totalDuration = enabledScenes.reduce((sum, scene) => sum + scene.duration_estimate, 0);
// Group scenes by emphasis type
const sceneBreakdown = scenes.reduce((acc, scene) => {
const type = scene.emphasis_tags?.[0] || 'main_content';
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Calculate enabled scene breakdown
const enabledBreakdown = enabledScenes.reduce((acc, scene) => {
const type = scene.emphasis_tags?.[0] || 'main_content';
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const averageDuration = enabledScenes.length > 0
? Math.round((totalDuration / enabledScenes.length) * 10) / 10
: 0;
return {
totalScenes,
enabledScenes: enabledScenes.length,
totalDuration,
sceneBreakdown,
enabledBreakdown,
averageDuration,
};
}, [scenes]);
const formatDuration = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
const getSceneTypeLabel = (type: string): string => {
switch (type) {
case 'hook': return 'Hook';
case 'cta': return 'CTA';
case 'transition': return 'Transition';
case 'main_content': return 'Main Content';
default: return type.charAt(0).toUpperCase() + type.slice(1);
}
};
const getSceneTypeColor = (type: string): 'primary' | 'secondary' | 'default' => {
switch (type) {
case 'hook': return 'primary';
case 'cta': return 'secondary';
default: return 'default';
}
};
return (
<Card
elevation={0}
sx={{
border: '1px solid #e5e7eb',
borderRadius: 2,
bgcolor: '#ffffff',
mb: 3,
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
>
<CardContent sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Timeline sx={{ color: 'primary.main', fontSize: 20 }} />
<Typography
variant="h6"
sx={{
fontWeight: 600,
fontSize: '1.125rem',
letterSpacing: '-0.01em',
}}
>
Scene Statistics
</Typography>
</Box>
<Stack spacing={2}>
{/* Main Statistics Row */}
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Movie sx={{ color: 'text.secondary', fontSize: 18 }} />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
{stats.enabledScenes} of {stats.totalScenes} scenes enabled
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AccessTime sx={{ color: 'text.secondary', fontSize: 18 }} />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Total: {formatDuration(stats.totalDuration)}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Average: {stats.averageDuration}s per scene
</Typography>
</Box>
{/* Scene Type Breakdown */}
<Divider sx={{ my: 1 }} />
<Box>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: 'text.primary',
mb: 1.5,
fontSize: '0.875rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Scene Breakdown
</Typography>
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
{Object.entries(stats.enabledBreakdown).map(([type, count]) => (
<Chip
key={type}
label={`${getSceneTypeLabel(type)}: ${count}`}
size="small"
color={getSceneTypeColor(type)}
variant="outlined"
sx={{
fontWeight: 500,
'& .MuiChip-label': {
px: 1.5,
},
}}
/>
))}
</Stack>
{stats.enabledScenes !== stats.totalScenes && (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
mt: 1,
display: 'block',
}}
>
{stats.totalScenes - stats.enabledScenes} scene{stats.totalScenes - stats.enabledScenes !== 1 ? 's' : ''} disabled
</Typography>
)}
</Box>
</Stack>
</CardContent>
</Card>
);
});
SceneStatisticsCard.displayName = 'SceneStatisticsCard';

View File

@@ -0,0 +1,215 @@
/**
* Scene Timeline Component
*
* Displays a horizontal timeline/flow view of all scenes showing
* sequence, duration, and scene types in a visual format.
*/
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Stack,
Chip,
Tooltip,
} from '@mui/material';
import { AccessTime } from '@mui/icons-material';
import { Scene } from '../../../services/youtubeApi';
import { getSceneIcon, getSceneColor, getSceneTypeLabel, formatDuration } from '../utils/sceneHelpers';
interface SceneTimelineProps {
scenes: Scene[];
}
export const SceneTimeline: React.FC<SceneTimelineProps> = React.memo(({
scenes,
}) => {
const enabledScenes = scenes.filter(s => s.enabled !== false);
if (enabledScenes.length === 0) {
return null;
}
// Calculate total duration
const totalDuration = enabledScenes.reduce((sum, scene) => sum + scene.duration_estimate, 0);
return (
<Card
elevation={0}
sx={{
border: '1px solid #e5e7eb',
borderRadius: 2,
bgcolor: '#ffffff',
mb: 3,
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
},
}}
>
<CardContent sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AccessTime sx={{ color: 'primary.main', fontSize: 20 }} />
<Typography
variant="h6"
sx={{
fontWeight: 600,
fontSize: '1.125rem',
letterSpacing: '-0.01em',
}}
>
Scene Sequence
</Typography>
</Box>
{/* Timeline Visualization */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
{enabledScenes.map((scene, index) => (
<React.Fragment key={scene.scene_number}>
{/* Scene Node */}
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Scene {scene.scene_number}: {scene.title}
</Typography>
<Typography variant="caption" sx={{ display: 'block' }}>
{scene.narration?.substring(0, 100)}...
</Typography>
<Typography variant="caption" sx={{ display: 'block', mt: 0.5 }}>
Duration: {scene.duration_estimate}s
</Typography>
</Box>
}
arrow
placement="top"
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: 80,
p: 1,
borderRadius: 1,
border: `2px solid ${getSceneColor(scene.emphasis_tags?.[0] || 'main_content')}`,
bgcolor: 'white',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-1px)',
},
}}
>
{getSceneIcon(scene.emphasis_tags?.[0] || 'main_content', 'medium')}
<Typography
variant="caption"
sx={{
fontWeight: 600,
fontSize: '0.7rem',
mt: 0.5,
color: 'text.primary',
}}
>
{scene.scene_number}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'text.secondary',
textAlign: 'center',
}}
>
{scene.duration_estimate}s
</Typography>
</Box>
</Tooltip>
{/* Arrow between scenes */}
{index < enabledScenes.length - 1 && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 1,
}}
>
<Box
sx={{
width: 20,
height: 1,
bgcolor: '#d1d5db',
position: 'relative',
'&::after': {
content: '""',
position: 'absolute',
right: -4,
top: -2,
width: 0,
height: 0,
borderLeft: '4px solid #d1d5db',
borderTop: '2px solid transparent',
borderBottom: '2px solid transparent',
},
}}
/>
</Box>
)}
</React.Fragment>
))}
</Box>
{/* Timeline Summary */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Chip
label={`Total: ${formatDuration(totalDuration)}`}
size="small"
sx={{ fontWeight: 500 }}
/>
<Chip
label={`${enabledScenes.length} scenes`}
size="small"
variant="outlined"
/>
<Chip
label={`Avg: ${Math.round((totalDuration / enabledScenes.length) * 10) / 10}s`}
size="small"
variant="outlined"
/>
</Stack>
<Stack direction="row" spacing={1}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#3b82f6' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Hook
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#6b7280' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Content
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#8b5cf6' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
CTA
</Typography>
</Box>
</Stack>
</Box>
</Box>
</CardContent>
</Card>
);
});
SceneTimeline.displayName = 'SceneTimeline';

View File

@@ -7,16 +7,16 @@ 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';
import { OperationButton } from '../../shared/OperationButton';
import { buildSceneBuildingOperation } from '../utils/operationHelpers';
import { DurationType } from '../constants';
interface ScenesStepProps {
videoPlan: VideoPlan;
@@ -32,6 +32,8 @@ interface ScenesStepProps {
onToggleScene: (sceneNumber: number) => void;
onBack: () => void;
onNext: () => void;
onAvatarRegenerate?: () => void;
regeneratingAvatar?: boolean;
}
export const ScenesStep: React.FC<ScenesStepProps> = React.memo(({
@@ -48,12 +50,21 @@ export const ScenesStep: React.FC<ScenesStepProps> = React.memo(({
onToggleScene,
onBack,
onNext,
onAvatarRegenerate,
regeneratingAvatar = false,
}) => {
const enabledScenesCount = useMemo(
() => scenes.filter(s => s.enabled !== false).length,
[scenes]
);
// Memoize operation object to avoid recreating on every render
const sceneBuildingOperation = useMemo(() => {
const durationType = (videoPlan?.duration_type as DurationType) || 'medium';
const hasPlan = !!videoPlan; // Check if plan exists
return buildSceneBuildingOperation(durationType, hasPlan);
}, [videoPlan]);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -71,38 +82,29 @@ export const ScenesStep: React.FC<ScenesStepProps> = React.memo(({
2 Review & Edit Scenes
</Typography>
{scenes.length === 0 && (
<Button
<OperationButton
operation={sceneBuildingOperation}
label="Build Scenes from Plan"
variant="contained"
color="error"
startIcon={<PlayArrow />}
onClick={onBuildScenes}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <PlayArrow />}
>
{loading ? 'Building Scenes...' : 'Build Scenes from Plan'}
</Button>
loading={loading}
checkOnHover={true}
checkOnMount={false}
showCost={true}
/>
)}
</Box>
<PlanDetails plan={videoPlan} />
<PlanDetails
plan={videoPlan}
onAvatarRegenerate={onAvatarRegenerate}
regeneratingAvatar={regeneratingAvatar}
/>
{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>
) : (
{scenes.length === 0 && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<VideoLibrary sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>

View File

@@ -0,0 +1,168 @@
/**
* SelectWithCustom Component
*
* A select dropdown that allows users to choose from predefined options
* or enter a custom value. Shows custom input when "Custom" option is selected.
*/
import React, { useState, useEffect } from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
FormHelperText,
Box,
Typography,
Tooltip,
IconButton,
} from '@mui/material';
import { InfoOutlined } from '@mui/icons-material';
import { selectSx, labelSx, helperSx, inputSx, selectMenuProps } from '../styles';
export interface SelectOption {
value: string;
label: string;
description?: string;
}
interface SelectWithCustomProps {
label: string;
value: string;
options: SelectOption[];
customValue: string;
onSelectChange: (value: string) => void;
onCustomChange: (value: string) => void;
helperText?: string;
tooltipText?: string;
placeholder?: string;
required?: boolean;
multiline?: boolean;
rows?: number;
sx?: any;
}
export const SelectWithCustom: React.FC<SelectWithCustomProps> = ({
label,
value,
options,
customValue,
onSelectChange,
onCustomChange,
helperText,
tooltipText,
placeholder,
required = false,
multiline = false,
rows = 1,
sx,
}) => {
const [isCustom, setIsCustom] = useState(false);
// Check if current value is custom (not in options)
useEffect(() => {
const isCustomValue = Boolean(value && !options.some(opt => opt.value === value));
setIsCustom(isCustomValue);
}, [value, options]);
const handleSelectChange = (newValue: string) => {
if (newValue === '__custom__') {
setIsCustom(true);
// Don't change the main value yet - wait for custom input
} else {
setIsCustom(false);
onSelectChange(newValue);
// Clear custom value when selecting a predefined option
if (customValue) {
onCustomChange('');
}
}
};
const handleCustomChange = (newValue: string) => {
onCustomChange(newValue);
// Update main value immediately as user types
onSelectChange(newValue);
};
const handleCustomBlur = () => {
// Trim the value when losing focus
const trimmed = customValue.trim();
if (trimmed !== customValue) {
onCustomChange(trimmed);
onSelectChange(trimmed);
}
};
return (
<Box sx={sx}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<InputLabel sx={labelSx} required={required}>
{label}
</InputLabel>
{tooltipText && (
<Tooltip title={tooltipText} arrow placement="top">
<IconButton size="small" sx={{ ml: 0.5, p: 0.25, color: '#64748b' }}>
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{!isCustom ? (
<FormControl fullWidth>
<Select
value={value || ''}
onChange={(e) => handleSelectChange(e.target.value)}
sx={selectSx}
displayEmpty
MenuProps={selectMenuProps}
>
<MenuItem value="">
<em>Select an option...</em>
</MenuItem>
{options.map((option) => (
<MenuItem key={option.value} value={option.value}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a' }}>
{option.label}
</Typography>
{option.description && (
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.25 }}>
{option.description}
</Typography>
)}
</Box>
</MenuItem>
))}
<MenuItem value="__custom__">
<Typography variant="body2" sx={{ fontStyle: 'italic', color: '#667eea' }}>
+ Enter custom...
</Typography>
</MenuItem>
</Select>
{helperText && (
<FormHelperText sx={helperSx}>{helperText}</FormHelperText>
)}
</FormControl>
) : (
<TextField
value={customValue}
onChange={(e) => handleCustomChange(e.target.value)}
onBlur={handleCustomBlur}
placeholder={placeholder}
multiline={multiline}
rows={multiline ? rows : undefined}
fullWidth
autoFocus
sx={inputSx}
InputLabelProps={{ sx: labelSx }}
FormHelperTextProps={{ sx: helperSx }}
helperText={helperText || 'Enter your custom value'}
/>
)}
</Box>
);
};

View File

@@ -15,5 +15,234 @@ 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
export const VIDEO_TYPES = [
'tutorial',
'review',
'educational',
'entertainment',
'vlog',
'product_demo',
'reaction',
'storytelling',
] as const;
export type VideoType = typeof VIDEO_TYPES[number];
export interface VideoTypeConfig {
label: string;
description: string;
optimalDurations: DurationType[];
typicalScenes: { min: number; max: number };
}
export const VIDEO_TYPE_CONFIGS: Record<VideoType, VideoTypeConfig> = {
tutorial: {
label: 'Tutorial / How-To',
description: 'Step-by-step guides, instructions, and how-to content',
optimalDurations: ['medium', 'long'],
typicalScenes: { min: 3, max: 8 },
},
review: {
label: 'Review / Unboxing',
description: 'Product reviews, unboxings, and comparisons',
optimalDurations: ['medium', 'long'],
typicalScenes: { min: 4, max: 10 },
},
educational: {
label: 'Educational / Explainer',
description: 'Concept explanations, learning content, and educational videos',
optimalDurations: ['medium', 'long'],
typicalScenes: { min: 4, max: 12 },
},
entertainment: {
label: 'Entertainment',
description: 'Funny, engaging, viral content',
optimalDurations: ['shorts', 'medium'],
typicalScenes: { min: 3, max: 8 },
},
vlog: {
label: 'Vlog / Personal',
description: 'Personal storytelling, daily experiences, and vlogs',
optimalDurations: ['medium', 'long'],
typicalScenes: { min: 5, max: 15 },
},
product_demo: {
label: 'Product Demo / Commercial',
description: 'Product showcases, demos, and sales-focused content',
optimalDurations: ['shorts', 'medium'],
typicalScenes: { min: 3, max: 7 },
},
reaction: {
label: 'Reaction / Commentary',
description: 'Reaction videos, commentary, and responses to content',
optimalDurations: ['medium', 'long'],
typicalScenes: { min: 4, max: 12 },
},
storytelling: {
label: 'Storytelling / Documentary',
description: 'Narrative-driven content, documentaries, and stories',
optimalDurations: ['long'],
typicalScenes: { min: 6, max: 20 },
},
};
// Target Audience Options
export interface TargetAudienceOption {
value: string;
label: string;
description: string;
}
export const TARGET_AUDIENCE_OPTIONS: TargetAudienceOption[] = [
{
value: 'tech_professionals',
label: 'Tech Professionals',
description: 'Developers, engineers, IT professionals aged 25-45',
},
{
value: 'business_owners',
label: 'Business Owners & Entrepreneurs',
description: 'Small business owners, startups, entrepreneurs',
},
{
value: 'students',
label: 'Students & Learners',
description: 'High school, college students, lifelong learners',
},
{
value: 'parents',
label: 'Parents & Families',
description: 'Parents with children, family-oriented content',
},
{
value: 'creators',
label: 'Content Creators',
description: 'YouTubers, streamers, social media creators',
},
{
value: 'fitness_enthusiasts',
label: 'Fitness & Health Enthusiasts',
description: 'Gym-goers, athletes, health-conscious individuals',
},
{
value: 'gamers',
label: 'Gamers',
description: 'Gaming enthusiasts, esports fans',
},
{
value: 'travelers',
label: 'Travelers & Adventurers',
description: 'Travel enthusiasts, adventure seekers',
},
{
value: 'foodies',
label: 'Food Enthusiasts',
description: 'Cooking enthusiasts, food lovers, home chefs',
},
{
value: 'fashion_style',
label: 'Fashion & Style',
description: 'Fashion-conscious individuals, style enthusiasts',
},
];
// Video Goal Options
export interface VideoGoalOption {
value: string;
label: string;
description: string;
}
export const VIDEO_GOAL_OPTIONS: VideoGoalOption[] = [
{
value: 'educate',
label: 'Educate & Inform',
description: 'Teach concepts, explain topics, share knowledge',
},
{
value: 'entertain',
label: 'Entertain & Engage',
description: 'Make viewers laugh, keep them engaged, build audience',
},
{
value: 'sell',
label: 'Drive Sales & Conversions',
description: 'Promote products, drive purchases, generate leads',
},
{
value: 'build_brand',
label: 'Build Brand Awareness',
description: 'Increase visibility, establish authority, grow recognition',
},
{
value: 'grow_subscribers',
label: 'Grow Subscribers',
description: 'Attract new subscribers, build community',
},
{
value: 'increase_views',
label: 'Maximize Views & Reach',
description: 'Boost watch time, improve algorithm ranking',
},
{
value: 'inspire',
label: 'Inspire & Motivate',
description: 'Motivate action, share success stories, inspire change',
},
{
value: 'document',
label: 'Document & Share',
description: 'Share experiences, document processes, create memories',
},
];
// Brand Style Options
export interface BrandStyleOption {
value: string;
label: string;
description: string;
}
export const BRAND_STYLE_OPTIONS: BrandStyleOption[] = [
{
value: 'modern_minimalist',
label: 'Modern Minimalist',
description: 'Clean, simple, tech-forward aesthetic',
},
{
value: 'energetic_vibrant',
label: 'Energetic & Vibrant',
description: 'Colorful, dynamic, high-energy visuals',
},
{
value: 'professional_polished',
label: 'Professional & Polished',
description: 'Corporate, trustworthy, refined style',
},
{
value: 'warm_friendly',
label: 'Warm & Friendly',
description: 'Approachable, inviting, personable feel',
},
{
value: 'bold_edgy',
label: 'Bold & Edgy',
description: 'Daring, unconventional, attention-grabbing',
},
{
value: 'natural_organic',
label: 'Natural & Organic',
description: 'Earth tones, authentic, unpolished',
},
{
value: 'luxury_premium',
label: 'Luxury & Premium',
description: 'High-end, sophisticated, exclusive',
},
{
value: 'playful_fun',
label: 'Playful & Fun',
description: 'Lighthearted, whimsical, entertaining',
},
];
export const POLLING_INTERVAL_MS = 2000; // 2 seconds

View File

@@ -0,0 +1,99 @@
/**
* Custom hook for loading avatar as blob URL from authenticated endpoints
*/
import { useState, useEffect } from 'react';
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
interface UseAvatarBlobUrlResult {
avatarBlobUrl: string | null;
avatarLoading: boolean;
}
export const useAvatarBlobUrl = (avatarUrl: string | null | undefined): UseAvatarBlobUrlResult => {
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const [avatarLoading, setAvatarLoading] = useState(false);
useEffect(() => {
if (!avatarUrl) {
setAvatarBlobUrl(null);
setAvatarLoading(false);
return;
}
// If it's a data URL, use it directly (no blob needed)
if (avatarUrl.startsWith('data:')) {
setAvatarBlobUrl(null);
setAvatarLoading(false);
return;
}
// If it's an authenticated YouTube image endpoint, load as blob
const isYouTubeImage = avatarUrl.includes('/api/youtube/images/') ||
avatarUrl.includes('/api/youtube/avatar/');
if (!isYouTubeImage) {
setAvatarBlobUrl(null);
setAvatarLoading(false);
return;
}
// Fetch as blob for authenticated endpoints
let isMounted = true;
const currentAvatarUrl = avatarUrl;
setAvatarLoading(true);
const loadAvatarBlob = async () => {
try {
// Normalize path
let imagePath = currentAvatarUrl.startsWith('/')
? currentAvatarUrl
: `/${currentAvatarUrl}`;
// Remove query parameters if present
imagePath = imagePath.split('?')[0];
const blobUrl = await fetchMediaBlobUrl(imagePath);
if (!isMounted || avatarUrl !== currentAvatarUrl) {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
return;
}
setAvatarBlobUrl((prevBlobUrl) => {
// Clean up previous blob URL if exists
if (prevBlobUrl && prevBlobUrl !== blobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return blobUrl;
});
setAvatarLoading(false);
} catch (err) {
console.error('[useAvatarBlobUrl] Failed to load avatar blob:', err);
if (isMounted && avatarUrl === currentAvatarUrl) {
setAvatarBlobUrl(null);
setAvatarLoading(false);
}
}
};
loadAvatarBlob();
return () => {
isMounted = false;
// Cleanup blob URL when component unmounts or URL changes
setAvatarBlobUrl((prevBlobUrl) => {
if (prevBlobUrl && prevBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(prevBlobUrl);
}
return null;
});
setAvatarLoading(false);
};
}, [avatarUrl]);
return { avatarBlobUrl, avatarLoading };
};

View File

@@ -1,38 +1,191 @@
/**
* Shared styles for YouTube Creator Studio
* Enterprise-quality styling with improved contrast and readability
*/
import { YT_RED, YT_TEXT } from './constants';
// Enhanced color palette for better contrast
const BORDER_COLOR = '#d1d5db'; // Lighter gray for better contrast
const BORDER_HOVER = '#9ca3af'; // Medium gray on hover
const BORDER_FOCUS = YT_RED;
const TEXT_PRIMARY = '#111827'; // Darker for better readability
const TEXT_SECONDARY = '#6b7280'; // Medium gray for secondary text
const TEXT_PLACEHOLDER = '#9ca3af'; // Lighter gray for placeholders
const BACKGROUND = '#ffffff';
const BACKGROUND_HOVER = '#f9fafb';
export const inputSx = {
'& .MuiOutlinedInput-root': {
backgroundColor: '#fff',
color: YT_TEXT,
borderRadius: 1,
backgroundColor: BACKGROUND,
color: TEXT_PRIMARY,
borderRadius: 1.5,
fontSize: '0.9375rem', // 15px for better readability
transition: 'all 0.2s ease-in-out',
'& fieldset': {
borderColor: '#c6c6c6',
borderColor: BORDER_COLOR,
borderWidth: '1.5px',
},
'&:hover fieldset': {
borderColor: YT_RED,
borderColor: BORDER_HOVER,
},
'&.Mui-focused fieldset': {
borderColor: YT_RED,
boxShadow: '0 0 0 2px rgba(255,0,0,0.08)',
borderColor: BORDER_FOCUS,
borderWidth: '2px',
boxShadow: `0 0 0 3px rgba(255, 0, 0, 0.1)`,
},
'& input::placeholder, & textarea::placeholder': {
color: '#5f6368',
color: TEXT_PLACEHOLDER,
opacity: 1,
fontSize: '0.9375rem',
},
'&.Mui-disabled': {
backgroundColor: BACKGROUND_HOVER,
'& fieldset': {
borderColor: BORDER_COLOR,
},
},
},
'& .MuiInputLabel-root': {
fontSize: '0.875rem',
fontWeight: 500,
},
};
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' },
backgroundColor: BACKGROUND,
borderRadius: 1.5,
fontSize: '0.9375rem',
transition: 'all 0.2s ease-in-out',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: BORDER_COLOR,
borderWidth: '1.5px',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: BORDER_HOVER,
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: BORDER_FOCUS,
borderWidth: '2px',
boxShadow: `0 0 0 3px rgba(255, 0, 0, 0.1)`,
},
'& .MuiSelect-select': {
color: TEXT_PRIMARY,
backgroundColor: BACKGROUND,
padding: '14px 14px',
fontSize: '0.9375rem',
fontWeight: 400,
},
'& .MuiSvgIcon-root': {
color: TEXT_SECONDARY,
fontSize: '1.5rem',
},
'&.Mui-disabled': {
backgroundColor: BACKGROUND_HOVER,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: BORDER_COLOR,
},
},
};
export const labelSx = { color: '#5f6368', '&.Mui-focused': { color: YT_RED } };
export const helperSx = { color: '#5f6368' };
// Menu props for Select dropdown - ensures light theme
export const selectMenuProps = {
PaperProps: {
sx: {
backgroundColor: BACKGROUND,
color: TEXT_PRIMARY,
borderRadius: 2,
border: `1px solid ${BORDER_COLOR}`,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
mt: 0.5,
maxHeight: 400,
'& .MuiMenuItem-root': {
color: TEXT_PRIMARY,
backgroundColor: BACKGROUND,
fontSize: '0.9375rem',
padding: '10px 16px',
'&:hover': {
backgroundColor: BACKGROUND_HOVER,
},
'&.Mui-selected': {
backgroundColor: '#f3f4f6',
color: TEXT_PRIMARY,
'&:hover': {
backgroundColor: '#e5e7eb',
},
},
'&.Mui-focusVisible': {
backgroundColor: BACKGROUND_HOVER,
},
},
},
},
MenuListProps: {
sx: {
padding: 0,
'& .MuiMenuItem-root': {
color: TEXT_PRIMARY,
'& em': {
color: TEXT_SECONDARY,
fontStyle: 'normal',
},
},
},
},
};
export const labelSx = {
color: TEXT_PRIMARY,
fontSize: '0.875rem',
fontWeight: 600,
marginBottom: '4px',
'&.Mui-focused': {
color: BORDER_FOCUS,
},
'&.Mui-required': {
'&::after': {
content: '" *"',
color: BORDER_FOCUS,
},
},
};
export const helperSx = {
color: TEXT_SECONDARY,
fontSize: '0.8125rem', // 13px
marginTop: '6px',
lineHeight: 1.5,
fontWeight: 400,
};
// Additional styles for better UI
export const paperSx = {
backgroundColor: BACKGROUND,
border: `1.5px solid ${BORDER_COLOR}`,
borderRadius: 2,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.05)',
};
export const sectionTitleSx = {
fontSize: '1.25rem',
fontWeight: 600,
color: TEXT_PRIMARY,
marginBottom: 2,
lineHeight: 1.4,
};
export const tooltipSx = {
'& .MuiTooltip-tooltip': {
backgroundColor: TEXT_PRIMARY,
color: BACKGROUND,
fontSize: '0.8125rem',
padding: '8px 12px',
borderRadius: 1,
maxWidth: 300,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
},
'& .MuiTooltip-arrow': {
color: TEXT_PRIMARY,
},
};

View File

@@ -0,0 +1,61 @@
# YouTube Creator Operation Helpers
This utility module provides YouTube-specific operation definitions for use with the shared `OperationButton` component.
## Purpose
- **Separation of Concerns**: Keeps YouTube-specific operation logic isolated from shared components
- **Non-Invasive**: No changes required to `OperationButton` or Image Studio
- **Consistent UX**: Provides cost estimation and preflight checks like Image Studio
## Functions
### `buildVideoPlanningOperation(durationType, providerOverride?)`
Builds operation object for video plan generation.
**Token Estimates:**
- Shorts: 9000 tokens (includes scenes in one call)
- Medium: 6000 tokens
- Long: 7000 tokens
### `buildSceneBuildingOperation(durationType, hasPlan, providerOverride?)`
Builds operation object for scene generation.
**Token Estimates:**
- Shorts: 0 tokens (already included in planning)
- Medium: 6500 tokens (base + 1 batch enhancement)
- Long: 10000 tokens (base + 2 batch enhancements)
### `buildImageEditingOperation()`
Builds operation object for image editing (Make Presentable).
### `buildImageGenerationOperation(providerOverride?)`
Builds operation object for image generation (avatars/scenes).
## Usage
```typescript
import { buildVideoPlanningOperation } from '../utils/operationHelpers';
<OperationButton
operation={buildVideoPlanningOperation(durationType)}
label="Generate Video Plan"
onClick={handleGenerate}
showCost={true}
checkOnHover={true}
/>
```
## Operation Types
- `video_planning` - YouTube-specific operation type for plan generation
- `scene_building` - YouTube-specific operation type for scene generation
- `image_editing` - Shared operation type (used by Image Studio too)
- `image_generation` - Shared operation type (used by Image Studio too)
## Provider Mapping
- Default: `gemini` (most common)
- HuggingFace: Maps to `mistral` enum for usage tracking
- Backend will use actual provider from `GPT_PROVIDER` env var regardless of frontend estimate

View File

@@ -0,0 +1,149 @@
# Robustness Improvements
This document outlines the robustness improvements made to the YouTube Creator operation helpers and components.
## 1. Input Validation
### Duration Type Validation
- **Function**: `validateDurationType()`
- **Purpose**: Validates and normalizes duration type inputs
- **Features**:
- Handles `null`, `undefined`, and invalid string values
- Falls back to `'medium'` for invalid inputs
- Development-only warnings for invalid values
- Type-safe validation using `DURATION_TYPES` constant
### Token Count Validation
- **Function**: `validateTokenCount()`
- **Purpose**: Ensures token counts are non-negative integers
- **Features**:
- Rounds to nearest integer
- Clamps negative values to 0
- Prevents invalid token estimates
## 2. Provider Mapping
### Centralized Provider Logic
- **Functions**: `mapProviderToEnum()`, `getActualProviderName()`
- **Purpose**: Consistent provider name mapping
- **Features**:
- Normalizes provider strings (lowercase, trimmed)
- Maps HuggingFace/Mistral correctly
- Defaults to Gemini for unknown providers
- Separates enum value from display name
## 3. Type Safety
### Flexible Parameter Types
- All duration type parameters accept:
- `DurationType` (valid type)
- `string` (for runtime values from API)
- `null` / `undefined` (for optional values)
- Functions validate and normalize before use
### Type Guards
- Runtime validation ensures type safety
- Prevents runtime errors from invalid API responses
## 4. Performance Optimization
### Memoization
- **PlanStep.tsx**: Memoizes `videoPlanningOperation` and `imageEditingOperation`
- **ScenesStep.tsx**: Memoizes `sceneBuildingOperation`
- **Benefits**:
- Prevents unnecessary operation object recreation
- Reduces re-renders of `OperationButton`
- Improves performance on rapid state changes
### Dependency Tracking
- Memoization dependencies are minimal and correct:
- `videoPlanningOperation`: depends on `durationType` only
- `imageEditingOperation`: no dependencies (static)
- `sceneBuildingOperation`: depends on `videoPlan?.duration_type` and `videoPlan` existence
## 5. Error Handling
### Graceful Degradation
- Invalid inputs default to safe values
- No exceptions thrown for invalid data
- Development warnings help debugging
### Null Safety
- All functions handle `null`/`undefined` inputs
- Optional chaining used where appropriate
- Default values provided for missing data
## 6. Edge Cases Handled
### Duration Type Edge Cases
-`null` or `undefined` → defaults to `'medium'`
- ✅ Invalid string → defaults to `'medium'` with warning
- ✅ Empty string → defaults to `'medium'`
- ✅ Valid type → passes through unchanged
### Scene Building Edge Cases
- ✅ Shorts with plan → 0 tokens (already included)
- ✅ Shorts without plan → normal token estimate
- ✅ Missing `videoPlan` → defaults to `'medium'` duration
- ✅ Invalid `duration_type` in plan → validates and normalizes
### Provider Edge Cases
-`null`/`undefined` → defaults to `'gemini'`
-`'huggingface'` → maps to `'mistral'` enum
- ✅ Case-insensitive matching
- ✅ Whitespace trimming
## 7. Code Quality
### Documentation
- JSDoc comments for all public functions
- Parameter descriptions
- Return type documentation
- Usage examples in README
### Consistency
- Consistent naming conventions
- Consistent error handling patterns
- Consistent validation approach
### Maintainability
- Single responsibility functions
- Clear separation of concerns
- Easy to test and extend
## 8. Testing Considerations
### Testable Functions
- Pure functions (no side effects)
- Predictable outputs
- Easy to mock dependencies
### Test Cases to Consider
1. Valid duration types (shorts, medium, long)
2. Invalid duration types (null, undefined, invalid strings)
3. Provider mapping (gemini, huggingface, mistral)
4. Token estimation accuracy
5. Memoization behavior
6. Edge cases (empty plan, missing fields)
## 9. Backward Compatibility
### Non-Breaking Changes
- All changes are backward compatible
- Existing code continues to work
- New validation is additive only
### Migration Path
- No migration needed
- Gradual adoption possible
- Old code patterns still work
## 10. Future Improvements
### Potential Enhancements
1. Add unit tests for validation functions
2. Add integration tests for operation building
3. Consider adding operation caching
4. Add telemetry for invalid inputs
5. Consider provider detection from API response

View File

@@ -0,0 +1,197 @@
/**
* YouTube Creator Operation Helpers
*
* Provides utility functions to build operation objects for OperationButton
* with YouTube-specific operation types and token estimates.
*
* This module maintains separation of concerns by keeping YouTube-specific
* logic isolated from the shared OperationButton component.
*/
import { PreflightOperation } from '../../../services/billingService';
import { DurationType, DURATION_TYPES } from '../constants';
/**
* Validates and normalizes duration type.
*
* @param durationType - Duration type to validate
* @returns Valid DurationType or 'medium' as fallback
*/
function validateDurationType(durationType?: DurationType | string | null): DurationType {
if (!durationType) return 'medium';
if (DURATION_TYPES.includes(durationType as DurationType)) {
return durationType as DurationType;
}
// Log warning in development
if (process.env.NODE_ENV === 'development') {
console.warn(`[YouTube Creator] Invalid duration type: ${durationType}, defaulting to 'medium'`);
}
return 'medium';
}
/**
* Validates token count is non-negative.
*
* @param tokens - Token count to validate
* @returns Validated token count (0 if negative)
*/
function validateTokenCount(tokens: number): number {
return Math.max(0, Math.round(tokens));
}
/**
* Estimate token count for YouTube operations based on duration type.
*
* Estimates are based on backend analysis:
* - Video planning: ~6000-9000 tokens (varies by duration)
* - Scene building: ~6500-10000 tokens (varies by duration and enhancements)
*
* @param operationType - Type of operation
* @param durationType - Video duration type (validated and normalized)
* @returns Estimated token count (non-negative integer)
*/
function estimateYouTubeTokens(
operationType: 'video_planning' | 'scene_building',
durationType?: DurationType | string | null
): number {
const normalizedDuration = validateDurationType(durationType);
const baseEstimates = {
video_planning: {
shorts: 9000, // Includes scenes in one optimized call
medium: 6000, // Plan only
long: 7000, // Plan only (longer prompts)
},
scene_building: {
shorts: 0, // Already included in planning for shorts
medium: 6500, // Base generation + 1 batch enhancement
long: 10000, // Base generation + 2 batch enhancements
},
};
const tokens = baseEstimates[operationType][normalizedDuration];
return validateTokenCount(tokens);
}
/**
* Maps provider string to backend enum value.
*
* @param provider - Provider name (e.g., 'gemini', 'huggingface')
* @returns Backend enum value ('gemini' or 'mistral')
*/
function mapProviderToEnum(provider: string): 'gemini' | 'mistral' {
const normalized = provider.toLowerCase().trim();
if (normalized === 'huggingface' || normalized === 'mistral') {
return 'mistral';
}
return 'gemini'; // Default to gemini
}
/**
* Gets actual provider name for display/logging.
*
* @param provider - Provider name
* @returns Actual provider name string
*/
function getActualProviderName(provider: string): string {
const normalized = provider.toLowerCase().trim();
if (normalized === 'huggingface' || normalized === 'mistral') {
return 'huggingface';
}
return 'gemini';
}
/**
* Build operation object for video planning.
*
* @param durationType - Video duration type (affects token estimate, validated)
* @param providerOverride - Optional provider override (defaults to 'gemini')
* @returns PreflightOperation object for OperationButton
*/
export function buildVideoPlanningOperation(
durationType?: DurationType | string | null,
providerOverride?: string
): PreflightOperation {
// Default to gemini (most common provider)
// Backend will use actual provider from GPT_PROVIDER env var regardless
const provider = providerOverride || 'gemini';
const normalizedDuration = validateDurationType(durationType);
return {
provider: mapProviderToEnum(provider),
operation_type: 'video_planning',
tokens_requested: estimateYouTubeTokens('video_planning', normalizedDuration),
actual_provider_name: getActualProviderName(provider),
};
}
/**
* Build operation object for scene building.
*
* @param durationType - Video duration type (affects token estimate, validated)
* @param hasPlan - Whether plan already exists (affects if scenes are included in planning)
* @param providerOverride - Optional provider override (defaults to 'gemini')
* @returns PreflightOperation object for OperationButton
*/
export function buildSceneBuildingOperation(
durationType?: DurationType | string | null,
hasPlan: boolean = true,
providerOverride?: string
): PreflightOperation {
const normalizedDuration = validateDurationType(durationType);
const provider = providerOverride || 'gemini';
// For shorts, scenes are included in planning, so no separate operation needed
if (normalizedDuration === 'shorts' && hasPlan) {
// Return minimal operation (scenes already generated)
return {
provider: mapProviderToEnum(provider),
operation_type: 'scene_building',
tokens_requested: 0, // Already included in planning
actual_provider_name: getActualProviderName(provider),
};
}
return {
provider: mapProviderToEnum(provider),
operation_type: 'scene_building',
tokens_requested: estimateYouTubeTokens('scene_building', normalizedDuration),
actual_provider_name: getActualProviderName(provider),
};
}
/**
* Build operation object for image editing (Make Presentable).
*
* @returns PreflightOperation object for OperationButton
*/
export function buildImageEditingOperation(): PreflightOperation {
return {
provider: 'image_edit',
operation_type: 'image_editing',
tokens_requested: 0, // Image operations are not token-based
actual_provider_name: 'image_edit',
};
}
/**
* Build operation object for image generation (Avatar/Scene images).
*
* @param providerOverride - Optional provider override (defaults to 'stability')
* @returns PreflightOperation object for OperationButton
*/
export function buildImageGenerationOperation(
providerOverride?: string
): PreflightOperation {
// Default to stability (common image provider)
// Valid providers: 'stability', 'openai', 'anthropic', etc.
const provider = (providerOverride || 'stability').toLowerCase().trim();
return {
provider,
operation_type: 'image_generation',
tokens_requested: 0, // Image operations are not token-based
actual_provider_name: provider,
};
}

View File

@@ -0,0 +1,73 @@
/**
* Scene Helper Utilities
*
* Shared utility functions for scene-related operations across YouTube Creator components.
*/
import React from 'react';
import { Movie, CallMade, Shuffle, PlayArrow } from '@mui/icons-material';
/**
* Get icon component for scene emphasis type
*/
export const getSceneIcon = (emphasisTag: string, fontSize: 'small' | 'medium' = 'small'): React.ReactElement => {
switch (emphasisTag) {
case 'hook':
return <Movie fontSize={fontSize} />;
case 'cta':
return <CallMade fontSize={fontSize} />;
case 'transition':
return <Shuffle fontSize={fontSize} />;
case 'main_content':
default:
return <PlayArrow fontSize={fontSize} />;
}
};
/**
* Get color hex code for scene emphasis type
*/
export const getSceneColor = (emphasisTag: string): string => {
switch (emphasisTag) {
case 'hook':
return '#3b82f6'; // Blue
case 'cta':
return '#8b5cf6'; // Purple
case 'transition':
return '#10b981'; // Green
case 'main_content':
default:
return '#6b7280'; // Gray
}
};
/**
* Get human-readable label for scene type
*/
export const getSceneTypeLabel = (type: string): string => {
switch (type) {
case 'hook':
return 'Hook';
case 'cta':
return 'CTA';
case 'transition':
return 'Transition';
case 'main_content':
return 'Content';
default:
return type.charAt(0).toUpperCase() + type.slice(1);
}
};
/**
* Format duration in seconds to human-readable string
*/
export const formatDuration = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};

View File

@@ -0,0 +1,492 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Grid,
Box,
Typography,
TextField,
InputAdornment,
CircularProgress,
Card,
CardMedia,
CardContent,
IconButton,
Alert,
Tooltip,
Stack,
} from '@mui/material';
import {
Search,
Close,
CheckCircle,
Favorite,
FavoriteBorder,
Collections,
} from '@mui/icons-material';
import { useContentAssets, ContentAsset } from '../../hooks/useContentAssets';
import { fetchMediaBlobUrl } from '../../utils/fetchMediaBlobUrl';
export interface AssetLibraryImageModalProps {
open: boolean;
onClose: () => void;
onSelect: (asset: ContentAsset) => void;
title?: string;
sourceModule?: string | string[]; // Optional filter by source module(s) (e.g., 'youtube_creator', 'podcast_maker', or ['youtube_creator', 'podcast_maker'])
allowFavoritesOnly?: boolean; // Optional favorites-only filter toggle
}
/**
* Reusable modal to browse and pick images from the Asset Library.
* Image-only, with search and optional favorites/source filtering.
*/
export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
open,
onClose,
onSelect,
title = 'Select Image from Asset Library',
sourceModule,
allowFavoritesOnly = false,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedAsset, setSelectedAsset] = useState<ContentAsset | null>(null);
const [page, setPage] = useState(0);
const [favoritesOnly, setFavoritesOnly] = useState(false);
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set());
const pageSize = 24;
// Filter for images only
const filters = {
asset_type: 'image' as const,
source_module: sourceModule,
search: searchQuery || undefined,
favorites_only: allowFavoritesOnly && favoritesOnly ? true : undefined,
limit: pageSize,
offset: page * pageSize,
};
const { assets, loading, error, total, toggleFavorite, refetch } = useContentAssets(filters);
// Check if a URL requires authentication (internal API endpoints)
const isAuthenticatedUrl = useCallback((url: string): boolean => {
if (!url) return false;
return url.includes('/api/podcast/') ||
url.includes('/api/youtube/') ||
url.includes('/api/story/') ||
(url.startsWith('/') && !url.startsWith('//'));
}, []);
// Load blob URLs for authenticated images
useEffect(() => {
if (!open || assets.length === 0) {
// Clean up blob URLs when modal closes or no assets
setImageBlobUrls(prev => {
prev.forEach((url) => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
return new Map();
});
setLoadingImages(new Set());
return;
}
const loadBlobUrls = async () => {
const newBlobUrls = new Map<number, string>();
const newLoadingImages = new Set<number>();
for (const asset of assets) {
if (!asset.file_url) continue;
// Check if this is an authenticated endpoint
if (isAuthenticatedUrl(asset.file_url)) {
newLoadingImages.add(asset.id);
try {
const blobUrl = await fetchMediaBlobUrl(asset.file_url);
if (blobUrl) {
newBlobUrls.set(asset.id, blobUrl);
}
} catch (err) {
console.error(`[AssetLibraryImageModal] Failed to load image for asset ${asset.id}:`, err);
} finally {
newLoadingImages.delete(asset.id);
}
} else {
// External URL, use directly
newBlobUrls.set(asset.id, asset.file_url);
}
}
setImageBlobUrls(prev => {
// Clean up old blob URLs that are no longer needed
prev.forEach((url, id) => {
if (!newBlobUrls.has(id) && url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
return newBlobUrls;
});
setLoadingImages(newLoadingImages);
};
loadBlobUrls();
// Cleanup function
return () => {
// Don't clean up here - let the next effect handle it
};
}, [assets, open, isAuthenticatedUrl]);
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
imageBlobUrls.forEach((url) => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
};
}, []);
const handleSelect = useCallback(() => {
if (selectedAsset) {
onSelect(selectedAsset);
handleClose();
}
}, [selectedAsset, onSelect]);
const handleClose = useCallback(() => {
onClose();
setSelectedAsset(null);
setSearchQuery('');
setPage(0);
setFavoritesOnly(false);
}, [onClose]);
const handleAssetClick = useCallback((asset: ContentAsset) => {
setSelectedAsset(asset);
}, []);
const handleFavoriteToggle = useCallback(
async (assetId: number, e: React.MouseEvent) => {
e.stopPropagation();
try {
await toggleFavorite(assetId);
refetch();
} catch (err) {
console.error('Error toggling favorite:', err);
}
},
[toggleFavorite, refetch]
);
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
borderRadius: 2,
maxHeight: '90vh',
backgroundColor: '#ffffff',
},
}}
>
<DialogTitle>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack direction="row" spacing={1} alignItems="center">
<Collections sx={{ color: '#FF0000' }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: '#111827' }}>
{title}
</Typography>
</Stack>
<IconButton onClick={handleClose} size="small" sx={{ color: '#6b7280' }}>
<Close />
</IconButton>
</Stack>
</DialogTitle>
<DialogContent dividers sx={{ backgroundColor: '#f9fafb' }}>
{/* Search and Filters */}
<Box sx={{ mb: 3 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
<TextField
fullWidth
placeholder="Search images by title, description, or tags..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(0);
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search sx={{ color: '#9ca3af' }} />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#ffffff',
'& fieldset': {
borderColor: '#d1d5db',
},
},
}}
/>
{allowFavoritesOnly && (
<Button
variant={favoritesOnly ? 'contained' : 'outlined'}
startIcon={<Favorite />}
onClick={() => {
setFavoritesOnly(!favoritesOnly);
setPage(0);
}}
sx={{
minWidth: 160,
borderColor: '#d1d5db',
color: favoritesOnly ? '#ffffff' : '#6b7280',
bgcolor: favoritesOnly ? '#ef4444' : 'transparent',
'&:hover': {
borderColor: '#9ca3af',
bgcolor: favoritesOnly ? '#dc2626' : '#f9fafb',
},
}}
>
{favoritesOnly ? 'Favorites' : 'All Images'}
</Button>
)}
</Stack>
<Typography variant="body2" sx={{ color: '#6b7280', mt: 1.5 }}>
{loading
? 'Loading...'
: total > 0
? `${total} image${total !== 1 ? 's' : ''} found`
: 'No images found'}
</Typography>
</Box>
{/* Error State */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* Loading State */}
{loading && assets.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : assets.length === 0 ? (
/* Empty State */
<Box sx={{ textAlign: 'center', py: 8 }}>
<Collections sx={{ fontSize: 64, color: '#d1d5db', mb: 2 }} />
<Typography variant="h6" sx={{ color: '#6b7280', mb: 1 }}>
{searchQuery ? 'No images found matching your search.' : 'No images in your asset library yet.'}
</Typography>
<Typography variant="body2" sx={{ color: '#9ca3af' }}>
{searchQuery ? 'Try a different search term.' : 'Generate some images first to see them here.'}
</Typography>
</Box>
) : (
/* Image Grid */
<Box
sx={{
maxHeight: 'calc(90vh - 280px)',
overflowY: 'auto',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: '#f1f5f9',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: '#cbd5e1',
borderRadius: '4px',
'&:hover': {
backgroundColor: '#94a3b8',
},
},
}}
>
<Grid container spacing={2}>
{assets.map((asset) => (
<Grid item xs={6} sm={4} md={3} key={asset.id}>
<Card
sx={{
cursor: 'pointer',
position: 'relative',
border: selectedAsset?.id === asset.id ? '2px solid #FF0000' : '1px solid #e5e7eb',
borderRadius: 2,
overflow: 'hidden',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: 4,
borderColor: selectedAsset?.id === asset.id ? '#FF0000' : '#9ca3af',
transform: 'translateY(-2px)',
},
}}
onClick={() => handleAssetClick(asset)}
>
{/* Image */}
<Box sx={{ position: 'relative', paddingTop: '100%' }}>
{loadingImages.has(asset.id) ? (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#f3f4f6',
}}
>
<CircularProgress size={24} />
</Box>
) : (
<CardMedia
component="img"
image={imageBlobUrls.get(asset.id) || asset.file_url}
alt={asset.title || 'Asset'}
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
bgcolor: '#f3f4f6', // Fallback background while loading
}}
onError={(e) => {
// Fallback if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
)}
{/* Selected Indicator */}
{selectedAsset?.id === asset.id && (
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
bgcolor: '#FF0000',
borderRadius: '50%',
p: 0.5,
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
>
<CheckCircle sx={{ color: 'white', fontSize: 20 }} />
</Box>
)}
{/* Favorite Button */}
<Tooltip title={asset.is_favorite ? 'Remove from favorites' : 'Add to favorites'}>
<IconButton
size="small"
sx={{
position: 'absolute',
top: 8,
left: 8,
bgcolor: 'rgba(255, 255, 255, 0.9)',
'&:hover': { bgcolor: 'white' },
transition: 'all 0.2s',
}}
onClick={(e) => handleFavoriteToggle(asset.id, e)}
>
{asset.is_favorite ? (
<Favorite sx={{ color: '#ef4444', fontSize: 18 }} />
) : (
<FavoriteBorder sx={{ color: '#6b7280', fontSize: 18 }} />
)}
</IconButton>
</Tooltip>
</Box>
{/* Title */}
{asset.title && (
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography
variant="caption"
sx={{
display: 'block',
fontWeight: 500,
color: '#111827',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={asset.title}
>
{asset.title}
</Typography>
{asset.source_module && (
<Typography
variant="caption"
sx={{
display: 'block',
color: '#6b7280',
fontSize: '0.7rem',
mt: 0.25,
}}
>
{asset.source_module.replace('_', ' ')}
</Typography>
)}
</CardContent>
)}
</Card>
</Grid>
))}
</Grid>
{/* Load More (if needed) */}
{total > (page + 1) * pageSize && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Button variant="outlined" onClick={() => setPage(page + 1)} disabled={loading}>
{loading ? <CircularProgress size={20} /> : 'Load More'}
</Button>
</Box>
)}
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2, backgroundColor: '#ffffff', borderTop: '1px solid #e5e7eb' }}>
<Button onClick={handleClose} sx={{ color: '#6b7280' }}>
Cancel
</Button>
<Button
variant="contained"
color="error"
onClick={handleSelect}
disabled={!selectedAsset}
startIcon={selectedAsset ? <CheckCircle /> : undefined}
sx={{
minWidth: 140,
'&:disabled': {
backgroundColor: '#e5e7eb',
color: '#9ca3af',
},
}}
>
Select Image
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useMemo, useRef } from 'react';
import {
Button,
ButtonProps,
@@ -129,9 +129,13 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
}, [label, formattedCost]);
// Determine if button should be disabled
// NOTE: We do NOT disable when canProceed === false to allow users to click and see subscription modal
// The API call will return 429, which triggers the subscription modal via global error handler
const isDisabled = useMemo(() => {
return externalDisabled || externalLoading || preflightLoading || (canProceed !== null && !canProceed);
}, [externalDisabled, externalLoading, preflightLoading, canProceed]);
return externalDisabled || externalLoading || preflightLoading;
// Removed: || (canProceed !== null && !canProceed)
// This allows users to click even when limits are exceeded, so they can see subscription modal
}, [externalDisabled, externalLoading, preflightLoading]);
// Build tooltip content
const tooltipContent = useMemo(() => {
@@ -187,16 +191,41 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
return content.length > 0 ? <Box sx={{ p: 0.5 }}>{content}</Box> : null;
}, [canProceed, estimatedCost, formattedCost, limitInfo, preflightError, preflightLoading]);
// Handle hover
// Debounce hover checks to prevent excessive API calls
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastCheckTimeRef = useRef<number>(0);
const MIN_CHECK_INTERVAL = 5000; // Only check once every 5 seconds max
// Handle hover with debouncing
const handleMouseEnter = () => {
if (checkOnHover) {
const now = Date.now();
const timeSinceLastCheck = now - lastCheckTimeRef.current;
// If we checked recently, skip (use cache)
if (timeSinceLastCheck < MIN_CHECK_INTERVAL) {
return;
}
// Clear any existing timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// Debounce the check by 300ms to prevent rapid-fire calls
hoverTimeoutRef.current = setTimeout(() => {
triggerCheck(operation);
lastCheckTimeRef.current = Date.now();
}, 300);
}
};
// Handle click
// Allow clicks even when canProceed === false to let users see subscription modal
// The API will return 429, which triggers the subscription modal via global error handler
const handleClick = () => {
if (!isDisabled && (canProceed === null || canProceed)) {
if (!isDisabled) {
// Always allow click - if limits are exceeded, API will return 429 and show modal
onClick();
}
};
@@ -210,21 +239,20 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
}, [canProceed, color]);
// Determine if we should show loading spinner
const showLoading = externalLoading || (preflightLoading && checkOnMount);
// Only show spinner for external loading (actual operation), not for preflight checks
const showLoading = externalLoading;
// Custom label override for loading state
const displayLabel = useMemo(() => {
if (externalLoading && buttonProps?.children) {
return buttonProps.children;
}
if (showLoading && !externalLoading) {
return 'Checking...';
}
if (canProceed !== null && !canProceed && preflightError) {
return preflightError;
}
// Don't show "Checking..." during preflight - keep label stable with cost
// Preflight loading is handled by spinner in icon position only
// Note: We don't override label when canProceed === false to keep button clickable
// The tooltip will show the limit info, and clicking will trigger subscription modal
return buttonLabel;
}, [externalLoading, showLoading, canProceed, preflightError, buttonLabel, buttonProps?.children]);
}, [externalLoading, buttonLabel, buttonProps?.children]);
// Build button with icon
const button = (
@@ -235,6 +263,8 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
startIcon={
showLoading ? (
<CircularProgress size={16} color="inherit" />
) : (preflightLoading && checkOnMount) ? (
<CircularProgress size={16} color="inherit" />
) : (canProceed !== null && !canProceed) ? (
<WarningIcon fontSize="small" />
) : (
@@ -253,6 +283,15 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
</Button>
);
// Cleanup timeout on unmount
React.useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
// Wrap with tooltip if we have content
if (tooltipContent || checkOnHover) {
return (

View File

@@ -14,4 +14,8 @@ export * from './styled';
export * from './types';
// Shared utilities
export * from './utils';
export * from './utils';
// Asset Library modal (images only)
export { AssetLibraryImageModal } from './AssetLibraryImageModal';
export type { AssetLibraryImageModalProps } from './AssetLibraryImageModal';