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:
ajaysi
2026-01-01 17:56:25 +05:30
parent 7512933c65
commit b134e9dc7e
252 changed files with 40333 additions and 2712 deletions

View 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">
Well surface cost estimates, provider choices, and templates here as the module goes live.
</Typography>
</Stack>
</Paper>
</VideoStudioLayout>
);
};
export default ModulePlaceholder;

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

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

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

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

View 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 (510 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'],
},
];

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export { CreateVideoPreview } from './CreateVideoPreview';
export { AvatarVideoPreview } from './AvatarVideoPreview';
export { EnhanceVideoPreview } from './EnhanceVideoPreview';

View 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[];
}

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { VideoUpload } from './VideoUpload';
export { AudioSettings } from './AudioSettings';

View File

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

View File

@@ -0,0 +1,2 @@
export { AddAudioToVideo } from './AddAudioToVideo';
export { default } from './AddAudioToVideo';

View File

@@ -0,0 +1,3 @@
// Re-export from the AvatarVideo component
export { AvatarVideo } from './AvatarVideo/AvatarVideo';
export { default } from './AvatarVideo/AvatarVideo';

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export { ImageUpload } from './ImageUpload';
export { AudioUpload } from './AudioUpload';
export { AvatarSettings } from './AvatarSettings';

View File

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

View File

@@ -0,0 +1,2 @@
export { AvatarVideo } from './AvatarVideo';
export { default } from './AvatarVideo';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { CreateVideo } from './CreateVideo';
export { default } from './CreateVideo';

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
// Re-export from the EnhanceVideo component
export { EnhanceVideo } from './EnhanceVideo/EnhanceVideo';
export { default } from './EnhanceVideo/EnhanceVideo';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { VideoUpload } from './VideoUpload';
export { EnhancementSettings } from './EnhancementSettings';

View File

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

View File

@@ -0,0 +1,2 @@
export { EnhanceVideo } from './EnhanceVideo';
export { default } from './EnhanceVideo';

View File

@@ -0,0 +1,3 @@
// Re-export from the ExtendVideo component
export { ExtendVideo } from './ExtendVideo/ExtendVideo';
export { default } from './ExtendVideo/ExtendVideo';

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export { VideoUpload } from './VideoUpload';
export { AudioUpload } from './AudioUpload';
export { ExtendSettings } from './ExtendSettings';

View File

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

View File

@@ -0,0 +1,2 @@
export { ExtendVideo } from './ExtendVideo';
export { default } from './ExtendVideo';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export { ImageUpload } from './ImageUpload';
export { VideoUpload } from './VideoUpload';
export { SettingsPanel } from './SettingsPanel';
export { ModelSelector } from './ModelSelector';

View File

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

View File

@@ -0,0 +1,2 @@
export { FaceSwap } from './FaceSwap';
export { default } from './FaceSwap';

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export { VideoUpload } from './VideoUpload';
export { PlatformSelector } from './PlatformSelector';
export { OptimizationOptions } from './OptimizationOptions';
export { PreviewGrid } from './PreviewGrid';

View File

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

View File

@@ -0,0 +1,2 @@
export { SocialVideo } from './SocialVideo';
export { default } from './SocialVideo';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { TransformVideo } from './TransformVideo';
export { default } from './TransformVideo';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { VideoUpload } from './VideoUpload';
export { BackgroundImageUpload } from './BackgroundImageUpload';

View File

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

View File

@@ -0,0 +1,2 @@
export { VideoBackgroundRemover } from './VideoBackgroundRemover';
export { default } from './VideoBackgroundRemover';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { VideoUpload } from './VideoUpload';
export { LanguageSelector } from './LanguageSelector';

View File

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

View File

@@ -0,0 +1,2 @@
export { VideoTranslate } from './VideoTranslate';
export { default } from './VideoTranslate';