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:
ajaysi
2025-11-23 09:21:11 +05:30
parent eede21ad42
commit 77d7c0cde6
38 changed files with 5939 additions and 37 deletions

View File

@@ -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 />} />

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View File

@@ -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: {

View File

@@ -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',

View File

@@ -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}

View File

@@ -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}

View File

@@ -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

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -0,0 +1,3 @@
export { OptimizedImage } from './OptimizedImage';
export { OptimizedVideo } from './OptimizedVideo';

View File

@@ -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';

View 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,
};
};

View File

@@ -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,
};
};