Update: Video Number, Refresh button, Video Process View, Generate Button, Job Queue Process: The video queue limit goes for 4. Later once have to clean the queue to request the videos again. Video queue will be visible once history is created else it will take all requests for videos togther

This commit is contained in:
Manish Tanwar 2025-10-10 18:00:36 +05:30
parent d9fcfaa01d
commit 7c972e8cd8
6 changed files with 363 additions and 158 deletions

View file

@ -14,11 +14,57 @@ This is a full-stack web application for integrating Google's Veo 3.0 video gene
### Key Features
- Text-to-video and image-to-video generation
- Dual model support (Veo 3.0 and Veo 3.0 Fast)
- Multi-process job queue system (max 2 concurrent jobs per instance)
- Real-time progress tracking with job status polling
- Azure AD SSO authentication (with dev mode bypass)
- Automatic file cleanup and download management
- Usage tracking via webhook integration
## Common Commands
### Development
```bash
# Start both frontend and backend in development mode
./run-dev.sh
# Start backend only
cd backend && python app.py
# Start frontend only
cd frontend && npm run dev
# Build frontend for production
cd frontend && npm run build
# Frontend linting
cd frontend && npm run lint
```
### Backend Commands
```bash
# Install Python dependencies
cd backend && pip install -r requirements.txt
# Create virtual environment
python -m venv venv && source venv/bin/activate # Linux/Mac
python -m venv venv && venv\Scripts\activate # Windows
# Run backend in debug mode
cd backend && FLASK_DEBUG=True python app.py
```
### Production Deployment
```bash
# Backend systemd service management
sudo systemctl start veo-video-generator
sudo systemctl stop veo-video-generator
sudo systemctl restart veo-video-generator
sudo systemctl status veo-video-generator
# View service logs
sudo journalctl -u veo-video-generator -f
```
## Project Structure
```
@ -120,23 +166,52 @@ npm run dev
- `@azure/msal-react==2.0.7` - Microsoft authentication
- `vite==5.0.8` - Build tool
## Core Architecture
### Job Queue System
The application implements a multi-process job queue system in `backend/video_generator.py`:
- **Global queue**: `job_queue` array stores pending job IDs
- **Processing jobs**: `processing_jobs` tracks active jobs (max 2 concurrent via `CONCURRENT_JOB_LIMIT`)
- **User limits**: `user_job_counts` enforces max 4 jobs per user (`MAX_QUEUE_SIZE_PER_USER`)
- **Job status**: In-memory `job_status` dictionary tracks all job states (consider Redis for production scaling)
### Backend Request Flow
1. **Job creation**: `generate_video_task()` validates input, creates UUID, queues job
2. **Queue processing**: `process_job_queue()` thread picks jobs from queue
3. **Video generation**: `generate_video()` calls Google Gen AI SDK with threading
4. **Status updates**: Jobs transition through: queued → processing → polling → completed/failed
5. **File management**: Videos downloaded to `temp_downloads/`, cleaned up after user download
### Authentication Architecture
- **Production**: Azure AD SSO via MSAL (`@azure/msal-react`)
- **Development**: Auth bypass when `VITE_DEV_MODE=true`
- **Backend validation**: No auth validation on backend (frontend-only)
### Storage Architecture
- **Temporary images**: Uploaded to GCS `temp_images/` bucket path, cleaned up after processing
- **Generated videos**: Downloaded from GCS to local `temp_downloads/` directory
- **Automatic cleanup**: Configurable delays (15s small files, 30s large files)
## Video Generation Flow
1. **User submits request** via React frontend (prompt + optional image + parameters)
2. **Backend creates job** with unique UUID and initializes status tracking
3. **Image processing** (if provided): validate, convert to JPEG, upload to GCS
4. **API call to Google** using Gen AI SDK with Veo 3.0 model
5. **Backend polls** long-running operation every 30 seconds
6. **Frontend polls** status endpoint every 2 seconds for progress updates
7. **Video download** from GCS to backend temp storage when complete
8. **User downloads** video and backend automatically cleans up temp files
2. **Job queuing**: Backend creates job with UUID, adds to global queue, enforces user limits
3. **Queue processing**: Background thread picks next job when slot available
4. **Image processing** (if provided): validate, convert to JPEG, upload to GCS
5. **API call to Google**: Gen AI SDK call with Veo 3.0 model in separate thread
6. **Backend polling**: Long-running operation polled every 30 seconds
7. **Frontend polling**: Status endpoint polled every 2 seconds for progress updates
8. **Video download**: Completed video downloaded from GCS to local temp storage
9. **User download**: Video served to user and temp files cleaned up automatically
## Important Implementation Details
- **Job tracking**: In-memory dictionary (`job_status`) stores generation status (consider Redis for production scaling)
- **Authentication**: Azure AD SSO in production, bypassed in dev mode (`VITE_DEV_MODE=true`)
- **Concurrent processing**: Max 2 simultaneous video generations per backend instance
- **Queue management**: FIFO queue with per-user limits to prevent abuse
- **Error handling**: Max 3 retries per job with exponential backoff
- **File cleanup**: Automatic deletion after download, configurable cleanup endpoint
- **CORS**: Development allows localhost, production restricts to specific domain
- **CORS**: Development allows localhost, production restricts to specific domain
- **Image formats**: All Pillow-supported formats accepted, automatically converted to JPEG
- **Model selection**: UI allows choosing between Veo 3.0 (high-quality) and Veo 3.0 Fast (optimized)
- **Usage tracking**: Optional webhook integration sends generation data to external endpoint
- **Usage tracking**: Optional webhook integration sends generation data to external endpoint
- **Threading**: Job processing and GCS operations use threading for non-blocking execution

View file

@ -183,15 +183,15 @@ def generate_video():
print(f"DEBUG: Error cleaning up image: {e}")
return jsonify({'error': 'Seed must be a number between 0 and 4294967295'}), 400
# Validate sample count (increased limit to 10)
if not isinstance(sample_count, int) or sample_count < 1 or sample_count > 10:
# Validate sample count (limit to 4)
if not isinstance(sample_count, int) or sample_count < 1 or sample_count > 4:
if image_path and os.path.exists(image_path):
try:
os.remove(image_path)
os.rmdir(os.path.join(Config.TEMP_DOWNLOAD_PATH, f"job_{job_id}"))
except Exception as e:
print(f"DEBUG: Error cleaning up image: {e}")
return jsonify({'error': 'Sample count must be between 1 and 10'}), 400
return jsonify({'error': 'Sample count must be between 1 and 4'}), 400
# Check queue limit for user
if not can_add_to_queue(user_email):

View file

@ -12,7 +12,8 @@ import {
Divider,
Alert,
IconButton,
Tooltip
Tooltip,
GlobalStyles
} from '@mui/material';
import {
CheckCircleRounded,
@ -30,6 +31,7 @@ const QueueManager = ({ userEmail }) => {
const [jobs, setJobs] = useState([]);
const [queueStatus, setQueueStatus] = useState({});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const fetchUserJobs = async () => {
@ -106,6 +108,28 @@ const QueueManager = ({ userEmail }) => {
return job.progress || 0;
};
const isActiveJob = (job) => {
const activeStatuses = [
JOB_STATUS.STARTING,
JOB_STATUS.UPLOADING_IMAGE,
JOB_STATUS.GENERATING,
JOB_STATUS.PROCESSING,
JOB_STATUS.DOWNLOADING,
'retry_1_of_3',
'retry_2_of_3',
'retry_3_of_3'
];
return activeStatuses.includes(job.status) || job.status.startsWith('retry_');
};
const getMostRecentActiveJob = () => {
return jobs.find(job => isActiveJob(job));
};
const getHistoricalJobs = () => {
return jobs.filter(job => !isActiveJob(job));
};
const renderDownloadButtons = (job) => {
if (job.status !== JOB_STATUS.COMPLETED) return null;
@ -146,6 +170,140 @@ const QueueManager = ({ userEmail }) => {
return <Box sx={{ mt: 1 }}>{buttons}</Box>;
};
const renderJobItem = (job, isActive = false) => {
return (
<ListItem
sx={{
flexDirection: 'column',
alignItems: 'stretch',
py: 2,
...(isActive && {
border: '2px solid',
borderColor: 'primary.main',
borderRadius: 2,
backgroundColor: 'primary.50',
mb: 2
})
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon(job.status)}
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
{job.videos_requested} video{job.videos_requested > 1 ? 's' : ''}
</Typography>
<Chip
label={job.status.replace(/_/g, ' ').toUpperCase()}
color={getStatusColor(job.status)}
size="small"
/>
{job.status === JOB_STATUS.QUEUED && job.queue_position > 0 && (
<Chip
label={`Position: ${job.queue_position}`}
variant="outlined"
size="small"
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{job.can_cancel && job.status === JOB_STATUS.QUEUED && (
<Button
variant="outlined"
color="error"
size="small"
startIcon={<CancelRounded />}
onClick={() => handleCancelJob(job.job_id)}
>
Cancel
</Button>
)}
</Box>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{job.prompt.length > 100 ? `${job.prompt.substring(0, 100)}...` : job.prompt}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>
{job.message || 'Processing...'}
</Typography>
{/* Enhanced Progress Bar for Active Jobs */}
<LinearProgress
variant="determinate"
value={getProgressValue(job)}
sx={{
mb: 1,
...(isActive && {
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(25, 118, 210, 0.1)'
})
}}
/>
{isActive && (
<Typography variant="caption" color="primary.main" sx={{ mb: 1, fontWeight: 'bold' }}>
{getProgressValue(job)}% complete
</Typography>
)}
{/* Download Buttons */}
{renderDownloadButtons(job)}
{/* Job Details */}
<Grid container spacing={1} sx={{ mt: 1 }}>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
Created: {new Date(job.created_at).toLocaleString()}
</Typography>
</Grid>
{job.started_at && (
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
Started: {new Date(job.started_at).toLocaleString()}
</Typography>
</Grid>
)}
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
Model: {job.model_name?.includes('fast') ? 'Fast' : 'Standard'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
Length: {job.video_length_sec}s
</Typography>
</Grid>
</Grid>
{/* Retry Information */}
{job.retry_count > 0 && (
<Alert severity="warning" sx={{ mt: 1 }}>
<strong>Retry attempt {job.retry_count} of {job.max_retries}</strong>
<br />
{job.status.includes('retry') && job.message && (
<span>{job.message}</span>
)}
{job.error && job.error.includes('timeout') && (
<div style={{ marginTop: '8px', fontSize: '0.9em' }}>
<strong>Note:</strong> This is likely due to network connectivity issues. The system will automatically retry with longer timeouts.
</div>
)}
</Alert>
)}
{/* Final Failure Information */}
{job.final_failure && (
<Alert severity="error" sx={{ mt: 1 }}>
Failed after {job.retry_count} retries. {job.error}
</Alert>
)}
</ListItem>
);
};
if (loading) {
return (
<Paper elevation={2} sx={{ p: 3 }}>
@ -156,12 +314,26 @@ const QueueManager = ({ userEmail }) => {
}
return (
<Paper elevation={2} sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<>
<GlobalStyles styles={{
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' }
}
}} />
<Paper elevation={2} sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Job Queue</Typography>
<Tooltip title="Refresh">
<IconButton onClick={() => { fetchUserJobs(); fetchQueueStatusData(); }}>
<RefreshRounded />
<IconButton
onClick={async () => {
setRefreshing(true);
await Promise.all([fetchUserJobs(), fetchQueueStatusData()]);
setRefreshing(false);
}}
disabled={refreshing}
>
<RefreshRounded sx={{ ...(refreshing && { animation: 'spin 1s linear infinite' }) }} />
</IconButton>
</Tooltip>
</Box>
@ -181,123 +353,58 @@ const QueueManager = ({ userEmail }) => {
</Alert>
)}
{jobs.length === 0 ? (
{jobs.length === 0 ? (
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No jobs found. Submit a video generation request to see it here.
</Typography>
) : (
<List>
{jobs.map((job, index) => (
<React.Fragment key={job.job_id}>
<ListItem sx={{ flexDirection: 'column', alignItems: 'stretch', py: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon(job.status)}
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
{job.videos_requested} video{job.videos_requested > 1 ? 's' : ''}
</Typography>
<Chip
label={job.status.replace(/_/g, ' ').toUpperCase()}
color={getStatusColor(job.status)}
size="small"
/>
{job.status === JOB_STATUS.QUEUED && job.queue_position > 0 && (
<Chip
label={`Position: ${job.queue_position}`}
variant="outlined"
size="small"
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{job.can_cancel && job.status === JOB_STATUS.QUEUED && (
<Button
variant="outlined"
color="error"
size="small"
startIcon={<CancelRounded />}
onClick={() => handleCancelJob(job.job_id)}
>
Cancel
</Button>
)}
</Box>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{job.prompt.length > 100 ? `${job.prompt.substring(0, 100)}...` : job.prompt}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>
{job.message || 'Processing...'}
</Typography>
{/* Progress Bar */}
<LinearProgress
variant="determinate"
value={getProgressValue(job)}
sx={{ mb: 1 }}
/>
{/* Download Buttons */}
{renderDownloadButtons(job)}
{/* Job Details */}
<Grid container spacing={1} sx={{ mt: 1 }}>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
Created: {new Date(job.created_at).toLocaleString()}
</Typography>
</Grid>
{job.started_at && (
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
Started: {new Date(job.started_at).toLocaleString()}
</Typography>
</Grid>
)}
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
Model: {job.model_name?.includes('fast') ? 'Fast' : 'Standard'}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">
Length: {job.video_length_sec}s
</Typography>
</Grid>
</Grid>
{/* Retry Information */}
{job.retry_count > 0 && (
<Alert severity="warning" sx={{ mt: 1 }}>
<strong>Retry attempt {job.retry_count} of {job.max_retries}</strong>
<br />
{job.status.includes('retry') && job.message && (
<span>{job.message}</span>
)}
{job.error && job.error.includes('timeout') && (
<div style={{ marginTop: '8px', fontSize: '0.9em' }}>
<strong>Note:</strong> This is likely due to network connectivity issues. The system will automatically retry with longer timeouts.
</div>
)}
</Alert>
)}
{/* Final Failure Information */}
{job.final_failure && (
<Alert severity="error" sx={{ mt: 1 }}>
Failed after {job.retry_count} retries. {job.error}
</Alert>
)}
</ListItem>
{index < jobs.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
<>
{/* Active Job Section */}
{getMostRecentActiveJob() && (
<>
<Typography variant="h6" sx={{ mb: 2, color: 'primary.main' }}>
Current Video Generation
</Typography>
<List>
{renderJobItem(getMostRecentActiveJob(), true)}
</List>
{getHistoricalJobs().length > 0 && (
<>
<Divider
sx={{
my: 3,
borderWidth: 2,
borderColor: 'divider',
'&::before, &::after': {
borderColor: 'divider',
borderWidth: 2
}
}}
/>
<Typography variant="h6" sx={{ mb: 2, color: 'text.secondary' }}>
Job History
</Typography>
</>
)}
</>
)}
{/* Historical Jobs Section */}
{getHistoricalJobs().length > 0 && (
<List>
{getHistoricalJobs().map((job, index) => (
<React.Fragment key={job.job_id}>
{renderJobItem(job, false)}
{index < getHistoricalJobs().length - 1 && <Divider />}
</React.Fragment>
))}
</List>
)}
</>
)}
</Paper>
</Paper>
</>
);
};

View file

@ -14,7 +14,8 @@ import {
AccordionSummary,
AccordionDetails,
FormControlLabel,
Checkbox
Checkbox,
Alert
} from '@mui/material';
import {
PlayArrowRounded,
@ -26,7 +27,7 @@ import {
} from '@mui/icons-material';
import { VIDEO_GENERATION_OPTIONS, IMAGE_UPLOAD_CONFIG } from '../utils/constants';
const VideoForm = ({ onSubmit, isGenerating }) => {
const VideoForm = ({ onSubmit, isGenerating, userJobs = [], queueLimit = 4 }) => {
const [formData, setFormData] = useState({
prompt: '',
video_length_sec: 8,
@ -130,8 +131,13 @@ const VideoForm = ({ onSubmit, isGenerating }) => {
newErrors.seed = 'Seed must be a number between 0 and 4294967295';
}
if (formData.sampleCount < 1 || formData.sampleCount > 10) {
newErrors.sampleCount = 'Sample count must be between 1 and 10';
if (formData.sampleCount < 1 || formData.sampleCount > 4) {
newErrors.sampleCount = 'Sample count must be between 1 and 4';
}
// Check queue limit
if (userJobs.length >= queueLimit) {
newErrors.queue = `Maximum ${queueLimit} jobs allowed. Please wait for completion or cancel existing jobs.`;
}
@ -410,18 +416,31 @@ const VideoForm = ({ onSubmit, isGenerating }) => {
</Grid>
<Grid item xs={12}>
{errors.queue && (
<Alert severity="warning" sx={{ mb: 2 }}>
{errors.queue}
</Alert>
)}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
type="submit"
variant="contained"
size="large"
startIcon={<PlayArrowRounded />}
disabled={isGenerating}
disabled={userJobs.length >= queueLimit}
sx={{ px: 4, py: 1.5 }}
>
{isGenerating ? 'Generating Video...' : 'Generate Video'}
{userJobs.length >= queueLimit
? 'Queue Full'
: userJobs.some(job => ['starting', 'uploading_image', 'generating', 'processing', 'downloading'].includes(job.status))
? 'Add to Queue'
: 'Generate Video'
}
</Button>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', display: 'block', mt: 1 }}>
Queue: {userJobs.length}/{queueLimit} jobs
</Typography>
</Grid>
</Grid>
</form>

View file

@ -1,11 +1,11 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Box, Grid } from '@mui/material';
import { useIsAuthenticated, useMsal } from '@azure/msal-react';
import VideoForm from './VideoForm';
import ProgressIndicator from './ProgressIndicator';
import QueueManager from './QueueManager';
import { useVideoGeneration } from '../hooks/useVideoGeneration';
import { useMockIsAuthenticated, useMockMsal } from './DevAuthWrapper';
import { getUserJobs } from '../services/api';
const VideoGenerator = () => {
const isDev = import.meta.env.VITE_DEV_MODE === 'true';
@ -13,6 +13,8 @@ const VideoGenerator = () => {
const isAuthenticated = isDev ? useMockIsAuthenticated() : useIsAuthenticated();
const { accounts } = isDev ? useMockMsal() : useMsal();
const [userJobs, setUserJobs] = useState([]);
const {
isGenerating,
status,
@ -37,6 +39,24 @@ const VideoGenerator = () => {
return 'anonymous';
};
const fetchUserJobs = async () => {
try {
const userEmail = getUserEmail();
const data = await getUserJobs(userEmail);
setUserJobs(data.jobs || []);
} catch (err) {
console.error('Failed to fetch user jobs:', err);
}
};
useEffect(() => {
fetchUserJobs();
// Poll for job updates every 2 seconds
const interval = setInterval(fetchUserJobs, 2000);
return () => clearInterval(interval);
}, []);
const handleFormSubmit = (videoConfig) => {
const userEmail = getUserEmail();
startGeneration(videoConfig, userEmail);
@ -50,19 +70,9 @@ const VideoGenerator = () => {
<VideoForm
onSubmit={handleFormSubmit}
isGenerating={isGenerating}
userJobs={userJobs}
queueLimit={4}
/>
<Box sx={{ mt: 3 }}>
<ProgressIndicator
status={status}
progress={progress}
message={message}
error={error}
downloadLinks={downloadLinks}
onDownload={handleDownload}
onReset={reset}
/>
</Box>
</Grid>
{/* Right Column - Queue Manager */}

View file

@ -37,13 +37,7 @@ export const VIDEO_GENERATION_OPTIONS = {
{ value: 1, label: '1 video' },
{ value: 2, label: '2 videos' },
{ value: 3, label: '3 videos' },
{ value: 4, label: '4 videos' },
{ value: 5, label: '5 videos' },
{ value: 6, label: '6 videos' },
{ value: 7, label: '7 videos' },
{ value: 8, label: '8 videos' },
{ value: 9, label: '9 videos' },
{ value: 10, label: '10 videos' }
{ value: 4, label: '4 videos' }
]
};