veo3/frontend/src/components/VideoForm.jsx
2025-11-04 02:31:40 +05:30

714 lines
No EOL
26 KiB
JavaScript

import React, { useState } from 'react';
import {
Paper,
Typography,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
Box,
Grid,
Accordion,
AccordionSummary,
AccordionDetails,
FormControlLabel,
Checkbox,
Alert
} from '@mui/material';
import {
PlayArrowRounded,
ExpandMoreRounded,
SettingsRounded,
CloudUploadRounded,
ImageRounded,
DeleteRounded
} from '@mui/icons-material';
import { VIDEO_GENERATION_OPTIONS, IMAGE_UPLOAD_CONFIG, REFERENCE_IMAGE_CONFIG } from '../utils/constants';
const VideoForm = ({ onSubmit, isGenerating, userJobs = [] }) => {
const [formData, setFormData] = useState({
prompt: '',
video_length_sec: 8,
aspect_ratio: '16:9',
person_generation: 'allow_adult',
model_name: 'veo-3.1-generate-preview',
seed: '',
generate_audio: true,
sampleCount: 1
});
const [errors, setErrors] = useState({});
const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const [selectedLastFrame, setSelectedLastFrame] = useState(null);
const [lastFramePreview, setLastFramePreview] = useState(null);
const [selectedReferenceImages, setSelectedReferenceImages] = useState([]);
const [referenceImagePreviews, setReferenceImagePreviews] = useState([]);
// Get model capabilities based on selected model
const selectedModelConfig = VIDEO_GENERATION_OPTIONS.models.find(m => m.value === formData.model_name) || {};
const modelCapabilities = {
supportsReferenceImages: selectedModelConfig.supportsReferenceImages || false,
supportsLastFrame: selectedModelConfig.supportsLastFrame || false,
supportsVideoExtension: selectedModelConfig.supportsVideoExtension || false
};
const handleChange = (field) => (event) => {
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
setFormData(prev => ({
...prev,
[field]: value
}));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ''
}));
}
};
const validateImage = (file) => {
const errors = [];
// Check file size
if (file.size > IMAGE_UPLOAD_CONFIG.maxSize) {
errors.push(`File too large. Maximum size: ${IMAGE_UPLOAD_CONFIG.maxSize / (1024 * 1024)}MB`);
}
// Check file type
if (!IMAGE_UPLOAD_CONFIG.supportedFormats.includes(file.type)) {
errors.push(`Unsupported format. Supported: ${IMAGE_UPLOAD_CONFIG.supportedExtensions.join(', ')}`);
}
return errors;
};
const handleImageSelect = (event) => {
const file = event.target.files[0];
if (!file) return;
const imageErrors = validateImage(file);
if (imageErrors.length > 0) {
setErrors(prev => ({
...prev,
image: imageErrors[0]
}));
return;
}
setSelectedImage(file);
setErrors(prev => ({
...prev,
image: ''
}));
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setImagePreview(e.target.result);
};
reader.readAsDataURL(file);
};
const handleImageRemove = () => {
setSelectedImage(null);
setImagePreview(null);
setErrors(prev => ({
...prev,
image: ''
}));
// Reset file input
const fileInput = document.getElementById('image-upload');
if (fileInput) fileInput.value = '';
};
const handleLastFrameSelect = (event) => {
const file = event.target.files[0];
if (!file) return;
const imageErrors = validateImage(file);
if (imageErrors.length > 0) {
setErrors(prev => ({
...prev,
lastFrame: imageErrors[0]
}));
return;
}
setSelectedLastFrame(file);
setErrors(prev => ({
...prev,
lastFrame: ''
}));
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setLastFramePreview(e.target.result);
};
reader.readAsDataURL(file);
};
const handleLastFrameRemove = () => {
setSelectedLastFrame(null);
setLastFramePreview(null);
setErrors(prev => ({
...prev,
lastFrame: ''
}));
const fileInput = document.getElementById('last-frame-upload');
if (fileInput) fileInput.value = '';
};
const handleReferenceImageSelect = (event) => {
const file = event.target.files[0];
if (!file) return;
// Check if max reference images reached
if (selectedReferenceImages.length >= REFERENCE_IMAGE_CONFIG.maxReferenceImages) {
setErrors(prev => ({
...prev,
referenceImages: `Maximum ${REFERENCE_IMAGE_CONFIG.maxReferenceImages} reference images allowed`
}));
return;
}
const imageErrors = validateImage(file);
if (imageErrors.length > 0) {
setErrors(prev => ({
...prev,
referenceImages: imageErrors[0]
}));
return;
}
setSelectedReferenceImages(prev => [...prev, file]);
setErrors(prev => ({
...prev,
referenceImages: ''
}));
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setReferenceImagePreviews(prev => [...prev, e.target.result]);
};
reader.readAsDataURL(file);
// Reset file input for next selection
event.target.value = '';
};
const handleReferenceImageRemove = (index) => {
setSelectedReferenceImages(prev => prev.filter((_, i) => i !== index));
setReferenceImagePreviews(prev => prev.filter((_, i) => i !== index));
setErrors(prev => ({
...prev,
referenceImages: ''
}));
};
const validateForm = () => {
const newErrors = {};
if (!formData.prompt.trim()) {
newErrors.prompt = 'Prompt is required';
} else if (formData.prompt.trim().length < 10) {
newErrors.prompt = 'Prompt must be at least 10 characters long';
}
if (formData.video_length_sec < 4 || formData.video_length_sec > 8) {
newErrors.video_length_sec = 'Video length must be 4, 6, or 8 seconds';
}
if (formData.seed && formData.seed.trim() !== '' && (isNaN(formData.seed) || parseInt(formData.seed) < 0 || parseInt(formData.seed) > 4294967295)) {
newErrors.seed = 'Seed must be a number between 0 and 4294967295';
}
if (formData.sampleCount < 1 || formData.sampleCount > 4) {
newErrors.sampleCount = 'Sample count must be between 1 and 4';
}
// Veo 3.1 specific validations
if (selectedLastFrame && !selectedImage) {
newErrors.lastFrame = 'Last frame requires a first frame image';
}
if (selectedReferenceImages.length > REFERENCE_IMAGE_CONFIG.maxReferenceImages) {
newErrors.referenceImages = `Maximum ${REFERENCE_IMAGE_CONFIG.maxReferenceImages} reference images allowed`;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (event) => {
event.preventDefault();
if (validateForm()) {
onSubmit({
...formData,
image: selectedImage,
lastFrame: selectedLastFrame,
referenceImages: selectedReferenceImages
});
}
};
return (
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Generate Your Video
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Describe your video and customize the generation settings below.
</Typography>
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid item xs={12}>
<TextField
fullWidth
multiline
rows={4}
label="Video Prompt"
placeholder="Describe the video you want to generate... (e.g., 'A stunning time-lapse of a flower blooming')"
value={formData.prompt}
onChange={handleChange('prompt')}
error={!!errors.prompt}
helperText={errors.prompt || 'Be as descriptive as possible for better results'}
required
/>
</Grid>
<Grid item xs={12}>
<Box sx={{ border: '2px dashed #ccc', borderRadius: 2, p: 3, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<ImageRounded />
First Frame Image (Optional)
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Upload a reference image to guide video generation
</Typography>
{!imagePreview ? (
<Box>
<input
accept="image/*"
style={{ display: 'none' }}
id="image-upload"
type="file"
onChange={handleImageSelect}
/>
<label htmlFor="image-upload">
<Button
variant="outlined"
component="span"
startIcon={<CloudUploadRounded />}
sx={{ mt: 1 }}
>
Upload Image
</Button>
</label>
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
Supported: JPG, PNG Max: 10MB Min: 720p
<br />
<em>Tip: Smaller images (under 2MB) upload faster and are less likely to timeout</em>
</Typography>
</Box>
) : (
<Box>
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<img
src={imagePreview}
alt="Preview"
style={{
maxWidth: '300px',
maxHeight: '200px',
borderRadius: '8px',
objectFit: 'cover'
}}
/>
<Button
variant="contained"
color="error"
size="small"
startIcon={<DeleteRounded />}
onClick={handleImageRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
minWidth: 'auto'
}}
>
Remove
</Button>
</Box>
<Typography variant="body2" sx={{ mt: 1 }}>
{selectedImage?.name}
</Typography>
</Box>
)}
{errors.image && (
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
{errors.image}
</Typography>
)}
</Box>
</Grid>
{/* Last Frame Upload - Veo 3.1 Only */}
{modelCapabilities.supportsLastFrame && (
<Grid item xs={12}>
<Box sx={{ border: '2px dashed #9c27b0', borderRadius: 2, p: 3, textAlign: 'center', bgcolor: '#f3e5f5' }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, color: '#7b1fa2' }}>
<ImageRounded />
Last Frame Image (Veo 3.1 - Optional)
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Upload a last frame for interpolation between first and last frames
</Typography>
<Alert severity="info" sx={{ mt: 1, mb: 2, textAlign: 'left' }}>
<strong>Frame Interpolation:</strong> When you provide both a first frame and last frame, Veo 3.1 will generate video content that smoothly transitions between them.
</Alert>
{!lastFramePreview ? (
<Box>
<input
accept="image/*"
style={{ display: 'none' }}
id="last-frame-upload"
type="file"
onChange={handleLastFrameSelect}
/>
<label htmlFor="last-frame-upload">
<Button
variant="outlined"
component="span"
startIcon={<CloudUploadRounded />}
sx={{ mt: 1 }}
color="secondary"
>
Upload Last Frame
</Button>
</label>
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
Supported: JPG, PNG Max: 10MB
</Typography>
</Box>
) : (
<Box>
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<img
src={lastFramePreview}
alt="Last Frame Preview"
style={{
maxWidth: '300px',
maxHeight: '200px',
borderRadius: '8px',
objectFit: 'cover'
}}
/>
<Button
variant="contained"
color="error"
size="small"
startIcon={<DeleteRounded />}
onClick={handleLastFrameRemove}
sx={{
position: 'absolute',
top: 8,
right: 8,
minWidth: 'auto'
}}
>
Remove
</Button>
</Box>
<Typography variant="body2" sx={{ mt: 1 }}>
{selectedLastFrame?.name}
</Typography>
</Box>
)}
{errors.lastFrame && (
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
{errors.lastFrame}
</Typography>
)}
</Box>
</Grid>
)}
{/* Reference Images - Veo 3.1 Only */}
{modelCapabilities.supportsReferenceImages && (
<Grid item xs={12}>
<Box sx={{ border: '2px dashed #1976d2', borderRadius: 2, p: 3, textAlign: 'center', bgcolor: '#e3f2fd' }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, color: '#1565c0' }}>
<ImageRounded />
Reference Images (Veo 3.1 - Optional)
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Upload up to 3 reference images to guide video content and preserve subject appearance
</Typography>
<Alert severity="info" sx={{ mt: 1, mb: 2, textAlign: 'left' }}>
<strong>Reference Images:</strong> Use these to maintain consistency of subjects, styles, or specific visual elements throughout the generated video.
</Alert>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', justifyContent: 'center', mt: 2 }}>
{referenceImagePreviews.map((preview, index) => (
<Box key={index} sx={{ position: 'relative', display: 'inline-block' }}>
<img
src={preview}
alt={`Reference ${index + 1}`}
style={{
width: '150px',
height: '150px',
borderRadius: '8px',
objectFit: 'cover',
border: '2px solid #1976d2'
}}
/>
<Button
variant="contained"
color="error"
size="small"
onClick={() => handleReferenceImageRemove(index)}
sx={{
position: 'absolute',
top: 4,
right: 4,
minWidth: 'auto',
padding: '4px 8px'
}}
>
<DeleteRounded fontSize="small" />
</Button>
<Typography variant="caption" display="block" sx={{ mt: 0.5, color: '#1565c0' }}>
Ref {index + 1}
</Typography>
</Box>
))}
</Box>
{selectedReferenceImages.length < REFERENCE_IMAGE_CONFIG.maxReferenceImages && (
<Box sx={{ mt: 2 }}>
<input
accept="image/*"
style={{ display: 'none' }}
id="reference-image-upload"
type="file"
onChange={handleReferenceImageSelect}
/>
<label htmlFor="reference-image-upload">
<Button
variant="outlined"
component="span"
startIcon={<CloudUploadRounded />}
color="primary"
>
Add Reference Image
</Button>
</label>
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
{selectedReferenceImages.length}/{REFERENCE_IMAGE_CONFIG.maxReferenceImages} images Supported: JPG, PNG Max: 10MB each
</Typography>
</Box>
)}
{errors.referenceImages && (
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
{errors.referenceImages}
</Typography>
)}
</Box>
</Grid>
)}
<Grid item xs={12}>
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SettingsRounded />
<Typography variant="h6">Advanced Settings</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Video Length</InputLabel>
<Select
value={formData.video_length_sec}
onChange={handleChange('video_length_sec')}
label="Video Length"
error={!!errors.video_length_sec}
>
{VIDEO_GENERATION_OPTIONS.videoLengths.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{errors.video_length_sec && (
<Typography variant="caption" color="error" sx={{ mt: 0.5 }}>
{errors.video_length_sec}
</Typography>
)}
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Number of Videos</InputLabel>
<Select
value={formData.sampleCount}
onChange={handleChange('sampleCount')}
label="Number of Videos"
error={!!errors.sampleCount}
>
{VIDEO_GENERATION_OPTIONS.sampleCounts.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{errors.sampleCount && (
<Typography variant="caption" color="error" sx={{ mt: 0.5 }}>
{errors.sampleCount}
</Typography>
)}
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel>Model</InputLabel>
<Select
value={formData.model_name}
onChange={handleChange('model_name')}
label="Model"
>
{VIDEO_GENERATION_OPTIONS.models.map((model) => (
<MenuItem key={model.value} value={model.value}>
<Box>
<Typography variant="body1">
{model.label}
{model.recommended && (
<Typography component="span" variant="caption" color="primary" sx={{ ml: 1, fontWeight: 'bold' }}>
RECOMMENDED
</Typography>
)}
</Typography>
<Typography variant="body2" color="text.secondary">
{model.description} ${model.pricePerSecond}/sec {model.speed}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Aspect Ratio</InputLabel>
<Select
value={formData.aspect_ratio}
onChange={handleChange('aspect_ratio')}
label="Aspect Ratio"
>
{VIDEO_GENERATION_OPTIONS.aspectRatios.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Person Generation</InputLabel>
<Select
value={formData.person_generation}
onChange={handleChange('person_generation')}
label="Person Generation"
>
{VIDEO_GENERATION_OPTIONS.personGeneration.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
type="number"
label="Seed (Optional)"
placeholder="Leave empty for random seed"
value={formData.seed}
onChange={handleChange('seed')}
error={!!errors.seed}
helperText={errors.seed || 'Optional: Enter a number (0-4294967295) for reproducible results, or leave empty for random'}
inputProps={{ min: 0, max: 4294967295 }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControlLabel
control={
<Checkbox
checked={formData.generate_audio}
onChange={handleChange('generate_audio')}
name="generate_audio"
/>
}
label="Generate Audio"
sx={{ mt: 1 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 4, mt: -1 }}>
Include audio in the generated video
</Typography>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</Grid>
<Grid item xs={12}>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
type="submit"
variant="contained"
size="large"
startIcon={<PlayArrowRounded />}
sx={{ px: 4, py: 1.5 }}
>
{userJobs.some(job => ['starting', 'uploading_image', 'generating', 'processing', 'downloading', 'queued'].includes(job.status))
? 'Add to Queue'
: 'Generate Video'
}
</Button>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', display: 'block', mt: 1 }}>
Total Jobs: {userJobs.length}
</Typography>
</Grid>
</Grid>
</form>
</Paper>
);
};
export default VideoForm;