AI Image Studio, AI podcast Maker, AI product Marketing
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
730
frontend/src/components/ImageStudio/TransformStudio.tsx
Normal file
730
frontend/src/components/ImageStudio/TransformStudio.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
922
frontend/src/components/PodcastMaker/PodcastDashboard.tsx
Normal file
922
frontend/src/components/PodcastMaker/PodcastDashboard.tsx
Normal 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'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'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">10–30s 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;
|
||||
|
||||
115
frontend/src/components/PodcastMaker/types.ts
Normal file
115
frontend/src/components/PodcastMaker/types.ts
Normal 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;
|
||||
};
|
||||
|
||||
351
frontend/src/components/ProductMarketing/AssetAuditPanel.tsx
Normal file
351
frontend/src/components/ProductMarketing/AssetAuditPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
508
frontend/src/components/ProductMarketing/CampaignWizard.tsx
Normal file
508
frontend/src/components/ProductMarketing/CampaignWizard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
236
frontend/src/components/ProductMarketing/ChannelPackBuilder.tsx
Normal file
236
frontend/src/components/ProductMarketing/ChannelPackBuilder.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
451
frontend/src/components/ProductMarketing/ProposalReview.tsx
Normal file
451
frontend/src/components/ProductMarketing/ProposalReview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
8
frontend/src/components/ProductMarketing/index.ts
Normal file
8
frontend/src/components/ProductMarketing/index.ts
Normal 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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
496
frontend/src/hooks/useProductMarketing.ts
Normal file
496
frontend/src/hooks/useProductMarketing.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
||||
153
frontend/src/hooks/useTransformStudio.ts
Normal file
153
frontend/src/hooks/useTransformStudio.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
||||
415
frontend/src/services/podcastApi.ts
Normal file
415
frontend/src/services/podcastApi.ts
Normal 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 today’s 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;
|
||||
|
||||
Reference in New Issue
Block a user