AI Image Studio Progress Review
- Added new router for content assets - Added new service for content assets - Added new model for content assets - Added new utils for content assets - Added new docs for content assets - Added new tests for content assets - Added new examples for content assets - Added new guides for content assets
This commit is contained in:
@@ -12,7 +12,7 @@ import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||
import StoryWriter from './components/StoryWriter/StoryWriter';
|
||||
import { CreateStudio, EditStudio, UpscaleStudio, ImageStudioDashboard } from './components/ImageStudio';
|
||||
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
|
||||
import PricingPage from './components/Pricing/PricingPage';
|
||||
import WixTestPage from './components/WixTestPage/WixTestPage';
|
||||
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
|
||||
@@ -455,6 +455,9 @@ const App: React.FC = () => {
|
||||
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-editor" element={<ProtectedRoute><EditStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-upscale" element={<ProtectedRoute><UpscaleStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-control" element={<ProtectedRoute><ControlStudio /></ProtectedRoute>} />
|
||||
<Route path="/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
|
||||
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
|
||||
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
|
||||
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
|
||||
1031
frontend/src/components/ImageStudio/AssetLibrary.tsx
Normal file
1031
frontend/src/components/ImageStudio/AssetLibrary.tsx
Normal file
File diff suppressed because it is too large
Load Diff
545
frontend/src/components/ImageStudio/ControlStudio.tsx
Normal file
545
frontend/src/components/ImageStudio/ControlStudio.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
TextField,
|
||||
Alert,
|
||||
Slider,
|
||||
Divider,
|
||||
Chip,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import DeleteIcon from '@mui/icons-material/DeleteOutline';
|
||||
import UploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import { motion, type Variants, type Easing } from 'framer-motion';
|
||||
import {
|
||||
useImageStudio,
|
||||
ControlOperationMeta,
|
||||
ControlImageRequestPayload,
|
||||
} from '../../hooks/useImageStudio';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { OperationButton } from '../shared/OperationButton';
|
||||
import { EditResultViewer } from './EditResultViewer';
|
||||
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
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 ImageUploadSlot: 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
|
||||
component="label"
|
||||
variant="outlined"
|
||||
startIcon={<UploadIcon />}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: alpha('#667eea', 0.5),
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
borderColor: alpha('#667eea', 0.8),
|
||||
background: alpha('#667eea', 0.05),
|
||||
},
|
||||
}}
|
||||
>
|
||||
Upload {label}
|
||||
<input type="file" accept="image/*" hidden onChange={handleFile} />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const ControlStudio: React.FC = () => {
|
||||
const {
|
||||
loadControlOperations,
|
||||
controlOperations,
|
||||
isLoadingControlOps,
|
||||
processControl,
|
||||
isProcessingControl,
|
||||
controlResult,
|
||||
controlError,
|
||||
clearControlResult,
|
||||
} = useImageStudio();
|
||||
|
||||
const [operation, setOperation] = useState<string>('sketch');
|
||||
const [controlImage, setControlImage] = useState<string | null>(null);
|
||||
const [styleImage, setStyleImage] = useState<string | null>(null);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [negativePrompt, setNegativePrompt] = useState('');
|
||||
const [controlStrength, setControlStrength] = useState(0.7);
|
||||
const [fidelity, setFidelity] = useState(0.5);
|
||||
const [styleStrength, setStyleStrength] = useState(1.0);
|
||||
const [compositionFidelity, setCompositionFidelity] = useState(0.9);
|
||||
const [changeStrength, setChangeStrength] = useState(0.9);
|
||||
const [aspectRatio, setAspectRatio] = useState('1:1');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadControlOperations();
|
||||
}, [loadControlOperations]);
|
||||
|
||||
useEffect(() => {
|
||||
const keys = Object.keys(controlOperations);
|
||||
if (keys.length && !keys.includes(operation)) {
|
||||
setOperation(keys[0]);
|
||||
}
|
||||
}, [controlOperations, operation]);
|
||||
|
||||
// Reset state when operation changes
|
||||
useEffect(() => {
|
||||
// Reset sliders to defaults based on operation
|
||||
if (operation === 'style_transfer') {
|
||||
setStyleStrength(1.0);
|
||||
setCompositionFidelity(0.9);
|
||||
setChangeStrength(0.9);
|
||||
} else if (operation === 'style') {
|
||||
setFidelity(0.5);
|
||||
} else if (operation === 'sketch' || operation === 'structure') {
|
||||
setControlStrength(0.7);
|
||||
}
|
||||
// Clear result when switching operations
|
||||
clearControlResult();
|
||||
setLocalError(null);
|
||||
}, [operation, clearControlResult]);
|
||||
|
||||
const operationMeta: ControlOperationMeta | undefined = controlOperations[operation];
|
||||
const fields = operationMeta?.fields || {};
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!controlImage) return false;
|
||||
if (!prompt.trim()) return false;
|
||||
if (fields.style_image && !styleImage) return false;
|
||||
return true;
|
||||
}, [controlImage, prompt, fields.style_image, styleImage]);
|
||||
|
||||
// Use same operation type as image generation for consistency
|
||||
const controlOperation = useMemo(() => ({
|
||||
provider: 'stability',
|
||||
operation_type: 'image_generation', // Control ops use image generation limits
|
||||
actual_provider_name: 'stability',
|
||||
model: 'core', // Default model for cost estimation
|
||||
}), []);
|
||||
|
||||
const buildPayload = (): ControlImageRequestPayload | null => {
|
||||
if (!controlImage) {
|
||||
setLocalError('Please upload a control image.');
|
||||
return null;
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
setLocalError('Please provide a prompt.');
|
||||
return null;
|
||||
}
|
||||
if (fields.style_image && !styleImage) {
|
||||
setLocalError('Style image is required for style transfer.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: ControlImageRequestPayload = {
|
||||
control_image_base64: controlImage,
|
||||
operation: operation as 'sketch' | 'structure' | 'style' | 'style_transfer',
|
||||
prompt: prompt.trim(),
|
||||
style_image_base64: fields.style_image ? styleImage || undefined : undefined,
|
||||
negative_prompt: negativePrompt || undefined,
|
||||
control_strength: fields.control_strength ? controlStrength : undefined,
|
||||
fidelity: fields.fidelity ? fidelity : undefined,
|
||||
style_strength: fields.style_strength ? styleStrength : undefined,
|
||||
composition_fidelity: operation === 'style_transfer' ? compositionFidelity : undefined,
|
||||
change_strength: operation === 'style_transfer' ? changeStrength : undefined,
|
||||
aspect_ratio: fields.aspect_ratio ? aspectRatio : undefined,
|
||||
output_format: 'png',
|
||||
};
|
||||
return payload;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLocalError(null);
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
await processControl(payload);
|
||||
} catch {
|
||||
// errors handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const operationLabels: Record<string, string> = {
|
||||
sketch: 'Sketch to Image',
|
||||
structure: 'Structure Control',
|
||||
style: 'Style Control',
|
||||
style_transfer: 'Style Transfer',
|
||||
};
|
||||
|
||||
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',
|
||||
}}
|
||||
>
|
||||
Control Studio
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Advanced control for precise image generation. Transform sketches, maintain structure, apply styles, and transfer visual characteristics.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{(localError || controlError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => {
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
{localError || controlError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={5}>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<EditNoteIcon sx={{ color: '#a78bfa' }} />
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Operation
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{(Object.keys(controlOperations) as Array<keyof typeof controlOperations>).map((key) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={controlOperations[key]?.label || operationLabels[key] || key}
|
||||
onClick={() => {
|
||||
setOperation(key);
|
||||
}}
|
||||
sx={{
|
||||
bgcolor: operation === key ? alpha('#667eea', 0.2) : 'transparent',
|
||||
border: `1px solid ${operation === key ? '#667eea' : 'rgba(255,255,255,0.1)'}`,
|
||||
color: operation === key ? '#c7d2fe' : 'text.secondary',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
bgcolor: alpha('#667eea', 0.1),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{operationMeta && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
{operationMeta.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<ImageUploadSlot
|
||||
label={operation === 'style_transfer' ? 'Initial Image' : 'Control Image'}
|
||||
helper={
|
||||
operation === 'sketch'
|
||||
? 'Upload a sketch or line drawing'
|
||||
: operation === 'structure'
|
||||
? 'Upload an image whose structure to maintain'
|
||||
: operation === 'style'
|
||||
? 'Upload a style reference image'
|
||||
: 'Upload the image to restyle'
|
||||
}
|
||||
value={controlImage}
|
||||
onChange={setControlImage}
|
||||
/>
|
||||
|
||||
{fields.style_image && (
|
||||
<ImageUploadSlot
|
||||
label="Style Image"
|
||||
helper="Upload a style reference image"
|
||||
value={styleImage}
|
||||
onChange={setStyleImage}
|
||||
/>
|
||||
)}
|
||||
</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}>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
label="Prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Describe what you want to generate..."
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Negative Prompt"
|
||||
value={negativePrompt}
|
||||
onChange={(e) => setNegativePrompt(e.target.value)}
|
||||
placeholder="Elements to avoid..."
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{fields.control_strength && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}>
|
||||
Control Strength: {Math.round(controlStrength * 100)}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={controlStrength}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(_, value) => setControlStrength(value as number)}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{fields.fidelity && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}>
|
||||
Style Fidelity: {Math.round(fidelity * 100)}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={fidelity}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(_, value) => setFidelity(value as number)}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{fields.style_strength && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}>
|
||||
Style Strength: {Math.round(styleStrength * 100)}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={styleStrength}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(_, value) => setStyleStrength(value as number)}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}>
|
||||
Composition Fidelity: {Math.round(compositionFidelity * 100)}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={compositionFidelity}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(_, value) => setCompositionFidelity(value as number)}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 1 }}>
|
||||
Change Strength: {Math.round(changeStrength * 100)}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={changeStrength}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(_, value) => setChangeStrength(value as number)}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{fields.aspect_ratio && (
|
||||
<TextField
|
||||
select
|
||||
label="Aspect Ratio"
|
||||
value={aspectRatio}
|
||||
onChange={(e) => setAspectRatio(e.target.value)}
|
||||
fullWidth
|
||||
SelectProps={{
|
||||
native: true,
|
||||
}}
|
||||
>
|
||||
<option value="1:1">1:1 (Square)</option>
|
||||
<option value="16:9">16:9 (Landscape)</option>
|
||||
<option value="9:16">9:16 (Portrait)</option>
|
||||
<option value="4:3">4:3 (Standard)</option>
|
||||
<option value="3:4">3:4 (Portrait)</option>
|
||||
</TextField>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<OperationButton
|
||||
operation={controlOperation}
|
||||
label="Generate"
|
||||
startIcon={<EditNoteIcon />}
|
||||
onClick={handleGenerate}
|
||||
disabled={!canSubmit}
|
||||
loading={isProcessingControl}
|
||||
checkOnMount
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(90deg, #7c3aed, #2563eb)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditResultViewer
|
||||
originalImage={controlImage}
|
||||
result={controlResult ? {
|
||||
success: controlResult.success,
|
||||
operation: controlResult.operation,
|
||||
provider: controlResult.provider,
|
||||
image_base64: controlResult.image_base64,
|
||||
width: controlResult.width,
|
||||
height: controlResult.height,
|
||||
metadata: controlResult.metadata,
|
||||
} : null}
|
||||
isProcessing={isProcessingControl}
|
||||
onReset={() => {
|
||||
clearControlResult();
|
||||
setPrompt('');
|
||||
setNegativePrompt('');
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MotionPaper>
|
||||
</ImageStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
587
frontend/src/components/ImageStudio/SocialOptimizer.tsx
Normal file
587
frontend/src/components/ImageStudio/SocialOptimizer.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
Chip,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import ShareIcon from '@mui/icons-material/Share';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import DeleteIcon from '@mui/icons-material/DeleteOutline';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import { motion, type Variants, type Easing } from 'framer-motion';
|
||||
import { useImageStudio, PlatformFormat } from '../../hooks/useImageStudio';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { OperationButton } from '../shared/OperationButton';
|
||||
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
const PLATFORMS = [
|
||||
{ value: 'instagram', label: 'Instagram', icon: '📷' },
|
||||
{ value: 'facebook', label: 'Facebook', icon: '👥' },
|
||||
{ value: 'twitter', label: 'Twitter/X', icon: '🐦' },
|
||||
{ value: 'linkedin', label: 'LinkedIn', icon: '💼' },
|
||||
{ value: 'youtube', label: 'YouTube', icon: '📺' },
|
||||
{ value: 'pinterest', label: 'Pinterest', icon: '📌' },
|
||||
{ value: 'tiktok', label: 'TikTok', icon: '🎵' },
|
||||
];
|
||||
|
||||
const CROP_MODES = [
|
||||
{ value: 'smart', label: 'Smart Crop', description: 'Preserve important content' },
|
||||
{ value: 'center', label: 'Center Crop', description: 'Crop from center' },
|
||||
{ value: 'fit', label: 'Fit', description: 'Fit with padding' },
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
export const SocialOptimizer: React.FC = () => {
|
||||
const {
|
||||
optimizeForSocial,
|
||||
getPlatformFormats,
|
||||
isOptimizing,
|
||||
optimizeResult,
|
||||
optimizeError,
|
||||
clearOptimizeResult,
|
||||
} = useImageStudio();
|
||||
|
||||
const [sourceImage, setSourceImage] = useState<string | null>(null);
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||
const [formatSelections, setFormatSelections] = useState<Record<string, string>>({});
|
||||
const [platformFormats, setPlatformFormats] = useState<Record<string, PlatformFormat[]>>({});
|
||||
const [cropMode, setCropMode] = useState<string>('smart');
|
||||
const [showSafeZones, setShowSafeZones] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Load formats when platforms are selected
|
||||
useEffect(() => {
|
||||
const loadFormats = async () => {
|
||||
const formats: Record<string, PlatformFormat[]> = {};
|
||||
for (const platform of selectedPlatforms) {
|
||||
if (!platformFormats[platform]) {
|
||||
const formatsList = await getPlatformFormats(platform);
|
||||
formats[platform] = formatsList;
|
||||
}
|
||||
}
|
||||
if (Object.keys(formats).length > 0) {
|
||||
setPlatformFormats((prev) => ({ ...prev, ...formats }));
|
||||
// Set default format for each platform
|
||||
setFormatSelections((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.entries(formats).forEach(([platform, formatList]) => {
|
||||
if (!updated[platform] && formatList.length > 0) {
|
||||
updated[platform] = formatList[0].name;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
if (selectedPlatforms.length > 0) {
|
||||
loadFormats();
|
||||
}
|
||||
}, [selectedPlatforms, getPlatformFormats]);
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
setSourceImage(dataUrl);
|
||||
clearOptimizeResult();
|
||||
setLocalError(null);
|
||||
} catch (err) {
|
||||
setLocalError('Failed to read image file');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlatformToggle = (platform: string) => {
|
||||
setSelectedPlatforms((prev) => {
|
||||
if (prev.includes(platform)) {
|
||||
const updated = prev.filter((p) => p !== platform);
|
||||
const newSelections = { ...formatSelections };
|
||||
delete newSelections[platform];
|
||||
setFormatSelections(newSelections);
|
||||
return updated;
|
||||
} else {
|
||||
return [...prev, platform];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOptimize = async () => {
|
||||
setLocalError(null);
|
||||
if (!sourceImage) {
|
||||
setLocalError('Please upload a source image.');
|
||||
return;
|
||||
}
|
||||
if (selectedPlatforms.length === 0) {
|
||||
setLocalError('Please select at least one platform.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formatNames: Record<string, string> = {};
|
||||
selectedPlatforms.forEach((platform) => {
|
||||
const format = formatSelections[platform];
|
||||
if (format) {
|
||||
formatNames[platform] = format;
|
||||
}
|
||||
});
|
||||
|
||||
await optimizeForSocial({
|
||||
image_base64: sourceImage,
|
||||
platforms: selectedPlatforms,
|
||||
format_names: Object.keys(formatNames).length > 0 ? formatNames : undefined,
|
||||
show_safe_zones: showSafeZones,
|
||||
crop_mode: cropMode,
|
||||
output_format: 'png',
|
||||
});
|
||||
} catch {
|
||||
// Error handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (imageBase64: string, filename: string) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = imageBase64;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
if (!optimizeResult) return;
|
||||
optimizeResult.results.forEach((result, index) => {
|
||||
const filename = `${result.platform}_${result.format.replace(/\s+/g, '_')}_${index + 1}.png`;
|
||||
handleDownload(result.image_base64, filename);
|
||||
});
|
||||
};
|
||||
|
||||
const canOptimize = sourceImage && selectedPlatforms.length > 0 && !isOptimizing;
|
||||
|
||||
const socialOperation = useMemo(
|
||||
() => ({
|
||||
provider: 'internal',
|
||||
operation_type: 'image_processing',
|
||||
actual_provider_name: 'internal',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
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',
|
||||
}}
|
||||
>
|
||||
Social Optimizer
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Optimize images for all major social platforms with smart cropping, safe zones, and batch export.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{(localError || optimizeError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => {
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
{localError || optimizeError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack spacing={3}>
|
||||
{/* Image Upload */}
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: alpha('#0f172a', 0.7),
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Source Image
|
||||
</Typography>
|
||||
{sourceImage ? (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={sourceImage}
|
||||
alt="Source"
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setSourceImage(null);
|
||||
clearOptimizeResult();
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
bgcolor: alpha('#000', 0.5),
|
||||
color: '#fff',
|
||||
'&:hover': { bgcolor: alpha('#000', 0.7) },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
component="label"
|
||||
variant="outlined"
|
||||
startIcon={<CloudUploadIcon />}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: alpha('#667eea', 0.5),
|
||||
color: 'text.secondary',
|
||||
py: 3,
|
||||
'&:hover': {
|
||||
borderColor: alpha('#667eea', 0.8),
|
||||
background: alpha('#667eea', 0.05),
|
||||
},
|
||||
}}
|
||||
>
|
||||
Upload Image
|
||||
<input type="file" accept="image/*" hidden onChange={handleFileUpload} />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Platform Selection */}
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: alpha('#0f172a', 0.7),
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Select Platforms
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{PLATFORMS.map((platform) => (
|
||||
<FormControlLabel
|
||||
key={platform.value}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedPlatforms.includes(platform.value)}
|
||||
onChange={() => handlePlatformToggle(platform.value)}
|
||||
sx={{ color: '#667eea' }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography>{platform.icon}</Typography>
|
||||
<Typography>{platform.label}</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Format Selection */}
|
||||
{selectedPlatforms.length > 0 && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: alpha('#0f172a', 0.7),
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Format Selection
|
||||
</Typography>
|
||||
{selectedPlatforms.map((platform) => {
|
||||
const formats = platformFormats[platform] || [];
|
||||
if (formats.length === 0) return null;
|
||||
return (
|
||||
<FormControl key={platform} fullWidth size="small">
|
||||
<InputLabel>{PLATFORMS.find((p) => p.value === platform)?.label}</InputLabel>
|
||||
<Select
|
||||
value={formatSelections[platform] || formats[0].name}
|
||||
label={PLATFORMS.find((p) => p.value === platform)?.label}
|
||||
onChange={(e) =>
|
||||
setFormatSelections((prev) => ({
|
||||
...prev,
|
||||
[platform]: e.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{formats.map((format) => (
|
||||
<MenuItem key={format.name} value={format.name}>
|
||||
{format.name} ({format.width}x{format.height})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: alpha('#0f172a', 0.7),
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Options
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Crop Mode
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={cropMode}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setCropMode(value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{CROP_MODES.map((mode) => (
|
||||
<ToggleButton key={mode.value} value={mode.value}>
|
||||
<Stack spacing={0.5} alignItems="center">
|
||||
<Typography variant="caption" fontWeight={600}>
|
||||
{mode.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
|
||||
{mode.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={showSafeZones}
|
||||
onChange={(e) => setShowSafeZones(e.target.checked)}
|
||||
sx={{ color: '#667eea' }}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography>Show Safe Zones</Typography>
|
||||
<Tooltip title="Display text safe zone overlays on optimized images">
|
||||
<IconButton size="small" sx={{ p: 0.5 }}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<OperationButton
|
||||
operation={socialOperation}
|
||||
label="Optimize Images"
|
||||
startIcon={<ShareIcon />}
|
||||
onClick={handleOptimize}
|
||||
disabled={!canOptimize}
|
||||
loading={isOptimizing}
|
||||
checkOnMount={false}
|
||||
showCost={false}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(90deg, #7c3aed, #2563eb)',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={8}>
|
||||
{optimizeResult && optimizeResult.results.length > 0 && (
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Optimized Images ({optimizeResult.total_optimized})
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownloadAll}
|
||||
sx={{ borderRadius: 999 }}
|
||||
>
|
||||
Download All
|
||||
</Button>
|
||||
</Stack>
|
||||
<Grid container spacing={2}>
|
||||
{optimizeResult.results.map((result, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={index}>
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: alpha('#0f172a', 0.7),
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={result.image_base64}
|
||||
alt={`${result.platform} ${result.format}`}
|
||||
sx={{ height: 200, objectFit: 'contain' }}
|
||||
/>
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||
<Chip
|
||||
label={result.platform}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha('#667eea', 0.2),
|
||||
color: '#c7d2fe',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={`${result.width}x${result.height}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{result.format}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() =>
|
||||
handleDownload(
|
||||
result.image_base64,
|
||||
`${result.platform}_${result.format.replace(/\s+/g, '_')}.png`
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
{!optimizeResult && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: alpha('#0f172a', 0.5),
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Upload an image and select platforms to see optimized results here.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MotionPaper>
|
||||
</ImageStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -115,7 +115,8 @@ export const studioModules: ModuleConfig[] = [
|
||||
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',
|
||||
status: 'live',
|
||||
route: '/social-optimizer',
|
||||
icon: <ShareIcon />,
|
||||
help: 'Ship consistent assets across every social surface.',
|
||||
pricing: {
|
||||
@@ -139,7 +140,8 @@ export const studioModules: ModuleConfig[] = [
|
||||
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',
|
||||
status: 'live',
|
||||
route: '/image-control',
|
||||
icon: <EditNoteIcon />,
|
||||
help: 'For art directors who need total control over AI outputs.',
|
||||
pricing: {
|
||||
@@ -187,7 +189,8 @@ export const studioModules: ModuleConfig[] = [
|
||||
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',
|
||||
status: 'live',
|
||||
route: '/asset-library',
|
||||
icon: <LibraryBooksIcon />,
|
||||
help: 'Centralize every visual produced inside ALwrity.',
|
||||
pricing: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip, Button } from '@mui/material';
|
||||
import { controlAssets } from '../constants';
|
||||
import { OptimizedImage } from '../utils/OptimizedImage';
|
||||
import { OptimizedVideo } from '../utils/OptimizedVideo';
|
||||
|
||||
export const ControlEffectPreview: React.FC = () => {
|
||||
const [videoKey, setVideoKey] = React.useState(0);
|
||||
@@ -32,11 +34,17 @@ export const ControlEffectPreview: React.FC = () => {
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#e9d5ff' }}>
|
||||
Control Input
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
<OptimizedImage
|
||||
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)' }}
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 100vw, 50vw"
|
||||
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 }}>
|
||||
@@ -94,7 +102,15 @@ export const ControlEffectPreview: React.FC = () => {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<video key={videoKey} controls poster={controlAssets.inputImage} style={{ width: '100%', display: 'block' }} src={controlAssets.outputVideo} />
|
||||
<OptimizedVideo
|
||||
key={videoKey}
|
||||
src={controlAssets.outputVideo}
|
||||
poster={controlAssets.inputImage}
|
||||
alt="Control video output"
|
||||
controls
|
||||
preload="none"
|
||||
sx={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { createExamples } from '../constants';
|
||||
import { OptimizedImage } from '../utils/OptimizedImage';
|
||||
|
||||
export const CreateEffectPreview: React.FC = () => {
|
||||
const [textHovered, setTextHovered] = React.useState(false);
|
||||
@@ -28,13 +29,22 @@ export const CreateEffectPreview: React.FC = () => {
|
||||
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',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={example.image}
|
||||
alt={example.label}
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 70vw, 50vw"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Box, Stack, Typography, Chip, Tooltip } from '@mui/material';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import { editBeforeAfter } from '../constants';
|
||||
import { OptimizedImage } from '../utils/OptimizedImage';
|
||||
|
||||
export const EditEffectPreview: React.FC = () => {
|
||||
const [exampleIndex, setExampleIndex] = React.useState(0);
|
||||
@@ -54,30 +55,48 @@ export const EditEffectPreview: React.FC = () => {
|
||||
overflow: 'hidden',
|
||||
border: '4px solid #22d3ee',
|
||||
minHeight: { xs: 260, md: 300 },
|
||||
'& > img': {
|
||||
'& > *:first-of-type': {
|
||||
'--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': {
|
||||
'& > *:last-of-type': {
|
||||
'--progress': 'calc(-1 * var(--gap))',
|
||||
gridArea: '1 / 1',
|
||||
transition: 'clip-path 0.4s 0.1s',
|
||||
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': {
|
||||
'&:hover > *:last-of-type, &:hover > *:first-of-type:hover': {
|
||||
'--progress': 'calc(50% - var(--gap))',
|
||||
},
|
||||
'&:hover > img:first-of-type, &:hover > img:first-of-type:hover + img': {
|
||||
'&:hover > *:first-of-type, &:hover > *:first-of-type:hover + *': {
|
||||
'--progress': 'calc(-50% - var(--gap))',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box component="img" src={pair.before} alt="Original asset" />
|
||||
<Box component="img" src={pair.after} alt="Edited asset" />
|
||||
<OptimizedImage
|
||||
src={pair.before}
|
||||
alt="Original asset"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 100vw, 50vw"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<OptimizedImage
|
||||
src={pair.after}
|
||||
alt="Edited asset"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 100vw, 50vw"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { transformAssets, platformPresets } from '../constants';
|
||||
import { OptimizedImage } from '../utils/OptimizedImage';
|
||||
|
||||
export const SocialOptimizerEffectPreview: React.FC = () => (
|
||||
<Box
|
||||
@@ -30,11 +31,18 @@ export const SocialOptimizerEffectPreview: React.FC = () => (
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
<OptimizedImage
|
||||
src={transformAssets.storyboard}
|
||||
alt="Source creative"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 2, filter: 'brightness(0.8)' }}
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 100vw, 100vw"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 2,
|
||||
filter: 'brightness(0.8)',
|
||||
}}
|
||||
/>
|
||||
{platformPresets.map(frame => (
|
||||
<Box
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip, Button } from '@mui/material';
|
||||
import { transformAssets } from '../constants';
|
||||
import { OptimizedImage } from '../utils/OptimizedImage';
|
||||
import { OptimizedVideo } from '../utils/OptimizedVideo';
|
||||
|
||||
export const TransformEffectPreview: React.FC = () => {
|
||||
const [videoKey, setVideoKey] = React.useState(0);
|
||||
@@ -53,7 +55,13 @@ export const TransformEffectPreview: React.FC = () => {
|
||||
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
|
||||
}}
|
||||
>
|
||||
<Box component="img" src={transformAssets.storyboard} alt="Storyboard still" sx={{ width: '100%', display: 'block' }} />
|
||||
<OptimizedImage
|
||||
src={transformAssets.storyboard}
|
||||
alt="Storyboard still"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 100vw, 50vw"
|
||||
sx={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -86,7 +94,15 @@ export const TransformEffectPreview: React.FC = () => {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<video key={videoKey} controls poster={transformAssets.storyboard} style={{ width: '100%', display: 'block' }} src={transformAssets.video} />
|
||||
<OptimizedVideo
|
||||
key={videoKey}
|
||||
src={transformAssets.video}
|
||||
poster={transformAssets.storyboard}
|
||||
alt="Transform video preview"
|
||||
controls
|
||||
preload="none"
|
||||
sx={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { upscaleSamples } from '../constants';
|
||||
import { OptimizedImage } from '../utils/OptimizedImage';
|
||||
|
||||
export const UpscaleEffectPreview: React.FC = () => (
|
||||
<Box
|
||||
@@ -92,7 +93,13 @@ export const UpscaleEffectPreview: React.FC = () => (
|
||||
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' }} />
|
||||
<OptimizedImage
|
||||
src={card.image}
|
||||
alt={card.label}
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 140px, 180px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Box, Skeleton } from '@mui/material';
|
||||
|
||||
interface OptimizedImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
sx?: any;
|
||||
loading?: 'lazy' | 'eager';
|
||||
placeholder?: 'blur' | 'empty';
|
||||
sizes?: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
export const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||
src,
|
||||
alt,
|
||||
sx = {},
|
||||
loading = 'lazy',
|
||||
placeholder = 'blur',
|
||||
sizes,
|
||||
width,
|
||||
height,
|
||||
}) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [isInView, setIsInView] = useState(loading === 'eager');
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const imgRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading === 'eager') {
|
||||
setIsInView(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: '50px',
|
||||
threshold: 0.01,
|
||||
}
|
||||
);
|
||||
|
||||
if (imgRef.current) {
|
||||
observer.observe(imgRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
const handleLoad = () => {
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setHasError(true);
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
// Extract clip-path and other advanced CSS from sx to apply to wrapper
|
||||
const {
|
||||
clipPath,
|
||||
gridArea,
|
||||
'--progress': progress,
|
||||
...imgSx
|
||||
} = sx || {};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={imgRef}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
clipPath,
|
||||
gridArea,
|
||||
'--progress': progress,
|
||||
...(clipPath ? {} : sx),
|
||||
}}
|
||||
>
|
||||
{!isLoaded && !hasError && (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height="100%"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: 'rgba(15,23,42,0.5)',
|
||||
borderRadius: imgSx.borderRadius || 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isInView && (
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
loading={loading}
|
||||
sizes={sizes}
|
||||
width={width}
|
||||
height={height}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: isLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
...imgSx,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasError && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(15,23,42,0.8)',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
Failed to load image
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Box, Skeleton } from '@mui/material';
|
||||
|
||||
interface OptimizedVideoProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
alt?: string;
|
||||
sx?: any;
|
||||
controls?: boolean;
|
||||
preload?: 'none' | 'metadata' | 'auto';
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
playsInline?: boolean;
|
||||
}
|
||||
|
||||
export const OptimizedVideo: React.FC<OptimizedVideoProps> = ({
|
||||
src,
|
||||
poster,
|
||||
alt,
|
||||
sx = {},
|
||||
controls = true,
|
||||
preload = 'metadata',
|
||||
muted = false,
|
||||
loop = false,
|
||||
playsInline = true,
|
||||
}) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Less aggressive: load when element is visible or about to be visible
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: '50px',
|
||||
threshold: 0.01, // Trigger as soon as any part is visible
|
||||
}
|
||||
);
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLoadedData = () => {
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setHasError(true);
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{!isLoaded && !hasError && (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height="100%"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: 'rgba(15,23,42,0.5)',
|
||||
borderRadius: sx.borderRadius || 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Always render video element, but use lazy loading attribute */}
|
||||
<Box
|
||||
component="video"
|
||||
ref={videoRef}
|
||||
src={isInView ? src : undefined}
|
||||
poster={poster}
|
||||
controls={controls}
|
||||
preload={isInView ? preload : 'none'}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
playsInline={playsInline}
|
||||
onLoadedData={handleLoadedData}
|
||||
onCanPlay={handleCanPlay}
|
||||
onError={handleError}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
opacity: isLoaded ? 1 : poster ? 0.7 : 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
{hasError && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(15,23,42,0.8)',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '0.875rem',
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
Failed to load video
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Dashboard Media Optimization
|
||||
|
||||
This directory contains optimized components for images and videos used in the Image Studio Dashboard previews.
|
||||
|
||||
## Components
|
||||
|
||||
### OptimizedImage
|
||||
A lazy-loading image component with the following features:
|
||||
- **Intersection Observer**: Images only load when they're about to enter the viewport (50px margin)
|
||||
- **Loading States**: Skeleton placeholders while images load
|
||||
- **Error Handling**: Graceful fallback UI for failed image loads
|
||||
- **Smooth Transitions**: Fade-in effect when images load
|
||||
- **Responsive Sizing**: Supports `sizes` attribute for responsive image loading
|
||||
- **Native Lazy Loading**: Falls back to native `loading="lazy"` attribute
|
||||
|
||||
### OptimizedVideo
|
||||
A lazy-loading video component with the following features:
|
||||
- **Intersection Observer**: Videos only load when they're about to be visible (100px margin)
|
||||
- **Preload Control**: Default `preload="none"` to prevent unnecessary bandwidth usage
|
||||
- **Poster Images**: Shows poster image while video loads
|
||||
- **Loading States**: Skeleton placeholders during video load
|
||||
- **Hover-to-Load**: Videos can be set to load on hover for better UX
|
||||
- **Error Handling**: Graceful fallback UI for failed video loads
|
||||
|
||||
## Performance Benefits
|
||||
|
||||
1. **Reduced Initial Load**: Images and videos only load when needed
|
||||
2. **Bandwidth Savings**: Videos don't preload, saving data for users
|
||||
3. **Better UX**: Loading states provide visual feedback
|
||||
4. **SEO Friendly**: Proper alt text and semantic HTML
|
||||
5. **Accessibility**: Error states and fallbacks for better accessibility
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { OptimizedImage, OptimizedVideo } from '../utils';
|
||||
|
||||
// Image with lazy loading
|
||||
<OptimizedImage
|
||||
src="/path/to/image.jpg"
|
||||
alt="Description"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 100vw, 50vw"
|
||||
sx={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
|
||||
// Video with lazy loading
|
||||
<OptimizedVideo
|
||||
src="/path/to/video.mp4"
|
||||
poster="/path/to/poster.jpg"
|
||||
alt="Video description"
|
||||
controls
|
||||
preload="none"
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Always provide meaningful `alt` text for images
|
||||
2. Use appropriate `sizes` attribute for responsive images
|
||||
3. Set `preload="none"` for videos that aren't immediately visible
|
||||
4. Provide poster images for videos to improve perceived performance
|
||||
5. Use `loading="eager"` only for above-the-fold critical images
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { OptimizedImage } from './OptimizedImage';
|
||||
export { OptimizedVideo } from './OptimizedVideo';
|
||||
|
||||
@@ -4,6 +4,9 @@ export { ImageResultsGallery } from './ImageResultsGallery';
|
||||
export { CostEstimator } from './CostEstimator';
|
||||
export { EditStudio } from './EditStudio';
|
||||
export { UpscaleStudio } from './UpscaleStudio';
|
||||
export { ControlStudio } from './ControlStudio';
|
||||
export { SocialOptimizer } from './SocialOptimizer';
|
||||
export { AssetLibrary } from './AssetLibrary';
|
||||
export { ImageStudioDashboard } from './ImageStudioDashboard';
|
||||
export { ImageStudioLayout } from './ImageStudioLayout';
|
||||
export { ImageMaskEditor } from './ImageMaskEditor';
|
||||
|
||||
244
frontend/src/hooks/useContentAssets.ts
Normal file
244
frontend/src/hooks/useContentAssets.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
|
||||
export interface ContentAsset {
|
||||
id: number;
|
||||
user_id: string;
|
||||
asset_type: 'text' | 'image' | 'video' | 'audio';
|
||||
source_module: string;
|
||||
filename: string;
|
||||
file_url: string;
|
||||
file_path?: string;
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
tags: string[];
|
||||
metadata: Record<string, any>;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
cost: number;
|
||||
generation_time?: number;
|
||||
is_favorite: boolean;
|
||||
download_count: number;
|
||||
share_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AssetFilters {
|
||||
asset_type?: 'text' | 'image' | 'video' | 'audio';
|
||||
source_module?: string;
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
favorites_only?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AssetListResponse {
|
||||
assets: ContentAsset[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
export const useContentAssets = (filters: AssetFilters = {}) => {
|
||||
const { getToken } = useAuth();
|
||||
const [assets, setAssets] = useState<ContentAsset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const fetchAssets = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (filters.asset_type) params.append('asset_type', filters.asset_type);
|
||||
if (filters.source_module) params.append('source_module', filters.source_module);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.tags && filters.tags.length > 0) params.append('tags', filters.tags.join(','));
|
||||
if (filters.favorites_only) params.append('favorites_only', 'true');
|
||||
params.append('limit', String(filters.limit || 100));
|
||||
params.append('offset', String(filters.offset || 0));
|
||||
|
||||
// Add cache busting for fresh data
|
||||
params.append('_t', String(Date.now()));
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/?${params.toString()}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch assets: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: AssetListResponse = await response.json();
|
||||
setAssets(data.assets);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch assets');
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getToken, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssets();
|
||||
}, [fetchAssets]);
|
||||
|
||||
const toggleFavorite = useCallback(async (assetId: number) => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/${assetId}/favorite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to toggle favorite');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update local state
|
||||
setAssets(prev =>
|
||||
prev.map(asset =>
|
||||
asset.id === assetId ? { ...asset, is_favorite: data.is_favorite } : asset
|
||||
)
|
||||
);
|
||||
|
||||
return data.is_favorite;
|
||||
} catch (err) {
|
||||
console.error('Error toggling favorite:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
const deleteAsset = useCallback(async (assetId: number) => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/${assetId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete asset');
|
||||
}
|
||||
|
||||
// Remove from local state
|
||||
setAssets(prev => prev.filter(asset => asset.id !== assetId));
|
||||
setTotal(prev => prev - 1);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error deleting asset:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
const trackUsage = useCallback(async (assetId: number, action: 'download' | 'share' | 'access') => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(`${API_BASE_URL}/api/content-assets/${assetId}/usage?action=${action}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error tracking usage:', err);
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
const updateAsset = useCallback(async (
|
||||
assetId: number,
|
||||
updates: { title?: string; description?: string; tags?: string[] }
|
||||
) => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const body: any = {};
|
||||
if (updates.title !== undefined) body.title = updates.title;
|
||||
if (updates.description !== undefined) body.description = updates.description;
|
||||
if (updates.tags !== undefined) body.tags = updates.tags; // Send as array, not comma-separated
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/${assetId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update asset');
|
||||
}
|
||||
|
||||
const updatedAsset = await response.json();
|
||||
|
||||
// Update local state
|
||||
setAssets(prev =>
|
||||
prev.map(asset =>
|
||||
asset.id === assetId ? { ...asset, ...updatedAsset } : asset
|
||||
)
|
||||
);
|
||||
|
||||
return updatedAsset;
|
||||
} catch (err) {
|
||||
console.error('Error updating asset:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
return {
|
||||
assets,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
refetch: fetchAssets,
|
||||
toggleFavorite,
|
||||
deleteAsset,
|
||||
updateAsset,
|
||||
trackUsage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -156,6 +156,81 @@ export interface UpscaleResult {
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ControlOperationMeta {
|
||||
label: string;
|
||||
description: string;
|
||||
provider: string;
|
||||
fields?: {
|
||||
control_image?: boolean;
|
||||
style_image?: boolean;
|
||||
control_strength?: boolean;
|
||||
fidelity?: boolean;
|
||||
style_strength?: boolean;
|
||||
aspect_ratio?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ControlImageRequestPayload {
|
||||
control_image_base64: string;
|
||||
operation: 'sketch' | 'structure' | 'style' | 'style_transfer';
|
||||
prompt: string;
|
||||
style_image_base64?: string;
|
||||
negative_prompt?: string;
|
||||
control_strength?: number;
|
||||
fidelity?: number;
|
||||
style_strength?: number;
|
||||
composition_fidelity?: number;
|
||||
change_strength?: number;
|
||||
aspect_ratio?: string;
|
||||
style_preset?: string;
|
||||
seed?: number;
|
||||
output_format?: string;
|
||||
}
|
||||
|
||||
export interface ControlResult {
|
||||
success: boolean;
|
||||
operation: string;
|
||||
provider: string;
|
||||
image_base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SocialOptimizeResult {
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
platform: string;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: string;
|
||||
image_base64: string;
|
||||
safe_zone: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
}>;
|
||||
total_optimized: number;
|
||||
}
|
||||
|
||||
export interface PlatformFormat {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: string;
|
||||
safe_zone: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
file_type: string;
|
||||
max_size_mb: number;
|
||||
}
|
||||
|
||||
export const useImageStudio = () => {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [providers, setProviders] = useState<Record<string, Provider> | null>(null);
|
||||
@@ -172,6 +247,14 @@ export const useImageStudio = () => {
|
||||
const [upscaleResult, setUpscaleResult] = useState<UpscaleResult | null>(null);
|
||||
const [isUpscaling, setIsUpscaling] = useState(false);
|
||||
const [upscaleError, setUpscaleError] = useState<string | null>(null);
|
||||
const [controlOperations, setControlOperations] = useState<Record<string, ControlOperationMeta>>({});
|
||||
const [isLoadingControlOps, setIsLoadingControlOps] = useState(false);
|
||||
const [isProcessingControl, setIsProcessingControl] = useState(false);
|
||||
const [controlResult, setControlResult] = useState<ControlResult | null>(null);
|
||||
const [controlError, setControlError] = useState<string | null>(null);
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [optimizeResult, setOptimizeResult] = useState<SocialOptimizeResult | null>(null);
|
||||
const [optimizeError, setOptimizeError] = useState<string | null>(null);
|
||||
|
||||
// Load templates
|
||||
const loadTemplates = useCallback(async (platform?: string, category?: string) => {
|
||||
@@ -351,6 +434,83 @@ export const useImageStudio = () => {
|
||||
setUpscaleError(null);
|
||||
}, []);
|
||||
|
||||
// Load control operations
|
||||
const loadControlOperations = useCallback(async () => {
|
||||
setIsLoadingControlOps(true);
|
||||
try {
|
||||
const response = await aiApiClient.get('/api/image-studio/control/operations');
|
||||
setControlOperations(response.data.operations || {});
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load control operations:', err);
|
||||
} finally {
|
||||
setIsLoadingControlOps(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Process control
|
||||
const processControl = useCallback(async (payload: ControlImageRequestPayload) => {
|
||||
setIsProcessingControl(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const response = await aiApiClient.post('/api/image-studio/control/process', payload);
|
||||
setControlResult(response.data);
|
||||
return response.data as ControlResult;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to process control:', err);
|
||||
const message = err.response?.data?.detail || 'Failed to process control';
|
||||
setControlError(message);
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsProcessingControl(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearControlResult = useCallback(() => {
|
||||
setControlResult(null);
|
||||
setControlError(null);
|
||||
}, []);
|
||||
|
||||
// Social Optimizer
|
||||
const optimizeForSocial = useCallback(async (payload: {
|
||||
image_base64: string;
|
||||
platforms: string[];
|
||||
format_names?: Record<string, string>;
|
||||
show_safe_zones?: boolean;
|
||||
crop_mode?: string;
|
||||
focal_point?: { x: number; y: number };
|
||||
output_format?: string;
|
||||
}) => {
|
||||
setIsOptimizing(true);
|
||||
setOptimizeError(null);
|
||||
try {
|
||||
const response = await aiApiClient.post('/api/image-studio/social/optimize', payload);
|
||||
setOptimizeResult(response.data);
|
||||
return response.data as SocialOptimizeResult;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to optimize for social:', err);
|
||||
const message = err.response?.data?.detail || 'Failed to optimize for social platforms';
|
||||
setOptimizeError(message);
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPlatformFormats = useCallback(async (platform: string): Promise<PlatformFormat[]> => {
|
||||
try {
|
||||
const response = await aiApiClient.get(`/api/image-studio/social/platforms/${platform}/formats`);
|
||||
return response.data.formats || [];
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to load formats for ${platform}:`, err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearOptimizeResult = useCallback(() => {
|
||||
setOptimizeResult(null);
|
||||
setOptimizeError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
templates,
|
||||
@@ -368,6 +528,11 @@ export const useImageStudio = () => {
|
||||
upscaleResult,
|
||||
isUpscaling,
|
||||
upscaleError,
|
||||
controlOperations,
|
||||
isLoadingControlOps,
|
||||
isProcessingControl,
|
||||
controlResult,
|
||||
controlError,
|
||||
|
||||
// Actions
|
||||
loadTemplates,
|
||||
@@ -383,6 +548,15 @@ export const useImageStudio = () => {
|
||||
clearEditResult,
|
||||
processUpscale,
|
||||
clearUpscaleResult,
|
||||
loadControlOperations,
|
||||
processControl,
|
||||
clearControlResult,
|
||||
optimizeForSocial,
|
||||
getPlatformFormats,
|
||||
isOptimizing,
|
||||
optimizeResult,
|
||||
optimizeError,
|
||||
clearOptimizeResult,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user