AI Researcher and Video Studio implementation complete

This commit is contained in:
ajaysi
2026-01-05 15:49:51 +05:30
parent b134e9dc7e
commit 0b63ae7fc1
200 changed files with 39535 additions and 1375 deletions

View File

@@ -16,11 +16,12 @@ export const VideoStudioDashboard: React.FC = () => {
sx={{
maxWidth: 1400,
mx: 'auto',
borderRadius: 4,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.78)',
borderRadius: 5,
border: '1px solid rgba(255,255,255,0.12)',
background: 'rgba(15,23,42,0.85)',
p: { xs: 3, md: 5 },
backdropFilter: 'blur(25px)',
backdropFilter: 'blur(30px)',
boxShadow: '0 20px 60px rgba(15,23,42,0.6), inset 0 1px 0 rgba(255,255,255,0.05)',
}}
>

View File

@@ -39,11 +39,21 @@ export const VideoStudioLayout: React.FC<VideoStudioLayoutProps> = ({
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 40%, #312e81 100%)',
background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 35%, #312e81 70%, #1e1b4b 100%)',
py: 4,
px: 2,
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(circle at 20% 50%, rgba(99,102,241,0.15) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139,92,246,0.15) 0%, transparent 50%)',
pointerEvents: 'none',
},
}}
>
<Box

View File

@@ -0,0 +1,388 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
Box,
Stack,
Chip,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import { alpha } from '@mui/material/styles';
export interface AIModel {
name: string;
provider: string;
capabilities: string[];
pricing: {
model: 'per_second' | 'per_video' | 'flat_rate' | 'free';
rate?: number;
min?: number;
max?: number;
unit?: string;
description: string;
};
features: string[];
}
export interface PerfectForUseCase {
title: string;
description: string;
examples: string[];
}
export interface CostDetail {
factors: string[];
typicalRange?: string;
examples: Array<{
scenario: string;
cost: string;
}>;
}
interface InfoModalProps {
open: boolean;
onClose: () => void;
title: string;
type: 'perfect-for' | 'cost' | 'ai-models';
perfectFor?: PerfectForUseCase[];
costDetails?: CostDetail;
aiModels?: AIModel[];
}
export const InfoModal: React.FC<InfoModalProps> = ({
open,
onClose,
title,
type,
perfectFor,
costDetails,
aiModels,
}) => {
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
background: '#ffffff',
border: '2px solid rgba(139,92,246,0.3)',
borderRadius: 4,
boxShadow: '0 24px 48px rgba(0,0,0,0.3), 0 0 0 1px rgba(0,0,0,0.05)',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '2px solid rgba(139,92,246,0.2)',
pb: 2.5,
background: 'linear-gradient(135deg, rgba(139,92,246,0.1), rgba(99,102,241,0.08))',
}}
>
<Typography variant="h6" fontWeight={800} sx={{ color: '#1e293b', fontSize: '1.25rem' }}>
{title}
</Typography>
<IconButton
onClick={onClose}
size="small"
sx={{
color: '#64748b',
'&:hover': {
background: 'rgba(239,68,68,0.1)',
color: '#ef4444',
},
}}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent sx={{ pt: 3, backgroundColor: '#ffffff' }}>
{type === 'perfect-for' && perfectFor && (
<Stack spacing={3}>
{perfectFor.map((useCase, index) => (
<Box key={index}>
<Typography
variant="subtitle1"
fontWeight={800}
sx={{ color: '#6366f1', mb: 1.5, fontSize: '1.1rem' }}
>
{useCase.title}
</Typography>
<Typography
variant="body2"
sx={{ color: '#334155', mb: 1.5, lineHeight: 1.8, fontSize: '0.95rem' }}
>
{useCase.description}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{useCase.examples.map((example, idx) => (
<Chip
key={idx}
label={example}
size="small"
sx={{
background: 'rgba(139,92,246,0.1)',
color: '#6366f1',
border: '1px solid rgba(139,92,246,0.3)',
fontWeight: 600,
}}
/>
))}
</Stack>
{index < perfectFor.length - 1 && (
<Divider sx={{ mt: 2, borderColor: 'rgba(255,255,255,0.1)' }} />
)}
</Box>
))}
</Stack>
)}
{type === 'cost' && costDetails && (
<Stack spacing={3}>
<Box>
<Typography
variant="subtitle1"
fontWeight={800}
sx={{ color: '#0ea5e9', mb: 1.5, fontSize: '1.1rem' }}
>
Cost Factors
</Typography>
<Stack spacing={1.5}>
{costDetails.factors.map((factor, idx) => (
<Box
key={idx}
sx={{
p: 2,
borderRadius: 2,
background: 'rgba(14,165,233,0.08)',
border: '1px solid rgba(56,189,248,0.2)',
}}
>
<Typography
variant="body2"
sx={{ color: '#334155', fontWeight: 600, lineHeight: 1.7 }}
>
{factor}
</Typography>
</Box>
))}
</Stack>
</Box>
{costDetails.typicalRange && (
<Box>
<Typography
variant="subtitle1"
fontWeight={700}
sx={{ color: '#c7d2fe', mb: 1.5 }}
>
Typical Cost Range
</Typography>
<Chip
label={costDetails.typicalRange}
sx={{
background: 'linear-gradient(120deg, rgba(16,185,129,0.15), rgba(34,197,94,0.1))',
color: '#059669',
border: '1px solid rgba(34,197,94,0.3)',
fontWeight: 700,
fontSize: '0.9rem',
p: 1,
}}
/>
</Box>
)}
{costDetails.examples && costDetails.examples.length > 0 && (
<Box>
<Typography
variant="subtitle1"
fontWeight={700}
sx={{ color: '#c7d2fe', mb: 1.5 }}
>
Cost Examples
</Typography>
<TableContainer
component={Paper}
sx={{
background: '#f8fafc',
border: '1px solid rgba(0,0,0,0.1)',
}}
>
<Table size="small">
<TableHead>
<TableRow sx={{ background: 'rgba(139,92,246,0.05)' }}>
<TableCell sx={{ color: '#1e293b', fontWeight: 700 }}>Scenario</TableCell>
<TableCell align="right" sx={{ color: '#1e293b', fontWeight: 700 }}>
Estimated Cost
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{costDetails.examples.map((example, idx) => (
<TableRow key={idx}>
<TableCell sx={{ color: '#334155' }}>
{example.scenario}
</TableCell>
<TableCell align="right" sx={{ color: '#059669', fontWeight: 700 }}>
{example.cost}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Stack>
)}
{type === 'ai-models' && aiModels && (
<Stack spacing={3}>
{aiModels.map((model, index) => (
<Box
key={index}
sx={{
p: 2.5,
borderRadius: 2,
background: 'rgba(99,102,241,0.05)',
border: '1px solid rgba(139,92,246,0.2)',
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={800} sx={{ color: '#6366f1' }}>
{model.name}
</Typography>
<Chip
label={model.provider}
size="small"
sx={{
background: 'rgba(139,92,246,0.1)',
color: '#6366f1',
fontWeight: 600,
}}
/>
</Stack>
<Box sx={{ mb: 2 }}>
<Typography
variant="subtitle2"
fontWeight={800}
sx={{ color: '#6366f1', mb: 1.5, fontSize: '0.95rem' }}
>
Capabilities
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{model.capabilities.map((capability, idx) => (
<Chip
key={idx}
label={capability}
size="small"
sx={{
background: 'rgba(14,165,233,0.1)',
color: '#0ea5e9',
border: '1px solid rgba(56,189,248,0.2)',
fontWeight: 600,
}}
/>
))}
</Stack>
</Box>
<Box sx={{ mb: 2 }}>
<Typography
variant="subtitle2"
fontWeight={800}
sx={{ color: '#99f6e4', mb: 1.5, fontSize: '0.95rem' }}
>
Pricing
</Typography>
<Box
sx={{
p: 2,
borderRadius: 2,
background: 'rgba(16,185,129,0.08)',
border: '2px solid rgba(34,197,94,0.2)',
}}
>
<Typography
variant="body2"
sx={{ color: '#059669', fontWeight: 700, mb: 1, fontSize: '1rem' }}
>
{model.pricing.description}
</Typography>
{model.pricing.rate && (
<Typography variant="body2" sx={{ color: '#334155', fontWeight: 600 }}>
Rate: <span style={{ color: '#059669' }}>${model.pricing.rate}</span>
{model.pricing.unit || '/second'}
{model.pricing.min && ` (min: $${model.pricing.min})`}
{model.pricing.max && ` (max: $${model.pricing.max})`}
</Typography>
)}
</Box>
</Box>
<Box>
<Typography
variant="subtitle2"
fontWeight={800}
sx={{ color: '#6366f1', mb: 1.5, fontSize: '0.95rem' }}
>
Key Features
</Typography>
<Stack spacing={1}>
{model.features.map((feature, idx) => (
<Typography
key={idx}
variant="body2"
sx={{ color: '#334155', pl: 1.5, lineHeight: 1.7 }}
>
{feature}
</Typography>
))}
</Stack>
</Box>
{index < aiModels.length - 1 && (
<Divider sx={{ mt: 2, borderColor: 'rgba(255,255,255,0.1)' }} />
)}
</Box>
))}
</Stack>
)}
</DialogContent>
<DialogActions sx={{ p: 2, borderTop: '1px solid rgba(0,0,0,0.1)', backgroundColor: '#f8fafc' }}>
<Box sx={{ flex: 1 }} />
<IconButton
onClick={onClose}
sx={{
background: 'rgba(139,92,246,0.1)',
color: '#6366f1',
'&:hover': {
background: 'rgba(139,92,246,0.2)',
},
}}
>
<CloseIcon />
</IconButton>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
Box,
Paper,
@@ -14,10 +14,14 @@ 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 PeopleIcon from '@mui/icons-material/People';
import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import { alpha } from '@mui/material/styles';
import type { ModuleConfig } from './types';
import { statusStyles } from './modules';
import { CreateVideoPreview, AvatarVideoPreview, EnhanceVideoPreview } from './previews';
import { CreateVideoPreview, AvatarVideoPreview, EnhanceVideoPreview, VideoTranslatePreview, VideoBackgroundRemoverPreview } from './previews';
import { InfoModal } from './InfoModal';
interface ModuleCardProps {
module: ModuleConfig;
@@ -36,25 +40,47 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
}) => {
const status = statusStyles[module.status];
const disabled = module.status !== 'live';
const [openModal, setOpenModal] = useState<'perfect-for' | 'cost' | 'ai-models' | null>(null);
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))',
p: 3.5,
border: isHovered
? '2px solid rgba(139,92,246,0.6)'
: '1px solid rgba(255,255,255,0.18)',
background: isHovered
? 'linear-gradient(160deg, rgba(30,41,59,0.98), rgba(51,65,85,0.95))'
: 'linear-gradient(160deg, rgba(15,23,42,0.98), rgba(30,41,59,0.95))',
display: 'flex',
flexDirection: 'column',
gap: 1.75,
gap: 2,
position: 'relative',
transition: 'transform 0.28s ease, box-shadow 0.28s ease, border-color 0.28s ease',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
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)',
? '0 32px 64px rgba(79,70,229,0.4), 0 0 0 1px rgba(139,92,246,0.2), inset 0 1px 0 rgba(255,255,255,0.1)'
: '0 8px 32px rgba(15,23,42,0.5), 0 0 0 1px rgba(255,255,255,0.05), inset 0 1px 0 rgba(255,255,255,0.05)',
transform: isHovered ? 'translateY(-6px) scale(1.01)' : 'translateY(0) scale(1)',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: isHovered
? 'linear-gradient(90deg, #8b5cf6, #6366f1, #3b82f6, #8b5cf6)'
: 'linear-gradient(90deg, rgba(139,92,246,0.3), rgba(99,102,241,0.2))',
backgroundSize: '200% 100%',
animation: isHovered ? 'shimmer 2s linear infinite' : 'none',
'@keyframes shimmer': {
'0%': { backgroundPosition: '200% 0' },
'100%': { backgroundPosition: '-200% 0' },
},
},
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
@@ -63,24 +89,45 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
width: 52,
height: 52,
borderRadius: 2.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: alpha('#6366f1', 0.2),
background: isHovered
? 'linear-gradient(135deg, rgba(139,92,246,0.35), rgba(99,102,241,0.3))'
: 'linear-gradient(135deg, rgba(99,102,241,0.25), rgba(139,92,246,0.2))',
color: '#c7d2fe',
fontSize: 22,
fontSize: 26,
border: '1px solid rgba(139,92,246,0.3)',
boxShadow: isHovered
? '0 8px 16px rgba(139,92,246,0.3), inset 0 1px 0 rgba(255,255,255,0.1)'
: '0 4px 12px rgba(99,102,241,0.2)',
transition: 'all 0.3s ease',
}}
>
{module.icon}
</Box>
<Stack spacing={0.25}>
<Typography variant="h6" fontWeight={700}>
<Typography
variant="h6"
fontWeight={800}
sx={{
color: '#f1f5f9',
fontSize: '1.25rem',
letterSpacing: '-0.02em',
}}
>
{module.title}
</Typography>
<Typography variant="body2" color="text.secondary">
<Typography
variant="body2"
sx={{
color: 'rgba(203,213,225,0.9)',
fontWeight: 500,
}}
>
{module.subtitle}
</Typography>
</Stack>
@@ -89,14 +136,25 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
label={status.label}
size="small"
sx={{
backgroundColor: alpha(status.color, 0.2),
backgroundColor: alpha(status.color, 0.25),
color: status.color,
fontWeight: 700,
border: `1px solid ${alpha(status.color, 0.4)}`,
boxShadow: `0 2px 8px ${alpha(status.color, 0.2)}`,
fontSize: '0.7rem',
height: 26,
}}
/>
</Stack>
<Typography variant="body2" sx={{ color: 'rgba(241,245,249,0.95)' }}>
<Typography
variant="body2"
sx={{
color: 'rgba(241,245,249,0.92)',
lineHeight: 1.7,
fontSize: '0.95rem',
}}
>
{module.description}
</Typography>
@@ -107,96 +165,223 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
size="small"
label={item}
sx={{
background: 'linear-gradient(120deg, rgba(99,102,241,0.45), rgba(14,165,233,0.38))',
background: isHovered
? 'linear-gradient(120deg, rgba(139,92,246,0.5), rgba(99,102,241,0.45))'
: 'linear-gradient(120deg, rgba(99,102,241,0.4), rgba(14,165,233,0.35))',
color: '#f8fafc',
border: '1px solid rgba(255,255,255,0.35)',
border: '1px solid rgba(255,255,255,0.4)',
fontWeight: 600,
letterSpacing: 0.2,
letterSpacing: 0.3,
fontSize: '0.75rem',
height: 28,
boxShadow: '0 2px 8px rgba(99,102,241,0.2)',
transition: 'all 0.3s ease',
}}
/>
))}
</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>
{/* Info Chips */}
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
{module.perfectFor && (
<Chip
icon={<PeopleIcon sx={{ fontSize: 18 }} />}
label="Perfect for"
onClick={() => setOpenModal(openModal === 'perfect-for' ? null : 'perfect-for')}
sx={{
background: openModal === 'perfect-for'
? 'linear-gradient(120deg, rgba(139,92,246,0.5), rgba(99,102,241,0.45))'
: 'linear-gradient(120deg, rgba(139,92,246,0.3), rgba(99,102,241,0.25))',
color: '#c7d2fe',
border: '2px solid rgba(139,92,246,0.6)',
fontWeight: 700,
fontSize: '0.8rem',
height: 36,
cursor: 'pointer',
px: 2,
py: 1,
boxShadow: openModal === 'perfect-for'
? '0 4px 12px rgba(139,92,246,0.4)'
: '0 2px 8px rgba(139,92,246,0.2)',
'&:hover': {
background: 'linear-gradient(120deg, rgba(139,92,246,0.4), rgba(99,102,241,0.35))',
border: '2px solid rgba(139,92,246,0.8)',
transform: 'translateY(-2px)',
boxShadow: '0 6px 16px rgba(139,92,246,0.4)',
},
transition: 'all 0.2s ease',
}}
/>
)}
{module.costDetails && (
<Chip
icon={<AttachMoneyIcon sx={{ fontSize: 18 }} />}
label="Cost depends on"
onClick={() => setOpenModal(openModal === 'cost' ? null : 'cost')}
sx={{
background: openModal === 'cost'
? 'linear-gradient(120deg, rgba(14,165,233,0.5), rgba(56,189,248,0.45))'
: 'linear-gradient(120deg, rgba(14,165,233,0.3), rgba(56,189,248,0.25))',
color: '#7dd3fc',
border: '2px solid rgba(56,189,248,0.6)',
fontWeight: 700,
fontSize: '0.8rem',
height: 36,
cursor: 'pointer',
px: 2,
py: 1,
boxShadow: openModal === 'cost'
? '0 4px 12px rgba(56,189,248,0.4)'
: '0 2px 8px rgba(56,189,248,0.2)',
'&:hover': {
background: 'linear-gradient(120deg, rgba(14,165,233,0.4), rgba(56,189,248,0.35))',
border: '2px solid rgba(56,189,248,0.8)',
transform: 'translateY(-2px)',
boxShadow: '0 6px 16px rgba(56,189,248,0.4)',
},
transition: 'all 0.2s ease',
}}
/>
)}
{module.aiModels && (
<Chip
icon={<SmartToyIcon sx={{ fontSize: 18 }} />}
label="AI Models"
onClick={() => setOpenModal(openModal === 'ai-models' ? null : 'ai-models')}
sx={{
background: openModal === 'ai-models'
? 'linear-gradient(120deg, rgba(16,185,129,0.5), rgba(34,197,94,0.45))'
: 'linear-gradient(120deg, rgba(16,185,129,0.3), rgba(34,197,94,0.25))',
color: '#99f6e4',
border: '2px solid rgba(34,197,94,0.6)',
fontWeight: 700,
fontSize: '0.8rem',
height: 36,
cursor: 'pointer',
px: 2,
py: 1,
boxShadow: openModal === 'ai-models'
? '0 4px 12px rgba(34,197,94,0.4)'
: '0 2px 8px rgba(34,197,94,0.2)',
'&:hover': {
background: 'linear-gradient(120deg, rgba(16,185,129,0.4), rgba(34,197,94,0.35))',
border: '2px solid rgba(34,197,94,0.8)',
transform: 'translateY(-2px)',
boxShadow: '0 6px 16px rgba(34,197,94,0.4)',
},
transition: 'all 0.2s ease',
}}
/>
)}
</Stack>
{/* Visual Preview Component */}
{module.status === 'live' && (
<Box sx={{ mt: 1 }}>
{(module.status === 'live' || module.status === 'beta') && (
<Box sx={{ mt: 1.5 }}>
{module.key === 'create' && <CreateVideoPreview />}
{module.key === 'avatar' && <AvatarVideoPreview />}
{module.key === 'enhance' && <EnhanceVideoPreview />}
{module.key === 'video-translate' && <VideoTranslatePreview />}
{module.key === 'video-background-remover' && <VideoBackgroundRemoverPreview />}
</Box>
)}
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 'auto' }}>
<Stack
direction="row"
spacing={1.5}
alignItems="center"
sx={{
mt: 'auto',
pt: 2,
borderTop: '1px solid rgba(255,255,255,0.1)',
}}
>
<Button
variant="contained"
size="small"
size="medium"
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)',
fontSize: '0.95rem',
px: 3,
py: 1.25,
background: disabled
? 'rgba(148,163,184,0.25)'
: isHovered
? 'linear-gradient(120deg, #8b5cf6, #6366f1)'
: 'linear-gradient(120deg, #6366f1, #8b5cf6)',
boxShadow: disabled
? 'none'
: isHovered
? '0 8px 24px rgba(139,92,246,0.5), 0 0 0 1px rgba(255,255,255,0.1)'
: '0 4px 16px rgba(99,102,241,0.4)',
'&:hover': {
background: 'linear-gradient(120deg, #8b5cf6, #6366f1)',
boxShadow: '0 12px 32px rgba(139,92,246,0.6), 0 0 0 1px rgba(255,255,255,0.15)',
transform: 'translateY(-2px)',
},
transition: 'all 0.3s ease',
}}
>
{disabled ? 'Preview' : 'Open'}
{disabled ? 'Preview' : 'Open Studio'}
</Button>
<Tooltip title="Feature details & roadmap">
<Tooltip
title="View detailed feature documentation and use cases"
arrow
placement="top"
>
<Button
size="small"
size="medium"
variant="text"
color="inherit"
onClick={() => onNavigate(module.route)}
sx={{ textTransform: 'none', color: '#c7d2fe' }}
sx={{
textTransform: 'none',
color: 'rgba(199,210,254,0.9)',
fontWeight: 600,
fontSize: '0.9rem',
'&:hover': {
color: '#c7d2fe',
background: 'rgba(139,92,246,0.1)',
},
}}
>
Learn more
</Button>
</Tooltip>
</Stack>
{/* Info Modals - Open on Click */}
{module.perfectFor && (
<InfoModal
open={openModal === 'perfect-for'}
onClose={() => setOpenModal(null)}
title={`Perfect for - ${module.title}`}
type="perfect-for"
perfectFor={module.perfectFor}
/>
)}
{module.costDetails && (
<InfoModal
open={openModal === 'cost'}
onClose={() => setOpenModal(null)}
title={`Cost Information - ${module.title}`}
type="cost"
costDetails={module.costDetails}
/>
)}
{module.aiModels && (
<InfoModal
open={openModal === 'ai-models'}
onClose={() => setOpenModal(null)}
title={`AI Models & Capabilities - ${module.title}`}
type="ai-models"
aiModels={module.aiModels}
/>
)}
</Paper>
);
};

View File

@@ -12,6 +12,7 @@ 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';
import type { AIModel, PerfectForUseCase, CostDetail } from './InfoModal';
export const statusStyles = {
live: { label: 'Live', color: '#10b981' },
@@ -25,45 +26,212 @@ export const videoStudioModules: ModuleConfig[] = [
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'],
'Transform text descriptions into engaging video content instantly. Perfect for content creators producing daily social media content, marketers creating campaign videos, and businesses generating product showcases. Supports text-to-video and image-to-video with automatic platform optimization for Instagram Reels, TikTok, YouTube Shorts, and LinkedIn.',
highlights: ['Text to Video', 'Image to Video', 'Auto Platform Optimization'],
status: 'live',
route: '/video-studio/create',
pricingNote: 'Cost depends on video length and quality. We show you the price before generating.',
pricingNote: 'Cost depends on video length and quality. We show you the exact price before generating. Typical range: $0.50-$1.50 per video.',
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'],
help: 'Perfect for content creators producing daily social media content, marketers creating campaign videos, and businesses generating product showcases. Just describe your video idea (e.g., "A modern coffee shop with baristas crafting latte art") and we create it with optimal settings for your chosen platform. Add background music or voiceover in post-production.',
costDrivers: ['Video duration (515 seconds recommended)', 'Resolution (480p/720p/1080p)', 'Platform format (9:16, 16:9, 1:1)'],
perfectFor: [
{
title: 'Content Creators',
description: 'Produce daily social media content for Instagram Reels, TikTok, and YouTube Shorts. Create engaging videos from simple text descriptions without video editing skills.',
examples: ['Instagram Reels', 'TikTok Videos', 'YouTube Shorts', 'Daily Content'],
},
{
title: 'Digital Marketers',
description: 'Create campaign videos, product showcases, and promotional content quickly. Generate multiple variations for A/B testing and multi-platform campaigns.',
examples: ['Campaign Videos', 'Product Showcases', 'Social Media Ads', 'A/B Testing'],
},
{
title: 'Businesses',
description: 'Generate professional product videos, explainer content, and brand storytelling videos. Perfect for e-commerce, SaaS, and service businesses.',
examples: ['Product Demos', 'Explainer Videos', 'Brand Content', 'E-commerce'],
},
],
costDetails: {
factors: [
'Video duration: 5-15 seconds recommended for optimal cost',
'Resolution: 480p ($0.50), 720p ($0.75), 1080p ($1.00+)',
'Platform format: Auto-optimized for Instagram, TikTok, YouTube, LinkedIn',
'Provider selection: Auto-selects best model based on requirements',
],
typicalRange: '$0.50 - $1.50 per video',
examples: [
{ scenario: '5-second Instagram Reel (720p)', cost: '$0.50' },
{ scenario: '10-second TikTok video (1080p)', cost: '$1.00' },
{ scenario: '15-second LinkedIn post (1080p)', cost: '$1.50' },
],
},
aiModels: [
{
name: 'WAN 2.5',
provider: 'WaveSpeed AI',
capabilities: ['Text-to-Video', 'Image-to-Video', 'High Quality', 'Fast Generation'],
pricing: {
model: 'per_second',
rate: 0.05,
unit: '/second',
description: '$0.05 per second (minimum 5 seconds, typical 5-15 seconds)',
},
features: [
'Best for short-form social media content',
'Automatic platform optimization',
'Motion and style control',
'High-quality output ready for social platforms',
],
},
{
name: 'Seedance 1.5 Pro',
provider: 'WaveSpeed AI',
capabilities: ['Text-to-Video', 'Longer Duration', 'Professional Quality'],
pricing: {
model: 'per_second',
rate: 0.08,
unit: '/second',
description: '$0.08 per second (best for 10-30 second videos)',
},
features: [
'Ideal for longer-form content',
'Professional-grade quality',
'Better motion continuity',
'Suitable for YouTube and LinkedIn',
],
},
],
},
{
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'],
'Transform static photos into dynamic talking videos with perfect lip-sync. Ideal for content creators building personal brands, marketers creating personalized campaigns, and businesses producing explainer videos. Upload a photo and audio to generate professional talking avatars that engage audiences across social platforms.',
highlights: ['Talking Avatars', 'Perfect Lip-sync', 'Multi-language Support'],
status: 'beta',
route: '/video-studio/avatar',
pricingNote: 'Cost depends on video length and quality',
pricingNote: 'Cost depends on video length and quality. Perfect for short-form content (5-30 seconds)',
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'],
help: 'Perfect for content creators building personal brands, marketers creating personalized video campaigns, and businesses producing explainer videos. Upload your photo and audio, and we create a talking video with perfect lip-sync. Great for Instagram Reels, LinkedIn videos, YouTube intros, and personalized customer messages.',
costDrivers: ['Video duration (seconds)', 'Resolution (480p/720p/1080p)'],
perfectFor: [
{
title: 'Personal Branding',
description: 'Build your personal brand with talking avatar videos. Perfect for content creators, coaches, and thought leaders who want to create engaging video content without appearing on camera.',
examples: ['YouTube Intros', 'Instagram Reels', 'LinkedIn Videos', 'Personal Branding'],
},
{
title: 'Marketing Campaigns',
description: 'Create personalized video messages for customers, product explainers, and campaign videos. Scale personalized video content without hiring actors or video production teams.',
examples: ['Customer Messages', 'Product Explainer', 'Campaign Videos', 'Personalization'],
},
{
title: 'Business Content',
description: 'Produce professional explainer videos, training content, and corporate communications. Transform static presentations into dynamic talking videos.',
examples: ['Explainer Videos', 'Training Content', 'Corporate Communications', 'E-learning'],
},
],
costDetails: {
factors: [
'Video duration: 5-30 seconds recommended',
'Resolution: 480p ($0.20), 720p ($0.40), 1080p ($0.60+)',
'Audio length determines video duration',
'Perfect lip-sync quality across all resolutions',
],
typicalRange: '$0.20 - $0.60 per video',
examples: [
{ scenario: '10-second talking avatar (720p)', cost: '$0.40' },
{ scenario: '20-second explainer video (1080p)', cost: '$0.60' },
{ scenario: '30-second personalized message (720p)', cost: '$0.60' },
],
},
aiModels: [
{
name: 'Hunyuan Avatar',
provider: 'Tencent',
capabilities: ['Talking Avatars', 'Perfect Lip-sync', 'Natural Expressions'],
pricing: {
model: 'per_second',
rate: 0.02,
unit: '/second',
description: '$0.02 per second (minimum 5 seconds)',
},
features: [
'Industry-leading lip-sync accuracy',
'Natural facial expressions and movements',
'Supports multiple languages',
'High-quality output for professional use',
],
},
],
},
{
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'],
'Transform low-resolution videos into professional-quality content. Upscale from 480p to 1080p or 4K, boost frame rate from 24fps to 60fps, and dramatically improve clarity. Essential for content creators upgrading phone footage, marketers repurposing old content, and businesses preparing videos for professional presentations.',
highlights: ['AI Upscaling', 'Frame Rate Boost', 'Professional Quality'],
status: 'live',
route: '/video-studio/enhance',
pricingNote: 'Cost depends on original quality and target quality',
pricingNote: 'Cost depends on original quality and target quality. FlashVSR AI model provides best results',
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'],
help: 'Perfect for content creators upgrading phone footage to professional quality, marketers repurposing old content for new campaigns, and businesses preparing videos for presentations. Transform 480p phone videos into 1080p professional content ready for YouTube, LinkedIn, and marketing materials. FlashVSR AI model ensures superior upscaling with motion preservation.',
costDrivers: ['Original resolution', 'Target resolution (1080p/4K)', 'Video duration'],
perfectFor: [
{
title: 'Content Upgrading',
description: 'Upgrade phone footage to professional quality. Transform 480p videos shot on mobile devices into 1080p content ready for YouTube, LinkedIn, and marketing materials.',
examples: ['Phone to Professional', 'YouTube Ready', 'LinkedIn Quality', 'Marketing Materials'],
},
{
title: 'Content Repurposing',
description: 'Repurpose old content for new campaigns. Enhance archived videos, upgrade legacy content, and breathe new life into existing video assets.',
examples: ['Archive Enhancement', 'Legacy Content', 'Campaign Repurposing', 'Asset Upgrading'],
},
{
title: 'Professional Presentations',
description: 'Prepare videos for professional presentations, client deliverables, and corporate communications. Ensure consistent quality across all video assets.',
examples: ['Client Deliverables', 'Corporate Videos', 'Presentations', 'Professional Quality'],
},
],
costDetails: {
factors: [
'Original resolution: Lower resolution = higher upscaling cost',
'Target resolution: 1080p ($0.10/s), 4K ($0.20/s)',
'Video duration: Cost scales with video length',
'Frame rate boost: Additional cost for 60fps conversion',
],
typicalRange: '$0.10 - $0.20 per second',
examples: [
{ scenario: '480p to 1080p (10 seconds)', cost: '$1.00' },
{ scenario: '720p to 4K (15 seconds)', cost: '$3.00' },
{ scenario: '1080p to 4K with 60fps (20 seconds)', cost: '$4.00' },
],
},
aiModels: [
{
name: 'FlashVSR',
provider: 'WaveSpeed AI',
capabilities: ['Video Upscaling', 'Motion Preservation', 'Quality Enhancement'],
pricing: {
model: 'per_second',
rate: 0.10,
unit: '/second',
description: '$0.10 per second for 1080p, $0.20 per second for 4K',
},
features: [
'Superior upscaling with motion preservation',
'Maintains video quality and details',
'Supports up to 4K resolution',
'Frame rate boost to 60fps available',
],
},
],
},
{
key: 'extend',
@@ -83,17 +251,56 @@ export const videoStudioModules: ModuleConfig[] = [
{
key: 'edit',
title: 'Edit Studio',
subtitle: 'Trim, enhance, and customize',
subtitle: 'Trim, speed control, and stabilization',
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',
'Free video editing using FFmpeg: trim/cut to time range, slow motion or fast forward (0.25x4x), and camera stabilization with vidstab. Perfect for polishing social clips and fixing shaky footage.',
highlights: ['Trim & Cut', 'Speed Control', 'Stabilization'],
status: 'live',
route: '/video-studio/edit',
pricingNote: 'Cost depends on video length and number of edits',
eta: 'Coming soon',
pricingNote: 'Free (FFmpeg processing)',
eta: 'Now',
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'],
help: 'Free editing operations: Trim video to specific time range, adjust playback speed (slow motion or fast forward), and stabilize shaky footage. More features coming soon!',
costDrivers: ['Free - no cost'],
perfectFor: [
{
title: 'Content Polishing',
description: 'Polish your social media content with professional editing tools. Trim unwanted sections, adjust speed for dramatic effect, and stabilize shaky footage.',
examples: ['Social Media', 'Content Polishing', 'Quick Edits', 'Post-Production'],
},
{
title: 'Video Optimization',
description: 'Optimize videos for different platforms. Trim to platform-specific durations, adjust speed for engagement, and fix camera shake.',
examples: ['Platform Optimization', 'Duration Control', 'Speed Adjustment', 'Quality Fix'],
},
],
costDetails: {
factors: ['All operations are free', 'No cost for trim, speed, or stabilization', 'FFmpeg-based processing', 'Unlimited usage'],
typicalRange: 'Free',
examples: [
{ scenario: 'Trim 10-second clip', cost: 'Free' },
{ scenario: 'Speed adjustment (2x)', cost: 'Free' },
{ scenario: 'Stabilize shaky footage', cost: 'Free' },
],
},
aiModels: [
{
name: 'FFmpeg',
provider: 'Open Source',
capabilities: ['Video Editing', 'Trim & Cut', 'Speed Control', 'Stabilization'],
pricing: {
model: 'free',
description: 'Free - No cost for all operations',
},
features: [
'Professional-grade video editing',
'Trim and cut to precise time ranges',
'Speed control from 0.25x to 4x',
'Camera stabilization with vidstab',
'Text overlay and audio controls',
],
},
],
},
{
key: 'transform',
@@ -154,6 +361,57 @@ export const videoStudioModules: ModuleConfig[] = [
icon: <TranslateIcon />,
help: 'Perfect for global content creators, localization, and reaching international audiences. No voice actors or dubbing needed.',
costDrivers: ['Video duration'],
perfectFor: [
{
title: 'Global Content',
description: 'Reach international audiences with translated video content. Perfect for content creators expanding to new markets and businesses going global.',
examples: ['International Marketing', 'Global Expansion', 'Multi-market Content', 'Localization'],
},
{
title: 'Localization',
description: 'Localize video content for different regions and languages. Maintain brand voice and messaging across all markets without hiring voice actors.',
examples: ['Content Localization', 'Regional Adaptation', 'Brand Consistency', 'Market Entry'],
},
{
title: 'Accessibility',
description: 'Make content accessible to diverse audiences. Translate educational content, tutorials, and informational videos to multiple languages.',
examples: ['Educational Content', 'Tutorials', 'Accessibility', 'Inclusive Content'],
},
],
costDetails: {
factors: [
'Video duration: Cost scales with video length',
'Language selection: All 70+ languages same price',
'Lip-sync preservation: Automatic and included',
'Natural voice: AI-generated voices maintain quality',
],
typicalRange: '$0.19 - $0.75 per video',
examples: [
{ scenario: '5-second video translation', cost: '$0.19' },
{ scenario: '10-second video translation', cost: '$0.38' },
{ scenario: '20-second video translation', cost: '$0.75' },
],
},
aiModels: [
{
name: 'HeyGen Video Translate',
provider: 'HeyGen',
capabilities: ['Video Translation', 'Lip-sync Preservation', '70+ Languages', 'Natural Voice'],
pricing: {
model: 'per_second',
rate: 0.0375,
unit: '/second',
description: '$0.0375 per second (70+ languages, 175+ dialects)',
},
features: [
'Translates to 70+ languages and 175+ dialects',
'Preserves perfect lip-sync',
'Natural-sounding AI voices',
'No voice actors or dubbing needed',
'Maintains original emotion and tone',
],
},
],
},
{
key: 'video-background-remover',
@@ -169,6 +427,59 @@ export const videoStudioModules: ModuleConfig[] = [
icon: <WallpaperIcon />,
help: 'Perfect for product videos, presentations, and creative content. Remove backgrounds or replace them with custom images.',
costDrivers: ['Video duration'],
perfectFor: [
{
title: 'Product Videos',
description: 'Create professional product videos with clean backgrounds. Remove distracting backgrounds or replace with branded environments for e-commerce and marketing.',
examples: ['E-commerce', 'Product Showcase', 'Marketing Videos', 'Branded Content'],
},
{
title: 'Presentations',
description: 'Prepare videos for presentations and corporate communications. Remove backgrounds for clean, professional look or replace with branded backgrounds.',
examples: ['Corporate Videos', 'Presentations', 'Professional Content', 'Business Communications'],
},
{
title: 'Creative Content',
description: 'Create creative video content with custom backgrounds. Perfect for social media, advertising, and artistic projects.',
examples: ['Social Media', 'Advertising', 'Creative Projects', 'Visual Effects'],
},
],
costDetails: {
factors: [
'Video duration: $0.01 per second',
'Minimum charge: $0.05 for videos ≤5 seconds',
'Maximum charge: $6.00 for videos up to 600 seconds',
'Background replacement: Same price as removal',
],
typicalRange: '$0.05 - $1.00 per video',
examples: [
{ scenario: '5-second background removal', cost: '$0.05' },
{ scenario: '10-second background replacement', cost: '$0.10' },
{ scenario: '30-second product video', cost: '$0.30' },
],
},
aiModels: [
{
name: 'Video Background Remover',
provider: 'WaveSpeed AI',
capabilities: ['Background Removal', 'Background Replacement', 'Clean Matting', 'Edge-Aware Blending'],
pricing: {
model: 'per_second',
rate: 0.01,
unit: '/second',
min: 0.05,
max: 6.00,
description: '$0.01 per second (min $0.05, max $6.00)',
},
features: [
'Automatic background detection and removal',
'Clean matting with edge-aware blending',
'Custom background replacement support',
'Transparent background option',
'Production-ready quality output',
],
},
],
},
{
key: 'add-audio-to-video',
@@ -184,20 +495,81 @@ export const videoStudioModules: ModuleConfig[] = [
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'],
perfectFor: [
{
title: 'Post-Production',
description: 'Add professional Foley and ambient audio to videos. Perfect for film, animation, and video production workflows.',
examples: ['Film Production', 'Animation', 'Video Production', 'Post-Production'],
},
{
title: 'Social Content',
description: 'Generate audio for social media content. Create engaging audio tracks for silent footage or enhance existing videos.',
examples: ['Social Media', 'Content Creation', 'Silent Footage', 'Audio Enhancement'],
},
],
costDetails: {
factors: [
'Model selection: Hunyuan (per-second) or Think Sound (flat rate)',
'Video duration: Longer videos cost more with Hunyuan',
'Audio quality: 48 kHz hi-fi output',
'Optional text prompts for sound guidance',
],
typicalRange: '$0.05 - $0.20 per video',
examples: [
{ scenario: '5-second video (Hunyuan)', cost: '$0.10' },
{ scenario: '10-second video (Think Sound)', cost: '$0.05' },
{ scenario: '15-second video (Hunyuan)', cost: '$0.30' },
],
},
aiModels: [
{
name: 'Hunyuan Video Foley',
provider: 'Tencent',
capabilities: ['Foley Generation', '48 kHz Hi-Fi', 'Multi-Scene Sync'],
pricing: {
model: 'per_second',
rate: 0.02,
unit: '/second',
description: '$0.02 per second (48 kHz hi-fi output)',
},
features: [
'48 kHz hi-fi audio quality',
'Multi-scene synchronization',
'Timing-accurate audio tracks',
'Optional text prompt control',
],
},
{
name: 'Think Sound',
provider: 'WaveSpeed AI',
capabilities: ['Context-Aware Audio', 'Prompt-Guided', 'Flat Rate'],
pricing: {
model: 'flat_rate',
rate: 0.05,
description: '$0.05 per video (flat rate, any duration)',
},
features: [
'Context-aware sound generation',
'Built-in prompt enhancer',
'Flat rate pricing (any duration)',
'Best for consistent pricing',
],
},
],
},
{
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',
'Search, filter, and organize all your video assets. Create collections, mark favorites, track usage, and manage your video content library with powerful search and filtering tools.',
highlights: ['Search & Filter', 'Collections', 'Favorites', 'Usage Tracking'],
status: 'live',
route: '/video-studio/library',
pricingNote: 'Storage and download costs',
eta: 'Beta',
pricingNote: 'Free (storage included)',
eta: 'Now',
icon: <LibraryBooksIcon />,
help: 'Perfect for content creators managing multiple videos. Keep everything organized, track usage, and share securely.',
costDrivers: ['Storage space', 'Downloads'],
help: 'Perfect for content creators managing multiple videos. Search by title, model, or prompt. Create collections to organize videos by project or campaign. Track downloads and usage.',
costDrivers: ['Free - no cost'],
},
];

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { Box, Stack, Typography, Chip } from '@mui/material';
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
export const VideoBackgroundRemoverPreview: React.FC = () => {
return (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '3px solid',
borderImage: 'linear-gradient(135deg, rgba(239,68,68,0.8), rgba(249,115,22,0.8), rgba(251,191,36,0.8)) 1',
overflow: 'hidden',
height: { xs: 260, md: 300 },
display: 'flex',
background: '#0f172a',
}}
>
<Box
sx={{
flex: '0 0 auto',
width: '50%',
position: 'relative',
overflow: 'hidden',
}}
>
<OptimizedVideo
src="/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4"
alt="Original video with background"
controls
muted
loop
playsInline
preload="metadata"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<Box
sx={{
position: 'absolute',
top: 16,
left: 16,
background: 'rgba(239,68,68,0.9)',
color: '#fff',
px: 2,
py: 1,
borderRadius: 2,
fontWeight: 700,
fontSize: '0.85rem',
}}
>
Original
</Box>
</Box>
<Box
sx={{
flex: '0 0 auto',
width: '50%',
position: 'relative',
overflow: 'hidden',
background: 'linear-gradient(135deg, #1e293b, #334155)',
}}
>
<OptimizedVideo
src="/videos/text-video-voiceover.mp4"
alt="Background removed video"
controls
muted
loop
playsInline
preload="metadata"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<Box
sx={{
position: 'absolute',
top: 16,
right: 16,
background: 'rgba(16,185,129,0.9)',
color: '#fff',
px: 2,
py: 1,
borderRadius: 2,
fontWeight: 700,
fontSize: '0.85rem',
}}
>
Background Removed
</Box>
<Box
sx={{
position: 'absolute',
bottom: 16,
left: 16,
right: 16,
background: 'rgba(15,23,42,0.9)',
color: '#fff',
p: 1.5,
borderRadius: 2,
}}
>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Chip
size="small"
label="Clean Matting"
sx={{ background: '#10b981', color: '#fff', fontWeight: 600, fontSize: '0.7rem' }}
/>
<Chip
size="small"
label="$0.01/s"
sx={{ background: '#3b82f6', color: '#fff', fontWeight: 600, fontSize: '0.7rem' }}
/>
</Stack>
</Box>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { Box, Stack, Typography, Chip } from '@mui/material';
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
export const VideoTranslatePreview: React.FC = () => {
return (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '3px solid',
borderImage: 'linear-gradient(135deg, rgba(14,165,233,0.8), rgba(99,102,241,0.8), rgba(139,92,246,0.8)) 1',
overflow: 'hidden',
height: { xs: 260, md: 300 },
display: 'flex',
background: '#0f172a',
}}
>
<Box
sx={{
flex: '0 0 auto',
width: '70%',
position: 'relative',
overflow: 'hidden',
}}
>
<OptimizedVideo
src="/videos/text-video-voiceover.mp4"
alt="Video translation example"
controls
muted
loop
playsInline
preload="metadata"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<Box
sx={{
position: 'absolute',
top: 16,
left: 16,
background: 'rgba(14,165,233,0.9)',
color: '#fff',
px: 2,
py: 1,
borderRadius: 2,
fontWeight: 700,
fontSize: '0.85rem',
}}
>
Original: English
</Box>
</Box>
<Box
sx={{
flex: '0 0 auto',
width: '30%',
background: 'rgba(248,250,252,0.95)',
color: '#0f172a',
p: 3,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
boxShadow: '-12px 0 24px rgba(15,23,42,0.25)',
}}
>
<Typography variant="overline" sx={{ letterSpacing: 1.5, color: '#0ea5e9', fontWeight: 700 }}>
Translate to 70+ Languages
</Typography>
<Typography variant="subtitle2" fontWeight={700}>
AI Video Translation
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.85rem' }}>
Preserves lip-sync and natural voice. Perfect for global content, localization, and reaching international audiences.
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
size="small"
label="70+ Languages"
sx={{ background: '#cffafe', color: '#0f766e', borderRadius: 999, fontWeight: 600 }}
/>
<Chip
size="small"
label="Lip-sync"
sx={{ background: '#ede9fe', color: '#4c1d95', borderRadius: 999, fontWeight: 600 }}
/>
<Chip
size="small"
label="$0.0375/s"
sx={{ background: '#dcfce7', color: '#166534', borderRadius: 999, fontWeight: 600 }}
/>
</Stack>
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5 }}>
Best for: Global content creators, localization, international marketing
</Typography>
</Box>
</Box>
);
};

View File

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

View File

@@ -1,3 +1,7 @@
import type { AIModel, PerfectForUseCase, CostDetail } from './InfoModal';
export type { AIModel, PerfectForUseCase, CostDetail };
export type ModuleStatus = 'live' | 'beta' | 'coming soon';
export interface ModuleConfig {
@@ -13,4 +17,7 @@ export interface ModuleConfig {
icon?: React.ReactNode;
help?: string;
costDrivers?: string[];
perfectFor?: PerfectForUseCase[];
costDetails?: CostDetail;
aiModels?: AIModel[];
}

View File

@@ -1,20 +0,0 @@
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,448 @@
import React from 'react';
import {
Grid,
Box,
Button,
Typography,
Stack,
CircularProgress,
LinearProgress,
Alert,
Paper,
} from '@mui/material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useEditVideo } from './hooks/useEditVideo';
import {
VideoUpload,
OperationSelector,
TrimSettings,
SpeedSettings,
StabilizeSettings,
TextOverlaySettings,
VolumeSettings,
NormalizeSettings,
DenoiseSettings,
} from './components';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import EditIcon from '@mui/icons-material/Edit';
export const EditVideo: React.FC = () => {
const {
videoFile,
videoPreview,
videoDuration,
setVideoFile,
operation,
setOperation,
startTime,
endTime,
maxDuration,
trimMode,
setStartTime,
setEndTime,
setMaxDuration,
setTrimMode,
speedFactor,
setSpeedFactor,
smoothing,
setSmoothing,
overlayText,
textPosition,
fontSize,
fontColor,
backgroundColor,
textStartTime,
textEndTime,
setOverlayText,
setTextPosition,
setFontSize,
setFontColor,
setBackgroundColor,
setTextStartTime,
setTextEndTime,
volumeFactor,
setVolumeFactor,
targetLevel,
setTargetLevel,
denoiseStrength,
setDenoiseStrength,
editing,
progress,
error,
result,
canEdit,
costHint,
operationDescription,
processVideo,
reset,
} = useEditVideo();
return (
<VideoStudioLayout
headerProps={{
title: 'Edit Studio',
subtitle:
'Trim, adjust speed, stabilize, add text, and enhance audio. All operations are free using FFmpeg.',
}}
>
<Grid container spacing={3}>
{/* Left Panel - Upload & Settings */}
<Grid item xs={12} lg={5}>
<Stack spacing={2}>
<VideoUpload
videoPreview={videoPreview}
videoDuration={videoDuration}
onVideoSelect={setVideoFile}
/>
{videoFile && (
<>
<OperationSelector
selectedOperation={operation}
onOperationChange={setOperation}
/>
{operation === 'trim' && (
<TrimSettings
videoDuration={videoDuration}
startTime={startTime}
endTime={endTime}
maxDuration={maxDuration}
trimMode={trimMode}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onMaxDurationChange={setMaxDuration}
onTrimModeChange={setTrimMode}
/>
)}
{operation === 'speed' && (
<SpeedSettings
videoDuration={videoDuration}
speedFactor={speedFactor}
onSpeedFactorChange={setSpeedFactor}
/>
)}
{operation === 'stabilize' && (
<StabilizeSettings
smoothing={smoothing}
onSmoothingChange={setSmoothing}
/>
)}
{operation === 'text' && (
<TextOverlaySettings
text={overlayText}
position={textPosition}
fontSize={fontSize}
fontColor={fontColor}
backgroundColor={backgroundColor}
startTime={textStartTime}
endTime={textEndTime}
videoDuration={videoDuration}
onTextChange={setOverlayText}
onPositionChange={setTextPosition}
onFontSizeChange={setFontSize}
onFontColorChange={setFontColor}
onBackgroundColorChange={setBackgroundColor}
onStartTimeChange={setTextStartTime}
onEndTimeChange={setTextEndTime}
/>
)}
{operation === 'volume' && (
<VolumeSettings
volumeFactor={volumeFactor}
onVolumeFactorChange={setVolumeFactor}
/>
)}
{operation === 'normalize' && (
<NormalizeSettings
targetLevel={targetLevel}
onTargetLevelChange={setTargetLevel}
/>
)}
{operation === 'denoise' && (
<DenoiseSettings
strength={denoiseStrength}
onStrengthChange={setDenoiseStrength}
/>
)}
</>
)}
<Box>
<Button
fullWidth
variant="contained"
size="large"
startIcon={
editing ? (
<CircularProgress size={20} color="inherit" />
) : (
<EditIcon />
)
}
onClick={processVideo}
disabled={!canEdit || editing}
sx={{
py: 1.5,
backgroundColor: '#3b82f6',
'&:hover': {
backgroundColor: '#2563eb',
},
'&:disabled': {
backgroundColor: '#cbd5e1',
color: '#94a3b8',
},
}}
>
{editing ? 'Processing...' : 'Process Video'}
</Button>
</Box>
{videoFile && (
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="body2" color="text.secondary">
Cost:
</Typography>
<Typography variant="body2" fontWeight={600} color="#10b981">
{costHint}
</Typography>
</Stack>
{operationDescription && (
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 1, display: 'block' }}
>
{operationDescription}
</Typography>
)}
</Box>
)}
</Stack>
</Grid>
{/* Right Panel - Preview & Result */}
<Grid item xs={12} lg={7}>
<Stack spacing={3}>
{/* Progress */}
{editing && (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#f8fafc',
}}
>
<Stack spacing={2}>
<Stack direction="row" alignItems="center" spacing={1}>
<CircularProgress size={20} />
<Typography variant="body2" color="text.secondary">
Processing video...
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#3b82f6',
},
}}
/>
<Typography variant="caption" color="text.secondary" textAlign="center">
{progress.toFixed(0)}% complete
</Typography>
</Stack>
</Paper>
)}
{/* Error */}
{error && (
<Alert
severity="error"
icon={<ErrorIcon />}
onClose={() => reset()}
sx={{ borderRadius: 2 }}
>
{error}
</Alert>
)}
{/* Result */}
{result && (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '2px solid #10b981',
backgroundColor: '#f0fdf4',
}}
>
<Stack spacing={2}>
<Stack direction="row" alignItems="center" spacing={1}>
<CheckCircleIcon sx={{ color: '#10b981' }} />
<Typography variant="h6" color="#0f172a" fontWeight={600}>
Video Processed Successfully!
</Typography>
</Stack>
<video
src={result.video_url}
controls
style={{
width: '100%',
maxHeight: '400px',
borderRadius: '8px',
objectFit: 'contain',
backgroundColor: '#000',
}}
/>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="body2" color="text.secondary">
Operation: <strong>{result.edit_type}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Cost: <strong>${result.cost.toFixed(4)}</strong>
</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}>
Edit Another Video
</Button>
</Stack>
</Stack>
</Paper>
)}
{/* Info Box */}
{!editing && !result && (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#f8fafc',
}}
>
<Typography
variant="subtitle2"
sx={{ mb: 2, fontWeight: 600, color: '#0f172a' }}
>
About Edit Studio
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Edit Studio provides free video editing operations using FFmpeg:
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 1, display: 'block' }}>
Video Processing
</Typography>
<Stack spacing={1} sx={{ mb: 2 }}>
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">
Trim & Cut
</Typography>
<Typography variant="caption" color="text.secondary">
Cut video to specific time range or limit to a maximum duration.
</Typography>
</Box>
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">
Speed Control
</Typography>
<Typography variant="caption" color="text.secondary">
Slow motion (0.25x-0.5x) or fast forward (1.5x-4x).
</Typography>
</Box>
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">
Stabilization
</Typography>
<Typography variant="caption" color="text.secondary">
Reduce camera shake using FFmpeg's vidstab filter.
</Typography>
</Box>
</Stack>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 1, display: 'block' }}>
Text & Audio
</Typography>
<Stack spacing={1}>
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">
Text Overlay
</Typography>
<Typography variant="caption" color="text.secondary">
Add captions, titles, or watermarks with customizable style.
</Typography>
</Box>
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">
Volume Control
</Typography>
<Typography variant="caption" color="text.secondary">
Mute, reduce, or boost audio volume.
</Typography>
</Box>
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">
Audio Normalization
</Typography>
<Typography variant="caption" color="text.secondary">
EBU R128 loudness normalization for streaming platforms.
</Typography>
</Box>
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">
Noise Reduction
</Typography>
<Typography variant="caption" color="text.secondary">
Remove background noise like AC, hum, or hiss.
</Typography>
</Box>
</Stack>
</Paper>
)}
</Stack>
</Grid>
</Grid>
</VideoStudioLayout>
);
};
export default EditVideo;

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { Box, Typography, Paper, Slider, Stack, Chip } from '@mui/material';
import NoiseAwareIcon from '@mui/icons-material/NoiseAware';
interface DenoiseSettingsProps {
strength: number;
onStrengthChange: (value: number) => void;
}
export const DenoiseSettings: React.FC<DenoiseSettingsProps> = ({
strength,
onStrengthChange,
}) => {
const getStrengthLevel = (value: number) => {
if (value <= 0.3) return { label: 'Light', color: '#10b981', description: 'Subtle cleanup, preserves original audio quality' };
if (value <= 0.6) return { label: 'Moderate', color: '#3b82f6', description: 'Good for background noise like AC, fans' };
return { label: 'Strong', color: '#f59e0b', description: 'Heavy noise, may affect voice clarity' };
};
const level = getStrengthLevel(strength);
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 }}>
<NoiseAwareIcon sx={{ color: '#3b82f6' }} />
<Typography variant="subtitle2" sx={{ color: '#0f172a', fontWeight: 700 }}>
Noise Reduction Settings
</Typography>
</Stack>
<Stack spacing={3}>
{/* Strength Display */}
<Box>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h4" fontWeight={700} color="#0f172a">
{Math.round(strength * 100)}%
</Typography>
<Chip
label={level.label}
size="small"
sx={{
backgroundColor: level.color,
color: '#fff',
}}
/>
</Stack>
<Slider
value={strength}
onChange={(_, value) => onStrengthChange(value as number)}
min={0}
max={1}
step={0.1}
marks={[
{ value: 0, label: '0%' },
{ value: 0.3, label: '30%' },
{ value: 0.5, label: '50%' },
{ value: 0.7, label: '70%' },
{ value: 1, label: '100%' },
]}
sx={{
color: level.color,
'& .MuiSlider-markLabel': {
fontSize: '0.75rem',
},
}}
/>
</Box>
{/* Current Level Description */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary">
<strong>{level.label} Reduction:</strong> {level.description}
</Typography>
</Box>
{/* Tips */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#dbeafe',
border: '1px solid #93c5fd',
}}
>
<Typography variant="body2" color="#1e40af" sx={{ mb: 1 }}>
<strong>💡 Tips for Best Results</strong>
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="caption" color="#1e40af">
Start with light reduction and increase if needed
</Typography>
<Typography component="li" variant="caption" color="#1e40af">
High values may make voices sound muffled
</Typography>
<Typography component="li" variant="caption" color="#1e40af">
Works best on constant background noise (AC, hum)
</Typography>
<Typography component="li" variant="caption" color="#1e40af">
May not remove intermittent noises (clicks, pops)
</Typography>
</Stack>
</Box>
{strength > 0.7 && (
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#fef3c7',
border: '1px solid #fbbf24',
}}
>
<Typography variant="caption" color="#92400e">
High strength may affect audio quality. Consider using a lower setting and applying
normalization after.
</Typography>
</Box>
)}
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { Box, Typography, Paper, Slider, Stack, Chip, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
import TuneIcon from '@mui/icons-material/Tune';
interface NormalizeSettingsProps {
targetLevel: number;
onTargetLevelChange: (value: number) => void;
}
const presets = [
{ value: -14, label: 'Streaming (YouTube, Spotify)', description: '-14 LUFS' },
{ value: -16, label: 'Podcasts', description: '-16 LUFS' },
{ value: -23, label: 'TV Broadcast (EBU R128)', description: '-23 LUFS' },
{ value: -24, label: 'US Broadcast (ATSC A/85)', description: '-24 LUFS' },
];
export const NormalizeSettings: React.FC<NormalizeSettingsProps> = ({
targetLevel,
onTargetLevelChange,
}) => {
const currentPreset = presets.find((p) => p.value === targetLevel);
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 }}>
<TuneIcon sx={{ color: '#3b82f6' }} />
<Typography variant="subtitle2" sx={{ color: '#0f172a', fontWeight: 700 }}>
Audio Normalization Settings
</Typography>
</Stack>
<Stack spacing={3}>
{/* Preset Selection */}
<FormControl fullWidth size="small">
<InputLabel>Preset</InputLabel>
<Select
value={targetLevel}
label="Preset"
onChange={(e) => onTargetLevelChange(e.target.value as number)}
>
{presets.map((preset) => (
<MenuItem key={preset.value} value={preset.value}>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between" width="100%">
<span>{preset.label}</span>
<Chip label={preset.description} size="small" variant="outlined" />
</Stack>
</MenuItem>
))}
</Select>
</FormControl>
{/* Manual Slider */}
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Target Level: {targetLevel} LUFS
</Typography>
<Slider
value={targetLevel}
onChange={(_, value) => onTargetLevelChange(value as number)}
min={-30}
max={-10}
step={1}
marks={[
{ value: -30, label: '-30' },
{ value: -23, label: '-23' },
{ value: -16, label: '-16' },
{ value: -14, label: '-14' },
{ value: -10, label: '-10' },
]}
sx={{ color: '#3b82f6' }}
/>
</Box>
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#dbeafe',
border: '1px solid #93c5fd',
}}
>
<Typography variant="body2" color="#1e40af" sx={{ mb: 1 }}>
<strong>EBU R128 Normalization</strong>
</Typography>
<Typography variant="caption" color="#1e40af">
{currentPreset
? `Using ${currentPreset.label} preset (${currentPreset.description}). This ensures consistent audio levels across your content.`
: `Custom level: ${targetLevel} LUFS. Lower values = quieter, higher values = louder.`}
</Typography>
</Box>
{/* LUFS Explanation */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="caption" color="text.secondary">
<strong>What is LUFS?</strong> Loudness Units relative to Full Scale (LUFS) is the
industry standard for measuring audio loudness. It accounts for human perception,
making volume levels consistent across different content.
</Typography>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,182 @@
import React from 'react';
import { Box, Typography, Paper, Stack, Chip, Divider } from '@mui/material';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import SpeedIcon from '@mui/icons-material/Speed';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import TuneIcon from '@mui/icons-material/Tune';
import NoiseAwareIcon from '@mui/icons-material/NoiseAware';
import type { EditOperation } from '../hooks/useEditVideo';
interface OperationSelectorProps {
selectedOperation: EditOperation;
onOperationChange: (operation: EditOperation) => void;
}
interface OperationInfo {
key: EditOperation;
label: string;
description: string;
icon: React.ReactNode;
phase: 1 | 2;
}
const operations: OperationInfo[] = [
// Phase 1 - Video Operations
{
key: 'trim',
label: 'Trim & Cut',
description: 'Cut video to specific time range or max duration',
icon: <ContentCutIcon />,
phase: 1,
},
{
key: 'speed',
label: 'Speed Control',
description: 'Slow motion (0.25x-0.5x) or fast forward (1.5x-4x)',
icon: <SpeedIcon />,
phase: 1,
},
{
key: 'stabilize',
label: 'Stabilize',
description: 'Reduce camera shake with FFmpeg vidstab',
icon: <CameraAltIcon />,
phase: 1,
},
// Phase 2 - Text & Audio Operations
{
key: 'text',
label: 'Text Overlay',
description: 'Add text captions, titles, or watermarks',
icon: <TextFieldsIcon />,
phase: 2,
},
{
key: 'volume',
label: 'Volume Control',
description: 'Adjust audio volume (mute, reduce, boost)',
icon: <VolumeUpIcon />,
phase: 2,
},
{
key: 'normalize',
label: 'Normalize Audio',
description: 'EBU R128 loudness normalization for streaming',
icon: <TuneIcon />,
phase: 2,
},
{
key: 'denoise',
label: 'Noise Reduction',
description: 'Remove background noise (AC, hum, hiss)',
icon: <NoiseAwareIcon />,
phase: 2,
},
];
export const OperationSelector: React.FC<OperationSelectorProps> = ({
selectedOperation,
onOperationChange,
}) => {
const phase1Ops = operations.filter((op) => op.phase === 1);
const phase2Ops = operations.filter((op) => op.phase === 2);
const renderOperation = (op: OperationInfo) => (
<Box
key={op.key}
onClick={() => onOperationChange(op.key)}
sx={{
p: 1.5,
borderRadius: 2,
border: '2px solid',
borderColor: selectedOperation === op.key ? '#3b82f6' : '#e2e8f0',
backgroundColor: selectedOperation === op.key ? '#eff6ff' : '#ffffff',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: selectedOperation === op.key ? '#eff6ff' : '#f8fafc',
},
}}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={{
p: 0.75,
borderRadius: 1,
backgroundColor: selectedOperation === op.key ? '#3b82f6' : '#f1f5f9',
color: selectedOperation === op.key ? '#fff' : '#64748b',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{op.icon}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" fontWeight={600} color="#0f172a" noWrap>
{op.label}
</Typography>
<Chip
label="Free"
size="small"
sx={{
height: 16,
fontSize: '0.6rem',
backgroundColor: '#10b981',
color: '#fff',
}}
/>
</Stack>
<Typography variant="caption" color="text.secondary" noWrap>
{op.description}
</Typography>
</Box>
</Stack>
</Box>
);
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
}}
>
<Typography
variant="subtitle2"
sx={{
mb: 1.5,
color: '#0f172a',
fontWeight: 700,
}}
>
Edit Operation
</Typography>
{/* Phase 1: Video */}
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Video Processing
</Typography>
<Stack spacing={0.75} sx={{ mb: 2 }}>
{phase1Ops.map(renderOperation)}
</Stack>
<Divider sx={{ my: 1.5 }} />
{/* Phase 2: Audio */}
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
Text & Audio
</Typography>
<Stack spacing={0.75}>
{phase2Ops.map(renderOperation)}
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { Box, Typography, Paper, Slider, Stack, Chip } from '@mui/material';
import SpeedIcon from '@mui/icons-material/Speed';
interface SpeedSettingsProps {
videoDuration: number;
speedFactor: number;
onSpeedFactorChange: (value: number) => void;
}
const speedMarks = [
{ value: 0.25, label: '0.25x' },
{ value: 0.5, label: '0.5x' },
{ value: 1.0, label: '1x' },
{ value: 1.5, label: '1.5x' },
{ value: 2.0, label: '2x' },
{ value: 4.0, label: '4x' },
];
export const SpeedSettings: React.FC<SpeedSettingsProps> = ({
videoDuration,
speedFactor,
onSpeedFactorChange,
}) => {
const resultDuration = videoDuration / speedFactor;
const getSpeedLabel = (factor: number) => {
if (factor < 1) return 'Slow Motion';
if (factor === 1) return 'Normal';
return 'Fast Forward';
};
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 }}>
<SpeedIcon sx={{ color: '#3b82f6' }} />
<Typography
variant="subtitle2"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Speed Settings
</Typography>
</Stack>
<Stack spacing={3}>
{/* Speed Slider */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h4" fontWeight={700} color="#0f172a">
{speedFactor}x
</Typography>
<Chip
label={getSpeedLabel(speedFactor)}
size="small"
sx={{
backgroundColor: speedFactor < 1 ? '#6366f1' : speedFactor > 1 ? '#f59e0b' : '#10b981',
color: '#fff',
}}
/>
</Stack>
<Slider
value={speedFactor}
onChange={(_, value) => onSpeedFactorChange(value as number)}
min={0.25}
max={4.0}
step={0.25}
marks={speedMarks}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}x`}
sx={{
color: '#3b82f6',
'& .MuiSlider-markLabel': {
fontSize: '0.75rem',
},
}}
/>
</Box>
{/* Duration Preview */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="caption" color="text.secondary">
Original Duration
</Typography>
<Typography variant="body1" fontWeight={600} color="#0f172a">
{videoDuration.toFixed(1)}s
</Typography>
</Box>
<Typography variant="h5" color="#64748b">
</Typography>
<Box>
<Typography variant="caption" color="text.secondary">
Result Duration
</Typography>
<Typography variant="body1" fontWeight={600} color="#3b82f6">
{resultDuration.toFixed(1)}s
</Typography>
</Box>
</Stack>
</Box>
{/* Tips */}
<Typography variant="caption" color="text.secondary">
{speedFactor < 1
? '💡 Slow motion is great for dramatic effect or analyzing motion'
: speedFactor > 1
? '💡 Fast forward can help condense long clips or create time-lapse effect'
: '💡 Normal speed keeps the video unchanged'}
</Typography>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { Box, Typography, Paper, Slider, Stack, Chip } from '@mui/material';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
interface StabilizeSettingsProps {
smoothing: number;
onSmoothingChange: (value: number) => void;
}
export const StabilizeSettings: React.FC<StabilizeSettingsProps> = ({
smoothing,
onSmoothingChange,
}) => {
const getStabilizationLevel = (value: number) => {
if (value <= 5) return { label: 'Light', color: '#10b981' };
if (value <= 15) return { label: 'Moderate', color: '#3b82f6' };
if (value <= 30) return { label: 'Strong', color: '#f59e0b' };
return { label: 'Maximum', color: '#ef4444' };
};
const level = getStabilizationLevel(smoothing);
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 }}>
<CameraAltIcon sx={{ color: '#3b82f6' }} />
<Typography
variant="subtitle2"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Stabilization Settings
</Typography>
</Stack>
<Stack spacing={3}>
{/* Smoothing Slider */}
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h4" fontWeight={700} color="#0f172a">
{smoothing}
</Typography>
<Chip
label={level.label}
size="small"
sx={{
backgroundColor: level.color,
color: '#fff',
}}
/>
</Stack>
<Slider
value={smoothing}
onChange={(_, value) => onSmoothingChange(value as number)}
min={1}
max={100}
step={1}
marks={[
{ value: 1, label: 'Min' },
{ value: 10, label: '10' },
{ value: 30, label: '30' },
{ value: 50, label: '50' },
{ value: 100, label: 'Max' },
]}
valueLabelDisplay="auto"
sx={{
color: '#3b82f6',
'& .MuiSlider-markLabel': {
fontSize: '0.75rem',
},
}}
/>
</Box>
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
<strong>Smoothing</strong> controls how aggressively camera shake is corrected.
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
<Typography component="li" variant="caption" color="text.secondary">
<strong>1-5:</strong> Light stabilization, preserves natural motion
</Typography>
<Typography component="li" variant="caption" color="text.secondary">
<strong>10-15:</strong> Recommended for handheld footage
</Typography>
<Typography component="li" variant="caption" color="text.secondary">
<strong>30+:</strong> Strong stabilization, may add slight zoom
</Typography>
</Stack>
</Box>
{/* Warning */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#fef3c7',
border: '1px solid #fbbf24',
}}
>
<Typography variant="caption" color="#92400e">
Stabilization uses FFmpeg's vidstab filter and requires FFmpeg with vidstab support.
Processing may take longer for high-resolution or long videos.
</Typography>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { Box, Typography, Paper, TextField, Slider, Stack, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
import TextFieldsIcon from '@mui/icons-material/TextFields';
interface TextOverlaySettingsProps {
text: string;
position: string;
fontSize: number;
fontColor: string;
backgroundColor: string;
startTime: number;
endTime: number | null;
videoDuration: number;
onTextChange: (value: string) => void;
onPositionChange: (value: string) => void;
onFontSizeChange: (value: number) => void;
onFontColorChange: (value: string) => void;
onBackgroundColorChange: (value: string) => void;
onStartTimeChange: (value: number) => void;
onEndTimeChange: (value: number | null) => void;
}
const positions = [
{ value: 'top', label: 'Top Center' },
{ value: 'center', label: 'Center' },
{ value: 'bottom', label: 'Bottom Center' },
{ value: 'top-left', label: 'Top Left' },
{ value: 'top-right', label: 'Top Right' },
{ value: 'bottom-left', label: 'Bottom Left' },
{ value: 'bottom-right', label: 'Bottom Right' },
];
const colors = [
{ value: 'white', label: 'White' },
{ value: 'black', label: 'Black' },
{ value: 'yellow', label: 'Yellow' },
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' },
{ value: 'green', label: 'Green' },
];
export const TextOverlaySettings: React.FC<TextOverlaySettingsProps> = ({
text,
position,
fontSize,
fontColor,
backgroundColor,
startTime,
endTime,
videoDuration,
onTextChange,
onPositionChange,
onFontSizeChange,
onFontColorChange,
onBackgroundColorChange,
onStartTimeChange,
onEndTimeChange,
}) => {
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 }}>
<TextFieldsIcon sx={{ color: '#3b82f6' }} />
<Typography variant="subtitle2" sx={{ color: '#0f172a', fontWeight: 700 }}>
Text Overlay Settings
</Typography>
</Stack>
<Stack spacing={3}>
{/* Text Input */}
<TextField
label="Text to Display"
value={text}
onChange={(e) => onTextChange(e.target.value)}
multiline
rows={2}
fullWidth
placeholder="Enter your text here..."
/>
{/* Position */}
<FormControl fullWidth size="small">
<InputLabel>Position</InputLabel>
<Select
value={position}
label="Position"
onChange={(e) => onPositionChange(e.target.value)}
>
{positions.map((pos) => (
<MenuItem key={pos.value} value={pos.value}>
{pos.label}
</MenuItem>
))}
</Select>
</FormControl>
{/* Font Size */}
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Font Size: {fontSize}px
</Typography>
<Slider
value={fontSize}
onChange={(_, value) => onFontSizeChange(value as number)}
min={12}
max={120}
step={4}
marks={[
{ value: 24, label: '24' },
{ value: 48, label: '48' },
{ value: 72, label: '72' },
{ value: 96, label: '96' },
]}
sx={{ color: '#3b82f6' }}
/>
</Box>
{/* Colors */}
<Stack direction="row" spacing={2}>
<FormControl fullWidth size="small">
<InputLabel>Font Color</InputLabel>
<Select
value={fontColor}
label="Font Color"
onChange={(e) => onFontColorChange(e.target.value)}
>
{colors.map((color) => (
<MenuItem key={color.value} value={color.value}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 16,
height: 16,
borderRadius: 1,
backgroundColor: color.value,
border: '1px solid #e2e8f0',
}}
/>
<span>{color.label}</span>
</Stack>
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>Background</InputLabel>
<Select
value={backgroundColor.split('@')[0]}
label="Background"
onChange={(e) => onBackgroundColorChange(`${e.target.value}@0.5`)}
>
{colors.map((color) => (
<MenuItem key={color.value} value={color.value}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 16,
height: 16,
borderRadius: 1,
backgroundColor: color.value,
border: '1px solid #e2e8f0',
opacity: 0.5,
}}
/>
<span>{color.label}</span>
</Stack>
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
{/* Time Range */}
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Display Time: {startTime.toFixed(1)}s - {endTime !== null ? `${endTime.toFixed(1)}s` : 'end'}
</Typography>
<Slider
value={[startTime, endTime ?? videoDuration]}
onChange={(_, value) => {
if (Array.isArray(value)) {
onStartTimeChange(value[0]);
onEndTimeChange(value[1] >= videoDuration ? null : value[1]);
}
}}
min={0}
max={videoDuration}
step={0.1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value.toFixed(1)}s`}
sx={{ color: '#3b82f6' }}
/>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,163 @@
import React from 'react';
import { Box, Typography, Paper, Slider, TextField, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import type { TrimMode } from '../hooks/useEditVideo';
interface TrimSettingsProps {
videoDuration: number;
startTime: number;
endTime: number;
maxDuration: number | null;
trimMode: TrimMode;
onStartTimeChange: (value: number) => void;
onEndTimeChange: (value: number) => void;
onMaxDurationChange: (value: number | null) => void;
onTrimModeChange: (value: TrimMode) => void;
}
export const TrimSettings: React.FC<TrimSettingsProps> = ({
videoDuration,
startTime,
endTime,
maxDuration,
trimMode,
onStartTimeChange,
onEndTimeChange,
onMaxDurationChange,
onTrimModeChange,
}) => {
const handleRangeChange = (_event: Event, value: number | number[]) => {
if (Array.isArray(value)) {
onStartTimeChange(value[0]);
onEndTimeChange(value[1]);
}
};
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 }}>
<ContentCutIcon sx={{ color: '#3b82f6' }} />
<Typography
variant="subtitle2"
sx={{
color: '#0f172a',
fontWeight: 700,
}}
>
Trim Settings
</Typography>
</Stack>
<Stack spacing={3}>
{/* Time Range Slider */}
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Select time range: {startTime.toFixed(1)}s - {endTime.toFixed(1)}s ({(endTime - startTime).toFixed(1)}s)
</Typography>
<Slider
value={[startTime, endTime]}
onChange={handleRangeChange}
min={0}
max={videoDuration}
step={0.1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value.toFixed(1)}s`}
sx={{
color: '#3b82f6',
'& .MuiSlider-thumb': {
width: 16,
height: 16,
},
}}
/>
</Box>
{/* Manual Time Input */}
<Stack direction="row" spacing={2}>
<TextField
label="Start (s)"
type="number"
size="small"
value={startTime}
onChange={(e) => onStartTimeChange(parseFloat(e.target.value) || 0)}
inputProps={{
min: 0,
max: endTime - 0.1,
step: 0.1,
}}
sx={{ flex: 1 }}
/>
<TextField
label="End (s)"
type="number"
size="small"
value={endTime}
onChange={(e) => onEndTimeChange(parseFloat(e.target.value) || videoDuration)}
inputProps={{
min: startTime + 0.1,
max: videoDuration,
step: 0.1,
}}
sx={{ flex: 1 }}
/>
</Stack>
{/* Max Duration Mode */}
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Or set max duration (optional):
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<TextField
label="Max Duration (s)"
type="number"
size="small"
value={maxDuration ?? ''}
onChange={(e) => {
const val = e.target.value;
onMaxDurationChange(val ? parseFloat(val) : null);
}}
inputProps={{
min: 1,
max: videoDuration,
step: 0.1,
}}
sx={{ width: 150 }}
/>
{maxDuration !== null && (
<ToggleButtonGroup
value={trimMode}
exclusive
onChange={(_, value) => value && onTrimModeChange(value)}
size="small"
>
<ToggleButton value="beginning" sx={{ px: 2 }}>
Beginning
</ToggleButton>
<ToggleButton value="middle" sx={{ px: 2 }}>
Middle
</ToggleButton>
<ToggleButton value="end" sx={{ px: 2 }}>
End
</ToggleButton>
</ToggleButtonGroup>
)}
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{maxDuration !== null
? `Will keep the ${trimMode} ${maxDuration}s of the video`
: 'Leave empty to use start/end times'}
</Typography>
</Box>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,188 @@
import React, { useRef, useState, useCallback } from 'react';
import { Box, Typography, Paper, Stack, IconButton } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloseIcon from '@mui/icons-material/Close';
interface VideoUploadProps {
videoPreview: string | null;
videoDuration: number;
onVideoSelect: (file: File | null) => void;
}
export const VideoUpload: React.FC<VideoUploadProps> = ({
videoPreview,
videoDuration,
onVideoSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragActive, setIsDragActive] = useState(false);
const handleFileSelect = (file: 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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onVideoSelect(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
handleFileSelect(file);
}
},
[]
);
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,
}}
>
Source Video
</Typography>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={handleInputChange}
/>
{videoPreview ? (
<Box sx={{ position: 'relative' }}>
<video
src={videoPreview}
controls
style={{
width: '100%',
maxHeight: '300px',
borderRadius: '8px',
objectFit: 'contain',
backgroundColor: '#000',
}}
/>
<IconButton
onClick={handleClear}
sx={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
color: '#fff',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
},
}}
size="small"
>
<CloseIcon fontSize="small" />
</IconButton>
<Typography
variant="caption"
sx={{
display: 'block',
mt: 1,
color: '#64748b',
textAlign: 'center',
}}
>
Duration: {videoDuration.toFixed(1)}s
</Typography>
</Box>
) : (
<Box
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
sx={{
p: 4,
borderRadius: 2,
border: '2px dashed',
borderColor: isDragActive ? '#3b82f6' : '#e2e8f0',
backgroundColor: isDragActive ? '#eff6ff' : '#f8fafc',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: '#3b82f6',
backgroundColor: '#eff6ff',
},
}}
>
<Stack spacing={2} alignItems="center">
<CloudUploadIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
<Typography variant="body2" color="text.secondary" textAlign="center">
{isDragActive
? 'Drop the video here...'
: 'Drag and drop a video file, or click to select'}
</Typography>
<Typography variant="caption" color="text.secondary">
Supported: MP4, MOV, AVI, WebM, MKV
</Typography>
</Stack>
</Box>
)}
</Paper>
);
};

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { Box, Typography, Paper, Slider, Stack, Chip } from '@mui/material';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import VolumeOffIcon from '@mui/icons-material/VolumeOff';
import VolumeMuteIcon from '@mui/icons-material/VolumeMute';
interface VolumeSettingsProps {
volumeFactor: number;
onVolumeFactorChange: (value: number) => void;
}
const volumeMarks = [
{ value: 0, label: 'Mute' },
{ value: 0.5, label: '50%' },
{ value: 1.0, label: '100%' },
{ value: 2.0, label: '200%' },
{ value: 3.0, label: '300%' },
];
export const VolumeSettings: React.FC<VolumeSettingsProps> = ({
volumeFactor,
onVolumeFactorChange,
}) => {
const getVolumeLabel = (factor: number) => {
if (factor === 0) return { label: 'Muted', color: '#64748b', icon: <VolumeOffIcon /> };
if (factor < 1) return { label: 'Reduced', color: '#f59e0b', icon: <VolumeMuteIcon /> };
if (factor === 1) return { label: 'Original', color: '#10b981', icon: <VolumeUpIcon /> };
if (factor <= 2) return { label: 'Boosted', color: '#3b82f6', icon: <VolumeUpIcon /> };
return { label: 'Loud', color: '#ef4444', icon: <VolumeUpIcon /> };
};
const info = getVolumeLabel(volumeFactor);
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 }}>
<VolumeUpIcon sx={{ color: '#3b82f6' }} />
<Typography variant="subtitle2" sx={{ color: '#0f172a', fontWeight: 700 }}>
Volume Settings
</Typography>
</Stack>
<Stack spacing={3}>
{/* Volume Display */}
<Box>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h3" fontWeight={700} color="#0f172a">
{Math.round(volumeFactor * 100)}%
</Typography>
<Chip
icon={info.icon}
label={info.label}
size="small"
sx={{
backgroundColor: info.color,
color: '#fff',
'& .MuiChip-icon': { color: '#fff' },
}}
/>
</Stack>
<Slider
value={volumeFactor}
onChange={(_, value) => onVolumeFactorChange(value as number)}
min={0}
max={3}
step={0.1}
marks={volumeMarks}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
sx={{
color: info.color,
'& .MuiSlider-markLabel': {
fontSize: '0.75rem',
},
}}
/>
</Box>
{/* Info Box */}
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#f1f5f9',
border: '1px solid #e2e8f0',
}}
>
<Typography variant="body2" color="text.secondary">
<strong>Volume Factor:</strong>
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0, mt: 1 }}>
<Typography component="li" variant="caption" color="text.secondary">
<strong>0%:</strong> Completely muted (silent video)
</Typography>
<Typography component="li" variant="caption" color="text.secondary">
<strong>50%:</strong> Half volume (quieter)
</Typography>
<Typography component="li" variant="caption" color="text.secondary">
<strong>100%:</strong> Original volume (no change)
</Typography>
<Typography component="li" variant="caption" color="text.secondary">
<strong>200%+:</strong> Boosted (may cause distortion)
</Typography>
</Stack>
</Box>
{volumeFactor > 2 && (
<Box
sx={{
p: 2,
borderRadius: 2,
backgroundColor: '#fef3c7',
border: '1px solid #fbbf24',
}}
>
<Typography variant="caption" color="#92400e">
High volume levels may cause audio distortion. Consider normalizing instead.
</Typography>
</Box>
)}
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,9 @@
export { VideoUpload } from './VideoUpload';
export { OperationSelector } from './OperationSelector';
export { TrimSettings } from './TrimSettings';
export { SpeedSettings } from './SpeedSettings';
export { StabilizeSettings } from './StabilizeSettings';
export { TextOverlaySettings } from './TextOverlaySettings';
export { VolumeSettings } from './VolumeSettings';
export { NormalizeSettings } from './NormalizeSettings';
export { DenoiseSettings } from './DenoiseSettings';

View File

@@ -0,0 +1,309 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { aiApiClient } from '../../../../../api/client';
export type EditOperation = 'trim' | 'speed' | 'stabilize' | 'text' | 'volume' | 'normalize' | 'denoise';
export type TrimMode = 'beginning' | 'middle' | 'end';
export const useEditVideo = () => {
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPreview, setVideoPreview] = useState<string | null>(null);
const [videoDuration, setVideoDuration] = useState<number>(10);
// Edit operation
const [operation, setOperation] = useState<EditOperation>('trim');
// Trim settings
const [startTime, setStartTime] = useState<number>(0);
const [endTime, setEndTime] = useState<number>(10);
const [maxDuration, setMaxDuration] = useState<number | null>(null);
const [trimMode, setTrimMode] = useState<TrimMode>('beginning');
// Speed settings
const [speedFactor, setSpeedFactor] = useState<number>(1.0);
// Stabilization settings
const [smoothing, setSmoothing] = useState<number>(10);
// Text overlay settings
const [overlayText, setOverlayText] = useState<string>('');
const [textPosition, setTextPosition] = useState<string>('center');
const [fontSize, setFontSize] = useState<number>(48);
const [fontColor, setFontColor] = useState<string>('white');
const [backgroundColor, setBackgroundColor] = useState<string>('black@0.5');
const [textStartTime, setTextStartTime] = useState<number>(0);
const [textEndTime, setTextEndTime] = useState<number | null>(null);
// Volume settings
const [volumeFactor, setVolumeFactor] = useState<number>(1.0);
// Normalize settings
const [targetLevel, setTargetLevel] = useState<number>(-14);
// Denoise settings
const [denoiseStrength, setDenoiseStrength] = useState<number>(0.5);
// State
const [editing, setEditing] = 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; edit_type: string } | null>(null);
// Update preview when file changes
useEffect(() => {
if (videoFile) {
const url = URL.createObjectURL(videoFile);
setVideoPreview(url);
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
setVideoDuration(video.duration);
setEndTime(video.duration);
URL.revokeObjectURL(video.src);
};
video.src = url;
return () => {
URL.revokeObjectURL(url);
};
} else {
setVideoPreview(null);
setVideoDuration(10);
setEndTime(10);
}
}, [videoFile]);
const canEdit = useMemo(() => {
if (!videoFile) return false;
switch (operation) {
case 'trim':
return startTime < endTime && endTime <= videoDuration;
case 'speed':
return speedFactor > 0 && speedFactor <= 4.0;
case 'stabilize':
return smoothing >= 1 && smoothing <= 100;
case 'text':
return overlayText.trim().length > 0;
case 'volume':
return volumeFactor >= 0 && volumeFactor <= 5.0;
case 'normalize':
return targetLevel >= -50 && targetLevel <= 0;
case 'denoise':
return denoiseStrength >= 0 && denoiseStrength <= 1;
default:
return false;
}
}, [videoFile, operation, startTime, endTime, videoDuration, speedFactor, smoothing, overlayText, volumeFactor, targetLevel, denoiseStrength]);
const costHint = useMemo(() => {
if (!videoFile) return 'Upload a video to see cost';
return 'Free (FFmpeg processing)';
}, [videoFile]);
const operationDescription = useMemo(() => {
switch (operation) {
case 'trim':
return `Trim video from ${startTime.toFixed(1)}s to ${endTime.toFixed(1)}s (${(endTime - startTime).toFixed(1)}s output)`;
case 'speed':
const resultDuration = videoDuration / speedFactor;
return `${speedFactor}x speed -> ${resultDuration.toFixed(1)}s output`;
case 'stabilize':
return `Stabilize with smoothing: ${smoothing} (higher = smoother)`;
case 'text':
return `Add "${overlayText.substring(0, 20)}${overlayText.length > 20 ? '...' : ''}" at ${textPosition}`;
case 'volume':
return `${volumeFactor === 0 ? 'Mute' : volumeFactor < 1 ? 'Reduce' : volumeFactor === 1 ? 'Keep' : 'Boost'} volume to ${Math.round(volumeFactor * 100)}%`;
case 'normalize':
return `Normalize audio to ${targetLevel} LUFS`;
case 'denoise':
return `Reduce noise at ${Math.round(denoiseStrength * 100)}% strength`;
default:
return '';
}
}, [operation, startTime, endTime, speedFactor, videoDuration, smoothing, overlayText, textPosition, volumeFactor, targetLevel, denoiseStrength]);
const processVideo = useCallback(async (): Promise<void> => {
if (!videoFile || !canEdit) return;
setEditing(true);
setProgress(0);
setError(null);
setResult(null);
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 90) return prev;
return prev + Math.random() * 10;
});
}, 1000);
try {
const formData = new FormData();
formData.append('file', videoFile);
let endpoint = '/api/video-studio/edit/';
switch (operation) {
case 'trim':
endpoint += 'trim';
formData.append('start_time', startTime.toString());
formData.append('end_time', endTime.toString());
if (maxDuration !== null) {
formData.append('max_duration', maxDuration.toString());
}
formData.append('trim_mode', trimMode);
break;
case 'speed':
endpoint += 'speed';
formData.append('speed_factor', speedFactor.toString());
break;
case 'stabilize':
endpoint += 'stabilize';
formData.append('smoothing', smoothing.toString());
break;
case 'text':
endpoint += 'text';
formData.append('text', overlayText);
formData.append('position', textPosition);
formData.append('font_size', fontSize.toString());
formData.append('font_color', fontColor);
formData.append('background_color', backgroundColor);
formData.append('start_time', textStartTime.toString());
if (textEndTime !== null) {
formData.append('end_time', textEndTime.toString());
}
break;
case 'volume':
endpoint += 'volume';
formData.append('volume_factor', volumeFactor.toString());
break;
case 'normalize':
endpoint += 'normalize';
formData.append('target_level', targetLevel.toString());
break;
case 'denoise':
endpoint += 'denoise';
formData.append('strength', denoiseStrength.toString());
break;
}
const response = await aiApiClient.post(endpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
clearInterval(progressInterval);
setProgress(100);
setResult({
video_url: response.data.video_url,
cost: response.data.cost || 0,
edit_type: response.data.edit_type,
});
} catch (err: any) {
clearInterval(progressInterval);
setError(err.response?.data?.detail || err.message || 'Video editing failed');
} finally {
setEditing(false);
}
}, [videoFile, canEdit, operation, startTime, endTime, maxDuration, trimMode, speedFactor, smoothing, overlayText, textPosition, fontSize, fontColor, backgroundColor, textStartTime, textEndTime, volumeFactor, targetLevel, denoiseStrength]);
const reset = useCallback(() => {
setVideoFile(null);
setVideoPreview(null);
setVideoDuration(10);
setOperation('trim');
setStartTime(0);
setEndTime(10);
setMaxDuration(null);
setTrimMode('beginning');
setSpeedFactor(1.0);
setSmoothing(10);
setOverlayText('');
setTextPosition('center');
setFontSize(48);
setFontColor('white');
setBackgroundColor('black@0.5');
setTextStartTime(0);
setTextEndTime(null);
setVolumeFactor(1.0);
setTargetLevel(-14);
setDenoiseStrength(0.5);
setEditing(false);
setProgress(0);
setError(null);
setResult(null);
}, []);
return {
// Video state
videoFile,
videoPreview,
videoDuration,
setVideoFile,
// Operation
operation,
setOperation,
// Trim settings
startTime,
endTime,
maxDuration,
trimMode,
setStartTime,
setEndTime,
setMaxDuration,
setTrimMode,
// Speed settings
speedFactor,
setSpeedFactor,
// Stabilization settings
smoothing,
setSmoothing,
// Text overlay settings
overlayText,
textPosition,
fontSize,
fontColor,
backgroundColor,
textStartTime,
textEndTime,
setOverlayText,
setTextPosition,
setFontSize,
setFontColor,
setBackgroundColor,
setTextStartTime,
setTextEndTime,
// Volume settings
volumeFactor,
setVolumeFactor,
// Normalize settings
targetLevel,
setTargetLevel,
// Denoise settings
denoiseStrength,
setDenoiseStrength,
// State
editing,
progress,
error,
result,
canEdit,
costHint,
operationDescription,
// Actions
processVideo,
reset,
};
};

View File

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

View File

@@ -0,0 +1,799 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Box,
Paper,
Typography,
TextField,
InputAdornment,
Grid,
Card,
CardContent,
CardMedia,
Chip,
IconButton,
Stack,
Button,
ButtonGroup,
Tabs,
Tab,
FormControl,
Select,
MenuItem,
InputLabel,
Divider,
CircularProgress,
Alert,
Snackbar,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Checkbox,
Tooltip,
Menu,
ListItemIcon,
ListItemText,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Autocomplete,
} from '@mui/material';
import {
Search,
GridView,
ViewList,
Favorite,
FavoriteBorder,
Download,
Share,
Delete,
VideoLibrary,
Collections,
Add,
Edit,
MoreVert,
CalendarToday,
CheckCircle,
HourglassEmpty,
Error as ErrorIcon,
Refresh,
Sort,
FilterList,
Folder,
FolderOpen,
} from '@mui/icons-material';
import { VideoStudioLayout } from '../../VideoStudioLayout';
import { useContentAssets, AssetFilters, ContentAsset } from '../../../../hooks/useContentAssets';
import { useCollections, Collection } from '../../../../hooks/useCollections';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div role="tabpanel" hidden={value !== index} {...other}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
const getStatusIcon = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return <CheckCircle sx={{ color: '#10b981', fontSize: 18 }} />;
case 'processing':
return <HourglassEmpty sx={{ color: '#f59e0b', fontSize: 18 }} />;
case 'failed':
return <ErrorIcon sx={{ color: '#ef4444', fontSize: 18 }} />;
default:
return <HourglassEmpty sx={{ color: '#6b7280', fontSize: 18 }} />;
}
};
export const LibraryVideo: React.FC = () => {
const [searchParams] = useSearchParams();
const urlSourceModule = searchParams.get('source_module');
const urlAssetType = searchParams.get('asset_type');
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [tabValue, setTabValue] = useState(0);
const [filterType, setFilterType] = useState(() => {
if (urlAssetType) {
return urlAssetType === 'video' ? 'videos' : 'all';
}
return 'videos'; // Default to videos for Video Studio
});
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [selectedAssets, setSelectedAssets] = useState<Set<number>>(new Set());
const [page, setPage] = useState(0);
const [pageSize] = useState(50);
const [anchorEl, setAnchorEl] = useState<{ [key: number]: HTMLElement | null }>({});
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success',
});
// Collections state
const [selectedCollection, setSelectedCollection] = useState<number | null>(null);
const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const [newCollectionDescription, setNewCollectionDescription] = useState('');
const {
collections,
loading: collectionsLoading,
createCollection,
deleteCollection,
addAssetsToCollection,
removeAssetsFromCollection,
refetch: refetchCollections,
} = useCollections();
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchQuery);
setPage(0);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
// Build filters
const filters: AssetFilters = useMemo(() => {
const baseFilters: AssetFilters = {
limit: pageSize,
offset: page * pageSize,
};
if (urlSourceModule) {
baseFilters.source_module = urlSourceModule as any;
} else {
// Default to video_studio sources for Video Studio
baseFilters.source_module = 'main_video_generation';
}
if (debouncedSearch) {
baseFilters.search = debouncedSearch;
}
if (filterType === 'videos') {
baseFilters.asset_type = 'video';
} else if (filterType === 'favorites') {
baseFilters.favorites_only = true;
}
if (tabValue === 1) {
baseFilters.favorites_only = true;
}
return baseFilters;
}, [debouncedSearch, filterType, tabValue, page, pageSize, urlSourceModule]);
// Update filters when collection is selected
const collectionFilters: AssetFilters = useMemo(() => {
const baseFilters = { ...filters };
if (selectedCollection !== null) {
baseFilters.collection_id = selectedCollection;
}
if (sortBy) {
baseFilters.sort_by = sortBy;
}
if (sortOrder) {
baseFilters.sort_order = sortOrder;
}
return baseFilters;
}, [filters, selectedCollection, sortBy, sortOrder]);
const { assets, loading, error, total, toggleFavorite, deleteAsset, trackUsage, refetch } = useContentAssets(collectionFilters);
// Use assets directly since backend now filters by collection_id
const filteredAssets = assets;
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
setPage(0);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedAssets(new Set(assets.map(a => a.id)));
} else {
setSelectedAssets(new Set());
}
};
const handleSelectAsset = (assetId: number, checked: boolean) => {
const newSelected = new Set(selectedAssets);
if (checked) {
newSelected.add(assetId);
} else {
newSelected.delete(assetId);
}
setSelectedAssets(newSelected);
};
const handleBulkDelete = async () => {
if (selectedAssets.size === 0) return;
if (!window.confirm(`Delete ${selectedAssets.size} selected asset(s)?`)) return;
try {
await Promise.all(Array.from(selectedAssets).map(id => deleteAsset(id)));
setSelectedAssets(new Set());
setSnackbar({ open: true, message: `${selectedAssets.size} asset(s) deleted`, severity: 'success' });
refetch();
} catch (err) {
setSnackbar({ open: true, message: 'Failed to delete assets', severity: 'error' });
}
};
const handleBulkDownload = async () => {
if (selectedAssets.size === 0) return;
try {
const selectedAssetsData = assets.filter(a => selectedAssets.has(a.id));
await Promise.all(selectedAssetsData.map(asset => trackUsage(asset.id, 'download')));
selectedAssetsData.forEach(asset => {
window.open(asset.file_url, '_blank');
});
setSnackbar({ open: true, message: `Downloading ${selectedAssets.size} asset(s)`, severity: 'success' });
} catch (err) {
setSnackbar({ open: true, message: 'Failed to download assets', severity: 'error' });
}
};
const handleFavorite = async (assetId: number) => {
try {
await toggleFavorite(assetId);
const asset = assets.find(a => a.id === assetId);
setSnackbar({
open: true,
message: asset?.is_favorite ? 'Removed from favorites' : 'Added to favorites',
severity: 'success',
});
} catch (err) {
setSnackbar({ open: true, message: 'Failed to update favorite', severity: 'error' });
}
};
const handleDelete = async (assetId: number) => {
if (!window.confirm('Are you sure you want to delete this asset?')) return;
try {
await deleteAsset(assetId);
setSnackbar({ open: true, message: 'Asset deleted', severity: 'success' });
refetch();
} catch (err) {
setSnackbar({ open: true, message: 'Failed to delete asset', severity: 'error' });
}
};
const handleDownload = async (asset: ContentAsset) => {
try {
await trackUsage(asset.id, 'download');
window.open(asset.file_url, '_blank');
} catch (err) {
console.error('Error downloading:', err);
}
};
const handleCreateCollection = async () => {
if (!newCollectionName.trim()) return;
try {
await createCollection({
name: newCollectionName,
description: newCollectionDescription,
is_public: false,
});
setCollectionDialogOpen(false);
setNewCollectionName('');
setNewCollectionDescription('');
setSnackbar({ open: true, message: 'Collection created', severity: 'success' });
} catch (err) {
setSnackbar({ open: true, message: 'Failed to create collection', severity: 'error' });
}
};
const handleAddToCollection = async (collectionId: number) => {
if (selectedAssets.size === 0) return;
try {
await addAssetsToCollection(collectionId, Array.from(selectedAssets));
setSelectedAssets(new Set());
setSnackbar({ open: true, message: 'Assets added to collection', severity: 'success' });
refetch();
} catch (err) {
setSnackbar({ open: true, message: 'Failed to add assets to collection', severity: 'error' });
}
};
const handleRemoveFromCollection = async (assetId: number) => {
if (!selectedCollection) return;
try {
await removeAssetsFromCollection(selectedCollection, [assetId]);
setSnackbar({ open: true, message: 'Asset removed from collection', severity: 'success' });
refetch();
} catch (err) {
setSnackbar({ open: true, message: 'Failed to remove asset from collection', severity: 'error' });
}
};
const handleDeleteCollection = async (collectionId: number) => {
if (!window.confirm('Are you sure you want to delete this collection? Assets will not be deleted.')) return;
try {
await deleteCollection(collectionId);
if (selectedCollection === collectionId) {
setSelectedCollection(null);
}
setSnackbar({ open: true, message: 'Collection deleted', severity: 'success' });
refetch();
} catch (err) {
setSnackbar({ open: true, message: 'Failed to delete collection', severity: 'error' });
}
};
return (
<VideoStudioLayout
headerProps={{
title: 'Asset Library',
subtitle: 'Manage and organize all your video assets. Search, filter, create collections, and track usage.',
}}
>
<Box sx={{ width: '100%' }}>
{/* Search and Filter Bar */}
<Paper elevation={0} sx={{ p: 2, mb: 3, borderRadius: 2, border: '1px solid #e2e8f0' }}>
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="center">
<TextField
fullWidth
placeholder="Search videos by title, description, prompt, or filename..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
size="small"
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Sort By</InputLabel>
<Select
value={sortBy}
label="Sort By"
onChange={(e) => setSortBy(e.target.value)}
>
<MenuItem value="created_at">Date Created</MenuItem>
<MenuItem value="updated_at">Last Updated</MenuItem>
<MenuItem value="cost">Cost</MenuItem>
<MenuItem value="file_size">File Size</MenuItem>
<MenuItem value="title">Title</MenuItem>
</Select>
</FormControl>
<ButtonGroup size="small">
<Button
variant={sortOrder === 'desc' ? 'contained' : 'outlined'}
onClick={() => setSortOrder('desc')}
>
Newest
</Button>
<Button
variant={sortOrder === 'asc' ? 'contained' : 'outlined'}
onClick={() => setSortOrder('asc')}
>
Oldest
</Button>
</ButtonGroup>
<ButtonGroup size="small">
<Button
variant={viewMode === 'grid' ? 'contained' : 'outlined'}
onClick={() => setViewMode('grid')}
startIcon={<GridView />}
>
Grid
</Button>
<Button
variant={viewMode === 'list' ? 'contained' : 'outlined'}
onClick={() => setViewMode('list')}
startIcon={<ViewList />}
>
List
</Button>
</ButtonGroup>
</Stack>
{/* Collections Sidebar */}
<Stack direction="row" spacing={2}>
<Paper
elevation={0}
sx={{
p: 2,
minWidth: 250,
borderRadius: 2,
border: '1px solid #e2e8f0',
backgroundColor: '#f8fafc',
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="subtitle2" fontWeight={700}>
Collections
</Typography>
<IconButton
size="small"
onClick={() => setCollectionDialogOpen(true)}
sx={{ color: '#3b82f6' }}
>
<Add />
</IconButton>
</Stack>
<Stack spacing={1}>
<Button
fullWidth
variant={selectedCollection === null ? 'contained' : 'outlined'}
startIcon={<VideoLibrary />}
onClick={() => setSelectedCollection(null)}
sx={{ justifyContent: 'flex-start' }}
>
All Videos
</Button>
{collections.map((collection) => (
<Button
key={collection.id}
fullWidth
variant={selectedCollection === collection.id ? 'contained' : 'outlined'}
startIcon={<Folder />}
onClick={() => setSelectedCollection(collection.id)}
sx={{ justifyContent: 'flex-start' }}
>
<Box sx={{ flex: 1, textAlign: 'left' }}>{collection.name}</Box>
<Chip label={collection.asset_count} size="small" sx={{ ml: 1 }} />
</Button>
))}
</Stack>
</Paper>
{/* Main Content Area */}
<Box sx={{ flex: 1 }}>
<Tabs value={tabValue} onChange={handleTabChange} sx={{ mb: 2 }}>
<Tab label="All Videos" />
<Tab label="Favorites" />
</Tabs>
<TabPanel value={tabValue} index={0}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
) : error ? (
<Alert severity="error" action={<Button onClick={refetch}>Retry</Button>}>
{error}
</Alert>
) : filteredAssets.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<VideoLibrary sx={{ fontSize: 64, color: '#94a3b8', mb: 2 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
No videos found
</Typography>
<Typography variant="body2" color="text.secondary">
{searchQuery ? 'Try adjusting your search query' : 'Your video assets will appear here'}
</Typography>
</Paper>
) : (
<>
{/* Bulk Actions */}
{selectedAssets.size > 0 && (
<Paper sx={{ p: 2, mb: 2, backgroundColor: '#eff6ff', border: '1px solid #93c5fd' }}>
<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="body2" fontWeight={600}>
{selectedAssets.size} selected
</Typography>
<Button size="small" onClick={handleBulkDownload} startIcon={<Download />}>
Download
</Button>
<Button size="small" color="error" onClick={handleBulkDelete} startIcon={<Delete />}>
Delete
</Button>
<Divider orientation="vertical" flexItem />
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Add to Collection</InputLabel>
<Select
value=""
label="Add to Collection"
onChange={(e) => handleAddToCollection(Number(e.target.value))}
>
{collections.map((collection) => (
<MenuItem key={collection.id} value={collection.id}>
{collection.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Paper>
)}
{/* Grid View */}
{viewMode === 'grid' ? (
<Grid container spacing={2}>
{filteredAssets.map((asset) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={asset.id}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: selectedAssets.has(asset.id) ? '2px solid #3b82f6' : '1px solid #e2e8f0',
}}
>
<Box sx={{ position: 'relative' }}>
<Checkbox
checked={selectedAssets.has(asset.id)}
onChange={(e) => handleSelectAsset(asset.id, e.target.checked)}
sx={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
}}
/>
<CardMedia
component="video"
src={asset.file_url}
sx={{
height: 200,
backgroundColor: '#000',
objectFit: 'contain',
}}
controls
/>
<IconButton
sx={{
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
color: '#fff',
'&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.8)' },
}}
size="small"
onClick={(e) => setAnchorEl({ ...anchorEl, [asset.id]: e.currentTarget })}
>
<MoreVert />
</IconButton>
</Box>
<CardContent sx={{ flexGrow: 1, p: 1.5 }}>
<Typography variant="body2" fontWeight={600} noWrap>
{asset.title || asset.filename}
</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: 'wrap', gap: 0.5 }}>
{asset.model && (
<Chip label={asset.model} size="small" variant="outlined" />
)}
{asset.cost > 0 && (
<Chip label={`$${asset.cost.toFixed(4)}`} size="small" variant="outlined" />
)}
</Stack>
</CardContent>
<Box sx={{ p: 1, borderTop: '1px solid #e2e8f0' }}>
<Stack direction="row" spacing={1} justifyContent="space-between">
<IconButton
size="small"
onClick={() => handleFavorite(asset.id)}
color={asset.is_favorite ? 'error' : 'default'}
>
{asset.is_favorite ? <Favorite /> : <FavoriteBorder />}
</IconButton>
<IconButton size="small" onClick={() => handleDownload(asset)}>
<Download />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDelete(asset.id)}>
<Delete />
</IconButton>
</Stack>
</Box>
<Menu
anchorEl={anchorEl[asset.id]}
open={Boolean(anchorEl[asset.id])}
onClose={() => setAnchorEl({ ...anchorEl, [asset.id]: null })}
>
<MenuItem onClick={() => handleDownload(asset)}>
<ListItemIcon><Download fontSize="small" /></ListItemIcon>
<ListItemText>Download</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleFavorite(asset.id)}>
<ListItemIcon>
{asset.is_favorite ? <Favorite fontSize="small" /> : <FavoriteBorder fontSize="small" />}
</ListItemIcon>
<ListItemText>{asset.is_favorite ? 'Remove from Favorites' : 'Add to Favorites'}</ListItemText>
</MenuItem>
{selectedCollection && (
<MenuItem onClick={() => handleRemoveFromCollection(asset.id)}>
<ListItemIcon><Delete fontSize="small" /></ListItemIcon>
<ListItemText>Remove from Collection</ListItemText>
</MenuItem>
)}
<Divider />
<MenuItem onClick={() => handleDelete(asset.id)}>
<ListItemIcon><Delete fontSize="small" color="error" /></ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
</Menu>
</Card>
</Grid>
))}
</Grid>
) : (
/* List View */
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid #e2e8f0' }}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
checked={selectedAssets.size === assets.length && assets.length > 0}
indeterminate={selectedAssets.size > 0 && selectedAssets.size < assets.length}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</TableCell>
<TableCell>Video</TableCell>
<TableCell>Title</TableCell>
<TableCell>Model</TableCell>
<TableCell>Cost</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredAssets.map((asset) => (
<TableRow key={asset.id} hover>
<TableCell padding="checkbox">
<Checkbox
checked={selectedAssets.has(asset.id)}
onChange={(e) => handleSelectAsset(asset.id, e.target.checked)}
/>
</TableCell>
<TableCell>
<video
src={asset.file_url}
style={{ width: 120, height: 68, objectFit: 'cover', borderRadius: 4 }}
controls
/>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={600}>
{asset.title || asset.filename}
</Typography>
{asset.description && (
<Typography variant="caption" color="text.secondary" noWrap>
{asset.description}
</Typography>
)}
</TableCell>
<TableCell>
{asset.model && <Chip label={asset.model} size="small" />}
</TableCell>
<TableCell>${asset.cost.toFixed(4)}</TableCell>
<TableCell>
{new Date(asset.created_at).toLocaleDateString()}
</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<IconButton size="small" onClick={() => handleFavorite(asset.id)}>
{asset.is_favorite ? <Favorite color="error" /> : <FavoriteBorder />}
</IconButton>
<IconButton size="small" onClick={() => handleDownload(asset)}>
<Download />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleDelete(asset.id)}>
<Delete />
</IconButton>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{/* Pagination */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 3 }}>
<Typography variant="body2" color="text.secondary">
Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, total)} of {total} videos
</Typography>
<Stack direction="row" spacing={1}>
<Button
disabled={page === 0}
onClick={() => setPage(p => p - 1)}
>
Previous
</Button>
<Button
disabled={(page + 1) * pageSize >= total}
onClick={() => setPage(p => p + 1)}
>
Next
</Button>
</Stack>
</Box>
</>
)}
</TabPanel>
<TabPanel value={tabValue} index={1}>
{/* Favorites tab - same content as All Videos but filtered */}
<Typography>Favorites view (same as above with favorites_only filter)</Typography>
</TabPanel>
</Box>
</Stack>
</Stack>
</Paper>
{/* Create Collection Dialog */}
<Dialog open={collectionDialogOpen} onClose={() => setCollectionDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create New Collection</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Collection Name"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
fullWidth
required
/>
<TextField
label="Description (optional)"
value={newCollectionDescription}
onChange={(e) => setNewCollectionDescription(e.target.value)}
fullWidth
multiline
rows={3}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setCollectionDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={handleCreateCollection}
disabled={!newCollectionName.trim()}
>
Create
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={snackbar.open}
autoHideDuration={3000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
message={snackbar.message}
/>
</Box>
</VideoStudioLayout>
);
};
export default LibraryVideo;

View File

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