Added YouTube Creator scene building flow documentation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
272
frontend/src/components/YouTubeCreator/components/AvatarCard.tsx
Normal file
272
frontend/src/components/YouTubeCreator/components/AvatarCard.tsx
Normal 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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
61
frontend/src/components/YouTubeCreator/utils/README.md
Normal file
61
frontend/src/components/YouTubeCreator/utils/README.md
Normal 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
|
||||
|
||||
149
frontend/src/components/YouTubeCreator/utils/ROBUSTNESS.md
Normal file
149
frontend/src/components/YouTubeCreator/utils/ROBUSTNESS.md
Normal 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
|
||||
|
||||
197
frontend/src/components/YouTubeCreator/utils/operationHelpers.ts
Normal file
197
frontend/src/components/YouTubeCreator/utils/operationHelpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
492
frontend/src/components/shared/AssetLibraryImageModal.tsx
Normal file
492
frontend/src/components/shared/AssetLibraryImageModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user