AI Image Studio, AI podcast Maker, AI product Marketing

This commit is contained in:
ajaysi
2025-11-28 14:33:52 +05:30
parent 77d7c0cde6
commit 49e2131715
122 changed files with 22311 additions and 4331 deletions

View File

@@ -13,6 +13,9 @@ import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
import BlogWriter from './components/BlogWriter/BlogWriter';
import StoryWriter from './components/StoryWriter/StoryWriter';
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
import { ProductMarketingDashboard } from './components/ProductMarketing';
import { ProductPhotoshootStudio } from './components/ProductMarketing/ProductPhotoshootStudio';
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
import PricingPage from './components/Pricing/PricingPage';
import WixTestPage from './components/WixTestPage/WixTestPage';
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
@@ -451,13 +454,17 @@ const App: React.FC = () => {
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />
<Route path="/image-editor" element={<ProtectedRoute><EditStudio /></ProtectedRoute>} />
<Route path="/image-upscale" element={<ProtectedRoute><UpscaleStudio /></ProtectedRoute>} />
<Route path="/image-control" element={<ProtectedRoute><ControlStudio /></ProtectedRoute>} />
<Route path="/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
<Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} />
<Route path="/campaign-creator/photoshoot" element={<ProtectedRoute><ProductPhotoshootStudio /></ProtectedRoute>} />
<Route path="/product-marketing" element={<Navigate to="/campaign-creator" replace />} />
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
<Route path="/pricing" element={<PricingPage />} />

View File

@@ -55,11 +55,13 @@ import {
MoreVert,
Upload,
CalendarToday,
FilterList,
CheckCircle,
HourglassEmpty,
Error as ErrorIcon,
Refresh,
Warning,
ExpandMore,
ExpandLess,
} from '@mui/icons-material';
import { ImageStudioLayout } from './ImageStudioLayout';
import { useContentAssets, AssetFilters, ContentAsset } from '../../hooks/useContentAssets';
@@ -111,6 +113,7 @@ const getStatusChip = (status: string) => {
color: style.color,
fontWeight: 600,
textTransform: 'capitalize',
height: 28,
}}
/>
);
@@ -135,6 +138,7 @@ export const AssetLibrary: React.FC = () => {
message: '',
severity: 'success',
});
const [textPreviews, setTextPreviews] = useState<{ [key: number]: { content: string; loading: boolean; expanded: boolean } }>({});
// Debounce search query
useEffect(() => {
@@ -301,13 +305,59 @@ export const AssetLibrary: React.FC = () => {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
const timezoneOffset = -date.getTimezoneOffset();
const offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0');
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');
const offsetSign = timezoneOffset >= 0 ? '+' : '-';
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} GMT${offsetSign}${offsetHours}.${offsetMinutes}`;
} catch {
return dateString;
}
};
const getAssetPreview = (asset: ContentAsset) => {
// Fetch text content for text assets
const fetchTextContent = async (asset: ContentAsset) => {
if (asset.asset_type !== 'text' || textPreviews[asset.id]) return;
setTextPreviews(prev => ({ ...prev, [asset.id]: { content: '', loading: true, expanded: false } }));
try {
const token = await (window as any).Clerk?.session?.getToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(asset.file_url, { headers });
if (response.ok) {
const content = await response.text();
setTextPreviews(prev => ({ ...prev, [asset.id]: { content, loading: false, expanded: false } }));
} else {
throw new Error('Failed to fetch text content');
}
} catch (error) {
console.error('Error fetching text content:', error);
setTextPreviews(prev => ({
...prev,
[asset.id]: { content: 'Failed to load content', loading: false, expanded: false }
}));
}
};
const toggleTextPreview = (asset: ContentAsset) => {
if (asset.asset_type !== 'text') return;
if (!textPreviews[asset.id]) {
fetchTextContent(asset);
} else {
setTextPreviews(prev => ({
...prev,
[asset.id]: { ...prev[asset.id], expanded: !prev[asset.id].expanded }
}));
}
};
const getAssetPreview = (asset: ContentAsset, isListView: boolean = false) => {
if (asset.asset_type === 'image') {
return (
<Box
@@ -320,7 +370,9 @@ export const AssetLibrary: React.FC = () => {
objectFit: 'cover',
borderRadius: 1,
border: '1px solid rgba(255,255,255,0.1)',
cursor: 'pointer',
}}
onClick={() => window.open(asset.file_url, '_blank')}
/>
);
} else if (asset.asset_type === 'video') {
@@ -335,7 +387,9 @@ export const AssetLibrary: React.FC = () => {
alignItems: 'center',
justifyContent: 'center',
border: '1px solid rgba(255,255,255,0.1)',
cursor: 'pointer',
}}
onClick={() => window.open(asset.file_url, '_blank')}
>
<VideoLibrary sx={{ color: '#c7d2fe', fontSize: 32 }} />
</Box>
@@ -352,11 +406,114 @@ export const AssetLibrary: React.FC = () => {
alignItems: 'center',
justifyContent: 'center',
border: '1px solid rgba(255,255,255,0.1)',
cursor: 'pointer',
}}
onClick={() => window.open(asset.file_url, '_blank')}
>
<AudioFile sx={{ color: '#93c5fd', fontSize: 32 }} />
</Box>
);
} else if (asset.asset_type === 'text') {
const preview = textPreviews[asset.id];
const previewText = preview?.content || '';
const lines = previewText.split('\n');
const previewLines = lines.slice(0, 2).join('\n');
const hasMore = lines.length > 2 || previewText.length > 100;
return (
<Box
sx={{
width: isListView ? 'auto' : 80,
minHeight: isListView ? 'auto' : 80,
maxWidth: isListView ? 300 : 80,
borderRadius: 1,
background: 'rgba(107,114,128,0.2)',
border: '1px solid rgba(255,255,255,0.1)',
cursor: 'pointer',
p: isListView ? 1.5 : 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}
onClick={(e) => {
e.stopPropagation();
toggleTextPreview(asset);
}}
>
{preview?.loading ? (
<CircularProgress size={20} sx={{ m: 'auto' }} />
) : preview?.expanded ? (
<Box sx={{
flex: 1,
overflow: 'auto',
fontSize: isListView ? '0.8rem' : '0.7rem',
color: '#d1d5db',
maxHeight: isListView ? 200 : 150,
}}>
<Typography
variant="caption"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: isListView ? 'monospace' : 'inherit',
lineHeight: 1.5,
}}
>
{previewText.substring(0, isListView ? 1000 : 500)}
{previewText.length > (isListView ? 1000 : 500) && '...'}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleTextPreview(asset);
}}
sx={{ position: 'absolute', bottom: 4, right: 4, p: 0.5 }}
>
<ExpandLess sx={{ fontSize: 16, color: '#d1d5db' }} />
</IconButton>
</Box>
) : (
<>
<TextFields sx={{ color: '#d1d5db', fontSize: isListView ? 28 : 24, mb: 0.5 }} />
{previewText ? (
<Typography
variant="caption"
sx={{
fontSize: isListView ? '0.75rem' : '0.65rem',
color: '#9ca3af',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: isListView ? 3 : 2,
WebkitBoxOrient: 'vertical',
lineHeight: 1.3,
mb: 0.5,
}}
>
{previewLines || previewText.substring(0, 100)}
</Typography>
) : (
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#9ca3af' }}>
Click to preview
</Typography>
)}
{hasMore && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleTextPreview(asset);
}}
sx={{ position: 'absolute', bottom: 4, right: 4, p: 0.5 }}
>
<ExpandMore sx={{ fontSize: 16, color: '#d1d5db' }} />
</IconButton>
)}
</>
)}
</Box>
);
} else {
return (
<Box
@@ -369,7 +526,9 @@ export const AssetLibrary: React.FC = () => {
alignItems: 'center',
justifyContent: 'center',
border: '1px solid rgba(255,255,255,0.1)',
cursor: 'pointer',
}}
onClick={() => window.open(asset.file_url, '_blank')}
>
<TextFields sx={{ color: '#d1d5db', fontSize: 32 }} />
</Box>
@@ -377,11 +536,17 @@ export const AssetLibrary: React.FC = () => {
}
};
const getModelName = (asset: ContentAsset) => {
if (asset.model) return asset.model;
if (asset.provider) return `${asset.provider}/${asset.source_module.replace('_', ' ')}`;
return asset.source_module.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
};
const filteredAssets = useMemo(() => {
let filtered = assets;
if (statusFilter !== 'all') {
filtered = filtered.filter(a => (a.metadata?.status || 'completed') === statusFilter);
filtered = filtered.filter(a => (a.asset_metadata?.status || 'completed') === statusFilter);
}
if (dateFilter) {
@@ -432,7 +597,7 @@ export const AssetLibrary: React.FC = () => {
{/* Reminder Banner */}
<Alert
severity="warning"
icon={<Star />}
icon={<Warning />}
sx={{
background: 'rgba(245,158,11,0.1)',
border: '1px solid rgba(245,158,11,0.3)',
@@ -637,7 +802,15 @@ export const AssetLibrary: React.FC = () => {
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={() => refetch()}
onClick={() => {
refetch();
setIdSearch('');
setModelSearch('');
setDateFilter('');
setStatusFilter('all');
setFilterType('all');
setSelectedAssets(new Set());
}}
sx={{ ml: 'auto', textTransform: 'none' }}
>
Reset
@@ -773,11 +946,11 @@ export const AssetLibrary: React.FC = () => {
'&:hover': { textDecoration: 'underline' },
}}
>
{asset.model || asset.provider || asset.source_module.replace(/_/g, ' ')}
{getModelName(asset)}
</Typography>
</TableCell>
<TableCell>{getStatusChip(asset.metadata?.status || 'completed')}</TableCell>
<TableCell>{getAssetPreview(asset)}</TableCell>
<TableCell>{getStatusChip(asset.asset_metadata?.status || 'completed')}</TableCell>
<TableCell>{getAssetPreview(asset, true)}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.875rem' }}>
{formatDate(asset.created_at)}
@@ -885,6 +1058,67 @@ export const AssetLibrary: React.FC = () => {
objectFit: 'cover',
}}
/>
) : asset.asset_type === 'text' ? (
<Box
sx={{
width: '100%',
height: '100%',
p: 2,
background: 'rgba(107,114,128,0.2)',
color: '#d1d5db',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{textPreviews[asset.id]?.loading ? (
<CircularProgress size={24} sx={{ m: 'auto' }} />
) : textPreviews[asset.id]?.expanded ? (
<>
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
flex: 1,
fontFamily: 'monospace',
fontSize: '0.875rem',
lineHeight: 1.6,
}}
>
{textPreviews[asset.id].content}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleTextPreview(asset);
}}
sx={{ alignSelf: 'flex-end', mt: 1 }}
>
<ExpandLess />
</IconButton>
</>
) : (
<>
<TextFields sx={{ fontSize: 48, mb: 1, opacity: 0.7 }} />
<Typography variant="body2" sx={{ textAlign: 'center', mb: 1 }}>
Text Content
</Typography>
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.stopPropagation();
toggleTextPreview(asset);
}}
sx={{ mt: 'auto' }}
>
Preview
</Button>
</>
)}
</Box>
) : (
<Box
sx={{
@@ -897,7 +1131,7 @@ export const AssetLibrary: React.FC = () => {
color: '#c7d2fe',
}}
>
{asset.asset_type === 'audio' ? <AudioFile /> : <TextFields />}
{getAssetIcon(asset.asset_type)}
</Box>
)}
<Box
@@ -944,7 +1178,7 @@ export const AssetLibrary: React.FC = () => {
{asset.title || asset.filename}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mb: 1 }}>
{getStatusChip(asset.metadata?.status || 'completed')}
{getStatusChip(asset.asset_metadata?.status || 'completed')}
<Chip
label={asset.asset_type}
size="small"
@@ -1029,3 +1263,18 @@ export const AssetLibrary: React.FC = () => {
</ImageStudioLayout>
);
};
const getAssetIcon = (assetType: string) => {
switch (assetType) {
case 'image':
return <ImageIcon />;
case 'video':
return <VideoLibrary />;
case 'audio':
return <AudioFile />;
case 'text':
return <TextFields />;
default:
return <ImageIcon />;
}
};

View File

@@ -3,6 +3,9 @@ import { Box } from '@mui/material';
import { motion } from 'framer-motion';
import type { Variants } from 'framer-motion';
import DashboardHeader from '../shared/DashboardHeader';
import type { DashboardHeaderProps } from '../shared/types';
const MotionBox = motion(Box);
const sparkleVariants: Variants = {
@@ -20,57 +23,81 @@ const sparkleVariants: Variants = {
interface ImageStudioLayoutProps {
children: React.ReactNode;
showHeader?: boolean;
headerProps?: DashboardHeaderProps;
}
export const ImageStudioLayout: React.FC<ImageStudioLayoutProps> = ({ children }) => (
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
py: 4,
px: 2,
position: 'relative',
overflow: 'hidden',
}}
>
<Box
sx={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 0,
}}
>
{[...Array(20)].map((_, i) => (
<MotionBox
key={i}
variants={sparkleVariants}
initial="initial"
animate="animate"
transition={{ delay: i * 0.1 }}
sx={{
position: 'absolute',
width: 4,
height: 4,
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.6)',
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
}}
/>
))}
</Box>
const defaultHeaderProps: DashboardHeaderProps = {
title: 'AI Image Studio',
subtitle:
'One hub for every visual workflow: generate, edit, upscale, transform, optimize, and manage assets built for content and marketing teams.',
};
export const ImageStudioLayout: React.FC<ImageStudioLayoutProps> = ({
children,
showHeader = true,
headerProps,
}) => {
const mergedHeaderProps = {
...defaultHeaderProps,
...headerProps,
};
return (
<Box
sx={{
maxWidth: 1400,
mx: 'auto',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
py: 4,
px: 2,
position: 'relative',
zIndex: 1,
overflow: 'hidden',
}}
>
{children}
</Box>
</Box>
);
<Box
sx={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 0,
}}
>
{[...Array(20)].map((_, i) => (
<MotionBox
key={i}
variants={sparkleVariants}
initial="initial"
animate="animate"
transition={{ delay: i * 0.1 }}
sx={{
position: 'absolute',
width: 4,
height: 4,
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.6)',
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
}}
/>
))}
</Box>
<Box
sx={{
maxWidth: 1400,
mx: 'auto',
position: 'relative',
zIndex: 1,
}}
>
{showHeader && (
<Box sx={{ mb: 3 }}>
<DashboardHeader {...mergedHeaderProps} />
</Box>
)}
{children}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,730 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Grid,
Paper,
Stack,
Typography,
TextField,
Button,
Alert,
Select,
MenuItem,
FormControl,
InputLabel,
Tabs,
Tab,
Card,
CardContent,
CardMedia,
IconButton,
Tooltip,
LinearProgress,
Chip,
Divider,
} from '@mui/material';
import { alpha } from '@mui/material/styles';
import {
Transform as TransformIcon,
VideoLibrary,
Upload,
PlayArrow,
Download,
AttachMoney,
Info,
Close,
} from '@mui/icons-material';
import { motion, type Variants, type Easing } from 'framer-motion';
import { useTransformStudio } from '../../hooks/useTransformStudio';
import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
const MotionPaper = motion(Paper);
const MotionCard = motion(Card);
const fadeEase: Easing = [0.4, 0, 0.2, 1];
const cardVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: fadeEase },
},
};
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`transform-tabpanel-${index}`}
aria-labelledby={`transform-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
export const TransformStudio: React.FC = () => {
const [tabValue, setTabValue] = useState(0);
const [imageBase64, setImageBase64] = useState<string>('');
const [audioBase64, setAudioBase64] = useState<string>('');
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [resolution, setResolution] = useState<'480p' | '720p' | '1080p'>('720p');
const [duration, setDuration] = useState<5 | 10>(5);
const [seed, setSeed] = useState<string>('');
const [enablePromptExpansion, setEnablePromptExpansion] = useState(true);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const {
isGenerating,
error,
result,
costEstimate,
transformImageToVideo,
createTalkingAvatar,
estimateCost,
clearError,
clearResult,
} = useTransformStudio();
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
clearError();
clearResult();
setVideoUrl(null);
};
const handleImageUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please upload an image file');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setImageBase64(result);
};
reader.readAsDataURL(file);
}, []);
const handleAudioUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('audio/')) {
alert('Please upload an audio file (wav or mp3)');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setAudioBase64(result);
};
reader.readAsDataURL(file);
}, []);
const canGenerateImageToVideo = useMemo(() => {
return imageBase64 && prompt.trim().length > 0;
}, [imageBase64, prompt]);
const canGenerateTalkingAvatar = useMemo(() => {
return imageBase64 && audioBase64;
}, [imageBase64, audioBase64]);
const handleEstimateCost = useCallback(async () => {
if (tabValue === 0) {
// Image-to-video
if (!canGenerateImageToVideo) return;
await estimateCost({
operation: 'image-to-video',
resolution,
duration,
});
} else {
// Talking avatar
if (!canGenerateTalkingAvatar) return;
await estimateCost({
operation: 'talking-avatar',
resolution: resolution as '480p' | '720p',
});
}
}, [tabValue, canGenerateImageToVideo, canGenerateTalkingAvatar, resolution, duration, estimateCost]);
const handleGenerate = useCallback(async () => {
clearError();
clearResult();
setVideoUrl(null);
try {
if (tabValue === 0) {
// Image-to-video
const response = await transformImageToVideo({
image_base64: imageBase64,
prompt,
audio_base64: audioBase64 || undefined,
resolution,
duration,
negative_prompt: negativePrompt || undefined,
seed: seed ? parseInt(seed) : undefined,
enable_prompt_expansion: enablePromptExpansion,
});
if (response.video_url) {
// Get auth token for video URL (video elements can't use headers)
const token = await (window as any).Clerk?.session?.getToken();
const baseUrl = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8000';
const videoUrlWithToken = token
? `${baseUrl}${response.video_url}?token=${encodeURIComponent(token)}`
: `${baseUrl}${response.video_url}`;
setVideoUrl(videoUrlWithToken);
}
} else {
// Talking avatar
const response = await createTalkingAvatar({
image_base64: imageBase64,
audio_base64: audioBase64,
resolution: resolution as '480p' | '720p',
prompt: prompt || undefined,
seed: seed ? parseInt(seed) : undefined,
});
if (response.video_url) {
// Get auth token for video URL (video elements can't use headers)
const token = await (window as any).Clerk?.session?.getToken();
const baseUrl = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8000';
const videoUrlWithToken = token
? `${baseUrl}${response.video_url}?token=${encodeURIComponent(token)}`
: `${baseUrl}${response.video_url}`;
setVideoUrl(videoUrlWithToken);
}
}
} catch (err) {
// Error is handled by the hook
console.error('Generation failed:', err);
}
}, [
tabValue,
imageBase64,
audioBase64,
prompt,
negativePrompt,
resolution,
duration,
seed,
enablePromptExpansion,
transformImageToVideo,
createTalkingAvatar,
clearError,
clearResult,
]);
const handleDownload = useCallback(() => {
if (videoUrl) {
window.open(videoUrl, '_blank');
}
}, [videoUrl]);
return (
<ImageStudioLayout>
<MotionPaper
elevation={0}
variants={cardVariants}
initial="hidden"
animate="visible"
sx={{
maxWidth: 1400,
mx: 'auto',
borderRadius: 4,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.72)',
p: { xs: 3, md: 5 },
backdropFilter: 'blur(25px)',
}}
>
<Stack spacing={3}>
<Box>
<Typography
variant="h4"
fontWeight={800}
sx={{
background: 'linear-gradient(120deg,#ede9fe,#c7d2fe)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: 1,
}}
>
Transform Studio
</Typography>
<Typography variant="body2" color="text.secondary">
Convert images into videos, talking avatars, and more
</Typography>
</Box>
<Tabs
value={tabValue}
onChange={handleTabChange}
sx={{
borderBottom: 1,
borderColor: 'divider',
'& .MuiTab-root': {
color: 'text.secondary',
'&.Mui-selected': {
color: 'primary.main',
},
},
}}
>
<Tab label="Image to Video" icon={<VideoLibrary />} iconPosition="start" />
<Tab label="Talking Avatar" icon={<TransformIcon />} iconPosition="start" />
</Tabs>
{error && (
<Alert severity="error" onClose={clearError}>
{error}
</Alert>
)}
{/* Image-to-Video Tab */}
<TabPanel value={tabValue} index={0}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<MotionCard variants={cardVariants} sx={{ p: 3, background: 'rgba(255,255,255,0.05)' }}>
<Stack spacing={3}>
<Typography variant="h6" fontWeight={600}>
Upload Image
</Typography>
<Box>
<input
accept="image/*"
style={{ display: 'none' }}
id="image-upload"
type="file"
onChange={handleImageUpload}
/>
<label htmlFor="image-upload">
<Button
variant="outlined"
component="span"
startIcon={<Upload />}
fullWidth
sx={{ py: 2 }}
>
{imageBase64 ? 'Change Image' : 'Upload Image'}
</Button>
</label>
{imageBase64 && (
<Box sx={{ mt: 2 }}>
<CardMedia
component="img"
image={imageBase64}
alt="Uploaded image"
sx={{
maxHeight: 300,
objectFit: 'contain',
borderRadius: 2,
}}
/>
</Box>
)}
</Box>
<TextField
label="Video Prompt"
multiline
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe what should happen in the video..."
fullWidth
required
/>
<Box>
<input
accept="audio/*"
style={{ display: 'none' }}
id="audio-upload"
type="file"
onChange={handleAudioUpload}
/>
<label htmlFor="audio-upload">
<Button
variant="outlined"
component="span"
startIcon={<Upload />}
fullWidth
sx={{ py: 1.5 }}
>
{audioBase64 ? 'Change Audio (Optional)' : 'Upload Audio (Optional)'}
</Button>
</label>
</Box>
<TextField
label="Negative Prompt (Optional)"
multiline
rows={2}
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
placeholder="What to avoid in the video..."
fullWidth
/>
<Grid container spacing={2}>
<Grid item xs={6}>
<FormControl fullWidth>
<InputLabel>Resolution</InputLabel>
<Select
value={resolution}
label="Resolution"
onChange={(e) => setResolution(e.target.value as any)}
>
<MenuItem value="480p">480p</MenuItem>
<MenuItem value="720p">720p</MenuItem>
<MenuItem value="1080p">1080p</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={6}>
<FormControl fullWidth>
<InputLabel>Duration</InputLabel>
<Select
value={duration}
label="Duration"
onChange={(e) => setDuration(e.target.value as 5 | 10)}
>
<MenuItem value={5}>5 seconds</MenuItem>
<MenuItem value={10}>10 seconds</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<TextField
label="Seed (Optional)"
value={seed}
onChange={(e) => setSeed(e.target.value)}
placeholder="Random seed for reproducibility"
fullWidth
/>
</Stack>
</MotionCard>
</Grid>
<Grid item xs={12} md={6}>
<MotionCard variants={cardVariants} sx={{ p: 3, background: 'rgba(255,255,255,0.05)' }}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" fontWeight={600}>
Preview & Generate
</Typography>
{costEstimate && (
<Chip
icon={<AttachMoney />}
label={`$${costEstimate.estimated_cost.toFixed(2)}`}
color="primary"
variant="outlined"
/>
)}
</Box>
{isGenerating && (
<Box>
<LinearProgress />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, textAlign: 'center' }}>
Generating video... This may take 1-2 minutes.
</Typography>
</Box>
)}
{videoUrl && (
<Box>
<video
src={videoUrl}
controls
style={{
width: '100%',
borderRadius: 8,
maxHeight: 400,
}}
/>
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
<Button
variant="contained"
startIcon={<Download />}
onClick={handleDownload}
fullWidth
>
Download Video
</Button>
</Box>
{result && (
<Box sx={{ mt: 2 }}>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary">
Duration: {result.duration}s | Resolution: {result.resolution} | Cost: ${result.cost.toFixed(2)}
</Typography>
</Box>
)}
</Box>
)}
{!videoUrl && !isGenerating && (
<Box
sx={{
border: '2px dashed',
borderColor: 'divider',
borderRadius: 2,
p: 4,
textAlign: 'center',
}}
>
<VideoLibrary sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="body2" color="text.secondary">
Generated video will appear here
</Typography>
</Box>
)}
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<AttachMoney />}
onClick={handleEstimateCost}
disabled={!canGenerateImageToVideo || isGenerating}
fullWidth
>
Estimate Cost
</Button>
<OperationButton
onClick={handleGenerate}
disabled={!canGenerateImageToVideo || isGenerating}
loading={isGenerating}
fullWidth
>
Generate Video
</OperationButton>
</Stack>
</Stack>
</MotionCard>
</Grid>
</Grid>
</TabPanel>
{/* Talking Avatar Tab */}
<TabPanel value={tabValue} index={1}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<MotionCard variants={cardVariants} sx={{ p: 3, background: 'rgba(255,255,255,0.05)' }}>
<Stack spacing={3}>
<Typography variant="h6" fontWeight={600}>
Upload Image & Audio
</Typography>
<Box>
<input
accept="image/*"
style={{ display: 'none' }}
id="avatar-image-upload"
type="file"
onChange={handleImageUpload}
/>
<label htmlFor="avatar-image-upload">
<Button
variant="outlined"
component="span"
startIcon={<Upload />}
fullWidth
sx={{ py: 2 }}
>
{imageBase64 ? 'Change Image' : 'Upload Person Image'}
</Button>
</label>
{imageBase64 && (
<Box sx={{ mt: 2 }}>
<CardMedia
component="img"
image={imageBase64}
alt="Uploaded image"
sx={{
maxHeight: 300,
objectFit: 'contain',
borderRadius: 2,
}}
/>
</Box>
)}
</Box>
<Box>
<input
accept="audio/*"
style={{ display: 'none' }}
id="avatar-audio-upload"
type="file"
onChange={handleAudioUpload}
/>
<label htmlFor="avatar-audio-upload">
<Button
variant="outlined"
component="span"
startIcon={<Upload />}
fullWidth
sx={{ py: 2 }}
required
>
{audioBase64 ? 'Change Audio' : 'Upload Audio (Required)'}
</Button>
</label>
</Box>
<TextField
label="Prompt (Optional)"
multiline
rows={3}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe expression, style, or pose..."
fullWidth
/>
<FormControl fullWidth>
<InputLabel>Resolution</InputLabel>
<Select
value={resolution}
label="Resolution"
onChange={(e) => setResolution(e.target.value as '480p' | '720p')}
>
<MenuItem value="480p">480p</MenuItem>
<MenuItem value="720p">720p</MenuItem>
</Select>
</FormControl>
<TextField
label="Seed (Optional)"
value={seed}
onChange={(e) => setSeed(e.target.value)}
placeholder="Random seed for reproducibility"
fullWidth
/>
</Stack>
</MotionCard>
</Grid>
<Grid item xs={12} md={6}>
<MotionCard variants={cardVariants} sx={{ p: 3, background: 'rgba(255,255,255,0.05)' }}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" fontWeight={600}>
Preview & Generate
</Typography>
{costEstimate && (
<Chip
icon={<AttachMoney />}
label={`$${costEstimate.estimated_cost.toFixed(2)}`}
color="primary"
variant="outlined"
/>
)}
</Box>
{isGenerating && (
<Box>
<LinearProgress />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, textAlign: 'center' }}>
Generating talking avatar... This may take up to 10 minutes.
</Typography>
</Box>
)}
{videoUrl && (
<Box>
<video
src={videoUrl}
controls
style={{
width: '100%',
borderRadius: 8,
maxHeight: 400,
}}
/>
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
<Button
variant="contained"
startIcon={<Download />}
onClick={handleDownload}
fullWidth
>
Download Video
</Button>
</Box>
{result && (
<Box sx={{ mt: 2 }}>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary">
Duration: {result.duration}s | Resolution: {result.resolution} | Cost: ${result.cost.toFixed(2)}
</Typography>
</Box>
)}
</Box>
)}
{!videoUrl && !isGenerating && (
<Box
sx={{
border: '2px dashed',
borderColor: 'divider',
borderRadius: 2,
p: 4,
textAlign: 'center',
}}
>
<TransformIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="body2" color="text.secondary">
Generated talking avatar will appear here
</Typography>
</Box>
)}
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<AttachMoney />}
onClick={handleEstimateCost}
disabled={!canGenerateTalkingAvatar || isGenerating}
fullWidth
>
Estimate Cost
</Button>
<OperationButton
onClick={handleGenerate}
disabled={!canGenerateTalkingAvatar || isGenerating}
loading={isGenerating}
fullWidth
>
Generate Avatar
</OperationButton>
</Stack>
</Stack>
</MotionCard>
</Grid>
</Grid>
</TabPanel>
</Stack>
</MotionPaper>
</ImageStudioLayout>
);
};

View File

@@ -89,9 +89,10 @@ export const studioModules: ModuleConfig[] = [
title: 'Transform Studio',
subtitle: 'Image → Video / Avatar / 3D',
description:
'WaveSpeed WAN 2.5 (image-to-video), Hunyuan Avatar, and Stable Fast 3D to convert images into motion, avatars, or 3D assets.',
'WaveSpeed WAN 2.5 (image-to-video), InfiniteTalk (talking avatars), and Stable Fast 3D to convert images into motion, avatars, or 3D assets.',
highlights: ['Image-to-video', 'Talking avatars', '3D export'],
status: 'coming soon',
status: 'live',
route: '/image-transform',
icon: <TransformIcon />,
help: 'Designed for campaign teasers, explainers, and immersive media.',
pricing: {
@@ -116,7 +117,7 @@ export const studioModules: ModuleConfig[] = [
'Smart resize, safe zones, and engagement tips for Instagram, TikTok, LinkedIn, YouTube, Pinterest, and more in one click.',
highlights: ['Text safe zones', 'Batch export', 'Platform presets'],
status: 'live',
route: '/social-optimizer',
route: '/image-studio/social-optimizer',
icon: <ShareIcon />,
help: 'Ship consistent assets across every social surface.',
pricing: {

View File

@@ -6,6 +6,7 @@ export { EditStudio } from './EditStudio';
export { UpscaleStudio } from './UpscaleStudio';
export { ControlStudio } from './ControlStudio';
export { SocialOptimizer } from './SocialOptimizer';
export { TransformStudio } from './TransformStudio';
export { AssetLibrary } from './AssetLibrary';
export { ImageStudioDashboard } from './ImageStudioDashboard';
export { ImageStudioLayout } from './ImageStudioLayout';

View File

@@ -0,0 +1,922 @@
import React, { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { BlogResearchResponse, ResearchProvider } from "../../services/blogWriterApi";
import { podcastApi } from "../../services/podcastApi";
import {
CreateProjectPayload,
Fact,
Job,
Knobs,
Line,
PodcastAnalysis,
PodcastEstimate,
Query,
RenderJobResult,
Research,
Scene,
Script,
} from "./types";
/* ================= UI PRIMITIVES ================= */
const Card: React.FC<{ className?: string; children?: React.ReactNode; "aria-label"?: string }> = ({
children,
className = "",
["aria-label"]: ariaLabel,
}) => (
<section
role="region"
aria-label={ariaLabel}
className={`backdrop-blur-xl bg-[#071022]/80 border border-white/10 text-white rounded-2xl p-5 shadow-2xl ${className}`}
>
{children}
</section>
);
const PrimaryButton: React.FC<{
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
ariaLabel?: string;
}> = ({ children, onClick, disabled = false, ariaLabel }) => (
<motion.button
whileHover={{ scale: disabled ? 1 : 1.02 }}
whileTap={{ scale: disabled ? 1 : 0.97 }}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-md font-medium shadow ${
disabled ? "bg-gray-400 text-gray-800 cursor-not-allowed" : "bg-gradient-to-r from-indigo-500 to-blue-600 text-white"
}`}
>
{children}
</motion.button>
);
const SecondaryButton: React.FC<{ children: React.ReactNode; onClick?: () => void; ariaLabel?: string }> = ({
children,
onClick,
ariaLabel,
}) => (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
onClick={onClick}
aria-label={ariaLabel}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm border bg-transparent"
>
{children}
</motion.button>
);
const Input: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = (props) => (
<input
{...props}
className={`mt-1 block w-full rounded-md border border-white/20 bg-white/6 text-white placeholder-white/40 p-2 ${
props.className ?? ""
}`}
style={{ backdropFilter: "blur(6px)" }}
/>
);
const Label: React.FC<{ htmlFor?: string; children: React.ReactNode }> = ({ htmlFor, children }) => (
<label htmlFor={htmlFor} className="block text-sm font-medium text-white/90">
{children}
</label>
);
/* ================= Helpers ================= */
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
const getSceneVoiceEmotion = (knobs: Knobs) => knobs.voice_emotion || "neutral";
const announceError = (setAnnouncement: (msg: string) => void, error: unknown) => {
const message = error instanceof Error ? error.message : "Unexpected error";
setAnnouncement(message);
};
/* ================= CreateModal ================= */
const CreateModal: React.FC<{
onCreate: (payload: CreateProjectPayload) => void;
open: boolean;
defaultKnobs: Knobs;
}> = ({ onCreate, open, defaultKnobs }) => {
const [idea, setIdea] = useState("");
const [url, setUrl] = useState("");
const [speakers, setSpeakers] = useState<number>(1);
const [duration, setDuration] = useState<number>(10);
const [budgetCap, setBudgetCap] = useState<number>(50);
const [voiceFile, setVoiceFile] = useState<File | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
const submit = () => {
if (!idea && !url) return;
onCreate({
ideaOrUrl: idea || url,
speakers,
duration,
knobs,
budgetCap,
files: { voiceFile, avatarFile },
});
};
if (!open) return null;
return (
<div>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="fixed inset-0 bg-black/60 backdrop-blur-md z-40" />
<motion.aside
initial={{ opacity: 0, y: 24, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 280, damping: 30 }}
className="fixed left-1/2 top-12 transform -translate-x-1/2 z-50 w-[min(1100px,95%)] max-h-[85vh] overflow-auto"
>
<Card className="glass-panel border border-white/10 overflow-hidden">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-indigo-300 via-purple-300 to-blue-200">
Create episode
</h3>
<p className="text-sm text-gray-300 mt-1">Enter a short idea or paste a blog URL. We&apos;ll analyze and suggest defaults.</p>
</div>
<PrimaryButton onClick={submit} ariaLabel="Analyze and continue">
Analyze & Continue
</PrimaryButton>
</div>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div>
<Label htmlFor="idea_input">Idea (one-line)</Label>
<Input id="idea_input" placeholder="Write a short idea or paste a blog URL" value={idea} onChange={(e) => setIdea(e.target.value)} />
<p className="text-xs text-gray-400 mt-1">One sentence is enough AI will expand it into an outline.</p>
</div>
<div>
<Label htmlFor="url_input">Or Blog URL</Label>
<Input id="url_input" placeholder="https://yourblog.com/post" value={url} onChange={(e) => setUrl(e.target.value)} />
<p className="text-xs text-gray-400 mt-1">Providing a URL lets the AI ground the script in article facts.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="speakers_select">Speakers</Label>
<select
id="speakers_select"
aria-label="Number of speakers"
value={speakers}
onChange={(e) => setSpeakers(Number(e.target.value))}
className="mt-1 block w-full rounded-md border border-white/10 bg-transparent p-2"
>
<option value={1}>1 (single)</option>
<option value={2}>2 (dialog)</option>
</select>
</div>
<div>
<Label htmlFor="duration_input">Duration (mins)</Label>
<Input id="duration_input" type="number" value={duration} onChange={(e) => setDuration(Number(e.target.value))} />
</div>
</div>
<div>
<Label htmlFor="budget_input">Budget cap (USD)</Label>
<Input id="budget_input" type="number" value={budgetCap} onChange={(e) => setBudgetCap(Number(e.target.value))} />
<p className="text-xs text-gray-400 mt-1">We&apos;ll prevent renders that exceed this cap.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Voice sample (optional)</Label>
<input aria-label="Upload voice sample" type="file" accept="audio/*" onChange={(e) => setVoiceFile(e.target.files?.[0] ?? null)} className="mt-2" />
<p className="text-xs text-gray-400 mt-1">1030s clean wav/mp3 recommended.</p>
</div>
<div>
<Label>Avatar image (optional)</Label>
<input aria-label="Upload avatar image" type="file" accept="image/*" onChange={(e) => setAvatarFile(e.target.files?.[0] ?? null)} className="mt-2" />
<p className="text-xs text-gray-400 mt-1">Square image works best.</p>
</div>
</div>
</div>
<aside className="space-y-4">
<div className="p-4 bg-gradient-to-br from-indigo-600/30 to-blue-400/20 rounded-lg border border-white/10">
<h4 className="text-sm font-semibold text-white/90">Quick estimate</h4>
<div className="mt-3 text-sm text-gray-200">Estimate updates after analysis.</div>
</div>
<div className="p-4 rounded-lg border border-white/6 bg-white/3">
<h5 className="text-sm font-medium">Production defaults</h5>
<div className="mt-2 text-xs text-gray-300">Voice emotion</div>
<div className="mt-1 flex items-center gap-2">
{["enthusiastic", "neutral", "calm"].map((emotion) => (
<button
key={emotion}
onClick={() => setKnobs({ ...knobs, voice_emotion: emotion })}
className={`px-2 py-1 rounded ${
knobs.voice_emotion === emotion ? "bg-indigo-600/30" : "bg-white/5"
} text-sm`}
>
{emotion}
</button>
))}
</div>
<div className="mt-3 text-xs text-gray-300">Audio quality</div>
<div className="mt-1 flex items-center gap-2">
{["preview", "standard", "hd"].map((tier) => (
<button
key={tier}
onClick={() => setKnobs({ ...knobs, bitrate: tier })}
className={`px-2 py-1 rounded ${knobs.bitrate === tier ? "bg-indigo-600/30" : "bg-white/5"} text-sm`}
>
{tier.toUpperCase()}
</button>
))}
</div>
<div className="mt-3">
<SecondaryButton onClick={() => setKnobs({ ...defaultKnobs })}>Reset defaults</SecondaryButton>
</div>
</div>
<div className="p-4 rounded-lg border border-white/6 bg-white/3">
<h5 className="text-sm font-medium">Privacy & consent</h5>
<p className="mt-2 text-xs text-gray-300">We require explicit consent before creating voice clones.</p>
</div>
</aside>
</div>
<div className="mt-6 flex items-center justify-between">
<div className="text-xs text-gray-400">By continuing you agree to our processing terms for AI-generated voices and avatars.</div>
<div className="flex items-center gap-3">
<SecondaryButton
onClick={() => {
setIdea("");
setUrl("");
setSpeakers(1);
setDuration(10);
setBudgetCap(50);
setVoiceFile(null);
setAvatarFile(null);
setKnobs({ ...defaultKnobs });
}}
>
Reset
</SecondaryButton>
<PrimaryButton onClick={submit}>Analyze & Continue</PrimaryButton>
</div>
</div>
</Card>
</motion.aside>
</div>
);
};
/* ================= FactCard & AnalysisPanel ================= */
const FactCard: React.FC<{ fact: Fact }> = ({ fact }) => {
const hostname = useMemo(() => {
try {
return new URL(fact.url).hostname;
} catch {
return fact.url;
}
}, [fact.url]);
return (
<motion.div whileHover={{ y: -4 }} className="rounded-lg border border-white/6 bg-white/4 p-3">
<div className="text-sm text-gray-100">{fact.quote}</div>
<div className="text-xs text-gray-300 mt-2">
Source:{" "}
<a className="text-indigo-300 underline" href={fact.url} target="_blank" rel="noreferrer">
{hostname || "source"}
</a>
</div>
<div className="text-xs text-gray-400 mt-1">
Date: {fact.date} Confidence: {(fact.confidence * 100).toFixed(0)}%
</div>
</motion.div>
);
};
const AnalysisPanel: React.FC<{ analysis: PodcastAnalysis | null; onRegenerate?: () => void }> = ({ analysis, onRegenerate }) => {
if (!analysis) return null;
return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.28 }}>
<Card className="lg:col-span-2" aria-label="analysis-panel">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold bg-clip-text text-transparent bg-gradient-to-r from-indigo-300 via-purple-300 to-blue-200">AI Analysis</h3>
<p className="text-sm text-gray-300 mt-1">Defaults derived from WaveSpeed + story writer setup.</p>
</div>
<div>
<SecondaryButton onClick={onRegenerate}>Regenerate</SecondaryButton>
</div>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium text-gray-100">Audience</h4>
<p className="mt-1 text-sm text-gray-200">{analysis.audience}</p>
<h4 className="font-medium mt-3 text-gray-100">Content Type</h4>
<p className="mt-1 text-sm text-gray-200">{analysis.contentType}</p>
<h4 className="font-medium mt-3 text-gray-100">Top Keywords</h4>
<ul className="mt-1 list-disc ml-5 text-sm text-gray-200">
{analysis.topKeywords.map((k) => (
<li key={k}>{k}</li>
))}
</ul>
</div>
<div>
<h4 className="font-medium text-gray-100">Suggested Outlines</h4>
<ul className="mt-1 text-sm text-gray-200">
{analysis.suggestedOutlines.map((o) => (
<li key={o.id} className="mb-2">
<strong>{o.title}:</strong> {o.segments.join(" • ")}
</li>
))}
</ul>
<h4 className="font-medium mt-3 text-gray-100">Title Suggestions</h4>
<div className="mt-2 flex gap-2 flex-wrap">
{analysis.titleSuggestions.map((t) => (
<button key={t} className="px-3 py-1 bg-white/6 border border-white/6 rounded text-sm hover:bg-white/10">
{t}
</button>
))}
</div>
</div>
</div>
</Card>
</motion.div>
);
};
/* ================= Script Editor ================= */
const LineEditor: React.FC<{
line: Line;
onChange: (l: Line) => void;
onPreview: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
}> = ({ line, onChange, onPreview }) => {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(line.text);
useEffect(() => setText(line.text), [line.text]);
return (
<motion.div whileHover={{ y: -3 }} className="p-3 border rounded-lg bg-white/5">
<div className="flex items-start gap-4">
<div className="flex-1">
<div className="text-sm font-semibold text-gray-100">{line.speaker}</div>
{editing ? (
<textarea value={text} onChange={(e) => setText(e.target.value)} className="mt-2 w-full p-2 rounded bg-black/5 text-sm" rows={3} />
) : (
<div className="mt-2 text-sm text-gray-200">{line.text}</div>
)}
<div className="mt-2 text-xs text-gray-400">Facts: {line.usedFactIds?.length ? line.usedFactIds.join(", ") : "None"}</div>
</div>
<div className="flex flex-col gap-2">
<button
className="px-3 py-1 rounded bg-white/6 text-sm"
onClick={() => {
if (editing) {
onChange({ ...line, text });
}
setEditing(!editing);
}}
>
{editing ? "Save" : "Edit"}
</button>
<PrimaryButton
onClick={async () => {
const res = await onPreview(line.text);
if (res.audioUrl) window.open(res.audioUrl, "_blank");
else alert(res.message);
}}
>
Preview TTS
</PrimaryButton>
</div>
</div>
</motion.div>
);
};
const SceneEditor: React.FC<{
scene: Scene;
onUpdateScene: (s: Scene) => void;
onApprove: (id: string) => Promise<void>;
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
}> = ({ scene, onUpdateScene, onApprove, onPreviewLine }) => {
const updateLine = (updatedLine: Line) => {
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
onUpdateScene(updated);
};
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-lg font-semibold text-gray-100">{scene.title}</h4>
<div className="text-xs text-gray-400">Duration: {scene.duration}s</div>
</div>
<div className="flex items-center gap-2">
<div className={`text-sm px-2 py-1 rounded ${scene.approved ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"}`}>
{scene.approved ? "Approved" : "Not approved"}
</div>
<PrimaryButton
onClick={async () => {
await onApprove(scene.id);
onUpdateScene({ ...scene, approved: true });
}}
>
Approve Scene
</PrimaryButton>
</div>
</div>
<div className="mt-3 space-y-3">
{scene.lines.map((line) => (
<LineEditor key={line.id} line={line} onChange={updateLine} onPreview={(text) => onPreviewLine(text)} />
))}
</div>
</Card>
);
};
const ScriptEditor: React.FC<{
projectId: string;
idea: string;
research: Research | null;
rawResearch: BlogResearchResponse | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
onBackToResearch: () => void;
onProceedToRendering: (script: Script) => void;
}> = ({ projectId, idea, research, rawResearch, knobs, speakers, durationMinutes, onBackToResearch, onProceedToRendering }) => {
const [script, setScript] = useState<Script | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
setLoading(true);
podcastApi
.generateScript({
projectId,
idea,
research: rawResearch,
knobs,
speakers,
durationMinutes,
})
.then((res) => {
if (mounted) {
setScript(res);
setError(null);
}
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to generate script");
})
.finally(() => mounted && setLoading(false));
return () => {
mounted = false;
};
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes]);
const updateScene = (updated: Scene) => {
if (!script) return;
setScript({ ...script, scenes: script.scenes.map((s) => (s.id === updated.id ? updated : s)) });
};
const approveScene = async (sceneId: string) => {
try {
await podcastApi.approveScene({ projectId, sceneId });
setScript((prev) =>
prev
? {
...prev,
scenes: prev.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
}
: prev
);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to approve scene";
setError(message);
throw err;
}
};
const allApproved = script && script.scenes.every((s) => s.approved);
return (
<div className="mt-6">
<div className="flex items-center gap-3 mb-4">
<button className="text-sm text-gray-400" onClick={onBackToResearch}>
Back to research
</button>
<h2 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-300 via-purple-300 to-blue-200">Script Editor</h2>
</div>
{loading && <div className="p-3 bg-yellow-50 rounded text-black">Generating script...</div>}
{error && <div className="p-3 bg-red-100 text-red-900 rounded">{error}</div>}
{script && (
<div className="space-y-4">
<div className="p-3 bg-white/5 rounded border">
<div className="text-sm text-gray-300">Each scene must be approved before rendering.</div>
</div>
{script.scenes.map((scene) => (
<motion.div key={scene.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-3">
<SceneEditor scene={scene} onUpdateScene={updateScene} onApprove={approveScene} onPreviewLine={(text) => podcastApi.previewLine(text)} />
</motion.div>
))}
<div className="mt-4 p-4 bg-white/5 rounded flex items-center justify-between">
<div>
<div className="text-sm">All scenes approved: {allApproved ? "Yes" : "No"}</div>
<div className="text-xs text-gray-400">Rendering enabled after all scenes are approved.</div>
</div>
<div>
<PrimaryButton onClick={() => script && onProceedToRendering(script)} disabled={!allApproved}>
Proceed to Rendering
</PrimaryButton>
</div>
</div>
</div>
)}
</div>
);
};
/* ================= Render Queue ================= */
const RenderQueue: React.FC<{
projectId: string;
script: Script;
knobs: Knobs;
onBack: () => void;
}> = ({ projectId, script, knobs, onBack }) => {
const [jobs, setJobs] = useState<Job[]>(
script.scenes.map((s) => ({ sceneId: s.id, title: s.title, status: "idle", progress: 0, previewUrl: null, finalUrl: null, jobId: null }))
);
const [rendering, setRendering] = useState<string | null>(null);
const getScene = (sceneId: string) => script.scenes.find((s) => s.id === sceneId);
const runRender = async (sceneId: string, mode: "preview" | "full") => {
const scene = getScene(sceneId);
if (!scene) return;
setRendering(sceneId);
setJobs((list) =>
list.map((job) =>
job.sceneId === sceneId
? { ...job, status: mode === "preview" ? "previewing" : "running", progress: mode === "preview" ? 25 : 40 }
: job
)
);
try {
const result: RenderJobResult = await podcastApi.renderSceneAudio({
scene,
voiceId: "Wise_Woman",
emotion: getSceneVoiceEmotion(knobs),
speed: knobs.voice_speed,
});
setJobs((list) =>
list.map((job) =>
job.sceneId === sceneId
? {
...job,
status: "completed",
progress: 100,
previewUrl: mode === "preview" ? result.audioUrl : job.previewUrl,
finalUrl: mode === "full" ? result.audioUrl : job.finalUrl,
}
: job
)
);
if (mode === "preview") window.open(result.audioUrl, "_blank");
} catch (error) {
setJobs((list) =>
list.map((job) => (job.sceneId === sceneId ? { ...job, status: "failed", progress: 0 } : job))
);
} finally {
setRendering(null);
}
};
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-4">
<button className="text-sm text-gray-400" onClick={onBack}>
Back to script
</button>
<h2 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-300 via-purple-300 to-blue-200">Render Queue</h2>
</div>
<div className="grid grid-cols-1 gap-4">
{jobs.map((job) => (
<Card key={job.sceneId} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-md bg-white/5 flex items-center justify-center text-gray-200 font-semibold">
{job.title
.split(" ")
.slice(0, 2)
.map((s) => s[0])
.join("")}
</div>
<div>
<div className="font-medium text-gray-100">{job.title}</div>
<div className="text-xs text-gray-400">Scene: {job.sceneId}</div>
{job.finalUrl && (
<div className="text-sm mt-1">
Final:{" "}
<a className="text-indigo-300 underline" href={job.finalUrl} target="_blank" rel="noreferrer">
Download
</a>
</div>
)}
</div>
</div>
<div className="flex-1">
<div className="flex items-center justify-between gap-4">
<div className="text-sm">
Status: <strong className="capitalize">{job.status}</strong>
</div>
<div className="text-xs text-gray-400">Progress: {job.progress}%</div>
</div>
<div className="w-full bg-white/7 rounded overflow-hidden h-2 mt-2">
<div className="h-2 bg-gradient-to-r from-indigo-400 to-blue-400" style={{ width: `${job.progress}%` }} />
</div>
<div className="mt-3 flex items-center gap-2 justify-end flex-wrap">
{job.status === "idle" && (
<>
<SecondaryButton onClick={() => runRender(job.sceneId, "preview")}>Preview</SecondaryButton>
<PrimaryButton onClick={() => runRender(job.sceneId, "full")} disabled={rendering === job.sceneId}>
Start Full Render
</PrimaryButton>
</>
)}
{job.status === "completed" && job.previewUrl && (
<PrimaryButton onClick={() => window.open(job.previewUrl || job.finalUrl || "#", "_blank")}>
Listen
</PrimaryButton>
)}
{job.status === "failed" && (
<button onClick={() => runRender(job.sceneId, "full")} className="px-3 py-1 rounded-md bg-yellow-500 text-black text-sm">
Retry
</button>
)}
</div>
</div>
</Card>
))}
</div>
<div className="flex justify-end mt-4">
<SecondaryButton onClick={onBack}>Done</SecondaryButton>
</div>
</div>
);
};
/* ================= Dashboard ================= */
const PodcastDashboard: React.FC = () => {
const [project, setProject] = useState<{ id: string; idea: string; duration: number; speakers: number } | null>(null);
const [analysis, setAnalysis] = useState<PodcastAnalysis | null>(null);
const [queries, setQueries] = useState<Query[]>([]);
const [selectedQueries, setSelectedQueries] = useState<Set<string>>(new Set());
const [research, setResearch] = useState<Research | null>(null);
const [rawResearch, setRawResearch] = useState<BlogResearchResponse | null>(null);
const [estimate, setEstimate] = useState<PodcastEstimate | null>(null);
const [loading, setLoading] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [showScriptEditor, setShowScriptEditor] = useState(false);
const [showRenderQueue, setShowRenderQueue] = useState(false);
const [scriptData, setScriptData] = useState<Script | null>(null);
const [knobsState, setKnobsState] = useState<Knobs>(DEFAULT_KNOBS);
const [researchProvider, setResearchProvider] = useState<ResearchProvider>("google");
useEffect(() => {
if (announcement) {
const t = setTimeout(() => setAnnouncement(""), 4000);
return () => clearTimeout(t);
}
return undefined;
}, [announcement]);
const handleCreate = async (payload: CreateProjectPayload) => {
try {
setLoading(true);
setAnnouncement("Analyzing your idea — AI suggestions incoming");
const result = await podcastApi.createProject(payload);
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
setAnalysis(result.analysis);
setEstimate(result.estimate);
setQueries(result.queries);
setSelectedQueries(new Set(result.queries.map((q) => q.id)));
setKnobsState(payload.knobs);
setAnnouncement("Analysis complete");
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setLoading(false);
}
};
const handleRunResearch = async () => {
if (!project) {
setAnnouncement("Create a project first.");
return;
}
try {
setLoading(true);
setAnnouncement("Running grounded research — fetching sources");
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
const { research: mapped, raw } = await podcastApi.runResearch({
projectId: project.id,
topic: project.idea,
approvedQueries,
provider: researchProvider,
});
setResearch(mapped);
setRawResearch(raw);
setAnnouncement("Research ready — review fact cards");
} catch (error) {
announceError(setAnnouncement, error);
} finally {
setLoading(false);
}
};
const handleGenerateScript = () => {
if (!project || !research) {
setAnnouncement("Project or research missing — cannot generate script");
return;
}
setScriptData(null);
setShowScriptEditor(true);
};
const handleProceedToRendering = (script: Script) => {
setScriptData(script);
setShowRenderQueue(true);
setShowScriptEditor(false);
};
const toggleQuery = (id: string) => {
setSelectedQueries((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<main className="p-6 max-w-7xl mx-auto">
<header className="flex items-center justify-between mb-6 flex-wrap gap-4">
<div>
<h1 className="text-2xl font-bold">Alwrity AI Podcast Maker</h1>
<p className="text-sm text-gray-400">Create grounded episodes with editable scripts and WaveSpeed renders.</p>
</div>
<div className="flex items-center gap-3">
<SecondaryButton onClick={() => window.open("/docs", "_blank")}>Docs</SecondaryButton>
<PrimaryButton onClick={() => window.location.reload()}>New Episode</PrimaryButton>
</div>
</header>
{announcement && (
<div className="mb-4 rounded bg-blue-50 text-blue-900 px-4 py-2 border border-blue-200 shadow">{announcement}</div>
)}
{loading && <div className="p-3 mb-4 bg-yellow-50 text-yellow-900 rounded border border-yellow-200">Working... please wait</div>}
{!project && <CreateModal open onCreate={handleCreate} defaultKnobs={DEFAULT_KNOBS} />}
<div className="space-y-6">
{analysis && !showScriptEditor && !showRenderQueue && <AnalysisPanel analysis={analysis} onRegenerate={() => setAnalysis({ ...analysis })} />}
{estimate && !showScriptEditor && !showRenderQueue && (
<Card className="mt-4" aria-label="estimate">
<h5 className="font-semibold">Estimated cost</h5>
<div className="mt-2 text-sm text-gray-200">Total estimate: ${estimate.total}</div>
<div className="text-xs text-gray-400 mt-1">
TTS ${estimate.ttsCost} Avatars ${estimate.avatarCost} Research ${estimate.researchCost}
</div>
</Card>
)}
{queries.length > 0 && !showScriptEditor && !showRenderQueue && (
<div>
<Card className="mt-4 space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h5 className="font-semibold">Queries</h5>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">Provider:</span>
<select
value={researchProvider}
onChange={(e) => setResearchProvider(e.target.value as ResearchProvider)}
className="bg-black/30 border border-white/10 rounded px-3 py-1 text-sm"
>
<option value="google">Google Grounding</option>
<option value="exa">Exa Neural Search</option>
</select>
</div>
</div>
<div className="grid gap-3">
{queries.map((q) => (
<label key={q.id} className="flex items-start gap-3 text-sm text-gray-200 border border-white/10 rounded-lg p-3">
<input
type="checkbox"
checked={selectedQueries.has(q.id)}
onChange={() => toggleQuery(q.id)}
className="mt-1"
/>
<div>
<div>{q.query}</div>
<div className="text-xs text-gray-400">{q.rationale}</div>
</div>
</label>
))}
</div>
<div className="flex justify-end">
<PrimaryButton onClick={handleRunResearch}>Run research</PrimaryButton>
</div>
</Card>
</div>
)}
{research && !showScriptEditor && !showRenderQueue && (
<div className="space-y-4">
<Card className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Research summary</h3>
<PrimaryButton onClick={handleGenerateScript}>Generate Script</PrimaryButton>
</div>
<p className="text-sm text-gray-200">{research.summary}</p>
{research.factCards.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{research.factCards.map((fact) => (
<FactCard key={fact.id} fact={fact} />
))}
</div>
)}
</Card>
</div>
)}
{showScriptEditor && project && (
<ScriptEditor
projectId={project.id}
idea={project.idea}
research={research}
rawResearch={rawResearch}
knobs={knobsState}
speakers={project.speakers}
durationMinutes={project.duration}
onBackToResearch={() => setShowScriptEditor(false)}
onProceedToRendering={(s) => handleProceedToRendering(s)}
/>
)}
{showRenderQueue && project && scriptData && (
<RenderQueue
projectId={project.id}
script={scriptData}
knobs={knobsState}
onBack={() => {
setShowRenderQueue(false);
setShowScriptEditor(true);
}}
/>
)}
</div>
</main>
);
};
export default PodcastDashboard;

View File

@@ -0,0 +1,115 @@
export type Knobs = {
voice_emotion: string;
voice_speed: number;
resolution: string;
scene_length_target: number;
sample_rate: number;
bitrate: string;
};
export type Query = {
id: string;
query: string;
rationale: string;
needsRecentStats: boolean;
};
export type Fact = {
id: string;
quote: string;
url: string;
date: string;
confidence: number;
};
export type Research = {
summary: string;
factCards: Fact[];
mappedAngles: {
title: string;
why: string;
mappedFactIds: string[];
}[];
};
export type Line = {
id: string;
speaker: string;
text: string;
usedFactIds?: string[];
};
export type Scene = {
id: string;
title: string;
duration: number;
lines: Line[];
approved?: boolean;
};
export type Script = {
scenes: Scene[];
};
export type JobStatus =
| "idle"
| "previewing"
| "queued"
| "running"
| "completed"
| "cancelled"
| "failed";
export type Job = {
sceneId: string;
title: string;
status: JobStatus;
progress: number;
previewUrl?: string | null;
finalUrl?: string | null;
jobId?: string | null;
};
export type PodcastAnalysis = {
audience: string;
contentType: string;
topKeywords: string[];
suggestedOutlines: { id: number | string; title: string; segments: string[] }[];
suggestedKnobs: Knobs;
titleSuggestions: string[];
};
export type PodcastEstimate = {
ttsCost: number;
avatarCost: number;
videoCost: number;
researchCost: number;
total: number;
};
export type CreateProjectPayload = {
ideaOrUrl: string;
speakers: number;
duration: number;
knobs: Knobs;
budgetCap: number;
files: { voiceFile?: File | null; avatarFile?: File | null };
};
export type CreateProjectResult = {
projectId: string;
analysis: PodcastAnalysis;
estimate: PodcastEstimate;
queries: Query[];
};
export type RenderJobResult = {
audioUrl: string;
audioFilename: string;
provider: string;
model: string;
cost: number;
voiceId: string;
fileSize: number;
};

View File

@@ -0,0 +1,351 @@
import React, { useState, useCallback } from 'react';
import {
Box,
Paper,
Typography,
Button,
Stack,
Alert,
CircularProgress,
Chip,
Divider,
Card,
CardContent,
List,
ListItem,
ListItemIcon,
ListItemText,
LinearProgress,
} from '@mui/material';
import {
Upload,
CheckCircle,
Warning,
Error as ErrorIcon,
PhotoLibrary,
ArrowBack,
AutoAwesome,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { ImageStudioLayout } from '../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { useProductMarketing } from '../../hooks/useProductMarketing';
interface AssetAuditPanelProps {
onClose: () => void;
}
export const AssetAuditPanel: React.FC<AssetAuditPanelProps> = ({ onClose }) => {
const { auditAsset, auditResult, isAuditing, error, clearAuditResult } = useProductMarketing();
const [dragActive, setDragActive] = useState(false);
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
await handleFile(file);
}
},
[]
);
const handleFileInput = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
await handleFile(file);
}
},
[]
);
const handleFile = async (file: File) => {
if (!file.type.startsWith('image/')) {
alert('Please upload an image file');
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
const base64 = e.target?.result as string;
setUploadedImage(base64);
await auditAsset(base64);
};
reader.readAsDataURL(file);
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'error';
case 'medium':
return 'warning';
case 'low':
return 'info';
default:
return 'default';
}
};
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'high':
return <ErrorIcon />;
case 'medium':
return <Warning />;
case 'low':
return <CheckCircle />;
default:
return null;
}
};
return (
<ImageStudioLayout
headerProps={{
title: 'Asset Audit',
subtitle: 'Upload existing assets for AI-powered quality assessment and enhancement recommendations',
}}
>
<GlassyCard
sx={{
maxWidth: 1000,
mx: 'auto',
p: { xs: 3, md: 4 },
}}
>
<Button startIcon={<ArrowBack />} onClick={onClose} sx={{ mb: 3 }}>
Back to Dashboard
</Button>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Upload Area */}
{!uploadedImage && (
<Box
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
sx={{
border: `2px dashed ${dragActive ? '#7c3aed' : 'rgba(255,255,255,0.2)'}`,
borderRadius: 3,
p: 6,
textAlign: 'center',
background: dragActive ? 'rgba(124, 58, 237, 0.1)' : 'rgba(255,255,255,0.02)',
transition: 'all 0.2s',
cursor: 'pointer',
}}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileInput}
style={{ display: 'none' }}
/>
<Upload sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Drag & drop an image here
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
or click to browse
</Typography>
<Button variant="outlined" startIcon={<PhotoLibrary />}>
Select Image
</Button>
</Box>
)}
{/* Uploaded Image Preview */}
{uploadedImage && !auditResult && (
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Uploaded Image
</Typography>
<Paper
sx={{
p: 2,
background: 'rgba(255,255,255,0.02)',
borderRadius: 2,
}}
>
<Box
component="img"
src={uploadedImage}
alt="Uploaded"
sx={{
maxWidth: '100%',
maxHeight: 400,
borderRadius: 2,
}}
/>
</Paper>
{isAuditing && (
<Box sx={{ mt: 2 }}>
<LinearProgress />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, textAlign: 'center' }}>
Analyzing asset quality...
</Typography>
</Box>
)}
</Box>
)}
{/* Audit Results */}
{auditResult && (
<Stack spacing={3}>
<SectionHeader
title="Audit Results"
subtitle="AI-powered quality assessment and recommendations"
/>
{/* Asset Info */}
<GlassyCard sx={{ p: 3 }}>
<Stack spacing={2}>
<Typography variant="h6" gutterBottom>
Asset Information
</Typography>
<Box display="flex" gap={2} flexWrap="wrap">
<Chip
label={`${auditResult.asset_info.width} × ${auditResult.asset_info.height}`}
icon={<PhotoLibrary />}
/>
<Chip label={auditResult.asset_info.format} />
<Chip label={auditResult.asset_info.mode} />
<Chip
label={`Quality: ${(auditResult.asset_info.quality_score * 100).toFixed(0)}%`}
color={auditResult.asset_info.quality_score > 0.7 ? 'success' : 'warning'}
/>
</Box>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Quality Score
</Typography>
<LinearProgress
variant="determinate"
value={auditResult.asset_info.quality_score * 100}
sx={{ height: 8, borderRadius: 1 }}
/>
</Box>
</Stack>
</GlassyCard>
{/* Status */}
<Alert
severity={
auditResult.status === 'usable'
? 'success'
: auditResult.status === 'needs_enhancement'
? 'warning'
: 'error'
}
>
<Typography variant="body1" fontWeight={700} gutterBottom>
Status: {auditResult.status === 'usable' ? 'Ready to Use' : 'Needs Enhancement'}
</Typography>
{auditResult.status === 'needs_enhancement' && (
<Typography variant="body2">
This asset may benefit from enhancement operations. See recommendations below.
</Typography>
)}
</Alert>
{/* Recommendations */}
{auditResult.recommendations.length > 0 && (
<GlassyCard sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Enhancement Recommendations
</Typography>
<List>
{auditResult.recommendations.map((rec, index) => (
<React.Fragment key={index}>
<ListItem>
<ListItemIcon>
{getPriorityIcon(rec.priority)}
</ListItemIcon>
<ListItemText
primary={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="body1" fontWeight={600}>
{rec.operation.replace('_', ' ').toUpperCase()}
</Typography>
<Chip
label={rec.priority}
size="small"
color={getPriorityColor(rec.priority) as any}
/>
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
{rec.reason}
</Typography>
{rec.suggested_mode && (
<Typography variant="caption" color="text.secondary">
Suggested mode: {rec.suggested_mode}
</Typography>
)}
</Box>
}
/>
</ListItem>
{index < auditResult.recommendations.length - 1 && (
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
)}
</React.Fragment>
))}
</List>
</GlassyCard>
)}
{/* Actions */}
<Box display="flex" gap={2} justifyContent="flex-end">
<Button variant="outlined" onClick={() => {
setUploadedImage(null);
clearAuditResult();
}}>
Upload Another
</Button>
<Button
variant="contained"
startIcon={<AutoAwesome />}
onClick={() => {
// Navigate to Edit Studio or enhancement flow
alert('Enhancement flow coming soon');
}}
disabled={auditResult.status === 'error'}
>
Enhance Asset
</Button>
</Box>
</Stack>
)}
</GlassyCard>
</ImageStudioLayout>
);
};

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Box, Stepper, Step, StepLabel, Typography, Chip } from '@mui/material';
import { CheckCircle, RadioButtonUnchecked } from '@mui/icons-material';
interface CampaignFlowIndicatorProps {
currentStep: 'blueprint' | 'proposals' | 'review' | 'generate' | 'complete';
}
const steps = [
{ id: 'blueprint', label: 'Create Blueprint' },
{ id: 'proposals', label: 'Generate Proposals' },
{ id: 'review', label: 'Review & Approve' },
{ id: 'generate', label: 'Generate Assets' },
{ id: 'complete', label: 'Complete' },
];
export const CampaignFlowIndicator: React.FC<CampaignFlowIndicatorProps> = ({ currentStep }) => {
const currentStepIndex = steps.findIndex((s) => s.id === currentStep);
return (
<Box sx={{ mb: 4 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h6" fontWeight={700}>
Campaign Flow
</Typography>
<Chip
label={`Step ${currentStepIndex + 1} of ${steps.length}`}
size="small"
color="primary"
/>
</Box>
<Stepper activeStep={currentStepIndex} alternativeLabel>
{steps.map((step, index) => (
<Step key={step.id} completed={index < currentStepIndex}>
<StepLabel
StepIconComponent={({ active, completed }) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: completed
? 'linear-gradient(135deg, #7c3aed, #a78bfa)'
: active
? 'rgba(124, 58, 237, 0.2)'
: 'rgba(255,255,255,0.1)',
border: active ? '2px solid #7c3aed' : 'none',
}}
>
{completed ? (
<CheckCircle sx={{ color: '#fff', fontSize: 24 }} />
) : (
<RadioButtonUnchecked
sx={{
color: active ? '#7c3aed' : 'rgba(255,255,255,0.5)',
fontSize: 24,
}}
/>
)}
</Box>
)}
>
{step.label}
</StepLabel>
</Step>
))}
</Stepper>
</Box>
);
};

View File

@@ -0,0 +1,508 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
Button,
TextField,
Stepper,
Step,
StepLabel,
StepContent,
Stack,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
Alert,
CircularProgress,
Divider,
Grid,
} from '@mui/material';
import {
ArrowBack,
ArrowForward,
CheckCircle,
Campaign,
AutoAwesome,
TrendingUp,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { ImageStudioLayout } from '../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { CampaignFlowIndicator } from './CampaignFlowIndicator';
import { PreflightValidationAlert } from './PreflightValidationAlert';
import { useProductMarketing } from '../../hooks/useProductMarketing';
const MotionBox = motion(Box);
interface CampaignWizardProps {
onComplete: (blueprint: any) => void;
onCancel: () => void;
}
const steps = [
'Campaign Goal & KPI',
'Select Channels',
'Product Context',
'Review & Create',
];
const channelOptions = [
{ value: 'instagram', label: 'Instagram', icon: '📷' },
{ value: 'linkedin', label: 'LinkedIn', icon: '💼' },
{ value: 'facebook', label: 'Facebook', icon: '👥' },
{ value: 'tiktok', label: 'TikTok', icon: '🎵' },
{ value: 'twitter', label: 'Twitter/X', icon: '🐦' },
{ value: 'pinterest', label: 'Pinterest', icon: '📌' },
{ value: 'youtube', label: 'YouTube', icon: '▶️' },
];
const goalOptions = [
{ value: 'product_launch', label: 'Product Launch', description: 'Launch a new product or feature' },
{ value: 'awareness', label: 'Brand Awareness', description: 'Increase brand visibility' },
{ value: 'conversion', label: 'Drive Conversions', description: 'Generate leads and sales' },
{ value: 'retention', label: 'Customer Retention', description: 'Engage existing customers' },
];
export const CampaignWizard: React.FC<CampaignWizardProps> = ({ onComplete, onCancel }) => {
const {
createCampaignBlueprint,
generateAssetProposals,
isCreatingBlueprint,
isGeneratingProposals,
error,
getBrandDNA,
brandDNA,
validateCampaignPreflight,
preflightResult,
isValidatingPreflight,
} = useProductMarketing();
const [activeStep, setActiveStep] = useState(0);
const [campaignName, setCampaignName] = useState('');
const [goal, setGoal] = useState('');
const [kpi, setKpi] = useState('');
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
const [productDescription, setProductDescription] = useState('');
const [productName, setProductName] = useState('');
const [marketingGoal, setMarketingGoal] = useState('');
useEffect(() => {
// Load brand DNA on mount
if (!brandDNA) {
getBrandDNA();
}
}, [brandDNA, getBrandDNA]);
// Run pre-flight validation when on review step (step 3) and we have all required data
useEffect(() => {
if (activeStep === 3 && campaignName && goal && selectedChannels.length > 0) {
validateCampaignPreflight({
campaign_name: campaignName,
goal: goal,
kpi: kpi || undefined,
channels: selectedChannels,
product_context: {
product_name: productName,
product_description: productDescription,
marketing_goal: marketingGoal,
},
}).catch(console.error);
}
}, [activeStep, campaignName, goal, selectedChannels, kpi, productName, productDescription, marketingGoal, validateCampaignPreflight]);
const handleNext = () => {
if (activeStep < steps.length - 1) {
setActiveStep((prev) => prev + 1);
}
};
const handleBack = () => {
if (activeStep > 0) {
setActiveStep((prev) => prev - 1);
}
};
const handleChannelToggle = (channel: string) => {
setSelectedChannels((prev) =>
prev.includes(channel) ? prev.filter((c) => c !== channel) : [...prev, channel]
);
};
const handleCreate = async () => {
try {
// Step 1: Create blueprint
const blueprint = await createCampaignBlueprint({
campaign_name: campaignName,
goal: goal,
kpi: kpi || undefined,
channels: selectedChannels,
product_context: {
product_name: productName,
product_description: productDescription,
marketing_goal: marketingGoal,
},
});
// Step 2: Generate proposals automatically
try {
await generateAssetProposals(blueprint.campaign_id, {
product_name: productName,
product_description: productDescription,
marketing_goal: marketingGoal,
});
} catch (proposalErr) {
// Log but don't fail - proposals can be generated later
console.warn('Failed to generate proposals:', proposalErr);
}
// Step 3: Complete wizard
onComplete(blueprint);
} catch (err) {
// Error handled in hook
}
};
const canProceed = () => {
switch (activeStep) {
case 0:
return campaignName.trim() !== '' && goal !== '';
case 1:
return selectedChannels.length > 0;
case 2:
return productDescription.trim() !== '' || productName.trim() !== '';
case 3:
return true;
default:
return false;
}
};
return (
<ImageStudioLayout
headerProps={{
title: 'Campaign Wizard',
subtitle: 'Create a personalized marketing campaign with AI-generated assets',
}}
>
<GlassyCard
sx={{
maxWidth: 900,
mx: 'auto',
p: { xs: 3, md: 4 },
}}
>
<CampaignFlowIndicator currentStep="blueprint" />
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => {}}>
{error}
</Alert>
)}
<Stepper activeStep={activeStep} orientation="vertical">
{/* Step 1: Campaign Goal & KPI */}
<Step>
<StepLabel>
<Typography variant="h6" fontWeight={700}>
Campaign Goal & KPI
</Typography>
</StepLabel>
<StepContent>
<Stack spacing={3}>
<TextField
label="Campaign Name"
value={campaignName}
onChange={(e) => setCampaignName(e.target.value)}
fullWidth
placeholder="e.g., Q1 Product Launch"
required
/>
<FormControl fullWidth>
<InputLabel>Campaign Goal</InputLabel>
<Select value={goal} onChange={(e) => setGoal(e.target.value)} label="Campaign Goal">
{goalOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
<Box>
<Typography variant="body1">{option.label}</Typography>
<Typography variant="caption" color="text.secondary">
{option.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Key Performance Indicator (Optional)"
value={kpi}
onChange={(e) => setKpi(e.target.value)}
fullWidth
placeholder="e.g., 10,000 impressions, 500 sign-ups"
helperText="How will you measure campaign success?"
/>
{brandDNA && (
<Alert severity="info">
<Typography variant="body2" fontWeight={700} gutterBottom>
Personalized for: {brandDNA.persona?.persona_name || 'Your Brand'}
</Typography>
<Typography variant="caption">
Tone: {brandDNA.writing_style?.tone} Audience: {brandDNA.target_audience?.industry_focus}
</Typography>
</Alert>
)}
<Box display="flex" justifyContent="flex-end" gap={2}>
<Button onClick={onCancel}>Cancel</Button>
<Button variant="contained" onClick={handleNext} disabled={!canProceed()}>
Next
</Button>
</Box>
</Stack>
</StepContent>
</Step>
{/* Step 2: Select Channels */}
<Step>
<StepLabel>
<Typography variant="h6" fontWeight={700}>
Select Channels
</Typography>
</StepLabel>
<StepContent>
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
Select the platforms where you want to publish your campaign. AI will generate platform-optimized assets for each.
</Typography>
<Grid container spacing={2}>
{channelOptions.map((channel) => (
<Grid item xs={6} sm={4} key={channel.value}>
<Paper
onClick={() => handleChannelToggle(channel.value)}
sx={{
p: 2,
cursor: 'pointer',
border: selectedChannels.includes(channel.value)
? '2px solid #7c3aed'
: '1px solid rgba(255,255,255,0.1)',
background: selectedChannels.includes(channel.value)
? 'rgba(124, 58, 237, 0.1)'
: 'rgba(255,255,255,0.02)',
transition: 'all 0.2s',
'&:hover': {
background: 'rgba(124, 58, 237, 0.05)',
},
}}
>
<Stack spacing={1} alignItems="center">
<Typography variant="h4">{channel.icon}</Typography>
<Typography variant="body2" fontWeight={600}>
{channel.label}
</Typography>
{selectedChannels.includes(channel.value) && (
<CheckCircle sx={{ color: '#7c3aed', fontSize: 20 }} />
)}
</Stack>
</Paper>
</Grid>
))}
</Grid>
{selectedChannels.length > 0 && (
<Alert severity="success">
{selectedChannels.length} channel(s) selected. AI will generate optimized assets for each platform.
</Alert>
)}
<Box display="flex" justifyContent="space-between">
<Button onClick={handleBack}>Back</Button>
<Button variant="contained" onClick={handleNext} disabled={!canProceed()}>
Next
</Button>
</Box>
</Stack>
</StepContent>
</Step>
{/* Step 3: Product Context */}
<Step>
<StepLabel>
<Typography variant="h6" fontWeight={700}>
Product Context
</Typography>
</StepLabel>
<StepContent>
<Stack spacing={3}>
<Typography variant="body2" color="text.secondary">
Provide information about your product. This helps AI generate more accurate and relevant marketing assets.
</Typography>
<TextField
label="Product Name"
value={productName}
onChange={(e) => setProductName(e.target.value)}
fullWidth
placeholder="e.g., AI Writing Assistant"
/>
<TextField
label="Product Description"
value={productDescription}
onChange={(e) => setProductDescription(e.target.value)}
fullWidth
multiline
rows={4}
placeholder="Describe your product, its key features, and benefits..."
required
/>
<TextField
label="Marketing Goal for This Campaign"
value={marketingGoal}
onChange={(e) => setMarketingGoal(e.target.value)}
fullWidth
placeholder="e.g., Drive sign-ups, increase awareness, showcase features"
helperText="What specific outcome do you want from this campaign?"
/>
<Box display="flex" justifyContent="space-between">
<Button onClick={handleBack}>Back</Button>
<Button variant="contained" onClick={handleNext} disabled={!canProceed()}>
Next
</Button>
</Box>
</Stack>
</StepContent>
</Step>
{/* Step 4: Review & Create */}
<Step>
<StepLabel>
<Typography variant="h6" fontWeight={700}>
Review & Create
</Typography>
</StepLabel>
<StepContent>
<Stack spacing={3}>
<GlassyCard sx={{ p: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Campaign Name
</Typography>
<Typography variant="h6">{campaignName}</Typography>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Goal
</Typography>
<Typography variant="body1">
{goalOptions.find((g) => g.value === goal)?.label || goal}
</Typography>
</Box>
{kpi && (
<>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
KPI
</Typography>
<Typography variant="body1">{kpi}</Typography>
</Box>
</>
)}
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Channels ({selectedChannels.length})
</Typography>
<Box display="flex" gap={1} flexWrap="wrap">
{selectedChannels.map((channel) => {
const channelInfo = channelOptions.find((c) => c.value === channel);
return (
<Chip
key={channel}
label={`${channelInfo?.icon} ${channelInfo?.label}`}
size="small"
/>
);
})}
</Box>
</Box>
{productDescription && (
<>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Product Description
</Typography>
<Typography variant="body2">{productDescription}</Typography>
</Box>
</>
)}
</Stack>
</GlassyCard>
{/* Pre-flight Validation Alert */}
<PreflightValidationAlert
validationResult={preflightResult}
isLoading={isValidatingPreflight}
/>
<Alert severity="info" icon={<AutoAwesome />}>
<Typography variant="body2" fontWeight={700} gutterBottom>
Next Steps
</Typography>
<Typography variant="body2">
After creating the blueprint, AI will automatically generate personalized asset proposals. You'll then review and approve them before assets are generated.
</Typography>
</Alert>
<Box display="flex" justifyContent="space-between">
<Button onClick={handleBack}>Back</Button>
<Button
variant="contained"
onClick={handleCreate}
disabled={
isCreatingBlueprint ||
isGeneratingProposals ||
isValidatingPreflight ||
(preflightResult && !preflightResult.can_proceed)
}
startIcon={
isCreatingBlueprint || isGeneratingProposals ? (
<CircularProgress size={16} />
) : (
<AutoAwesome />
)
}
>
{isCreatingBlueprint
? 'Creating Campaign...'
: isGeneratingProposals
? 'Generating Proposals...'
: 'Create Campaign & Generate Proposals'}
</Button>
</Box>
</Stack>
</StepContent>
</Step>
</Stepper>
</GlassyCard>
</ImageStudioLayout>
);
};

View File

@@ -0,0 +1,236 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
Button,
Stack,
Chip,
Grid,
Card,
CardContent,
Divider,
Alert,
CircularProgress,
Tabs,
Tab,
} from '@mui/material';
import {
Instagram,
LinkedIn,
Facebook,
Twitter,
YouTube,
Pinterest,
MusicNote,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { useProductMarketing } from '../../hooks/useProductMarketing';
interface ChannelPackBuilderProps {
channels: string[];
onChannelPackReady?: (packs: any) => void;
}
const channelIcons: Record<string, React.ReactNode> = {
instagram: <Instagram />,
linkedin: <LinkedIn />,
facebook: <Facebook />,
twitter: <Twitter />,
youtube: <YouTube />,
pinterest: <Pinterest />,
tiktok: <MusicNote />,
};
export const ChannelPackBuilder: React.FC<ChannelPackBuilderProps> = ({
channels,
onChannelPackReady,
}) => {
const { getChannelPack, channelPack, isLoadingChannelPack, error } = useProductMarketing();
const [selectedChannel, setSelectedChannel] = useState<string>(channels[0] || 'instagram');
const [channelPacks, setChannelPacks] = useState<Record<string, any>>({});
useEffect(() => {
// Load packs for all channels
channels.forEach((channel) => {
loadChannelPack(channel);
});
}, [channels]);
const loadChannelPack = async (channel: string) => {
try {
const pack = await getChannelPack(channel);
setChannelPacks((prev) => ({ ...prev, [channel]: pack }));
} catch (err) {
// Error handled in hook
}
};
const currentPack = channelPacks[selectedChannel];
return (
<GlassyCard sx={{ p: 4 }}>
<SectionHeader
title="Channel Packs"
subtitle="Platform-specific templates and optimization settings"
sx={{ mb: 3 }}
/>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Channel Tabs */}
<Tabs
value={selectedChannel}
onChange={(_, value) => setSelectedChannel(value)}
sx={{ mb: 3 }}
variant="scrollable"
scrollButtons="auto"
>
{channels.map((channel) => {
const icon = channelIcons[channel];
return (
<Tab
key={channel}
label={channel.charAt(0).toUpperCase() + channel.slice(1)}
value={channel}
icon={icon ? <>{icon}</> : undefined}
iconPosition="start"
/>
);
})}
</Tabs>
{/* Channel Pack Content */}
{isLoadingChannelPack && !currentPack ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : currentPack ? (
<Stack spacing={3}>
{/* Templates */}
{currentPack.templates && currentPack.templates.length > 0 && (
<Box>
<Typography variant="h6" gutterBottom>
Recommended Templates
</Typography>
<Grid container spacing={2} sx={{ mt: 1 }}>
{currentPack.templates.slice(0, 3).map((template: any, index: number) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<Card
sx={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<CardContent>
<Stack spacing={1}>
<Typography variant="body1" fontWeight={600}>
{template.name}
</Typography>
<Chip label={template.dimensions} size="small" />
<Typography variant="caption" color="text.secondary">
{template.aspect_ratio} {template.quality}
</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
)}
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Formats */}
{currentPack.formats && currentPack.formats.length > 0 && (
<Box>
<Typography variant="h6" gutterBottom>
Platform Formats
</Typography>
<Grid container spacing={2} sx={{ mt: 1 }}>
{currentPack.formats.map((format: any, index: number) => (
<Grid item xs={6} sm={4} key={index}>
<Paper
sx={{
p: 2,
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<Stack spacing={1}>
<Typography variant="body2" fontWeight={600}>
{format.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{format.width} × {format.height}
</Typography>
<Chip label={format.ratio} size="small" />
</Stack>
</Paper>
</Grid>
))}
</Grid>
</Box>
)}
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Copy Framework */}
{currentPack.copy_framework && Object.keys(currentPack.copy_framework).length > 0 && (
<Box>
<Typography variant="h6" gutterBottom>
Copy Framework
</Typography>
<Paper
sx={{
p: 2,
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<Stack spacing={1}>
{Object.entries(currentPack.copy_framework).map(([key, value]) => (
<Box key={key}>
<Typography variant="body2" fontWeight={600} gutterBottom>
{key.replace('_', ' ').toUpperCase()}
</Typography>
<Typography variant="body2" color="text.secondary">
{String(value)}
</Typography>
</Box>
))}
</Stack>
</Paper>
</Box>
)}
{/* Optimization Tips */}
{currentPack.optimization_tips && currentPack.optimization_tips.length > 0 && (
<Box>
<Typography variant="h6" gutterBottom>
Optimization Tips
</Typography>
<Stack spacing={1}>
{currentPack.optimization_tips.map((tip: string, index: number) => (
<Alert key={index} severity="info" sx={{ background: 'rgba(59, 130, 246, 0.1)' }}>
{tip}
</Alert>
))}
</Stack>
</Box>
)}
</Stack>
) : (
<Alert severity="info">No pack data available for {selectedChannel}</Alert>
)}
</GlassyCard>
);
};

View File

@@ -0,0 +1,150 @@
import React from 'react';
import {
Alert,
AlertTitle,
Box,
Typography,
Stack,
Chip,
CircularProgress,
LinearProgress,
} from '@mui/material';
import {
CheckCircle,
Error as ErrorIcon,
Warning,
Info,
AutoAwesome,
Image as ImageIcon,
TextFields,
AttachMoney,
} from '@mui/icons-material';
import { PreflightValidationResult } from '../../hooks/useProductMarketing';
interface PreflightValidationAlertProps {
validationResult: PreflightValidationResult | null;
isLoading?: boolean;
onClose?: () => void;
}
export const PreflightValidationAlert: React.FC<PreflightValidationAlertProps> = ({
validationResult,
isLoading = false,
onClose,
}) => {
if (isLoading) {
return (
<Alert
severity="info"
icon={<CircularProgress size={20} />}
sx={{
mb: 3,
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
}}
>
<AlertTitle>Validating Campaign...</AlertTitle>
<Typography variant="body2" color="text.secondary">
Checking subscription limits and estimating costs
</Typography>
<LinearProgress sx={{ mt: 1 }} />
</Alert>
);
}
if (!validationResult) {
return null;
}
const { can_proceed, message, summary } = validationResult;
const severity = can_proceed ? 'success' : 'warning';
const icon = can_proceed ? <CheckCircle /> : <Warning />;
const title = can_proceed ? 'Ready to Generate' : 'Action Required';
return (
<Alert
severity={severity}
icon={icon}
onClose={onClose}
sx={{
mb: 3,
background: can_proceed
? 'rgba(34, 197, 94, 0.1)'
: 'rgba(245, 158, 11, 0.1)',
border: can_proceed
? '1px solid rgba(34, 197, 94, 0.3)'
: '1px solid rgba(245, 158, 11, 0.3)',
}}
>
<AlertTitle sx={{ fontWeight: 700 }}>{title}</AlertTitle>
{message && (
<Typography variant="body2" sx={{ mb: summary ? 2 : 0 }}>
{message}
</Typography>
)}
{summary && (
<Stack spacing={2} mt={summary ? 2 : 0}>
{/* Summary Stats */}
<Box
display="flex"
gap={2}
flexWrap="wrap"
sx={{
p: 2,
borderRadius: 2,
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.08)',
}}
>
<Chip
icon={<AutoAwesome />}
label={`${summary.total_assets} Total Assets`}
color={can_proceed ? 'success' : 'default'}
variant="outlined"
/>
<Chip
icon={<ImageIcon />}
label={`${summary.image_count} Images`}
color="primary"
variant="outlined"
/>
<Chip
icon={<TextFields />}
label={`${summary.text_count} Text Assets`}
color="primary"
variant="outlined"
/>
<Chip
icon={<AttachMoney />}
label={`$${summary.estimated_cost.toFixed(2)} Est. Cost`}
color={summary.estimated_cost > 0 ? 'warning' : 'default'}
variant="outlined"
sx={{
fontWeight: 700,
fontSize: '0.875rem',
}}
/>
</Box>
{/* Additional Info */}
{can_proceed && (
<Typography variant="body2" color="text.secondary">
<Info sx={{ fontSize: 16, verticalAlign: 'middle', mr: 0.5 }} />
All subscription limits are sufficient. You can proceed with campaign creation.
</Typography>
)}
{!can_proceed && (
<Typography variant="body2" color="warning.main" sx={{ fontWeight: 600 }}>
<ErrorIcon sx={{ fontSize: 16, verticalAlign: 'middle', mr: 0.5 }} />
Please upgrade your subscription or reduce the number of assets to continue.
</Typography>
)}
</Stack>
)}
</Alert>
);
};

View File

@@ -0,0 +1,473 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
Button,
Grid,
Card,
CardContent,
Stack,
Chip,
Divider,
CircularProgress,
Alert,
} from '@mui/material';
import {
Campaign,
AutoAwesome,
PhotoLibrary,
Assessment,
TrendingUp,
CheckCircle,
RadioButtonUnchecked,
PhotoCamera,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { ImageStudioLayout } from '../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { useProductMarketing } from '../../hooks/useProductMarketing';
import { CampaignWizard } from './CampaignWizard';
import { AssetAuditPanel } from './AssetAuditPanel';
import { ProposalReview } from './ProposalReview';
import { useNavigate } from 'react-router-dom';
const MotionCard = motion(Card);
interface CampaignSummary {
campaign_id: string;
campaign_name: string;
goal: string;
status: string;
total_assets: number;
completed_assets: number;
channels: string[];
}
export const ProductMarketingDashboard: React.FC = () => {
const {
getBrandDNA,
brandDNA,
isLoadingBrandDNA,
listCampaigns,
campaigns: apiCampaigns,
isLoadingCampaigns,
} = useProductMarketing();
const [showWizard, setShowWizard] = useState(false);
const [showAssetAudit, setShowAssetAudit] = useState(false);
const [reviewCampaignId, setReviewCampaignId] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
// Load brand DNA on mount
if (!brandDNA) {
getBrandDNA();
}
// Load campaigns on mount
listCampaigns();
}, [brandDNA, getBrandDNA, listCampaigns]);
const handleCreateCampaign = () => {
setShowWizard(true);
};
const handleJourneySelect = (journey: string) => {
if (journey === 'launch') {
setShowWizard(true);
} else if (journey === 'photoshoot') {
navigate('/campaign-creator/photoshoot');
} else if (journey === 'optimize') {
// TODO: Show optimization insights
alert('Optimization insights coming soon!');
}
};
const handleWizardComplete = (blueprint: any) => {
setShowWizard(false);
// Reload campaigns from API
listCampaigns();
// Navigate to proposal review
setReviewCampaignId(blueprint.campaign_id);
};
if (showWizard) {
return <CampaignWizard onComplete={handleWizardComplete} onCancel={() => setShowWizard(false)} />;
}
if (showAssetAudit) {
return <AssetAuditPanel onClose={() => setShowAssetAudit(false)} />;
}
if (reviewCampaignId) {
return (
<ProposalReview
campaignId={reviewCampaignId}
onBack={() => {
setReviewCampaignId(null);
listCampaigns();
}}
onComplete={() => {
setReviewCampaignId(null);
listCampaigns();
}}
/>
);
}
return (
<ImageStudioLayout
headerProps={{
title: 'AI Campaign Creator',
subtitle:
'Create consistent, personalized marketing campaigns across all digital platforms. AI handles the heavy lifting—you just approve.',
}}
>
<GlassyCard
sx={{
maxWidth: 1400,
mx: 'auto',
p: { xs: 3, md: 5 },
}}
>
{/* Brand DNA Status */}
{isLoadingBrandDNA ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : brandDNA ? (
<Alert severity="success" sx={{ mb: 3 }}>
Brand DNA loaded: {brandDNA.persona?.persona_name || 'Default Persona'} {' '}
{brandDNA.writing_style?.tone || 'professional'} tone {brandDNA.target_audience?.industry_focus || 'general'} industry
</Alert>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
Brand DNA not available. Complete onboarding to enable personalized campaigns.
</Alert>
)}
{/* User Journey Selection */}
<SectionHeader
title="Choose Your Journey"
subtitle="Select how you want to create marketing assets"
sx={{ mb: 3 }}
/>
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(124, 58, 237, 0.1)',
border: '1px solid rgba(124, 58, 237, 0.3)',
}}
onClick={() => handleJourneySelect('launch')}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<Campaign sx={{ color: '#c4b5fd', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Journey A: Launch Campaign
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Create a new marketing campaign from scratch. AI generates personalized assets based on your brand DNA.
</Typography>
<Button variant="contained" startIcon={<AutoAwesome />} fullWidth>
Start Campaign Wizard
</Button>
</Stack>
</CardContent>
</MotionCard>
</Grid>
<Grid item xs={12} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
}}
onClick={() => setShowAssetAudit(true)}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<PhotoLibrary sx={{ color: '#93c5fd', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Journey B: Enhance Assets
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Upload existing assets for AI-powered quality assessment and enhancement recommendations.
</Typography>
<Button variant="contained" startIcon={<PhotoLibrary />} fullWidth>
Upload & Audit
</Button>
</Stack>
</CardContent>
</MotionCard>
</Grid>
<Grid item xs={12} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(16, 185, 129, 0.1)',
border: '1px solid rgba(16, 185, 129, 0.3)',
}}
onClick={() => handleJourneySelect('photoshoot')}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<PhotoCamera sx={{ color: '#6ee7b7', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Journey C: Product Photoshoot
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Generate professional product images for e-commerce listings and marketing campaigns.
</Typography>
<Button variant="contained" startIcon={<PhotoCamera />} fullWidth>
Launch Photoshoot Studio
</Button>
</Stack>
</CardContent>
</MotionCard>
</Grid>
<Grid item xs={12} md={4}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(191, 219, 254, 0.1)',
border: '1px solid rgba(191, 219, 254, 0.3)',
}}
onClick={() => handleJourneySelect('optimize')}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<TrendingUp sx={{ color: '#bfdbfe', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Journey D: Optimize
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Get AI-powered insights and suggestions to optimize your existing campaigns and assets.
</Typography>
<Button variant="contained" startIcon={<TrendingUp />} fullWidth>
View Insights
</Button>
</Stack>
</CardContent>
</MotionCard>
</Grid>
</Grid>
<Divider sx={{ my: 4, borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Quick Actions */}
<SectionHeader
title="Quick Actions"
subtitle="Start a new campaign or enhance existing assets"
sx={{ mb: 3 }}
/>
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} md={6}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(124, 58, 237, 0.1)',
border: '1px solid rgba(124, 58, 237, 0.3)',
}}
onClick={handleCreateCampaign}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<Campaign sx={{ color: '#c4b5fd', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Create Campaign
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Launch a new marketing campaign with AI-generated assets personalized to your brand.
</Typography>
<Button variant="contained" startIcon={<AutoAwesome />} fullWidth>
Start Campaign Wizard
</Button>
</Stack>
</CardContent>
</MotionCard>
</Grid>
<Grid item xs={12} md={6}>
<MotionCard
whileHover={{ scale: 1.02 }}
sx={{
height: '100%',
cursor: 'pointer',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
}}
onClick={() => setShowAssetAudit(true)}
>
<CardContent>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<PhotoLibrary sx={{ color: '#93c5fd', fontSize: 32 }} />
<Typography variant="h6" fontWeight={700}>
Audit Assets
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Upload existing assets for AI-powered quality assessment and enhancement recommendations.
</Typography>
<Button variant="contained" startIcon={<PhotoLibrary />} fullWidth>
Upload & Audit
</Button>
</Stack>
</CardContent>
</MotionCard>
</Grid>
</Grid>
<Divider sx={{ my: 4, borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Active Campaigns */}
<SectionHeader
title="Active Campaigns"
subtitle={
isLoadingCampaigns
? 'Loading campaigns...'
: apiCampaigns.length === 0
? 'No active campaigns. Create your first campaign to get started.'
: `${apiCampaigns.length} campaign(s) in progress`
}
sx={{ mb: 3 }}
/>
{isLoadingCampaigns ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : apiCampaigns.length === 0 ? (
<Paper
sx={{
p: 4,
textAlign: 'center',
background: 'rgba(255,255,255,0.02)',
border: '1px dashed rgba(255,255,255,0.1)',
}}
>
<Campaign sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
No campaigns yet
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Create your first campaign to start generating personalized marketing assets
</Typography>
<Button variant="contained" startIcon={<AutoAwesome />} onClick={handleCreateCampaign}>
Create Campaign
</Button>
</Paper>
) : (
<Grid container spacing={2}>
{apiCampaigns.map((campaign) => (
<Grid item xs={12} md={6} key={campaign.campaign_id}>
<GlassyCard sx={{ p: 3 }}>
<Stack spacing={2}>
<Box display="flex" justifyContent="space-between" alignItems="start">
<Box>
<Typography variant="h6" fontWeight={700} gutterBottom>
{campaign.campaign_name}
</Typography>
<Typography variant="body2" color="text.secondary">
{campaign.goal}
</Typography>
</Box>
<Chip
label={campaign.status}
size="small"
color={campaign.status === 'ready' ? 'success' : 'default'}
/>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Progress
</Typography>
<Box display="flex" alignItems="center" gap={1}>
<Box flex={1}>
<Box
sx={{
height: 8,
borderRadius: 1,
background: 'rgba(255,255,255,0.1)',
overflow: 'hidden',
}}
>
<Box
sx={{
height: '100%',
width: `${((campaign.asset_nodes?.filter((n: any) => n.status === 'ready' || n.status === 'approved').length || 0) / (campaign.asset_nodes?.length || 1)) * 100}%`,
background: 'linear-gradient(90deg, #7c3aed, #a78bfa)',
transition: 'width 0.3s ease',
}}
/>
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
{campaign.asset_nodes?.filter((n: any) => n.status === 'ready' || n.status === 'approved').length || 0}/{campaign.asset_nodes?.length || 0}
</Typography>
</Box>
</Box>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Channels
</Typography>
<Box display="flex" gap={1} flexWrap="wrap">
{campaign.channels.map((channel) => (
<Chip key={channel} label={channel} size="small" />
))}
</Box>
</Box>
<Button
variant="outlined"
fullWidth
onClick={() => {
// Check if proposals exist, if so show review, otherwise show campaign
setReviewCampaignId(campaign.campaign_id);
}}
>
{campaign.asset_nodes?.some((n: any) => n.status === 'proposed') ? 'Review Proposals' : 'View Campaign'}
</Button>
</Stack>
</GlassyCard>
</Grid>
))}
</Grid>
)}
</GlassyCard>
</ImageStudioLayout>
);
};

View File

@@ -0,0 +1,248 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
Stack,
Grid,
Card,
CardMedia,
CardContent,
IconButton,
Tooltip,
CircularProgress,
Alert,
Button,
Chip,
} from '@mui/material';
import {
OpenInNew,
PhotoLibrary,
Refresh,
Favorite,
FavoriteBorder,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useContentAssets, ContentAsset } from '../../../hooks/useContentAssets';
import { useNavigate } from 'react-router-dom';
const MotionCard = motion(Card);
interface ProductAssetsGalleryProps {
limit?: number;
onAssetSelect?: (asset: ContentAsset) => void;
showViewAllButton?: boolean;
refreshKey?: number; // Trigger refresh when this changes
}
export const ProductAssetsGallery: React.FC<ProductAssetsGalleryProps> = ({
limit = 6,
onAssetSelect,
showViewAllButton = true,
refreshKey = 0,
}) => {
const navigate = useNavigate();
const [refreshing, setRefreshing] = useState(false);
// Filter for product marketing images
const filters = {
asset_type: 'image' as const,
source_module: 'product_marketing',
tags: ['product_marketing', 'product_image'],
limit: limit,
offset: 0,
};
const { assets, loading, error, refetch, toggleFavorite } = useContentAssets(filters);
// Refresh when refreshKey changes
useEffect(() => {
if (refreshKey > 0) {
refetch();
}
}, [refreshKey, refetch]);
const handleRefresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const handleViewInLibrary = () => {
navigate('/image-studio/asset-library', {
state: { filters: { source_module: 'product_marketing', asset_type: 'image' } },
});
};
if (loading && assets.length === 0) {
return (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mb: 2 }}>
Failed to load product images: {error}
</Alert>
);
}
if (assets.length === 0) {
return (
<Box
sx={{
textAlign: 'center',
py: 4,
px: 2,
}}
>
<PhotoLibrary sx={{ fontSize: 48, color: 'text.secondary', opacity: 0.5, mb: 2 }} />
<Typography variant="body1" color="text.secondary" gutterBottom>
No product images yet
</Typography>
<Typography variant="body2" color="text.secondary">
Generate your first product image to see it here
</Typography>
</Box>
);
}
return (
<Stack spacing={2}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" fontWeight={600}>
Your Product Images ({assets.length})
</Typography>
<Stack direction="row" spacing={1}>
<Tooltip title="Refresh">
<IconButton
size="small"
onClick={handleRefresh}
disabled={refreshing || loading}
>
<Refresh />
</IconButton>
</Tooltip>
{showViewAllButton && (
<Button
variant="outlined"
size="small"
startIcon={<OpenInNew />}
onClick={handleViewInLibrary}
>
View All in Library
</Button>
)}
</Stack>
</Box>
<Grid container spacing={2}>
{assets.map((asset, index) => (
<Grid item xs={6} sm={4} md={3} key={asset.id}>
<MotionCard
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
sx={{
cursor: onAssetSelect ? 'pointer' : 'default',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
'&:hover': {
borderColor: 'rgba(255, 255, 255, 0.3)',
transform: 'translateY(-4px)',
},
transition: 'all 0.2s',
}}
onClick={() => onAssetSelect?.(asset)}
>
{asset.file_url ? (
<CardMedia
component="img"
image={asset.file_url}
alt={asset.title || 'Product image'}
sx={{
height: 150,
objectFit: 'cover',
}}
/>
) : (
<Box
sx={{
height: 150,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.2)',
}}
>
<PhotoLibrary sx={{ fontSize: 40, color: 'text.secondary' }} />
</Box>
)}
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
<Stack spacing={1}>
<Typography
variant="caption"
fontWeight={600}
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{asset.title || 'Untitled'}
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
{asset.provider && (
<Chip
label={asset.provider}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.65rem' }}
/>
)}
{asset.tags?.slice(0, 1).map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.65rem' }}
/>
))}
</Stack>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="caption" color="text.secondary">
{asset.created_at
? new Date(asset.created_at).toLocaleDateString()
: 'Recent'}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleFavorite(asset.id);
}}
sx={{ p: 0.5 }}
>
{asset.is_favorite ? (
<Favorite sx={{ fontSize: 18, color: '#ef4444' }} />
) : (
<FavoriteBorder sx={{ fontSize: 18 }} />
)}
</IconButton>
</Stack>
</Stack>
</CardContent>
</MotionCard>
</Grid>
))}
</Grid>
</Stack>
);
};

View File

@@ -0,0 +1,251 @@
import React from 'react';
import {
Box,
Card,
CardContent,
Typography,
Stack,
Button,
CircularProgress,
Alert,
Chip,
IconButton,
Tooltip,
} from '@mui/material';
import {
Download,
Share,
Favorite,
FavoriteBorder,
Refresh,
Image as ImageIcon,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
const MotionCard = motion(Card);
interface GeneratedProductImage {
image_url?: string;
asset_id?: number;
product_name: string;
provider?: string;
model?: string;
cost?: number;
generation_time?: number;
success: boolean;
error?: string;
}
interface ProductImagePreviewProps {
generatedImages: GeneratedProductImage[];
isLoading: boolean;
error: string | null;
onDownload?: (image: GeneratedProductImage) => void;
onRegenerate?: () => void;
onSaveToLibrary?: (image: GeneratedProductImage) => void;
}
export const ProductImagePreview: React.FC<ProductImagePreviewProps> = ({
generatedImages,
isLoading,
error,
onDownload,
onRegenerate,
onSaveToLibrary,
}) => {
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 400,
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="body1" color="text.secondary">
Generating your product image...
</Typography>
<Typography variant="caption" color="text.secondary">
This may take 30-60 seconds
</Typography>
</Box>
);
}
if (error) {
return (
<Alert
severity="error"
sx={{ mb: 2 }}
action={
onRegenerate && (
<Button color="inherit" size="small" onClick={onRegenerate}>
Try Again
</Button>
)
}
>
{error}
</Alert>
);
}
if (!generatedImages || generatedImages.length === 0) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 400,
gap: 2,
textAlign: 'center',
p: 4,
}}
>
<ImageIcon sx={{ fontSize: 64, color: 'text.secondary', opacity: 0.5 }} />
<Typography variant="h6" color="text.secondary">
No images generated yet
</Typography>
<Typography variant="body2" color="text.secondary">
Fill in the product details and click "Generate Product Image" to create your first product photo
</Typography>
</Box>
);
}
return (
<Stack spacing={3}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" fontWeight={600}>
Generated Images ({generatedImages.length})
</Typography>
{onRegenerate && (
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={onRegenerate}
size="small"
>
Regenerate
</Button>
)}
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
},
gap: 3,
}}
>
{generatedImages.map((image, index) => (
<MotionCard
key={image.asset_id || index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
sx={{
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
overflow: 'hidden',
}}
>
{image.image_url ? (
<Box
component="img"
src={image.image_url}
alt={image.product_name}
sx={{
width: '100%',
height: 300,
objectFit: 'cover',
display: 'block',
}}
/>
) : (
<Box
sx={{
width: '100%',
height: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.2)',
}}
>
<ImageIcon sx={{ fontSize: 64, color: 'text.secondary' }} />
</Box>
)}
<CardContent>
<Stack spacing={2}>
<Typography variant="subtitle2" fontWeight={600}>
{image.product_name}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{image.provider && (
<Chip label={image.provider} size="small" variant="outlined" />
)}
{image.model && (
<Chip label={image.model} size="small" variant="outlined" />
)}
{image.cost && (
<Chip
label={`$${image.cost.toFixed(2)}`}
size="small"
color="primary"
variant="outlined"
/>
)}
</Stack>
<Stack direction="row" spacing={1}>
{onDownload && (
<Button
variant="outlined"
size="small"
startIcon={<Download />}
onClick={() => onDownload(image)}
fullWidth
>
Download
</Button>
)}
{onSaveToLibrary && (
<Button
variant="outlined"
size="small"
startIcon={<FavoriteBorder />}
onClick={() => onSaveToLibrary(image)}
fullWidth
>
Save
</Button>
)}
</Stack>
{image.generation_time && (
<Typography variant="caption" color="text.secondary">
Generated in {image.generation_time.toFixed(1)}s
</Typography>
)}
</Stack>
</CardContent>
</MotionCard>
))}
</Box>
</Stack>
);
};

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Box, TextField, Stack, Typography } from '@mui/material';
import { Product as ProductIcon } from '@mui/icons-material';
interface ProductInfoFormProps {
productName: string;
productDescription: string;
onProductNameChange: (value: string) => void;
onProductDescriptionChange: (value: string) => void;
}
export const ProductInfoForm: React.FC<ProductInfoFormProps> = ({
productName,
productDescription,
onProductNameChange,
onProductDescriptionChange,
}) => {
return (
<Stack spacing={3}>
<Box display="flex" alignItems="center" gap={1}>
<ProductIcon sx={{ color: '#c4b5fd' }} />
<Typography variant="h6" fontWeight={600}>
Product Information
</Typography>
</Box>
<TextField
label="Product Name"
value={productName}
onChange={(e) => onProductNameChange(e.target.value)}
fullWidth
required
placeholder="e.g., Premium Wireless Headphones"
helperText="Enter the name of your product"
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
'&:hover': {
background: 'rgba(255, 255, 255, 0.08)',
},
},
}}
/>
<TextField
label="Product Description"
value={productDescription}
onChange={(e) => onProductDescriptionChange(e.target.value)}
fullWidth
required
multiline
rows={4}
placeholder="Describe your product: features, benefits, target audience..."
helperText="Provide details about your product to help AI generate accurate images"
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
'&:hover': {
background: 'rgba(255, 255, 255, 0.08)',
},
},
}}
/>
</Stack>
);
};

View File

@@ -0,0 +1,327 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Grid,
Stack,
Alert,
CircularProgress,
Typography,
Divider,
Paper,
} from '@mui/material';
import {
AutoAwesome,
PhotoCamera,
ArrowBack,
} from '@mui/icons-material';
import { ImageStudioLayout } from '../../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../../ImageStudio/ui/SectionHeader';
import { useProductMarketing } from '../../../hooks/useProductMarketing';
import { ProductInfoForm } from './ProductInfoForm';
import { StyleSelector } from './StyleSelector';
import { ProductVariations } from './ProductVariations';
import { ProductImagePreview } from './ProductImagePreview';
import { ProductAssetsGallery } from './ProductAssetsGallery';
import { useNavigate } from 'react-router-dom';
interface ProductPhotoshootStudioProps {
onBack?: () => void;
onComplete?: (images: any[]) => void;
}
export const ProductPhotoshootStudio: React.FC<ProductPhotoshootStudioProps> = ({
onBack,
onComplete,
}) => {
const {
generateProductImage,
isGeneratingProductImage,
generatedProductImage,
productImageError,
brandDNA,
getBrandDNA,
isLoadingBrandDNA,
} = useProductMarketing();
// Product Information
const [productName, setProductName] = useState('');
const [productDescription, setProductDescription] = useState('');
// Style Settings
const [environment, setEnvironment] = useState('studio');
const [backgroundStyle, setBackgroundStyle] = useState('white');
const [lighting, setLighting] = useState('natural');
const [style, setStyle] = useState('photorealistic');
// Variations
const [productVariant, setProductVariant] = useState('');
const [angle, setAngle] = useState('');
const [resolution, setResolution] = useState('1024x1024');
const [numVariations, setNumVariations] = useState(1);
// Additional Options
const [additionalContext, setAdditionalContext] = useState('');
const [brandColors, setBrandColors] = useState<string[]>([]);
// Generated Images
const [generatedImages, setGeneratedImages] = useState<any[]>([]);
const [assetsGalleryRefetch, setAssetsGalleryRefetch] = useState(0);
// Load brand DNA on mount
useEffect(() => {
if (!brandDNA) {
getBrandDNA().catch(console.error);
}
}, [brandDNA, getBrandDNA]);
// Extract brand colors from brand DNA
useEffect(() => {
if (brandDNA?.visual_identity?.color_palette) {
setBrandColors(brandDNA.visual_identity.color_palette);
}
}, [brandDNA]);
const handleGenerate = async () => {
if (!productName.trim() || !productDescription.trim()) {
return;
}
try {
const result = await generateProductImage({
product_name: productName,
product_description: productDescription,
environment,
background_style: backgroundStyle,
lighting,
style,
product_variant: productVariant || undefined,
angle: angle || undefined,
resolution,
num_variations: numVariations,
brand_colors: brandColors.length > 0 ? brandColors : undefined,
additional_context: additionalContext || undefined,
});
if (result && result.success) {
// Add to generated images list
setGeneratedImages((prev) => [...prev, result]);
// Trigger Asset Library refresh
setAssetsGalleryRefetch((prev) => prev + 1);
// Call onComplete callback if provided
if (onComplete) {
onComplete([result]);
}
}
} catch (error: any) {
console.error('Failed to generate product image:', error);
}
};
const handleDownload = (image: any) => {
if (image.image_url) {
const link = document.createElement('a');
link.href = image.image_url;
link.download = `${productName.replace(/\s+/g, '_')}_${Date.now()}.png`;
link.click();
}
};
const handleSaveToLibrary = (image: any) => {
// Image is already saved to Asset Library via the API
// This could trigger a refresh or show a success message
console.log('Image saved to library:', image.asset_id);
};
const navigate = useNavigate();
const canGenerate = productName.trim() && productDescription.trim();
const handleBackNavigation = () => {
if (onBack) {
onBack();
} else {
navigate('/campaign-creator');
}
};
return (
<ImageStudioLayout
headerProps={{
title: 'Product Photoshoot Studio',
subtitle:
'Generate professional product images for e-commerce listings, marketing materials, and product showcases.',
}}
>
<GlassyCard
sx={{
maxWidth: 1400,
mx: 'auto',
p: { xs: 3, md: 5 },
}}
>
<Button
startIcon={<ArrowBack />}
onClick={handleBackNavigation}
sx={{ mb: 3 }}
variant="outlined"
>
Back to Campaign Creator
</Button>
{/* Brand DNA Status */}
{isLoadingBrandDNA ? (
<Box display="flex" justifyContent="center" py={2}>
<CircularProgress size={24} />
</Box>
) : brandDNA ? (
<Alert severity="success" sx={{ mb: 3 }}>
Brand DNA loaded: {brandDNA.persona?.persona_name || 'Default Persona'} Using brand
colors and style guidelines
</Alert>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
Complete onboarding to enable personalized product images with your brand DNA.
</Alert>
)}
<Grid container spacing={4}>
{/* Left Column: Form */}
<Grid item xs={12} md={5}>
<Stack spacing={4}>
{/* Product Information */}
<Paper
sx={{
p: 3,
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<ProductInfoForm
productName={productName}
productDescription={productDescription}
onProductNameChange={setProductName}
onProductDescriptionChange={setProductDescription}
/>
</Paper>
{/* Style Selector */}
<Paper
sx={{
p: 3,
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<StyleSelector
environment={environment}
backgroundStyle={backgroundStyle}
lighting={lighting}
style={style}
onEnvironmentChange={setEnvironment}
onBackgroundStyleChange={setBackgroundStyle}
onLightingChange={setLighting}
onStyleChange={setStyle}
/>
</Paper>
{/* Product Variations */}
<Paper
sx={{
p: 3,
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<ProductVariations
productVariant={productVariant}
angle={angle}
resolution={resolution}
numVariations={numVariations}
onProductVariantChange={setProductVariant}
onAngleChange={setAngle}
onResolutionChange={setResolution}
onNumVariationsChange={setNumVariations}
/>
</Paper>
{/* Generate Button */}
<Button
variant="contained"
size="large"
fullWidth
startIcon={<AutoAwesome />}
onClick={handleGenerate}
disabled={!canGenerate || isGeneratingProductImage}
sx={{
py: 2,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #764ba2 0%, #667eea 100%)',
},
}}
>
{isGeneratingProductImage ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} color="inherit" />
Generating...
</>
) : (
'Generate Product Image'
)}
</Button>
{/* Error Display */}
{productImageError && (
<Alert severity="error" onClose={() => {}}>
{productImageError}
</Alert>
)}
</Stack>
</Grid>
{/* Right Column: Preview & Gallery */}
<Grid item xs={12} md={7}>
<Stack spacing={3}>
{/* Newly Generated Images Preview */}
<Paper
sx={{
p: 3,
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
minHeight: 400,
}}
>
<ProductImagePreview
generatedImages={generatedImages}
isLoading={isGeneratingProductImage}
error={productImageError}
onDownload={handleDownload}
onSaveToLibrary={handleSaveToLibrary}
onRegenerate={canGenerate ? handleGenerate : undefined}
/>
</Paper>
{/* Asset Library Gallery */}
<Paper
sx={{
p: 3,
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<ProductAssetsGallery
refreshKey={assetsGalleryRefetch}
limit={6}
showViewAllButton={true}
/>
</Paper>
</Stack>
</Grid>
</Grid>
</GlassyCard>
</ImageStudioLayout>
);
};

View File

@@ -0,0 +1,163 @@
import React from 'react';
import {
Box,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
Typography,
Stack,
} from '@mui/material';
import { AspectRatio, Colorize } from '@mui/icons-material';
interface ProductVariationsProps {
productVariant: string;
angle: string;
resolution: string;
numVariations: number;
onProductVariantChange: (value: string) => void;
onAngleChange: (value: string) => void;
onResolutionChange: (value: string) => void;
onNumVariationsChange: (value: number) => void;
}
const ANGLES = [
{ value: 'front', label: 'Front View', description: 'Centered composition' },
{ value: 'side', label: 'Side View', description: 'Profile showing depth' },
{ value: 'top', label: 'Top Down', description: 'Flat lay style' },
{ value: '360', label: '3/4 Angle', description: 'Showing multiple sides' },
];
const RESOLUTIONS = [
{ value: '1024x1024', label: 'Square (1024×1024)', description: 'Instagram, social media' },
{ value: '1280x720', label: 'Landscape (1280×720)', description: 'Website, banners' },
{ value: '720x1280', label: 'Portrait (720×1280)', description: 'Mobile, stories' },
{ value: 'square', label: 'Square Default', description: '1:1 aspect ratio' },
{ value: 'landscape', label: 'Landscape Default', description: '16:9 aspect ratio' },
{ value: 'portrait', label: 'Portrait Default', description: '9:16 aspect ratio' },
];
export const ProductVariations: React.FC<ProductVariationsProps> = ({
productVariant,
angle,
resolution,
numVariations,
onProductVariantChange,
onAngleChange,
onResolutionChange,
onNumVariationsChange,
}) => {
return (
<Stack spacing={3}>
<Box display="flex" alignItems="center" gap={1}>
<Colorize sx={{ color: '#c4b5fd' }} />
<Typography variant="h6" fontWeight={600}>
Product Variations & Settings
</Typography>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
label="Product Variant (Optional)"
value={productVariant}
onChange={(e) => onProductVariantChange(e.target.value)}
fullWidth
placeholder="e.g., Black, Large, Pro Model"
helperText="Color, size, model variant, etc."
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
'&:hover': {
background: 'rgba(255, 255, 255, 0.08)',
},
},
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Product Angle</InputLabel>
<Select
value={angle}
label="Product Angle"
onChange={(e) => onAngleChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
},
}}
>
<MenuItem value="">
<em>Default (Auto)</em>
</MenuItem>
{ANGLES.map((ang) => (
<MenuItem key={ang.value} value={ang.value}>
<Box>
<Typography variant="body1">{ang.label}</Typography>
<Typography variant="caption" color="text.secondary">
{ang.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Resolution</InputLabel>
<Select
value={resolution}
label="Resolution"
onChange={(e) => onResolutionChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
},
}}
>
{RESOLUTIONS.map((res) => (
<MenuItem key={res.value} value={res.value}>
<Box>
<Typography variant="body1">{res.label}</Typography>
<Typography variant="caption" color="text.secondary">
{res.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Number of Variations</InputLabel>
<Select
value={numVariations}
label="Number of Variations"
onChange={(e) => onNumVariationsChange(Number(e.target.value))}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
},
}}
>
{[1, 2, 3, 4, 5].map((num) => (
<MenuItem key={num} value={num}>
{num} {num === 1 ? 'variation' : 'variations'}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</Stack>
);
};

View File

@@ -0,0 +1,188 @@
import React from 'react';
import {
Box,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
Typography,
Chip,
Stack,
} from '@mui/material';
import { Palette, LightMode, Style as StyleIcon, PhotoCamera } from '@mui/icons-material';
interface StyleSelectorProps {
environment: string;
backgroundStyle: string;
lighting: string;
style: string;
onEnvironmentChange: (value: string) => void;
onBackgroundStyleChange: (value: string) => void;
onLightingChange: (value: string) => void;
onStyleChange: (value: string) => void;
}
const ENVIRONMENTS = [
{ value: 'studio', label: 'Studio', icon: '🏛️', description: 'Clean, professional studio photography' },
{ value: 'lifestyle', label: 'Lifestyle', icon: '🏠', description: 'Product in use, relatable settings' },
{ value: 'outdoor', label: 'Outdoor', icon: '🌲', description: 'Natural outdoor environments' },
{ value: 'minimalist', label: 'Minimalist', icon: '✨', description: 'Simple, clean aesthetic' },
{ value: 'luxury', label: 'Luxury', icon: '💎', description: 'Premium, high-end feel' },
];
const BACKGROUND_STYLES = [
{ value: 'white', label: 'White Background', description: 'Clean white backdrop' },
{ value: 'transparent', label: 'Transparent', description: 'Isolated product' },
{ value: 'lifestyle', label: 'Lifestyle', description: 'Contextual environment' },
{ value: 'branded', label: 'Branded', description: 'Brand colors/background' },
];
const LIGHTING_STYLES = [
{ value: 'natural', label: 'Natural', description: 'Soft, balanced lighting' },
{ value: 'studio', label: 'Studio', description: 'Even, professional lighting' },
{ value: 'dramatic', label: 'Dramatic', description: 'High contrast, artistic' },
{ value: 'soft', label: 'Soft', description: 'Gentle, diffused lighting' },
];
const IMAGE_STYLES = [
{ value: 'photorealistic', label: 'Photorealistic', description: 'Highly detailed, professional' },
{ value: 'minimalist', label: 'Minimalist', description: 'Clean, simple composition' },
{ value: 'luxury', label: 'Luxury', description: 'Sophisticated, refined' },
{ value: 'technical', label: 'Technical', description: 'Feature-focused documentation' },
];
export const StyleSelector: React.FC<StyleSelectorProps> = ({
environment,
backgroundStyle,
lighting,
style,
onEnvironmentChange,
onBackgroundStyleChange,
onLightingChange,
onStyleChange,
}) => {
return (
<Stack spacing={3}>
<Box display="flex" alignItems="center" gap={1}>
<StyleIcon sx={{ color: '#c4b5fd' }} />
<Typography variant="h6" fontWeight={600}>
Style & Environment
</Typography>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Environment</InputLabel>
<Select
value={environment}
label="Environment"
onChange={(e) => onEnvironmentChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
},
}}
>
{ENVIRONMENTS.map((env) => (
<MenuItem key={env.value} value={env.value}>
<Box>
<Typography variant="body1">
{env.icon} {env.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{env.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Background Style</InputLabel>
<Select
value={backgroundStyle}
label="Background Style"
onChange={(e) => onBackgroundStyleChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
},
}}
>
{BACKGROUND_STYLES.map((bg) => (
<MenuItem key={bg.value} value={bg.value}>
<Box>
<Typography variant="body1">{bg.label}</Typography>
<Typography variant="caption" color="text.secondary">
{bg.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Lighting</InputLabel>
<Select
value={lighting}
label="Lighting"
onChange={(e) => onLightingChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
},
}}
>
{LIGHTING_STYLES.map((light) => (
<MenuItem key={light.value} value={light.value}>
<Box>
<Typography variant="body1">{light.label}</Typography>
<Typography variant="caption" color="text.secondary">
{light.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Image Style</InputLabel>
<Select
value={style}
label="Image Style"
onChange={(e) => onStyleChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
background: 'rgba(255, 255, 255, 0.05)',
},
}}
>
{IMAGE_STYLES.map((imgStyle) => (
<MenuItem key={imgStyle.value} value={imgStyle.value}>
<Box>
<Typography variant="body1">{imgStyle.label}</Typography>
<Typography variant="caption" color="text.secondary">
{imgStyle.description}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</Stack>
);
};

View File

@@ -0,0 +1,7 @@
export { ProductPhotoshootStudio } from './ProductPhotoshootStudio';
export { ProductInfoForm } from './ProductInfoForm';
export { StyleSelector } from './StyleSelector';
export { ProductVariations } from './ProductVariations';
export { ProductImagePreview } from './ProductImagePreview';
export { ProductAssetsGallery } from './ProductAssetsGallery';

View File

@@ -0,0 +1,451 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
Button,
Stack,
Card,
CardContent,
Chip,
Divider,
Alert,
CircularProgress,
Grid,
TextField,
IconButton,
Tooltip,
Checkbox,
FormControlLabel,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
ArrowBack,
CheckCircle,
Close,
AutoAwesome,
Edit,
PlayArrow,
AttachMoney,
PhotoLibrary,
Description,
ExpandMore,
Info,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { ImageStudioLayout } from '../ImageStudio/ImageStudioLayout';
import { GlassyCard } from '../ImageStudio/ui/GlassyCard';
import { SectionHeader } from '../ImageStudio/ui/SectionHeader';
import { CampaignFlowIndicator } from './CampaignFlowIndicator';
import { useProductMarketing, AssetProposal } from '../../hooks/useProductMarketing';
interface ProposalReviewProps {
campaignId: string;
onBack: () => void;
onComplete: () => void;
}
export const ProposalReview: React.FC<ProposalReviewProps> = ({
campaignId,
onBack,
onComplete,
}) => {
const {
getCampaignProposals,
proposals,
isGeneratingProposals,
generateAsset,
isGeneratingAsset,
error,
} = useProductMarketing();
const [selectedProposals, setSelectedProposals] = useState<Set<string>>(new Set());
const [editingProposal, setEditingProposal] = useState<string | null>(null);
const [editedPrompts, setEditedPrompts] = useState<Record<string, string>>({});
const [isGenerating, setIsGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState<Record<string, boolean>>({});
useEffect(() => {
// Load proposals for this campaign
getCampaignProposals(campaignId).catch(console.error);
}, [campaignId, getCampaignProposals]);
const handleToggleProposal = (assetId: string) => {
setSelectedProposals((prev) => {
const next = new Set(prev);
if (next.has(assetId)) {
next.delete(assetId);
} else {
next.add(assetId);
}
return next;
});
};
const handleSelectAll = () => {
if (proposals && proposals.proposals) {
if (selectedProposals.size === Object.keys(proposals.proposals).length) {
setSelectedProposals(new Set());
} else {
setSelectedProposals(new Set(Object.keys(proposals.proposals)));
}
}
};
const handleEditPrompt = (assetId: string, currentPrompt: string) => {
setEditingProposal(assetId);
setEditedPrompts((prev) => ({
...prev,
[assetId]: currentPrompt,
}));
};
const handleSavePrompt = (assetId: string) => {
setEditingProposal(null);
// Prompt is already saved in editedPrompts state
};
const handleGenerateSelected = async () => {
if (!proposals || !proposals.proposals || selectedProposals.size === 0) {
return;
}
setIsGenerating(true);
const progress: Record<string, boolean> = {};
try {
// Generate assets one by one
for (const assetId of selectedProposals) {
const proposal = proposals.proposals[assetId];
if (!proposal) continue;
progress[assetId] = true;
setGenerationProgress({ ...progress });
try {
// Use edited prompt if available
const promptToUse = editedPrompts[assetId] || proposal.proposed_prompt;
await generateAsset(
{
...proposal,
proposed_prompt: promptToUse,
},
{}
);
progress[assetId] = false;
setGenerationProgress({ ...progress });
} catch (err) {
console.error(`Failed to generate asset ${assetId}:`, err);
progress[assetId] = false;
setGenerationProgress({ ...progress });
}
}
// After all assets are generated, complete the flow
setTimeout(() => {
onComplete();
}, 1000);
} catch (err) {
console.error('Error generating assets:', err);
} finally {
setIsGenerating(false);
setGenerationProgress({});
}
};
const totalCost = proposals
? Object.values(proposals.proposals)
.filter((p, idx) => selectedProposals.has(Object.keys(proposals.proposals)[idx]))
.reduce((sum, p) => sum + (p.cost_estimate || 0), 0)
: 0;
const proposalsList = proposals?.proposals ? Object.entries(proposals.proposals) : [];
return (
<ImageStudioLayout
headerProps={{
title: 'Review Asset Proposals',
subtitle: 'Review and approve AI-generated proposals for your campaign assets',
}}
>
<GlassyCard
sx={{
maxWidth: 1200,
mx: 'auto',
p: { xs: 3, md: 4 },
}}
>
<Button startIcon={<ArrowBack />} onClick={onBack} sx={{ mb: 3 }}>
Back to Campaign
</Button>
<CampaignFlowIndicator currentStep="review" />
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => {}}>
{error}
</Alert>
)}
{isGeneratingProposals ? (
<Box display="flex" flexDirection="column" alignItems="center" py={6}>
<CircularProgress size={48} sx={{ mb: 2 }} />
<Typography variant="h6" gutterBottom>
Generating Proposals...
</Typography>
<Typography variant="body2" color="text.secondary">
AI is creating personalized asset proposals based on your brand DNA
</Typography>
</Box>
) : proposalsList.length === 0 ? (
<Alert severity="info" sx={{ mb: 3 }}>
No proposals found. Generate proposals first.
</Alert>
) : (
<Stack spacing={3}>
{/* Header with actions */}
<Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Box>
<Typography variant="h5" fontWeight={700} gutterBottom>
{proposalsList.length} Asset Proposals
</Typography>
<Typography variant="body2" color="text.secondary">
Review and select proposals to generate assets
</Typography>
</Box>
<Box display="flex" gap={2}>
<Button variant="outlined" onClick={handleSelectAll}>
{selectedProposals.size === proposalsList.length ? 'Deselect All' : 'Select All'}
</Button>
<Button
variant="contained"
startIcon={isGenerating ? <CircularProgress size={16} /> : <PlayArrow />}
onClick={handleGenerateSelected}
disabled={selectedProposals.size === 0 || isGenerating}
>
{isGenerating ? 'Generating...' : `Generate ${selectedProposals.size} Selected`}
</Button>
</Box>
</Box>
{/* Cost Summary */}
{selectedProposals.size > 0 && (
<Alert severity="info" icon={<AttachMoney />}>
<Typography variant="body2">
<strong>Estimated Cost:</strong> ${totalCost.toFixed(2)} for {selectedProposals.size} asset(s)
</Typography>
</Alert>
)}
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Proposals List */}
<Grid container spacing={2}>
{proposalsList.map(([assetId, proposal]) => {
const isSelected = selectedProposals.has(assetId);
const isGenerating = generationProgress[assetId];
const isEditing = editingProposal === assetId;
const editedPrompt = editedPrompts[assetId] || proposal.proposed_prompt;
return (
<Grid item xs={12} key={assetId}>
<GlassyCard
sx={{
border: isSelected ? '2px solid #7c3aed' : '1px solid rgba(255,255,255,0.08)',
background: isSelected ? 'rgba(124, 58, 237, 0.1)' : 'rgba(255,255,255,0.02)',
transition: 'all 0.2s',
}}
>
<CardContent>
<Stack spacing={2}>
{/* Proposal Header */}
<Box display="flex" justifyContent="space-between" alignItems="start">
<Box display="flex" alignItems="center" gap={2}>
<Checkbox
checked={isSelected}
onChange={() => handleToggleProposal(assetId)}
disabled={isGenerating}
/>
<Box>
<Typography variant="h6" fontWeight={700}>
{proposal.asset_type.toUpperCase()} - {proposal.channel}
</Typography>
<Box display="flex" gap={1} mt={1}>
<Chip
label={proposal.channel}
size="small"
icon={<PhotoLibrary />}
/>
<Chip
label={proposal.recommended_provider || 'Auto'}
size="small"
color="secondary"
/>
<Chip
label={`$${proposal.cost_estimate?.toFixed(2) || '0.00'}`}
size="small"
icon={<AttachMoney />}
color="success"
/>
</Box>
</Box>
</Box>
{isGenerating && (
<CircularProgress size={24} />
)}
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Concept Summary */}
{proposal.concept_summary && (
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Concept
</Typography>
<Typography variant="body1">{proposal.concept_summary}</Typography>
</Box>
)}
{/* Prompt */}
<Accordion sx={{ background: 'rgba(255,255,255,0.02)' }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex" alignItems="center" gap={1}>
<AutoAwesome fontSize="small" />
<Typography variant="body2" fontWeight={600}>
AI-Generated Prompt
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
{isEditing ? (
<Stack spacing={2}>
<TextField
multiline
rows={4}
value={editedPrompt}
onChange={(e) =>
setEditedPrompts((prev) => ({
...prev,
[assetId]: e.target.value,
}))
}
fullWidth
sx={{
'& .MuiInputBase-root': {
background: 'rgba(255,255,255,0.05)',
},
}}
/>
<Box display="flex" gap={2}>
<Button
variant="contained"
size="small"
onClick={() => handleSavePrompt(assetId)}
>
Save
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setEditingProposal(null);
setEditedPrompts((prev) => {
const next = { ...prev };
delete next[assetId];
return next;
});
}}
>
Cancel
</Button>
</Box>
</Stack>
) : (
<Stack spacing={2}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{editedPrompt}
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<Edit />}
onClick={() => handleEditPrompt(assetId, editedPrompt)}
>
Edit Prompt
</Button>
</Stack>
)}
</AccordionDetails>
</Accordion>
{/* Template Info */}
{proposal.recommended_template && (
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
Recommended Template
</Typography>
<Chip label={proposal.recommended_template} size="small" />
</Box>
)}
</Stack>
</CardContent>
</GlassyCard>
</Grid>
);
})}
</Grid>
{/* Bottom Actions */}
<Box
sx={{
position: 'sticky',
bottom: 0,
background: 'rgba(15,23,42,0.95)',
backdropFilter: 'blur(20px)',
p: 3,
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.08)',
mt: 4,
}}
>
<Box display="flex" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={2}>
<Box>
<Typography variant="body2" color="text.secondary">
{selectedProposals.size} of {proposalsList.length} proposals selected
</Typography>
{selectedProposals.size > 0 && (
<Typography variant="h6" color="success.main">
Estimated Cost: ${totalCost.toFixed(2)}
</Typography>
)}
</Box>
<Box display="flex" gap={2}>
<Button variant="outlined" onClick={onBack}>
Cancel
</Button>
<Button
variant="contained"
size="large"
startIcon={isGenerating ? <CircularProgress size={20} /> : <AutoAwesome />}
onClick={handleGenerateSelected}
disabled={selectedProposals.size === 0 || isGenerating}
>
{isGenerating
? 'Generating Assets...'
: `Generate ${selectedProposals.size} Asset${selectedProposals.size !== 1 ? 's' : ''}`}
</Button>
</Box>
</Box>
</Box>
</Stack>
)}
</GlassyCard>
</ImageStudioLayout>
);
};

View File

@@ -0,0 +1,8 @@
export { ProductMarketingDashboard } from './ProductMarketingDashboard';
export { CampaignWizard } from './CampaignWizard';
export { AssetAuditPanel } from './AssetAuditPanel';
export { ChannelPackBuilder } from './ChannelPackBuilder';
export { ProposalReview } from './ProposalReview';
export { CampaignFlowIndicator } from './CampaignFlowIndicator';
export { ProductPhotoshootStudio } from './ProductPhotoshootStudio';

View File

@@ -15,7 +15,7 @@ export interface ContentAsset {
description?: string;
prompt?: string;
tags: string[];
metadata: Record<string, any>;
asset_metadata: Record<string, any>;
provider?: string;
model?: string;
cost: number;
@@ -44,7 +44,7 @@ export interface AssetListResponse {
offset: number;
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8000';
export const useContentAssets = (filters: AssetFilters = {}) => {
const { getToken } = useAuth();

View File

@@ -0,0 +1,496 @@
import { useState, useCallback } from 'react';
import { aiApiClient } from '../api/client';
export interface CampaignCreateRequest {
campaign_name: string;
goal: string;
kpi?: string;
channels: string[];
product_context?: {
product_description?: string;
product_name?: string;
marketing_goal?: string;
[key: string]: any;
};
}
export interface CampaignBlueprint {
campaign_id: string;
campaign_name: string;
goal: string;
kpi?: string;
phases: Array<{
name: string;
duration_days: number;
purpose: string;
}>;
asset_nodes: Array<{
asset_id: string;
asset_type: string;
channel: string;
status: string;
}>;
channels: string[];
status: string;
}
export interface AssetProposal {
asset_id: string;
asset_type: string;
channel: string;
proposed_prompt: string;
recommended_template?: string;
recommended_provider?: string;
cost_estimate: number;
concept_summary: string;
}
export interface AssetProposalsResponse {
proposals: Record<string, AssetProposal>;
total_assets: number;
}
export interface BrandDNATokens {
writing_style: {
tone: string;
voice: string;
complexity: string;
engagement_level: string;
};
target_audience: {
demographics: string[];
industry_focus: string;
expertise_level: string;
};
visual_identity: {
color_palette?: string[];
brand_values?: string[];
positioning?: string;
style_guidelines?: any;
};
persona: {
persona_name?: string;
archetype?: string;
core_belief?: string;
linguistic_fingerprint?: any;
platform_personas?: any;
};
competitive_positioning: {
differentiators: string[];
unique_value_props: string[];
};
}
export interface ChannelPack {
channel: string;
platform: string;
asset_type: string;
templates: Array<{
id: string;
name: string;
dimensions: string;
aspect_ratio: string;
recommended_provider: string;
quality: string;
}>;
formats: Array<{
name: string;
width: number;
height: number;
ratio: string;
safe_zone?: any;
}>;
copy_framework: Record<string, any>;
optimization_tips: string[];
}
export interface AssetAuditResult {
asset_info: {
width: number;
height: number;
format: string;
mode: string;
quality_score: number;
};
recommendations: Array<{
operation: string;
priority: string;
reason: string;
suggested_mode?: string;
suggested_format?: string;
suggested_operations?: string[];
}>;
status: 'usable' | 'needs_enhancement' | 'error';
error?: string;
}
export interface PreflightValidationResult {
can_proceed: boolean;
message?: string;
error_details?: Record<string, any>;
summary: {
total_assets: number;
image_count: number;
text_count: number;
estimated_cost: number;
};
}
export const useProductMarketing = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Campaign Blueprint
const [blueprint, setBlueprint] = useState<CampaignBlueprint | null>(null);
const [isCreatingBlueprint, setIsCreatingBlueprint] = useState(false);
// Asset Proposals
const [proposals, setProposals] = useState<AssetProposalsResponse | null>(null);
const [isGeneratingProposals, setIsGeneratingProposals] = useState(false);
// Brand DNA
const [brandDNA, setBrandDNA] = useState<BrandDNATokens | null>(null);
const [isLoadingBrandDNA, setIsLoadingBrandDNA] = useState(false);
// Channel Packs
const [channelPack, setChannelPack] = useState<ChannelPack | null>(null);
const [isLoadingChannelPack, setIsLoadingChannelPack] = useState(false);
// Asset Audit
const [auditResult, setAuditResult] = useState<AssetAuditResult | null>(null);
const [isAuditing, setIsAuditing] = useState(false);
// Asset Generation
const [isGeneratingAsset, setIsGeneratingAsset] = useState(false);
const [generatedAsset, setGeneratedAsset] = useState<any>(null);
// Pre-flight Validation
const [preflightResult, setPreflightResult] = useState<PreflightValidationResult | null>(null);
const [isValidatingPreflight, setIsValidatingPreflight] = useState(false);
const createCampaignBlueprint = useCallback(
async (request: CampaignCreateRequest): Promise<CampaignBlueprint> => {
setIsCreatingBlueprint(true);
setError(null);
try {
const response = await aiApiClient.post<CampaignBlueprint>(
'/api/product-marketing/campaigns/create-blueprint',
request
);
setBlueprint(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create campaign blueprint';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsCreatingBlueprint(false);
}
},
[]
);
const generateAssetProposals = useCallback(
async (campaignId: string, productContext?: any): Promise<AssetProposalsResponse> => {
setIsGeneratingProposals(true);
setError(null);
try {
const response = await aiApiClient.post<AssetProposalsResponse>(
`/api/product-marketing/campaigns/${campaignId}/generate-proposals`,
{
campaign_id: campaignId,
product_context: productContext,
}
);
setProposals(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate asset proposals';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsGeneratingProposals(false);
}
},
[]
);
const generateAsset = useCallback(
async (assetProposal: AssetProposal, productContext?: any): Promise<any> => {
setIsGeneratingAsset(true);
setError(null);
try {
const response = await aiApiClient.post('/api/product-marketing/assets/generate', {
asset_proposal: assetProposal,
product_context: productContext,
});
setGeneratedAsset(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate asset';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsGeneratingAsset(false);
}
},
[]
);
const getBrandDNA = useCallback(async (): Promise<BrandDNATokens> => {
setIsLoadingBrandDNA(true);
setError(null);
try {
const response = await aiApiClient.get<{ brand_dna: BrandDNATokens }>('/api/product-marketing/brand-dna');
setBrandDNA(response.data.brand_dna);
return response.data.brand_dna;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get brand DNA';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoadingBrandDNA(false);
}
}, []);
const getChannelBrandDNA = useCallback(
async (channel: string): Promise<BrandDNATokens> => {
setIsLoadingBrandDNA(true);
setError(null);
try {
const response = await aiApiClient.get<{ channel: string; brand_dna: BrandDNATokens }>(
`/api/product-marketing/brand-dna/channel/${channel}`
);
return response.data.brand_dna;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get channel brand DNA';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoadingBrandDNA(false);
}
},
[]
);
const getChannelPack = useCallback(
async (channel: string, assetType: string = 'social_post'): Promise<ChannelPack> => {
setIsLoadingChannelPack(true);
setError(null);
try {
const response = await aiApiClient.get<ChannelPack>(
`/api/product-marketing/channels/${channel}/pack?asset_type=${assetType}`
);
setChannelPack(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get channel pack';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoadingChannelPack(false);
}
},
[]
);
const auditAsset = useCallback(
async (imageBase64: string, assetMetadata?: any): Promise<AssetAuditResult> => {
setIsAuditing(true);
setError(null);
try {
const response = await aiApiClient.post<AssetAuditResult>('/api/product-marketing/assets/audit', {
image_base64: imageBase64,
asset_metadata: assetMetadata,
});
setAuditResult(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to audit asset';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsAuditing(false);
}
},
[]
);
const clearError = useCallback(() => {
setError(null);
}, []);
const clearBlueprint = useCallback(() => {
setBlueprint(null);
}, []);
const clearProposals = useCallback(() => {
setProposals(null);
}, []);
const clearAuditResult = useCallback(() => {
setAuditResult(null);
}, []);
// Campaign listing
const [campaigns, setCampaigns] = useState<CampaignBlueprint[]>([]);
const [isLoadingCampaigns, setIsLoadingCampaigns] = useState(false);
const listCampaigns = useCallback(async (status?: string): Promise<CampaignBlueprint[]> => {
setIsLoadingCampaigns(true);
setError(null);
try {
const url = status ? `/api/product-marketing/campaigns?status=${status}` : '/api/product-marketing/campaigns';
const response = await aiApiClient.get<{ campaigns: CampaignBlueprint[]; total: number }>(url);
setCampaigns(response.data.campaigns);
return response.data.campaigns;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to list campaigns';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoadingCampaigns(false);
}
}, []);
const getCampaign = useCallback(async (campaignId: string): Promise<CampaignBlueprint> => {
setError(null);
try {
const response = await aiApiClient.get<CampaignBlueprint>(`/api/product-marketing/campaigns/${campaignId}`);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get campaign';
setError(errorMessage);
throw new Error(errorMessage);
}
}, []);
const getCampaignProposals = useCallback(async (campaignId: string): Promise<AssetProposalsResponse> => {
setError(null);
try {
const response = await aiApiClient.get<AssetProposalsResponse>(`/api/product-marketing/campaigns/${campaignId}/proposals`);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get proposals';
setError(errorMessage);
throw new Error(errorMessage);
}
}, []);
const validateCampaignPreflight = useCallback(
async (request: CampaignCreateRequest): Promise<PreflightValidationResult> => {
setIsValidatingPreflight(true);
setError(null);
try {
const response = await aiApiClient.post<PreflightValidationResult>(
'/api/product-marketing/campaigns/validate-preflight',
request
);
setPreflightResult(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to validate campaign pre-flight';
setError(errorMessage);
// Return error result
const errorResult: PreflightValidationResult = {
can_proceed: false,
message: errorMessage,
summary: {
total_assets: 0,
image_count: 0,
text_count: 0,
estimated_cost: 0,
},
};
setPreflightResult(errorResult);
return errorResult;
} finally {
setIsValidatingPreflight(false);
}
},
[]
);
// Product Image Generation (Product Marketing Suite - Product Assets)
const [isGeneratingProductImage, setIsGeneratingProductImage] = useState(false);
const [generatedProductImage, setGeneratedProductImage] = useState<any>(null);
const [productImageError, setProductImageError] = useState<string | null>(null);
const generateProductImage = useCallback(
async (request: {
product_name: string;
product_description: string;
environment?: string;
background_style?: string;
lighting?: string;
product_variant?: string;
angle?: string;
style?: string;
resolution?: string;
num_variations?: number;
brand_colors?: string[];
additional_context?: string;
}): Promise<any> => {
setIsGeneratingProductImage(true);
setProductImageError(null);
try {
const response = await aiApiClient.post('/api/product-marketing/products/photoshoot', request);
setGeneratedProductImage(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product image';
setProductImageError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsGeneratingProductImage(false);
}
},
[]
);
return {
// State
isLoading,
error,
blueprint,
isCreatingBlueprint,
proposals,
isGeneratingProposals,
brandDNA,
isLoadingBrandDNA,
channelPack,
isLoadingChannelPack,
auditResult,
isAuditing,
isGeneratingAsset,
generatedAsset,
preflightResult,
isValidatingPreflight,
// Actions
createCampaignBlueprint,
generateAssetProposals,
generateAsset,
getBrandDNA,
getChannelBrandDNA,
getChannelPack,
auditAsset,
clearError,
clearBlueprint,
clearProposals,
clearAuditResult,
campaigns,
isLoadingCampaigns,
listCampaigns,
getCampaign,
getCampaignProposals,
validateCampaignPreflight,
// Product Image Generation (Product Marketing Suite)
generateProductImage,
isGeneratingProductImage,
generatedProductImage,
productImageError,
};
};

View File

@@ -0,0 +1,153 @@
import { useState, useCallback } from 'react';
import { aiApiClient } from '../api/client';
export interface TransformImageToVideoRequest {
image_base64: string;
prompt: string;
audio_base64?: string;
resolution?: '480p' | '720p' | '1080p';
duration?: 5 | 10;
negative_prompt?: string;
seed?: number;
enable_prompt_expansion?: boolean;
}
export interface TalkingAvatarRequest {
image_base64: string;
audio_base64: string;
resolution?: '480p' | '720p';
prompt?: string;
mask_image_base64?: string;
seed?: number;
}
export interface TransformVideoResponse {
success: boolean;
video_url: string;
video_base64?: string;
duration: number;
resolution: string;
width: number;
height: number;
file_size: number;
cost: number;
provider: string;
model: string;
metadata: Record<string, any>;
}
export interface CostEstimateRequest {
operation: 'image-to-video' | 'talking-avatar';
resolution: string;
duration?: number;
}
export interface CostEstimateResponse {
estimated_cost: number;
breakdown: {
base_cost: number;
per_second: number;
duration: number;
total: number;
};
currency: string;
provider: string;
model: string;
}
export const useTransformStudio = () => {
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<TransformVideoResponse | null>(null);
const [costEstimate, setCostEstimate] = useState<CostEstimateResponse | null>(null);
const transformImageToVideo = useCallback(
async (request: TransformImageToVideoRequest): Promise<TransformVideoResponse> => {
setIsGenerating(true);
setError(null);
setResult(null);
try {
const response = await aiApiClient.post<TransformVideoResponse>(
'/api/image-studio/transform/image-to-video',
request
);
setResult(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to generate video';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsGenerating(false);
}
},
[]
);
const createTalkingAvatar = useCallback(
async (request: TalkingAvatarRequest): Promise<TransformVideoResponse> => {
setIsGenerating(true);
setError(null);
setResult(null);
try {
const response = await aiApiClient.post<TransformVideoResponse>(
'/api/image-studio/transform/talking-avatar',
request
);
setResult(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to create talking avatar';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsGenerating(false);
}
},
[]
);
const estimateCost = useCallback(
async (request: CostEstimateRequest): Promise<CostEstimateResponse> => {
try {
const response = await aiApiClient.post<CostEstimateResponse>(
'/api/image-studio/transform/estimate-cost',
request
);
setCostEstimate(response.data);
return response.data;
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to estimate cost';
setError(errorMessage);
throw new Error(errorMessage);
}
},
[]
);
const clearError = useCallback(() => {
setError(null);
}, []);
const clearResult = useCallback(() => {
setResult(null);
}, []);
return {
isGenerating,
error,
result,
costEstimate,
transformImageToVideo,
createTalkingAvatar,
estimateCost,
clearError,
clearResult,
};
};

View File

@@ -0,0 +1,415 @@
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse, ResearchProvider } from "./blogWriterApi";
import {
storyWriterApi,
StoryGenerationRequest,
StoryScene,
StorySetupGenerationResponse,
} from "./storyWriterApi";
import { getResearchConfig, ResearchPersona } from "../api/researchConfig";
import { aiApiClient } from "../api/client";
import {
CreateProjectPayload,
CreateProjectResult,
Fact,
Knobs,
Line,
PodcastAnalysis,
PodcastEstimate,
Query,
RenderJobResult,
Research,
Scene,
Script,
} from "../components/PodcastMaker/types";
import { checkPreflight, PreflightOperation } from "./billingService";
import { TaskStatusResponse } from "./blogWriterApi";
type WaitForTaskFn = (taskId: string) => Promise<TaskStatusResponse>;
const DEFAULT_KNOBS: Knobs = {
voice_emotion: "neutral",
voice_speed: 1,
resolution: "720p",
scene_length_target: 45,
sample_rate: 24000,
bitrate: "standard",
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createId = (prefix: string) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}_${crypto.randomUUID()}`;
}
return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
};
const deriveSegments = (option?: StorySetupGenerationResponse["options"][0]): string[] => {
const segments: string[] = [];
if (option?.plot_elements) {
option.plot_elements
.split(/[,.;]+/)
.map((p) => p.trim())
.filter(Boolean)
.forEach((p) => segments.push(p));
}
if (!segments.length && option?.premise) {
segments.push("Intro", "Key Takeaways", "Examples", "CTA");
}
return segments.slice(0, 5);
};
const estimateCosts = ({
minutes,
scenes,
chars,
quality,
avatars,
}: {
minutes: number;
scenes: number;
chars: number;
quality: string;
avatars: number;
}): PodcastEstimate => {
const secs = Math.max(60, minutes * 60);
const ttsCost = (chars / 1000) * 0.05;
const avatarCost = avatars * 0.15;
const videoRate = quality === "hd" ? 0.06 : 0.03;
const videoCost = secs * videoRate;
const researchCost = 0.5;
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
return {
ttsCost: +ttsCost.toFixed(2),
avatarCost: +avatarCost.toFixed(2),
videoCost: +videoCost.toFixed(2),
researchCost,
total,
};
};
const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => {
const keywords = persona?.suggested_keywords?.length ? persona.suggested_keywords : seed.split(/\s+/).filter(Boolean);
if (!keywords.length) {
return [
{
id: createId("q"),
query: seed || "ai marketing small business",
rationale: "Seed query derived from idea/topic",
needsRecentStats: true,
},
];
}
const angles = persona?.research_angles ?? [];
return keywords.slice(0, 6).map((keyword, idx) => ({
id: createId("q"),
query: `${keyword}`.trim(),
rationale: angles[idx % angles.length] || "High-impact persona angle",
needsRecentStats: /202[45]|latest|trend/i.test(keyword),
}));
};
const mapSourcesToFacts = (sources: BlogResearchResponse["sources"]): Fact[] => {
if (!sources || !sources.length) return [];
return sources.slice(0, 12).map((source, idx) => ({
id: source.url || createId("fact"),
quote: source.excerpt || source.title || "Insight",
url: source.url || "",
date: source.published_at || "Unknown",
confidence: typeof source.credibility_score === "number" ? source.credibility_score : 0.8 - idx * 0.02,
}));
};
const mapResearchResponse = (response: BlogResearchResponse): Research => {
const factCards = mapSourcesToFacts(response.sources);
const summary =
response.keyword_analysis?.summary ||
response.keyword_analysis?.key_insights?.join(" • ") ||
"Research completed. Review fact cards for details.";
const mappedAngles =
response.suggested_angles?.map((angle, idx) => ({
title: angle,
why: response.keyword_analysis?.angle_breakdown?.[angle]?.reason || "High priority topic from research insights.",
mappedFactIds: factCards.slice(idx, idx + 2).map((fact) => fact.id),
})) || [];
return {
summary,
factCards,
mappedAngles,
};
};
const splitIntoLines = (text: string, speakers: number): Line[] => {
const sentences = text
.split(/(?<=[.?!])\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 4);
if (!sentences.length) {
return [
{
id: createId("line"),
speaker: "Host",
text: text || "Let's dive into todays topic.",
},
];
}
return sentences.map((sentence, idx) => ({
id: createId("line"),
speaker: idx % speakers === 0 ? "Host" : `Guest ${((idx % speakers) + 1).toString()}`,
text: sentence,
}));
};
const storySceneToPodcastScene = (scene: StoryScene, knobs: Knobs, speakers: number): Scene => {
const text = scene.description || scene.audio_narration || scene.image_prompt || scene.title || "Narration";
return {
id: `scene-${scene.scene_number || createId("scene")}`,
title: scene.title || `Scene ${scene.scene_number}`,
duration: Math.max(20, knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
lines: splitIntoLines(text, Math.max(1, speakers)),
approved: false,
};
};
const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StoryScene[] | string | undefined): StoryScene[] => {
if (!outline) return [];
if (typeof outline === "string") {
return [
{
scene_number: 1,
title: outline.slice(0, 60),
description: outline,
image_prompt: outline,
audio_narration: outline,
} as StoryScene,
];
}
if (Array.isArray(outline)) {
return outline as StoryScene[];
}
return [];
};
const waitForTaskCompletion = async (taskId: string, poll: WaitForTaskFn): Promise<any> => {
let attempts = 0;
while (attempts < 120) {
const status = await poll(taskId);
if (status.status === "completed") {
return status.result;
}
if (status.status === "failed") {
throw new Error(status.error || "Task failed");
}
await sleep(2500);
attempts += 1;
}
throw new Error("Task polling timed out");
};
const ensurePreflight = async (operation: PreflightOperation) => {
const result = await checkPreflight(operation);
if (!result.can_proceed) {
const message = result.operations[0]?.message || "Pre-flight validation failed";
throw new Error(message);
}
return result;
};
export const podcastApi = {
async createProject(payload: CreateProjectPayload): Promise<CreateProjectResult> {
const storyIdea = payload.ideaOrUrl || "AI marketing for small businesses";
const setup = await storyWriterApi.generateStorySetup({ story_idea: storyIdea });
const primary = setup.options?.[0];
const suggestedOutlines = [
{
id: "primary",
title: primary?.premise?.slice(0, 60) || "Episode Outline",
segments: deriveSegments(primary),
},
];
const analysis: PodcastAnalysis = {
audience: primary?.audience_age_group || "Growth-minded pros",
contentType: primary?.persona || "How-to podcast",
topKeywords: suggestedOutlines[0].segments.slice(0, 3),
suggestedOutlines,
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
titleSuggestions: [
primary?.premise?.slice(0, 80),
`${primary?.persona || "AI Host"} on ${primary?.story_setting || "automation"}`,
].filter(Boolean) as string[],
};
const researchConfig = await getResearchConfig().catch(() => null);
const queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea);
const projectId = createId("podcast");
const estimate = estimateCosts({
minutes: payload.duration,
scenes: Math.ceil((payload.duration * 60) / (payload.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target)),
chars: Math.max(1000, payload.duration * 900),
quality: payload.knobs.bitrate || "standard",
avatars: payload.speakers,
});
return {
projectId,
analysis,
estimate,
queries,
};
},
async runResearch(params: {
projectId: string;
topic: string;
approvedQueries: Query[];
provider?: ResearchProvider;
}): Promise<{ research: Research; raw: BlogResearchResponse }> {
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
if (!keywords.length) {
throw new Error("At least one query must be approved for research.");
}
const researchPayload: BlogResearchRequest = {
keywords,
topic: params.topic || keywords[0],
research_mode: "basic",
config: {
provider: params.provider || "google",
include_statistics: params.approvedQueries.some((q) => q.needsRecentStats),
},
};
await ensurePreflight({
provider: params.provider === "exa" ? "exa" : "gemini",
operation_type: params.provider === "exa" ? "exa_neural_search" : "google_grounding",
tokens_requested: params.provider === "exa" ? 0 : 1200,
actual_provider_name: params.provider || "google",
});
const { task_id } = await blogWriterApi.startResearch(researchPayload);
const result = (await waitForTaskCompletion(task_id, blogWriterApi.pollResearchStatus)) as BlogResearchResponse;
const mapped = mapResearchResponse(result);
return { research: mapped, raw: result };
},
async generateScript(params: {
projectId: string;
idea: string;
research?: BlogResearchResponse | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
}): Promise<Script> {
await ensurePreflight({
provider: "gemini",
operation_type: "script_generation",
tokens_requested: 2000,
actual_provider_name: "gemini",
});
const premise =
params.research?.keyword_analysis?.summary ||
params.research?.keyword_analysis?.key_insights?.join(" ") ||
params.idea;
const storyRequest: StoryGenerationRequest = {
persona: "AI Podcast Host",
story_setting: "Modern marketing studio",
character_input: "Host and guest conversation",
plot_elements: params.research?.suggested_angles?.join(", ") || params.idea,
writing_style: "Conversational",
story_tone: "Informative",
narrative_pov: "first-person",
audience_age_group: "Adults",
content_rating: "G",
ending_preference: "Call to action",
story_length: params.durationMinutes > 15 ? "Long" : "Medium",
};
const outlineResponse = await storyWriterApi.generateOutline(premise, storyRequest);
const storyScenes = ensureScenes(outlineResponse.outline);
const scriptScenes = storyScenes.map((scene) => storySceneToPodcastScene(scene, params.knobs, params.speakers));
return { scenes: scriptScenes };
},
async previewLine(
text: string,
options: { voiceId?: string; speed?: number; emotion?: string } = {}
): Promise<{ ok: boolean; message: string; audioUrl?: string }> {
await ensurePreflight({
provider: "audio",
operation_type: "tts_preview",
tokens_requested: text.length,
actual_provider_name: "wavespeed",
});
const response = await storyWriterApi.generateAIAudio({
scene_number: 0,
scene_title: "Preview",
text,
voice_id: options.voiceId || "Wise_Woman",
speed: options.speed || 1.0,
emotion: options.emotion || "neutral",
});
if (!response.success) {
throw new Error(response.error || "Preview failed");
}
return {
ok: true,
message: "Preview ready opening audio in new tab.",
audioUrl: response.audio_url,
};
},
async renderSceneAudio(params: { scene: Scene; voiceId?: string; emotion?: string; speed?: number }): Promise<RenderJobResult> {
const text = params.scene.lines.map((line) => line.text).join(" ");
await ensurePreflight({
provider: "audio",
operation_type: "tts_full_render",
tokens_requested: text.length,
actual_provider_name: "wavespeed",
});
const response = await storyWriterApi.generateAIAudio({
scene_number: Number(params.scene.id.replace(/\D+/g, "")) || 0,
scene_title: params.scene.title,
text,
voice_id: params.voiceId || "Wise_Woman",
speed: params.speed || 1.0,
emotion: params.emotion || "neutral",
});
if (!response.success) {
throw new Error(response.error || "Render failed");
}
return {
audioUrl: response.audio_url,
audioFilename: response.audio_filename,
provider: response.provider,
model: response.model,
cost: response.cost,
voiceId: response.voice_id,
fileSize: response.file_size,
};
},
async approveScene(params: { projectId: string; sceneId: string; notes?: string }) {
await aiApiClient.post("/api/story/script/approve", {
project_id: params.projectId,
scene_id: params.sceneId,
approved: true,
notes: params.notes,
});
},
};
export type PodcastApi = typeof podcastApi;