diff --git a/CLAUDE.md b/CLAUDE.md index b3ca25d..d465298 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 \ No newline at end of file +- **Usage tracking**: Optional webhook integration sends generation data to external endpoint +- **Threading**: Job processing and GCS operations use threading for non-blocking execution \ No newline at end of file diff --git a/backend/routes/api.py b/backend/routes/api.py index 6452a97..d3ab305 100644 --- a/backend/routes/api.py +++ b/backend/routes/api.py @@ -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): diff --git a/frontend/src/components/QueueManager.jsx b/frontend/src/components/QueueManager.jsx index 914fcb6..a1e411b 100644 --- a/frontend/src/components/QueueManager.jsx +++ b/frontend/src/components/QueueManager.jsx @@ -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 {buttons}; }; + const renderJobItem = (job, isActive = false) => { + return ( + + + + {getStatusIcon(job.status)} + + {job.videos_requested} video{job.videos_requested > 1 ? 's' : ''} + + + {job.status === JOB_STATUS.QUEUED && job.queue_position > 0 && ( + + )} + + + + {job.can_cancel && job.status === JOB_STATUS.QUEUED && ( + + )} + + + + + {job.prompt.length > 100 ? `${job.prompt.substring(0, 100)}...` : job.prompt} + + + + {job.message || 'Processing...'} + + + {/* Enhanced Progress Bar for Active Jobs */} + + + {isActive && ( + + {getProgressValue(job)}% complete + + )} + + {/* Download Buttons */} + {renderDownloadButtons(job)} + + {/* Job Details */} + + + + Created: {new Date(job.created_at).toLocaleString()} + + + {job.started_at && ( + + + Started: {new Date(job.started_at).toLocaleString()} + + + )} + + + Model: {job.model_name?.includes('fast') ? 'Fast' : 'Standard'} + + + + + Length: {job.video_length_sec}s + + + + + {/* Retry Information */} + {job.retry_count > 0 && ( + + Retry attempt {job.retry_count} of {job.max_retries} +
+ {job.status.includes('retry') && job.message && ( + {job.message} + )} + {job.error && job.error.includes('timeout') && ( +
+ Note: This is likely due to network connectivity issues. The system will automatically retry with longer timeouts. +
+ )} +
+ )} + + {/* Final Failure Information */} + {job.final_failure && ( + + Failed after {job.retry_count} retries. {job.error} + + )} +
+ ); + }; + if (loading) { return ( @@ -156,12 +314,26 @@ const QueueManager = ({ userEmail }) => { } return ( - - + <> + + + Job Queue - { fetchUserJobs(); fetchQueueStatusData(); }}> - + { + setRefreshing(true); + await Promise.all([fetchUserJobs(), fetchQueueStatusData()]); + setRefreshing(false); + }} + disabled={refreshing} + > + @@ -181,123 +353,58 @@ const QueueManager = ({ userEmail }) => { )} - {jobs.length === 0 ? ( +{jobs.length === 0 ? ( No jobs found. Submit a video generation request to see it here. ) : ( - - {jobs.map((job, index) => ( - - - - - {getStatusIcon(job.status)} - - {job.videos_requested} video{job.videos_requested > 1 ? 's' : ''} - - - {job.status === JOB_STATUS.QUEUED && job.queue_position > 0 && ( - - )} - - - - {job.can_cancel && job.status === JOB_STATUS.QUEUED && ( - - )} - - - - - {job.prompt.length > 100 ? `${job.prompt.substring(0, 100)}...` : job.prompt} - - - - {job.message || 'Processing...'} - - - {/* Progress Bar */} - - - {/* Download Buttons */} - {renderDownloadButtons(job)} - - {/* Job Details */} - - - - Created: {new Date(job.created_at).toLocaleString()} - - - {job.started_at && ( - - - Started: {new Date(job.started_at).toLocaleString()} - - - )} - - - Model: {job.model_name?.includes('fast') ? 'Fast' : 'Standard'} - - - - - Length: {job.video_length_sec}s - - - - - {/* Retry Information */} - {job.retry_count > 0 && ( - - Retry attempt {job.retry_count} of {job.max_retries} -
- {job.status.includes('retry') && job.message && ( - {job.message} - )} - {job.error && job.error.includes('timeout') && ( -
- Note: This is likely due to network connectivity issues. The system will automatically retry with longer timeouts. -
- )} -
- )} - - {/* Final Failure Information */} - {job.final_failure && ( - - Failed after {job.retry_count} retries. {job.error} - - )} -
- {index < jobs.length - 1 && } -
- ))} -
+ <> + {/* Active Job Section */} + {getMostRecentActiveJob() && ( + <> + + Current Video Generation + + + {renderJobItem(getMostRecentActiveJob(), true)} + + + {getHistoricalJobs().length > 0 && ( + <> + + + Job History + + + )} + + )} + + {/* Historical Jobs Section */} + {getHistoricalJobs().length > 0 && ( + + {getHistoricalJobs().map((job, index) => ( + + {renderJobItem(job, false)} + {index < getHistoricalJobs().length - 1 && } + + ))} + + )} + )} -
+
+ ); }; diff --git a/frontend/src/components/VideoForm.jsx b/frontend/src/components/VideoForm.jsx index d1a3151..3755bf8 100644 --- a/frontend/src/components/VideoForm.jsx +++ b/frontend/src/components/VideoForm.jsx @@ -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 }) => { + {errors.queue && ( + + {errors.queue} + + )} + + Queue: {userJobs.length}/{queueLimit} jobs + diff --git a/frontend/src/components/VideoGenerator.jsx b/frontend/src/components/VideoGenerator.jsx index 0177996..d3da643 100644 --- a/frontend/src/components/VideoGenerator.jsx +++ b/frontend/src/components/VideoGenerator.jsx @@ -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 = () => { - - - - {/* Right Column - Queue Manager */} diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index e102703..e52f08d 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -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' } ] };