Added video studio router and endpoints. Added research router and endpoints. Added youtube router and endpoints. Added onboarding utils router and endpoints. Added onboarding utils service. Added onboarding utils models. Added onboarding utils routes. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils. Added onboarding utils utils.
This commit is contained in:
86
frontend/src/components/VideoStudio/ModulePlaceholder.tsx
Normal file
86
frontend/src/components/VideoStudio/ModulePlaceholder.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { Box, Paper, Stack, Typography, Chip } from '@mui/material';
|
||||
import { VideoStudioLayout } from './VideoStudioLayout';
|
||||
|
||||
interface ModulePlaceholderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status?: 'live' | 'beta' | 'coming soon';
|
||||
description?: string;
|
||||
bullets?: string[];
|
||||
}
|
||||
|
||||
const statusColor: Record<string, { bg: string; color: string }> = {
|
||||
live: { bg: 'rgba(16,185,129,0.18)', color: '#10b981' },
|
||||
beta: { bg: 'rgba(59,130,246,0.18)', color: '#3b82f6' },
|
||||
'coming soon': { bg: 'rgba(249,115,22,0.18)', color: '#f97316' },
|
||||
};
|
||||
|
||||
export const ModulePlaceholder: React.FC<ModulePlaceholderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
status = 'coming soon',
|
||||
description,
|
||||
bullets = [],
|
||||
}) => {
|
||||
const style = statusColor[status] || statusColor['coming soon'];
|
||||
|
||||
return (
|
||||
<VideoStudioLayout headerProps={{ title, subtitle }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 1100,
|
||||
mx: 'auto',
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.78)',
|
||||
p: { xs: 3, md: 4 },
|
||||
backdropFilter: 'blur(18px)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Chip
|
||||
label={status.toUpperCase()}
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: style.bg,
|
||||
color: style.color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
{description && (
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{bullets.length > 0 && (
|
||||
<Stack spacing={1}>
|
||||
{bullets.map(item => (
|
||||
<Box
|
||||
key={item}
|
||||
sx={{
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 2,
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{item}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
We’ll surface cost estimates, provider choices, and templates here as the module goes live.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModulePlaceholder;
|
||||
45
frontend/src/components/VideoStudio/VideoStudioDashboard.tsx
Normal file
45
frontend/src/components/VideoStudio/VideoStudioDashboard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Grid, Paper, Stack, Typography, Divider } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { VideoStudioLayout } from './VideoStudioLayout';
|
||||
import { videoStudioModules } from './dashboard/modules';
|
||||
import { ModuleCard } from './dashboard/ModuleCard';
|
||||
|
||||
export const VideoStudioDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [hovered, setHovered] = React.useState<string>('');
|
||||
|
||||
return (
|
||||
<VideoStudioLayout>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.78)',
|
||||
p: { xs: 3, md: 5 },
|
||||
backdropFilter: 'blur(25px)',
|
||||
}}
|
||||
>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{videoStudioModules.map(module => (
|
||||
<Grid item xs={12} md={6} key={module.key}>
|
||||
<ModuleCard
|
||||
module={module}
|
||||
isHovered={hovered === module.key}
|
||||
onMouseEnter={() => setHovered(module.key)}
|
||||
onMouseLeave={() => setHovered('')}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoStudioDashboard;
|
||||
96
frontend/src/components/VideoStudio/VideoStudioLayout.tsx
Normal file
96
frontend/src/components/VideoStudio/VideoStudioLayout.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import DashboardHeader from '../shared/DashboardHeader';
|
||||
import type { DashboardHeaderProps } from '../shared/types';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
const sparkleVariants: Variants = {
|
||||
initial: { scale: 0, rotate: 0 },
|
||||
animate: {
|
||||
scale: [0, 1, 0],
|
||||
rotate: [0, 180, 360],
|
||||
transition: { duration: 2, repeat: Infinity, ease: 'easeInOut' },
|
||||
},
|
||||
};
|
||||
|
||||
interface VideoStudioLayoutProps {
|
||||
children: React.ReactNode;
|
||||
showHeader?: boolean;
|
||||
headerProps?: DashboardHeaderProps;
|
||||
}
|
||||
|
||||
const defaultHeaderProps: DashboardHeaderProps = {
|
||||
title: 'AI Video Studio',
|
||||
subtitle:
|
||||
'Provider-agnostic, cost-transparent video creation. Generate, enhance, and optimize videos with guided presets.',
|
||||
};
|
||||
|
||||
export const VideoStudioLayout: React.FC<VideoStudioLayoutProps> = ({
|
||||
children,
|
||||
showHeader = true,
|
||||
headerProps,
|
||||
}) => {
|
||||
const mergedHeaderProps = { ...defaultHeaderProps, ...headerProps };
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 40%, #312e81 100%)',
|
||||
py: 4,
|
||||
px: 2,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<MotionBox
|
||||
key={i}
|
||||
variants={sparkleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
transition={{ delay: i * 0.08 }}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255, 255, 255, 0.6)',
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{showHeader && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<DashboardHeader {...mergedHeaderProps} />
|
||||
</Box>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoStudioLayout;
|
||||
202
frontend/src/components/VideoStudio/dashboard/ModuleCard.tsx
Normal file
202
frontend/src/components/VideoStudio/dashboard/ModuleCard.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Tooltip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import SavingsIcon from '@mui/icons-material/Savings';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import type { ModuleConfig } from './types';
|
||||
import { statusStyles } from './modules';
|
||||
import { CreateVideoPreview, AvatarVideoPreview, EnhanceVideoPreview } from './previews';
|
||||
|
||||
interface ModuleCardProps {
|
||||
module: ModuleConfig;
|
||||
isHovered: boolean;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onNavigate: (route: string) => void;
|
||||
}
|
||||
|
||||
export const ModuleCard: React.FC<ModuleCardProps> = ({
|
||||
module,
|
||||
isHovered,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onNavigate,
|
||||
}) => {
|
||||
const status = statusStyles[module.status];
|
||||
const disabled = module.status !== 'live';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
p: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'linear-gradient(160deg, rgba(15,23,42,0.95), rgba(30,41,59,0.92))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.75,
|
||||
position: 'relative',
|
||||
transition: 'transform 0.28s ease, box-shadow 0.28s ease, border-color 0.28s ease',
|
||||
boxShadow: isHovered
|
||||
? '0 24px 50px rgba(79,70,229,0.32)'
|
||||
: '0 12px 28px rgba(15,23,42,0.35)',
|
||||
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: alpha('#6366f1', 0.2),
|
||||
color: '#c7d2fe',
|
||||
fontSize: 22,
|
||||
}}
|
||||
>
|
||||
{module.icon}
|
||||
</Box>
|
||||
<Stack spacing={0.25}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
{module.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{module.subtitle}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Chip
|
||||
label={status.label}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: alpha(status.color, 0.2),
|
||||
color: status.color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'rgba(241,245,249,0.95)' }}>
|
||||
{module.description}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{module.highlights.map(item => (
|
||||
<Chip
|
||||
key={item}
|
||||
size="small"
|
||||
label={item}
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg, rgba(99,102,241,0.45), rgba(14,165,233,0.38))',
|
||||
color: '#f8fafc',
|
||||
border: '1px solid rgba(255,255,255,0.35)',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Tooltip title={module.help || 'Guidance and intended use cases'}>
|
||||
<HelpOutlineIcon sx={{ fontSize: 18, color: 'rgba(148,163,184,0.95)' }} />
|
||||
</Tooltip>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{module.help || 'Built for creators: pick a template and we guide duration/aspect and cost.'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<InfoOutlinedIcon sx={{ fontSize: 18, color: 'rgba(148,163,184,0.9)' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{module.pricingNote || 'Cost shown before run (duration, resolution, provider).'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{module.costDrivers && (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{module.costDrivers.map(driver => (
|
||||
<Chip
|
||||
key={driver}
|
||||
size="small"
|
||||
icon={<SavingsIcon sx={{ fontSize: 16 }} />}
|
||||
label={driver}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(15,118,110,0.25)',
|
||||
color: '#99f6e4',
|
||||
border: '1px solid rgba(34,197,94,0.35)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
ETA: {module.eta || 'TBD'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Visual Preview Component */}
|
||||
{module.status === 'live' && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{module.key === 'create' && <CreateVideoPreview />}
|
||||
{module.key === 'avatar' && <AvatarVideoPreview />}
|
||||
{module.key === 'enhance' && <EnhanceVideoPreview />}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 'auto' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={disabled ? <LockIcon /> : <LaunchIcon />}
|
||||
disabled={disabled}
|
||||
onClick={() => onNavigate(module.route)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
boxShadow: 'none',
|
||||
background: disabled ? 'rgba(148,163,184,0.25)' : 'linear-gradient(120deg,#6366f1,#8b5cf6)',
|
||||
}}
|
||||
>
|
||||
{disabled ? 'Preview' : 'Open'}
|
||||
</Button>
|
||||
<Tooltip title="Feature details & roadmap">
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
color="inherit"
|
||||
onClick={() => onNavigate(module.route)}
|
||||
sx={{ textTransform: 'none', color: '#c7d2fe' }}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
50
frontend/src/components/VideoStudio/dashboard/constants.ts
Normal file
50
frontend/src/components/VideoStudio/dashboard/constants.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const createVideoExamples = [
|
||||
{
|
||||
id: 'instagram-reel',
|
||||
label: 'Instagram Reel',
|
||||
prompt: 'A modern coffee shop interior with baristas crafting latte art, warm golden hour lighting streaming through large windows, customers chatting at wooden tables, cozy atmosphere, 9:16 vertical format',
|
||||
description: 'Perfect for Instagram Reels and TikTok. Shows how text descriptions become engaging short-form video content.',
|
||||
price: '$0.50',
|
||||
eta: '~15s',
|
||||
provider: 'Auto-select',
|
||||
video: '/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4',
|
||||
platform: 'Instagram',
|
||||
useCase: 'Social media content',
|
||||
},
|
||||
{
|
||||
id: 'linkedin-post',
|
||||
label: 'LinkedIn Post',
|
||||
prompt: 'Professional workspace with laptop, notebook, and coffee cup on a minimalist desk, soft natural lighting, clean modern office environment, 16:9 format',
|
||||
description: 'Ideal for LinkedIn posts and professional content. Demonstrates how simple descriptions create polished business videos.',
|
||||
price: '$0.75',
|
||||
eta: '~18s',
|
||||
provider: 'Auto-select',
|
||||
video: '/videos/text-video-voiceover.mp4',
|
||||
platform: 'LinkedIn',
|
||||
useCase: 'Professional content',
|
||||
},
|
||||
{
|
||||
id: 'youtube-short',
|
||||
label: 'YouTube Short',
|
||||
prompt: 'Dynamic product showcase with rotating view, vibrant colors, smooth camera movement, energetic music vibe, 9:16 vertical format',
|
||||
description: 'Great for YouTube Shorts and product demos. Shows how product descriptions transform into engaging video content.',
|
||||
price: '$0.60',
|
||||
eta: '~16s',
|
||||
provider: 'Auto-select',
|
||||
video: '/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4',
|
||||
platform: 'YouTube',
|
||||
useCase: 'Product marketing',
|
||||
},
|
||||
];
|
||||
|
||||
export const enhanceVideoExamples = {
|
||||
before: '/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4',
|
||||
after: '/videos/text-video-voiceover.mp4',
|
||||
description: 'Upscale 480p to 1080p, boost frame rate from 24fps to 60fps, and enhance clarity for professional use.',
|
||||
};
|
||||
|
||||
export const avatarExamples = {
|
||||
image: '/images/scene_1_Welcome_to_the_Cloud_Kitchen___ae6436d9.png',
|
||||
video: '/videos/text-video-voiceover.mp4',
|
||||
description: 'Upload a photo and audio to create a talking avatar perfect for explainer videos, tutorials, and personalized messages.',
|
||||
};
|
||||
203
frontend/src/components/VideoStudio/dashboard/modules.tsx
Normal file
203
frontend/src/components/VideoStudio/dashboard/modules.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React from 'react';
|
||||
import MovieCreationIcon from '@mui/icons-material/MovieCreation';
|
||||
import FaceRetouchingNaturalIcon from '@mui/icons-material/FaceRetouchingNatural';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import HighQualityIcon from '@mui/icons-material/HighQuality';
|
||||
import TimelineIcon from '@mui/icons-material/Timeline';
|
||||
import TransformIcon from '@mui/icons-material/Transform';
|
||||
import ShareIcon from '@mui/icons-material/Share';
|
||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||
import TranslateIcon from '@mui/icons-material/Translate';
|
||||
import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
||||
import MusicNoteIcon from '@mui/icons-material/MusicNote';
|
||||
import type { ModuleConfig } from './types';
|
||||
|
||||
export const statusStyles = {
|
||||
live: { label: 'Live', color: '#10b981' },
|
||||
beta: { label: 'Beta', color: '#3b82f6' },
|
||||
'coming soon': { label: 'Coming Soon', color: '#f97316' },
|
||||
};
|
||||
|
||||
export const videoStudioModules: ModuleConfig[] = [
|
||||
{
|
||||
key: 'create',
|
||||
title: 'Create Studio',
|
||||
subtitle: 'Turn your ideas into videos',
|
||||
description:
|
||||
'Describe your video idea and we create it for you. Perfect for Instagram Reels, TikTok, YouTube Shorts, LinkedIn posts, and more. We automatically choose the best settings for your platform.',
|
||||
highlights: ['Text to Video', 'Image to Video', 'Platform Ready'],
|
||||
status: 'live',
|
||||
route: '/video-studio/create',
|
||||
pricingNote: 'Cost depends on video length and quality. We show you the price before generating.',
|
||||
eta: 'Now',
|
||||
icon: <MovieCreationIcon />,
|
||||
help: 'Perfect for creating engaging social media content. Just describe what you want and we handle the rest. Add background music or voiceover later.',
|
||||
costDrivers: ['Video length (5–10 seconds)', 'Quality (480p/720p/1080p)', 'Platform format'],
|
||||
},
|
||||
{
|
||||
key: 'avatar',
|
||||
title: 'Avatar Studio',
|
||||
subtitle: 'Create talking videos from photos',
|
||||
description:
|
||||
'Upload a photo and audio to create a talking avatar. Perfect for explainer videos, tutorials, personalized messages, and social media content. Your photo comes to life with perfect lip-sync.',
|
||||
highlights: ['Talking Avatars', 'Lip-sync', 'Translation'],
|
||||
status: 'beta',
|
||||
route: '/video-studio/avatar',
|
||||
pricingNote: 'Cost depends on video length and quality',
|
||||
eta: 'Beta',
|
||||
icon: <FaceRetouchingNaturalIcon />,
|
||||
help: 'Great for creating personalized video messages, explainer videos, and tutorials. Upload your photo and audio, and we create a talking video.',
|
||||
costDrivers: ['Video length', 'Quality'],
|
||||
},
|
||||
{
|
||||
key: 'enhance',
|
||||
title: 'Enhance Studio',
|
||||
subtitle: 'Upgrade your video quality',
|
||||
description:
|
||||
'Transform low-resolution videos into professional-quality content. Upscale from 480p to 1080p or 4K, boost frame rate, and improve clarity. Perfect for upgrading social media content or preparing videos for YouTube.',
|
||||
highlights: ['Upscale Quality', 'Smooth Motion', 'Frame Rate Boost'],
|
||||
status: 'live',
|
||||
route: '/video-studio/enhance',
|
||||
pricingNote: 'Cost depends on original quality and target quality',
|
||||
eta: 'Now',
|
||||
icon: <HighQualityIcon />,
|
||||
help: 'Perfect for improving videos shot on phones or upgrading old content. Make your videos look professional and ready for any platform.',
|
||||
costDrivers: ['Original quality', 'Target quality', 'Video length'],
|
||||
},
|
||||
{
|
||||
key: 'extend',
|
||||
title: 'Extend Studio',
|
||||
subtitle: 'Extend short clips seamlessly',
|
||||
description:
|
||||
'Turn short video clips into longer videos with seamless motion and audio continuity. Perfect for extending social media content, creating longer scenes from existing footage, and adding smooth transitions.',
|
||||
highlights: ['Motion Continuity', 'Audio Sync', 'Seamless Extension'],
|
||||
status: 'live',
|
||||
route: '/video-studio/extend',
|
||||
pricingNote: 'Cost depends on extension duration and resolution',
|
||||
eta: 'Now',
|
||||
icon: <TimelineIcon />,
|
||||
help: 'Great for extending short clips into longer videos. Describe how you want the video to continue, and we create a seamless extension with preserved motion and style.',
|
||||
costDrivers: ['Extension duration', 'Resolution', 'Video length'],
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
title: 'Edit Studio',
|
||||
subtitle: 'Trim, enhance, and customize',
|
||||
description:
|
||||
'Trim and cut videos, adjust speed, stabilize shaky footage, replace backgrounds, swap faces, add captions and subtitles, and color grade. All the editing tools you need in one place.',
|
||||
highlights: ['Trim & Cut', 'Background Swap', 'Add Captions'],
|
||||
status: 'coming soon',
|
||||
route: '/video-studio/edit',
|
||||
pricingNote: 'Cost depends on video length and number of edits',
|
||||
eta: 'Coming soon',
|
||||
icon: <EditIcon />,
|
||||
help: 'Complete video editing suite for content creators. Make your videos perfect before sharing on social media.',
|
||||
costDrivers: ['Video length', 'Number of edits'],
|
||||
},
|
||||
{
|
||||
key: 'transform',
|
||||
title: 'Transform Studio',
|
||||
subtitle: 'Change format and style',
|
||||
description:
|
||||
'Convert videos between different formats (MP4, MOV, WebM, GIF), change aspect ratios (16:9, 9:16, 1:1), adjust speed, scale resolution, and compress files. All transformations use fast FFmpeg processing.',
|
||||
highlights: ['Format Conversion', 'Aspect Ratio', 'Speed Control', 'Resolution Scaling', 'Compression'],
|
||||
status: 'live',
|
||||
route: '/video-studio/transform',
|
||||
pricingNote: 'Free (FFmpeg processing)',
|
||||
eta: 'Now',
|
||||
icon: <TransformIcon />,
|
||||
help: 'Perfect for adapting one video for multiple platforms. Convert formats, change aspect ratios, adjust speed, scale resolution, and compress files - all for free using FFmpeg.',
|
||||
costDrivers: ['Free processing'],
|
||||
},
|
||||
{
|
||||
key: 'social',
|
||||
title: 'Social Optimizer',
|
||||
subtitle: 'One-click platform optimization',
|
||||
description:
|
||||
'Create optimized versions of your video for Instagram, TikTok, YouTube, LinkedIn, and Twitter with one click. Includes safe zones, compression, and thumbnails. Make your content platform-ready instantly.',
|
||||
highlights: ['Multi-Platform', 'Safe Zones', 'Auto Thumbnails'],
|
||||
status: 'live',
|
||||
route: '/video-studio/social',
|
||||
pricingNote: 'Free (FFmpeg processing)',
|
||||
eta: 'Now',
|
||||
icon: <ShareIcon />,
|
||||
help: 'Save time by creating platform-optimized versions automatically. One video, multiple platforms, perfect formatting for each.',
|
||||
costDrivers: ['Free processing'],
|
||||
},
|
||||
{
|
||||
key: 'faceswap',
|
||||
title: 'Face Swap Studio',
|
||||
subtitle: 'Replace characters in videos',
|
||||
description:
|
||||
'Swap faces or characters in videos using MoCha AI. Upload a reference image and source video to seamlessly replace characters while preserving motion, emotion, and camera perspective.',
|
||||
highlights: ['Character Replacement', 'Motion Preservation', 'Identity Consistency'],
|
||||
status: 'live',
|
||||
route: '/video-studio/face-swap',
|
||||
pricingNote: '$0.04/s (480p) or $0.08/s (720p), min 5s charge',
|
||||
eta: 'Now',
|
||||
icon: <SwapHorizIcon />,
|
||||
help: 'Perfect for film, advertising, digital avatars, and creative character transformation. No pose or depth maps needed.',
|
||||
costDrivers: ['Video duration', 'Resolution (480p/720p)'],
|
||||
},
|
||||
{
|
||||
key: 'video-translate',
|
||||
title: 'Video Translate Studio',
|
||||
subtitle: 'Translate videos to 70+ languages',
|
||||
description:
|
||||
'Translate videos to 70+ languages and 175+ dialects with AI. Preserves lip-sync and natural voice. Perfect for global content, localization, and reaching international audiences.',
|
||||
highlights: ['70+ Languages', 'Lip-sync Preservation', 'Natural Voice'],
|
||||
status: 'live',
|
||||
route: '/video-studio/video-translate',
|
||||
pricingNote: '$0.0375/second',
|
||||
eta: 'Now',
|
||||
icon: <TranslateIcon />,
|
||||
help: 'Perfect for global content creators, localization, and reaching international audiences. No voice actors or dubbing needed.',
|
||||
costDrivers: ['Video duration'],
|
||||
},
|
||||
{
|
||||
key: 'video-background-remover',
|
||||
title: 'Background Remover Studio',
|
||||
subtitle: 'Remove or replace video backgrounds',
|
||||
description:
|
||||
'Remove or replace video backgrounds with clean matting and edge-aware blending. Upload a background image to replace, or leave empty for transparent background. Perfect for product videos, presentations, and creative content.',
|
||||
highlights: ['Clean Matting', 'Edge-Aware Blending', 'Background Replacement'],
|
||||
status: 'live',
|
||||
route: '/video-studio/video-background-remover',
|
||||
pricingNote: '$0.01/second (min $0.05, max $6.00)',
|
||||
eta: 'Now',
|
||||
icon: <WallpaperIcon />,
|
||||
help: 'Perfect for product videos, presentations, and creative content. Remove backgrounds or replace them with custom images.',
|
||||
costDrivers: ['Video duration'],
|
||||
},
|
||||
{
|
||||
key: 'add-audio-to-video',
|
||||
title: 'Add Audio to Video Studio',
|
||||
subtitle: 'Generate realistic Foley and ambient audio',
|
||||
description:
|
||||
'Generate realistic Foley and ambient audio directly from video using AI. Choose between Hunyuan Video Foley (48 kHz hi-fi, multi-scene sync) or Think Sound (context-aware, flat rate pricing). Perfect for post-production, social content, and prototyping.',
|
||||
highlights: ['2 AI Models', '48 kHz Hi-Fi', 'Context-Aware'],
|
||||
status: 'live',
|
||||
route: '/video-studio/add-audio-to-video',
|
||||
pricingNote: '$0.02/s (Hunyuan) or $0.05/video (Think Sound)',
|
||||
eta: 'Now',
|
||||
icon: <MusicNoteIcon />,
|
||||
help: 'Perfect for post-production, social content, and prototyping. Use optional text prompts to guide specific sounds or let AI automatically generate appropriate audio based on visual cues.',
|
||||
costDrivers: ['Video duration'],
|
||||
},
|
||||
{
|
||||
key: 'library',
|
||||
title: 'Asset Library',
|
||||
subtitle: 'Organize and manage your videos',
|
||||
description:
|
||||
'Keep all your videos organized with AI-powered tagging, version tracking, usage analytics, and secure sharing. Manage your video content library like a pro.',
|
||||
highlights: ['AI Tagging', 'Version Control', 'Usage Analytics'],
|
||||
status: 'beta',
|
||||
route: '/video-studio/library',
|
||||
pricingNote: 'Storage and download costs',
|
||||
eta: 'Beta',
|
||||
icon: <LibraryBooksIcon />,
|
||||
help: 'Perfect for content creators managing multiple videos. Keep everything organized, track usage, and share securely.',
|
||||
costDrivers: ['Storage space', 'Downloads'],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { avatarExamples } from '../constants';
|
||||
import { OptimizedImage } from '../../../ImageStudio/dashboard/utils/OptimizedImage';
|
||||
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
|
||||
|
||||
export const AvatarVideoPreview: React.FC = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.5)',
|
||||
p: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'linear-gradient(135deg,#0ea5e9,#6366f1)',
|
||||
color: '#e0f2fe',
|
||||
p: 2,
|
||||
minHeight: 260,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#cffafe' }}>
|
||||
Step 1: Upload Photo + Audio
|
||||
</Typography>
|
||||
<Typography variant="body2">{avatarExamples.description}</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{['Photo upload', 'Audio upload', 'Lip-sync'].map(label => (
|
||||
<Chip
|
||||
key={label}
|
||||
size="small"
|
||||
label={label}
|
||||
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={avatarExamples.image}
|
||||
alt="Avatar photo example"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 600px) 100vw, 50vw"
|
||||
sx={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1.5,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: '#020617',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#38bdf8' }}>
|
||||
Result: Talking Avatar
|
||||
</Typography>
|
||||
<Chip
|
||||
label="720p"
|
||||
size="small"
|
||||
sx={{ background: 'rgba(56,189,248,0.15)', color: '#38bdf8', borderRadius: 999 }}
|
||||
/>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src={avatarExamples.video}
|
||||
poster={avatarExamples.image}
|
||||
alt="Avatar video preview"
|
||||
controls
|
||||
preload="metadata"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
sx={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
|
||||
Perfect for explainer videos, tutorials, personalized messages, and social media content. Your photo comes to life with perfect lip-sync.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { createVideoExamples } from '../constants';
|
||||
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
|
||||
|
||||
export const CreateVideoPreview: React.FC = () => {
|
||||
const [textHovered, setTextHovered] = React.useState(false);
|
||||
const [exampleIndex, setExampleIndex] = React.useState(0);
|
||||
const example = createVideoExamples[exampleIndex];
|
||||
const videoWidth = textHovered ? '20%' : '70%';
|
||||
const textWidth = textHovered ? '80%' : '30%';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '3px solid',
|
||||
borderImage:
|
||||
'linear-gradient(135deg, rgba(124,58,237,0.8), rgba(14,165,233,0.8), rgba(16,185,129,0.8)) 1',
|
||||
overflow: 'hidden',
|
||||
height: { xs: 260, md: 300 },
|
||||
display: 'flex',
|
||||
background: '#0f172a',
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: videoWidth,
|
||||
transition: 'width 0.4s ease, filter 0.4s ease',
|
||||
filter: textHovered ? 'saturate(1.1)' : 'saturate(1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src={example.video}
|
||||
alt={example.label}
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
borderRadius: 999,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
boxShadow: '0 10px 20px rgba(2,6,23,0.45)',
|
||||
}}
|
||||
>
|
||||
{createVideoExamples.map((_, idx) => (
|
||||
<Box
|
||||
key={_.id}
|
||||
onClick={() => setExampleIndex(idx)}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: idx === exampleIndex ? '#c4b5fd' : 'rgba(255,255,255,0.3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: textWidth,
|
||||
background: 'rgba(248,250,252,0.95)',
|
||||
color: '#0f172a',
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
boxShadow: '-12px 0 24px rgba(15,23,42,0.25)',
|
||||
transition: 'width 0.4s ease',
|
||||
}}
|
||||
onMouseEnter={() => setTextHovered(true)}
|
||||
onMouseLeave={() => setTextHovered(false)}
|
||||
>
|
||||
<Stack spacing={0.5} sx={{ overflowY: textHovered ? 'auto' : 'hidden', pr: 1 }}>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 1.5, color: '#818cf8' }}>
|
||||
Step 1: Enter Your Video Requirements
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Example Prompt
|
||||
</Typography>
|
||||
<Typography variant="body2">{example.prompt}</Typography>
|
||||
<Typography variant="body2" sx={{ fontStyle: 'italic', color: '#475569' }}>
|
||||
{example.description}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Price ${example.price}`}
|
||||
sx={{ background: '#ede9fe', color: '#4c1d95', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Ready in ${example.eta}`}
|
||||
sx={{ background: '#cffafe', color: '#0f766e', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={example.platform}
|
||||
sx={{ background: '#dcfce7', color: '#166534', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5 }}>
|
||||
Best for: {example.useCase}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { enhanceVideoExamples } from '../constants';
|
||||
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
|
||||
|
||||
export const EnhanceVideoPreview: React.FC = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.5)',
|
||||
p: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'linear-gradient(135deg,#ef4444,#f97316)',
|
||||
color: '#fee2e2',
|
||||
p: 2,
|
||||
minHeight: 260,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#fecaca' }}>
|
||||
Before: 480p @ 24fps
|
||||
</Typography>
|
||||
<Typography variant="body2">{enhanceVideoExamples.description}</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{['480p', '24fps', 'Standard'].map(label => (
|
||||
<Chip
|
||||
key={label}
|
||||
size="small"
|
||||
label={label}
|
||||
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src={enhanceVideoExamples.before}
|
||||
alt="Before enhancement"
|
||||
controls
|
||||
preload="metadata"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
sx={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'linear-gradient(135deg,#10b981,#059669)',
|
||||
color: '#d1fae5',
|
||||
p: 2,
|
||||
minHeight: 260,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#a7f3d0' }}>
|
||||
After: 1080p @ 60fps
|
||||
</Typography>
|
||||
<Typography variant="body2">Enhanced quality ready for professional use</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{['1080p', '60fps', 'Enhanced'].map(label => (
|
||||
<Chip
|
||||
key={label}
|
||||
size="small"
|
||||
label={label}
|
||||
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src={enhanceVideoExamples.after}
|
||||
alt="After enhancement"
|
||||
controls
|
||||
preload="metadata"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
sx={{ width: '100%', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
|
||||
Transform low-resolution videos into professional-quality content. Perfect for upgrading social media content or preparing videos for YouTube and other platforms.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CreateVideoPreview } from './CreateVideoPreview';
|
||||
export { AvatarVideoPreview } from './AvatarVideoPreview';
|
||||
export { EnhanceVideoPreview } from './EnhanceVideoPreview';
|
||||
16
frontend/src/components/VideoStudio/dashboard/types.ts
Normal file
16
frontend/src/components/VideoStudio/dashboard/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type ModuleStatus = 'live' | 'beta' | 'coming soon';
|
||||
|
||||
export interface ModuleConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
highlights: string[];
|
||||
status: ModuleStatus;
|
||||
route: string;
|
||||
pricingNote?: string;
|
||||
eta?: string;
|
||||
icon?: React.ReactNode;
|
||||
help?: string;
|
||||
costDrivers?: string[];
|
||||
}
|
||||
14
frontend/src/components/VideoStudio/index.ts
Normal file
14
frontend/src/components/VideoStudio/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { VideoStudioLayout } from './VideoStudioLayout';
|
||||
export { VideoStudioDashboard } from './VideoStudioDashboard';
|
||||
export { CreateVideo } from './modules/CreateVideo';
|
||||
export { AvatarVideo } from './modules/AvatarVideo';
|
||||
export { EnhanceVideo } from './modules/EnhanceVideo';
|
||||
export { ExtendVideo } from './modules/ExtendVideo';
|
||||
export { EditVideo } from './modules/EditVideo';
|
||||
export { TransformVideo } from './modules/TransformVideo/TransformVideo';
|
||||
export { SocialVideo } from './modules/SocialVideo/SocialVideo';
|
||||
export { FaceSwap } from './modules/FaceSwap';
|
||||
export { VideoTranslate } from './modules/VideoTranslate';
|
||||
export { VideoBackgroundRemover } from './modules/VideoBackgroundRemover';
|
||||
export { AddAudioToVideo } from './modules/AddAudioToVideo';
|
||||
export { LibraryVideo } from './modules/LibraryVideo';
|
||||
@@ -0,0 +1,315 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useAddAudioToVideo } from './hooks/useAddAudioToVideo';
|
||||
import { VideoUpload, AudioSettings } from './components';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import MusicNoteIcon from '@mui/icons-material/MusicNote';
|
||||
|
||||
const AddAudioToVideo: React.FC = () => {
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
model,
|
||||
prompt,
|
||||
seed,
|
||||
processing,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
setVideoFile,
|
||||
setModel,
|
||||
setPrompt,
|
||||
setSeed,
|
||||
canAddAudio,
|
||||
costHint,
|
||||
addAudio,
|
||||
reset,
|
||||
} = useAddAudioToVideo();
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Add Audio to Video Studio',
|
||||
subtitle: 'Generate realistic Foley and ambient audio directly from video using Tencent Hunyuan\'s video-to-audio model. Aligns on-screen actions and scene context to produce timing-accurate, high-quality audio tracks with 48 kHz hi-fi output.',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={3}>
|
||||
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
|
||||
|
||||
<AudioSettings
|
||||
model={model}
|
||||
prompt={prompt}
|
||||
seed={seed}
|
||||
costHint={costHint}
|
||||
onModelChange={setModel}
|
||||
onPromptChange={setPrompt}
|
||||
onSeedChange={setSeed}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={processing ? <CircularProgress size={20} color="inherit" /> : <MusicNoteIcon />}
|
||||
onClick={addAudio}
|
||||
disabled={!canAddAudio || processing}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{processing ? 'Processing...' : 'Add Audio to Video'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{processing && (
|
||||
<Box>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Generating audio... This may take a few minutes...
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => {}} icon={<ErrorIcon />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Alert
|
||||
severity="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
action={
|
||||
<Button size="small" onClick={reset}>
|
||||
Process Another
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Audio added successfully! Cost: ${result.cost.toFixed(4)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Results */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
{result ? (
|
||||
// Result view
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Video with Audio
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #10b981',
|
||||
backgroundColor: '#000',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f0fdf4' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#059669' }}>
|
||||
Audio Added ({result.model_used})
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
download
|
||||
sx={{
|
||||
backgroundColor: '#10b981',
|
||||
'&:hover': {
|
||||
backgroundColor: '#059669',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
<Button variant="outlined" fullWidth onClick={reset}>
|
||||
Process Another
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : videoPreview ? (
|
||||
// Original video preview
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Original Video Preview
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video and configure audio settings to get started
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video to see preview
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Your video with audio will appear here
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
|
||||
About Audio Generation Models
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 0.5 }}>
|
||||
Hunyuan Video Foley:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Multi-scene synchronization – Audio aligned to complex, fast-cut visuals
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
48 kHz hi-fi output – Professional clarity with low noise
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Pricing: $0.02/second
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 0.5 }}>
|
||||
Think Sound:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Context-aware sound – Analyzes visual elements to generate matching audio
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Prompt-guided output with built-in Prompt Enhancer for AI-assisted optimization
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
High-quality output with clear, realistic audio
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Pricing: $0.05 per video (flat rate)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>
|
||||
Pro Tips for Best Quality:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Use videos with clear visuals and distinct actions for best audio matching
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Add prompts to specify the type of sound (e.g., "engine roaring", "footsteps on gravel")
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Ensure videos have visible sound-producing elements like movement or impacts
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Fix the seed when iterating to compare different prompt variations
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { AddAudioToVideo };
|
||||
export default AddAudioToVideo;
|
||||
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, TextField, Paper, Chip } from '@mui/material';
|
||||
import MusicNoteIcon from '@mui/icons-material/MusicNote';
|
||||
import type { AudioModel } from '../hooks/useAddAudioToVideo';
|
||||
|
||||
interface AudioSettingsProps {
|
||||
model: AudioModel;
|
||||
prompt: string;
|
||||
seed: number | null;
|
||||
costHint: string;
|
||||
onModelChange: (model: AudioModel) => void;
|
||||
onPromptChange: (prompt: string) => void;
|
||||
onSeedChange: (seed: number | null) => void;
|
||||
}
|
||||
|
||||
export const AudioSettings: React.FC<AudioSettingsProps> = ({
|
||||
model,
|
||||
prompt,
|
||||
seed,
|
||||
costHint,
|
||||
onModelChange,
|
||||
onPromptChange,
|
||||
onSeedChange,
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<MusicNoteIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Audio Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Audio Model
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={model}
|
||||
onChange={(e) => onModelChange(e.target.value as AudioModel)}
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="hunyuan-video-foley">Hunyuan Video Foley ($0.02/s)</MenuItem>
|
||||
<MenuItem value="think-sound">Think Sound ($0.05/video)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{model === 'hunyuan-video-foley'
|
||||
? 'Tencent Hunyuan\'s video-to-audio model: Multi-scene synchronization, 48 kHz hi-fi output, SOTA performance'
|
||||
: model === 'think-sound'
|
||||
? 'Context-aware video-to-audio generation: Analyzes visual elements to generate matching audio. Features built-in Prompt Enhancer for AI-assisted optimization.'
|
||||
: 'Generate audio from video'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Audio Prompt (Optional)
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
placeholder={
|
||||
model === 'hunyuan-video-foley'
|
||||
? "Briefly describe the mood or key sounds (e.g., 'Rainy street ambience, soft footsteps, distant cars' or 'Kitchen ASMR: chopping vegetables, sizzling pan')"
|
||||
: "Describe the type of sound you want (e.g., 'engine roaring', 'footsteps on gravel', 'ocean waves crashing'). The built-in Prompt Enhancer will optimize your prompt for better results."
|
||||
}
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{model === 'hunyuan-video-foley'
|
||||
? 'Optional: Leave empty to let AI automatically generate appropriate sounds based on visual cues'
|
||||
: 'Optional: Add text descriptions to guide the style and type of audio generated. The built-in Prompt Enhancer will optimize your prompt for better results. Use clear, descriptive prompts for best quality.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Seed (Optional)
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
value={seed === null ? '' : seed}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onSeedChange(value === '' ? null : parseInt(value, 10));
|
||||
}}
|
||||
placeholder="-1 for random"
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Use -1 for random seed, or specify a number for reproducible results. Fix the seed when iterating to compare different prompt variations.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Estimated Cost:
|
||||
</Typography>
|
||||
<Chip
|
||||
label={costHint}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{model === 'think-sound'
|
||||
? 'Pricing: $0.05 per video (flat rate)'
|
||||
: 'Pricing: $0.02/second (estimated)'}
|
||||
</Typography>
|
||||
{model === 'hunyuan-video-foley' && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Minimum charge: 5 seconds | Maximum: 10 minutes (600 seconds)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Source Video
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{videoPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload video
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP4, WebM up to 500MB (max 10 minutes)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { AudioSettings } from './AudioSettings';
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { aiApiClient } from '../../../../../api/client';
|
||||
|
||||
export type AudioModel = 'hunyuan-video-foley' | 'think-sound';
|
||||
|
||||
export const useAddAudioToVideo = () => {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [model, setModel] = useState<AudioModel>('hunyuan-video-foley');
|
||||
const [prompt, setPrompt] = useState<string>('');
|
||||
const [seed, setSeed] = useState<number | null>(null);
|
||||
const [processing, setProcessing] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number; model_used: string } | null>(null);
|
||||
const [estimatedDuration, setEstimatedDuration] = useState<number>(10.0);
|
||||
const [costEstimate, setCostEstimate] = useState<number | null>(null);
|
||||
|
||||
// Update preview when file changes
|
||||
useEffect(() => {
|
||||
if (videoFile) {
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
|
||||
// Rough estimate: 1MB ≈ 1 second at 1080p
|
||||
const estimated = Math.max(5, videoFile.size / (1024 * 1024));
|
||||
setEstimatedDuration(estimated);
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
setEstimatedDuration(10.0);
|
||||
}
|
||||
}, [videoFile]);
|
||||
|
||||
// Fetch cost estimate when model or duration changes
|
||||
useEffect(() => {
|
||||
const fetchCostEstimate = async () => {
|
||||
if (!videoFile || estimatedDuration < 5) {
|
||||
setCostEstimate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('model', model);
|
||||
formData.append('estimated_duration', estimatedDuration.toString());
|
||||
|
||||
const response = await aiApiClient.post('/api/video-studio/add-audio-to-video/estimate-cost', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.estimated_cost) {
|
||||
setCostEstimate(response.data.estimated_cost);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cost estimate:', err);
|
||||
// Fallback to client-side calculation
|
||||
if (model === 'think-sound') {
|
||||
setCostEstimate(0.05); // Flat rate per video
|
||||
} else {
|
||||
const costPerSecond = 0.02;
|
||||
setCostEstimate(Math.max(5.0, estimatedDuration) * costPerSecond);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchCostEstimate();
|
||||
}, [videoFile, model, estimatedDuration]);
|
||||
|
||||
const canAddAudio = useMemo(() => {
|
||||
return videoFile !== null;
|
||||
}, [videoFile]);
|
||||
|
||||
const costHint = useMemo(() => {
|
||||
if (!videoFile) return 'Upload a video to see cost estimate';
|
||||
|
||||
if (costEstimate !== null) {
|
||||
return `Est. ~$${costEstimate.toFixed(2)} (${estimatedDuration.toFixed(0)}s)`;
|
||||
}
|
||||
|
||||
// Fallback calculation
|
||||
if (model === 'think-sound') {
|
||||
return `Est. ~$0.05 (flat rate per video)`;
|
||||
} else {
|
||||
const costPerSecond = 0.02;
|
||||
const estimatedCost = Math.max(5.0, estimatedDuration) * costPerSecond;
|
||||
return `Est. ~$${estimatedCost.toFixed(2)} (${estimatedDuration.toFixed(0)}s)`;
|
||||
}
|
||||
}, [videoFile, estimatedDuration, costEstimate]);
|
||||
|
||||
const addAudio = async () => {
|
||||
if (!videoFile) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('video_file', videoFile);
|
||||
formData.append('model', model);
|
||||
if (prompt) {
|
||||
formData.append('prompt', prompt);
|
||||
}
|
||||
if (seed !== null) {
|
||||
formData.append('seed', seed.toString());
|
||||
}
|
||||
|
||||
// Submit audio addition request
|
||||
setProgress(10);
|
||||
const response = await aiApiClient.post('/api/video-studio/add-audio-to-video', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const uploadProgress = Math.round((progressEvent.loaded * 30) / progressEvent.total);
|
||||
setProgress(uploadProgress);
|
||||
}
|
||||
},
|
||||
timeout: 600000, // 10 minutes timeout
|
||||
});
|
||||
|
||||
setProgress(40);
|
||||
|
||||
// Simulate progress updates
|
||||
let simulatedProgress = 40;
|
||||
const progressInterval = setInterval(() => {
|
||||
simulatedProgress = Math.min(90, simulatedProgress + 5);
|
||||
setProgress(simulatedProgress);
|
||||
}, 2000);
|
||||
|
||||
try {
|
||||
if (response.data.success) {
|
||||
clearInterval(progressInterval);
|
||||
setProcessing(false);
|
||||
setResult(response.data);
|
||||
setProgress(100);
|
||||
} else {
|
||||
clearInterval(progressInterval);
|
||||
throw new Error(response.data.error || 'Adding audio failed');
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(progressInterval);
|
||||
throw err;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProcessing(false);
|
||||
setProgress(0);
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to add audio');
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setProcessing(false);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setVideoFile(null);
|
||||
setPrompt('');
|
||||
setSeed(null);
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
videoFile,
|
||||
videoPreview,
|
||||
model,
|
||||
prompt,
|
||||
seed,
|
||||
processing,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
estimatedDuration,
|
||||
costEstimate,
|
||||
// Setters
|
||||
setVideoFile,
|
||||
setModel,
|
||||
setPrompt,
|
||||
setSeed,
|
||||
// Computed
|
||||
canAddAudio,
|
||||
costHint,
|
||||
// Actions
|
||||
addAudio,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AddAudioToVideo } from './AddAudioToVideo';
|
||||
export { default } from './AddAudioToVideo';
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export from the AvatarVideo component
|
||||
export { AvatarVideo } from './AvatarVideo/AvatarVideo';
|
||||
export { default } from './AvatarVideo/AvatarVideo';
|
||||
@@ -0,0 +1,249 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useAvatarVideo } from './hooks/useAvatarVideo';
|
||||
import { ImageUpload, AudioUpload, AvatarSettings } from './components';
|
||||
import { aiApiClient } from '../../../../api/client';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
|
||||
export const AvatarVideo: React.FC = () => {
|
||||
const {
|
||||
imageFile,
|
||||
imagePreview,
|
||||
audioFile,
|
||||
audioPreview,
|
||||
resolution,
|
||||
model,
|
||||
prompt,
|
||||
seed,
|
||||
setImageFile,
|
||||
setAudioFile,
|
||||
setResolution,
|
||||
setModel,
|
||||
setPrompt,
|
||||
setSeed,
|
||||
canGenerate,
|
||||
costHint,
|
||||
} = useAvatarVideo();
|
||||
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number } | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!imageFile || !audioFile) return;
|
||||
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
setStatusMessage('Starting avatar generation...');
|
||||
|
||||
try {
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
formData.append('audio', audioFile);
|
||||
formData.append('resolution', resolution);
|
||||
formData.append('model', model);
|
||||
if (prompt) {
|
||||
formData.append('prompt', prompt);
|
||||
}
|
||||
if (seed !== null) {
|
||||
formData.append('seed', seed.toString());
|
||||
}
|
||||
|
||||
// Submit generation request
|
||||
const response = await aiApiClient.post('/api/video-studio/avatar/create-async', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
const { task_id } = response.data;
|
||||
setTaskId(task_id);
|
||||
setStatusMessage('Avatar generation started. Polling for updates...');
|
||||
|
||||
// Poll for status
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const statusResponse = await aiApiClient.get(`/api/video-studio/task/${task_id}/status`);
|
||||
const status = statusResponse.data;
|
||||
|
||||
setProgress(status.progress || 0);
|
||||
setStatusMessage(status.message || 'Processing...');
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
setGenerating(false);
|
||||
setResult(status.result);
|
||||
setStatusMessage('Avatar generation complete!');
|
||||
} else if (status.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
setGenerating(false);
|
||||
setError(status.error || 'Avatar generation failed');
|
||||
setStatusMessage('Generation failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Polling error:', err);
|
||||
// Continue polling on transient errors
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => clearInterval(pollInterval);
|
||||
} catch (err: any) {
|
||||
setGenerating(false);
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to start avatar generation');
|
||||
setStatusMessage('Failed to start generation');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: "Avatar Studio",
|
||||
subtitle: "Create talking videos from photos",
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel: Uploads and Settings */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Stack spacing={3}>
|
||||
<ImageUpload
|
||||
imagePreview={imagePreview}
|
||||
onImageSelect={setImageFile}
|
||||
/>
|
||||
|
||||
<AudioUpload
|
||||
audioPreview={audioPreview}
|
||||
onAudioSelect={setAudioFile}
|
||||
/>
|
||||
|
||||
<AvatarSettings
|
||||
resolution={resolution}
|
||||
model={model}
|
||||
prompt={prompt}
|
||||
seed={seed}
|
||||
onResolutionChange={setResolution}
|
||||
onModelChange={setModel}
|
||||
onPromptChange={setPrompt}
|
||||
onSeedChange={setSeed}
|
||||
/>
|
||||
|
||||
{/* Cost and Generate */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Estimated Cost
|
||||
</Typography>
|
||||
<Typography variant="h6" fontWeight={700} color="#3b82f6">
|
||||
{costHint}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Typography variant="body2" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{generating && (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{progress > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Progress: {progress.toFixed(0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
startIcon={generating ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={handleGenerate}
|
||||
disabled={!canGenerate || generating}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{generating ? 'Generating...' : 'Create Avatar'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel: Preview/Result */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
minHeight: 400,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{result ? (
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Avatar Generated!
|
||||
</Typography>
|
||||
<video
|
||||
src={result.video_url}
|
||||
controls
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 500,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cost: ${result.cost.toFixed(2)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
{imagePreview && audioPreview
|
||||
? 'Upload your photo and audio, then click "Create Avatar" to generate your talking avatar.'
|
||||
: 'Upload a photo and audio to create your talking avatar.'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarVideo;
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import AudioFileIcon from '@mui/icons-material/AudioFile';
|
||||
|
||||
interface AudioUploadProps {
|
||||
audioPreview: string | null;
|
||||
onAudioSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const AudioUpload: React.FC<AudioUploadProps> = ({
|
||||
audioPreview,
|
||||
onAudioSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate audio file
|
||||
if (!file.type.startsWith('audio/')) {
|
||||
alert('Please select an audio file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
alert('Audio file must be less than 50MB');
|
||||
return;
|
||||
}
|
||||
onAudioSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onAudioSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Upload Audio
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{audioPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<AudioFileIcon sx={{ color: '#3b82f6' }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
Audio file selected
|
||||
</Typography>
|
||||
<audio
|
||||
src={audioPreview}
|
||||
controls
|
||||
style={{ width: '100%', marginTop: 8 }}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<AudioFileIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload audio
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP3, WAV up to 50MB (max 10 minutes)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, TextField, Button, CircularProgress, Tooltip } from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import type { AvatarResolution, AvatarModel } from '../hooks/useAvatarVideo';
|
||||
import { optimizePrompt } from '../../../../../api/videoStudioApi';
|
||||
|
||||
interface AvatarSettingsProps {
|
||||
resolution: AvatarResolution;
|
||||
model: AvatarModel;
|
||||
prompt: string;
|
||||
seed: number | null;
|
||||
onResolutionChange: (value: AvatarResolution) => void;
|
||||
onModelChange: (value: AvatarModel) => void;
|
||||
onPromptChange: (value: string) => void;
|
||||
onSeedChange: (value: number | null) => void;
|
||||
}
|
||||
|
||||
export const AvatarSettings: React.FC<AvatarSettingsProps> = ({
|
||||
resolution,
|
||||
model,
|
||||
prompt,
|
||||
seed,
|
||||
onResolutionChange,
|
||||
onModelChange,
|
||||
onPromptChange,
|
||||
onSeedChange,
|
||||
}) => {
|
||||
const [enhancing, setEnhancing] = useState(false);
|
||||
|
||||
const handleEnhancePrompt = async () => {
|
||||
if (!prompt.trim() || enhancing) return;
|
||||
|
||||
setEnhancing(true);
|
||||
try {
|
||||
const result = await optimizePrompt({
|
||||
text: prompt,
|
||||
mode: 'video', // Use 'video' mode for avatar generation
|
||||
style: 'default',
|
||||
});
|
||||
|
||||
if (result.success && result.optimized_prompt) {
|
||||
onPromptChange(result.optimized_prompt);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to enhance prompt:', error);
|
||||
} finally {
|
||||
setEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>AI Model</InputLabel>
|
||||
<Select
|
||||
value={model}
|
||||
label="AI Model"
|
||||
onChange={e => onModelChange(e.target.value as AvatarModel)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="infinitetalk">
|
||||
<Stack>
|
||||
<Typography variant="body2">InfiniteTalk - Long Form</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Up to 10 minutes, $0.03-0.06/s
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="hunyuan-avatar">
|
||||
<Stack>
|
||||
<Typography variant="body2">Hunyuan Avatar - Fast & Affordable</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Up to 2 minutes, $0.15-0.30 per 5s
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Video Quality</InputLabel>
|
||||
<Select
|
||||
value={resolution}
|
||||
label="Video Quality"
|
||||
onChange={e => onResolutionChange(e.target.value as AvatarResolution)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="480p">
|
||||
<Stack>
|
||||
<Typography variant="body2">480p - Fast & Affordable</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{model === 'hunyuan-avatar' ? '$0.15 per 5 seconds' : '$0.03 per second'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="720p">
|
||||
<Stack>
|
||||
<Typography variant="body2">720p - High Quality</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{model === 'hunyuan-avatar' ? '$0.30 per 5 seconds' : '$0.06 per second'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Expression Prompt (Optional)
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, mb: 0.5 }}>
|
||||
AI Prompt Optimizer
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
|
||||
Enhances your expression prompt for better avatar results by improving:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem', mt: 0.5 }}>
|
||||
• Visual clarity & composition
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
|
||||
• Expression details & style consistency
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={enhancing ? <CircularProgress size={16} /> : <AutoAwesomeIcon />}
|
||||
onClick={handleEnhancePrompt}
|
||||
disabled={!prompt.trim() || enhancing}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
py: 0.5,
|
||||
px: 1.5,
|
||||
borderColor: '#3b82f6',
|
||||
color: '#3b82f6',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{enhancing ? 'Enhancing...' : 'Enhance Instructions'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="e.g., 'Confident, friendly smile' or 'Professional, serious expression'"
|
||||
value={prompt}
|
||||
onChange={e => onPromptChange(e.target.value)}
|
||||
helperText="Describe the expression or style you want for your avatar"
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Seed (Optional)"
|
||||
placeholder="Leave empty for random"
|
||||
value={seed || ''}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
onSeedChange(value ? parseInt(value, 10) : null);
|
||||
}}
|
||||
helperText="Use the same seed to generate similar results"
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
|
||||
interface ImageUploadProps {
|
||||
imagePreview: string | null;
|
||||
onImageSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||
imagePreview,
|
||||
onImageSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate image file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('Image file must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
onImageSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onImageSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Upload Photo
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{imagePreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: 400,
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<ImageIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload a photo
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
PNG, JPG up to 10MB
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ImageUpload } from './ImageUpload';
|
||||
export { AudioUpload } from './AudioUpload';
|
||||
export { AvatarSettings } from './AvatarSettings';
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
|
||||
export type AvatarResolution = '480p' | '720p';
|
||||
export type AvatarModel = 'infinitetalk' | 'hunyuan-avatar';
|
||||
|
||||
export const useAvatarVideo = () => {
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [audioPreview, setAudioPreview] = useState<string | null>(null);
|
||||
const [resolution, setResolution] = useState<AvatarResolution>('720p');
|
||||
const [model, setModel] = useState<AvatarModel>('infinitetalk');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [maskImageFile, setMaskImageFile] = useState<File | null>(null);
|
||||
const [seed, setSeed] = useState<number | null>(null);
|
||||
|
||||
// Cost estimation
|
||||
const costHint = useMemo(() => {
|
||||
const estimatedDuration = 10; // TODO: Get actual audio duration
|
||||
|
||||
if (model === 'hunyuan-avatar') {
|
||||
// Hunyuan Avatar: $0.15/5s (480p) or $0.30/5s (720p)
|
||||
const costPer5Seconds = resolution === '480p' ? 0.15 : 0.30;
|
||||
const billable5SecondBlocks = Math.ceil(estimatedDuration / 5);
|
||||
const estimate = (costPer5Seconds * billable5SecondBlocks).toFixed(2);
|
||||
return `Est. ~$${estimate}`;
|
||||
} else {
|
||||
// InfiniteTalk: $0.03/s (480p) or $0.06/s (720p)
|
||||
const costPerSecond = resolution === '480p' ? 0.03 : 0.06;
|
||||
const estimate = (costPerSecond * estimatedDuration).toFixed(2);
|
||||
return `Est. ~$${estimate}`;
|
||||
}
|
||||
}, [resolution, model]);
|
||||
|
||||
const canGenerate = useMemo(() => {
|
||||
return imageFile !== null && audioFile !== null;
|
||||
}, [imageFile, audioFile]);
|
||||
|
||||
const handleImageSelect = useCallback((file: File | null) => {
|
||||
setImageFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setImagePreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setImagePreview(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAudioSelect = useCallback((file: File | null) => {
|
||||
setAudioFile(file);
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setAudioPreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setAudioPreview(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMaskImageSelect = useCallback((file: File | null) => {
|
||||
setMaskImageFile(file);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
imageFile,
|
||||
imagePreview,
|
||||
audioFile,
|
||||
audioPreview,
|
||||
resolution,
|
||||
model,
|
||||
prompt,
|
||||
maskImageFile,
|
||||
seed,
|
||||
// Setters
|
||||
setImageFile: handleImageSelect,
|
||||
setAudioFile: handleAudioSelect,
|
||||
setResolution,
|
||||
setModel,
|
||||
setPrompt,
|
||||
setMaskImageFile: handleMaskImageSelect,
|
||||
setSeed,
|
||||
// Computed
|
||||
canGenerate,
|
||||
costHint,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AvatarVideo } from './AvatarVideo';
|
||||
export { default } from './AvatarVideo';
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface CarouselPlaceholderProps {
|
||||
examples: string[];
|
||||
interval?: number;
|
||||
onExampleChange?: (example: string, index: number) => void;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export const CarouselPlaceholder: React.FC<CarouselPlaceholderProps> = ({
|
||||
examples,
|
||||
interval = 4000,
|
||||
onExampleChange,
|
||||
paused = false,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (examples.length <= 1 || paused) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentIndex(prev => {
|
||||
const next = (prev + 1) % examples.length;
|
||||
if (onExampleChange) {
|
||||
onExampleChange(examples[next], next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [examples.length, interval, onExampleChange, paused]);
|
||||
|
||||
if (examples.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
minHeight: 24,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontStyle: 'italic',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{examples[currentIndex]}
|
||||
</Typography>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{examples.length > 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -24,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{examples.map((_, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: idx === currentIndex ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.2)',
|
||||
transition: 'background 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { Grid } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useCreateVideo } from './hooks/useCreateVideo';
|
||||
import { GenerationSettingsPanel, VideoExamplesPanel } from './components';
|
||||
import { handleExampleClick, handleAssetClick } from './utils/exampleHandlers';
|
||||
import { createVideoExamples } from '../../dashboard/constants';
|
||||
import type { ContentAsset } from '../../../../hooks/useContentAssets';
|
||||
|
||||
export const CreateVideo: React.FC = () => {
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
prompt,
|
||||
setPrompt,
|
||||
negativePrompt,
|
||||
setNegativePrompt,
|
||||
duration,
|
||||
setDuration,
|
||||
resolution,
|
||||
setResolution,
|
||||
aspect,
|
||||
setAspect,
|
||||
motion,
|
||||
setMotion,
|
||||
audioAttached,
|
||||
setAudioAttached,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
selectedExample,
|
||||
setSelectedExample,
|
||||
selectedAssetId,
|
||||
setSelectedAssetId,
|
||||
promptPlaceholderIndex,
|
||||
setPromptPlaceholderIndex,
|
||||
negativePlaceholderIndex,
|
||||
setNegativePlaceholderIndex,
|
||||
promptFocused,
|
||||
setPromptFocused,
|
||||
negativeFocused,
|
||||
setNegativeFocused,
|
||||
canGenerate,
|
||||
costHint,
|
||||
libraryVideos,
|
||||
loadingLibraryVideos,
|
||||
handleFileSelect,
|
||||
} = useCreateVideo();
|
||||
|
||||
const handleExampleClickWrapper = (index: number) => {
|
||||
const example = createVideoExamples[index];
|
||||
handleExampleClick(
|
||||
index,
|
||||
example,
|
||||
setPrompt,
|
||||
setAspect,
|
||||
setSelectedExample,
|
||||
setSelectedAssetId
|
||||
);
|
||||
};
|
||||
|
||||
const handleAssetClickWrapper = (asset: ContentAsset) => {
|
||||
handleAssetClick(
|
||||
asset,
|
||||
setPrompt,
|
||||
setAspect,
|
||||
setResolution,
|
||||
setSelectedAssetId,
|
||||
setSelectedExample
|
||||
);
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
// Placeholder: hook preflight + job creation later
|
||||
alert('This is a UI preview. Backend generation will be wired in the next step.');
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Create Studio',
|
||||
subtitle: 'AI-Powered Video Generation for Content Creators. Turn your ideas into engaging videos for Instagram, TikTok, YouTube, LinkedIn, and more.',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
{/* Left Panel - Generation Controls */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<GenerationSettingsPanel
|
||||
mode={mode}
|
||||
prompt={prompt}
|
||||
negativePrompt={negativePrompt}
|
||||
duration={duration}
|
||||
resolution={resolution}
|
||||
aspect={aspect}
|
||||
motion={motion}
|
||||
audioAttached={audioAttached}
|
||||
costHint={costHint}
|
||||
canGenerate={canGenerate}
|
||||
promptFocused={promptFocused}
|
||||
negativeFocused={negativeFocused}
|
||||
promptPlaceholderIndex={promptPlaceholderIndex}
|
||||
negativePlaceholderIndex={negativePlaceholderIndex}
|
||||
selectedModel={selectedModel}
|
||||
onModeChange={setMode}
|
||||
onPromptChange={setPrompt}
|
||||
onNegativePromptChange={setNegativePrompt}
|
||||
onDurationChange={setDuration}
|
||||
onResolutionChange={setResolution}
|
||||
onAspectChange={setAspect}
|
||||
onMotionChange={setMotion}
|
||||
onModelChange={setSelectedModel}
|
||||
onFileSelect={handleFileSelect}
|
||||
onPromptFocus={() => setPromptFocused(true)}
|
||||
onPromptBlur={() => setPromptFocused(false)}
|
||||
onNegativeFocus={() => setNegativeFocused(true)}
|
||||
onNegativeBlur={() => setNegativeFocused(false)}
|
||||
onPromptPlaceholderChange={setPromptPlaceholderIndex}
|
||||
onNegativePlaceholderChange={setNegativePlaceholderIndex}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Video Preview & Examples */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<VideoExamplesPanel
|
||||
examples={createVideoExamples}
|
||||
libraryVideos={libraryVideos}
|
||||
loadingLibraryVideos={loadingLibraryVideos}
|
||||
selectedExample={selectedExample}
|
||||
selectedAssetId={selectedAssetId}
|
||||
prompt={prompt}
|
||||
onExampleClick={handleExampleClickWrapper}
|
||||
onAssetClick={handleAssetClickWrapper}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateVideo;
|
||||
@@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import { Box, Card, CardContent, Stack, Typography, Chip } from '@mui/material';
|
||||
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
||||
import { motion as framerMotion } from 'framer-motion';
|
||||
import { OptimizedVideo } from '../../../../ImageStudio/dashboard/utils/OptimizedVideo';
|
||||
import type { ContentAsset } from '../../../../../hooks/useContentAssets';
|
||||
|
||||
interface AssetLibraryVideoCardProps {
|
||||
asset: ContentAsset;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const AssetLibraryVideoCard: React.FC<AssetLibraryVideoCardProps> = ({
|
||||
asset,
|
||||
isSelected,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<framerMotion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: isSelected ? '2px solid #667eea' : '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.2)',
|
||||
},
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingTop: '56.25%', // 16:9 aspect ratio
|
||||
backgroundColor: '#0f172a',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src={asset.file_url}
|
||||
alt={asset.title || asset.filename}
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: '#667eea',
|
||||
borderRadius: '50%',
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlineIcon sx={{ color: '#fff', fontSize: 20 }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: '#0f172a',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
}}
|
||||
title={asset.title || asset.filename}
|
||||
>
|
||||
{asset.title || asset.filename}
|
||||
</Typography>
|
||||
{asset.source_module && (
|
||||
<Chip
|
||||
label={asset.source_module}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(102, 126, 234, 0.1)',
|
||||
color: '#667eea',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
ml: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{asset.description && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#475569', fontSize: 11 }}
|
||||
title={asset.description}
|
||||
>
|
||||
{asset.description.length > 60
|
||||
? `${asset.description.substring(0, 60)}...`
|
||||
: asset.description}
|
||||
</Typography>
|
||||
)}
|
||||
{asset.prompt && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#6366f1',
|
||||
fontSize: 10,
|
||||
fontStyle: 'italic',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={asset.prompt}
|
||||
>
|
||||
"{asset.prompt.length > 50 ? `${asset.prompt.substring(0, 50)}...` : asset.prompt}"
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{asset.cost > 0 && (
|
||||
<Chip
|
||||
label={`$${asset.cost.toFixed(2)}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
color: '#047857',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{asset.asset_metadata?.resolution && (
|
||||
<Chip
|
||||
label={asset.asset_metadata.resolution}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
color: '#1e40af',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</framerMotion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Box, Card, CardContent, Stack, Typography, Chip } from '@mui/material';
|
||||
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
||||
import { motion as framerMotion } from 'framer-motion';
|
||||
import { OptimizedVideo } from '../../../../ImageStudio/dashboard/utils/OptimizedVideo';
|
||||
import type { ExampleVideo } from '../types';
|
||||
|
||||
interface ExampleVideoCardProps {
|
||||
example: ExampleVideo;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const ExampleVideoCard: React.FC<ExampleVideoCardProps> = ({
|
||||
example,
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<framerMotion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
border: isSelected ? '2px solid #667eea' : '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.2)',
|
||||
},
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingTop: '56.25%', // 16:9 aspect ratio
|
||||
backgroundColor: '#0f172a',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src={example.video}
|
||||
alt={example.label}
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: '#667eea',
|
||||
borderRadius: '50%',
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlineIcon sx={{ color: '#fff', fontSize: 20 }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
{example.label}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={example.platform}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(102, 126, 234, 0.1)',
|
||||
color: '#667eea',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: '#475569', fontSize: 11 }}>
|
||||
{example.description}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip
|
||||
label={example.price}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
color: '#047857',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={example.eta}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
color: '#1e40af',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</framerMotion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
Button,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import type { Mode } from '../types';
|
||||
import { PromptInput } from './PromptInput';
|
||||
import { VideoSettings } from './VideoSettings';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import type { Resolution, AspectPreset, MotionPreset, Duration } from '../types';
|
||||
|
||||
interface GenerationSettingsPanelProps {
|
||||
mode: Mode;
|
||||
prompt: string;
|
||||
negativePrompt: string;
|
||||
duration: Duration;
|
||||
resolution: Resolution;
|
||||
aspect: AspectPreset;
|
||||
motion: MotionPreset;
|
||||
audioAttached: boolean;
|
||||
costHint: string;
|
||||
canGenerate: boolean;
|
||||
promptFocused: boolean;
|
||||
negativeFocused: boolean;
|
||||
promptPlaceholderIndex: number;
|
||||
negativePlaceholderIndex: number;
|
||||
selectedModel: string;
|
||||
onModeChange: (mode: Mode) => void;
|
||||
onPromptChange: (value: string) => void;
|
||||
onNegativePromptChange: (value: string) => void;
|
||||
onDurationChange: (value: Duration) => void;
|
||||
onResolutionChange: (value: Resolution) => void;
|
||||
onAspectChange: (value: AspectPreset) => void;
|
||||
onMotionChange: (value: MotionPreset) => void;
|
||||
onModelChange: (modelId: string) => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onPromptFocus: () => void;
|
||||
onPromptBlur: () => void;
|
||||
onNegativeFocus: () => void;
|
||||
onNegativeBlur: () => void;
|
||||
onPromptPlaceholderChange: (index: number) => void;
|
||||
onNegativePlaceholderChange: (index: number) => void;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export const GenerationSettingsPanel: React.FC<GenerationSettingsPanelProps> = ({
|
||||
mode,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
duration,
|
||||
resolution,
|
||||
aspect,
|
||||
motion,
|
||||
costHint,
|
||||
canGenerate,
|
||||
promptFocused,
|
||||
negativeFocused,
|
||||
promptPlaceholderIndex,
|
||||
negativePlaceholderIndex,
|
||||
selectedModel,
|
||||
onModeChange,
|
||||
onPromptChange,
|
||||
onNegativePromptChange,
|
||||
onDurationChange,
|
||||
onResolutionChange,
|
||||
onAspectChange,
|
||||
onMotionChange,
|
||||
onModelChange,
|
||||
onFileSelect,
|
||||
onPromptFocus,
|
||||
onPromptBlur,
|
||||
onNegativeFocus,
|
||||
onNegativeBlur,
|
||||
onPromptPlaceholderChange,
|
||||
onNegativePlaceholderChange,
|
||||
onGenerate,
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
background: 'rgba(248, 250, 252, 0.96)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.35)',
|
||||
borderRadius: 3,
|
||||
p: 3,
|
||||
height: '100%',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
color: '#0f172a',
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ color: '#667eea' }} />
|
||||
Generation Settings
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Mode Toggle */}
|
||||
<ToggleButtonGroup
|
||||
value={mode}
|
||||
exclusive
|
||||
onChange={(_, val) => val && onModeChange(val)}
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: 2,
|
||||
'& .MuiToggleButton-root': {
|
||||
color: '#475569',
|
||||
'&.Mui-selected': {
|
||||
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="t2v">Text to Video</ToggleButton>
|
||||
<ToggleButton value="i2v">Image to Video</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{/* AI Model Selector (only for text-to-video) */}
|
||||
{mode === 't2v' && (
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={onModelChange}
|
||||
duration={duration}
|
||||
resolution={resolution}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Prompt Input */}
|
||||
<PromptInput
|
||||
prompt={prompt}
|
||||
negativePrompt={negativePrompt}
|
||||
onPromptChange={onPromptChange}
|
||||
onNegativePromptChange={onNegativePromptChange}
|
||||
promptFocused={promptFocused}
|
||||
negativeFocused={negativeFocused}
|
||||
onPromptFocus={onPromptFocus}
|
||||
onPromptBlur={onPromptBlur}
|
||||
onNegativeFocus={onNegativeFocus}
|
||||
onNegativeBlur={onNegativeBlur}
|
||||
promptPlaceholderIndex={promptPlaceholderIndex}
|
||||
negativePlaceholderIndex={negativePlaceholderIndex}
|
||||
onPromptPlaceholderChange={onPromptPlaceholderChange}
|
||||
onNegativePlaceholderChange={onNegativePlaceholderChange}
|
||||
/>
|
||||
|
||||
{/* Image Upload for i2v */}
|
||||
{mode === 'i2v' && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadFileIcon />}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderColor: '#d2d9ee',
|
||||
color: '#0f172a',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
||||
'&:hover': {
|
||||
borderColor: '#7c3aed',
|
||||
background: 'rgba(124, 58, 237, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Upload Image
|
||||
<input hidden accept="image/*" type="file" onChange={onFileSelect} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Video Settings */}
|
||||
<VideoSettings
|
||||
resolution={resolution}
|
||||
aspect={aspect}
|
||||
motion={motion}
|
||||
duration={duration}
|
||||
onResolutionChange={onResolutionChange}
|
||||
onAspectChange={onAspectChange}
|
||||
onMotionChange={onMotionChange}
|
||||
onDurationChange={onDurationChange}
|
||||
/>
|
||||
|
||||
{/* Cost Estimate */}
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<InfoOutlinedIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(99, 102, 241, 0.08)',
|
||||
color: '#0f172a',
|
||||
'& .MuiAlert-icon': { color: '#6366f1' },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Estimated Cost: {costHint}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
Final cost is confirmed before generation. Lower cost = shorter duration + lower quality.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
disabled={!canGenerate}
|
||||
fullWidth
|
||||
onClick={onGenerate}
|
||||
sx={{
|
||||
py: 2,
|
||||
borderRadius: 2,
|
||||
background: canGenerate
|
||||
? 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)'
|
||||
: '#e2e8f0',
|
||||
color: canGenerate ? '#fff' : '#94a3b8',
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
textTransform: 'none',
|
||||
boxShadow: canGenerate ? '0 8px 24px rgba(102, 126, 234, 0.4)' : 'none',
|
||||
'&:hover': {
|
||||
background: canGenerate
|
||||
? 'linear-gradient(90deg, #5568d3 0%, #65408b 100%)'
|
||||
: '#e2e8f0',
|
||||
boxShadow: canGenerate ? '0 12px 32px rgba(102, 126, 234, 0.5)' : 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Create Video
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { VIDEO_MODELS, type VideoModelInfo } from '../models/videoModels';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string;
|
||||
onModelChange: (modelId: string) => void;
|
||||
duration: number;
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
duration,
|
||||
resolution,
|
||||
}) => {
|
||||
const [expandedModel, setExpandedModel] = useState<string | false>(false);
|
||||
const selectedModelInfo = VIDEO_MODELS.find(m => m.id === selectedModel);
|
||||
|
||||
const handleAccordionChange = (modelId: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
||||
setExpandedModel(isExpanded ? modelId : false);
|
||||
};
|
||||
|
||||
const calculateCost = (model: VideoModelInfo): string => {
|
||||
const costPerSecond = model.costPerSecond[resolution] || model.costPerSecond[Object.keys(model.costPerSecond)[0]];
|
||||
const totalCost = costPerSecond * duration;
|
||||
return `$${totalCost.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const isModelCompatible = (model: VideoModelInfo): { compatible: boolean; reason?: string } => {
|
||||
if (!model.durations.includes(duration)) {
|
||||
return { compatible: false, reason: `Duration ${duration}s not supported. Available: ${model.durations.join(', ')}s` };
|
||||
}
|
||||
if (!model.resolutions.includes(resolution)) {
|
||||
return { compatible: false, reason: `Resolution ${resolution} not supported. Available: ${model.resolutions.join(', ')}` };
|
||||
}
|
||||
return { compatible: true };
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
AI Model
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Choose the AI model that best fits your content needs. Each model has different strengths, pricing, and capabilities."
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: '#64748b' }}>
|
||||
<HelpOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
'&:hover fieldset': { borderColor: '#cbd5f5' },
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#7c3aed',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{VIDEO_MODELS.map((model) => {
|
||||
const compatibility = isModelCompatible(model);
|
||||
return (
|
||||
<MenuItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
disabled={!compatibility.compatible}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography sx={{ color: compatibility.compatible ? '#0f172a' : '#94a3b8', fontWeight: 600 }}>
|
||||
{model.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block' }}>
|
||||
{model.tagline}
|
||||
</Typography>
|
||||
{!compatibility.compatible && (
|
||||
<Typography variant="caption" sx={{ color: '#ef4444', display: 'block', mt: 0.5 }}>
|
||||
{compatibility.reason}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{compatibility.compatible && (
|
||||
<Chip
|
||||
label={calculateCost(model)}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
color: '#0369a1',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Selected Model Details */}
|
||||
{selectedModelInfo && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||
{selectedModelInfo.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#475569' }}>
|
||||
{selectedModelInfo.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Best For */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', textTransform: 'uppercase' }}>
|
||||
Best For
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1 }}>
|
||||
{selectedModelInfo.bestFor.slice(0, 3).map((useCase) => (
|
||||
<Chip
|
||||
key={useCase}
|
||||
label={useCase}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#e0e7ff',
|
||||
color: '#4338ca',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Cost & Duration Info */}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block' }}>
|
||||
Estimated Cost
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
{calculateCost(selectedModelInfo)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block' }}>
|
||||
Audio Support
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: selectedModelInfo.audioSupport ? '#059669' : '#dc2626' }}>
|
||||
{selectedModelInfo.audioSupport ? 'Yes' : 'No'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Expandable Details */}
|
||||
<Accordion
|
||||
expanded={expandedModel === selectedModel}
|
||||
onChange={handleAccordionChange(selectedModel)}
|
||||
sx={{
|
||||
boxShadow: 'none',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#64748b' }} />}
|
||||
sx={{ minHeight: 40 }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>
|
||||
View Full Details & Tips
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
{/* Strengths */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', textTransform: 'uppercase', display: 'block', mb: 1 }}>
|
||||
Strengths
|
||||
</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
{selectedModelInfo.strengths.map((strength, idx) => (
|
||||
<ListItem key={idx} sx={{ py: 0.5, px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<CheckCircleIcon sx={{ fontSize: 16, color: '#059669' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={strength}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
sx: { color: '#475569' },
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Tips */}
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', textTransform: 'uppercase', display: 'block', mb: 1 }}>
|
||||
Pro Tips
|
||||
</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
{selectedModelInfo.tips.map((tip, idx) => (
|
||||
<ListItem key={idx} sx={{ py: 0.5, px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<InfoIcon sx={{ fontSize: 16, color: '#0369a1' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={tip}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
sx: { color: '#475569' },
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Model Comparison Link */}
|
||||
<Box sx={{ mt: 2, textAlign: 'center' }}>
|
||||
<Tooltip title="Compare all models side-by-side to find the best fit for your needs">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#667eea',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
'&:hover': { color: '#5568d3' },
|
||||
}}
|
||||
>
|
||||
Compare all models →
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, TextField, Typography, Stack, Button, CircularProgress, Tooltip } from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import { CarouselPlaceholder } from '../../CarouselPlaceholder';
|
||||
import { examplePrompts, exampleNegativePrompts, inputStyles, colors } from '../constants';
|
||||
import { optimizePrompt } from '../../../../../api/videoStudioApi';
|
||||
|
||||
interface PromptInputProps {
|
||||
prompt: string;
|
||||
negativePrompt: string;
|
||||
promptFocused: boolean;
|
||||
negativeFocused: boolean;
|
||||
promptPlaceholderIndex: number;
|
||||
negativePlaceholderIndex: number;
|
||||
onPromptChange: (value: string) => void;
|
||||
onNegativePromptChange: (value: string) => void;
|
||||
onPromptFocus: () => void;
|
||||
onPromptBlur: () => void;
|
||||
onNegativeFocus: () => void;
|
||||
onNegativeBlur: () => void;
|
||||
onPromptPlaceholderChange: (index: number) => void;
|
||||
onNegativePlaceholderChange: (index: number) => void;
|
||||
}
|
||||
|
||||
export const PromptInput: React.FC<PromptInputProps> = ({
|
||||
prompt,
|
||||
negativePrompt,
|
||||
promptFocused,
|
||||
negativeFocused,
|
||||
promptPlaceholderIndex,
|
||||
negativePlaceholderIndex,
|
||||
onPromptChange,
|
||||
onNegativePromptChange,
|
||||
onPromptFocus,
|
||||
onPromptBlur,
|
||||
onNegativeFocus,
|
||||
onNegativeBlur,
|
||||
onPromptPlaceholderChange,
|
||||
onNegativePlaceholderChange,
|
||||
}) => {
|
||||
const [enhancing, setEnhancing] = useState(false);
|
||||
|
||||
const handleEnhancePrompt = async () => {
|
||||
if (!prompt.trim() || enhancing) return;
|
||||
|
||||
setEnhancing(true);
|
||||
try {
|
||||
const result = await optimizePrompt({
|
||||
text: prompt,
|
||||
mode: 'video', // Always use 'video' mode for Video Studio
|
||||
style: 'default',
|
||||
});
|
||||
|
||||
if (result.success && result.optimized_prompt) {
|
||||
onPromptChange(result.optimized_prompt);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to enhance prompt:', error);
|
||||
// Optionally show error toast/notification
|
||||
} finally {
|
||||
setEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: colors.primary,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Describe Your Video
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, mb: 0.5 }}>
|
||||
AI Prompt Optimizer
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
|
||||
Enhances your prompt for better video generation by improving:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem', mt: 0.5 }}>
|
||||
• Visual clarity & composition
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
|
||||
• Cinematic framing & lighting
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.7rem' }}>
|
||||
• Camera movement & style consistency
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={enhancing ? <CircularProgress size={16} /> : <AutoAwesomeIcon />}
|
||||
onClick={handleEnhancePrompt}
|
||||
disabled={!prompt.trim() || enhancing}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
py: 0.5,
|
||||
px: 1.5,
|
||||
borderColor: colors.primary,
|
||||
color: colors.primary,
|
||||
'&:hover': {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{enhancing ? 'Enhancing...' : 'Enhance Instructions'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="Enter your video description..."
|
||||
value={prompt}
|
||||
onChange={e => onPromptChange(e.target.value)}
|
||||
onFocus={onPromptFocus}
|
||||
onBlur={onPromptBlur}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
...inputStyles.outlinedInputBase,
|
||||
minHeight: 140,
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#0f172a',
|
||||
'&::placeholder': {
|
||||
color: '#64748b',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{!prompt && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 56,
|
||||
left: 14,
|
||||
right: 14,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
opacity: promptFocused ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<CarouselPlaceholder
|
||||
examples={examplePrompts}
|
||||
interval={4000}
|
||||
paused={promptFocused}
|
||||
onExampleChange={(_: string, idx: number) => onPromptPlaceholderChange(idx)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: colors.primary,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
What to Avoid (Optional)
|
||||
</Typography>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<TextField
|
||||
label="What to avoid (optional)"
|
||||
value={negativePrompt}
|
||||
onChange={e => onNegativePromptChange(e.target.value)}
|
||||
onFocus={onNegativeFocus}
|
||||
onBlur={onNegativeBlur}
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': inputStyles.outlinedInputBase,
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#0f172a',
|
||||
'&::placeholder': {
|
||||
color: '#64748b',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{!negativePrompt && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
left: 14,
|
||||
right: 14,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
opacity: negativeFocused ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<CarouselPlaceholder
|
||||
examples={exampleNegativePrompts}
|
||||
interval={4000}
|
||||
paused={negativeFocused}
|
||||
onExampleChange={(_: string, idx: number) => onNegativePlaceholderChange(idx)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'block',
|
||||
color: colors.muted,
|
||||
}}
|
||||
>
|
||||
Use this to specify what you don't want in your video (e.g., "blurry, low quality, distorted faces")
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Divider,
|
||||
Grid,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import MovieCreationIcon from '@mui/icons-material/MovieCreation';
|
||||
import type { ExampleVideo } from '../types';
|
||||
import type { ContentAsset } from '../../../../../hooks/useContentAssets';
|
||||
import { ExampleVideoCard } from './ExampleVideoCard';
|
||||
import { AssetLibraryVideoCard } from './AssetLibraryVideoCard';
|
||||
|
||||
interface VideoExamplesPanelProps {
|
||||
examples: ExampleVideo[];
|
||||
libraryVideos: ContentAsset[];
|
||||
loadingLibraryVideos: boolean;
|
||||
selectedExample: number | null;
|
||||
selectedAssetId: number | null;
|
||||
prompt: string;
|
||||
onExampleClick: (index: number) => void;
|
||||
onAssetClick: (asset: ContentAsset) => void;
|
||||
}
|
||||
|
||||
export const VideoExamplesPanel: React.FC<VideoExamplesPanelProps> = ({
|
||||
examples,
|
||||
libraryVideos,
|
||||
loadingLibraryVideos,
|
||||
selectedExample,
|
||||
selectedAssetId,
|
||||
prompt,
|
||||
onExampleClick,
|
||||
onAssetClick,
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 3,
|
||||
p: 3,
|
||||
minHeight: 600,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
color: '#0f172a',
|
||||
}}
|
||||
>
|
||||
<MovieCreationIcon sx={{ color: '#667eea' }} />
|
||||
Video Examples & Preview
|
||||
</Typography>
|
||||
|
||||
{/* Example Videos */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, color: '#0f172a' }}>
|
||||
Example Videos
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{examples.map((example, index) => (
|
||||
<Grid item xs={12} sm={6} key={example.id}>
|
||||
<ExampleVideoCard
|
||||
example={example}
|
||||
index={index}
|
||||
isSelected={selectedExample === index}
|
||||
onClick={() => onExampleClick(index)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Asset Library Videos */}
|
||||
{libraryVideos.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 3, borderColor: 'rgba(0,0,0,0.1)' }} />
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Your Videos from Asset Library
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${libraryVideos.length} video${libraryVideos.length !== 1 ? 's' : ''}`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(102, 126, 234, 0.1)',
|
||||
color: '#667eea',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{loadingLibraryVideos ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" sx={{ color: '#475569' }}>
|
||||
Loading your videos...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{libraryVideos.map((asset) => (
|
||||
<Grid item xs={12} sm={6} key={asset.id}>
|
||||
<AssetLibraryVideoCard
|
||||
asset={asset}
|
||||
isSelected={selectedAssetId === asset.id}
|
||||
onClick={() => onAssetClick(asset)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3, borderColor: 'rgba(0,0,0,0.1)' }} />
|
||||
|
||||
{/* Empty State / Preview Area */}
|
||||
{!prompt && (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 8,
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea20, #764ba220)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<MovieCreationIcon sx={{ fontSize: 60, color: '#667eea' }} />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1, color: '#0f172a' }}>
|
||||
No Video Yet
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#475569', mb: 3 }}>
|
||||
Enter a prompt and click "Create Video" to generate your video, or click an example above to see what's possible
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{['Instagram Reel', 'TikTok Video', 'YouTube Short', 'LinkedIn Post'].map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(102, 126, 234, 0.1)',
|
||||
color: '#667eea',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Generated Video Preview (when available) */}
|
||||
{prompt && (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 4,
|
||||
px: 3,
|
||||
background: 'rgba(102, 126, 234, 0.05)',
|
||||
borderRadius: 2,
|
||||
border: '2px dashed rgba(102, 126, 234, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, mb: 2, color: '#0f172a' }}>
|
||||
Your video will appear here
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#475569' }}>
|
||||
Click "Create Video" to generate your video based on your prompt and settings
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, Slider } from '@mui/material';
|
||||
import type { Resolution, AspectPreset, MotionPreset, Duration } from '../types';
|
||||
import { motionPresets, aspectPresets, inputStyles } from '../constants';
|
||||
|
||||
interface VideoSettingsProps {
|
||||
resolution: Resolution;
|
||||
aspect: AspectPreset;
|
||||
motion: MotionPreset;
|
||||
duration: Duration;
|
||||
onResolutionChange: (value: Resolution) => void;
|
||||
onAspectChange: (value: AspectPreset) => void;
|
||||
onMotionChange: (value: MotionPreset) => void;
|
||||
onDurationChange: (value: Duration) => void;
|
||||
}
|
||||
|
||||
export const VideoSettings: React.FC<VideoSettingsProps> = ({
|
||||
resolution,
|
||||
aspect,
|
||||
motion,
|
||||
duration,
|
||||
onResolutionChange,
|
||||
onAspectChange,
|
||||
onMotionChange,
|
||||
onDurationChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Resolution, Aspect, Motion */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel sx={inputStyles.inputLabel}>Video Quality</InputLabel>
|
||||
<Select
|
||||
value={resolution}
|
||||
label="Video Quality"
|
||||
onChange={e => onResolutionChange(e.target.value as Resolution)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="480p">
|
||||
<Stack>
|
||||
<Typography variant="body2">480p - Fast & Affordable</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Perfect for quick social media tests
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="720p">
|
||||
<Stack>
|
||||
<Typography variant="body2">720p - Balanced</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Great for most platforms
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="1080p">
|
||||
<Stack>
|
||||
<Typography variant="body2">1080p - Premium</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Ideal for YouTube and professional content
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel sx={inputStyles.inputLabel}>Video Format</InputLabel>
|
||||
<Select
|
||||
value={aspect}
|
||||
label="Video Format"
|
||||
onChange={e => onAspectChange(e.target.value as AspectPreset)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="9:16">
|
||||
<Stack>
|
||||
<Typography variant="body2">9:16 - Vertical</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Instagram Reels, TikTok, YouTube Shorts
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="1:1">
|
||||
<Stack>
|
||||
<Typography variant="body2">1:1 - Square</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Instagram posts, Facebook feed
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="16:9">
|
||||
<Stack>
|
||||
<Typography variant="body2">16:9 - Landscape</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
YouTube, LinkedIn, landscape content
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel sx={inputStyles.inputLabel}>Movement Style</InputLabel>
|
||||
<Select
|
||||
value={motion}
|
||||
label="Movement Style"
|
||||
onChange={e => onMotionChange(e.target.value as MotionPreset)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
}}
|
||||
>
|
||||
{motionPresets.map(preset => (
|
||||
<MenuItem key={preset} value={preset}>
|
||||
<Stack>
|
||||
<Typography variant="body2">{preset}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{preset === 'Subtle'
|
||||
? 'Gentle movement, professional content'
|
||||
: preset === 'Medium'
|
||||
? 'Balanced motion, most social media'
|
||||
: 'Energetic movement, attention-grabbing'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Duration Slider */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: '#0f172a' }}>
|
||||
Duration: {duration} seconds
|
||||
</Typography>
|
||||
<Slider
|
||||
value={duration}
|
||||
min={5}
|
||||
max={10}
|
||||
step={3}
|
||||
marks={[
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 8, label: '8s' },
|
||||
{ value: 10, label: '10s' },
|
||||
]}
|
||||
onChange={(_, val) => onDurationChange(val as Duration)}
|
||||
sx={{
|
||||
color: '#667eea',
|
||||
'& .MuiSlider-markLabel': { color: '#475569' },
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: '#475569', mt: 0.5 }}>
|
||||
Shorter videos cost less. Perfect for testing ideas before investing in longer content.
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export { GenerationSettingsPanel } from './GenerationSettingsPanel';
|
||||
export { VideoExamplesPanel } from './VideoExamplesPanel';
|
||||
export { PromptInput } from './PromptInput';
|
||||
export { VideoSettings } from './VideoSettings';
|
||||
export { ExampleVideoCard } from './ExampleVideoCard';
|
||||
export { AssetLibraryVideoCard } from './AssetLibraryVideoCard';
|
||||
export { ModelSelector } from './ModelSelector';
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { MotionPreset, AspectPreset } from './types';
|
||||
|
||||
export const motionPresets: readonly MotionPreset[] = ['Subtle', 'Medium', 'Dynamic'] as const;
|
||||
export const aspectPresets: readonly AspectPreset[] = ['9:16', '1:1', '16:9'] as const;
|
||||
|
||||
// Example prompts for content creators
|
||||
export const examplePrompts = [
|
||||
'A modern coffee shop interior with baristas crafting latte art, warm golden hour lighting streaming through large windows, customers chatting at wooden tables, cozy atmosphere, perfect for Instagram Reels',
|
||||
'Professional workspace with laptop, notebook, and coffee cup on a minimalist desk, soft natural lighting, clean modern office environment, ideal for LinkedIn posts',
|
||||
'Dynamic product showcase with rotating view, vibrant colors, smooth camera movement, energetic music vibe, perfect for YouTube Shorts and product demos',
|
||||
];
|
||||
|
||||
export const exampleNegativePrompts = [
|
||||
'blurry, low quality, distorted faces, text overlays',
|
||||
'grainy footage, poor lighting, shaky camera, watermark',
|
||||
'unprofessional, cluttered background, bad composition',
|
||||
];
|
||||
|
||||
// Input styles
|
||||
export const inputStyles = {
|
||||
outlinedInputBase: {
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
'&:hover fieldset': { borderColor: '#cbd5f5' },
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: '#7c3aed',
|
||||
boxShadow: '0 0 0 3px rgba(124, 58, 237, 0.15)',
|
||||
},
|
||||
},
|
||||
inputLabel: {
|
||||
color: '#475569',
|
||||
fontWeight: 600,
|
||||
},
|
||||
};
|
||||
|
||||
// Color constants
|
||||
export const colors = {
|
||||
primary: '#0f172a',
|
||||
muted: '#475569',
|
||||
accent: '#667eea',
|
||||
accentSecondary: '#764ba2',
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useContentAssets, type ContentAsset } from '../../../../../hooks/useContentAssets';
|
||||
import { getModelInfo } from '../models/videoModels';
|
||||
import type { Mode, Duration, Resolution, AspectPreset, MotionPreset } from '../types';
|
||||
|
||||
export const useCreateVideo = () => {
|
||||
const [mode, setMode] = useState<Mode>('t2v');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [negativePrompt, setNegativePrompt] = useState('');
|
||||
const [duration, setDuration] = useState<Duration>(8);
|
||||
const [resolution, setResolution] = useState<Resolution>('720p');
|
||||
const [aspect, setAspect] = useState<AspectPreset>('9:16');
|
||||
const [motion, setMotion] = useState<MotionPreset>('Medium');
|
||||
const [audioAttached, setAudioAttached] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>('hunyuan-video-1.5'); // Default model
|
||||
const [selectedExample, setSelectedExample] = useState<number | null>(null);
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(null);
|
||||
const [promptPlaceholderIndex, setPromptPlaceholderIndex] = useState(0);
|
||||
const [negativePlaceholderIndex, setNegativePlaceholderIndex] = useState(0);
|
||||
const [promptFocused, setPromptFocused] = useState(false);
|
||||
const [negativeFocused, setNegativeFocused] = useState(false);
|
||||
|
||||
// Fetch videos from asset library
|
||||
const { assets: libraryVideos, loading: loadingLibraryVideos } = useContentAssets({
|
||||
asset_type: 'video',
|
||||
limit: 6,
|
||||
});
|
||||
|
||||
const canGenerate = useMemo(() => prompt.trim().length > 5, [prompt]);
|
||||
|
||||
const costHint = useMemo(() => {
|
||||
// Get model-specific pricing
|
||||
const modelInfo = getModelInfo(selectedModel);
|
||||
if (modelInfo) {
|
||||
const costPerSecond = modelInfo.costPerSecond[resolution] || modelInfo.costPerSecond[Object.keys(modelInfo.costPerSecond)[0]];
|
||||
const estimate = (costPerSecond * duration).toFixed(2);
|
||||
return `Est. ~$${estimate}`;
|
||||
}
|
||||
// Fallback to default pricing
|
||||
const base = resolution === '480p' ? 0.02 : resolution === '720p' ? 0.04 : 0.06;
|
||||
const estimate = (base * duration).toFixed(2);
|
||||
return `Est. ~$${estimate}`;
|
||||
}, [duration, resolution, selectedModel]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (mode === 'i2v' && e.target.files?.length) {
|
||||
// Placeholder: in later phases, we'll upload/preview
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
return {
|
||||
// State
|
||||
mode,
|
||||
setMode,
|
||||
prompt,
|
||||
setPrompt,
|
||||
negativePrompt,
|
||||
setNegativePrompt,
|
||||
duration,
|
||||
setDuration,
|
||||
resolution,
|
||||
setResolution,
|
||||
aspect,
|
||||
setAspect,
|
||||
motion,
|
||||
setMotion,
|
||||
audioAttached,
|
||||
setAudioAttached,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
selectedExample,
|
||||
setSelectedExample,
|
||||
selectedAssetId,
|
||||
setSelectedAssetId,
|
||||
promptPlaceholderIndex,
|
||||
setPromptPlaceholderIndex,
|
||||
negativePlaceholderIndex,
|
||||
setNegativePlaceholderIndex,
|
||||
promptFocused,
|
||||
setPromptFocused,
|
||||
negativeFocused,
|
||||
setNegativeFocused,
|
||||
// Computed
|
||||
canGenerate,
|
||||
costHint,
|
||||
libraryVideos,
|
||||
loadingLibraryVideos,
|
||||
// Handlers
|
||||
handleFileSelect,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CreateVideo } from './CreateVideo';
|
||||
export { default } from './CreateVideo';
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Video Model Information for Content Creators
|
||||
*
|
||||
* Non-technical, creator-focused descriptions to help users choose the right AI model
|
||||
* for their video generation needs.
|
||||
*/
|
||||
|
||||
export interface VideoModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
bestFor: string[];
|
||||
strengths: string[];
|
||||
limitations: string[];
|
||||
durations: number[];
|
||||
resolutions: string[];
|
||||
aspectRatios: string[];
|
||||
audioSupport: boolean;
|
||||
costPerSecond: {
|
||||
[resolution: string]: number;
|
||||
};
|
||||
exampleUseCases: string[];
|
||||
tips: string[];
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const VIDEO_MODELS: VideoModelInfo[] = [
|
||||
{
|
||||
id: 'hunyuan-video-1.5',
|
||||
name: 'HunyuanVideo 1.5',
|
||||
tagline: 'Lightweight & Fast - Perfect for Quick Content',
|
||||
description: 'A lightweight model that generates high-quality videos quickly. Great for social media content, quick iterations, and when you need fast results without breaking the bank.',
|
||||
bestFor: [
|
||||
'Instagram Reels & Stories',
|
||||
'TikTok videos',
|
||||
'Quick social media content',
|
||||
'Testing ideas and concepts',
|
||||
'Budget-conscious creators'
|
||||
],
|
||||
strengths: [
|
||||
'Fast generation time',
|
||||
'Affordable pricing',
|
||||
'Good motion quality',
|
||||
'Works well for short clips',
|
||||
'Great for testing prompts'
|
||||
],
|
||||
limitations: [
|
||||
'Limited to 5-10 second videos',
|
||||
'Only 480p or 720p resolution',
|
||||
'No audio generation',
|
||||
'Best for shorter content'
|
||||
],
|
||||
durations: [5, 8, 10],
|
||||
resolutions: ['480p', '720p'],
|
||||
aspectRatios: ['16:9', '9:16'],
|
||||
audioSupport: false,
|
||||
costPerSecond: {
|
||||
'480p': 0.02,
|
||||
'720p': 0.04,
|
||||
},
|
||||
exampleUseCases: [
|
||||
'Quick product showcases for social media',
|
||||
'Story highlights and behind-the-scenes',
|
||||
'Fast-paced social media content',
|
||||
'Testing video concepts before production'
|
||||
],
|
||||
tips: [
|
||||
'Use for 5-8 second clips for best results',
|
||||
'Describe motion and camera movement clearly',
|
||||
'Mention style and mood in your prompt',
|
||||
'Perfect for Instagram and TikTok content'
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lightricks/ltx-2-pro',
|
||||
name: 'LTX-2 Pro',
|
||||
tagline: 'Production Quality with Synchronized Audio',
|
||||
description: 'Professional-grade video generation with perfectly synchronized audio. Designed for real production workflows where quality and audio-video sync matter. Creates cinematic scenes with matching sound.',
|
||||
bestFor: [
|
||||
'YouTube videos',
|
||||
'Professional marketing content',
|
||||
'Music videos',
|
||||
'Film previsualization',
|
||||
'Advertising campaigns',
|
||||
'Production workflows'
|
||||
],
|
||||
strengths: [
|
||||
'Synchronized audio generation',
|
||||
'Cinematic quality',
|
||||
'Perfect audio-video sync',
|
||||
'Production-ready output',
|
||||
'1080p native resolution',
|
||||
'Great for longer content (6-10s)'
|
||||
],
|
||||
limitations: [
|
||||
'Fixed at 1080p (no lower resolutions)',
|
||||
'Higher cost per second',
|
||||
'Longer generation time',
|
||||
'Only 6-10 second durations'
|
||||
],
|
||||
durations: [6, 8, 10],
|
||||
resolutions: ['1080p'],
|
||||
aspectRatios: ['16:9', '9:16'],
|
||||
audioSupport: true,
|
||||
costPerSecond: {
|
||||
'1080p': 0.06,
|
||||
},
|
||||
exampleUseCases: [
|
||||
'YouTube video intros and outros',
|
||||
'Product launch videos with music',
|
||||
'Music video sequences',
|
||||
'Professional marketing clips',
|
||||
'Film storyboard visualization'
|
||||
],
|
||||
tips: [
|
||||
'Describe camera movements and scene composition',
|
||||
'Mention emotional tone and atmosphere',
|
||||
'Audio is automatically generated to match motion',
|
||||
'Best for 6-8 second clips for optimal quality',
|
||||
'Perfect for professional content creation'
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'google/veo3.1',
|
||||
name: 'Google Veo 3.1',
|
||||
tagline: 'High-Quality with Flexible Options',
|
||||
description: 'Google\'s advanced video generation model that creates high-quality videos with synchronized audio. Offers flexible resolution and aspect ratio options, perfect for various content platforms.',
|
||||
bestFor: [
|
||||
'YouTube content',
|
||||
'Professional presentations',
|
||||
'Multi-platform content',
|
||||
'High-quality social media',
|
||||
'Content requiring flexibility'
|
||||
],
|
||||
strengths: [
|
||||
'720p and 1080p options',
|
||||
'Synchronized audio generation',
|
||||
'Negative prompt support',
|
||||
'Seed control for consistency',
|
||||
'Flexible aspect ratios',
|
||||
'High visual quality'
|
||||
],
|
||||
limitations: [
|
||||
'Shorter duration options (4-8s)',
|
||||
'Higher cost for 1080p',
|
||||
'No 480p option'
|
||||
],
|
||||
durations: [4, 6, 8],
|
||||
resolutions: ['720p', '1080p'],
|
||||
aspectRatios: ['16:9', '9:16'],
|
||||
audioSupport: true,
|
||||
costPerSecond: {
|
||||
'720p': 0.08,
|
||||
'1080p': 0.12,
|
||||
},
|
||||
exampleUseCases: [
|
||||
'YouTube Shorts and regular videos',
|
||||
'Professional social media content',
|
||||
'Multi-platform content creation',
|
||||
'High-quality product showcases',
|
||||
'Content requiring specific aspect ratios'
|
||||
],
|
||||
tips: [
|
||||
'Use negative prompts to exclude unwanted elements',
|
||||
'Use seed values to create consistent variations',
|
||||
'720p is great for social media, 1080p for YouTube',
|
||||
'Describe scenes with clear visual details',
|
||||
'Audio automatically matches video motion'
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get model information by ID
|
||||
*/
|
||||
export function getModelInfo(modelId: string): VideoModelInfo | undefined {
|
||||
return VIDEO_MODELS.find(m => m.id === modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended model based on use case
|
||||
*/
|
||||
export function getRecommendedModel(useCase: string): VideoModelInfo | undefined {
|
||||
const useCaseLower = useCase.toLowerCase();
|
||||
|
||||
if (useCaseLower.includes('social') || useCaseLower.includes('instagram') || useCaseLower.includes('tiktok')) {
|
||||
return VIDEO_MODELS.find(m => m.id === 'hunyuan-video-1.5');
|
||||
}
|
||||
|
||||
if (useCaseLower.includes('youtube') || useCaseLower.includes('professional') || useCaseLower.includes('production')) {
|
||||
return VIDEO_MODELS.find(m => m.id === 'lightricks/ltx-2-pro');
|
||||
}
|
||||
|
||||
if (useCaseLower.includes('flexible') || useCaseLower.includes('multi-platform')) {
|
||||
return VIDEO_MODELS.find(m => m.id === 'google/veo3.1');
|
||||
}
|
||||
|
||||
return VIDEO_MODELS[0]; // Default to first model
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare models side by side
|
||||
*/
|
||||
export function compareModels(modelIds: string[]): VideoModelInfo[] {
|
||||
return VIDEO_MODELS.filter(m => modelIds.includes(m.id));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type Mode = 't2v' | 'i2v';
|
||||
|
||||
export type MotionPreset = 'Subtle' | 'Medium' | 'Dynamic';
|
||||
export type AspectPreset = '9:16' | '1:1' | '16:9';
|
||||
export type Resolution = '480p' | '720p' | '1080p';
|
||||
export type Duration = 5 | 8 | 10;
|
||||
|
||||
export interface VideoGenerationSettings {
|
||||
mode: Mode;
|
||||
prompt: string;
|
||||
negativePrompt: string;
|
||||
duration: Duration;
|
||||
resolution: Resolution;
|
||||
aspect: AspectPreset;
|
||||
motion: MotionPreset;
|
||||
audioAttached: boolean;
|
||||
}
|
||||
|
||||
export interface ExampleVideo {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt: string;
|
||||
description: string;
|
||||
price: string;
|
||||
eta: string;
|
||||
provider: string;
|
||||
video: string;
|
||||
platform: string;
|
||||
useCase: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ExampleVideo, AspectPreset } from '../types';
|
||||
import type { ContentAsset } from '../../../../../hooks/useContentAssets';
|
||||
import { aspectPresets } from '../constants';
|
||||
|
||||
export const handleExampleClick = (
|
||||
index: number,
|
||||
example: ExampleVideo,
|
||||
setPrompt: (value: string) => void,
|
||||
setAspect: (value: AspectPreset) => void,
|
||||
setSelectedExample: (index: number | null) => void,
|
||||
setSelectedAssetId: (id: number | null) => void
|
||||
) => {
|
||||
setSelectedExample(index);
|
||||
setSelectedAssetId(null);
|
||||
setPrompt(example.prompt);
|
||||
// Set appropriate settings based on example
|
||||
if (example.platform === 'Instagram' || example.platform === 'YouTube') {
|
||||
setAspect('9:16');
|
||||
} else if (example.platform === 'LinkedIn') {
|
||||
setAspect('16:9');
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAssetClick = (
|
||||
asset: ContentAsset,
|
||||
setPrompt: (value: string) => void,
|
||||
setAspect: (value: AspectPreset) => void,
|
||||
setResolution: (value: '480p' | '720p' | '1080p') => void,
|
||||
setSelectedAssetId: (id: number | null) => void,
|
||||
setSelectedExample: (index: number | null) => void
|
||||
) => {
|
||||
setSelectedAssetId(asset.id);
|
||||
setSelectedExample(null);
|
||||
// Use prompt from asset if available, otherwise use title or description
|
||||
if (asset.prompt) {
|
||||
setPrompt(asset.prompt);
|
||||
} else if (asset.title) {
|
||||
setPrompt(asset.title);
|
||||
} else if (asset.description) {
|
||||
setPrompt(asset.description);
|
||||
}
|
||||
// Try to extract settings from metadata
|
||||
if (asset.asset_metadata) {
|
||||
if (asset.asset_metadata.aspect_ratio || asset.asset_metadata.aspect) {
|
||||
const aspectValue = asset.asset_metadata.aspect_ratio || asset.asset_metadata.aspect;
|
||||
if (aspectPresets.includes(aspectValue as any)) {
|
||||
setAspect(aspectValue as AspectPreset);
|
||||
}
|
||||
}
|
||||
if (asset.asset_metadata.resolution) {
|
||||
const res = asset.asset_metadata.resolution.toLowerCase();
|
||||
if (res.includes('480')) setResolution('480p');
|
||||
else if (res.includes('720')) setResolution('720p');
|
||||
else if (res.includes('1080')) setResolution('1080p');
|
||||
}
|
||||
}
|
||||
};
|
||||
20
frontend/src/components/VideoStudio/modules/EditVideo.tsx
Normal file
20
frontend/src/components/VideoStudio/modules/EditVideo.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ModulePlaceholder from '../ModulePlaceholder';
|
||||
|
||||
export const EditVideo: React.FC = () => {
|
||||
return (
|
||||
<ModulePlaceholder
|
||||
title="Edit Studio"
|
||||
subtitle="Trim, replace, captions"
|
||||
status="coming soon"
|
||||
description="Non-destructive trims, speed changes, stabilization, background replace, object/face swap, captions/subtitles."
|
||||
bullets={[
|
||||
'Use cases: polish social clips, remove sections, localize with captions',
|
||||
'Planned: timeline editor, region/face selection, auto-captions',
|
||||
'Guardrails: duration caps per tier, cost shown before edits run',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditVideo;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export from the EnhanceVideo component
|
||||
export { EnhanceVideo } from './EnhanceVideo/EnhanceVideo';
|
||||
export { default } from './EnhanceVideo/EnhanceVideo';
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useEnhanceVideo } from './hooks/useEnhanceVideo';
|
||||
import { VideoUpload, EnhancementSettings } from './components';
|
||||
import { aiApiClient } from '../../../../api/client';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
|
||||
const EnhanceVideo: React.FC = () => {
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
targetResolution,
|
||||
enhancementType,
|
||||
setVideoFile,
|
||||
setTargetResolution,
|
||||
setEnhancementType,
|
||||
canEnhance,
|
||||
costHint,
|
||||
} = useEnhanceVideo();
|
||||
|
||||
const [enhancing, setEnhancing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number } | null>(null);
|
||||
const [progressInterval, setProgressInterval] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup progress interval on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
};
|
||||
}, [progressInterval]);
|
||||
|
||||
const handleEnhance = async () => {
|
||||
if (!videoFile) return;
|
||||
|
||||
setEnhancing(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
setStatusMessage('Starting video enhancement...');
|
||||
|
||||
try {
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
formData.append('file', videoFile);
|
||||
formData.append('enhancement_type', enhancementType);
|
||||
formData.append('target_resolution', targetResolution);
|
||||
formData.append('provider', 'wavespeed');
|
||||
formData.append('model', 'flashvsr');
|
||||
|
||||
// Submit enhancement request
|
||||
setStatusMessage('Uploading video...');
|
||||
const response = await aiApiClient.post('/api/video-studio/enhance', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const uploadProgress = Math.round((progressEvent.loaded * 20) / progressEvent.total);
|
||||
setProgress(uploadProgress);
|
||||
setStatusMessage(`Uploading video... ${uploadProgress}%`);
|
||||
}
|
||||
},
|
||||
timeout: 600000, // 10 minutes timeout for long videos
|
||||
});
|
||||
|
||||
setProgress(30);
|
||||
setStatusMessage('Processing video with FlashVSR... This may take a few minutes...');
|
||||
|
||||
// FlashVSR processing can take 3-20 seconds per 1 second of video
|
||||
// Simulate progress updates while waiting for response
|
||||
let simulatedProgress = 30;
|
||||
const interval = setInterval(() => {
|
||||
simulatedProgress = Math.min(90, simulatedProgress + 5);
|
||||
setProgress(simulatedProgress);
|
||||
setStatusMessage(`Processing... ${simulatedProgress}% (This may take several minutes for long videos)`);
|
||||
}, 2000);
|
||||
setProgressInterval(interval);
|
||||
|
||||
try {
|
||||
if (response.data.success) {
|
||||
clearInterval(interval);
|
||||
setProgressInterval(null);
|
||||
setEnhancing(false);
|
||||
setResult(response.data);
|
||||
setProgress(100);
|
||||
setStatusMessage('Video enhancement complete!');
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setProgressInterval(null);
|
||||
throw new Error(response.data.error || 'Enhancement failed');
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(interval);
|
||||
setProgressInterval(null);
|
||||
throw err;
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
setProgressInterval(null);
|
||||
}
|
||||
setEnhancing(false);
|
||||
setProgress(0);
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to enhance video');
|
||||
setStatusMessage('Enhancement failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setEnhancing(false);
|
||||
setProgress(0);
|
||||
setStatusMessage('');
|
||||
setError(null);
|
||||
setResult(null);
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
setProgressInterval(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Enhance Studio',
|
||||
subtitle: 'Upscale your videos to higher resolutions with FlashVSR. Improve video quality, restore clarity, and prepare content for professional delivery.',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={3}>
|
||||
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
|
||||
|
||||
<EnhancementSettings
|
||||
targetResolution={targetResolution}
|
||||
enhancementType={enhancementType}
|
||||
costHint={costHint}
|
||||
onTargetResolutionChange={setTargetResolution}
|
||||
onEnhancementTypeChange={setEnhancementType}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={enhancing ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={handleEnhance}
|
||||
disabled={!canEnhance || enhancing}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{enhancing ? 'Enhancing...' : 'Enhance Video'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{enhancing && (
|
||||
<Box>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Results */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
{result ? (
|
||||
// Side-by-side comparison view
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Comparison
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview || ''}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, backgroundColor: '#f8fafc' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>
|
||||
Original Video
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #10b981',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, backgroundColor: '#f0fdf4' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#059669' }}>
|
||||
Enhanced ({targetResolution.toUpperCase()})
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
download
|
||||
sx={{
|
||||
backgroundColor: '#10b981',
|
||||
'&:hover': {
|
||||
backgroundColor: '#059669',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download Enhanced Video
|
||||
</Button>
|
||||
<Button variant="outlined" fullWidth onClick={handleReset}>
|
||||
Enhance Another
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f0fdf4',
|
||||
border: '1px solid #10b981',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#065f46' }}>
|
||||
Enhancement Complete!
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Cost: ${result.cost.toFixed(4)} | Resolution: {targetResolution.toUpperCase()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
) : videoPreview ? (
|
||||
// Original video preview
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Original Video Preview
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video and select enhancement options to get started
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video to see preview
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Your enhanced video will appear here
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
|
||||
About FlashVSR
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
FlashVSR is the most advanced video upscaler, delivering:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Temporal consistency for stable motion
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Detail reconstruction for fine textures
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Artifact cleanup for compression blocks
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Natural look without overprocessing
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhanceVideo;
|
||||
export { EnhanceVideo };
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, Chip, Paper } from '@mui/material';
|
||||
import HighQualityIcon from '@mui/icons-material/HighQuality';
|
||||
import type { EnhancementResolution, EnhancementType } from '../hooks/useEnhanceVideo';
|
||||
|
||||
interface EnhancementSettingsProps {
|
||||
targetResolution: EnhancementResolution;
|
||||
enhancementType: EnhancementType;
|
||||
costHint: string;
|
||||
onTargetResolutionChange: (resolution: EnhancementResolution) => void;
|
||||
onEnhancementTypeChange: (type: EnhancementType) => void;
|
||||
}
|
||||
|
||||
export const EnhancementSettings: React.FC<EnhancementSettingsProps> = ({
|
||||
targetResolution,
|
||||
enhancementType,
|
||||
costHint,
|
||||
onTargetResolutionChange,
|
||||
onEnhancementTypeChange,
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<HighQualityIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Enhancement Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Enhancement Type
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={enhancementType}
|
||||
onChange={(e) => onEnhancementTypeChange(e.target.value as EnhancementType)}
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="upscale">Upscale (FlashVSR)</MenuItem>
|
||||
{/* Future enhancement types */}
|
||||
{/* <MenuItem value="stabilize">Stabilize</MenuItem>
|
||||
<MenuItem value="colorize">Colorize</MenuItem> */}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
FlashVSR upscales videos with temporal consistency and detail reconstruction
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Target Resolution
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Resolution</InputLabel>
|
||||
<Select
|
||||
value={targetResolution}
|
||||
onChange={(e) => onTargetResolutionChange(e.target.value as EnhancementResolution)}
|
||||
label="Resolution"
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="720p">720p HD ($0.06/5s)</MenuItem>
|
||||
<MenuItem value="1080p">1080p Full HD ($0.09/5s)</MenuItem>
|
||||
<MenuItem value="2k">2K ($0.12/5s)</MenuItem>
|
||||
<MenuItem value="4k">4K Ultra HD ($0.16/5s)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Higher resolution = better quality but higher cost
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Estimated Cost:
|
||||
</Typography>
|
||||
<Chip
|
||||
label={costHint}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
FlashVSR pricing: $0.012-$0.032/second (based on resolution)
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Minimum charge: 5 seconds | Maximum: 10 minutes (600 seconds)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Upload Video
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{videoPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload a video
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP4, WebM up to 500MB (max 10 minutes)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { EnhancementSettings } from './EnhancementSettings';
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { aiApiClient } from '../../../../../api/client';
|
||||
|
||||
export type EnhancementResolution = '720p' | '1080p' | '2k' | '4k';
|
||||
export type EnhancementType = 'upscale';
|
||||
|
||||
export const useEnhanceVideo = () => {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [targetResolution, setTargetResolution] = useState<EnhancementResolution>('1080p');
|
||||
const [enhancementType, setEnhancementType] = useState<EnhancementType>('upscale');
|
||||
const [estimatedDuration, setEstimatedDuration] = useState<number>(10.0);
|
||||
const [costEstimate, setCostEstimate] = useState<number | null>(null);
|
||||
|
||||
// Update preview when file changes
|
||||
useEffect(() => {
|
||||
if (videoFile) {
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
|
||||
// Rough estimate: 1MB ≈ 1 second at 1080p
|
||||
// In production, you'd parse the video to get actual duration
|
||||
const estimated = Math.max(5, videoFile.size / (1024 * 1024));
|
||||
setEstimatedDuration(estimated);
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
setEstimatedDuration(10.0);
|
||||
}
|
||||
}, [videoFile]);
|
||||
|
||||
// Fetch cost estimate when resolution or duration changes
|
||||
useEffect(() => {
|
||||
const fetchCostEstimate = async () => {
|
||||
if (!videoFile || estimatedDuration < 5) {
|
||||
setCostEstimate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('target_resolution', targetResolution);
|
||||
formData.append('estimated_duration', estimatedDuration.toString());
|
||||
|
||||
const response = await aiApiClient.post('/api/video-studio/enhance/estimate-cost', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.estimated_cost) {
|
||||
setCostEstimate(response.data.estimated_cost);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cost estimate:', err);
|
||||
// Fallback to client-side calculation
|
||||
const pricing = {
|
||||
'720p': 0.06 / 5,
|
||||
'1080p': 0.09 / 5,
|
||||
'2k': 0.12 / 5,
|
||||
'4k': 0.16 / 5,
|
||||
};
|
||||
const costPerSecond = pricing[targetResolution];
|
||||
setCostEstimate(Math.max(5.0, estimatedDuration) * costPerSecond);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCostEstimate();
|
||||
}, [videoFile, targetResolution, estimatedDuration]);
|
||||
|
||||
// Cost hint for display
|
||||
const costHint = useMemo(() => {
|
||||
if (!videoFile) return 'Upload a video to see cost estimate';
|
||||
|
||||
if (costEstimate !== null) {
|
||||
return `Est. ~$${costEstimate.toFixed(2)} (${estimatedDuration.toFixed(0)}s @ ${targetResolution})`;
|
||||
}
|
||||
|
||||
// Fallback calculation
|
||||
const pricing = {
|
||||
'720p': 0.06 / 5,
|
||||
'1080p': 0.09 / 5,
|
||||
'2k': 0.12 / 5,
|
||||
'4k': 0.16 / 5,
|
||||
};
|
||||
const costPerSecond = pricing[targetResolution];
|
||||
const estimatedCost = Math.max(5.0, estimatedDuration) * costPerSecond;
|
||||
return `Est. ~$${estimatedCost.toFixed(2)} (${estimatedDuration.toFixed(0)}s @ ${targetResolution})`;
|
||||
}, [videoFile, targetResolution, estimatedDuration, costEstimate]);
|
||||
|
||||
const canEnhance = useMemo(() => {
|
||||
return videoFile !== null;
|
||||
}, [videoFile]);
|
||||
|
||||
const handleVideoSelect = useCallback((file: File | null) => {
|
||||
setVideoFile(file);
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setVideoPreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
videoFile,
|
||||
videoPreview,
|
||||
targetResolution,
|
||||
enhancementType,
|
||||
estimatedDuration,
|
||||
costEstimate,
|
||||
// Setters
|
||||
setVideoFile: handleVideoSelect,
|
||||
setTargetResolution,
|
||||
setEnhancementType,
|
||||
// Computed
|
||||
canEnhance,
|
||||
costHint,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EnhanceVideo } from './EnhanceVideo';
|
||||
export { default } from './EnhanceVideo';
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export from the ExtendVideo component
|
||||
export { ExtendVideo } from './ExtendVideo/ExtendVideo';
|
||||
export { default } from './ExtendVideo/ExtendVideo';
|
||||
@@ -0,0 +1,373 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useExtendVideo } from './hooks/useExtendVideo';
|
||||
import { VideoUpload, AudioUpload, ExtendSettings } from './components';
|
||||
import { aiApiClient } from '../../../../api/client';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
|
||||
const ExtendVideo: React.FC = () => {
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
audioFile,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
model,
|
||||
resolution,
|
||||
duration,
|
||||
enablePromptExpansion,
|
||||
generateAudio,
|
||||
cameraFixed,
|
||||
seed,
|
||||
setVideoFile,
|
||||
setAudioFile,
|
||||
setPrompt,
|
||||
setNegativePrompt,
|
||||
setModel,
|
||||
setResolution,
|
||||
setDuration,
|
||||
setEnablePromptExpansion,
|
||||
setGenerateAudio,
|
||||
setCameraFixed,
|
||||
setSeed,
|
||||
canExtend,
|
||||
costHint,
|
||||
} = useExtendVideo();
|
||||
|
||||
const [extending, setExtending] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number; duration: number } | null>(null);
|
||||
|
||||
const handleExtend = async () => {
|
||||
if (!videoFile || !prompt.trim()) return;
|
||||
|
||||
setExtending(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
setStatusMessage('Starting video extension...');
|
||||
|
||||
try {
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
formData.append('file', videoFile);
|
||||
formData.append('prompt', prompt);
|
||||
formData.append('model', model);
|
||||
if (negativePrompt && model === 'wan-2.5') {
|
||||
formData.append('negative_prompt', negativePrompt);
|
||||
}
|
||||
if (audioFile && model === 'wan-2.5') {
|
||||
formData.append('audio', audioFile);
|
||||
}
|
||||
formData.append('resolution', resolution);
|
||||
formData.append('duration', duration.toString());
|
||||
if (model === 'wan-2.5') {
|
||||
formData.append('enable_prompt_expansion', enablePromptExpansion.toString());
|
||||
}
|
||||
if (model === 'seedance-1.5-pro') {
|
||||
formData.append('generate_audio', generateAudio.toString());
|
||||
formData.append('camera_fixed', cameraFixed.toString());
|
||||
}
|
||||
if (seed !== null) {
|
||||
formData.append('seed', seed.toString());
|
||||
}
|
||||
|
||||
// Submit extension request
|
||||
setStatusMessage('Uploading video...');
|
||||
const response = await aiApiClient.post('/api/video-studio/extend', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const uploadProgress = Math.round((progressEvent.loaded * 30) / progressEvent.total);
|
||||
setProgress(uploadProgress);
|
||||
setStatusMessage(`Uploading... ${uploadProgress}%`);
|
||||
}
|
||||
},
|
||||
timeout: 600000, // 10 minutes timeout
|
||||
});
|
||||
|
||||
setProgress(40);
|
||||
setStatusMessage('Extending video with WAN 2.5... This may take a few minutes...');
|
||||
|
||||
if (response.data.success) {
|
||||
setExtending(false);
|
||||
setResult(response.data);
|
||||
setProgress(100);
|
||||
setStatusMessage('Video extension complete!');
|
||||
} else {
|
||||
throw new Error(response.data.error || 'Extension failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setExtending(false);
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to extend video');
|
||||
setStatusMessage('Extension failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setExtending(false);
|
||||
setProgress(0);
|
||||
setStatusMessage('');
|
||||
setError(null);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Extend Studio',
|
||||
subtitle: 'Extend your short video clips into longer videos with motion and audio continuity. Perfect for creating seamless extended scenes from existing footage.',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={3}>
|
||||
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
|
||||
|
||||
{model === 'wan-2.5' && (
|
||||
<AudioUpload audioPreview={null} onAudioSelect={setAudioFile} />
|
||||
)}
|
||||
|
||||
<ExtendSettings
|
||||
model={model}
|
||||
prompt={prompt}
|
||||
negativePrompt={negativePrompt}
|
||||
resolution={resolution}
|
||||
duration={duration}
|
||||
enablePromptExpansion={enablePromptExpansion}
|
||||
generateAudio={generateAudio}
|
||||
cameraFixed={cameraFixed}
|
||||
seed={seed}
|
||||
costHint={costHint}
|
||||
onModelChange={setModel}
|
||||
onPromptChange={setPrompt}
|
||||
onNegativePromptChange={setNegativePrompt}
|
||||
onResolutionChange={setResolution}
|
||||
onDurationChange={setDuration}
|
||||
onEnablePromptExpansionChange={setEnablePromptExpansion}
|
||||
onGenerateAudioChange={setGenerateAudio}
|
||||
onCameraFixedChange={setCameraFixed}
|
||||
onSeedChange={setSeed}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={extending ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={handleExtend}
|
||||
disabled={!canExtend || extending}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{extending ? 'Extending...' : 'Extend Video'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{extending && (
|
||||
<Box>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Alert
|
||||
severity="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
action={
|
||||
<Button size="small" onClick={handleReset}>
|
||||
Extend Another
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Video extended successfully! Cost: ${result.cost.toFixed(2)} ({result.duration}s)
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Results */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Typography>
|
||||
|
||||
{videoPreview && !result && (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Original Video
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #3b82f6',
|
||||
backgroundColor: '#000',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f0f9ff' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Extended Video ({result.duration}s @ {resolution.toUpperCase()})
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
download
|
||||
>
|
||||
Download Extended Video
|
||||
</Button>
|
||||
<Button variant="outlined" fullWidth onClick={handleReset}>
|
||||
Extend Another Video
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!videoPreview && !result && (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video to see preview
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
|
||||
About Video Extension
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
WAN 2.5 Video-Extend creates seamless extensions of your videos with:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Motion continuity for smooth transitions
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Audio synchronization when audio is provided (3-30s, ≤15MB)
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Natural scene continuation with preserved style
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Multilingual support (Chinese and English prompts)
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Auto-generated audio if no audio is provided
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block', fontStyle: 'italic' }}>
|
||||
Note: If audio is longer than video duration, only the first segment is used. If audio is shorter, remaining video plays silently.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtendVideo;
|
||||
export { ExtendVideo };
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import AudioFileIcon from '@mui/icons-material/AudioFile';
|
||||
|
||||
interface AudioUploadProps {
|
||||
audioPreview: string | null;
|
||||
onAudioSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const AudioUpload: React.FC<AudioUploadProps> = ({
|
||||
audioPreview,
|
||||
onAudioSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate audio file
|
||||
if (!file.type.startsWith('audio/')) {
|
||||
alert('Please select an audio file');
|
||||
return;
|
||||
}
|
||||
// Validate audio file size (max 15MB per WAN 2.5 documentation)
|
||||
if (file.size > 15 * 1024 * 1024) {
|
||||
alert('Audio file must be less than 15MB (per WAN 2.5 requirements)');
|
||||
return;
|
||||
}
|
||||
onAudioSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onAudioSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Optional Audio Guide
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{audioPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
src={audioPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1} alignItems="center">
|
||||
<AudioFileIcon sx={{ fontSize: 36, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload audio (optional)
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP3, WAV up to 15MB (3-30s recommended)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,429 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Stack, Typography, FormControl, InputLabel, Select, MenuItem, TextField, FormControlLabel, Switch, Chip, Button, CircularProgress, Tooltip, Paper } from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import type { ExtendResolution, ExtendModel } from '../hooks/useExtendVideo';
|
||||
import { optimizePrompt } from '../../../../../api/videoStudioApi';
|
||||
|
||||
interface ExtendSettingsProps {
|
||||
model: ExtendModel;
|
||||
prompt: string;
|
||||
negativePrompt: string;
|
||||
resolution: ExtendResolution;
|
||||
duration: number;
|
||||
enablePromptExpansion: boolean;
|
||||
generateAudio: boolean;
|
||||
cameraFixed: boolean;
|
||||
seed: number | null;
|
||||
costHint: string;
|
||||
onModelChange: (model: ExtendModel) => void;
|
||||
onPromptChange: (value: string) => void;
|
||||
onNegativePromptChange: (value: string) => void;
|
||||
onResolutionChange: (resolution: ExtendResolution) => void;
|
||||
onDurationChange: (duration: number) => void;
|
||||
onEnablePromptExpansionChange: (enabled: boolean) => void;
|
||||
onGenerateAudioChange: (enabled: boolean) => void;
|
||||
onCameraFixedChange: (enabled: boolean) => void;
|
||||
onSeedChange: (seed: number | null) => void;
|
||||
}
|
||||
|
||||
export const ExtendSettings: React.FC<ExtendSettingsProps> = ({
|
||||
model,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
resolution,
|
||||
duration,
|
||||
enablePromptExpansion,
|
||||
generateAudio,
|
||||
cameraFixed,
|
||||
seed,
|
||||
costHint,
|
||||
onModelChange,
|
||||
onPromptChange,
|
||||
onNegativePromptChange,
|
||||
onResolutionChange,
|
||||
onDurationChange,
|
||||
onEnablePromptExpansionChange,
|
||||
onGenerateAudioChange,
|
||||
onCameraFixedChange,
|
||||
onSeedChange,
|
||||
}) => {
|
||||
const [enhancing, setEnhancing] = useState(false);
|
||||
|
||||
const handleEnhancePrompt = async () => {
|
||||
if (!prompt.trim() || enhancing) return;
|
||||
|
||||
setEnhancing(true);
|
||||
try {
|
||||
const result = await optimizePrompt({
|
||||
text: prompt,
|
||||
mode: 'video',
|
||||
style: 'default',
|
||||
});
|
||||
|
||||
if (result.success && result.optimized_prompt) {
|
||||
onPromptChange(result.optimized_prompt);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to enhance prompt:', error);
|
||||
} finally {
|
||||
setEnhancing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Model-specific options
|
||||
const isWan22Spicy = model === 'wan-2.2-spicy';
|
||||
const isSeedance = model === 'seedance-1.5-pro';
|
||||
const isWan25 = model === 'wan-2.5';
|
||||
|
||||
const availableResolutions: ExtendResolution[] = (isWan22Spicy || isSeedance)
|
||||
? ['480p', '720p']
|
||||
: ['480p', '720p', '1080p'];
|
||||
|
||||
const availableDurations = isWan22Spicy
|
||||
? [5, 8]
|
||||
: isSeedance
|
||||
? [4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
: [3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
AI Model
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={model}
|
||||
onChange={(e) => onModelChange(e.target.value as ExtendModel)}
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="wan-2.5">WAN 2.5 (Full Featured)</MenuItem>
|
||||
<MenuItem value="wan-2.2-spicy">WAN 2.2 Spicy (Fast & Affordable)</MenuItem>
|
||||
<MenuItem value="seedance-1.5-pro">Seedance 1.5 Pro (Advanced)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 1,
|
||||
p: 1.5,
|
||||
backgroundColor: isWan22Spicy ? '#fef3c7' : isSeedance ? '#f3e8ff' : '#eff6ff',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, color: '#0f172a', mb: 0.5 }}>
|
||||
{isWan22Spicy ? 'WAN 2.2 Spicy' : isSeedance ? 'Seedance 1.5 Pro' : 'WAN 2.5'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{isWan22Spicy
|
||||
? 'Fast and affordable: 480p/720p, 5 or 8 seconds. $0.03-0.06/s pricing. Perfect for quick extensions with expressive visuals.'
|
||||
: isSeedance
|
||||
? `Advanced features: 480p/720p, 4-12 seconds, auto audio generation, camera control. ${generateAudio ? '$0.024-0.052' : '$0.012-0.026'}/s pricing. Ideal for ad creatives and short dramas.`
|
||||
: 'Full featured: 480p/720p/1080p, 3-10 seconds, audio upload, negative prompts, and prompt expansion. $0.05-0.15/s pricing.'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Extension Prompt *
|
||||
</Typography>
|
||||
<Tooltip title="AI will optimize your prompt for better video extension results, improving visual clarity, composition, and motion continuity.">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={enhancing ? <CircularProgress size={16} /> : <AutoAwesomeIcon />}
|
||||
onClick={handleEnhancePrompt}
|
||||
disabled={!prompt.trim() || enhancing}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
py: 0.5,
|
||||
px: 1.5,
|
||||
borderColor: '#3b82f6',
|
||||
color: '#3b82f6',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{enhancing ? 'Enhancing...' : 'Enhance Instructions'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="Describe how you want to extend the video. For example: 'Continue the motion smoothly', 'Add a zoom out effect', 'Extend the scene with the character walking forward'"
|
||||
value={prompt}
|
||||
onChange={e => onPromptChange(e.target.value)}
|
||||
required
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#0f172a',
|
||||
'&::placeholder': {
|
||||
color: '#64748b',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Describe the motion, scene, or effect you want for the extended portion. Supports Chinese and English prompts.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isWan25 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Negative Prompt (Optional)
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="What to avoid in the extended video..."
|
||||
value={negativePrompt}
|
||||
onChange={e => onNegativePromptChange(e.target.value)}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#fff',
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isSeedance && (
|
||||
<>
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={generateAudio}
|
||||
onChange={(e) => onGenerateAudioChange(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Generate Audio"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
Automatically generate audio for the extended video
|
||||
{generateAudio
|
||||
? ' (Adds ~$0.012-0.026/s to cost)'
|
||||
: ' (Saves ~$0.012-0.026/s)'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={cameraFixed}
|
||||
onChange={(e) => onCameraFixedChange(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Fix Camera Position"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
Keep camera position fixed for stable shots
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Resolution
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={resolution}
|
||||
onChange={(e) => onResolutionChange(e.target.value as ExtendResolution)}
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{availableResolutions.map((res) => {
|
||||
// Model-specific pricing
|
||||
let price: string;
|
||||
if (isWan22Spicy) {
|
||||
price = res === '480p' ? '$0.03' : '$0.06';
|
||||
} else if (isSeedance) {
|
||||
// Seedance pricing varies by audio generation
|
||||
if (generateAudio) {
|
||||
price = res === '480p' ? '$0.024' : '$0.052';
|
||||
} else {
|
||||
price = res === '480p' ? '$0.012' : '$0.026';
|
||||
}
|
||||
} else {
|
||||
price = res === '480p' ? '$0.05' : res === '720p' ? '$0.10' : '$0.15';
|
||||
}
|
||||
return (
|
||||
<MenuItem key={res} value={res}>
|
||||
{res} ({price}/s)
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Extension Duration
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<Select
|
||||
value={duration}
|
||||
onChange={(e) => onDurationChange(Number(e.target.value))}
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{availableDurations.map((d) => (
|
||||
<MenuItem key={d} value={d}>
|
||||
{d} seconds
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
How long should the extended portion be?
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isWan25 && (
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={enablePromptExpansion}
|
||||
onChange={(e) => onEnablePromptExpansionChange(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Enable Prompt Expansion"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
Automatically enhance your prompt for better results
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Seed (Optional)
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
placeholder="Leave empty for random"
|
||||
value={seed ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onSeedChange(value === '' ? null : Number(value));
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: '#fff',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'& fieldset': { borderColor: '#e2e8f0' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Use the same seed to reproduce similar results
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Estimated Cost:
|
||||
</Typography>
|
||||
<Chip
|
||||
label={costHint}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Upload Video to Extend
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{videoPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload a video
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP4, WebM up to 500MB
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { AudioUpload } from './AudioUpload';
|
||||
export { ExtendSettings } from './ExtendSettings';
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
|
||||
export type ExtendResolution = '480p' | '720p' | '1080p';
|
||||
export type ExtendModel = 'wan-2.5' | 'wan-2.2-spicy' | 'seedance-1.5-pro';
|
||||
|
||||
export const useExtendVideo = () => {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [audioPreview, setAudioPreview] = useState<string | null>(null);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [negativePrompt, setNegativePrompt] = useState('');
|
||||
const [model, setModel] = useState<ExtendModel>('wan-2.5');
|
||||
const [resolution, setResolution] = useState<ExtendResolution>('720p');
|
||||
const [duration, setDuration] = useState<number>(5);
|
||||
const [enablePromptExpansion, setEnablePromptExpansion] = useState(false);
|
||||
const [generateAudio, setGenerateAudio] = useState<boolean>(true); // Seedance 1.5 Pro only
|
||||
const [cameraFixed, setCameraFixed] = useState<boolean>(false); // Seedance 1.5 Pro only
|
||||
const [seed, setSeed] = useState<number | null>(null);
|
||||
|
||||
// Adjust resolution and duration when model changes
|
||||
const handleModelChange = useCallback((newModel: ExtendModel) => {
|
||||
setModel(newModel);
|
||||
// Adjust resolution if needed
|
||||
if ((newModel === 'wan-2.2-spicy' || newModel === 'seedance-1.5-pro') && resolution === '1080p') {
|
||||
setResolution('720p');
|
||||
}
|
||||
// Adjust duration if needed
|
||||
if (newModel === 'wan-2.2-spicy' && duration !== 5 && duration !== 8) {
|
||||
setDuration(5);
|
||||
} else if (newModel === 'seedance-1.5-pro' && (duration < 4 || duration > 12)) {
|
||||
setDuration(5); // Default to 5s for Seedance
|
||||
}
|
||||
}, [resolution, duration]);
|
||||
|
||||
// Cost estimation (model-specific pricing)
|
||||
const costHint = useMemo(() => {
|
||||
if (!videoFile) return 'Upload a video to see cost estimate';
|
||||
|
||||
// Model-specific pricing
|
||||
let pricing: { [key: string]: number };
|
||||
if (model === 'wan-2.2-spicy') {
|
||||
// WAN 2.2 Spicy: $0.03/s (480p), $0.06/s (720p)
|
||||
pricing = {
|
||||
'480p': 0.03,
|
||||
'720p': 0.06,
|
||||
};
|
||||
} else if (model === 'seedance-1.5-pro') {
|
||||
// Seedance 1.5 Pro pricing varies by audio generation
|
||||
// With audio: $0.024/s (480p), $0.052/s (720p)
|
||||
// Without audio: $0.012/s (480p), $0.026/s (720p)
|
||||
if (generateAudio) {
|
||||
pricing = {
|
||||
'480p': 0.024,
|
||||
'720p': 0.052,
|
||||
};
|
||||
} else {
|
||||
pricing = {
|
||||
'480p': 0.012,
|
||||
'720p': 0.026,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// WAN 2.5: $0.05/s (480p), $0.10/s (720p), $0.15/s (1080p)
|
||||
pricing = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.10,
|
||||
'1080p': 0.15,
|
||||
};
|
||||
}
|
||||
|
||||
const costPerSecond = pricing[resolution as keyof typeof pricing] || pricing['720p'];
|
||||
const estimatedCost = (costPerSecond * duration).toFixed(2);
|
||||
|
||||
return `Est. ~$${estimatedCost} (${duration}s @ ${resolution})`;
|
||||
}, [videoFile, model, resolution, duration, generateAudio]);
|
||||
|
||||
const canExtend = useMemo(() => {
|
||||
return videoFile !== null && prompt.trim().length > 0;
|
||||
}, [videoFile, prompt]);
|
||||
|
||||
const handleVideoSelect = useCallback((file: File | null) => {
|
||||
setVideoFile(file);
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setVideoPreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAudioSelect = useCallback((file: File | null) => {
|
||||
setAudioFile(file);
|
||||
if (file) {
|
||||
// Validate audio file
|
||||
if (!file.type.startsWith('audio/')) {
|
||||
alert('Please select an audio file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
alert('Audio file must be less than 50MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setAudioPreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setAudioPreview(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
videoFile,
|
||||
videoPreview,
|
||||
audioFile,
|
||||
audioPreview,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
model,
|
||||
resolution,
|
||||
duration,
|
||||
enablePromptExpansion,
|
||||
generateAudio,
|
||||
cameraFixed,
|
||||
seed,
|
||||
// Setters
|
||||
setVideoFile: handleVideoSelect,
|
||||
setAudioFile: handleAudioSelect,
|
||||
setPrompt,
|
||||
setNegativePrompt,
|
||||
setModel: handleModelChange,
|
||||
setResolution,
|
||||
setDuration,
|
||||
setEnablePromptExpansion,
|
||||
setGenerateAudio,
|
||||
setCameraFixed,
|
||||
setSeed,
|
||||
// Computed
|
||||
canExtend,
|
||||
costHint,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExtendVideo } from './ExtendVideo';
|
||||
export { default } from './ExtendVideo';
|
||||
@@ -0,0 +1,332 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useFaceSwap } from './hooks/useFaceSwap';
|
||||
import { ImageUpload, VideoUpload, SettingsPanel, ModelSelector } from './components';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
|
||||
const FaceSwap: React.FC = () => {
|
||||
const {
|
||||
imageFile,
|
||||
imagePreview,
|
||||
videoFile,
|
||||
videoPreview,
|
||||
model,
|
||||
prompt,
|
||||
resolution,
|
||||
seed,
|
||||
targetGender,
|
||||
targetIndex,
|
||||
swapping,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
setImageFile,
|
||||
setVideoFile,
|
||||
setModel,
|
||||
setPrompt,
|
||||
setResolution,
|
||||
setSeed,
|
||||
setTargetGender,
|
||||
setTargetIndex,
|
||||
canSwap,
|
||||
costHint,
|
||||
swapFace,
|
||||
reset,
|
||||
} = useFaceSwap();
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Face Swap Studio',
|
||||
subtitle: 'Swap faces in videos using AI. Choose between MoCha (premium character replacement) or Video Face Swap (affordable multi-face support).',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={3}>
|
||||
<ModelSelector selectedModel={model} onModelChange={setModel} />
|
||||
|
||||
<ImageUpload imagePreview={imagePreview} onImageSelect={setImageFile} />
|
||||
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
|
||||
|
||||
{imageFile && videoFile && (
|
||||
<SettingsPanel
|
||||
model={model}
|
||||
prompt={prompt}
|
||||
resolution={resolution}
|
||||
seed={seed}
|
||||
targetGender={targetGender}
|
||||
targetIndex={targetIndex}
|
||||
onPromptChange={setPrompt}
|
||||
onResolutionChange={setResolution}
|
||||
onSeedChange={setSeed}
|
||||
onTargetGenderChange={setTargetGender}
|
||||
onTargetIndexChange={setTargetIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={swapping ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={swapFace}
|
||||
disabled={!canSwap || swapping}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{swapping ? 'Swapping Face...' : 'Swap Face'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{imageFile && videoFile && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Cost:</strong> {costHint}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{model === 'mocha'
|
||||
? 'Minimum charge: 5 seconds | Maximum billed: 120 seconds'
|
||||
: 'Minimum charge: 5 seconds | Maximum billed: 600 seconds (10 minutes)'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{swapping && (
|
||||
<Box>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Processing face swap... This may take a few minutes...
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => {}} icon={<ErrorIcon />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Alert
|
||||
severity="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
action={
|
||||
<Button size="small" onClick={reset}>
|
||||
Swap Another
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Face swap successful! Cost: ${result.cost.toFixed(2)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Results */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Typography>
|
||||
|
||||
{result ? (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #3b82f6',
|
||||
backgroundColor: '#000',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f0f9ff' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Face-Swapped Video
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
download
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
<Button variant="outlined" fullWidth onClick={reset}>
|
||||
Swap Another
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{imagePreview && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Reference Image:
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imagePreview}
|
||||
alt="Reference"
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxHeight: 200,
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{videoPreview && (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Source Video:
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!imagePreview && !videoPreview && (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload image and video to see preview
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
|
||||
About Face Swap Studio
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
MoCha performs seamless character replacement in videos:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Structure-free replacement - no pose or depth maps needed
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Preserves motion, emotion, and camera perspective
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Maintains identity consistency across frames
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Works with a single reference image and source video
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, fontStyle: 'italic' }}>
|
||||
<strong>Tips:</strong> Match pose & composition, keep aspect ratios consistent, limit video length to 60s for best results.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaceSwap;
|
||||
export { FaceSwap };
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
|
||||
interface ImageUploadProps {
|
||||
imagePreview: string | null;
|
||||
onImageSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||
imagePreview,
|
||||
onImageSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate image file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('Image file must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
onImageSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onImageSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Reference Image (Character to Swap In)
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{imagePreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imagePreview}
|
||||
alt="Reference image"
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxHeight: 300,
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<ImageIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload reference image
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
JPG, PNG up to 10MB (avoid WEBP)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Box, Paper, Stack, Typography, FormControl, Select, MenuItem, Chip, Divider } from '@mui/material';
|
||||
import { FaceSwapModel } from '../hooks/useFaceSwap';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: FaceSwapModel;
|
||||
onModelChange: (model: FaceSwapModel) => void;
|
||||
}
|
||||
|
||||
const MODEL_INFO = {
|
||||
mocha: {
|
||||
name: 'MoCha',
|
||||
tagline: 'Character Replacement with Motion Preservation',
|
||||
description: 'Advanced character replacement that preserves motion, emotion, and camera perspective. Perfect for film, advertising, and creative character transformation.',
|
||||
pricing: '$0.04/s (480p) or $0.08/s (720p)',
|
||||
maxLength: '120 seconds',
|
||||
features: ['Motion preservation', 'Expression transfer', 'Prompt guidance', 'Seed control', 'High quality output'],
|
||||
},
|
||||
'video-face-swap': {
|
||||
name: 'Video Face Swap',
|
||||
tagline: 'Simple Face Swap with Multi-Face Support',
|
||||
description: 'Affordable face swap with gender filtering and face index selection. Ideal for content creation, memes, and social media.',
|
||||
pricing: '$0.01/s',
|
||||
maxLength: '10 minutes (600 seconds)',
|
||||
features: ['Multi-face support', 'Gender filter', 'Face index selection', 'Affordable pricing', 'Long video support'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelChange }) => {
|
||||
const selectedInfo = MODEL_INFO[selectedModel];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
AI Model
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onChange={(e) => onModelChange(e.target.value as FaceSwapModel)}
|
||||
sx={{
|
||||
'& .MuiSelect-select': {
|
||||
py: 1.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="mocha">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography>MoCha</Typography>
|
||||
<Chip label="Premium" size="small" color="primary" />
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
<MenuItem value="video-face-swap">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography>Video Face Swap</Typography>
|
||||
<Chip label="Affordable" size="small" color="success" />
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#0f172a' }}>
|
||||
{selectedInfo.tagline}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
{selectedInfo.description}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Pricing:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
{selectedInfo.pricing}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Max Length:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
{selectedInfo.maxLength}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Features:
|
||||
</Typography>
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.5}>
|
||||
{selectedInfo.features.map((feature, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={feature}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
height: '20px',
|
||||
borderColor: '#cbd5e1',
|
||||
color: '#475569',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, TextField, FormControl, InputLabel, Select, MenuItem, Paper, Stack } from '@mui/material';
|
||||
import { Resolution, FaceSwapModel, TargetGender } from '../hooks/useFaceSwap';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
model: FaceSwapModel;
|
||||
prompt: string;
|
||||
resolution: Resolution;
|
||||
seed: number | null;
|
||||
targetGender: TargetGender;
|
||||
targetIndex: number;
|
||||
onPromptChange: (value: string) => void;
|
||||
onResolutionChange: (value: Resolution) => void;
|
||||
onSeedChange: (value: number | null) => void;
|
||||
onTargetGenderChange: (value: TargetGender) => void;
|
||||
onTargetIndexChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||
model,
|
||||
prompt,
|
||||
resolution,
|
||||
seed,
|
||||
targetGender,
|
||||
targetIndex,
|
||||
onPromptChange,
|
||||
onResolutionChange,
|
||||
onSeedChange,
|
||||
onTargetGenderChange,
|
||||
onTargetIndexChange,
|
||||
}) => {
|
||||
if (model === 'mocha') {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
MoCha Settings
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Prompt (Optional)"
|
||||
placeholder="e.g., preserve outfit; natural expressions; no background changes"
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
helperText="Optional prompt to guide the character replacement"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Resolution</InputLabel>
|
||||
<Select
|
||||
value={resolution}
|
||||
label="Resolution"
|
||||
onChange={(e) => onResolutionChange(e.target.value as Resolution)}
|
||||
>
|
||||
<MenuItem value="480p">480p ($0.04/second)</MenuItem>
|
||||
<MenuItem value="720p">720p ($0.08/second)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Seed (Optional)"
|
||||
type="number"
|
||||
value={seed || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onSeedChange(value === '' ? null : parseInt(value, 10));
|
||||
}}
|
||||
fullWidth
|
||||
helperText="Random seed for reproducibility (-1 for random, leave empty for random)"
|
||||
inputProps={{ min: -1 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// video-face-swap settings
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Video Face Swap Settings
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Target Gender</InputLabel>
|
||||
<Select
|
||||
value={targetGender}
|
||||
label="Target Gender"
|
||||
onChange={(e) => onTargetGenderChange(e.target.value as TargetGender)}
|
||||
>
|
||||
<MenuItem value="all">All (no filter)</MenuItem>
|
||||
<MenuItem value="female">Female only</MenuItem>
|
||||
<MenuItem value="male">Male only</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Target Face Index"
|
||||
type="number"
|
||||
value={targetIndex}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 0 && value <= 10) {
|
||||
onTargetIndexChange(value);
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
helperText="0 = largest face, 1 = second largest, etc. (0-10)"
|
||||
inputProps={{ min: 0, max: 10 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Source Video (Character to Replace)
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{videoPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload source video
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP4, WebM up to 500MB (max 120 seconds)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ImageUpload } from './ImageUpload';
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
export { ModelSelector } from './ModelSelector';
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { aiApiClient } from '../../../../../api/client';
|
||||
|
||||
export type Resolution = '480p' | '720p';
|
||||
export type FaceSwapModel = 'mocha' | 'video-face-swap';
|
||||
export type TargetGender = 'all' | 'female' | 'male';
|
||||
|
||||
export const useFaceSwap = () => {
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [model, setModel] = useState<FaceSwapModel>('mocha');
|
||||
const [prompt, setPrompt] = useState<string>('');
|
||||
const [resolution, setResolution] = useState<Resolution>('480p');
|
||||
const [seed, setSeed] = useState<number | null>(null);
|
||||
const [targetGender, setTargetGender] = useState<TargetGender>('all');
|
||||
const [targetIndex, setTargetIndex] = useState<number>(0);
|
||||
const [swapping, setSwapping] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number; model: string } | null>(null);
|
||||
|
||||
// Update previews when files change
|
||||
useEffect(() => {
|
||||
if (imageFile) {
|
||||
const url = URL.createObjectURL(imageFile);
|
||||
setImagePreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setImagePreview(null);
|
||||
}
|
||||
}, [imageFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoFile) {
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
}
|
||||
}, [videoFile]);
|
||||
|
||||
const canSwap = useMemo(() => {
|
||||
return imageFile !== null && videoFile !== null;
|
||||
}, [imageFile, videoFile]);
|
||||
|
||||
const costHint = useMemo(() => {
|
||||
if (!imageFile || !videoFile) return 'Upload image and video to see cost';
|
||||
|
||||
// MoCha pricing: $0.04/s (480p), $0.08/s (720p)
|
||||
// Video Face Swap pricing: $0.01/s
|
||||
// Minimum charge: 5 seconds for both
|
||||
// We'll estimate based on a default duration (actual cost calculated on backend)
|
||||
let costPerSecond: number;
|
||||
if (model === 'mocha') {
|
||||
costPerSecond = resolution === '480p' ? 0.04 : 0.08;
|
||||
} else {
|
||||
costPerSecond = 0.01;
|
||||
}
|
||||
const estimatedCost = costPerSecond * 10; // Estimate 10 seconds
|
||||
return `~$${estimatedCost.toFixed(2)} (estimated, based on video duration)`;
|
||||
}, [imageFile, videoFile, model, resolution]);
|
||||
|
||||
const swapFace = async (): Promise<void> => {
|
||||
if (!imageFile || !videoFile) return;
|
||||
|
||||
setSwapping(true);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image_file', imageFile);
|
||||
formData.append('video_file', videoFile);
|
||||
formData.append('model', model);
|
||||
|
||||
if (model === 'mocha') {
|
||||
if (prompt) {
|
||||
formData.append('prompt', prompt);
|
||||
}
|
||||
formData.append('resolution', resolution);
|
||||
if (seed !== null) {
|
||||
formData.append('seed', seed.toString());
|
||||
}
|
||||
} else {
|
||||
formData.append('target_gender', targetGender);
|
||||
formData.append('target_index', targetIndex.toString());
|
||||
}
|
||||
|
||||
setProgress(10);
|
||||
|
||||
const response = await aiApiClient.post('/api/video-studio/face-swap', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const uploadProgress = Math.round((progressEvent.loaded * 20) / progressEvent.total);
|
||||
setProgress(10 + uploadProgress);
|
||||
}
|
||||
},
|
||||
timeout: 600000, // 10 minutes
|
||||
});
|
||||
|
||||
setProgress(50);
|
||||
|
||||
if (response.data.success) {
|
||||
setResult(response.data);
|
||||
setProgress(100);
|
||||
} else {
|
||||
throw new Error(response.data.error || 'Face swap failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to swap face');
|
||||
setProgress(0);
|
||||
} finally {
|
||||
setSwapping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setVideoFile(null);
|
||||
setVideoPreview(null);
|
||||
setModel('mocha');
|
||||
setPrompt('');
|
||||
setResolution('480p');
|
||||
setSeed(null);
|
||||
setTargetGender('all');
|
||||
setTargetIndex(0);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
return {
|
||||
imageFile,
|
||||
imagePreview,
|
||||
videoFile,
|
||||
videoPreview,
|
||||
model,
|
||||
prompt,
|
||||
resolution,
|
||||
seed,
|
||||
targetGender,
|
||||
targetIndex,
|
||||
swapping,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
setImageFile,
|
||||
setVideoFile,
|
||||
setModel,
|
||||
setPrompt,
|
||||
setResolution,
|
||||
setSeed,
|
||||
setTargetGender,
|
||||
setTargetIndex,
|
||||
canSwap,
|
||||
costHint,
|
||||
swapFace,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FaceSwap } from './FaceSwap';
|
||||
export { default } from './FaceSwap';
|
||||
20
frontend/src/components/VideoStudio/modules/LibraryVideo.tsx
Normal file
20
frontend/src/components/VideoStudio/modules/LibraryVideo.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ModulePlaceholder from '../ModulePlaceholder';
|
||||
|
||||
export const LibraryVideo: React.FC = () => {
|
||||
return (
|
||||
<ModulePlaceholder
|
||||
title="Asset Library"
|
||||
subtitle="Governed delivery"
|
||||
status="beta"
|
||||
description="AI tagging, versions, usage analytics, and signed links to deliver videos safely across teams."
|
||||
bullets={[
|
||||
'Use cases: campaign organization, handoff to ads, compliance audits',
|
||||
'Planned: search by tags/prompts, usage stats, shareable signed URLs',
|
||||
'Guardrails: access control, audit logs, storage/egress visibility',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryVideo;
|
||||
@@ -0,0 +1,285 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useSocialVideo } from './hooks/useSocialVideo';
|
||||
import { VideoUpload, PlatformSelector, OptimizationOptions, PreviewGrid } from './components';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
const SocialVideo: React.FC = () => {
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
selectedPlatforms,
|
||||
autoCrop,
|
||||
generateThumbnails,
|
||||
compress,
|
||||
trimMode,
|
||||
optimizing,
|
||||
progress,
|
||||
results,
|
||||
errors,
|
||||
platformSpecs,
|
||||
setVideoFile,
|
||||
togglePlatform,
|
||||
setAutoCrop,
|
||||
setGenerateThumbnails,
|
||||
setCompress,
|
||||
setTrimMode,
|
||||
canOptimize,
|
||||
costHint,
|
||||
optimize,
|
||||
reset,
|
||||
} = useSocialVideo();
|
||||
|
||||
const handleDownload = (result: any) => {
|
||||
const videoUrl = result.video_url.startsWith('http')
|
||||
? result.video_url
|
||||
: `${window.location.origin}${result.video_url}`;
|
||||
window.open(videoUrl, '_blank');
|
||||
};
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
results.forEach((result) => {
|
||||
const videoUrl = result.video_url.startsWith('http')
|
||||
? result.video_url
|
||||
: `${window.location.origin}${result.video_url}`;
|
||||
window.open(videoUrl, '_blank');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Social Optimizer',
|
||||
subtitle: 'Optimize videos for Instagram, TikTok, YouTube, LinkedIn, Facebook, and Twitter. One video, multiple platform-ready versions.',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={3}>
|
||||
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
|
||||
|
||||
{videoFile && (
|
||||
<>
|
||||
<PlatformSelector
|
||||
selectedPlatforms={selectedPlatforms}
|
||||
platformSpecs={platformSpecs}
|
||||
onTogglePlatform={togglePlatform}
|
||||
/>
|
||||
|
||||
<OptimizationOptions
|
||||
autoCrop={autoCrop}
|
||||
generateThumbnails={generateThumbnails}
|
||||
compress={compress}
|
||||
trimMode={trimMode}
|
||||
onAutoCropChange={setAutoCrop}
|
||||
onGenerateThumbnailsChange={setGenerateThumbnails}
|
||||
onCompressChange={setCompress}
|
||||
onTrimModeChange={setTrimMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={optimizing ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={optimize}
|
||||
disabled={!canOptimize || optimizing}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{optimizing ? 'Optimizing...' : 'Optimize for All Platforms'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{videoFile && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Cost:</strong> {costHint}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{optimizing && (
|
||||
<Box>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Optimizing videos for {selectedPlatforms.length} platform{selectedPlatforms.length !== 1 ? 's' : ''}...
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<Alert severity="error" icon={<ErrorIcon />}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Optimization Errors:
|
||||
</Typography>
|
||||
{errors.map((error, index) => (
|
||||
<Typography key={index} variant="body2">
|
||||
{error.platform}: {error.error}
|
||||
</Typography>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<Alert
|
||||
severity="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
action={
|
||||
<Button size="small" onClick={reset}>
|
||||
Optimize Another
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Successfully optimized {results.length} video{results.length !== 1 ? 's' : ''}!
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Results */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
{results.length > 0 ? (
|
||||
<PreviewGrid
|
||||
results={results}
|
||||
optimizing={optimizing}
|
||||
onDownload={handleDownload}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Typography>
|
||||
|
||||
{videoPreview && (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Original Video
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!videoPreview && (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video to see preview
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
|
||||
About Social Optimizer
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Social Optimizer automatically creates platform-ready versions of your video:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Aspect ratio conversion (9:16, 16:9, 1:1)
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Duration trimming to platform limits
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
File size compression for platform requirements
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Thumbnail generation for each platform
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, fontStyle: 'italic' }}>
|
||||
All processing is free using FFmpeg.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialVideo;
|
||||
export { SocialVideo };
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, FormControlLabel, Switch, FormControl, RadioGroup, Radio, Stack, Paper } from '@mui/material';
|
||||
import { TrimMode } from '../hooks/useSocialVideo';
|
||||
|
||||
interface OptimizationOptionsProps {
|
||||
autoCrop: boolean;
|
||||
generateThumbnails: boolean;
|
||||
compress: boolean;
|
||||
trimMode: TrimMode;
|
||||
onAutoCropChange: (value: boolean) => void;
|
||||
onGenerateThumbnailsChange: (value: boolean) => void;
|
||||
onCompressChange: (value: boolean) => void;
|
||||
onTrimModeChange: (value: TrimMode) => void;
|
||||
}
|
||||
|
||||
export const OptimizationOptions: React.FC<OptimizationOptionsProps> = ({
|
||||
autoCrop,
|
||||
generateThumbnails,
|
||||
compress,
|
||||
trimMode,
|
||||
onAutoCropChange,
|
||||
onGenerateThumbnailsChange,
|
||||
onCompressChange,
|
||||
onTrimModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Optimization Options
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={autoCrop}
|
||||
onChange={(e) => onAutoCropChange(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
Auto-crop to platform ratio
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Automatically crop video to match platform aspect ratio
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={generateThumbnails}
|
||||
onChange={(e) => onGenerateThumbnailsChange(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
Generate thumbnails
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Create thumbnail images for each platform
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={compress}
|
||||
onChange={(e) => onCompressChange(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
Compress for file size limits
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Automatically compress videos to meet platform file size requirements
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
|
||||
Trim Mode (if video exceeds duration)
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={trimMode}
|
||||
onChange={(e) => onTrimModeChange(e.target.value as TrimMode)}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="beginning"
|
||||
control={<Radio size="small" />}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Keep Beginning - Trim from the end
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="middle"
|
||||
control={<Radio size="small" />}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Keep Middle - Trim from both ends
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="end"
|
||||
control={<Radio size="small" />}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
Keep End - Trim from the beginning
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, FormControlLabel, Checkbox, Stack, Chip, Paper } from '@mui/material';
|
||||
import { Platform } from '../hooks/useSocialVideo';
|
||||
|
||||
interface PlatformSelectorProps {
|
||||
selectedPlatforms: Platform[];
|
||||
platformSpecs: Record<string, any>;
|
||||
onTogglePlatform: (platform: Platform) => void;
|
||||
}
|
||||
|
||||
const platformInfo: Record<Platform, { label: string; icon: string; color: string }> = {
|
||||
instagram: { label: 'Instagram Reels', icon: '📷', color: '#E4405F' },
|
||||
tiktok: { label: 'TikTok', icon: '🎵', color: '#000000' },
|
||||
youtube: { label: 'YouTube Shorts', icon: '▶️', color: '#FF0000' },
|
||||
linkedin: { label: 'LinkedIn', icon: '💼', color: '#0077B5' },
|
||||
facebook: { label: 'Facebook', icon: '👥', color: '#1877F2' },
|
||||
twitter: { label: 'Twitter/X', icon: '🐦', color: '#1DA1F2' },
|
||||
};
|
||||
|
||||
export const PlatformSelector: React.FC<PlatformSelectorProps> = ({
|
||||
selectedPlatforms,
|
||||
platformSpecs,
|
||||
onTogglePlatform,
|
||||
}) => {
|
||||
const getPlatformSpec = (platform: Platform) => {
|
||||
const specs = platformSpecs[platform];
|
||||
if (!specs || specs.length === 0) return null;
|
||||
return specs[0]; // Get first format
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Select Platforms
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{(Object.keys(platformInfo) as Platform[]).map((platform) => {
|
||||
const info = platformInfo[platform];
|
||||
const spec = getPlatformSpec(platform);
|
||||
const isSelected = selectedPlatforms.includes(platform);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={platform}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `2px solid ${isSelected ? info.color : '#e2e8f0'}`,
|
||||
backgroundColor: isSelected ? `${info.color}08` : '#ffffff',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: info.color,
|
||||
backgroundColor: `${info.color}08`,
|
||||
},
|
||||
}}
|
||||
onClick={() => onTogglePlatform(platform)}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => onTogglePlatform(platform)}
|
||||
sx={{
|
||||
color: info.color,
|
||||
'&.Mui-checked': {
|
||||
color: info.color,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||
{info.icon} {info.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{spec && (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 0.5 }}>
|
||||
<Chip
|
||||
label={spec.aspect_ratio}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Max ${spec.max_duration}s`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`${spec.width}x${spec.height}`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box, Typography, Button, Stack, Chip, Paper, CircularProgress } from '@mui/material';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { PlatformResult } from '../hooks/useSocialVideo';
|
||||
|
||||
interface PreviewGridProps {
|
||||
results: PlatformResult[];
|
||||
optimizing: boolean;
|
||||
onDownload: (result: PlatformResult) => void;
|
||||
onDownloadAll: () => void;
|
||||
}
|
||||
|
||||
const platformColors: Record<string, string> = {
|
||||
instagram: '#E4405F',
|
||||
tiktok: '#000000',
|
||||
youtube: '#FF0000',
|
||||
linkedin: '#0077B5',
|
||||
facebook: '#1877F2',
|
||||
twitter: '#1DA1F2',
|
||||
};
|
||||
|
||||
export const PreviewGrid: React.FC<PreviewGridProps> = ({
|
||||
results,
|
||||
optimizing,
|
||||
onDownload,
|
||||
onDownloadAll,
|
||||
}) => {
|
||||
if (optimizing) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<CircularProgress size={48} sx={{ mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Optimizing videos for selected platforms...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Optimized videos will appear here
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
Optimized Videos ({results.length})
|
||||
</Typography>
|
||||
{results.length > 1 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={onDownloadAll}
|
||||
sx={{
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download All
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{results.map((result, index) => {
|
||||
const color = platformColors[result.platform] || '#3b82f6';
|
||||
const videoUrl = result.video_url.startsWith('http')
|
||||
? result.video_url
|
||||
: `${window.location.origin}${result.video_url}`;
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={index}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `2px solid ${color}`,
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color }}>
|
||||
{result.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip
|
||||
label={result.aspect_ratio}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`${result.width}x${result.height}`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
<Chip
|
||||
label={formatFileSize(result.file_size)}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
aspectRatio: result.aspect_ratio === '9:16' ? '9/16' : '16/9',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{result.thumbnail_url && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
|
||||
Thumbnail:
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={
|
||||
result.thumbnail_url.startsWith('http')
|
||||
? result.thumbnail_url
|
||||
: `${window.location.origin}${result.thumbnail_url}`
|
||||
}
|
||||
alt={`${result.name} thumbnail`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => onDownload(result)}
|
||||
href={videoUrl}
|
||||
download
|
||||
sx={{
|
||||
borderColor: color,
|
||||
color: color,
|
||||
'&:hover': {
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}08`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Upload Video
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{videoPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload a video
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP4, WebM up to 500MB (max 10 minutes)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { PlatformSelector } from './PlatformSelector';
|
||||
export { OptimizationOptions } from './OptimizationOptions';
|
||||
export { PreviewGrid } from './PreviewGrid';
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { aiApiClient } from '../../../../../api/client';
|
||||
|
||||
export type Platform = 'instagram' | 'tiktok' | 'youtube' | 'linkedin' | 'facebook' | 'twitter';
|
||||
export type TrimMode = 'beginning' | 'middle' | 'end';
|
||||
|
||||
export interface PlatformResult {
|
||||
platform: string;
|
||||
name: string;
|
||||
aspect_ratio: string;
|
||||
video_url: string;
|
||||
thumbnail_url?: string;
|
||||
duration: number;
|
||||
file_size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const useSocialVideo = () => {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<Platform[]>([]);
|
||||
const [autoCrop, setAutoCrop] = useState<boolean>(true);
|
||||
const [generateThumbnails, setGenerateThumbnails] = useState<boolean>(true);
|
||||
const [compress, setCompress] = useState<boolean>(true);
|
||||
const [trimMode, setTrimMode] = useState<TrimMode>('beginning');
|
||||
const [optimizing, setOptimizing] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [results, setResults] = useState<PlatformResult[]>([]);
|
||||
const [errors, setErrors] = useState<Array<{ platform: string; error: string }>>([]);
|
||||
const [platformSpecs, setPlatformSpecs] = useState<Record<string, any>>({});
|
||||
|
||||
// Update preview when file changes
|
||||
useEffect(() => {
|
||||
if (videoFile) {
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
}
|
||||
}, [videoFile]);
|
||||
|
||||
// Load platform specifications
|
||||
useEffect(() => {
|
||||
const loadPlatformSpecs = async () => {
|
||||
try {
|
||||
const response = await aiApiClient.get('/api/video-studio/social/platforms');
|
||||
if (response.data.success) {
|
||||
setPlatformSpecs(response.data.platforms);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load platform specs:', error);
|
||||
}
|
||||
};
|
||||
loadPlatformSpecs();
|
||||
}, []);
|
||||
|
||||
const togglePlatform = (platform: Platform) => {
|
||||
setSelectedPlatforms((prev) =>
|
||||
prev.includes(platform)
|
||||
? prev.filter((p) => p !== platform)
|
||||
: [...prev, platform]
|
||||
);
|
||||
};
|
||||
|
||||
const canOptimize = useMemo(() => {
|
||||
return videoFile !== null && selectedPlatforms.length > 0;
|
||||
}, [videoFile, selectedPlatforms]);
|
||||
|
||||
const costHint = useMemo(() => {
|
||||
if (!videoFile) return 'Upload a video to optimize';
|
||||
if (selectedPlatforms.length === 0) return 'Select at least one platform';
|
||||
return 'Free (FFmpeg processing)';
|
||||
}, [videoFile, selectedPlatforms]);
|
||||
|
||||
const optimize = async (): Promise<void> => {
|
||||
if (!videoFile || selectedPlatforms.length === 0) return;
|
||||
|
||||
setOptimizing(true);
|
||||
setProgress(0);
|
||||
setResults([]);
|
||||
setErrors([]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', videoFile);
|
||||
formData.append('platforms', selectedPlatforms.join(','));
|
||||
formData.append('auto_crop', autoCrop.toString());
|
||||
formData.append('generate_thumbnails', generateThumbnails.toString());
|
||||
formData.append('compress', compress.toString());
|
||||
formData.append('trim_mode', trimMode);
|
||||
|
||||
setProgress(20);
|
||||
|
||||
const response = await aiApiClient.post('/api/video-studio/social/optimize', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const uploadProgress = Math.round((progressEvent.loaded * 30) / progressEvent.total);
|
||||
setProgress(20 + uploadProgress);
|
||||
}
|
||||
},
|
||||
timeout: 600000, // 10 minutes
|
||||
});
|
||||
|
||||
setProgress(80);
|
||||
|
||||
if (response.data.success) {
|
||||
setResults(response.data.results || []);
|
||||
setErrors(response.data.errors || []);
|
||||
setProgress(100);
|
||||
} else {
|
||||
throw new Error(response.data.error || 'Optimization failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrors([
|
||||
{
|
||||
platform: 'all',
|
||||
error: error.response?.data?.detail || error.message || 'Optimization failed',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setVideoFile(null);
|
||||
setVideoPreview(null);
|
||||
setSelectedPlatforms([]);
|
||||
setResults([]);
|
||||
setErrors([]);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
return {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
selectedPlatforms,
|
||||
autoCrop,
|
||||
generateThumbnails,
|
||||
compress,
|
||||
trimMode,
|
||||
optimizing,
|
||||
progress,
|
||||
results,
|
||||
errors,
|
||||
platformSpecs,
|
||||
setVideoFile,
|
||||
togglePlatform,
|
||||
setAutoCrop,
|
||||
setGenerateThumbnails,
|
||||
setCompress,
|
||||
setTrimMode,
|
||||
canOptimize,
|
||||
costHint,
|
||||
optimize,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SocialVideo } from './SocialVideo';
|
||||
export { default } from './SocialVideo';
|
||||
@@ -0,0 +1,449 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useTransformVideo } from './hooks/useTransformVideo';
|
||||
import {
|
||||
VideoUpload,
|
||||
TransformTabs,
|
||||
FormatConverter,
|
||||
AspectConverter,
|
||||
SpeedAdjuster,
|
||||
ResolutionScaler,
|
||||
Compressor,
|
||||
} from './components';
|
||||
import { aiApiClient } from '../../../../api/client';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
|
||||
const TransformVideo: React.FC = () => {
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
transformType,
|
||||
outputFormat,
|
||||
codec,
|
||||
quality,
|
||||
audioCodec,
|
||||
targetAspect,
|
||||
cropMode,
|
||||
speedFactor,
|
||||
targetResolution,
|
||||
maintainAspect,
|
||||
targetSizeMb,
|
||||
compressQuality,
|
||||
setVideoFile,
|
||||
setTransformType,
|
||||
setOutputFormat,
|
||||
setCodec,
|
||||
setQuality,
|
||||
setAudioCodec,
|
||||
setTargetAspect,
|
||||
setCropMode,
|
||||
setSpeedFactor,
|
||||
setTargetResolution,
|
||||
setMaintainAspect,
|
||||
setTargetSizeMb,
|
||||
setCompressQuality,
|
||||
canTransform,
|
||||
costHint,
|
||||
} = useTransformVideo();
|
||||
|
||||
const [transforming, setTransforming] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number } | null>(null);
|
||||
|
||||
const handleTransform = async () => {
|
||||
if (!videoFile) return;
|
||||
|
||||
setTransforming(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
setStatusMessage('Starting video transformation...');
|
||||
|
||||
try {
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
formData.append('file', videoFile);
|
||||
formData.append('transform_type', transformType);
|
||||
|
||||
// Add transform-specific parameters
|
||||
if (transformType === 'format') {
|
||||
formData.append('output_format', outputFormat);
|
||||
if (codec) formData.append('codec', codec);
|
||||
formData.append('quality', quality);
|
||||
if (audioCodec) formData.append('audio_codec', audioCodec);
|
||||
} else if (transformType === 'aspect') {
|
||||
formData.append('target_aspect', targetAspect);
|
||||
formData.append('crop_mode', cropMode);
|
||||
} else if (transformType === 'speed') {
|
||||
formData.append('speed_factor', speedFactor.toString());
|
||||
} else if (transformType === 'resolution') {
|
||||
formData.append('target_resolution', targetResolution);
|
||||
formData.append('maintain_aspect', maintainAspect.toString());
|
||||
} else if (transformType === 'compress') {
|
||||
formData.append('compress_quality', compressQuality);
|
||||
if (targetSizeMb) {
|
||||
formData.append('target_size_mb', targetSizeMb.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Submit transformation request
|
||||
setStatusMessage('Uploading video...');
|
||||
const response = await aiApiClient.post('/api/video-studio/transform', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const uploadProgress = Math.round((progressEvent.loaded * 20) / progressEvent.total);
|
||||
setProgress(uploadProgress);
|
||||
setStatusMessage(`Uploading video... ${uploadProgress}%`);
|
||||
}
|
||||
},
|
||||
timeout: 600000, // 10 minutes timeout for long videos
|
||||
});
|
||||
|
||||
setProgress(30);
|
||||
setStatusMessage('Processing video... This may take a few minutes...');
|
||||
|
||||
if (response.data.success) {
|
||||
setTransforming(false);
|
||||
setResult(response.data);
|
||||
setProgress(100);
|
||||
setStatusMessage('Video transformation complete!');
|
||||
} else {
|
||||
throw new Error(response.data.error || 'Transformation failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setTransforming(false);
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to transform video');
|
||||
setStatusMessage('Transformation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setTransforming(false);
|
||||
setProgress(0);
|
||||
setStatusMessage('');
|
||||
setError(null);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const renderTransformSettings = () => {
|
||||
switch (transformType) {
|
||||
case 'format':
|
||||
return (
|
||||
<FormatConverter
|
||||
outputFormat={outputFormat}
|
||||
codec={codec}
|
||||
quality={quality}
|
||||
audioCodec={audioCodec}
|
||||
onOutputFormatChange={setOutputFormat}
|
||||
onCodecChange={setCodec}
|
||||
onQualityChange={setQuality}
|
||||
onAudioCodecChange={setAudioCodec}
|
||||
/>
|
||||
);
|
||||
case 'aspect':
|
||||
return (
|
||||
<AspectConverter
|
||||
targetAspect={targetAspect}
|
||||
cropMode={cropMode}
|
||||
onTargetAspectChange={setTargetAspect}
|
||||
onCropModeChange={setCropMode}
|
||||
/>
|
||||
);
|
||||
case 'speed':
|
||||
return (
|
||||
<SpeedAdjuster
|
||||
speedFactor={speedFactor}
|
||||
onSpeedFactorChange={setSpeedFactor}
|
||||
/>
|
||||
);
|
||||
case 'resolution':
|
||||
return (
|
||||
<ResolutionScaler
|
||||
targetResolution={targetResolution}
|
||||
maintainAspect={maintainAspect}
|
||||
onTargetResolutionChange={setTargetResolution}
|
||||
onMaintainAspectChange={setMaintainAspect}
|
||||
/>
|
||||
);
|
||||
case 'compress':
|
||||
return (
|
||||
<Compressor
|
||||
targetSizeMb={targetSizeMb}
|
||||
compressQuality={compressQuality}
|
||||
onTargetSizeMbChange={setTargetSizeMb}
|
||||
onCompressQualityChange={setCompressQuality}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Transform Studio',
|
||||
subtitle: 'Convert formats, change aspect ratios, adjust speed, scale resolution, and compress videos. All transformations use FFmpeg processing (free).',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={3}>
|
||||
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
|
||||
|
||||
{videoFile && (
|
||||
<>
|
||||
<TransformTabs
|
||||
transformType={transformType}
|
||||
onTransformTypeChange={setTransformType}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
{renderTransformSettings()}
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={transforming ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
|
||||
onClick={handleTransform}
|
||||
disabled={!canTransform || transforming}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{transforming ? 'Transforming...' : 'Transform Video'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{videoFile && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Cost:</strong> {costHint}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{transforming && (
|
||||
<Box>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Alert
|
||||
severity="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
action={
|
||||
<Button size="small" onClick={handleReset}>
|
||||
Transform Another
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Video transformed successfully! Cost: ${result.cost.toFixed(2)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Results */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</Typography>
|
||||
|
||||
{videoPreview && !result && (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Original Video
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #3b82f6',
|
||||
backgroundColor: '#000',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f0f9ff' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Transformed Video ({transformType})
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
download
|
||||
>
|
||||
Download Transformed Video
|
||||
</Button>
|
||||
<Button variant="outlined" fullWidth onClick={handleReset}>
|
||||
Transform Another Video
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!videoPreview && !result && (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video to see preview
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
|
||||
About Transform Studio
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Transform Studio uses FFmpeg for fast, free video processing:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Format conversion: MP4, MOV, WebM, GIF
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Aspect ratio conversion with smart cropping
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Speed adjustment (0.25x to 4x)
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Resolution scaling (480p to 4K)
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
File size compression
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransformVideo;
|
||||
export { TransformVideo };
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Stack, RadioGroup, FormControlLabel, Radio } from '@mui/material';
|
||||
import type { AspectRatio } from '../hooks/useTransformVideo';
|
||||
|
||||
interface AspectConverterProps {
|
||||
targetAspect: AspectRatio;
|
||||
cropMode: 'center' | 'letterbox';
|
||||
onTargetAspectChange: (aspect: AspectRatio) => void;
|
||||
onCropModeChange: (mode: 'center' | 'letterbox') => void;
|
||||
}
|
||||
|
||||
export const AspectConverter: React.FC<AspectConverterProps> = ({
|
||||
targetAspect,
|
||||
cropMode,
|
||||
onTargetAspectChange,
|
||||
onCropModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Aspect Ratio Conversion Settings
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Target Aspect Ratio</InputLabel>
|
||||
<Select
|
||||
value={targetAspect}
|
||||
label="Target Aspect Ratio"
|
||||
onChange={(e) => onTargetAspectChange(e.target.value as AspectRatio)}
|
||||
>
|
||||
<MenuItem value="16:9">16:9 (Landscape - YouTube, TV)</MenuItem>
|
||||
<MenuItem value="9:16">9:16 (Portrait - Instagram Reels, TikTok)</MenuItem>
|
||||
<MenuItem value="1:1">1:1 (Square - Instagram Posts)</MenuItem>
|
||||
<MenuItem value="4:5">4:5 (Portrait - Instagram Stories)</MenuItem>
|
||||
<MenuItem value="21:9">21:9 (Ultrawide - Cinematic)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl component="fieldset">
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Crop Mode
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={cropMode}
|
||||
onChange={(e) => onCropModeChange(e.target.value as 'center' | 'letterbox')}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="center"
|
||||
control={<Radio />}
|
||||
label="Center Crop (Crop to fit, may lose edges)"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="letterbox"
|
||||
control={<Radio />}
|
||||
label="Letterbox (Add black bars, preserves full video)"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Center Crop:</strong> Crops the video to fit the target aspect ratio. May remove parts of the video.
|
||||
<br />
|
||||
<strong>Letterbox:</strong> Adds black bars to fit the aspect ratio. Preserves the entire video.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Stack, TextField } from '@mui/material';
|
||||
import type { Quality } from '../hooks/useTransformVideo';
|
||||
|
||||
interface CompressorProps {
|
||||
targetSizeMb: number | null;
|
||||
compressQuality: Quality;
|
||||
onTargetSizeMbChange: (size: number | null) => void;
|
||||
onCompressQualityChange: (quality: Quality) => void;
|
||||
}
|
||||
|
||||
export const Compressor: React.FC<CompressorProps> = ({
|
||||
targetSizeMb,
|
||||
compressQuality,
|
||||
onTargetSizeMbChange,
|
||||
onCompressQualityChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Compression Settings
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Quality Preset</InputLabel>
|
||||
<Select
|
||||
value={compressQuality}
|
||||
label="Quality Preset"
|
||||
onChange={(e) => onCompressQualityChange(e.target.value as Quality)}
|
||||
>
|
||||
<MenuItem value="high">High (Best Quality, Larger File)</MenuItem>
|
||||
<MenuItem value="medium">Medium (Balanced)</MenuItem>
|
||||
<MenuItem value="low">Low (Smaller File, Lower Quality)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Target File Size (MB)"
|
||||
type="number"
|
||||
value={targetSizeMb || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onTargetSizeMbChange(value ? parseFloat(value) : null);
|
||||
}}
|
||||
helperText="Optional: Specify target file size. If not set, quality preset will be used."
|
||||
inputProps={{ min: 1, step: 0.1 }}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Quality Preset:</strong> Uses optimized bitrate settings for the selected quality level.
|
||||
<br />
|
||||
<strong>Target Size:</strong> Calculates bitrate to achieve the specified file size. Overrides quality preset if set.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Stack } from '@mui/material';
|
||||
import type { OutputFormat, Quality } from '../hooks/useTransformVideo';
|
||||
|
||||
interface FormatConverterProps {
|
||||
outputFormat: OutputFormat;
|
||||
codec: string;
|
||||
quality: Quality;
|
||||
audioCodec: string;
|
||||
onOutputFormatChange: (format: OutputFormat) => void;
|
||||
onCodecChange: (codec: string) => void;
|
||||
onQualityChange: (quality: Quality) => void;
|
||||
onAudioCodecChange: (codec: string) => void;
|
||||
}
|
||||
|
||||
export const FormatConverter: React.FC<FormatConverterProps> = ({
|
||||
outputFormat,
|
||||
codec,
|
||||
quality,
|
||||
audioCodec,
|
||||
onOutputFormatChange,
|
||||
onCodecChange,
|
||||
onQualityChange,
|
||||
onAudioCodecChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Format Conversion Settings
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Output Format</InputLabel>
|
||||
<Select
|
||||
value={outputFormat}
|
||||
label="Output Format"
|
||||
onChange={(e) => onOutputFormatChange(e.target.value as OutputFormat)}
|
||||
>
|
||||
<MenuItem value="mp4">MP4 (H.264)</MenuItem>
|
||||
<MenuItem value="mov">MOV (QuickTime)</MenuItem>
|
||||
<MenuItem value="webm">WebM (VP9)</MenuItem>
|
||||
<MenuItem value="gif">GIF (Animated)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{outputFormat !== 'gif' && (
|
||||
<>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Video Codec</InputLabel>
|
||||
<Select
|
||||
value={codec}
|
||||
label="Video Codec"
|
||||
onChange={(e) => onCodecChange(e.target.value)}
|
||||
disabled={outputFormat === 'webm'} // Auto-selected for WebM
|
||||
>
|
||||
<MenuItem value="libx264">H.264 (MP4, MOV)</MenuItem>
|
||||
<MenuItem value="libvpx-vp9">VP9 (WebM)</MenuItem>
|
||||
<MenuItem value="libx265">H.265/HEVC</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Audio Codec</InputLabel>
|
||||
<Select
|
||||
value={audioCodec}
|
||||
label="Audio Codec"
|
||||
onChange={(e) => onAudioCodecChange(e.target.value)}
|
||||
disabled={outputFormat === 'webm'} // Auto-selected for WebM
|
||||
>
|
||||
<MenuItem value="aac">AAC (MP4, MOV)</MenuItem>
|
||||
<MenuItem value="libopus">Opus (WebM)</MenuItem>
|
||||
<MenuItem value="mp3">MP3</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
{outputFormat !== 'gif' && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Quality</InputLabel>
|
||||
<Select
|
||||
value={quality}
|
||||
label="Quality"
|
||||
onChange={(e) => onQualityChange(e.target.value as Quality)}
|
||||
>
|
||||
<MenuItem value="high">High (Best Quality)</MenuItem>
|
||||
<MenuItem value="medium">Medium (Balanced)</MenuItem>
|
||||
<MenuItem value="low">Low (Smaller File)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{outputFormat === 'gif' && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
GIF format will be optimized for web with reduced frame rate (15fps) and no audio.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Stack, FormControlLabel, Checkbox } from '@mui/material';
|
||||
import type { Resolution } from '../hooks/useTransformVideo';
|
||||
|
||||
interface ResolutionScalerProps {
|
||||
targetResolution: Resolution;
|
||||
maintainAspect: boolean;
|
||||
onTargetResolutionChange: (resolution: Resolution) => void;
|
||||
onMaintainAspectChange: (maintain: boolean) => void;
|
||||
}
|
||||
|
||||
export const ResolutionScaler: React.FC<ResolutionScalerProps> = ({
|
||||
targetResolution,
|
||||
maintainAspect,
|
||||
onTargetResolutionChange,
|
||||
onMaintainAspectChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Resolution Scaling Settings
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Target Resolution</InputLabel>
|
||||
<Select
|
||||
value={targetResolution}
|
||||
label="Target Resolution"
|
||||
onChange={(e) => onTargetResolutionChange(e.target.value as Resolution)}
|
||||
>
|
||||
<MenuItem value="480p">480p (SD - 854x480)</MenuItem>
|
||||
<MenuItem value="720p">720p (HD - 1280x720)</MenuItem>
|
||||
<MenuItem value="1080p">1080p (Full HD - 1920x1080)</MenuItem>
|
||||
<MenuItem value="1440p">1440p (2K - 2560x1440)</MenuItem>
|
||||
<MenuItem value="4k">4K (UHD - 3840x2160)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={maintainAspect}
|
||||
onChange={(e) => onMaintainAspectChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Maintain Aspect Ratio"
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{maintainAspect
|
||||
? 'The video will be scaled to match the target resolution while preserving the original aspect ratio. This may add letterboxing or pillarboxing.'
|
||||
: 'The video will be stretched or compressed to exactly match the target resolution. This may distort the video if aspect ratios differ.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Slider, Stack, Chip } from '@mui/material';
|
||||
|
||||
interface SpeedAdjusterProps {
|
||||
speedFactor: number;
|
||||
onSpeedFactorChange: (factor: number) => void;
|
||||
}
|
||||
|
||||
const speedPresets = [
|
||||
{ label: '0.25x', value: 0.25, description: 'Very Slow' },
|
||||
{ label: '0.5x', value: 0.5, description: 'Slow Motion' },
|
||||
{ label: '1x', value: 1.0, description: 'Normal' },
|
||||
{ label: '1.5x', value: 1.5, description: 'Fast' },
|
||||
{ label: '2x', value: 2.0, description: '2x Speed' },
|
||||
{ label: '4x', value: 4.0, description: 'Time-lapse' },
|
||||
];
|
||||
|
||||
export const SpeedAdjuster: React.FC<SpeedAdjusterProps> = ({
|
||||
speedFactor,
|
||||
onSpeedFactorChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Speed Adjustment Settings
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
||||
Select a preset or use the slider for custom speed:
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mb: 3 }}>
|
||||
{speedPresets.map((preset) => (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
label={`${preset.label} (${preset.description})`}
|
||||
onClick={() => onSpeedFactorChange(preset.value)}
|
||||
color={speedFactor === preset.value ? 'primary' : 'default'}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
fontWeight: speedFactor === preset.value ? 700 : 400,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Custom Speed: {speedFactor}x
|
||||
</Typography>
|
||||
<Slider
|
||||
value={speedFactor}
|
||||
onChange={(_, value) => onSpeedFactorChange(value as number)}
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.25}
|
||||
marks={[
|
||||
{ value: 0.25, label: '0.25x' },
|
||||
{ value: 1.0, label: '1x' },
|
||||
{ value: 2.0, label: '2x' },
|
||||
{ value: 4.0, label: '4x' },
|
||||
]}
|
||||
sx={{
|
||||
'& .MuiSlider-markLabel': {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Speed adjustment affects both video and audio. Values below 1x create slow motion, values above 1x create fast-forward or time-lapse effects.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Tabs, Tab, Box } from '@mui/material';
|
||||
import type { TransformType } from '../hooks/useTransformVideo';
|
||||
|
||||
interface TransformTabsProps {
|
||||
transformType: TransformType;
|
||||
onTransformTypeChange: (type: TransformType) => void;
|
||||
}
|
||||
|
||||
export const TransformTabs: React.FC<TransformTabsProps> = ({
|
||||
transformType,
|
||||
onTransformTypeChange,
|
||||
}) => {
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: TransformType) => {
|
||||
onTransformTypeChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs
|
||||
value={transformType}
|
||||
onChange={handleChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
minWidth: 120,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab label="Format" value="format" />
|
||||
<Tab label="Aspect Ratio" value="aspect" />
|
||||
<Tab label="Speed" value="speed" />
|
||||
<Tab label="Resolution" value="resolution" />
|
||||
<Tab label="Compress" value="compress" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Upload Video
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{videoPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload a video
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP4, WebM up to 500MB (max 10 minutes)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { TransformTabs } from './TransformTabs';
|
||||
export { FormatConverter } from './FormatConverter';
|
||||
export { AspectConverter } from './AspectConverter';
|
||||
export { SpeedAdjuster } from './SpeedAdjuster';
|
||||
export { ResolutionScaler } from './ResolutionScaler';
|
||||
export { Compressor } from './Compressor';
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
|
||||
export type TransformType = 'format' | 'aspect' | 'speed' | 'resolution' | 'compress';
|
||||
export type OutputFormat = 'mp4' | 'mov' | 'webm' | 'gif';
|
||||
export type AspectRatio = '16:9' | '9:16' | '1:1' | '4:5' | '21:9';
|
||||
export type Quality = 'high' | 'medium' | 'low';
|
||||
export type Resolution = '480p' | '720p' | '1080p' | '1440p' | '4k';
|
||||
|
||||
export const useTransformVideo = () => {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [transformType, setTransformType] = useState<TransformType>('format');
|
||||
|
||||
// Format conversion state
|
||||
const [outputFormat, setOutputFormat] = useState<OutputFormat>('mp4');
|
||||
const [codec, setCodec] = useState<string>('libx264');
|
||||
const [quality, setQuality] = useState<Quality>('medium');
|
||||
const [audioCodec, setAudioCodec] = useState<string>('aac');
|
||||
|
||||
// Aspect ratio state
|
||||
const [targetAspect, setTargetAspect] = useState<AspectRatio>('16:9');
|
||||
const [cropMode, setCropMode] = useState<'center' | 'letterbox'>('center');
|
||||
|
||||
// Speed state
|
||||
const [speedFactor, setSpeedFactor] = useState<number>(1.0);
|
||||
|
||||
// Resolution state
|
||||
const [targetResolution, setTargetResolution] = useState<Resolution>('720p');
|
||||
const [maintainAspect, setMaintainAspect] = useState<boolean>(true);
|
||||
|
||||
// Compression state
|
||||
const [targetSizeMb, setTargetSizeMb] = useState<number | null>(null);
|
||||
const [compressQuality, setCompressQuality] = useState<Quality>('medium');
|
||||
|
||||
// Cost hint (FFmpeg operations are free)
|
||||
const costHint = useMemo(() => {
|
||||
if (!videoFile) return 'Upload a video to transform';
|
||||
return 'Free (FFmpeg processing)';
|
||||
}, [videoFile]);
|
||||
|
||||
const canTransform = useMemo(() => {
|
||||
if (!videoFile) return false;
|
||||
|
||||
// Validate based on transform type
|
||||
switch (transformType) {
|
||||
case 'format':
|
||||
return !!outputFormat;
|
||||
case 'aspect':
|
||||
return !!targetAspect;
|
||||
case 'speed':
|
||||
return speedFactor > 0 && speedFactor <= 4.0;
|
||||
case 'resolution':
|
||||
return !!targetResolution;
|
||||
case 'compress':
|
||||
return true; // Always valid
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [videoFile, transformType, outputFormat, targetAspect, speedFactor, targetResolution]);
|
||||
|
||||
const handleVideoSelect = useCallback((file: File | null) => {
|
||||
setVideoFile(file);
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setVideoPreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update codec based on format
|
||||
const handleFormatChange = useCallback((format: OutputFormat) => {
|
||||
setOutputFormat(format);
|
||||
// Auto-select appropriate codec
|
||||
if (format === 'webm') {
|
||||
setCodec('libvpx-vp9');
|
||||
setAudioCodec('libopus');
|
||||
} else if (format === 'gif') {
|
||||
setCodec('');
|
||||
setAudioCodec('');
|
||||
} else {
|
||||
setCodec('libx264');
|
||||
setAudioCodec('aac');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
videoFile,
|
||||
videoPreview,
|
||||
transformType,
|
||||
outputFormat,
|
||||
codec,
|
||||
quality,
|
||||
audioCodec,
|
||||
targetAspect,
|
||||
cropMode,
|
||||
speedFactor,
|
||||
targetResolution,
|
||||
maintainAspect,
|
||||
targetSizeMb,
|
||||
compressQuality,
|
||||
// Setters
|
||||
setVideoFile: handleVideoSelect,
|
||||
setTransformType,
|
||||
setOutputFormat: handleFormatChange,
|
||||
setCodec,
|
||||
setQuality,
|
||||
setAudioCodec,
|
||||
setTargetAspect,
|
||||
setCropMode,
|
||||
setSpeedFactor,
|
||||
setTargetResolution,
|
||||
setMaintainAspect,
|
||||
setTargetSizeMb,
|
||||
setCompressQuality,
|
||||
// Computed
|
||||
canTransform,
|
||||
costHint,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TransformVideo } from './TransformVideo';
|
||||
export { default } from './TransformVideo';
|
||||
@@ -0,0 +1,318 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper, Chip } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useVideoBackgroundRemover } from './hooks/useVideoBackgroundRemover';
|
||||
import { VideoUpload, BackgroundImageUpload } from './components';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
||||
|
||||
const VideoBackgroundRemover: React.FC = () => {
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
backgroundImageFile,
|
||||
backgroundImagePreview,
|
||||
removing,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
setVideoFile,
|
||||
setBackgroundImageFile,
|
||||
canRemove,
|
||||
costHint,
|
||||
removeBackground,
|
||||
reset,
|
||||
} = useVideoBackgroundRemover();
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Background Remover Studio',
|
||||
subtitle: 'Remove or replace video backgrounds with clean matting and edge-aware blending. Upload a background image to replace, or leave empty for transparent background.',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={3}>
|
||||
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
|
||||
|
||||
<BackgroundImageUpload
|
||||
imagePreview={backgroundImagePreview}
|
||||
onImageSelect={setBackgroundImageFile}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Estimated Cost:
|
||||
</Typography>
|
||||
<Chip
|
||||
label={costHint}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
Pricing: $0.01/second (min $0.05 for ≤5s, max $6.00 for 600s)
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Minimum: $0.05 | Maximum: $6.00 (10 minutes / 600 seconds)
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={removing ? <CircularProgress size={20} color="inherit" /> : <WallpaperIcon />}
|
||||
onClick={removeBackground}
|
||||
disabled={!canRemove || removing}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{removing ? 'Processing...' : backgroundImageFile ? 'Replace Background' : 'Remove Background'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{removing && (
|
||||
<Box>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Processing video... This may take a few minutes...
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => {}} icon={<ErrorIcon />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Alert
|
||||
severity="success"
|
||||
icon={<CheckCircleIcon />}
|
||||
action={
|
||||
<Button size="small" onClick={reset}>
|
||||
Process Another
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Background {result.has_background_replacement ? 'replaced' : 'removed'} successfully! Cost: ${result.cost.toFixed(4)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Results */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
{result ? (
|
||||
// Result view
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Processed Video
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #10b981',
|
||||
backgroundColor: '#000',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f0fdf4' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#059669' }}>
|
||||
{result.has_background_replacement ? 'Background Replaced' : 'Background Removed'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
href={result.video_url.startsWith('http') ? result.video_url : `${window.location.origin}${result.video_url}`}
|
||||
download
|
||||
sx={{
|
||||
backgroundColor: '#10b981',
|
||||
'&:hover': {
|
||||
backgroundColor: '#059669',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
<Button variant="outlined" fullWidth onClick={reset}>
|
||||
Process Another
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : videoPreview ? (
|
||||
// Original video preview
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Original Video Preview
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ p: 2, backgroundColor: '#f8fafc' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video and optionally add a background image to get started
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Upload a video to see preview
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Your processed video will appear here
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a' }}>
|
||||
About Background Removal
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
WaveSpeed Video Background Remover provides:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0, mb: 2 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Automatic background detection and removal
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Custom background replacement with your own images
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Transparent background support for further editing
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary">
|
||||
Production-ready quality with high-quality edge detection
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>
|
||||
Tips for Best Results:
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Use videos with clear subject-background separation
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Ensure adequate lighting for better edge detection
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Use high-resolution images for replacement backgrounds
|
||||
</Typography>
|
||||
<Typography component="li" variant="body2" color="text.secondary" sx={{ fontSize: '0.75rem' }}>
|
||||
Best results with landscape videos (16:9 ratio)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { VideoBackgroundRemover };
|
||||
export default VideoBackgroundRemover;
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack, Chip } from '@mui/material';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface BackgroundImageUploadProps {
|
||||
imagePreview: string | null;
|
||||
onImageSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const BackgroundImageUpload: React.FC<BackgroundImageUploadProps> = ({
|
||||
imagePreview,
|
||||
onImageSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate image file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('Image file must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
onImageSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onImageSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Background Image (Optional)
|
||||
</Typography>
|
||||
<Chip label="Optional" size="small" color="info" />
|
||||
</Stack>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{imagePreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imagePreview}
|
||||
alt="Background image"
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxHeight: 300,
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
startIcon={<CloseIcon />}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5} alignItems="center">
|
||||
<ImageIcon sx={{ fontSize: 40, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload background image
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
JPG, PNG up to 10MB
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
Leave empty to remove background (transparent)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Source Video
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{videoPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload video
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP4, WebM up to 500MB (max 10 minutes)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { BackgroundImageUpload } from './BackgroundImageUpload';
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { aiApiClient } from '../../../../../api/client';
|
||||
|
||||
export const useVideoBackgroundRemover = () => {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [backgroundImageFile, setBackgroundImageFile] = useState<File | null>(null);
|
||||
const [backgroundImagePreview, setBackgroundImagePreview] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number; has_background_replacement: boolean } | null>(null);
|
||||
const [estimatedDuration, setEstimatedDuration] = useState<number>(10.0);
|
||||
const [costEstimate, setCostEstimate] = useState<number | null>(null);
|
||||
|
||||
// Update previews when files change
|
||||
useEffect(() => {
|
||||
if (videoFile) {
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
|
||||
// Rough estimate: 1MB ≈ 1 second at 1080p
|
||||
const estimated = Math.max(5, videoFile.size / (1024 * 1024));
|
||||
setEstimatedDuration(estimated);
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
setEstimatedDuration(10.0);
|
||||
}
|
||||
}, [videoFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundImageFile) {
|
||||
const url = URL.createObjectURL(backgroundImageFile);
|
||||
setBackgroundImagePreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setBackgroundImagePreview(null);
|
||||
}
|
||||
}, [backgroundImageFile]);
|
||||
|
||||
// Fetch cost estimate when duration changes
|
||||
useEffect(() => {
|
||||
const fetchCostEstimate = async () => {
|
||||
if (!videoFile || estimatedDuration < 5) {
|
||||
setCostEstimate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('estimated_duration', estimatedDuration.toString());
|
||||
|
||||
const response = await aiApiClient.post('/api/video-studio/video-background-remover/estimate-cost', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.estimated_cost) {
|
||||
setCostEstimate(response.data.estimated_cost);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cost estimate:', err);
|
||||
// Fallback to client-side calculation
|
||||
// Pricing: $0.01/second, min $0.05 for ≤5s, max $6.00 for 600s
|
||||
const costPerSecond = 0.01;
|
||||
let estimatedCost = estimatedDuration * costPerSecond;
|
||||
if (estimatedDuration <= 5.0) {
|
||||
estimatedCost = 0.05; // Minimum charge
|
||||
} else if (estimatedDuration >= 600.0) {
|
||||
estimatedCost = 6.00; // Maximum charge
|
||||
}
|
||||
setCostEstimate(estimatedCost);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCostEstimate();
|
||||
}, [videoFile, estimatedDuration]);
|
||||
|
||||
const canRemove = useMemo(() => {
|
||||
return videoFile !== null;
|
||||
}, [videoFile]);
|
||||
|
||||
const costHint = useMemo(() => {
|
||||
if (!videoFile) return 'Upload a video to see cost estimate';
|
||||
|
||||
if (costEstimate !== null) {
|
||||
return `Est. ~$${costEstimate.toFixed(2)} (${estimatedDuration.toFixed(0)}s)`;
|
||||
}
|
||||
|
||||
// Fallback calculation
|
||||
// Pricing: $0.01/second, min $0.05 for ≤5s, max $6.00 for 600s
|
||||
const costPerSecond = 0.01;
|
||||
let estimatedCost = estimatedDuration * costPerSecond;
|
||||
if (estimatedDuration <= 5.0) {
|
||||
estimatedCost = 0.05; // Minimum charge
|
||||
} else if (estimatedDuration >= 600.0) {
|
||||
estimatedCost = 6.00; // Maximum charge
|
||||
}
|
||||
return `Est. ~$${estimatedCost.toFixed(2)} (${estimatedDuration.toFixed(0)}s)`;
|
||||
}, [videoFile, estimatedDuration, costEstimate]);
|
||||
|
||||
const removeBackground = async () => {
|
||||
if (!videoFile) return;
|
||||
|
||||
setRemoving(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('video_file', videoFile);
|
||||
if (backgroundImageFile) {
|
||||
formData.append('background_image_file', backgroundImageFile);
|
||||
}
|
||||
|
||||
// Submit background removal request
|
||||
setProgress(10);
|
||||
const response = await aiApiClient.post('/api/video-studio/video-background-remover', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const uploadProgress = Math.round((progressEvent.loaded * 30) / progressEvent.total);
|
||||
setProgress(uploadProgress);
|
||||
}
|
||||
},
|
||||
timeout: 600000, // 10 minutes timeout
|
||||
});
|
||||
|
||||
setProgress(40);
|
||||
|
||||
// Simulate progress updates
|
||||
let simulatedProgress = 40;
|
||||
const progressInterval = setInterval(() => {
|
||||
simulatedProgress = Math.min(90, simulatedProgress + 5);
|
||||
setProgress(simulatedProgress);
|
||||
}, 2000);
|
||||
|
||||
try {
|
||||
if (response.data.success) {
|
||||
clearInterval(progressInterval);
|
||||
setRemoving(false);
|
||||
setResult(response.data);
|
||||
setProgress(100);
|
||||
} else {
|
||||
clearInterval(progressInterval);
|
||||
throw new Error(response.data.error || 'Background removal failed');
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(progressInterval);
|
||||
throw err;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setRemoving(false);
|
||||
setProgress(0);
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to remove background');
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setRemoving(false);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setVideoFile(null);
|
||||
setBackgroundImageFile(null);
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
videoFile,
|
||||
videoPreview,
|
||||
backgroundImageFile,
|
||||
backgroundImagePreview,
|
||||
removing,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
estimatedDuration,
|
||||
costEstimate,
|
||||
// Setters
|
||||
setVideoFile,
|
||||
setBackgroundImageFile,
|
||||
// Computed
|
||||
canRemove,
|
||||
costHint,
|
||||
// Actions
|
||||
removeBackground,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VideoBackgroundRemover } from './VideoBackgroundRemover';
|
||||
export { default } from './VideoBackgroundRemover';
|
||||
@@ -0,0 +1,246 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box, Button, Typography, Stack, CircularProgress, LinearProgress, Alert, Paper } from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useVideoTranslate } from './hooks/useVideoTranslate';
|
||||
import { VideoUpload, LanguageSelector } from './components';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import TranslateIcon from '@mui/icons-material/Translate';
|
||||
|
||||
const VideoTranslate: React.FC = () => {
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
outputLanguage,
|
||||
translating,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
supportedLanguages,
|
||||
setVideoFile,
|
||||
setOutputLanguage,
|
||||
canTranslate,
|
||||
costHint,
|
||||
translateVideo,
|
||||
reset,
|
||||
} = useVideoTranslate();
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Video Translate Studio',
|
||||
subtitle: 'Translate videos to 70+ languages and 175+ dialects with AI. Preserves lip-sync and natural voice. Fast, accurate, and affordable at $0.0375/second.',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={3}>
|
||||
<VideoUpload videoPreview={videoPreview} onVideoSelect={setVideoFile} />
|
||||
|
||||
{videoFile && (
|
||||
<LanguageSelector
|
||||
outputLanguage={outputLanguage}
|
||||
supportedLanguages={supportedLanguages}
|
||||
onLanguageChange={setOutputLanguage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={translating ? <CircularProgress size={20} color="inherit" /> : <TranslateIcon />}
|
||||
onClick={translateVideo}
|
||||
disabled={!canTranslate || translating}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{translating ? 'Translating Video...' : 'Translate Video'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{videoFile && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Cost:</strong> {costHint}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Pricing: $0.0375/second
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{translating && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Progress: {progress}%
|
||||
</Typography>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => {}}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Result */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
{result ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '2px solid #10b981',
|
||||
backgroundColor: '#f0fdf4',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<CheckCircleIcon sx={{ color: '#10b981' }} />
|
||||
<Typography variant="h6" sx={{ color: '#065f46', fontWeight: 700 }}>
|
||||
Translation Complete!
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={result.video_url}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Target Language:</strong> {result.output_language}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Cost:</strong> ${result.cost.toFixed(4)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
href={result.video_url}
|
||||
download
|
||||
sx={{
|
||||
backgroundColor: '#10b981',
|
||||
'&:hover': {
|
||||
backgroundColor: '#059669',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={reset}>
|
||||
Translate Another
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : videoPreview ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Source Video Preview
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 500,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 6,
|
||||
borderRadius: 2,
|
||||
border: '2px dashed #cbd5e1',
|
||||
backgroundColor: '#f8fafc',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<TranslateIcon sx={{ fontSize: 64, color: '#cbd5e1', mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Upload a video to get started
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Your translated video will appear here
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { VideoTranslate };
|
||||
export default VideoTranslate;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Box, Paper, Stack, Typography, FormControl, InputLabel, Select, MenuItem, Autocomplete, TextField } from '@mui/material';
|
||||
import TranslateIcon from '@mui/icons-material/Translate';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
outputLanguage: string;
|
||||
supportedLanguages: string[];
|
||||
onLanguageChange: (language: string) => void;
|
||||
}
|
||||
|
||||
export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
outputLanguage,
|
||||
supportedLanguages,
|
||||
onLanguageChange,
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<TranslateIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Target Language
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Autocomplete
|
||||
value={outputLanguage}
|
||||
onChange={(event, newValue) => {
|
||||
if (newValue) {
|
||||
onLanguageChange(newValue);
|
||||
}
|
||||
}}
|
||||
options={supportedLanguages}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Select Language"
|
||||
placeholder="Search for a language..."
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
'& .MuiAutocomplete-input': {
|
||||
py: 1.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 1.5 }}
|
||||
>
|
||||
Supports 70+ languages and 175+ dialects. The video will be translated with
|
||||
lip-sync preservation.
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate video file
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Source Video
|
||||
</Typography>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{videoPreview ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 400,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
border: '2px dashed #cbd5e1',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<VideocamIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Click to upload video
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
MP4, WebM up to 500MB
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { LanguageSelector } from './LanguageSelector';
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { aiApiClient } from '../../../../../api/client';
|
||||
|
||||
export const useVideoTranslate = () => {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [outputLanguage, setOutputLanguage] = useState<string>('English');
|
||||
const [translating, setTranslating] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number; output_language: string } | null>(null);
|
||||
const [supportedLanguages, setSupportedLanguages] = useState<string[]>([]);
|
||||
|
||||
// Update preview when file changes
|
||||
useEffect(() => {
|
||||
if (videoFile) {
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
}
|
||||
}, [videoFile]);
|
||||
|
||||
// Load supported languages on mount
|
||||
useEffect(() => {
|
||||
const loadLanguages = async () => {
|
||||
try {
|
||||
const response = await aiApiClient.get('/api/video-studio/video-translate/languages');
|
||||
if (response.data.languages) {
|
||||
setSupportedLanguages(response.data.languages);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load languages:', err);
|
||||
// Use default list if API fails
|
||||
setSupportedLanguages([
|
||||
'English',
|
||||
'English (United States)',
|
||||
'English (UK)',
|
||||
'Spanish',
|
||||
'Spanish (Spain)',
|
||||
'Spanish (Mexico)',
|
||||
'French',
|
||||
'French (France)',
|
||||
'German',
|
||||
'German (Germany)',
|
||||
'Italian',
|
||||
'Portuguese',
|
||||
'Portuguese (Brazil)',
|
||||
'Chinese',
|
||||
'Chinese (Mandarin, Simplified)',
|
||||
'Japanese',
|
||||
'Korean',
|
||||
'Hindi',
|
||||
'Arabic',
|
||||
'Russian',
|
||||
]);
|
||||
}
|
||||
};
|
||||
loadLanguages();
|
||||
}, []);
|
||||
|
||||
const canTranslate = useMemo(() => {
|
||||
return videoFile !== null && outputLanguage !== '';
|
||||
}, [videoFile, outputLanguage]);
|
||||
|
||||
const costHint = useMemo(() => {
|
||||
if (!videoFile) return 'Upload video to see cost';
|
||||
|
||||
// HeyGen Video Translate pricing: $0.0375/s
|
||||
// We'll estimate based on a default duration (actual cost calculated on backend)
|
||||
const costPerSecond = 0.0375;
|
||||
const estimatedCost = costPerSecond * 10; // Estimate 10 seconds
|
||||
return `~$${estimatedCost.toFixed(2)} (estimated, based on video duration)`;
|
||||
}, [videoFile]);
|
||||
|
||||
const translateVideo = async (): Promise<void> => {
|
||||
if (!videoFile) return;
|
||||
|
||||
setTranslating(true);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('video_file', videoFile);
|
||||
formData.append('output_language', outputLanguage);
|
||||
|
||||
setProgress(10);
|
||||
|
||||
const response = await aiApiClient.post('/api/video-studio/video-translate', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const uploadProgress = Math.round((progressEvent.loaded * 20) / progressEvent.total);
|
||||
setProgress(10 + uploadProgress);
|
||||
}
|
||||
},
|
||||
timeout: 600000, // 10 minutes
|
||||
});
|
||||
|
||||
setProgress(50);
|
||||
|
||||
if (response.data.success) {
|
||||
setResult(response.data);
|
||||
setProgress(100);
|
||||
} else {
|
||||
throw new Error(response.data.error || 'Video translation failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to translate video');
|
||||
setProgress(0);
|
||||
} finally {
|
||||
setTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setVideoFile(null);
|
||||
setVideoPreview(null);
|
||||
setOutputLanguage('English');
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
return {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
outputLanguage,
|
||||
translating,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
supportedLanguages,
|
||||
setVideoFile,
|
||||
setOutputLanguage,
|
||||
canTranslate,
|
||||
costHint,
|
||||
translateVideo,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VideoTranslate } from './VideoTranslate';
|
||||
export { default } from './VideoTranslate';
|
||||
Reference in New Issue
Block a user