AI Image Studio Phase 1
This commit is contained in:
194
frontend/src/components/ImageStudio/CostEstimator.tsx
Normal file
194
frontend/src/components/ImageStudio/CostEstimator.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Stack,
|
||||
Chip,
|
||||
Divider,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
AttachMoney,
|
||||
TrendingUp,
|
||||
Speed,
|
||||
Info,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const MotionPaper = motion(Paper);
|
||||
|
||||
interface CostEstimate {
|
||||
provider: string;
|
||||
model?: string;
|
||||
operation: string;
|
||||
num_images: number;
|
||||
cost_per_image: number;
|
||||
total_cost: number;
|
||||
currency: string;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
interface CostEstimatorProps {
|
||||
estimate: CostEstimate;
|
||||
}
|
||||
|
||||
export const CostEstimator: React.FC<CostEstimatorProps> = ({ estimate }) => {
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: estimate.currency || 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Get cost level (low, medium, high)
|
||||
const getCostLevel = () => {
|
||||
if (estimate.total_cost === 0) return { label: 'Free', color: '#10b981' };
|
||||
if (estimate.total_cost < 0.50) return { label: 'Low Cost', color: '#10b981' };
|
||||
if (estimate.total_cost < 2.00) return { label: 'Medium Cost', color: '#f59e0b' };
|
||||
return { label: 'Premium Cost', color: '#8b5cf6' };
|
||||
};
|
||||
|
||||
const costLevel = getCostLevel();
|
||||
|
||||
return (
|
||||
<MotionPaper
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
elevation={0}
|
||||
sx={{
|
||||
background: `linear-gradient(135deg, ${alpha(costLevel.color, 0.05)}, ${alpha(costLevel.color, 0.02)})`,
|
||||
border: `1px solid ${alpha(costLevel.color, 0.2)}`,
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
background: `linear-gradient(135deg, ${costLevel.color}, ${alpha(costLevel.color, 0.7)})`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<AttachMoney sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||
Cost Estimate
|
||||
</Typography>
|
||||
{estimate.estimated && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 10 }}>
|
||||
Estimated pricing
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Chip
|
||||
label={costLevel.label}
|
||||
size="small"
|
||||
sx={{
|
||||
background: costLevel.color,
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
<Stack spacing={1.5}>
|
||||
{/* Per Image Cost */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontSize: 13 }}>
|
||||
Cost per image
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{formatCurrency(estimate.cost_per_image)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Number of Images */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontSize: 13 }}>
|
||||
Number of images
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: 13 }}>
|
||||
×{estimate.num_images}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Provider */}
|
||||
{estimate.provider && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontSize: 13 }}>
|
||||
Provider
|
||||
</Typography>
|
||||
<Chip
|
||||
label={estimate.provider}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: alpha('#667eea', 0.1),
|
||||
color: '#667eea',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Total Cost */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700 }}>
|
||||
Total Cost
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: costLevel.color,
|
||||
}}
|
||||
>
|
||||
{formatCurrency(estimate.total_cost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Info Note */}
|
||||
<Box
|
||||
sx={{
|
||||
background: alpha('#667eea', 0.05),
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Info sx={{ fontSize: 16, color: '#667eea', flexShrink: 0, mt: 0.2 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
|
||||
Costs are estimated and may vary. You will only be charged for successfully generated images. Failed generations are not billed.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</MotionPaper>
|
||||
);
|
||||
};
|
||||
|
||||
1236
frontend/src/components/ImageStudio/CreateStudio.tsx
Normal file
1236
frontend/src/components/ImageStudio/CreateStudio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
262
frontend/src/components/ImageStudio/EditImageUploader.tsx
Normal file
262
frontend/src/components/ImageStudio/EditImageUploader.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@mui/material';
|
||||
import UploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DeleteIcon from '@mui/icons-material/DeleteOutline';
|
||||
import BrushIcon from '@mui/icons-material/Brush';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { ImageMaskEditor } from './ImageMaskEditor';
|
||||
|
||||
interface EditImageUploaderProps {
|
||||
baseImage?: string | null;
|
||||
maskImage?: string | null;
|
||||
backgroundImage?: string | null;
|
||||
lightingImage?: string | null;
|
||||
requiresMask?: boolean;
|
||||
requiresBackground?: boolean;
|
||||
requiresLighting?: boolean;
|
||||
onBaseImageChange: (value: string | null) => void;
|
||||
onMaskImageChange: (value: string | null) => void;
|
||||
onBackgroundImageChange: (value: string | null) => void;
|
||||
onLightingImageChange: (value: string | null) => void;
|
||||
onOpenMaskEditor?: () => void;
|
||||
}
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const UploadSlot: React.FC<{
|
||||
label: string;
|
||||
helper?: string;
|
||||
value?: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}> = ({ label, helper, value, onChange }) => {
|
||||
const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
onChange(dataUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderStyle: value ? 'solid' : 'dashed',
|
||||
borderColor: value ? alpha('#667eea', 0.4) : alpha('#cbd5f5', 0.8),
|
||||
background: value ? alpha('#667eea', 0.08) : alpha('#667eea', 0.02),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
{label}
|
||||
</Typography>
|
||||
{helper && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{helper}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{value && (
|
||||
<Tooltip title="Remove image">
|
||||
<IconButton size="small" onClick={() => onChange(null)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{value ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={value}
|
||||
alt={`${label} preview`}
|
||||
style={{ width: '100%', display: 'block', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed',
|
||||
py: 2,
|
||||
color: '#667eea',
|
||||
borderColor: alpha('#667eea', 0.6),
|
||||
}}
|
||||
>
|
||||
Upload Image
|
||||
<input hidden type="file" accept="image/*" onChange={handleFile} />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditImageUploader: React.FC<EditImageUploaderProps> = ({
|
||||
baseImage,
|
||||
maskImage,
|
||||
backgroundImage,
|
||||
lightingImage,
|
||||
requiresMask,
|
||||
requiresBackground,
|
||||
requiresLighting,
|
||||
onBaseImageChange,
|
||||
onMaskImageChange,
|
||||
onBackgroundImageChange,
|
||||
onLightingImageChange,
|
||||
onOpenMaskEditor,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={2.5}>
|
||||
<UploadSlot
|
||||
label="Primary Image"
|
||||
helper="Required. Upload the image you want to edit."
|
||||
value={baseImage}
|
||||
onChange={onBaseImageChange}
|
||||
/>
|
||||
|
||||
{requiresMask && (
|
||||
<>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderStyle: maskImage ? 'solid' : 'dashed',
|
||||
borderColor: maskImage ? alpha('#667eea', 0.4) : alpha('#cbd5f5', 0.8),
|
||||
background: maskImage ? alpha('#667eea', 0.08) : alpha('#667eea', 0.02),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Mask (Optional)
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
White reveals areas to edit, black preserves original pixels.
|
||||
</Typography>
|
||||
</Box>
|
||||
{maskImage && (
|
||||
<Tooltip title="Remove mask">
|
||||
<IconButton size="small" onClick={() => onMaskImageChange(null)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{maskImage ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={maskImage}
|
||||
alt="Mask preview"
|
||||
style={{ width: '100%', display: 'block', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed',
|
||||
py: 2,
|
||||
color: '#667eea',
|
||||
borderColor: alpha('#667eea', 0.6),
|
||||
}}
|
||||
>
|
||||
Upload Mask
|
||||
<input hidden type="file" accept="image/*" onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
onMaskImageChange(dataUrl);
|
||||
}} />
|
||||
</Button>
|
||||
{baseImage && onOpenMaskEditor && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<BrushIcon />}
|
||||
onClick={onOpenMaskEditor}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
py: 2,
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(90deg, #5568d3, #65408b)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Create Mask
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{requiresBackground && (
|
||||
<UploadSlot
|
||||
label="Background Reference"
|
||||
helper="Provide a new background reference image."
|
||||
value={backgroundImage}
|
||||
onChange={onBackgroundImageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{requiresLighting && (
|
||||
<UploadSlot
|
||||
label="Lighting Reference"
|
||||
helper="Optional. Match subject lighting to this reference."
|
||||
value={lightingImage}
|
||||
onChange={onLightingImageChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
136
frontend/src/components/ImageStudio/EditOperationsToolbar.tsx
Normal file
136
frontend/src/components/ImageStudio/EditOperationsToolbar.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import BoltIcon from '@mui/icons-material/Bolt';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { EditOperationMeta } from '../../hooks/useImageStudio';
|
||||
|
||||
interface EditOperationsToolbarProps {
|
||||
operations: Record<string, EditOperationMeta>;
|
||||
selectedOperation: string;
|
||||
onSelect: (key: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const EditOperationsToolbar: React.FC<EditOperationsToolbarProps> = ({
|
||||
operations,
|
||||
selectedOperation,
|
||||
onSelect,
|
||||
loading,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 160,
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(operations);
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{entries.map(([key, meta]) => {
|
||||
const isSelected = selectedOperation === key;
|
||||
return (
|
||||
<Grid item xs={12} md={6} key={key}>
|
||||
<Card
|
||||
onClick={() => onSelect(key)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
borderWidth: 2,
|
||||
borderStyle: 'solid',
|
||||
borderColor: isSelected ? alpha('#667eea', 0.8) : 'transparent',
|
||||
background: isSelected
|
||||
? 'linear-gradient(135deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.2))'
|
||||
: 'rgba(255,255,255,0.08)',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: alpha('#667eea', 0.6),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={1.2}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
{meta.label}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<AutoAwesomeIcon sx={{ fontSize: 16 }} />}
|
||||
label={meta.provider}
|
||||
sx={{
|
||||
textTransform: 'capitalize',
|
||||
background: alpha('#1f2937', 0.6),
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{meta.description}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{meta.async && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<BoltIcon sx={{ fontSize: 16 }} />}
|
||||
label="Async"
|
||||
sx={{
|
||||
background: alpha('#f59e0b', 0.15),
|
||||
color: '#f59e0b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{meta.fields?.mask && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Mask"
|
||||
sx={{
|
||||
background: alpha('#10b981', 0.15),
|
||||
color: '#10b981',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{meta.fields?.background && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Background"
|
||||
sx={{
|
||||
background: alpha('#6366f1', 0.2),
|
||||
color: '#6366f1',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
189
frontend/src/components/ImageStudio/EditResultViewer.tsx
Normal file
189
frontend/src/components/ImageStudio/EditResultViewer.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
IconButton,
|
||||
Stack,
|
||||
Typography,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import { EditResult } from '../../hooks/useImageStudio';
|
||||
|
||||
interface EditResultViewerProps {
|
||||
originalImage?: string | null;
|
||||
result?: EditResult | null;
|
||||
isProcessing?: boolean;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export const EditResultViewer: React.FC<EditResultViewerProps> = ({
|
||||
originalImage,
|
||||
result,
|
||||
isProcessing,
|
||||
onReset,
|
||||
}) => {
|
||||
const handleDownload = () => {
|
||||
if (!result?.image_base64) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = result.image_base64;
|
||||
link.download = `edit-${result.operation}-${Date.now()}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
if (!originalImage) {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
minHeight: 280,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Upload an image to preview edits.
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Results
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Reset">
|
||||
<span>
|
||||
<IconButton disabled={!result && !originalImage} onClick={onReset}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download result">
|
||||
<span>
|
||||
<IconButton disabled={!result} onClick={handleDownload}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Original
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={originalImage}
|
||||
alt="Original reference"
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Edited
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
minHeight: 180,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{isProcessing && (
|
||||
<Stack alignItems="center" spacing={1} py={6}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Applying edits...
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{!isProcessing && result?.image_base64 && (
|
||||
<img
|
||||
src={result.image_base64}
|
||||
alt="Edited result"
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
{!isProcessing && !result && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No edits yet. Configure options and apply.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<ChipLabel label="Operation" value={result.operation} />
|
||||
<ChipLabel label="Provider" value={result.provider} />
|
||||
<ChipLabel label="Resolution" value={`${result.width}×${result.height}`} />
|
||||
{result.metadata?.style_preset && (
|
||||
<ChipLabel label="Style" value={result.metadata.style_preset} />
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const ChipLabel: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
414
frontend/src/components/ImageStudio/EditStudio.tsx
Normal file
414
frontend/src/components/ImageStudio/EditStudio.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
TextField,
|
||||
Alert,
|
||||
Slider,
|
||||
Divider,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { motion, type Variants, type Easing } from 'framer-motion';
|
||||
import {
|
||||
useImageStudio,
|
||||
EditOperationMeta,
|
||||
EditImageRequestPayload,
|
||||
} from '../../hooks/useImageStudio';
|
||||
import { EditImageUploader } from './EditImageUploader';
|
||||
import { EditOperationsToolbar } from './EditOperationsToolbar';
|
||||
import { EditResultViewer } from './EditResultViewer';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { OperationButton } from '../shared/OperationButton';
|
||||
import { ImageMaskEditor } from './ImageMaskEditor';
|
||||
|
||||
const MotionPaper = motion(Paper);
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
export const EditStudio: React.FC = () => {
|
||||
const {
|
||||
loadEditOperations,
|
||||
editOperations,
|
||||
isLoadingEditOps,
|
||||
processEdit,
|
||||
isProcessingEdit,
|
||||
editResult,
|
||||
editError,
|
||||
clearEditResult,
|
||||
} = useImageStudio();
|
||||
|
||||
const [operation, setOperation] = useState<string>('remove_background');
|
||||
const [baseImage, setBaseImage] = useState<string | null>(null);
|
||||
const [maskImage, setMaskImage] = useState<string | null>(null);
|
||||
const [backgroundImage, setBackgroundImage] = useState<string | null>(null);
|
||||
const [lightingImage, setLightingImage] = useState<string | null>(null);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [negativePrompt, setNegativePrompt] = useState('');
|
||||
const [searchPrompt, setSearchPrompt] = useState('');
|
||||
const [selectPrompt, setSelectPrompt] = useState('');
|
||||
const [expansion, setExpansion] = useState({
|
||||
left: 0,
|
||||
right: 0,
|
||||
up: 0,
|
||||
down: 0,
|
||||
});
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [showMaskEditor, setShowMaskEditor] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadEditOperations();
|
||||
}, [loadEditOperations]);
|
||||
|
||||
useEffect(() => {
|
||||
const keys = Object.keys(editOperations);
|
||||
if (keys.length && !keys.includes(operation)) {
|
||||
setOperation(keys[0]);
|
||||
}
|
||||
}, [editOperations, operation]);
|
||||
|
||||
const operationMeta: EditOperationMeta | undefined = editOperations[operation];
|
||||
const fields = operationMeta?.fields || {};
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!baseImage) return false;
|
||||
if (fields.prompt && !prompt.trim()) return false;
|
||||
if (fields.search_prompt && !searchPrompt.trim()) return false;
|
||||
if (fields.select_prompt && !selectPrompt.trim()) return false;
|
||||
if (fields.background && !backgroundImage && fields.lighting && !lightingImage) return false;
|
||||
return true;
|
||||
}, [baseImage, fields, prompt, searchPrompt, selectPrompt, backgroundImage, lightingImage]);
|
||||
|
||||
const editOperation = useMemo(() => ({
|
||||
provider: 'stability',
|
||||
operation_type: 'image_editing',
|
||||
actual_provider_name: 'stability',
|
||||
}), []);
|
||||
|
||||
const handleExpansionChange = (key: keyof typeof expansion, value: number) => {
|
||||
setExpansion(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const buildPayload = (): EditImageRequestPayload | null => {
|
||||
if (!baseImage) {
|
||||
setLocalError('Please upload an image to edit.');
|
||||
return null;
|
||||
}
|
||||
if (fields.prompt && !prompt.trim()) {
|
||||
setLocalError('This operation requires a prompt.');
|
||||
return null;
|
||||
}
|
||||
if (fields.search_prompt && !searchPrompt.trim()) {
|
||||
setLocalError('Please provide a search prompt.');
|
||||
return null;
|
||||
}
|
||||
if (fields.select_prompt && !selectPrompt.trim()) {
|
||||
setLocalError('Please provide a selection prompt.');
|
||||
return null;
|
||||
}
|
||||
if (fields.background && !backgroundImage && fields.lighting && !lightingImage) {
|
||||
setLocalError('Provide at least a background or lighting reference.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: EditImageRequestPayload = {
|
||||
image_base64: baseImage,
|
||||
operation,
|
||||
prompt: prompt || undefined,
|
||||
negative_prompt: negativePrompt || undefined,
|
||||
mask_base64: fields.mask ? maskImage || undefined : undefined,
|
||||
search_prompt: fields.search_prompt ? searchPrompt || undefined : undefined,
|
||||
select_prompt: fields.select_prompt ? selectPrompt || undefined : undefined,
|
||||
background_image_base64: fields.background ? backgroundImage || undefined : undefined,
|
||||
lighting_image_base64: fields.lighting ? lightingImage || undefined : undefined,
|
||||
expand_left: fields.expansion ? expansion.left : undefined,
|
||||
expand_right: fields.expansion ? expansion.right : undefined,
|
||||
expand_up: fields.expansion ? expansion.up : undefined,
|
||||
expand_down: fields.expansion ? expansion.down : undefined,
|
||||
output_format: 'png',
|
||||
options: {},
|
||||
};
|
||||
return payload;
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setLocalError(null);
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
await processEdit(payload);
|
||||
} catch {
|
||||
// errors handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageStudioLayout>
|
||||
<MotionPaper
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
background: 'rgba(15,23,42,0.7)',
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
p: { xs: 3, md: 4 },
|
||||
backdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.5} mb={3}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
background: 'linear-gradient(90deg, #ede9fe, #c7d2fe)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Edit Studio
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Advanced AI editing for marketers and content teams. Remove backgrounds, inpaint, recolor,
|
||||
and relight assets in one flow.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{(localError || editError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => {
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
{localError || editError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={5}>
|
||||
<Stack spacing={3}>
|
||||
<EditImageUploader
|
||||
baseImage={baseImage}
|
||||
maskImage={maskImage}
|
||||
backgroundImage={backgroundImage}
|
||||
lightingImage={lightingImage}
|
||||
requiresMask={fields.mask}
|
||||
requiresBackground={fields.background}
|
||||
requiresLighting={fields.lighting}
|
||||
onBaseImageChange={setBaseImage}
|
||||
onMaskImageChange={setMaskImage}
|
||||
onBackgroundImageChange={setBackgroundImage}
|
||||
onLightingImageChange={setLightingImage}
|
||||
onOpenMaskEditor={() => setShowMaskEditor(true)}
|
||||
/>
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<AutoFixHighIcon sx={{ color: '#a78bfa' }} />
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Operations
|
||||
</Typography>
|
||||
</Stack>
|
||||
<EditOperationsToolbar
|
||||
operations={editOperations}
|
||||
selectedOperation={operation}
|
||||
onSelect={key => {
|
||||
setOperation(key);
|
||||
setLocalError(null);
|
||||
clearEditResult();
|
||||
}}
|
||||
loading={isLoadingEditOps}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={7}>
|
||||
<Stack spacing={3}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: alpha('#0f172a', 0.7),
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{fields.prompt && (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
label="Prompt"
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder="Describe what you want to change..."
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
{fields.negative_prompt && (
|
||||
<TextField
|
||||
label="Negative Prompt"
|
||||
value={negativePrompt}
|
||||
onChange={e => setNegativePrompt(e.target.value)}
|
||||
placeholder="Elements to avoid..."
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
{fields.search_prompt && (
|
||||
<TextField
|
||||
label="Search Prompt"
|
||||
value={searchPrompt}
|
||||
onChange={e => setSearchPrompt(e.target.value)}
|
||||
placeholder="What should be replaced?"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
{fields.select_prompt && (
|
||||
<TextField
|
||||
label="Select Prompt"
|
||||
value={selectPrompt}
|
||||
onChange={e => setSelectPrompt(e.target.value)}
|
||||
placeholder="Describe what should be recolored"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
|
||||
{fields.expansion && (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" mb={1}>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Canvas Expansion (px)
|
||||
</Typography>
|
||||
<Chip size="small" label="Outpaint" />
|
||||
</Stack>
|
||||
{(['left', 'right', 'up', 'down'] as const).map(dir => (
|
||||
<Box key={dir} sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{dir.toUpperCase()}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={expansion[dir]}
|
||||
onChange={(_, value) =>
|
||||
handleExpansionChange(dir, value as number)
|
||||
}
|
||||
step={10}
|
||||
min={0}
|
||||
max={512}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<OperationButton
|
||||
operation={editOperation}
|
||||
label="Apply Edit"
|
||||
startIcon={<AutoFixHighIcon />}
|
||||
onClick={handleApply}
|
||||
disabled={!canSubmit}
|
||||
loading={isProcessingEdit}
|
||||
checkOnMount
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(90deg, #7c3aed, #2563eb)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditResultViewer
|
||||
originalImage={baseImage}
|
||||
result={editResult}
|
||||
isProcessing={isProcessingEdit}
|
||||
onReset={() => {
|
||||
clearEditResult();
|
||||
setPrompt('');
|
||||
setNegativePrompt('');
|
||||
setSearchPrompt('');
|
||||
setSelectPrompt('');
|
||||
setMaskImage(null);
|
||||
setBackgroundImage(null);
|
||||
setLightingImage(null);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Mask Editor Dialog */}
|
||||
<Dialog
|
||||
open={showMaskEditor}
|
||||
onClose={() => setShowMaskEditor(false)}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: alpha('#0f172a', 0.95),
|
||||
backdropFilter: 'blur(20px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<IconButton
|
||||
onClick={() => setShowMaskEditor(false)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 1,
|
||||
bgcolor: alpha('#000', 0.5),
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
bgcolor: alpha('#000', 0.7),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<ImageMaskEditor
|
||||
baseImage={baseImage}
|
||||
maskImage={maskImage}
|
||||
onMaskChange={(mask) => {
|
||||
setMaskImage(mask);
|
||||
setShowMaskEditor(false);
|
||||
}}
|
||||
onClose={() => setShowMaskEditor(false)}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</MotionPaper>
|
||||
</ImageStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
426
frontend/src/components/ImageStudio/ImageMaskEditor.tsx
Normal file
426
frontend/src/components/ImageStudio/ImageMaskEditor.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
IconButton,
|
||||
Slider,
|
||||
Button,
|
||||
Chip,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Brush,
|
||||
DeleteOutline,
|
||||
Clear,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Undo,
|
||||
} from '@mui/icons-material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
interface ImageMaskEditorProps {
|
||||
baseImage: string | null;
|
||||
maskImage: string | null;
|
||||
onMaskChange: (maskBase64: string | null) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type BrushMode = 'paint' | 'erase';
|
||||
|
||||
export const ImageMaskEditor: React.FC<ImageMaskEditorProps> = ({
|
||||
baseImage,
|
||||
maskImage,
|
||||
onMaskChange,
|
||||
onClose,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [brushMode, setBrushMode] = useState<BrushMode>('paint');
|
||||
const [brushSize, setBrushSize] = useState(20);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [history, setHistory] = useState<ImageData[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
// Initialize canvases
|
||||
useEffect(() => {
|
||||
if (!baseImage || !canvasRef.current || !maskCanvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const maskCanvas = maskCanvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const maskCtx = maskCanvas.getContext('2d');
|
||||
if (!ctx || !maskCtx) return;
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
|
||||
// Set canvas sizes to match image
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
maskCanvas.width = img.width;
|
||||
maskCanvas.height = img.height;
|
||||
|
||||
// Draw base image on display canvas
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Initialize mask canvas (black = preserve, white = edit)
|
||||
maskCtx.fillStyle = '#000000';
|
||||
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
|
||||
// If existing mask, load it
|
||||
if (maskImage) {
|
||||
const maskImg = new Image();
|
||||
maskImg.onload = () => {
|
||||
maskCtx.drawImage(maskImg, 0, 0);
|
||||
// Redraw display
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const maskData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
for (let i = 0; i < maskData.data.length; i += 4) {
|
||||
const maskValue = maskData.data[i];
|
||||
if (maskValue > 128) {
|
||||
imageData.data[i] = Math.min(255, imageData.data[i] * 0.7 + 255 * 0.3);
|
||||
imageData.data[i + 1] = imageData.data[i + 1] * 0.7;
|
||||
imageData.data[i + 2] = imageData.data[i + 2] * 0.7;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Save initial state to history
|
||||
const imageDataForHistory = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
setHistory([imageDataForHistory]);
|
||||
setHistoryIndex(0);
|
||||
};
|
||||
maskImg.src = maskImage;
|
||||
} else {
|
||||
// Redraw display
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Save initial state to history
|
||||
const imageDataForHistory = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
setHistory([imageDataForHistory]);
|
||||
setHistoryIndex(0);
|
||||
}
|
||||
};
|
||||
img.src = baseImage;
|
||||
}, [baseImage, maskImage]);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
if (!canvasRef.current || !maskCanvasRef.current || !imageRef.current) return;
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
const maskCtx = maskCanvasRef.current.getContext('2d');
|
||||
if (!ctx || !maskCtx) return;
|
||||
|
||||
// Draw base image
|
||||
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||
ctx.drawImage(imageRef.current, 0, 0);
|
||||
|
||||
// Overlay mask as red tint (white areas in mask = red overlay)
|
||||
const maskData = maskCtx.getImageData(0, 0, maskCanvasRef.current.width, maskCanvasRef.current.height);
|
||||
const imageData = ctx.getImageData(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||
|
||||
for (let i = 0; i < maskData.data.length; i += 4) {
|
||||
const maskValue = maskData.data[i]; // Grayscale value
|
||||
if (maskValue > 128) { // White area = masked (to be edited)
|
||||
// Apply red overlay
|
||||
imageData.data[i] = Math.min(255, imageData.data[i] * 0.7 + 255 * 0.3); // Red tint
|
||||
imageData.data[i + 1] = imageData.data[i + 1] * 0.7; // Reduce green
|
||||
imageData.data[i + 2] = imageData.data[i + 2] * 0.7; // Reduce blue
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}, []);
|
||||
|
||||
const saveHistory = useCallback(() => {
|
||||
if (!maskCanvasRef.current) return;
|
||||
const ctx = maskCanvasRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, maskCanvasRef.current.width, maskCanvasRef.current.height);
|
||||
const newHistory = history.slice(0, historyIndex + 1);
|
||||
newHistory.push(imageData);
|
||||
if (newHistory.length > 20) newHistory.shift();
|
||||
setHistory(newHistory);
|
||||
setHistoryIndex(newHistory.length - 1);
|
||||
}, [history, historyIndex]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex <= 0 || !maskCanvasRef.current) return;
|
||||
const ctx = maskCanvasRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const prevIndex = historyIndex - 1;
|
||||
ctx.putImageData(history[prevIndex], 0, 0);
|
||||
setHistoryIndex(prevIndex);
|
||||
redraw();
|
||||
}, [history, historyIndex, redraw]);
|
||||
|
||||
const getCoordinates = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
||||
if (!canvasRef.current) return null;
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0]?.clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0]?.clientY : e.clientY;
|
||||
|
||||
if (clientX === undefined || clientY === undefined) return null;
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
const draw = useCallback((x: number, y: number) => {
|
||||
if (!maskCanvasRef.current) return;
|
||||
const ctx = maskCanvasRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.globalCompositeOperation = brushMode === 'paint' ? 'source-over' : 'destination-out';
|
||||
ctx.fillStyle = brushMode === 'paint' ? '#ffffff' : '#000000';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, brushSize / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
redraw();
|
||||
}, [brushMode, brushSize, redraw]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const coords = getCoordinates(e);
|
||||
if (!coords) return;
|
||||
setIsDrawing(true);
|
||||
draw(coords.x, coords.y);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing) return;
|
||||
const coords = getCoordinates(e);
|
||||
if (!coords) return;
|
||||
draw(coords.x, coords.y);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDrawing) {
|
||||
setIsDrawing(false);
|
||||
saveHistory();
|
||||
exportMask();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const coords = getCoordinates(e);
|
||||
if (!coords) return;
|
||||
setIsDrawing(true);
|
||||
draw(coords.x, coords.y);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
if (!isDrawing) return;
|
||||
const coords = getCoordinates(e);
|
||||
if (!coords) return;
|
||||
draw(coords.x, coords.y);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isDrawing) {
|
||||
setIsDrawing(false);
|
||||
saveHistory();
|
||||
exportMask();
|
||||
}
|
||||
};
|
||||
|
||||
const exportMask = useCallback(() => {
|
||||
if (!maskCanvasRef.current) return;
|
||||
|
||||
const maskBase64 = maskCanvasRef.current.toDataURL('image/png');
|
||||
onMaskChange(maskBase64);
|
||||
}, [onMaskChange]);
|
||||
|
||||
const clearMask = () => {
|
||||
if (!maskCanvasRef.current) return;
|
||||
const ctx = maskCanvasRef.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, maskCanvasRef.current.width, maskCanvasRef.current.height);
|
||||
redraw();
|
||||
saveHistory();
|
||||
onMaskChange(null);
|
||||
};
|
||||
|
||||
if (!baseImage) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
background: alpha('#0f172a', 0.7),
|
||||
border: '1px dashed rgba(255,255,255,0.2)',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">
|
||||
Upload an image first to create a mask
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
background: alpha('#0f172a', 0.8),
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} p={2}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Mask Editor
|
||||
</Typography>
|
||||
{onClose && (
|
||||
<IconButton size="small" onClick={onClose}>
|
||||
<Clear fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Toolbar */}
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Tooltip title="Paint (add to mask)">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setBrushMode('paint')}
|
||||
sx={{
|
||||
bgcolor: brushMode === 'paint' ? alpha('#667eea', 0.2) : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Brush fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Erase (remove from mask)">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setBrushMode('erase')}
|
||||
sx={{
|
||||
bgcolor: brushMode === 'erase' ? alpha('#667eea', 0.2) : 'transparent',
|
||||
}}
|
||||
>
|
||||
<DeleteOutline fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Box sx={{ width: 100, mx: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Size: {brushSize}px
|
||||
</Typography>
|
||||
<Slider
|
||||
size="small"
|
||||
value={brushSize}
|
||||
onChange={(_, value) => setBrushSize(value as number)}
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title="Undo">
|
||||
<IconButton size="small" onClick={undo} disabled={historyIndex <= 0}>
|
||||
<Undo fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Clear mask">
|
||||
<IconButton size="small" onClick={clearMask}>
|
||||
<Clear fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Chip
|
||||
size="small"
|
||||
label={brushMode === 'paint' ? 'Paint Mode' : 'Erase Mode'}
|
||||
sx={{ ml: 'auto' }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Canvas Container */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 2,
|
||||
overflow: 'auto',
|
||||
maxHeight: '60vh',
|
||||
background: '#000',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{
|
||||
display: 'block',
|
||||
cursor: brushMode === 'paint' ? 'crosshair' : 'grab',
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
{/* Hidden mask canvas */}
|
||||
<canvas ref={maskCanvasRef} style={{ display: 'none' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<IconButton size="small" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}>
|
||||
<ZoomOut fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography variant="caption" sx={{ minWidth: 60, textAlign: 'center' }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setZoom(Math.min(2, zoom + 0.25))}>
|
||||
<ZoomIn fontSize="small" />
|
||||
</IconButton>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => setZoom(1)}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
Reset Zoom
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Instructions */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
💡 Tip: Paint areas you want to edit (shown in red overlay). White areas in the mask
|
||||
will be modified, black areas will be preserved.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
504
frontend/src/components/ImageStudio/ImageResultsGallery.tsx
Normal file
504
frontend/src/components/ImageStudio/ImageResultsGallery.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardActions,
|
||||
IconButton,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Chip,
|
||||
Stack,
|
||||
Tooltip,
|
||||
alpha,
|
||||
Paper,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Download,
|
||||
Favorite,
|
||||
FavoriteBorder,
|
||||
ZoomIn,
|
||||
Close,
|
||||
Share,
|
||||
Edit,
|
||||
ContentCopy,
|
||||
CheckCircle,
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence, type Variants, type Easing } from 'framer-motion';
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
const MotionBox = motion(Box);
|
||||
const galleryEase: Easing = [0.4, 0, 0.2, 1];
|
||||
|
||||
interface ImageResult {
|
||||
image_base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
seed?: number;
|
||||
variation: number;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
interface ImageResultsGalleryProps {
|
||||
results: ImageResult[];
|
||||
onImageSelect?: (image: ImageResult) => void;
|
||||
}
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.8 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.4, ease: galleryEase },
|
||||
},
|
||||
hover: {
|
||||
y: -8,
|
||||
transition: { duration: 0.2, ease: galleryEase },
|
||||
},
|
||||
};
|
||||
|
||||
export const ImageResultsGallery: React.FC<ImageResultsGalleryProps> = ({
|
||||
results,
|
||||
onImageSelect,
|
||||
}) => {
|
||||
const [selectedImage, setSelectedImage] = useState<ImageResult | null>(null);
|
||||
const [favorites, setFavorites] = useState<Set<number>>(new Set());
|
||||
const [downloadedImages, setDownloadedImages] = useState<Set<number>>(new Set());
|
||||
|
||||
// Handle favorite toggle
|
||||
const toggleFavorite = (index: number) => {
|
||||
setFavorites((prev) => {
|
||||
const newFavorites = new Set(prev);
|
||||
if (newFavorites.has(index)) {
|
||||
newFavorites.delete(index);
|
||||
} else {
|
||||
newFavorites.add(index);
|
||||
}
|
||||
return newFavorites;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle download
|
||||
const handleDownload = (image: ImageResult, index: number) => {
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = `data:image/png;base64,${image.image_base64}`;
|
||||
link.download = `generated-image-${Date.now()}-v${image.variation}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Mark as downloaded
|
||||
setDownloadedImages((prev) => new Set(prev).add(index));
|
||||
|
||||
// Remove downloaded indicator after 2 seconds
|
||||
setTimeout(() => {
|
||||
setDownloadedImages((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(index);
|
||||
return newSet;
|
||||
});
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle copy to clipboard
|
||||
const handleCopy = async (image: ImageResult) => {
|
||||
try {
|
||||
// Convert base64 to blob
|
||||
const response = await fetch(`data:image/png;base64,${image.image_base64}`);
|
||||
const blob = await response.blob();
|
||||
|
||||
// Copy to clipboard
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob }),
|
||||
]);
|
||||
|
||||
alert('Image copied to clipboard!');
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
alert('Failed to copy image');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{results.map((result, index) => {
|
||||
const isFavorite = favorites.has(index);
|
||||
const isDownloaded = downloadedImages.has(index);
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={`${result.variation}-${index}`}>
|
||||
<MotionCard
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
whileHover="hover"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
border: isFavorite ? '2px solid #f59e0b' : '1px solid #e2e8f0',
|
||||
boxShadow: isFavorite
|
||||
? '0 8px 24px rgba(245, 158, 11, 0.2)'
|
||||
: '0 4px 12px rgba(0,0,0,0.05)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 12px 32px rgba(102, 126, 234, 0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Favorite Badge */}
|
||||
{isFavorite && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
zIndex: 2,
|
||||
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
||||
color: '#fff',
|
||||
borderRadius: 2,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
boxShadow: '0 4px 12px rgba(245, 158, 11, 0.4)',
|
||||
}}
|
||||
>
|
||||
<Favorite sx={{ fontSize: 14 }} />
|
||||
Favorite
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Downloaded Indicator */}
|
||||
{isDownloaded && (
|
||||
<MotionBox
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
zIndex: 2,
|
||||
background: '#10b981',
|
||||
color: '#fff',
|
||||
borderRadius: 2,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<CheckCircle sx={{ fontSize: 14 }} />
|
||||
Downloaded
|
||||
</MotionBox>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
paddingTop: `${(result.height / result.width) * 100}%`,
|
||||
overflow: 'hidden',
|
||||
background: '#f8fafc',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setSelectedImage(result)}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={`data:image/png;base64,${result.image_base64}`}
|
||||
alt={`Generated variation ${result.variation}`}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hover Overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'&:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
background: '#fff',
|
||||
'&:hover': {
|
||||
background: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ZoomIn />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Metadata */}
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Stack direction="row" spacing={1} mb={1} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label={`Variation ${result.variation}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 22,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={`${result.width}×${result.height}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 22,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: '#f1f5f9',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={result.provider}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 22,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
background: alpha('#667eea', 0.1),
|
||||
color: '#667eea',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<CardActions sx={{ px: 2, pb: 2, pt: 0, gap: 1 }}>
|
||||
<Tooltip title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => toggleFavorite(index)}
|
||||
sx={{
|
||||
color: isFavorite ? '#f59e0b' : 'text.secondary',
|
||||
'&:hover': {
|
||||
background: alpha('#f59e0b', 0.1),
|
||||
color: '#f59e0b',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isFavorite ? <Favorite /> : <FavoriteBorder />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Download image">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDownload(result, index)}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
background: alpha('#10b981', 0.1),
|
||||
color: '#10b981',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Copy to clipboard">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(result)}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
background: alpha('#667eea', 0.1),
|
||||
color: '#667eea',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Tooltip title="View full size">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedImage(result)}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
background: alpha('#667eea', 0.1),
|
||||
color: '#667eea',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ZoomIn />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</CardActions>
|
||||
</MotionCard>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Grid>
|
||||
|
||||
{/* Full Size Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: '#1e293b',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0, position: 'relative' }}>
|
||||
{selectedImage && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={() => setSelectedImage(null)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 2,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
|
||||
<img
|
||||
src={`data:image/png;base64,${selectedImage.image_base64}`}
|
||||
alt="Full size"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Metadata Overlay */}
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Chip
|
||||
label={`${selectedImage.width}×${selectedImage.height}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={selectedImage.provider}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={selectedImage.model}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
onClick={() => {
|
||||
const index = results.findIndex(r => r === selectedImage);
|
||||
if (index !== -1) {
|
||||
handleDownload(selectedImage, index);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(90deg, #5568d3, #65408b)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
62
frontend/src/components/ImageStudio/ImageStudioDashboard.tsx
Normal file
62
frontend/src/components/ImageStudio/ImageStudioDashboard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Paper, Grid, Stack, Typography, Divider } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { studioModules } from './dashboard/modules';
|
||||
import { ModuleCard } from './dashboard/ModuleCard';
|
||||
|
||||
export const ImageStudioDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [hoveredModule, setHoveredModule] = React.useState<string>('');
|
||||
|
||||
return (
|
||||
<ImageStudioLayout>
|
||||
<Paper
|
||||
elevation={0}
|
||||
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={1}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg,#ede9fe,#c7d2fe)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
AI Image Studio
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
One hub for every visual workflow: generate, edit, upscale, transform, optimize, and manage
|
||||
assets built for content and marketing teams.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 3, borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{studioModules.map(module => (
|
||||
<Grid item xs={12} md={6} key={module.key}>
|
||||
<ModuleCard
|
||||
module={module}
|
||||
isHovered={hoveredModule === module.key}
|
||||
onMouseEnter={() => setHoveredModule(module.key)}
|
||||
onMouseLeave={() => setHoveredModule('')}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</ImageStudioLayout>
|
||||
);
|
||||
};
|
||||
76
frontend/src/components/ImageStudio/ImageStudioLayout.tsx
Normal file
76
frontend/src/components/ImageStudio/ImageStudioLayout.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Variants } from 'framer-motion';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
const sparkleVariants: Variants = {
|
||||
initial: { scale: 0, rotate: 0 },
|
||||
animate: {
|
||||
scale: [0, 1, 0],
|
||||
rotate: [0, 180, 360],
|
||||
transition: {
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface ImageStudioLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
454
frontend/src/components/ImageStudio/TemplateSelector.tsx
Normal file
454
frontend/src/components/ImageStudio/TemplateSelector.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
Grid,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Button,
|
||||
Stack,
|
||||
alpha,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
Instagram,
|
||||
Facebook,
|
||||
Twitter,
|
||||
LinkedIn,
|
||||
YouTube,
|
||||
Pinterest,
|
||||
TrendingUp,
|
||||
PhotoLibrary,
|
||||
Star,
|
||||
Close,
|
||||
Check,
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence, type Variants, type Easing } from 'framer-motion';
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
const templateCardEase: Easing = [0.4, 0, 1, 1];
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
platform?: string;
|
||||
aspect_ratio: {
|
||||
ratio: string;
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
};
|
||||
description: string;
|
||||
recommended_provider: string;
|
||||
style_preset: string;
|
||||
quality: string;
|
||||
use_cases: string[];
|
||||
}
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
templates: Template[];
|
||||
selectedTemplateId: string | null;
|
||||
onSelectTemplate: (template: Template) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// Platform icons mapping
|
||||
const platformIcons: Record<string, React.ReactElement> = {
|
||||
instagram: <Instagram />,
|
||||
facebook: <Facebook />,
|
||||
twitter: <Twitter />,
|
||||
linkedin: <LinkedIn />,
|
||||
youtube: <YouTube />,
|
||||
pinterest: <Pinterest />,
|
||||
};
|
||||
|
||||
// Platform colors
|
||||
const platformColors: Record<string, string> = {
|
||||
instagram: '#E4405F',
|
||||
facebook: '#1877F2',
|
||||
twitter: '#1DA1F2',
|
||||
linkedin: '#0A66C2',
|
||||
youtube: '#FF0000',
|
||||
pinterest: '#E60023',
|
||||
blog: '#10b981',
|
||||
email: '#8b5cf6',
|
||||
website: '#f59e0b',
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.9 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.3, ease: templateCardEase },
|
||||
},
|
||||
hover: {
|
||||
y: -4,
|
||||
transition: { duration: 0.2, ease: templateCardEase },
|
||||
},
|
||||
};
|
||||
|
||||
export const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
onSelectTemplate,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const resolvePlatformColor = useCallback((platform?: string | null) => {
|
||||
if (!platform) return '#667eea';
|
||||
return platformColors[platform as keyof typeof platformColors] || '#667eea';
|
||||
}, []);
|
||||
|
||||
// Get unique platforms
|
||||
const platforms = useMemo(() => {
|
||||
const uniquePlatforms = new Set(templates.map(t => t.platform).filter(Boolean));
|
||||
return Array.from(uniquePlatforms);
|
||||
}, [templates]);
|
||||
|
||||
// Filter templates
|
||||
const filteredTemplates = useMemo(() => {
|
||||
let filtered = templates;
|
||||
|
||||
// Filter by platform
|
||||
if (selectedPlatform) {
|
||||
filtered = filtered.filter(t => t.platform === selectedPlatform);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(t =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.description.toLowerCase().includes(query) ||
|
||||
t.use_cases.some(uc => uc.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [templates, selectedPlatform, searchQuery]);
|
||||
|
||||
// Display templates (show 6 or all)
|
||||
const displayTemplates = showAll ? filteredTemplates : filteredTemplates.slice(0, 6);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<PhotoLibrary sx={{ fontSize: 18, color: '#667eea' }} />
|
||||
Platform Templates
|
||||
{selectedTemplateId && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Selected"
|
||||
icon={<Check sx={{ fontSize: 14 }} />}
|
||||
sx={{
|
||||
ml: 1,
|
||||
height: 22,
|
||||
background: '#10b981',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{/* Search */}
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchQuery && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={() => setSearchQuery('')}>
|
||||
<Close sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
background: '#fff',
|
||||
'&:hover fieldset': {
|
||||
borderColor: '#667eea',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#667eea',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Platform Filter */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
label="All"
|
||||
onClick={() => setSelectedPlatform(null)}
|
||||
sx={{
|
||||
background: !selectedPlatform
|
||||
? 'linear-gradient(90deg, #667eea, #764ba2)'
|
||||
: 'transparent',
|
||||
color: !selectedPlatform ? '#fff' : 'text.secondary',
|
||||
fontWeight: 600,
|
||||
border: !selectedPlatform ? 'none' : '1px solid #e2e8f0',
|
||||
'&:hover': {
|
||||
background: !selectedPlatform
|
||||
? 'linear-gradient(90deg, #5568d3, #65408b)'
|
||||
: alpha('#667eea', 0.1),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{platforms.map((platform) => {
|
||||
const label = platform ? platform.charAt(0).toUpperCase() + platform.slice(1) : 'Unknown';
|
||||
const icon = platform ? platformIcons[platform] : undefined;
|
||||
const color = resolvePlatformColor(platform);
|
||||
const isSelected = selectedPlatform === platform;
|
||||
return (
|
||||
<Chip
|
||||
key={platform || 'unknown'}
|
||||
icon={icon}
|
||||
label={label}
|
||||
onClick={() => setSelectedPlatform(platform || null)}
|
||||
sx={{
|
||||
background: isSelected ? color : 'transparent',
|
||||
color: isSelected ? '#fff' : 'text.secondary',
|
||||
fontWeight: 600,
|
||||
border: isSelected ? 'none' : '1px solid #e2e8f0',
|
||||
'&:hover': {
|
||||
background: isSelected ? color : alpha(color, 0.1),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Templates Grid */}
|
||||
<Grid container spacing={1.5}>
|
||||
<AnimatePresence mode="wait">
|
||||
{displayTemplates.map((template) => {
|
||||
const isSelected = selectedTemplateId === template.id;
|
||||
const platformColor = resolvePlatformColor(template.platform || 'blog');
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={template.id}>
|
||||
<MotionCard
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
whileHover="hover"
|
||||
onClick={() => onSelectTemplate(template)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 2,
|
||||
border: isSelected ? `2px solid ${platformColor}` : '1px solid #e2e8f0',
|
||||
background: isSelected
|
||||
? `linear-gradient(135deg, ${alpha(platformColor, 0.05)}, ${alpha(platformColor, 0.02)})`
|
||||
: '#fff',
|
||||
boxShadow: isSelected
|
||||
? `0 4px 12px ${alpha(platformColor, 0.2)}`
|
||||
: '0 1px 3px rgba(0,0,0,0.05)',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: `0 8px 24px ${alpha(platformColor, 0.3)}`,
|
||||
border: `2px solid ${platformColor}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||
<Stack spacing={1}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
{/* Platform Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 1,
|
||||
background: `linear-gradient(135deg, ${platformColor}, ${alpha(platformColor, 0.7)})`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{platformIcons[template.platform || 'blog'] || <PhotoLibrary sx={{ fontSize: 18 }} />}
|
||||
</Box>
|
||||
|
||||
{/* Template Info */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
mb: 0.5,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{template.name}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Chip
|
||||
label={template.aspect_ratio.ratio}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
background: alpha(platformColor, 0.1),
|
||||
color: platformColor,
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={`${template.aspect_ratio.width}×${template.aspect_ratio.height}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
background: '#f1f5f9',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Selection Indicator */}
|
||||
{isSelected && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: platformColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Check sx={{ fontSize: 16, color: '#fff' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{template.description}
|
||||
</Typography>
|
||||
|
||||
{/* Quality Badge */}
|
||||
{template.quality === 'premium' && (
|
||||
<Chip
|
||||
icon={<Star sx={{ fontSize: 12 }} />}
|
||||
label="Premium"
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(90deg, #f59e0b, #d97706)',
|
||||
color: '#fff',
|
||||
width: 'fit-content',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Grid>
|
||||
|
||||
{/* Show More/Less Button */}
|
||||
{filteredTemplates.length > 6 && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 2,
|
||||
borderColor: 'divider',
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
borderColor: '#667eea',
|
||||
background: alpha('#667eea', 0.05),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showAll ? 'Show Less' : `Show All (${filteredTemplates.length})`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{filteredTemplates.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No templates found matching your criteria
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedPlatform(null);
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
329
frontend/src/components/ImageStudio/UpscaleStudio.tsx
Normal file
329
frontend/src/components/ImageStudio/UpscaleStudio.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Alert,
|
||||
Slider,
|
||||
} from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||
import UpgradeIcon from '@mui/icons-material/Upgrade';
|
||||
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { GlassyCard, SectionHeader, AsyncStatusBanner } from './ui';
|
||||
import { OperationButton } from '../shared/OperationButton';
|
||||
|
||||
import { useImageStudio } from '../../hooks/useImageStudio';
|
||||
|
||||
const modeOptions = [
|
||||
{ value: 'fast', label: 'Fast (4x)', description: 'Quick upscale with minimal changes' },
|
||||
{ value: 'conservative', label: 'Conservative 4K', description: 'Preserve details for print' },
|
||||
{ value: 'creative', label: 'Creative 4K', description: 'Add artistic enhancements' },
|
||||
{ value: 'auto', label: 'Auto', description: 'Let AI choose best mode' },
|
||||
];
|
||||
|
||||
const presetOptions = [
|
||||
{ value: 'web', label: 'Web (2048px)' },
|
||||
{ value: 'print', label: 'Print (3072px)' },
|
||||
{ value: 'social', label: 'Social (1080px)' },
|
||||
];
|
||||
|
||||
export const UpscaleStudio: React.FC = () => {
|
||||
const {
|
||||
processUpscale,
|
||||
clearUpscaleResult,
|
||||
isUpscaling,
|
||||
upscaleResult,
|
||||
upscaleError,
|
||||
} = useImageStudio();
|
||||
|
||||
const [mode, setMode] = useState<'fast' | 'conservative' | 'creative' | 'auto'>('auto');
|
||||
const [preset, setPreset] = useState<string>('web');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [imageBase64, setImageBase64] = useState<string | null>(null);
|
||||
const [zoom, setZoom] = useState<number>(1);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImageBase64(reader.result as string);
|
||||
reader.onerror = () => setLocalError('Failed to read image');
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleUpscale = async () => {
|
||||
setLocalError(null);
|
||||
if (!imageBase64) {
|
||||
setLocalError('Upload an image to upscale.');
|
||||
return;
|
||||
}
|
||||
|
||||
clearUpscaleResult();
|
||||
try {
|
||||
const payload = {
|
||||
image_base64: imageBase64,
|
||||
mode,
|
||||
preset,
|
||||
prompt: prompt || undefined,
|
||||
};
|
||||
await processUpscale(payload);
|
||||
} catch {
|
||||
// handled via hook state
|
||||
}
|
||||
};
|
||||
|
||||
const canUpscale = Boolean(imageBase64) && !isUpscaling;
|
||||
const upscaleOperation = useMemo(() => ({
|
||||
provider: 'stability',
|
||||
operation_type: 'image_upscale',
|
||||
actual_provider_name: 'stability',
|
||||
model: mode,
|
||||
}), [mode]);
|
||||
|
||||
return (
|
||||
<ImageStudioLayout>
|
||||
<Stack spacing={3}>
|
||||
<SectionHeader
|
||||
title="Upscale Studio"
|
||||
subtitle="Enhance resolution with fast 4x or 4K upscales powered by Stability AI."
|
||||
status="beta"
|
||||
/>
|
||||
|
||||
{(localError || upscaleError) && (
|
||||
<Alert severity="error" onClose={() => setLocalError(null)}>
|
||||
{localError || upscaleError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AsyncStatusBanner
|
||||
state={
|
||||
isUpscaling ? 'running' : upscaleResult ? 'success' : localError || upscaleError ? 'error' : 'idle'
|
||||
}
|
||||
message={
|
||||
isUpscaling
|
||||
? 'Upscaling your image...'
|
||||
: upscaleResult
|
||||
? 'Upscale complete!'
|
||||
: localError || upscaleError || undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={5}>
|
||||
<GlassyCard sx={{ p: 3 }}>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Upload Image</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<CloudUploadIcon />}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Select Image
|
||||
<input hidden type="file" accept="image/*" onChange={handleFile} />
|
||||
</Button>
|
||||
{imageBase64 && (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<img src={imageBase64} alt="Original" style={{ width: '100%' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2">Mode</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
value={mode}
|
||||
onChange={(_, value) => {
|
||||
if (value) setMode(value);
|
||||
}}
|
||||
fullWidth
|
||||
color="primary"
|
||||
>
|
||||
{modeOptions.map(option => (
|
||||
<ToggleButton key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label="Preset"
|
||||
value={preset}
|
||||
onChange={e => setPreset(e.target.value)}
|
||||
fullWidth
|
||||
>
|
||||
{presetOptions.map(option => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{(mode === 'conservative' || mode === 'creative') && (
|
||||
<TextField
|
||||
label="Prompt (optional)"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder="Describe how you want the upscale to enhance the image"
|
||||
/>
|
||||
)}
|
||||
|
||||
<OperationButton
|
||||
operation={upscaleOperation}
|
||||
label="Upscale Image"
|
||||
startIcon={<UpgradeIcon />}
|
||||
onClick={handleUpscale}
|
||||
disabled={!canUpscale}
|
||||
loading={isUpscaling}
|
||||
checkOnMount
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={7}>
|
||||
<GlassyCard sx={{ p: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<ZoomInIcon sx={{ color: '#c4b5fd' }} />
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Result Preview
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{!upscaleResult && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload an image and click “Upscale Image” to see the results here.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{upscaleResult && (
|
||||
<Stack spacing={2}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Original
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
height: 320,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageBase64 || ''}
|
||||
alt="Original"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Upscaled
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
height: 320,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={upscaleResult.image_base64}
|
||||
alt="Upscaled"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Zoom ({Math.round(zoom * 100)}%)
|
||||
</Typography>
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={(_, value) => setZoom(value as number)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<CheckCircleIcon sx={{ color: '#10b981' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{upscaleResult.width}×{upscaleResult.height} · Mode: {upscaleResult.mode}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = upscaleResult.image_base64;
|
||||
link.download = `upscaled-${Date.now()}.png`;
|
||||
link.click();
|
||||
}}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</GlassyCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</ImageStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
263
frontend/src/components/ImageStudio/dashboard/ModuleCard.tsx
Normal file
263
frontend/src/components/ImageStudio/dashboard/ModuleCard.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Tooltip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { ModuleConfig } from './types';
|
||||
import { statusStyles } from './constants';
|
||||
import { ModuleInfoCard } from './ModuleInfoCard';
|
||||
import {
|
||||
CreateEffectPreview,
|
||||
EditEffectPreview,
|
||||
UpscaleEffectPreview,
|
||||
TransformEffectPreview,
|
||||
SocialOptimizerEffectPreview,
|
||||
ControlEffectPreview,
|
||||
} from './previews';
|
||||
|
||||
interface ModuleCardProps {
|
||||
module: ModuleConfig;
|
||||
isHovered: boolean;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onNavigate: (route: string) => void;
|
||||
}
|
||||
|
||||
export const ModuleCard: React.FC<ModuleCardProps> = ({
|
||||
module,
|
||||
isHovered,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onNavigate,
|
||||
}) => {
|
||||
const status = statusStyles[module.status];
|
||||
const disabled = module.status !== 'live';
|
||||
const hasPreview =
|
||||
module.key === 'create' ||
|
||||
module.key === 'edit' ||
|
||||
module.key === 'upscale' ||
|
||||
module.key === 'transform' ||
|
||||
module.key === 'optimizer' ||
|
||||
module.key === 'control';
|
||||
|
||||
const renderPreview = () => {
|
||||
switch (module.key) {
|
||||
case 'create':
|
||||
return <CreateEffectPreview />;
|
||||
case 'edit':
|
||||
return <EditEffectPreview />;
|
||||
case 'upscale':
|
||||
return <UpscaleEffectPreview />;
|
||||
case 'transform':
|
||||
return <TransformEffectPreview />;
|
||||
case 'optimizer':
|
||||
return <SocialOptimizerEffectPreview />;
|
||||
case 'control':
|
||||
return <ControlEffectPreview />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
p: 3,
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
background: alpha('#111827', 0.8),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
position: 'relative',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
boxShadow: isHovered
|
||||
? '0 20px 45px rgba(124,58,237,0.25)'
|
||||
: '0 10px 25px rgba(15,23,42,0.35)',
|
||||
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
|
||||
overflow: 'hidden',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background:
|
||||
module.key === 'create'
|
||||
? 'radial-gradient(circle at top, rgba(124,58,237,0.25), transparent 60%)'
|
||||
: module.key === 'edit'
|
||||
? 'linear-gradient(120deg, rgba(8,145,178,0.25), transparent)'
|
||||
: module.key === 'upscale'
|
||||
? 'linear-gradient(90deg, rgba(248,113,113,0.25), transparent)'
|
||||
: 'linear-gradient(120deg, rgba(59,130,246,0.15), transparent)',
|
||||
opacity: isHovered ? 1 : 0.35,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: alpha('#6366f1', 0.2),
|
||||
color: '#c7d2fe',
|
||||
fontSize: 22,
|
||||
}}
|
||||
>
|
||||
{module.icon}
|
||||
</Box>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
{module.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{module.subtitle}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Chip
|
||||
label={status.label}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: alpha(status.color, 0.2),
|
||||
color: status.color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: hasPreview
|
||||
? 'rgba(248,250,252,0.92)'
|
||||
: 'rgba(148,163,184,0.95)',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{module.highlights.map(item => (
|
||||
<Chip
|
||||
key={item}
|
||||
size="small"
|
||||
label={item}
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg, rgba(99,102,241,0.35), rgba(14,165,233,0.35))',
|
||||
color: '#f1f5f9',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{hasPreview && (
|
||||
<>
|
||||
{renderPreview()}
|
||||
<ModuleInfoCard module={module} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasPreview && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 16,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(15,23,42,0.92)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
padding: 2,
|
||||
opacity: isHovered ? 1 : 0,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ color: '#a5b4fc', letterSpacing: 1 }}>
|
||||
Pricing & How it works
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography variant="body2" fontWeight={700}>
|
||||
{module.pricing.estimate}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{module.pricing.notes}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
{module.example.title}
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
{module.example.steps.map(step => (
|
||||
<Typography
|
||||
component="li"
|
||||
key={step}
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{step}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
ETA: {module.example.eta}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" mt="auto">
|
||||
<Tooltip title={module.help}>
|
||||
<InfoOutlinedIcon sx={{ color: 'rgba(255,255,255,0.6)', fontSize: 20 }} />
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={disabled}
|
||||
startIcon={disabled ? <LockIcon /> : <LaunchIcon />}
|
||||
onClick={() => {
|
||||
if (!disabled && module.route) {
|
||||
onNavigate(module.route);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
ml: 'auto',
|
||||
background: disabled
|
||||
? 'rgba(148,163,184,0.2)'
|
||||
: 'linear-gradient(90deg,#7c3aed,#2563eb)',
|
||||
}}
|
||||
>
|
||||
{disabled ? 'Coming Soon' : 'Open'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Paper, Stack, Typography, Divider } from '@mui/material';
|
||||
import { ModuleConfig } from './types';
|
||||
|
||||
export const ModuleInfoCard: React.FC<{ module: ModuleConfig }> = ({ module }) => (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
borderRadius: 3,
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
backgroundColor: 'rgba(15,23,42,0.65)',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="caption" sx={{ color: '#a5b4fc', letterSpacing: 1 }}>
|
||||
Pricing & Workflow
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{module.pricing.estimate}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{module.pricing.notes}
|
||||
</Typography>
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
{module.example.title}
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
{module.example.steps.map(step => (
|
||||
<Typography key={step} component="li" variant="body2" color="text.secondary">
|
||||
{step}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
ETA: {module.example.eta}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
93
frontend/src/components/ImageStudio/dashboard/constants.ts
Normal file
93
frontend/src/components/ImageStudio/dashboard/constants.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export const createExamples = [
|
||||
{
|
||||
id: 'ig-hero',
|
||||
label: 'Instagram hero',
|
||||
prompt:
|
||||
'"Cinematic coffee shop hero shot, golden hour lighting, stylish barista pouring latte art, 4k, depth of field, film grain"',
|
||||
provider: 'WaveSpeed Ideogram V3 Turbo',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1509042239860-f550ce710b93?auto=format&fit=crop&w=1200&q=80',
|
||||
description:
|
||||
'Polished hero visual for carousel slides and blog headers with photorealistic signage.',
|
||||
price: '$0.18',
|
||||
eta: '~4s',
|
||||
},
|
||||
{
|
||||
id: 'linkedin-thought',
|
||||
label: 'LinkedIn thought-leadership',
|
||||
prompt:
|
||||
'"Minimalist workspace flat lay, teal gradients, AI workflow diagrams, overhead view, ultra clean, 8k render"',
|
||||
provider: 'Gemini Imagen',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1487017159836-4e23ece2e4cf?auto=format&fit=crop&w=1200&q=80',
|
||||
description: 'Clean layout for LinkedIn posts that need professional, text-friendly framing.',
|
||||
price: '$0.11',
|
||||
eta: '~3s',
|
||||
},
|
||||
{
|
||||
id: 'tiktok-hook',
|
||||
label: 'TikTok hook frame',
|
||||
prompt:
|
||||
'"Vibrant neon studio, bold typography reading Growth Hacks, 9:16 layout, dynamic lighting, energetic vibe"',
|
||||
provider: 'WaveSpeed Qwen Image',
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1504196606672-aef5c9cefc92?auto=format&fit=crop&w=1200&q=80',
|
||||
description: 'High-energy vertical frame to start TikTok/Reels with bold colors and legible copy.',
|
||||
price: '$0.07',
|
||||
eta: '~2s',
|
||||
},
|
||||
];
|
||||
|
||||
export const upscaleSamples = {
|
||||
lowRes: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=600&q=30',
|
||||
hiRes: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=1600&q=80',
|
||||
};
|
||||
|
||||
export const transformAssets = {
|
||||
storyboard: '/images/scene_1_Welcome_to_the_Cloud_Kitchen___ae6436d9.png',
|
||||
video: '/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4',
|
||||
script:
|
||||
"Welcome to the Cloud Kitchen! Meet Ava, your virtual chef companion. Let's explore how she runs three delivery brands from one AI-powered hub.",
|
||||
};
|
||||
|
||||
export const controlAssets = {
|
||||
inputImage: '/images/scene_1_Welcome_to_the_Cloud_Kitchen___ae6436d9.png',
|
||||
outputVideo: '/videos/text-video-voiceover.mp4',
|
||||
prompt:
|
||||
"A confident woman in her 40s stands on a stage with a microphone. The background shows a large LED screen with abstract visuals. She smiles and begins speaking to the audience: \"Good evening everyone. Tonight, I want to share three powerful lessons about leadership and innovation.\" Her lip movements match her voice, and she uses expressive hand gestures while speaking.",
|
||||
seed: 2133312826,
|
||||
resolution: '720p',
|
||||
duration: 5,
|
||||
};
|
||||
|
||||
export const editBeforeAfter = [
|
||||
{
|
||||
before: 'https://images.unsplash.com/photo-1455587734955-081b22074882?auto=format&fit=crop&w=800&q=80',
|
||||
after: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=800&q=80',
|
||||
prompt: 'Inpainted background swap with studio lighting and relit subject',
|
||||
},
|
||||
{
|
||||
before: 'https://images.unsplash.com/photo-1472506200026-38c43d5fbf97?auto=format&fit=crop&w=800&q=80',
|
||||
after: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=800&q=80',
|
||||
prompt: 'Recolored wardrobe + added morning haze',
|
||||
},
|
||||
{
|
||||
before: 'https://images.unsplash.com/photo-1434389677669-e08b4cac3105?auto=format&fit=crop&w=800&q=80',
|
||||
after: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=80',
|
||||
prompt: 'Reframed hero crop with dramatic sky replacement',
|
||||
},
|
||||
];
|
||||
|
||||
export const platformPresets = [
|
||||
{ label: 'IG Feed 1:1', top: '10%', left: '5%', width: '35%', height: '35%' },
|
||||
{ label: 'TikTok 9:16', top: '5%', right: '5%', width: '25%', height: '60%' },
|
||||
{ label: 'LinkedIn 1.91:1', bottom: '8%', left: '10%', width: '55%', height: '25%' },
|
||||
{ label: 'Pinterest 2:3', bottom: '12%', right: '8%', width: '22%', height: '30%' },
|
||||
];
|
||||
|
||||
export const statusStyles = {
|
||||
live: { label: 'Live', color: '#10b981' },
|
||||
'coming soon': { label: 'Coming Soon', color: '#f97316' },
|
||||
planning: { label: 'In Planning', color: '#d1d5db' },
|
||||
};
|
||||
|
||||
7
frontend/src/components/ImageStudio/dashboard/index.ts
Normal file
7
frontend/src/components/ImageStudio/dashboard/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export { ModuleInfoCard } from './ModuleInfoCard';
|
||||
export { ModuleCard } from './ModuleCard';
|
||||
export { studioModules } from './modules';
|
||||
export * from './previews';
|
||||
|
||||
208
frontend/src/components/ImageStudio/dashboard/modules.tsx
Normal file
208
frontend/src/components/ImageStudio/dashboard/modules.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import BrushIcon from '@mui/icons-material/Brush';
|
||||
import UpgradeIcon from '@mui/icons-material/Upgrade';
|
||||
import TransformIcon from '@mui/icons-material/Transform';
|
||||
import ShareIcon from '@mui/icons-material/Share';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||
import { ModuleConfig } from './types';
|
||||
|
||||
export const studioModules: ModuleConfig[] = [
|
||||
{
|
||||
key: 'create',
|
||||
title: 'Create Studio',
|
||||
subtitle: 'Text-to-image generation',
|
||||
description:
|
||||
'Generate photorealistic visuals with Stability, WaveSpeed, HuggingFace, and Gemini. Templates, smart providers, and enterprise prompt controls included.',
|
||||
highlights: ['Smart provider routing', 'Platform templates', 'Cost preview'],
|
||||
status: 'live',
|
||||
route: '/image-generator',
|
||||
icon: <AutoAwesomeIcon />,
|
||||
help: 'Ideal for blog headers, social posts, ad creatives, and brand assets.',
|
||||
pricing: {
|
||||
estimate: '$0.12 - $0.48 / image (credit aware)',
|
||||
notes: 'Auto-select suggests lowest-cost provider before generation.',
|
||||
},
|
||||
example: {
|
||||
title: 'Instagram carousel hero image',
|
||||
steps: [
|
||||
'Choose Instagram template + 4:5 ratio',
|
||||
'Prompt helper enriches "fall coffee launch" copy',
|
||||
'Preview cost/time → generate 3 variations',
|
||||
],
|
||||
eta: '~4s per variation',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
title: 'Edit Studio',
|
||||
subtitle: 'AI-powered editing',
|
||||
description:
|
||||
'Remove backgrounds, inpaint, outpaint, recolor, and relight images with Stability AI workflows and Hugging Face conversational edits.',
|
||||
highlights: ['Object removal', 'Canvas expansion', 'Relight + background swap'],
|
||||
status: 'live',
|
||||
route: '/image-editor',
|
||||
icon: <BrushIcon />,
|
||||
help: 'Upload existing assets and enhance them with precise AI tools.',
|
||||
pricing: {
|
||||
estimate: '$0.08 - $0.30 / edit (based on area + ops)',
|
||||
notes: 'Bulk edits share the same upload to save credits.',
|
||||
},
|
||||
example: {
|
||||
title: 'Replace dull background for LinkedIn hero',
|
||||
steps: [
|
||||
'Upload portrait → auto mask detects subject',
|
||||
'Use "Replace background" preset → choose corporate loft style',
|
||||
'Relight + save layered history for future tweaks',
|
||||
],
|
||||
eta: '~6s render',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'upscale',
|
||||
title: 'Upscale Studio',
|
||||
subtitle: 'Resolution enhancement',
|
||||
description:
|
||||
'Fast 4x upscale, conservative 4K, and creative 4K pipelines powered by Stability AI. Perfect for print, campaigns, and hero imagery.',
|
||||
highlights: ['Fast 4x mode', '4K creative', 'Side-by-side preview'],
|
||||
status: 'live',
|
||||
route: '/image-upscale',
|
||||
icon: <UpgradeIcon />,
|
||||
help: 'Upscale images to 4K-ready assets with one click.',
|
||||
pricing: {
|
||||
estimate: '$0.10 (Fast) · $0.32 (Creative 4K)',
|
||||
notes: 'Queue batches overnight to reduce credit burn.',
|
||||
},
|
||||
example: {
|
||||
title: 'Print-ready hero panel',
|
||||
steps: [
|
||||
'Upload 1024 hero → auto-detect recommends Creative 4K',
|
||||
'Preview side-by-side → confirm texture preservation',
|
||||
'Schedule overnight batch with 6 variants',
|
||||
],
|
||||
eta: 'Fast = 1s · 4K = 6s',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'transform',
|
||||
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.',
|
||||
highlights: ['Image-to-video', 'Talking avatars', '3D export'],
|
||||
status: 'coming soon',
|
||||
icon: <TransformIcon />,
|
||||
help: 'Designed for campaign teasers, explainers, and immersive media.',
|
||||
pricing: {
|
||||
estimate: '$0.50 (10s video 480p) · $3.60 (avatar 2 min)',
|
||||
notes: 'Text-to-speech add-on billed separately per 15s.',
|
||||
},
|
||||
example: {
|
||||
title: 'Product launch teaser video',
|
||||
steps: [
|
||||
'Pick motion preset "Medium pan + glow"',
|
||||
'Upload hero shot + 8s script for TTS',
|
||||
'Preview storyboard → export 1080p MP4',
|
||||
],
|
||||
eta: '~15s generation',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'optimizer',
|
||||
title: 'Social Optimizer',
|
||||
subtitle: 'Platform-ready exports',
|
||||
description:
|
||||
'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: 'planning',
|
||||
icon: <ShareIcon />,
|
||||
help: 'Ship consistent assets across every social surface.',
|
||||
pricing: {
|
||||
estimate: '$0.02 - $0.06 / rendition',
|
||||
notes: 'Unlimited exports on Pro + Enterprise tiers.',
|
||||
},
|
||||
example: {
|
||||
title: 'One hero → 6 platform exports',
|
||||
steps: [
|
||||
'Add source image → auto-detect focal subject',
|
||||
'Select IG, TikTok, LinkedIn, Pinterest presets',
|
||||
'Review safe zones overlay → export ZIP + schedule',
|
||||
],
|
||||
eta: '~2s / platform',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'control',
|
||||
title: 'Control Studio',
|
||||
subtitle: 'Sketch, structure & style',
|
||||
description:
|
||||
'Sketch-to-image, structure control, and advanced style transfer so creative directors can steer outputs precisely.',
|
||||
highlights: ['Sketch control', 'Style libraries', 'Strength sliders'],
|
||||
status: 'planning',
|
||||
icon: <EditNoteIcon />,
|
||||
help: 'For art directors who need total control over AI outputs.',
|
||||
pricing: {
|
||||
estimate: '$0.20 / render with dual-control',
|
||||
notes: 'Saved reference boards reuse controls at $0.05.',
|
||||
},
|
||||
example: {
|
||||
title: 'Storyboard consistency pack',
|
||||
steps: [
|
||||
'Upload wireframe + art-style JPEG',
|
||||
'Set control strength 60% structure / 40% style',
|
||||
'Generate 8 shots → auto-tag to Asset Library',
|
||||
],
|
||||
eta: '~8s per shot',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'batch',
|
||||
title: 'Batch Processor',
|
||||
subtitle: 'Scale campaigns',
|
||||
description:
|
||||
'Queue generators, edits, upscales, and exports for entire campaigns with cost previews, scheduling, and monitoring.',
|
||||
highlights: ['Bulk prompts', 'Usage tracking', 'Schedule windows'],
|
||||
status: 'planning',
|
||||
icon: <LibraryBooksIcon />,
|
||||
help: 'Turn one brief into dozens of deliverables automatically.',
|
||||
pricing: {
|
||||
estimate: 'Dynamic · e.g. 25-image pack ≈ $9',
|
||||
notes: 'Warns when batch exceeds remaining credits.',
|
||||
},
|
||||
example: {
|
||||
title: 'Evergreen blog refresh',
|
||||
steps: [
|
||||
'Upload CSV prompts grouped by persona',
|
||||
'Assign module per row (Create, Edit, Upscale)',
|
||||
'Schedule weekend window + email digest',
|
||||
],
|
||||
eta: 'Depends on queue size',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'library',
|
||||
title: 'Asset Library',
|
||||
subtitle: 'Searchable visual archive',
|
||||
description:
|
||||
'AI-tagged collections, favorites, history, and collaboration. Filters by platform, persona, use case, or campaign.',
|
||||
highlights: ['AI tagging', 'Version history', 'Shareable collections'],
|
||||
status: 'planning',
|
||||
icon: <LibraryBooksIcon />,
|
||||
help: 'Centralize every visual produced inside ALwrity.',
|
||||
pricing: {
|
||||
estimate: 'Included in tier · extra storage $5 / 100GB',
|
||||
notes: 'Enterprise adds S3 export + governance logs.',
|
||||
},
|
||||
example: {
|
||||
title: 'Campaign war-room board',
|
||||
steps: [
|
||||
'Filter by persona + platform → pin hero assets',
|
||||
'Share read-only board with agency partner',
|
||||
'Track usage + cost per asset inside analytics tab',
|
||||
],
|
||||
eta: 'Instant search (<500ms)',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip, Button } from '@mui/material';
|
||||
import { controlAssets } from '../constants';
|
||||
|
||||
export const ControlEffectPreview: React.FC = () => {
|
||||
const [videoKey, setVideoKey] = React.useState(0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.5)',
|
||||
p: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'linear-gradient(135deg,#8b5cf6,#a855f7)',
|
||||
color: '#f3e8ff',
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#e9d5ff' }}>
|
||||
Control Input
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={controlAssets.inputImage}
|
||||
alt="Control reference"
|
||||
sx={{ width: '100%', borderRadius: 2, border: '2px solid rgba(255,255,255,0.2)', boxShadow: '0 10px 25px rgba(139,92,246,0.3)' }}
|
||||
/>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="caption" sx={{ color: '#e9d5ff', fontWeight: 600 }}>
|
||||
Prompt
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', lineHeight: 1.5 }}>
|
||||
{controlAssets.prompt}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Seed ${controlAssets.seed}`}
|
||||
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={controlAssets.resolution}
|
||||
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${controlAssets.duration}s`}
|
||||
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1.5,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: '#020617',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#a78bfa' }}>
|
||||
Generated Output
|
||||
</Typography>
|
||||
<Chip
|
||||
label="WAN 2.5"
|
||||
size="small"
|
||||
sx={{ background: 'rgba(167,139,250,0.15)', color: '#a78bfa', borderRadius: 999 }}
|
||||
/>
|
||||
<Button size="small" onClick={() => setVideoKey(prev => prev + 1)} sx={{ ml: 'auto', color: '#a78bfa', textTransform: 'none' }}>
|
||||
Reset preview
|
||||
</Button>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<video key={videoKey} controls poster={controlAssets.inputImage} style={{ width: '100%', display: 'block' }} src={controlAssets.outputVideo} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
background: 'rgba(15,23,42,0.7)',
|
||||
borderRadius: 999,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
color: '#f8fafc',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Voiceover · {controlAssets.duration}s
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
|
||||
Alibaba WAN 2.5 converts text or images into videos (480p/720p/1080p) with synced audio, faster and more affordable than Google Veo3.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { createExamples } from '../constants';
|
||||
|
||||
export const CreateEffectPreview: React.FC = () => {
|
||||
const [textHovered, setTextHovered] = React.useState(false);
|
||||
const [exampleIndex, setExampleIndex] = React.useState(0);
|
||||
const example = createExamples[exampleIndex];
|
||||
const imageWidth = textHovered ? '20%' : '70%';
|
||||
const textWidth = textHovered ? '80%' : '30%';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '3px solid',
|
||||
borderImage:
|
||||
'linear-gradient(135deg, rgba(124,58,237,0.8), rgba(14,165,233,0.8), rgba(16,185,129,0.8)) 1',
|
||||
overflow: 'hidden',
|
||||
height: { xs: 240, md: 280 },
|
||||
display: 'flex',
|
||||
background: '#0f172a',
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: imageWidth,
|
||||
transition: 'width 0.4s ease, filter 0.4s ease',
|
||||
backgroundImage: `url(${example.image})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: textHovered ? 'saturate(1.1)' : 'saturate(1)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
borderRadius: 999,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
boxShadow: '0 10px 20px rgba(2,6,23,0.45)',
|
||||
}}
|
||||
>
|
||||
{createExamples.map((_, idx) => (
|
||||
<Box
|
||||
key={_.id}
|
||||
onClick={() => setExampleIndex(idx)}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: idx === exampleIndex ? '#c4b5fd' : 'rgba(255,255,255,0.3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: textWidth,
|
||||
background: 'rgba(248,250,252,0.95)',
|
||||
color: '#0f172a',
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
boxShadow: '-12px 0 24px rgba(15,23,42,0.25)',
|
||||
transition: 'width 0.4s ease',
|
||||
}}
|
||||
onMouseEnter={() => setTextHovered(true)}
|
||||
onMouseLeave={() => setTextHovered(false)}
|
||||
>
|
||||
<Stack spacing={0.5} sx={{ overflowY: textHovered ? 'auto' : 'hidden', pr: 1 }}>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 1.5, color: '#818cf8' }}>
|
||||
{example.label}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Prompt
|
||||
</Typography>
|
||||
<Typography variant="body2">{example.prompt}</Typography>
|
||||
<Typography variant="body2">{example.description}</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Price ${example.price}`}
|
||||
sx={{ background: '#ede9fe', color: '#4c1d95', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Turnaround ${example.eta}`}
|
||||
sx={{ background: '#cffafe', color: '#0f766e', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={example.provider}
|
||||
sx={{ background: '#dcfce7', color: '#166534', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip, Tooltip } from '@mui/material';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import { editBeforeAfter } from '../constants';
|
||||
|
||||
export const EditEffectPreview: React.FC = () => {
|
||||
const [exampleIndex, setExampleIndex] = React.useState(0);
|
||||
const pair = editBeforeAfter[exampleIndex];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.5)',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: '#fcd34d', letterSpacing: 2 }}>
|
||||
Before → After
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2">
|
||||
Hover to reveal the original upload vs. AI-edited output. Perfect for showing background swaps,
|
||||
inpainting, or relighting.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{['Erase objects cleanly', 'Smart relight', 'Replace backgrounds'].map(label => (
|
||||
<Chip
|
||||
key={label}
|
||||
size="small"
|
||||
label={label}
|
||||
sx={{ background: 'rgba(236,252,203,0.12)', color: '#fef3c7', borderRadius: 999 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<InfoOutlinedIcon sx={{ fontSize: 18, color: 'rgba(252,211,77,0.85)', cursor: 'pointer' }} />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
'--gap': '8px',
|
||||
display: 'grid',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
border: '4px solid #22d3ee',
|
||||
minHeight: { xs: 260, md: 300 },
|
||||
'& > img': {
|
||||
'--progress': 'calc(-1 * var(--gap))',
|
||||
gridArea: '1 / 1',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'clip-path 0.4s 0.1s',
|
||||
},
|
||||
'& > img:first-of-type': {
|
||||
clipPath: 'polygon(0 0, calc(100% + var(--progress)) 0, 0 calc(100% + var(--progress)))',
|
||||
},
|
||||
'& > img:last-of-type': {
|
||||
clipPath: 'polygon(100% 100%, 100% calc(0% - var(--progress)), calc(0% - var(--progress)) 100%)',
|
||||
},
|
||||
'&:hover > img:last-of-type, &:hover > img:first-of-type:hover': {
|
||||
'--progress': 'calc(50% - var(--gap))',
|
||||
},
|
||||
'&:hover > img:first-of-type, &:hover > img:first-of-type:hover + img': {
|
||||
'--progress': 'calc(-50% - var(--gap))',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box component="img" src={pair.before} alt="Original asset" />
|
||||
<Box component="img" src={pair.after} alt="Edited asset" />
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
borderRadius: 999,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
boxShadow: '0 10px 20px rgba(2,6,23,0.5)',
|
||||
}}
|
||||
>
|
||||
{editBeforeAfter.map((_, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
onClick={() => setExampleIndex(idx)}
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: idx === exampleIndex ? '#f472b6' : 'rgba(255,255,255,0.4)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
borderRadius: 999,
|
||||
boxShadow: '0 10px 25px rgba(2,6,23,0.6)',
|
||||
}}
|
||||
>
|
||||
<Chip label="Original" size="small" sx={{ background: '#fef3c7', color: '#78350f', fontWeight: 600 }} />
|
||||
<Chip label="Edited" size="small" sx={{ background: '#a5b4fc', color: '#1e1b4b', fontWeight: 600 }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
|
||||
Prompt used: {pair.prompt}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { transformAssets, platformPresets } from '../constants';
|
||||
|
||||
export const SocialOptimizerEffectPreview: React.FC = () => (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.5)',
|
||||
p: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#fcd34d' }}>
|
||||
Platform Auto-Crop
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Smart resize finds the focal point and generates safe-zone aware crops for every surface.
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: '#020617',
|
||||
p: 2,
|
||||
position: 'relative',
|
||||
minHeight: 280,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={transformAssets.storyboard}
|
||||
alt="Source creative"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 2, filter: 'brightness(0.8)' }}
|
||||
/>
|
||||
{platformPresets.map(frame => (
|
||||
<Box
|
||||
key={frame.label}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
border: '2px solid rgba(248,250,252,0.8)',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 10px 20px rgba(2,6,23,0.45)',
|
||||
transition: 'transform 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { transform: 'scale(1.05)' },
|
||||
...frame,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -24,
|
||||
left: 0,
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
color: '#f8fafc',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{frame.label}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 2 }}>
|
||||
{['Safe zones', 'Focal cropping', 'Batch export'].map(label => (
|
||||
<Chip key={label} size="small" label={label} sx={{ background: 'rgba(15,118,110,0.2)', color: '#5eead4', borderRadius: 999 }} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip, Button } from '@mui/material';
|
||||
import { transformAssets } from '../constants';
|
||||
|
||||
export const TransformEffectPreview: React.FC = () => {
|
||||
const [videoKey, setVideoKey] = React.useState(0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.5)',
|
||||
p: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'linear-gradient(135deg,#0ea5e9,#6366f1)',
|
||||
color: '#e0f2fe',
|
||||
p: 2,
|
||||
minHeight: 260,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#cffafe' }}>
|
||||
Storyboard Prompt
|
||||
</Typography>
|
||||
<Typography variant="body2">{transformAssets.script}</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{['Image-to-video', 'WAN 2.5', '10s duration'].map(label => (
|
||||
<Chip
|
||||
key={label}
|
||||
size="small"
|
||||
label={label}
|
||||
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
|
||||
}}
|
||||
>
|
||||
<Box component="img" src={transformAssets.storyboard} alt="Storyboard still" sx={{ width: '100%', display: 'block' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1.5,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: '#020617',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#38bdf8' }}>
|
||||
Render Preview
|
||||
</Typography>
|
||||
<Chip
|
||||
label="1080p"
|
||||
size="small"
|
||||
sx={{ background: 'rgba(56,189,248,0.15)', color: '#38bdf8', borderRadius: 999 }}
|
||||
/>
|
||||
<Button size="small" onClick={() => setVideoKey(prev => prev + 1)} sx={{ ml: 'auto', color: '#38bdf8', textTransform: 'none' }}>
|
||||
Reset preview
|
||||
</Button>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<video key={videoKey} controls poster={transformAssets.storyboard} style={{ width: '100%', display: 'block' }} src={transformAssets.video} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
background: 'rgba(15,23,42,0.7)',
|
||||
borderRadius: 999,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
color: '#f8fafc',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Scene 1 · Cloud Kitchen
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
|
||||
Convert hero images into narrated clips with motion presets, subtitles, and audio uploads.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { upscaleSamples } from '../constants';
|
||||
|
||||
export const UpscaleEffectPreview: React.FC = () => (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.5)',
|
||||
p: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#f9a8d4' }}>
|
||||
4× Upscale Showcase
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Flip the panels to compare the low-res upload with the 4K-ready output.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label="Fast vs Creative"
|
||||
size="small"
|
||||
sx={{ background: 'rgba(236,72,153,0.15)', color: '#f9a8d4', borderRadius: 999 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
'&:hover .flip-left': { transform: 'rotateY(-180deg)' },
|
||||
'&:hover .flip-right': { transform: 'rotateY(180deg)' },
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ key: 'low', label: 'Before 600×400', className: 'flip-left', image: upscaleSamples.lowRes },
|
||||
{ key: 'high', label: 'After 2400×1600', className: 'flip-right', image: upscaleSamples.hiRes },
|
||||
].map(card => (
|
||||
<Box key={card.key} sx={{ perspective: 1000, width: { xs: 140, sm: 180 }, height: { xs: 200, sm: 240 } }}>
|
||||
<Box
|
||||
className={card.className}
|
||||
sx={{ position: 'relative', width: '100%', height: '100%', transition: '0.6s', transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
<Box
|
||||
className="front"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background:
|
||||
card.key === 'low'
|
||||
? 'linear-gradient(135deg,#4c1d95,#9333ea)'
|
||||
: 'linear-gradient(135deg,#0f766e,#14b8a6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: card.key === 'low' ? 'flex-end' : 'flex-start',
|
||||
px: 2,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
border: '2px solid rgba(255,255,255,0.8)',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ ml: card.key === 'low' ? -6 : 2, mr: card.key === 'low' ? 2 : -6 }}>
|
||||
{card.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
className="back"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: 3,
|
||||
transform: 'rotateY(180deg)',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
}}
|
||||
>
|
||||
<Box component="img" src={card.image} alt={card.label} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
|
||||
Try creative upscaling for texture enhancement, or fast mode for previews.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CreateEffectPreview } from './CreateEffectPreview';
|
||||
export { EditEffectPreview } from './EditEffectPreview';
|
||||
export { UpscaleEffectPreview } from './UpscaleEffectPreview';
|
||||
export { TransformEffectPreview } from './TransformEffectPreview';
|
||||
export { SocialOptimizerEffectPreview } from './SocialOptimizerEffectPreview';
|
||||
export { ControlEffectPreview } from './ControlEffectPreview';
|
||||
|
||||
25
frontend/src/components/ImageStudio/dashboard/types.ts
Normal file
25
frontend/src/components/ImageStudio/dashboard/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ModuleStatus = 'live' | 'coming soon' | 'planning';
|
||||
|
||||
export type ModuleConfig = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
highlights: string[];
|
||||
status: ModuleStatus;
|
||||
route?: string;
|
||||
icon: React.ReactNode;
|
||||
help: string;
|
||||
pricing: {
|
||||
estimate: string;
|
||||
notes: string;
|
||||
};
|
||||
example: {
|
||||
title: string;
|
||||
steps: string[];
|
||||
eta: string;
|
||||
};
|
||||
};
|
||||
|
||||
10
frontend/src/components/ImageStudio/index.ts
Normal file
10
frontend/src/components/ImageStudio/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { CreateStudio } from './CreateStudio';
|
||||
export { TemplateSelector } from './TemplateSelector';
|
||||
export { ImageResultsGallery } from './ImageResultsGallery';
|
||||
export { CostEstimator } from './CostEstimator';
|
||||
export { EditStudio } from './EditStudio';
|
||||
export { UpscaleStudio } from './UpscaleStudio';
|
||||
export { ImageStudioDashboard } from './ImageStudioDashboard';
|
||||
export { ImageStudioLayout } from './ImageStudioLayout';
|
||||
export { ImageMaskEditor } from './ImageMaskEditor';
|
||||
|
||||
62
frontend/src/components/ImageStudio/ui/AsyncStatusBanner.tsx
Normal file
62
frontend/src/components/ImageStudio/ui/AsyncStatusBanner.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Alert, LinearProgress, Stack, Typography } from '@mui/material';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/ErrorOutline';
|
||||
import HourglassTopIcon from '@mui/icons-material/HourglassTop';
|
||||
|
||||
interface AsyncStatusBannerProps {
|
||||
state: 'idle' | 'running' | 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const AsyncStatusBanner: React.FC<AsyncStatusBannerProps> = ({
|
||||
state,
|
||||
message,
|
||||
}) => {
|
||||
if (state === 'idle') return null;
|
||||
|
||||
if (state === 'running') {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<LinearProgress
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background: 'linear-gradient(90deg,#7c3aed,#2563eb)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<HourglassTopIcon sx={{ color: '#93c5fd' }} fontSize="small" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{message || 'Processing request…'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'success') {
|
||||
return (
|
||||
<Alert
|
||||
icon={<CheckCircleIcon />}
|
||||
severity="success"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{message || 'Completed successfully.'}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<ErrorIcon />}
|
||||
severity="error"
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{message || 'Something went wrong.'}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
35
frontend/src/components/ImageStudio/ui/GlassyCard.tsx
Normal file
35
frontend/src/components/ImageStudio/ui/GlassyCard.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Paper, PaperProps } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cardLiftVariants } from './motionPresets';
|
||||
|
||||
export interface GlassyCardProps extends PaperProps {
|
||||
animateOnMount?: boolean;
|
||||
}
|
||||
|
||||
export const GlassyCard: React.FC<GlassyCardProps> = ({
|
||||
children,
|
||||
animateOnMount = true,
|
||||
sx,
|
||||
...rest
|
||||
}) => (
|
||||
<Paper
|
||||
component={motion.div}
|
||||
variants={cardLiftVariants}
|
||||
initial={animateOnMount ? 'hidden' : false}
|
||||
animate={animateOnMount ? 'visible' : false}
|
||||
whileHover="hover"
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.72)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
38
frontend/src/components/ImageStudio/ui/LoadingSkeleton.tsx
Normal file
38
frontend/src/components/ImageStudio/ui/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Box, Skeleton, type BoxProps } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { shimmerVariants } from './motionPresets';
|
||||
|
||||
interface LoadingSkeletonProps extends BoxProps {
|
||||
lines?: number;
|
||||
}
|
||||
|
||||
export const LoadingSkeleton: React.FC<LoadingSkeletonProps> = ({
|
||||
lines = 3,
|
||||
sx,
|
||||
...rest
|
||||
}) => (
|
||||
<Box
|
||||
component={motion.div}
|
||||
variants={shimmerVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
sx={{ width: '100%', ...sx }}
|
||||
{...rest}
|
||||
>
|
||||
{Array.from({ length: lines }).map((_, idx) => (
|
||||
<Skeleton
|
||||
key={idx}
|
||||
variant="rectangular"
|
||||
height={16}
|
||||
animation="wave"
|
||||
sx={{
|
||||
my: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'rgba(148,163,184,0.15)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
52
frontend/src/components/ImageStudio/ui/SectionHeader.tsx
Normal file
52
frontend/src/components/ImageStudio/ui/SectionHeader.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Stack, Typography, Chip, type StackProps } from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
|
||||
interface SectionHeaderProps extends StackProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
status?: 'live' | 'beta' | 'coming';
|
||||
}
|
||||
|
||||
const statusStyles: Record<
|
||||
NonNullable<SectionHeaderProps['status']>,
|
||||
{ label: string; color: string; bg: string }
|
||||
> = {
|
||||
live: { label: 'Live', color: '#10b981', bg: 'rgba(16,185,129,0.15)' },
|
||||
beta: { label: 'Beta', color: '#f59e0b', bg: 'rgba(245,158,11,0.15)' },
|
||||
coming: { label: 'Coming Soon', color: '#94a3b8', bg: 'rgba(148,163,184,0.15)' },
|
||||
};
|
||||
|
||||
export const SectionHeader: React.FC<SectionHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
sx,
|
||||
...rest
|
||||
}) => (
|
||||
<Stack spacing={0.5} sx={{ color: '#f8fafc', ...sx }} {...rest}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<AutoAwesomeIcon sx={{ color: '#c4b5fd' }} fontSize="small" />
|
||||
<Typography variant="h5" fontWeight={800}>
|
||||
{title}
|
||||
</Typography>
|
||||
{status && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={statusStyles[status].label}
|
||||
sx={{
|
||||
backgroundColor: statusStyles[status].bg,
|
||||
color: statusStyles[status].color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
34
frontend/src/components/ImageStudio/ui/StatusChip.tsx
Normal file
34
frontend/src/components/ImageStudio/ui/StatusChip.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Chip, type ChipProps } from '@mui/material';
|
||||
|
||||
export interface StatusChipProps extends Omit<ChipProps, 'color'> {
|
||||
tone?: 'success' | 'warning' | 'info' | 'neutral';
|
||||
}
|
||||
|
||||
const toneStyles: Record<
|
||||
NonNullable<StatusChipProps['tone']>,
|
||||
{ bg: string; color: string }
|
||||
> = {
|
||||
success: { bg: 'rgba(16,185,129,0.15)', color: '#10b981' },
|
||||
warning: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b' },
|
||||
info: { bg: 'rgba(59,130,246,0.15)', color: '#3b82f6' },
|
||||
neutral: { bg: 'rgba(148,163,184,0.2)', color: '#cbd5f5' },
|
||||
};
|
||||
|
||||
export const StatusChip: React.FC<StatusChipProps> = ({
|
||||
tone = 'neutral',
|
||||
sx,
|
||||
...rest
|
||||
}) => (
|
||||
<Chip
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: toneStyles[tone].bg,
|
||||
color: toneStyles[tone].color,
|
||||
fontWeight: 700,
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
7
frontend/src/components/ImageStudio/ui/index.ts
Normal file
7
frontend/src/components/ImageStudio/ui/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './motionPresets';
|
||||
export * from './GlassyCard';
|
||||
export * from './SectionHeader';
|
||||
export * from './StatusChip';
|
||||
export * from './AsyncStatusBanner';
|
||||
export * from './LoadingSkeleton';
|
||||
|
||||
35
frontend/src/components/ImageStudio/ui/motionPresets.ts
Normal file
35
frontend/src/components/ImageStudio/ui/motionPresets.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type Variants, type Easing } from 'framer-motion';
|
||||
|
||||
export const easeOutSmooth: Easing = [0.4, 0, 0.2, 1];
|
||||
export const easeEmphasis: Easing = [0.22, 0.61, 0.36, 1];
|
||||
|
||||
export const fadeSlideVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease: easeOutSmooth },
|
||||
},
|
||||
};
|
||||
|
||||
export const cardLiftVariants: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.45, ease: easeEmphasis },
|
||||
},
|
||||
hover: {
|
||||
y: -4,
|
||||
transition: { duration: 0.2, ease: easeOutSmooth },
|
||||
},
|
||||
};
|
||||
|
||||
export const shimmerVariants: Variants = {
|
||||
initial: { opacity: 0.4 },
|
||||
animate: {
|
||||
opacity: [0.4, 0.7, 0.4],
|
||||
transition: { duration: 1.6, repeat: Infinity, ease: 'easeInOut' },
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user