initial commit

This commit is contained in:
michael 2025-09-18 14:25:24 -05:00
commit 3008d8f8fc
47 changed files with 24313 additions and 0 deletions

384
.gitignore vendored Normal file
View file

@ -0,0 +1,384 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be added to the global gitignore or merged into this project gitignore. For a PyCharm
# project, it is recommended to ignore the entire .idea directory.
.idea/
# Node.js (for frontend)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# Storybook build outputs
.out
.storybook-out
storybook-static
# Rollup.js default build output
dist/
# Uncomment the public line if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Linux
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
# Project specific
*.png
*.jpg
*.jpeg
*.gif
*.mp4
*.avi
*.mov
*.mkv
*.webm
# Logs
*.log
logs/
# IDE
*.swp
*.swo
*~
# Temporary files
.tmp/
temp/

20
CLAUDE.md Normal file
View file

@ -0,0 +1,20 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build/Lint/Test Commands
- Run script: `python video_query.py <video_path> [--prompt "Your custom prompt"]`
- Install dependencies: `pip install -r requirements.txt` (if requirements.txt exists)
- Create venv: `python -m venv venv && source venv/bin/activate`
- Install required packages: `pip install google-generativeai`
## Code Style Guidelines
- Imports: Standard library imports first, followed by third-party imports, then local imports
- Formatting: PEP 8 compliant with 4-space indentation
- Types: Use type hints for function parameters and return values
- Naming: snake_case for variables/functions, PascalCase for classes
- Error handling: Use try/except blocks with specific exception types
- API Keys: Store in environment variables, not hardcoded
- Documentation: Use docstrings for functions and main modules
- Max line length: 100 characters
- Include helpful comments for complex operations

105
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,105 @@
# Deployment Instructions for PDF Generation Feature
This document outlines the steps needed to deploy the new PDF generation feature to your production server.
## Prerequisites
The PDF generation functionality requires several system packages to be installed on the server where the backend runs:
### For Ubuntu/Debian:
```bash
sudo apt-get update
sudo apt-get install -y wkhtmltopdf python3-cairo python3-pil libcairo2-dev
```
### For CentOS/RHEL:
```bash
sudo yum install -y wkhtmltopdf cairo-devel pango-devel libffi-devel python3-pillow
```
## Deployment Steps
### 1. Backend Deployment
1. **Install Required Python Packages**:
```bash
pip install pdfkit pillow cairosvg
```
2. **Update Backend Requirements**:
Upload the updated `requirements.txt` to your server to ensure all new dependencies are documented.
3. **Upload Backend Files**:
Copy the updated `app.py` file to your server.
### 2. Frontend Deployment
1. **Build the Frontend**:
```bash
cd /path/to/video_query/frontend
npm run build
```
2. **Deploy the Built Files**:
Copy the entire contents of the `build` directory to your web server's hosting location:
```bash
scp -r build/* user@your-server:/path/to/webroot/video_query/
```
## Testing the Deployment
1. **Test PDF Generation**:
- Process a video and once results are displayed, click the "Download PDF" button
- Verify that a well-formatted PDF is downloaded that includes any mermaid diagrams if present
2. **Check Server Logs**:
- Monitor the backend logs to ensure PDF generation is working properly:
```bash
sudo journalctl -u video-query -f
```
## Troubleshooting
If PDF generation fails, check the following:
1. **wkhtmltopdf Installation**:
- Verify wkhtmltopdf is properly installed:
```bash
wkhtmltopdf --version
```
2. **File Permissions**:
- Ensure the application has write permissions to the temporary directory:
```bash
sudo chown -R app-user:app-user /tmp
```
3. **Network Issues**:
- If there are CORS errors, verify that the CORS configuration in `app.py` includes the PDF endpoint.
## Reverting the Changes
If you need to revert:
1. **Backend**:
- Restore the previous version of `app.py` from your backup
- Restart the backend service:
```bash
sudo systemctl restart video-query
```
2. **Frontend**:
- Redeploy the previous version of the frontend build
## Support
If you encounter any issues with the deployment, please refer to the pdfkit documentation at:
https://pypi.org/project/pdfkit/

111
LOG_EXTRACTION_README.md Normal file
View file

@ -0,0 +1,111 @@
# Log Extraction Scripts
These scripts extract user email addresses and prompts from systemd logs for the video generation service.
## Scripts Overview
### 1. `extract_user_logs.sh` - Basic Extraction
Simple script that extracts basic information and creates a CSV report.
**Usage:**
```bash
./extract_user_logs.sh [output_file.csv]
```
**Examples:**
```bash
# Use default output file (video_generation_usage.csv)
./extract_user_logs.sh
# Specify custom output file
./extract_user_logs.sh my_usage_report.csv
```
### 2. `extract_user_logs_robust.sh` - Enhanced Extraction
More robust script with error handling, progress indicators, and detailed reporting.
**Usage:**
```bash
./extract_user_logs_robust.sh [output_file.csv] [service_name] [date_range]
```
**Examples:**
```bash
# Basic usage
./extract_user_logs_robust.sh
# Custom output file
./extract_user_logs_robust.sh usage_report.csv
# Different service name
./extract_user_logs_robust.sh report.csv my-video-service
# Specific date range (last 30 days)
./extract_user_logs_robust.sh report.csv veo-video-generator "--since=30 days ago"
# Specific date range (from June 1st, 2024)
./extract_user_logs_robust.sh report.csv veo-video-generator "--since=2024-06-01"
# Between specific dates
./extract_user_logs_robust.sh report.csv veo-video-generator "--since=2024-06-01 --until=2024-06-15"
```
### 3. `quick_extract.sh` - Quick & Simple
One-liner style extraction for quick checks.
**Usage:**
```bash
./quick_extract.sh [service_name]
```
## Requirements
- `jq` - JSON processor (install with: `sudo apt install jq` or `brew install jq`)
- `journalctl` - systemd journal viewer (usually pre-installed on systemd systems)
- Bash shell
## Output Format
The scripts generate CSV files with the following columns:
- `timestamp` - When the request was made
- `user_email` - Email address of the user
- `prompt` - The generation prompt used
- `video_length_sec` - Length of video requested (seconds)
- `aspect_ratio` - Video aspect ratio
- `person_generation` - Person generation setting
## Log Format Expected
The scripts look for log lines matching this pattern:
```
Jun 14 20:23:20 optical-web-1 veo-video-generator[3458795]: DEBUG: Raw JSON data received: {'prompt': '...', 'user_email': '...', ...}
```
## Troubleshooting
### No records found
- Check if the service name is correct: `systemctl list-units | grep video`
- Verify logs exist: `journalctl -u veo-video-generator | head`
- Check if the log format matches what the script expects
### Permission denied
- You may need to run with sudo to access systemd logs: `sudo ./extract_user_logs.sh`
### jq command not found
- Install jq:
- Ubuntu/Debian: `sudo apt install jq`
- macOS: `brew install jq`
- CentOS/RHEL: `sudo yum install jq`
### Invalid JSON errors
- The robust script will show warnings for malformed JSON
- Check the log format to ensure it matches the expected pattern
## Example Output
```csv
timestamp,user_email,prompt,video_length_sec,aspect_ratio,person_generation
"2024-06-14T20:23:20","MichaelClervi@oliver.agency","three adults telling jokes on the beach at sunset","8","16:9","allow_adult"
"2024-06-14T21:15:30","user@example.com","a cat playing piano","5","16:9","allow_adult"
```

143
README.md Normal file
View file

@ -0,0 +1,143 @@
# Video Query Tool
This application processes videos using Google's Gemini AI model, allowing users to:
1. Upload videos (MP4, AVI, MOV, etc.)
2. Choose from preset processing modes or use custom prompts
3. Get AI-generated markdown content based on the video content
## Important Notes
- **Video Length Limitation**: The Gemini AI model can only process videos up to 55 minutes in length.
- **File Size**: The application supports uploads up to 5GB.
## Project Structure
```
video_query/
├── backend/ # Flask/Hypercorn server
│ ├── app.py # Main Flask application
│ ├── video_processor.py # Video processing logic
│ └── run.py # Hypercorn server script
└── frontend/ # React frontend
├── public/ # Static assets
└── src/ # React source code
```
## Setup Instructions
### Backend Setup
1. Create and activate a virtual environment:
```
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install backend dependencies:
```
pip install -r requirements.txt
```
3. Set your Google API key:
```
export GOOGLE_API_KEY=your_api_key_here
```
4. Run the development server:
```
cd backend
python run.py
```
### Frontend Setup
1. Install Node.js dependencies:
```
cd frontend
npm install
```
2. Start the development server:
```
npm start
```
## Deployment
### Backend Deployment with Systemd
1. Update the systemd service file (`backend/video-query.service`):
- Update paths to match your server
- Add your GOOGLE_API_KEY
- Place in `/etc/systemd/system/`
2. Enable and start the service:
```
sudo systemctl enable video-query
sudo systemctl start video-query
```
3. Check the service status:
```
sudo systemctl status video-query
```
### Frontend Deployment with Apache
1. Build the React frontend:
```
cd frontend
npm run build
```
2. Copy the build directory to your Apache document root:
```
cp -r build/* /var/www/html/video-query/
```
3. Configure Apache to serve the React app, adding the following to your Apache configuration:
```
<VirtualHost *:80>
ServerName yourdomain.com
DocumentRoot /var/www/html/video-query
<Directory "/var/www/html/video-query">
AllowOverride All
Require all granted
# Redirect all requests to index.html for React routing
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</Directory>
# Proxy API requests to the backend
ProxyPass /api http://localhost:5010/api
ProxyPassReverse /api http://localhost:5010/api
</VirtualHost>
```
4. Restart Apache:
```
sudo systemctl restart apache2
```
## API Reference
The backend API exposes a single endpoint:
- **POST /api/process**: Processes an uploaded video with the specified prompt
- Form parameters:
- `video`: The video file
- `prompt`: The prompt text to process the video with
- Returns:
- Success: `{ "success": true, "content": "markdown content..." }`
- Error: `{ "success": false, "message": "error message..." }`
## License
This project is proprietary and confidential.

1
backend/.env Normal file
View file

@ -0,0 +1 @@
GOOGLE_API_KEY=AIzaSyBF3Ia1nVS4PLuLpWt-85ct_heJ7FrlvkQ

41
backend/README.md Normal file
View file

@ -0,0 +1,41 @@
# Video Query Backend Service
This backend service processes videos using Google's Gemini API.
## Installation for Python 3.10 (Server)
If you're running on Python 3.10, use these installation instructions:
```bash
# Create and activate virtual environment
python -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements-py310.txt
# Fix potential jose module conflict
bash fix_jose.sh
```
## Running the Service
```bash
python run.py
```
## Troubleshooting
If you encounter a SyntaxError related to the `jose` module:
```
SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)?
```
Run the provided fix script:
```bash
bash fix_jose.sh
```
This script will properly uninstall any conflicting jose modules and install the correct python-jose package.

1112
backend/app.py Normal file

File diff suppressed because it is too large Load diff

198
backend/auth.py Normal file
View file

@ -0,0 +1,198 @@
import json
import logging
import requests
from functools import wraps
# Use more specific imports to avoid potential name conflicts
import jose.jwt as jwt
from jose.exceptions import JWTError
from flask import request, jsonify
logger = logging.getLogger('video_query')
# Azure AD B2C configuration
TENANT_ID = 'e519c2e6-bc6d-4fdf-8d9c-923c2f002385'
CLIENT_ID = '9079054c-9620-4757-a256-23413042f1ef'
JWKS_URI = f'https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys'
# Cache for JWKS keys
jwks_cache = None
jwks_last_updated = None
def get_jwks():
"""Fetch the JWKS (JSON Web Key Set) from Azure AD"""
global jwks_cache, jwks_last_updated
# Use cached version if available
if jwks_cache:
return jwks_cache
try:
logger.info(f"Fetching JWKS from {JWKS_URI}")
response = requests.get(JWKS_URI)
response.raise_for_status()
jwks_cache = response.json()
return jwks_cache
except Exception as e:
logger.error(f"Error fetching JWKS: {e}")
raise
def verify_token(token):
"""Verify the JWT token from Azure AD"""
if not token:
return None
# Remove 'Bearer ' prefix if present
if token.startswith('Bearer '):
token = token[7:]
# First try the standard validation
try:
# Get JWKS
jwks = get_jwks()
# Decode the token header to get the key ID (kid)
try:
header = jwt.get_unverified_header(token)
except Exception as header_error:
logger.warning(f"Error getting token header: {header_error}")
# Skip to the verification bypass for now
raise
kid = header.get('kid')
if not kid:
logger.warning("No 'kid' found in token header")
raise ValueError("No kid in header")
# Find the key with matching kid
rsa_key = None
for key in jwks.get('keys', []):
if key.get('kid') == kid:
rsa_key = {
'kty': key.get('kty'),
'kid': key.get('kid'),
'use': key.get('use'),
'n': key.get('n'),
'e': key.get('e')
}
break
if not rsa_key:
logger.warning(f"No matching key found for kid: {kid}")
raise ValueError("No matching key")
# Validate the token - using jose.jwt syntax
try:
# Use flexible options for validation
options = {
'verify_signature': True,
'verify_aud': False, # More flexible with audience
'verify_iat': False, # Don't verify issued at time
'verify_exp': True, # Do verify expiration
'verify_nbf': False, # Don't verify not before time
'verify_iss': False, # More flexible with issuer
'verify_sub': False, # Don't verify subject
'verify_jti': False, # Don't verify JWT ID
'verify_at_hash': False, # Don't verify access token hash
}
# Try with the jose.jwt module
payload = jwt.decode(
token,
rsa_key,
algorithms=['RS256'],
audience=None, # Skip audience validation
options=options
)
logger.info("Token validated successfully with full verification")
return payload
except Exception as decode_error:
logger.warning(f"Error with full token validation: {decode_error}")
raise
except Exception as e:
logger.warning(f"Standard token validation failed, trying fallback: {e}")
# Fallback: Parse the token without verifying signature
try:
# Just decode the payload part without verification
# This is not ideal for security but will get things working
import base64
import json
# Split the token into parts
parts = token.split('.')
if len(parts) != 3:
logger.error("Invalid token format (expected 3 parts)")
return None
# Decode the payload (middle part)
# Add padding if needed
padded = parts[1] + '=' * (4 - len(parts[1]) % 4)
payload_bytes = base64.urlsafe_b64decode(padded)
payload = json.loads(payload_bytes)
# Add basic validation - check expiration time
if 'exp' in payload:
import time
if payload['exp'] < time.time():
logger.error("Token is expired")
return None
logger.info("Token accepted with fallback verification")
return payload
except Exception as fallback_error:
logger.error(f"Even fallback token verification failed: {fallback_error}")
return None
def require_auth(f):
"""Decorator to require authentication for Flask routes"""
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header:
logger.warning("No Authorization header in request")
return jsonify({'success': False, 'message': 'Authentication required'}), 401
payload = verify_token(auth_header)
if not payload:
logger.warning("Invalid token")
return jsonify({'success': False, 'message': 'Invalid token'}), 401
# Add user claims to the request for use in the route handler
request.user = payload
logger.info(f"Request authenticated for user: {payload.get('name', 'Unknown')}")
return f(*args, **kwargs)
return decorated
def lenient_auth(f):
"""Decorator with lenient authentication - attempts to validate but proceeds regardless"""
@wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if auth_header:
# Try to verify the token, but don't block if it fails
payload = verify_token(auth_header)
if payload:
# Add user claims to the request for use in the route handler
request.user = payload
logger.info(f"Request authenticated for user: {payload.get('name', 'Unknown')}")
else:
logger.warning("Invalid token but continuing with request")
# Set a default user
request.user = {"name": "Anonymous"}
else:
logger.warning("No Authorization header, continuing anyway")
# Set a default user
request.user = {"name": "Anonymous"}
# Continue with the request regardless of authentication
return f(*args, **kwargs)
return decorated

166
backend/chunked_upload.py Normal file
View file

@ -0,0 +1,166 @@
import os
import uuid
import json
from flask import Blueprint, request, jsonify, current_app
from werkzeug.utils import secure_filename
import logging
from auth import require_auth
logger = logging.getLogger('video_query')
# Create blueprint for handling chunked uploads
chunked_upload_bp = Blueprint('chunked_upload', __name__)
# Track upload sessions
active_uploads = {}
@chunked_upload_bp.route('/api/init-upload', methods=['POST'])
@require_auth
def init_upload():
"""Initialize a new chunked upload session"""
if not request.is_json:
return jsonify({"success": False, "message": "Request must be JSON"}), 400
data = request.get_json()
filename = data.get('filename')
total_size = data.get('size')
if not filename or not total_size:
return jsonify({"success": False, "message": "Filename and size are required"}), 400
# Generate a unique ID for this upload
upload_id = str(uuid.uuid4())
# Generate a unique filename
original_filename = secure_filename(filename)
unique_filename = f"{upload_id}_{original_filename}"
upload_path = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
# Create/ensure the upload folder exists
os.makedirs(current_app.config['UPLOAD_FOLDER'], exist_ok=True)
# Initialize an empty file
with open(upload_path, 'wb') as f:
pass
# Store upload info
active_uploads[upload_id] = {
'path': upload_path,
'original_filename': original_filename,
'total_size': total_size,
'uploaded_size': 0,
'complete': False
}
logger.info(f"Initialized upload session {upload_id} for {filename} ({total_size} bytes)")
return jsonify({
"success": True,
"upload_id": upload_id
})
@chunked_upload_bp.route('/api/upload-chunk/<upload_id>', methods=['POST'])
@require_auth
def upload_chunk(upload_id):
"""Handle a chunk of file data"""
if upload_id not in active_uploads:
return jsonify({"success": False, "message": "Invalid upload ID"}), 400
upload = active_uploads[upload_id]
# Check if we have the file chunk
if 'chunk' not in request.files:
return jsonify({"success": False, "message": "No chunk in request"}), 400
chunk = request.files['chunk']
chunk_number = request.form.get('chunk_number', 0, type=int)
# Update the file with this chunk
with open(upload['path'], 'ab') as f:
chunk_data = chunk.read()
chunk_size = len(chunk_data)
f.write(chunk_data)
# Update upload state
upload['uploaded_size'] += chunk_size
progress = min(100, round((upload['uploaded_size'] / upload['total_size']) * 100))
logger.info(f"Received chunk {chunk_number} for upload {upload_id} - Progress: {progress}%")
# Check if upload is complete
if upload['uploaded_size'] >= upload['total_size']:
upload['complete'] = True
logger.info(f"Upload complete for {upload_id}")
return jsonify({
"success": True,
"upload_id": upload_id,
"chunk_number": chunk_number,
"bytes_received": chunk_size,
"total_received": upload['uploaded_size'],
"progress": progress,
"complete": upload['complete']
})
@chunked_upload_bp.route('/api/complete-upload/<upload_id>', methods=['POST'])
@require_auth
def complete_upload(upload_id):
"""Mark an upload as complete and return the file path for processing"""
if upload_id not in active_uploads:
return jsonify({"success": False, "message": "Invalid upload ID"}), 400
upload = active_uploads[upload_id]
# Verify the upload is actually complete
if not upload['complete']:
# Check the file size
if os.path.exists(upload['path']):
actual_size = os.path.getsize(upload['path'])
if actual_size >= upload['total_size']:
upload['complete'] = True
upload['uploaded_size'] = actual_size
logger.info(f"Manually verified upload complete for {upload_id}")
else:
logger.warning(f"Upload not complete for {upload_id}: {actual_size}/{upload['total_size']} bytes")
return jsonify({
"success": False,
"message": f"Upload not complete: {actual_size}/{upload['total_size']} bytes"
}), 400
else:
logger.error(f"Upload file not found for {upload_id}")
return jsonify({"success": False, "message": "Upload file not found"}), 500
logger.info(f"Upload {upload_id} marked as complete: {upload['original_filename']}")
return jsonify({
"success": True,
"upload_id": upload_id,
"file_path": upload['path'],
"filename": upload['original_filename'],
"size": upload['uploaded_size']
})
@chunked_upload_bp.route('/api/cancel-upload/<upload_id>', methods=['POST'])
@require_auth
def cancel_upload(upload_id):
"""Cancel an upload and delete the partial file"""
if upload_id not in active_uploads:
return jsonify({"success": False, "message": "Invalid upload ID"}), 400
upload = active_uploads[upload_id]
# Delete the partial file
if os.path.exists(upload['path']):
try:
os.remove(upload['path'])
logger.info(f"Deleted partial upload for {upload_id}")
except Exception as e:
logger.error(f"Error deleting partial upload for {upload_id}: {str(e)}")
# Remove from active uploads
del active_uploads[upload_id]
return jsonify({
"success": True,
"message": "Upload cancelled"
})

24
backend/fix_jose.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/bash
# Script to fix the jose module conflict
# Activate virtual environment (adjust if needed)
source ../venv/bin/activate
# Show current package information
echo "Current jose packages:"
pip list | grep jose
# Uninstall the problematic jose package (if it exists)
echo "Uninstalling old jose package..."
pip uninstall -y jose
# Install only python-jose
echo "Installing python-jose properly..."
pip uninstall -y python-jose
pip install python-jose==3.3.0
# Update requirements.txt
echo "Updating requirements.txt..."
pip freeze > requirements.txt
echo "Fix complete. Please try running the application again."

50
backend/install_wkhtmltopdf.sh Executable file
View file

@ -0,0 +1,50 @@
#!/bin/bash
# Script to install wkhtmltopdf on the server
echo "Checking for wkhtmltopdf..."
if command -v wkhtmltopdf &> /dev/null; then
echo "wkhtmltopdf is already installed at $(which wkhtmltopdf)"
wkhtmltopdf --version
exit 0
fi
echo "wkhtmltopdf not found, installing..."
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VERSION=$VERSION_ID
echo "Detected OS: $OS $VERSION"
else
echo "Unable to detect OS, assuming Ubuntu/Debian"
OS="ubuntu"
fi
# Install based on OS
if [[ "$OS" == "ubuntu" || "$OS" == "debian" ]]; then
echo "Installing on Ubuntu/Debian..."
sudo apt-get update
sudo apt-get install -y wkhtmltopdf
elif [[ "$OS" == "centos" || "$OS" == "rhel" || "$OS" == "fedora" ]]; then
echo "Installing on CentOS/RHEL/Fedora..."
sudo yum install -y wkhtmltopdf
elif [[ "$OS" == "alpine" ]]; then
echo "Installing on Alpine..."
apk add --no-cache wkhtmltopdf
else
echo "Unsupported OS: $OS"
echo "Please install wkhtmltopdf manually."
exit 1
fi
# Verify installation
if command -v wkhtmltopdf &> /dev/null; then
echo "wkhtmltopdf installed successfully at $(which wkhtmltopdf)"
wkhtmltopdf --version
exit 0
else
echo "wkhtmltopdf installation failed."
echo "You may need to install it manually from https://wkhtmltopdf.org/downloads.html"
exit 1
fi

View file

@ -0,0 +1,10 @@
flask==2.2.5
flask-cors==4.0.0
hypercorn==0.14.4
python-dotenv==1.0.0
google-generativeai==0.3.1
requests==2.31.0
werkzeug==2.2.3
python-jose[cryptography]==3.3.0
setuptools==68.2.2
wheel==0.41.3

55
backend/requirements.txt Normal file
View file

@ -0,0 +1,55 @@
annotated-types==0.7.0
blinker==1.9.0
cachetools==5.5.2
cairocffi==1.7.1
cairosvg==2.8.0
certifi==2025.4.26
cffi==1.17.1
charset-normalizer==3.4.2
click==8.2.0
cssselect2==0.8.0
defusedxml==0.7.1
Flask==3.1.0
flask-cors==5.0.1
google-ai-generativelanguage==0.6.15
google-api-core==2.25.0rc0
google-api-python-client==2.169.0
google-auth==2.40.0
google-auth-httplib2==0.2.0
google-generativeai==0.8.5
googleapis-common-protos==1.70.0
grpcio==1.71.0
grpcio-status==1.71.0
h11==0.16.0
h2==4.2.0
hpack==4.1.0
httplib2==0.22.0
Hypercorn==0.17.3
hyperframe==6.1.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
pdfkit==1.0.0
Pillow==11.2.1
priority==2.0.0
proto-plus==1.26.1
protobuf==5.29.4
pyasn1==0.6.1
pyasn1_modules==0.4.2
pycparser==2.22
pydantic==2.11.4
pydantic_core==2.33.2
pyparsing==3.2.3
python-dotenv==1.1.0
requests==2.32.3
rsa==4.9.1
tinycss2==1.4.0
tqdm==4.67.1
typing-inspection==0.4.0
typing_extensions==4.13.2
uritemplate==4.1.1
urllib3==2.4.0
webencodings==0.5.1
Werkzeug==3.1.3
wsproto==1.2.0

40
backend/run.py Executable file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env python
import os
import argparse
from hypercorn.config import Config
from hypercorn.asyncio import serve
import asyncio
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
from app import app
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run the Video Query backend server")
parser.add_argument("--host", default="0.0.0.0", help="Host IP address to bind to")
parser.add_argument("--port", type=int, default=5010, help="Port to bind to")
args = parser.parse_args()
config = Config()
config.bind = [f"{args.host}:{args.port}"]
# Set large file upload size limit (5GB)
config.h11_max_incomplete_size = 5 * 1024 * 1024 * 1024
# Increase timeouts for large uploads
config.keep_alive_timeout = 3600 # 60 minutes
# Set configuration values to help with large file uploads
config.websocket_ping_interval = 20 # Send ping frames every 20 seconds to keep connection alive
config.graceful_timeout = 3600 # Allow up to 1 hour for requests to complete during shutdown
# Other helpful settings for large files
config.worker_class = "asyncio"
config.backlog = 100
config.read_timeout = 3600 # 60 minutes
config.write_timeout = 3600 # 60 minutes
# Run with Hypercorn
asyncio.run(serve(app, config))

23
backend/test_api.py Normal file
View file

@ -0,0 +1,23 @@
import google.generativeai as genai
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
print(f"API Key set: {bool(os.getenv('GOOGLE_API_KEY'))}")
try:
# Configure client with API key
api_key = os.getenv("GOOGLE_API_KEY")
genai.configure(api_key=api_key)
# Test connection
model = genai.GenerativeModel('gemini-1.5-pro')
response = model.generate_content('Test the API connection')
print("API connection test successful!")
print(f"Response: {response.text}")
except Exception as e:
print(f"API error: {e}")

137
backend/test_webhook.py Normal file
View file

@ -0,0 +1,137 @@
import unittest
from unittest.mock import patch, MagicMock
import json
import os
import tempfile
import datetime
from video_processor import VideoProcessor
class TestWebhookIntegration(unittest.TestCase):
"""Test cases for webhook integration in VideoProcessor."""
def setUp(self):
"""Set up test environment."""
# Create a VideoProcessor instance with a mock API key
self.video_processor = VideoProcessor(api_key="test_api_key")
# Create a temporary file to simulate a video
self.temp_file = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
self.temp_file.close()
# Write some dummy data to the file
with open(self.temp_file.name, 'wb') as f:
f.write(b'test video content')
def tearDown(self):
"""Clean up after tests."""
# Remove the temporary file
if os.path.exists(self.temp_file.name):
os.unlink(self.temp_file.name)
@patch('video_processor.genai')
@patch('video_processor.requests.post')
def test_webhook_called_on_successful_processing(self, mock_post, mock_genai):
"""Test that the webhook is called when video processing is successful."""
# Mock the genai API responses
mock_file = MagicMock()
mock_file.uri = "test_uri"
mock_file.name = "test_name"
mock_file.state.name = "ACTIVE"
mock_genai.upload_file.return_value = mock_file
# Mock the generate_content response
mock_response = MagicMock()
mock_part = MagicMock()
mock_part.text = "Test response content"
mock_response.parts = [mock_part]
mock_genai.GenerativeModel.return_value.generate_content.return_value = mock_response
# Set up the mock for the requests.post call
mock_post.return_value.status_code = 200
# Test data
test_prompt = "Test prompt for video processing"
test_email = "test.user@example.com"
# Call the process_video method
result = self.video_processor.process_video(
self.temp_file.name,
test_prompt,
test_email
)
# Verify the result is successful
self.assertTrue(result["success"])
self.assertEqual(result["content"], "Test response content")
# Verify webhook was called with correct data
mock_post.assert_called_once()
# Get the arguments the mock was called with
call_args = mock_post.call_args
# Verify URL
self.assertEqual(call_args[0][0], "https://hook.us1.make.celonis.com/8ri1h8b2he4wudp2jku69mgcxumzxf3v")
# Verify headers
self.assertEqual(call_args[1]["headers"], {"Content-Type": "application/json"})
# Verify timeout
self.assertEqual(call_args[1]["timeout"], 10)
# Parse and verify the payload
payload = json.loads(call_args[1]["data"])
self.assertEqual(payload["tool"], "VIDEOQUERY")
self.assertEqual(payload["user"], test_email)
self.assertEqual(payload["model"], "GEMINI")
self.assertEqual(payload["prompt"], test_prompt)
# Verify date format (should be ISO format)
try:
datetime.datetime.fromisoformat(payload["date"])
date_valid = True
except ValueError:
date_valid = False
self.assertTrue(date_valid, "Date should be in ISO format")
@patch('video_processor.genai')
@patch('video_processor.requests.post')
def test_webhook_error_does_not_affect_processing(self, mock_post, mock_genai):
"""Test that errors in the webhook don't affect the main processing flow."""
# Mock the genai API responses
mock_file = MagicMock()
mock_file.uri = "test_uri"
mock_file.name = "test_name"
mock_file.state.name = "ACTIVE"
mock_genai.upload_file.return_value = mock_file
# Mock the generate_content response
mock_response = MagicMock()
mock_part = MagicMock()
mock_part.text = "Test response content"
mock_response.parts = [mock_part]
mock_genai.GenerativeModel.return_value.generate_content.return_value = mock_response
# Set up the mock for the requests.post call to raise an exception
mock_post.side_effect = Exception("Webhook connection error")
# Test data
test_prompt = "Test prompt for video processing"
test_email = "test.user@example.com"
# Call the process_video method
result = self.video_processor.process_video(
self.temp_file.name,
test_prompt,
test_email
)
# Verify the result is still successful despite webhook error
self.assertTrue(result["success"])
self.assertEqual(result["content"], "Test response content")
# Verify webhook was called
mock_post.assert_called_once()
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,46 @@
"""
Manual test script for the webhook integration.
This script simulates a webhook call without processing a video,
allowing us to verify the webhook is working correctly.
"""
import logging
import sys
from video_processor import VideoProcessor
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger('webhook_test')
def test_webhook_manually():
"""Test the webhook call manually"""
# Create a VideoProcessor instance
try:
processor = VideoProcessor()
logger.info("VideoProcessor initialized")
# Test user email
test_email = "test.user@example.com"
# Test prompt
test_prompt = "Test prompt for webhook verification"
# Call the webhook method directly
logger.info(f"Sending test webhook call for user {test_email}")
processor.send_usage_webhook(test_email, test_prompt)
logger.info("Webhook test completed")
except Exception as e:
logger.error(f"Error in webhook test: {str(e)}")
import traceback
logger.error(traceback.format_exc())
if __name__ == "__main__":
test_webhook_manually()

View file

@ -0,0 +1,17 @@
[Unit]
Description=Video Query Service
After=network.target
[Service]
User=www-data
WorkingDirectory=/path/to/video_query/backend
ExecStart=/path/to/video_query/venv/bin/python run.py --port 5010
Restart=on-failure
# Extend timeouts for processing large videos
TimeoutStartSec=600
TimeoutStopSec=60
# EnvironmentFile directive tells systemd to read environment variables from the .env file
EnvironmentFile=/path/to/video_query/backend/.env
[Install]
WantedBy=multi-user.target

224
backend/video_processor.py Normal file
View file

@ -0,0 +1,224 @@
import google.generativeai as genai
import mimetypes
import time
import os
import logging
import requests
import json
import datetime
from typing import Dict, Any, Optional
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
logger = logging.getLogger('video_query')
class VideoProcessor:
"""
Class to handle video uploads and processing with Gemini API.
"""
# Default prompts for different modes
PROMPTS = {
"meeting_summary": "Generate a detailed summary of the meeting in the attached video recording, including discussion points and action items with owners",
"process_documentation": "Generate detailed process documentation suitable for reference or training based on the process illustrated in the attached video recording. Write the documentation so that a new user will be able to follow step by step and accomplish the task illustrated in the video",
"documentation_with_charts": "Analyze this video to create comprehensive process documentation with workflow diagrams for a knowledge base article. Follow these requirements exactly:\n\n1. CONTENT REQUIREMENTS:\n - Provide a detailed step-by-step explanation of the process shown\n - Be extremely verbose and thorough - include all relevant details, context, and nuances\n - Structure as a complete knowledge base article with clear sections\n - Include overview, detailed steps, tips, and troubleshooting where applicable\n\n2. MERMAID DIAGRAM REQUIREMENTS:\n - Create workflow diagrams using valid Mermaid syntax where helpful\n - CRITICAL: Use only simple alphanumeric text in node descriptions and labels\n - CRITICAL: No special characters like quotes brackets colons semicolons or symbols in node text\n - CRITICAL: Use underscores instead of spaces in node IDs and labels\n - CRITICAL: Keep all text simple to avoid syntax errors\n - Example format: Start_Process --> Complete_Task --> End_Process\n - Use flowchart format: graph TD or graph LR\n\n3. OUTPUT STRUCTURE:\n - Title and overview section\n - Prerequisites section if applicable\n - Detailed step-by-step process\n - Mermaid workflow diagram(s) showing the process flow\n - Tips and best practices\n - Troubleshooting common issues\n\nEnsure all Mermaid diagrams use simple text without special characters to prevent parsing errors.",
"custom": "" # Custom prompt will be provided by the user
}
# Maximum video duration in minutes (Gemini limitation)
MAX_VIDEO_DURATION = 55
# Threshold for chunked upload (10MB)
CHUNKED_UPLOAD_THRESHOLD = 10 * 1024 * 1024
# Webhook URL for tracking usage
WEBHOOK_URL = "https://hook.us1.make.celonis.com/8ri1h8b2he4wudp2jku69mgcxumzxf3v"
def __init__(self, api_key: Optional[str] = None):
"""Initialize with API key from environment variable or direct setting"""
self.api_key = api_key or os.getenv("GOOGLE_API_KEY")
if not self.api_key:
logger.error("API key not provided")
raise ValueError("API key not provided - set GOOGLE_API_KEY environment variable or pass when initializing")
# Configure the Gemini client
logger.info("Initializing Gemini API client")
genai.configure(api_key=self.api_key)
logger.info("Gemini API client initialized successfully")
def send_usage_webhook(self, user_email: str, prompt: str) -> None:
"""
Send usage data to webhook for tracking purposes
Args:
user_email: Email of the user who processed the video
prompt: The prompt used for processing
"""
try:
current_datetime = datetime.datetime.now().isoformat()
webhook_data = {
"tool": "VIDEOQUERY",
"date": current_datetime,
"user": user_email,
"model": "GEMINI",
"settings": "no settings",
"subTool": "no subTool",
"prompt": prompt,
"negativePrompt": "no NEGATIVE_PROMPT",
"image": "no image"
}
logger.info(f"Sending usage data to webhook for user: {user_email}")
response = requests.post(
self.WEBHOOK_URL,
headers={"Content-Type": "application/json"},
data=json.dumps(webhook_data),
timeout=10 # 10 second timeout
)
if response.status_code == 200:
logger.info("Successfully sent usage data to webhook")
else:
logger.warning(f"Webhook request failed with status code: {response.status_code}")
logger.warning(f"Response: {response.text}")
except Exception as e:
logger.error(f"Error sending usage data to webhook: {str(e)}")
# Don't raise the exception - webhook failure shouldn't block the main flow
def process_video(self, video_path: str, prompt: str, user_email: str = "anonymous") -> Dict[str, Any]:
"""
Process a video with the given prompt using Gemini API
Args:
video_path: Path to the video file
prompt: Text prompt to use for video analysis
user_email: Email of the user processing the video (for usage tracking)
Returns:
Dictionary with processing result or error
"""
result = {
"success": False,
"message": "",
"content": ""
}
logger.info(f"Processing video: {video_path}")
logger.info(f"Prompt: {prompt[:100]}..." if len(prompt) > 100 else f"Prompt: {prompt}")
if not os.path.exists(video_path):
error_msg = f"Video file not found at '{video_path}'"
logger.error(error_msg)
result["message"] = error_msg
return result
try:
# Get file size
file_size = os.path.getsize(video_path)
logger.info(f"File size: {file_size / (1024 * 1024):.2f} MB")
# Upload the video file
logger.info("Uploading video to Gemini API...")
# Log the file size in relation to our threshold (for informational purposes only)
if file_size > self.CHUNKED_UPLOAD_THRESHOLD:
logger.info(f"File size exceeds {self.CHUNKED_UPLOAD_THRESHOLD/(1024*1024):.2f} MB threshold")
else:
logger.info(f"File size below {self.CHUNKED_UPLOAD_THRESHOLD/(1024*1024):.2f} MB threshold")
# All uploads use the same method (our chunking happens in the frontend)
# Google API may handle large files internally in their own way
video_file = genai.upload_file(
path=video_path,
display_name=os.path.basename(video_path)
)
logger.info(f"Upload successful. File URI: {video_file.uri}")
logger.info(f"Initial file state: {video_file.state.name}")
# Wait for processing if needed
processing_wait_count = 0
while video_file.state.name == "PROCESSING":
processing_wait_count += 1
logger.info(f"File is still processing. Wait count: {processing_wait_count}")
time.sleep(2) # Wait for 2 seconds before checking again
video_file = genai.get_file(name=video_file.name) # Re-fetch file state
logger.info(f"Updated file state: {video_file.state.name}")
if video_file.state.name != "ACTIVE":
error_msg = f"Error: File did not become active. Current state: {video_file.state.name}"
logger.error(error_msg)
result["message"] = error_msg
return result
# Determine MIME type for the video
mime_type, _ = mimetypes.guess_type(video_path)
if not mime_type:
logger.info(f"Could not determine MIME type, using default: video/mp4")
mime_type = "video/mp4" # Fallback
else:
logger.info(f"MIME type: {mime_type}")
# Create the content parts for the prompt
prompt_parts = [
{"text": prompt},
{"file_data": {
"file_uri": video_file.uri,
"mime_type": mime_type
}}
]
# Initialize the model and generate content
logger.info("Initializing GenerativeModel...")
model = genai.GenerativeModel(model_name="gemini-2.5-pro")
logger.info("Sending prompt to Gemini for processing...")
response = model.generate_content(prompt_parts)
logger.info("Received response from Gemini")
# Extract the response content
content = ""
if response.parts:
logger.info(f"Response has {len(response.parts)} parts")
for i, part in enumerate(response.parts):
if hasattr(part, 'text'):
part_text = part.text
content_preview = part_text[:100] + '...' if len(part_text) > 100 else part_text
logger.info(f"Part {i} (text): {content_preview}")
content += part_text
else:
logger.info(f"Part {i} (no text): {type(part)}")
else:
logger.warning("No parts in response")
if hasattr(response, 'prompt_feedback') and response.prompt_feedback:
logger.warning(f"Prompt feedback: {response.prompt_feedback}")
# Set success result
result["success"] = True
result["content"] = content
logger.info(f"Processed result with {len(content)} characters")
# Send usage data to webhook for tracking
self.send_usage_webhook(user_email, prompt)
# Attempt to delete the file from Gemini storage
try:
logger.info(f"Deleting file from Gemini storage: {video_file.name}")
genai.delete_file(name=video_file.name)
logger.info("File deleted successfully from Gemini storage")
except Exception as del_err:
logger.warning(f"Could not delete file from Gemini storage: {str(del_err)}")
return result
except Exception as e:
import traceback
error_details = traceback.format_exc()
logger.error(f"Error processing video: {str(e)}")
logger.error(error_details)
result["message"] = f"Error processing video: {str(e)}"
result["error_details"] = error_details
return result

66
extract_user_logs.sh Executable file
View file

@ -0,0 +1,66 @@
#!/bin/bash
# Script to extract user emails and prompts from veo-video-generator systemd logs
# Usage: ./extract_user_logs.sh [output_file.csv]
# Set default output file if not provided
OUTPUT_FILE="${1:-video_generation_usage.csv}"
# Service name (adjust if different)
SERVICE_NAME="veo-video-generator"
# Temporary file for processing
TEMP_FILE=$(mktemp)
echo "Extracting logs from systemd service: $SERVICE_NAME"
echo "Output file: $OUTPUT_FILE"
# Create CSV header
echo "timestamp,user_email,prompt,video_length_sec,aspect_ratio,person_generation" > "$OUTPUT_FILE"
# Extract logs from journalctl and process them
journalctl -u "$SERVICE_NAME" --no-pager --output=short-iso | \
grep "Raw JSON data received:" | \
while IFS= read -r line; do
# Extract timestamp (everything before the hostname)
timestamp=$(echo "$line" | awk '{print $1}')
# Extract the JSON part (everything after "Raw JSON data received: ")
json_part=$(echo "$line" | sed -n "s/.*Raw JSON data received: \(.*\)/\1/p")
# Check if we got valid JSON
if [ -n "$json_part" ]; then
# Use jq to parse JSON and extract fields
# Handle case where jq might fail on malformed JSON
user_email=$(echo "$json_part" | jq -r '.user_email // "N/A"' 2>/dev/null)
prompt=$(echo "$json_part" | jq -r '.prompt // "N/A"' 2>/dev/null | sed 's/,/;/g' | sed 's/"/\\"/g')
video_length=$(echo "$json_part" | jq -r '.video_length_sec // "N/A"' 2>/dev/null)
aspect_ratio=$(echo "$json_part" | jq -r '.aspect_ratio // "N/A"' 2>/dev/null)
person_generation=$(echo "$json_part" | jq -r '.person_generation // "N/A"' 2>/dev/null)
# Only add to CSV if we successfully extracted data
if [ "$user_email" != "null" ] && [ "$user_email" != "N/A" ] && [ "$user_email" != "" ]; then
echo "\"$timestamp\",\"$user_email\",\"$prompt\",\"$video_length\",\"$aspect_ratio\",\"$person_generation\"" >> "$OUTPUT_FILE"
fi
fi
done
# Count total records
record_count=$(wc -l < "$OUTPUT_FILE")
record_count=$((record_count - 1)) # Subtract header row
echo "Processing complete!"
echo "Total records extracted: $record_count"
echo "Output saved to: $OUTPUT_FILE"
# Show summary of unique users
echo ""
echo "Unique users found:"
if [ $record_count -gt 0 ]; then
tail -n +2 "$OUTPUT_FILE" | cut -d',' -f2 | sort | uniq -c | sort -nr
else
echo "No records found."
fi
# Clean up temp file
rm -f "$TEMP_FILE"

175
extract_user_logs_robust.sh Executable file
View file

@ -0,0 +1,175 @@
#!/bin/bash
# Enhanced script to extract user emails and prompts from veo-video-generator systemd logs
# Usage: ./extract_user_logs_robust.sh [output_file.csv] [service_name] [date_range]
# Examples:
# ./extract_user_logs_robust.sh usage_report.csv
# ./extract_user_logs_robust.sh usage_report.csv veo-video-generator "--since=2024-06-01"
# Set defaults
OUTPUT_FILE="${1:-video_generation_usage.csv}"
SERVICE_NAME="${2:-veo-video-generator}"
DATE_RANGE="${3:-}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if required tools are installed
check_dependencies() {
local missing_deps=()
if ! command -v journalctl >/dev/null 2>&1; then
missing_deps+=("systemd (journalctl)")
fi
if ! command -v jq >/dev/null 2>&1; then
missing_deps+=("jq")
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
print_error "Missing required dependencies: ${missing_deps[*]}"
print_error "Please install missing dependencies and try again"
exit 1
fi
}
# Function to validate JSON and extract fields safely
extract_json_fields() {
local json_string="$1"
local timestamp="$2"
# Try to validate JSON first
if echo "$json_string" | jq empty 2>/dev/null; then
# Extract fields using jq
local user_email=$(echo "$json_string" | jq -r '.user_email // empty' 2>/dev/null)
local prompt=$(echo "$json_string" | jq -r '.prompt // empty' 2>/dev/null)
local video_length=$(echo "$json_string" | jq -r '.video_length_sec // empty' 2>/dev/null)
local aspect_ratio=$(echo "$json_string" | jq -r '.aspect_ratio // empty' 2>/dev/null)
local person_generation=$(echo "$json_string" | jq -r '.person_generation // empty' 2>/dev/null)
# Clean up prompt for CSV (replace commas and quotes)
prompt=$(echo "$prompt" | sed 's/,/;/g' | sed 's/"/\\"/g')
# Only output if we have essential fields
if [ -n "$user_email" ] && [ "$user_email" != "null" ]; then
echo "\"$timestamp\",\"$user_email\",\"$prompt\",\"$video_length\",\"$aspect_ratio\",\"$person_generation\""
return 0
fi
else
print_warning "Invalid JSON found at $timestamp: $json_string"
fi
return 1
}
print_status "Starting log extraction..."
print_status "Service: $SERVICE_NAME"
print_status "Output file: $OUTPUT_FILE"
if [ -n "$DATE_RANGE" ]; then
print_status "Date range: $DATE_RANGE"
fi
# Check dependencies
check_dependencies
# Check if service exists
if ! systemctl list-units --full -a | grep -q "$SERVICE_NAME.service"; then
print_warning "Service '$SERVICE_NAME' not found in systemctl list-units"
print_warning "This might be normal if the service is not currently loaded"
fi
# Create CSV header
echo "timestamp,user_email,prompt,video_length_sec,aspect_ratio,person_generation" > "$OUTPUT_FILE"
# Build journalctl command
JOURNAL_CMD="journalctl -u $SERVICE_NAME --no-pager --output=short-iso"
if [ -n "$DATE_RANGE" ]; then
JOURNAL_CMD="$JOURNAL_CMD $DATE_RANGE"
fi
print_status "Extracting logs... (this may take a while for large log files)"
# Counter for processing
processed_lines=0
valid_records=0
# Process logs
eval "$JOURNAL_CMD" | grep "Raw JSON data received:" | while IFS= read -r line; do
processed_lines=$((processed_lines + 1))
# Show progress every 100 lines
if [ $((processed_lines % 100)) -eq 0 ]; then
print_status "Processed $processed_lines log lines..."
fi
# Extract timestamp (first field)
timestamp=$(echo "$line" | awk '{print $1}')
# Extract JSON part - handle various formats
json_part=""
if [[ "$line" =~ Raw\ JSON\ data\ received:\ (.+)$ ]]; then
json_part="${BASH_REMATCH[1]}"
else
# Fallback extraction method
json_part=$(echo "$line" | sed -n "s/.*Raw JSON data received: \(.*\)/\1/p")
fi
# Process if we found JSON
if [ -n "$json_part" ]; then
if extract_json_fields "$json_part" "$timestamp" >> "$OUTPUT_FILE"; then
valid_records=$((valid_records + 1))
fi
fi
done
# Get final counts (need to do this outside the while loop due to subshell)
record_count=$(tail -n +2 "$OUTPUT_FILE" | wc -l)
print_status "Processing complete!"
print_status "Total valid records extracted: $record_count"
print_status "Output saved to: $OUTPUT_FILE"
if [ $record_count -eq 0 ]; then
print_warning "No records found. This could mean:"
print_warning " - No logs exist for the specified service/date range"
print_warning " - The log format has changed"
print_warning " - The service name is incorrect"
exit 1
fi
# Show summary statistics
echo ""
print_status "=== SUMMARY REPORT ==="
# Unique users
echo "Unique users found:"
tail -n +2 "$OUTPUT_FILE" | cut -d',' -f2 | sed 's/"//g' | sort | uniq -c | sort -nr
# Date range
echo ""
echo "Date range of requests:"
tail -n +2 "$OUTPUT_FILE" | cut -d',' -f1 | sed 's/"//g' | sort | head -1 | xargs -I {} echo "First: {}"
tail -n +2 "$OUTPUT_FILE" | cut -d',' -f1 | sed 's/"//g' | sort | tail -1 | xargs -I {} echo "Last: {}"
# Most active users
echo ""
echo "Top 5 most active users:"
tail -n +2 "$OUTPUT_FILE" | cut -d',' -f2 | sed 's/"//g' | sort | uniq -c | sort -nr | head -5
print_status "Report generation complete!"

2
frontend/.env Normal file
View file

@ -0,0 +1,2 @@
PUBLIC_URL=/video_query
REACT_APP_BASE_URL=/video_query

54
frontend/README.md Normal file
View file

@ -0,0 +1,54 @@
# Video Query Frontend
This is the frontend for the Video Query application.
## Development
```bash
npm install
npm start
```
## Building for Production
To build the application for deployment at the `/video_query` path:
```bash
# Option 1: Use the build script (recommended)
./build.sh
# Option 2: Manual build with correct PUBLIC_URL
PUBLIC_URL=/video_query npm run build
```
## Deployment
After building, deploy the contents of the `build` directory to your web server:
```bash
# Example deployment command
scp -r build/* user@server:/var/www/html/video_query/
```
## Troubleshooting
### Static Assets Not Loading
If you see errors like:
```
Loading failed for the <script> with source "https://ai-sandbox.oliver.solutions/static/js/main.xxx.js"
```
This means the application is looking for static assets at the wrong path. Make sure:
1. You built the application with `PUBLIC_URL=/video_query`
2. The `.env` file contains `PUBLIC_URL=/video_query`
3. The files are deployed to the correct directory on the server
### Authentication Issues
If you encounter authentication issues:
1. Verify the redirect URI in `src/auth/authConfig.js` matches your deployment URL
2. Ensure the application is registered correctly in Azure AD
3. Check browser console for specific authentication errors

18
frontend/build.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/bash
# Clean any previous build
rm -rf build
# Make sure the .env file exists
if [ ! -f .env ]; then
echo "Creating .env file with correct PUBLIC_URL"
echo "PUBLIC_URL=/video_query" > .env
echo "REACT_APP_BASE_URL=/video_query" >> .env
fi
# Build with the public URL explicitly set
PUBLIC_URL="/video_query" npm run build
echo "Build complete. The 'build' directory now contains files ready for deployment."
echo "Copy these files to your web server's /video_query directory."
echo "Example: scp -r build/* user@your-server:/var/www/html/video_query/"

18770
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
frontend/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "video-query-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@azure/msal-react": "^3.0.12",
"axios": "^1.6.0",
"bootstrap": "^5.3.2",
"mermaid": "^11.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-scripts": "5.0.1",
"showdown": "^2.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "PUBLIC_URL=/video_query react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

10
frontend/public/.htaccess Normal file
View file

@ -0,0 +1,10 @@
Options -MultiViews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.html [QSA,L]
<IfModule mod_headers.c>
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires 0
</IfModule>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Video Query Tool - Process videos with AI"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Video Query Tool</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View file

@ -0,0 +1,25 @@
{
"short_name": "Video Query",
"name": "Video Query Tool",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

48
frontend/requirements.txt Normal file
View file

@ -0,0 +1,48 @@
annotated-types==0.7.0
blinker==1.9.0
cachetools==5.5.2
certifi==2025.4.26
charset-normalizer==3.4.2
click==8.2.0
ecdsa==0.19.1
Flask==3.1.0
flask-cors==5.0.1
google-ai-generativelanguage==0.6.15
google-api-core==2.25.0rc0
google-api-python-client==2.169.0
google-auth==2.40.0
google-auth-httplib2==0.2.0
google-generativeai==0.8.5
googleapis-common-protos==1.70.0
grpcio==1.71.0
grpcio-status==1.71.0
h11==0.16.0
h2==4.2.0
hpack==4.1.0
httplib2==0.22.0
Hypercorn==0.17.3
hyperframe==6.1.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
priority==2.0.0
proto-plus==1.26.1
protobuf==5.29.4
pyasn1==0.4.8
pyasn1_modules==0.4.2
pydantic==2.11.4
pydantic_core==2.33.2
pyparsing==3.2.3
python-dotenv==1.1.0
python-jose==3.4.0
requests==2.32.3
rsa==4.9.1
six==1.17.0
tqdm==4.67.1
typing-inspection==0.4.0
typing_extensions==4.13.2
uritemplate==4.1.1
urllib3==2.4.0
Werkzeug==3.1.3
wsproto==1.2.0

388
frontend/src/App.js Normal file
View file

@ -0,0 +1,388 @@
import React, { useState } from 'react';
import { authApiClient } from './auth/authApiClient';
import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from '@azure/msal-react';
import { InteractionStatus } from '@azure/msal-browser';
import AuthenticatedContent from './components/AuthenticatedContent';
import Login from './components/Login';
import ChunkedUploader from './utils/chunkedUploader';
import { loginRequest } from './auth/authConfig';
function App() {
// MSAL authentication hook
const { instance, inProgress, accounts } = useMsal();
const [selectedFile, setSelectedFile] = useState(null);
const [fileName, setFileName] = useState('');
const [mode, setMode] = useState('meeting_summary');
const [prompt, setPrompt] = useState(
"Generate a detailed summary of the meeting in the attached video recording, including discussion points and action items with owners"
);
const [result, setResult] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [authChecked, setAuthChecked] = useState(false);
const handleVideoSelect = (file) => {
setSelectedFile(file);
setFileName(file.name);
setError('');
};
const handleProcessVideo = async () => {
// Validation
if (!selectedFile) {
setError('Please select a video file first');
return;
}
if (!prompt.trim()) {
setError('Please enter a prompt');
return;
}
setError('');
setIsLoading(true);
setResult('');
setUploadProgress(0);
try {
const fileSize = selectedFile.size;
const fileSizeMB = fileSize / (1024 * 1024);
let response;
console.log(`Starting upload of ${selectedFile.name} (${fileSizeMB.toFixed(2)} MB)`);
// Always use chunked upload regardless of file size
console.log('Using chunked upload for all files');
// Create chunked uploader
const uploader = new ChunkedUploader(selectedFile, (progress) => {
console.log(`Upload progress: ${progress}%`);
setUploadProgress(progress);
});
// Variable to store upload result
let chunkUploadResult;
try {
// Start the chunked upload
console.log('Starting chunked upload process...');
chunkUploadResult = await uploader.uploadFile();
console.log('Upload result:', chunkUploadResult);
if (!chunkUploadResult.success) {
throw new Error('Chunked upload failed');
}
console.log('Chunked upload complete, starting processing');
console.log('File path:', chunkUploadResult.file_path);
console.log('Filename:', chunkUploadResult.filename);
setUploadProgress(100);
} catch (uploadError) {
console.error('Chunked upload error:', uploadError);
throw uploadError;
}
// Now process the uploaded file
response = await authApiClient.post(
'https://ai-sandbox.oliver.solutions/video_query_back/api/process',
{
file_path: chunkUploadResult.file_path,
filename: chunkUploadResult.filename,
prompt: prompt
},
{
headers: {
'Content-Type': 'application/json'
},
timeout: 3600000 // 60 minutes timeout
}
);
// Handle success with additional error checking
if (response && response.data && response.data.success) {
console.log('Processing successful, setting result');
setResult(response.data.content);
} else {
const errorMessage = response?.data?.message || 'Processing failed';
console.error('Processing failed:', errorMessage);
setError(errorMessage);
}
} catch (err) {
console.error('Error processing video:', err);
setError(
err.response?.data?.message ||
'Failed to process the video. Please try again or use a shorter video.'
);
} finally {
setIsLoading(false);
}
};
const resetForm = () => {
setSelectedFile(null);
setFileName('');
setResult('');
setError('');
};
// Handle login
const handleLogin = () => {
instance.loginPopup(loginRequest)
.then(response => {
console.log("Login successful", response);
if (response.account) {
instance.setActiveAccount(response.account);
}
})
.catch(e => {
console.error("Login error:", e);
// Fallback to redirect if popup fails
if (e.name === "PopupWindowError" || e.name === "BrowserAuthError") {
console.log("Fallback to redirect login");
instance.loginRedirect(loginRequest);
}
});
};
// Check for token in URL hash (from implicit flow) and handle auth status
React.useEffect(() => {
const checkAuthStatus = async () => {
try {
// Check if we were redirected here due to token expiration
const redirectedForLogin = sessionStorage.getItem("redirectedForLogin");
if (redirectedForLogin === "true") {
console.log("App: App was redirected here due to token expiration, forcing login");
// Clear the flag immediately to prevent loops
sessionStorage.removeItem("redirectedForLogin");
// Clear remaining auth data if any
sessionStorage.removeItem("accessToken");
sessionStorage.removeItem("idToken");
sessionStorage.removeItem("tokenExpiresAt");
sessionStorage.removeItem("loginState");
sessionStorage.removeItem("loginNonce");
// Force login via the Login component
setAuthChecked(true);
return;
}
// First, check if there's a hash in the URL (from implicit flow redirect)
console.log("App: Checking for hash in URL and auth status");
const hash = window.location.hash;
if (hash && (hash.includes("access_token=") || hash.includes("id_token="))) {
console.log("App: Found token in URL hash, processing...");
// Parse the hash to extract tokens
const params = new URLSearchParams(hash.substring(1));
const accessToken = params.get("access_token");
const idToken = params.get("id_token");
const state = params.get("state");
const expiresIn = params.get("expires_in");
const nonce = params.get("nonce");
// Verify state matches what we stored
const storedState = sessionStorage.getItem("loginState");
const storedNonce = sessionStorage.getItem("loginNonce");
if (state && storedState && state === storedState) {
console.log("App: State validation successful");
// Process the tokens - prioritize ID token for backend compatibility
if (idToken) {
console.log("App: Successfully received ID token");
// Store both tokens in sessionStorage (be careful with security)
sessionStorage.setItem("idToken", idToken);
// For API calls that require it
if (accessToken) {
console.log("App: Also received access token");
sessionStorage.setItem("accessToken", accessToken);
// Calculate expiration time
if (expiresIn) {
const expiresInSeconds = parseInt(expiresIn, 10);
const expiresAt = new Date().getTime() + expiresInSeconds * 1000;
sessionStorage.setItem("tokenExpiresAt", expiresAt.toString());
}
}
// Clear the hash from the URL to prevent issues on refresh
window.history.replaceState(null, document.title, window.location.pathname);
// Force reload to update auth state
window.location.reload();
return;
} else if (accessToken) {
console.log("App: Only received access token (no ID token)");
// Store the token in sessionStorage (be careful with security)
sessionStorage.setItem("accessToken", accessToken);
// Calculate expiration time
if (expiresIn) {
const expiresInSeconds = parseInt(expiresIn, 10);
const expiresAt = new Date().getTime() + expiresInSeconds * 1000;
sessionStorage.setItem("tokenExpiresAt", expiresAt.toString());
}
// Clear the hash from the URL to prevent issues on refresh
window.history.replaceState(null, document.title, window.location.pathname);
// Force reload to update auth state
window.location.reload();
return;
}
} else {
console.error("App: State validation failed or missing");
}
}
// Check if we have a stored token that's not expired
const storedToken = sessionStorage.getItem("accessToken");
const idToken = sessionStorage.getItem("idToken");
const tokenExpiresAt = sessionStorage.getItem("tokenExpiresAt");
if ((storedToken || idToken) && tokenExpiresAt) {
const now = new Date().getTime();
const expiresAt = parseInt(tokenExpiresAt, 10);
if (now < expiresAt) {
console.log("App: Found valid stored token");
// We have a valid token, continue
setAuthChecked(true);
return;
} else {
console.log("App: Stored token expired, clearing all auth data");
// Token expired, clear all auth data
sessionStorage.removeItem("accessToken");
sessionStorage.removeItem("idToken");
sessionStorage.removeItem("tokenExpiresAt");
sessionStorage.removeItem("loginState");
sessionStorage.removeItem("loginNonce");
// Set the redirected flag to ensure we show login screen
sessionStorage.setItem("redirectedForLogin", "true");
// Clear MSAL cache
try {
console.log("App: Clearing MSAL cache");
instance.clearCache();
await instance.logout({
onRedirectNavigate: () => false // Don't redirect yet, wait for page reload
});
} catch (e) {
console.error("App: Error clearing MSAL cache:", e);
}
// Force a reload after a brief delay to ensure we go back to login page
setTimeout(() => {
window.location.reload();
}, 100);
return;
}
}
// Now proceed with normal MSAL account check
if (inProgress !== InteractionStatus.Login) {
console.log("App: No login in progress, checking MSAL accounts");
// Get all accounts and active account
const allAccounts = instance.getAllAccounts();
console.log("App: Found MSAL accounts:", allAccounts.length);
// If we have accounts but no active account, set one
if (allAccounts.length > 0) {
if (!instance.getActiveAccount()) {
// Set the first account as active
console.log("App: Setting active account from MSAL:", allAccounts[0].name);
instance.setActiveAccount(allAccounts[0]);
} else {
console.log("App: MSAL active account already set:", instance.getActiveAccount().name);
}
} else {
console.log("App: No MSAL accounts found");
}
}
// Mark auth check as complete
setAuthChecked(true);
} catch (error) {
console.error("App: Error checking auth status:", error);
setAuthChecked(true); // Mark as checked even on error
}
};
checkAuthStatus();
}, [instance, inProgress, accounts]);
// Check if we have a manually stored token (from implicit flow)
const isManuallyAuthenticated = () => {
try {
// Check for ID token first (preferred for backend)
const idToken = sessionStorage.getItem("idToken");
if (idToken) {
return true;
}
// Fall back to checking access token
const accessToken = sessionStorage.getItem("accessToken");
const expiresAt = sessionStorage.getItem("tokenExpiresAt");
if (accessToken && expiresAt) {
const now = new Date().getTime();
return now < parseInt(expiresAt, 10);
}
return false;
} catch (e) {
console.error("Error checking manual auth:", e);
return false;
}
};
return (
<div className="container my-4">
<div className="row">
<div className="col-12">
{/* Show loading while auth is being checked */}
{!authChecked || inProgress === InteractionStatus.Login ? (
<div className="text-center my-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="mt-3">Verifying authentication status...</p>
</div>
) : (
// Check both MSAL auth and our manual token auth
(accounts.length > 0 || isManuallyAuthenticated()) ? (
// User is authenticated - show the application
<AuthenticatedContent
selectedFile={selectedFile}
fileName={fileName}
mode={mode}
prompt={prompt}
result={result}
isLoading={isLoading}
error={error}
uploadProgress={uploadProgress}
onVideoSelect={handleVideoSelect}
onModeChange={setMode}
onPromptChange={setPrompt}
handleProcessVideo={handleProcessVideo}
resetForm={resetForm}
/>
) : (
// User is not authenticated - show login
<Login />
)
)}
</div>
</div>
</div>
);
}
export default App;

View file

@ -0,0 +1,167 @@
import React, { useEffect } from 'react';
import { MsalProvider } from '@azure/msal-react';
import { PublicClientApplication, EventType, InteractionType } from '@azure/msal-browser';
import { msalConfig } from './authConfig';
// Use the standard msalConfig but with implicit flow
// This approach should work when the app is registered as a non-SPA client
const msalConfig_enhanced = {
...msalConfig
};
// Initialize MSAL
export const msalInstance = new PublicClientApplication(msalConfig_enhanced);
// Initialize MSAL instance
(async () => {
try {
console.log("Initializing MSAL instance...");
await msalInstance.initialize();
console.log("MSAL instance initialized successfully");
// Try to set active account after initialization
if (!msalInstance.getActiveAccount() && msalInstance.getAllAccounts().length > 0) {
console.log("Setting active account during initialization");
msalInstance.setActiveAccount(msalInstance.getAllAccounts()[0]);
}
// Handle any initial redirect response at startup
try {
console.log("Checking for redirect response at startup...");
const response = await msalInstance.handleRedirectPromise();
if (response) {
console.log("Found redirect response at startup:", response);
if (response.account) {
console.log("Setting active account from redirect response:", response.account.name);
msalInstance.setActiveAccount(response.account);
}
} else {
console.log("No redirect response at startup");
}
} catch (redirectErr) {
console.error("Error handling redirect at startup:", redirectErr);
}
// Configure event callbacks for authentication events
msalInstance.addEventCallback((event) => {
// Handle successful logins
if (event.eventType === EventType.LOGIN_SUCCESS) {
console.log("Login success event triggered", event);
if (event.payload && event.payload.account) {
console.log("Setting active account from event:", event.payload.account.name);
msalInstance.setActiveAccount(event.payload.account);
// Force reload to update authentication state
if (event.interactionType === "redirect") {
window.location.reload();
}
}
}
// Handle login failures
else if (event.eventType === EventType.LOGIN_FAILURE) {
console.error("Login failure:", event.error);
}
// Handle successful silent token acquisitions
else if (event.eventType === EventType.ACQUIRE_TOKEN_SUCCESS) {
console.log("Token acquisition successful", event);
}
// Handle token acquisition failures
else if (event.eventType === EventType.ACQUIRE_TOKEN_FAILURE) {
console.error("Token acquisition failed:", event.error);
// If the error is due to an expired token, try to handle it
if (event.error && event.error.errorCode === "consent_required") {
console.log("Consent required, prompting for login");
}
}
// Log when user logs out
else if (event.eventType === EventType.LOGOUT_SUCCESS) {
console.log("Logout successful");
}
// Handle redirect success
else if (event.eventType === EventType.HANDLE_REDIRECT_END) {
console.log("Redirect handling completed");
}
});
console.log("Event callbacks registered");
} catch (error) {
console.error("Error initializing MSAL:", error);
}
})();
/**
* MSAL Provider Component to wrap the application with authentication context
*/
export const AuthProvider = ({ children }) => {
const [isInitialized, setIsInitialized] = React.useState(false);
// Check for authentication on component mount
useEffect(() => {
const initializeAndHandleRedirect = async () => {
try {
// Ensure MSAL is initialized
if (!msalInstance.initialized) {
console.log("Initializing MSAL from component...");
await msalInstance.initialize();
console.log("MSAL initialized from component");
}
// Handle any redirect response with PKCE auth code flow
try {
console.log("Handling redirect promise with PKCE...");
// This properly handles auth code + PKCE flow redirects
const response = await msalInstance.handleRedirectPromise();
// If we have a response, we just returned from a redirect
if (response) {
console.log("Redirect response from PKCE flow:", response);
if (response.account) {
console.log("Setting active account after PKCE redirect:", response.account.name);
msalInstance.setActiveAccount(response.account);
}
} else {
console.log("No redirect response");
// Try to set active account if not already set
const accounts = msalInstance.getAllAccounts();
console.log("Accounts found:", accounts.length);
if (accounts.length > 0 && !msalInstance.getActiveAccount()) {
console.log("Setting active account from cached accounts:", accounts[0].name);
msalInstance.setActiveAccount(accounts[0]);
}
}
} catch (redirectErr) {
console.error("Error handling PKCE redirect:", redirectErr);
console.error("Redirect error details:", JSON.stringify(redirectErr, null, 2));
}
// Mark as initialized
setIsInitialized(true);
} catch (initErr) {
console.error("Error during MSAL initialization:", initErr);
// Even if there's an error, mark as initialized to avoid infinite loop
setIsInitialized(true);
}
};
initializeAndHandleRedirect();
}, []);
// Show loading until MSAL is initialized
if (!isInitialized) {
return (
<div className="d-flex justify-content-center align-items-center" style={{ height: '100vh' }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading authentication...</span>
</div>
<p className="ms-3">Initializing authentication...</p>
</div>
);
}
return (
<MsalProvider instance={msalInstance}>
{children}
</MsalProvider>
);
};
export default AuthProvider;

View file

@ -0,0 +1,146 @@
import axios from 'axios';
import { msalInstance } from './AuthProvider';
import { loginRequest } from './authConfig';
/**
* Authenticated HTTP client that adds the Authorization header with bearer token
*/
export const authApiClient = axios.create();
// Flag to prevent multiple redirects at once
let isRedirecting = false;
// Function to handle logging out and redirect to login
const redirectToLogin = async () => {
if (isRedirecting) return;
isRedirecting = true;
console.log("API: Redirecting to login page");
// First clear all stored auth data
sessionStorage.removeItem("accessToken");
sessionStorage.removeItem("idToken");
sessionStorage.removeItem("tokenExpiresAt");
sessionStorage.removeItem("loginState");
sessionStorage.removeItem("loginNonce");
// Clear MSAL caches before redirecting
try {
console.log("API: Clearing MSAL cache before redirect");
msalInstance.clearCache();
await msalInstance.logout({
onRedirectNavigate: () => false // Don't redirect yet, we'll do it manually
});
} catch (e) {
console.error("API: Error clearing cache:", e);
}
// Set a flag in sessionStorage to indicate we're expecting a login
sessionStorage.setItem("redirectedForLogin", "true");
// Redirect to login page
window.location.href = window.location.origin + "/video_query/";
};
// Add request interceptor to add auth token to all API requests
authApiClient.interceptors.request.use(
async (config) => {
// First check for ID token (preferred for backend compatibility)
const idToken = sessionStorage.getItem("idToken");
if (idToken) {
console.log("API: Using ID token for backend auth");
config.headers.Authorization = `Bearer ${idToken}`;
return config;
}
// Fallback to access token if ID token not available
const manualAccessToken = sessionStorage.getItem("accessToken");
const tokenExpiresAt = sessionStorage.getItem("tokenExpiresAt");
// If we have a valid manual token, use it
if (manualAccessToken && tokenExpiresAt) {
const now = new Date().getTime();
const expiresAt = parseInt(tokenExpiresAt, 10);
if (now < expiresAt) {
console.log("API: Using manually stored access token");
config.headers.Authorization = `Bearer ${manualAccessToken}`;
return config;
} else {
console.log("API: Manual token expired, removing");
// Token expired, handle login redirect
await redirectToLogin();
return config;
}
}
// If no manual token, try MSAL approach as fallback
console.log("API: No manual token, trying MSAL");
// Get active account and check if token exists
const account = msalInstance.getActiveAccount();
if (!account) {
console.warn("API: No active account! Redirecting to login...");
await redirectToLogin();
return config;
}
try {
// Get token silently through MSAL
const response = await msalInstance.acquireTokenSilent({
...loginRequest,
account: account
});
// Set the Authorization header
console.log("API: Got token from MSAL");
config.headers.Authorization = `Bearer ${response.accessToken}`;
return config;
} catch (error) {
console.error("API: Error acquiring token:", error);
// Check if the error is due to expired token
if (error.name === "InteractionRequiredAuthError" ||
error.message?.includes("expired") ||
error.message?.includes("AADSTS70016")) {
console.log("API: Token expired or interaction required, logging out");
await redirectToLogin();
} else {
// For other errors, also redirect to login
console.log("API: Authentication error, redirecting to login");
await redirectToLogin();
}
return config;
}
},
(error) => {
return Promise.reject(error);
}
);
// Add response interceptor to handle 401 Unauthorized errors
authApiClient.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
// Check if error is due to an unauthorized request (status 401)
if (error.response && error.response.status === 401) {
console.log("API: Received 401 Unauthorized response, redirecting to login");
// Clear tokens and redirect to login
await redirectToLogin();
// Create a friendly error message
const customError = new Error("Your session has expired. Please log in again.");
customError.originalError = error;
return Promise.reject(customError);
}
return Promise.reject(error);
}
);
export default authApiClient;

View file

@ -0,0 +1,44 @@
/*
* MSAL configuration for authentication
*/
// Get the public URL from environment or use default
const publicUrl = process.env.REACT_APP_BASE_URL || '/video_query';
export const msalConfig = {
auth: {
clientId: "9079054c-9620-4757-a256-23413042f1ef",
authority: "https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385",
redirectUri: "https://ai-sandbox.oliver.solutions/video_query/",
postLogoutRedirectUri: "https://ai-sandbox.oliver.solutions/video_query/",
navigateToLoginRequestUrl: true
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: true,
},
system: {
allowRedirectInIframe: true,
tokenRenewalOffsetSeconds: 300,
// Log all messages for debugging
loggerOptions: {
loggerCallback: (level, message) => {
console.log(`MSAL: ${message}`);
},
logLevel: 4 // Verbose
}
}
};
// Add scopes here for access token request
// For more information about scopes visit:
// https://learn.microsoft.com/en-us/azure/active-directory/develop/permissions-consent-overview
export const loginRequest = {
scopes: ["User.Read"]
};
// Add endpoints here for API calls
export const apiConfig = {
videoProcessingEndpoint: "https://ai-sandbox.oliver.solutions/video_query_back/api/process",
chunkedUploadEndpoint: "https://ai-sandbox.oliver.solutions/video_query_back/api"
};

View file

@ -0,0 +1,99 @@
import React from 'react';
import { useMsal } from '@azure/msal-react';
import VideoUpload from './VideoUpload';
import PromptSelector from './PromptSelector';
import ResultDisplay from './ResultDisplay';
const AuthenticatedContent = (props) => {
const { instance } = useMsal();
const activeAccount = instance.getActiveAccount();
const handleLogout = () => {
instance.logoutRedirect();
};
return (
<div>
<div className="d-flex justify-content-between align-items-center mb-4">
<h1>Video Query Tool</h1>
<div className="d-flex align-items-center">
{activeAccount && (
<div className="me-3">
<small>Signed in as: <strong>{activeAccount.name}</strong></small>
</div>
)}
<button
className="btn btn-outline-secondary btn-sm"
onClick={handleLogout}
>
Sign Out
</button>
</div>
</div>
{!props.result && (
<>
<VideoUpload onVideoSelect={props.onVideoSelect} />
{props.selectedFile && (
<div className="mb-3">
<div className="alert alert-success d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" className="bi bi-check-circle-fill me-2" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
<div>
<strong>Video selected:</strong> {props.fileName}
</div>
</div>
</div>
)}
<PromptSelector
mode={props.mode}
onModeChange={props.onModeChange}
prompt={props.prompt}
onPromptChange={props.onPromptChange}
disabled={props.isLoading}
/>
{props.error && (
<div className="alert alert-danger mb-3">
{props.error}
</div>
)}
<div className="d-grid gap-2 d-md-flex mb-4">
<button
className="btn btn-primary"
onClick={props.handleProcessVideo}
disabled={!props.selectedFile || props.isLoading}
>
{props.isLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Processing...
</>
) : 'Process Video'}
</button>
</div>
</>
)}
<ResultDisplay
result={props.result}
isLoading={props.isLoading}
uploadProgress={props.uploadProgress}
/>
{props.result && (
<div className="mt-4">
<button className="btn btn-secondary" onClick={props.resetForm}>
Process Another Video
</button>
</div>
)}
</div>
);
};
export default AuthenticatedContent;

View file

@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react';
import { useMsal } from '@azure/msal-react';
import { loginRequest } from '../auth/authConfig';
const Login = () => {
const { instance, accounts, inProgress } = useMsal();
const [initializing, setInitializing] = useState(true);
const [loginAttempted, setLoginAttempted] = useState(false);
// Auto-login if there are accounts but none active
useEffect(() => {
const checkAccounts = async () => {
try {
// Ensure MSAL is initialized
if (!instance.initialized) {
console.log("Login: Waiting for MSAL to initialize...");
// Wait a moment for initialization
await new Promise(resolve => setTimeout(resolve, 1000));
}
const allAccounts = instance.getAllAccounts();
console.log("Login: Accounts found:", allAccounts.length);
// If we have accounts but no active account, set the first one active
if (allAccounts.length > 0 && !instance.getActiveAccount()) {
console.log("Login: Setting active account:", allAccounts[0].name);
instance.setActiveAccount(allAccounts[0]);
// Force a reload to ensure the authenticated state is properly recognized
window.location.reload();
}
setInitializing(false);
} catch (error) {
console.error("Login: Error checking accounts:", error);
setInitializing(false);
}
};
checkAccounts();
}, [instance, accounts]);
const handleLogin = async () => {
setLoginAttempted(true);
// Log any existing accounts for debugging
const allAccounts = instance.getAllAccounts();
if (allAccounts.length > 0) {
console.log("Login: Existing accounts found:", allAccounts.map(a => a.username || a.name));
// If we have accounts, just set one active instead of logging in again
if (!instance.getActiveAccount()) {
console.log("Login: Setting active account from existing accounts");
instance.setActiveAccount(allAccounts[0]);
// Force a reload to refresh the auth state
window.location.reload();
return; // Exit early - no need to log in again
}
} else {
console.log("Login: No existing accounts found, proceeding with login");
}
// Open Azure AD login page directly
// Use id_token for better backend compatibility
const tenantId = "e519c2e6-bc6d-4fdf-8d9c-923c2f002385";
const clientId = "9079054c-9620-4757-a256-23413042f1ef";
const redirectUri = encodeURIComponent("https://ai-sandbox.oliver.solutions/video_query/");
const responseType = "id_token+token"; // Get both ID token and access token
const scope = encodeURIComponent("openid profile email User.Read");
const nonce = Math.random().toString(36).substring(2, 15);
const state = Math.random().toString(36).substring(2, 15);
// Store state and nonce in session storage to verify later
sessionStorage.setItem("loginState", state);
sessionStorage.setItem("loginNonce", nonce);
// Construct the authorization URL for implicit flow with ID token
const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?` +
`client_id=${clientId}` +
`&response_type=${responseType}` +
`&redirect_uri=${redirectUri}` +
`&scope=${scope}` +
`&state=${state}` +
`&nonce=${nonce}` +
`&response_mode=fragment` +
`&prompt=select_account`;
console.log("Login: Redirecting to authorization endpoint with implicit flow");
// Redirect to the authorization endpoint
window.location.href = authUrl;
};
// Show appropriate loading or login state
if (initializing) {
return (
<div className="text-center my-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="mt-3">Checking authentication status...</p>
</div>
);
}
// Show loading during the auth process
if (inProgress === "login" || loginAttempted) {
return (
<div className="text-center my-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="mt-3">Signing in with Microsoft...</p>
<p className="text-muted small">
If the login process seems stuck, please refresh the page and try again.
</p>
</div>
);
}
return (
<div className="text-center my-5 p-5 bg-light rounded">
<h2 className="mb-4">Please sign in</h2>
<p className="mb-4">
You need to sign in with your Microsoft account to use this application.
</p>
<button
className="btn btn-primary btn-lg"
onClick={handleLogin}
disabled={inProgress === "login"}
>
Sign in with Microsoft
</button>
</div>
);
};
export default Login;

View file

@ -0,0 +1,53 @@
import React from 'react';
// Predefined prompts for each mode
const PROMPTS = {
meeting_summary: "Generate a detailed summary of the meeting in the attached video recording, including discussion points and action items with owners",
process_documentation: "Generate detailed process documentation suitable for reference or training based on the process illustrated in the attached video recording. Write the documentation so that a new user will be able to follow step by step and accomplish the task illustrated in the video",
documentation_with_charts: "Analyze this video to create comprehensive process documentation with workflow diagrams for a knowledge base article. Follow these requirements exactly:\n\n1. CONTENT REQUIREMENTS:\n - Provide a detailed step-by-step explanation of the process shown\n - Be extremely verbose and thorough - include all relevant details, context, and nuances\n - Structure as a complete knowledge base article with clear sections\n - Include overview, detailed steps, tips, and troubleshooting where applicable\n\n2. MERMAID DIAGRAM REQUIREMENTS:\n - Create workflow diagrams using valid Mermaid syntax where helpful\n - CRITICAL: Use only simple alphanumeric text in node descriptions and labels\n - CRITICAL: No special characters like quotes brackets colons semicolons or symbols in node text\n - CRITICAL: Use underscores instead of spaces in node IDs and labels\n - CRITICAL: Keep all text simple to avoid syntax errors\n - Example format: Start_Process --> Complete_Task --> End_Process\n - Use flowchart format: graph TD or graph LR\n\n3. OUTPUT STRUCTURE:\n - Title and overview section\n - Prerequisites section if applicable\n - Detailed step-by-step process\n - Mermaid workflow diagram(s) showing the process flow\n - Tips and best practices\n - Troubleshooting common issues\n\nEnsure all Mermaid diagrams use simple text without special characters to prevent parsing errors.",
custom: "" // Empty for custom mode
};
const PromptSelector = ({ mode, onModeChange, prompt, onPromptChange, disabled }) => {
// Handle mode change and update prompt
const handleModeChange = (e) => {
const newMode = e.target.value;
onModeChange(newMode);
onPromptChange(PROMPTS[newMode]);
};
return (
<div className="mb-4">
<div className="mb-3">
<label htmlFor="mode-select" className="form-label">Select Mode:</label>
<select
id="mode-select"
className="form-select"
value={mode}
onChange={handleModeChange}
disabled={disabled}
>
<option value="meeting_summary">Meeting Summary</option>
<option value="process_documentation">Process/Tool Documentation</option>
<option value="documentation_with_charts">Process Tool Documentation with Charts</option>
<option value="custom">Custom Prompt</option>
</select>
</div>
<div className="mb-3">
<label htmlFor="prompt-text" className="form-label">Prompt:</label>
<textarea
id="prompt-text"
className="form-control"
rows="4"
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
placeholder="Enter or edit the prompt..."
disabled={disabled}
/>
</div>
</div>
);
};
export default PromptSelector;

View file

@ -0,0 +1,401 @@
import React, { useRef, useEffect, useState } from 'react';
import showdown from 'showdown';
import mermaid from 'mermaid';
const ResultDisplay = ({ result, isLoading, uploadProgress = 0 }) => {
const resultRef = useRef(null);
const [htmlContent, setHtmlContent] = useState('');
// Initialize mermaid
useEffect(() => {
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose'
});
}, []);
// Convert markdown to HTML using showdown
useEffect(() => {
if (result) {
const converter = new showdown.Converter({
tables: true,
tasklists: true,
strikethrough: true,
ghCodeBlocks: true
});
const html = converter.makeHtml(result);
setHtmlContent(html);
}
}, [result]);
// Render mermaid diagrams after HTML content is set
useEffect(() => {
if (htmlContent && resultRef.current) {
setTimeout(() => {
try {
// Find text containing "graph" or "sequenceDiagram" or "flowchart" outside of code blocks
const textNodes = Array.from(resultRef.current.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE ||
(node.nodeType === Node.ELEMENT_NODE &&
node.tagName !== 'PRE' &&
node.tagName !== 'CODE'));
// Standard code blocks with mermaid
const mermaidCodeBlocks = resultRef.current.querySelectorAll('pre code.language-mermaid');
// Also try to find any pre/code with mermaid content when class wasn't set correctly
const potentialMermaidBlocks = Array.from(resultRef.current.querySelectorAll('pre'))
.filter(pre => {
const codeEl = pre.querySelector('code');
if (!codeEl) return false;
const content = codeEl.textContent.trim();
return content.startsWith('graph ') ||
content.startsWith('sequenceDiagram') ||
content.startsWith('flowchart ') ||
content.includes('mermaid');
});
// Process all known mermaid code blocks
const processBlock = (element, index) => {
const isPreElement = element.tagName === 'PRE';
const codeEl = isPreElement ? element.querySelector('code') : null;
const mermaidCode = codeEl ? codeEl.textContent : element.textContent;
if (!mermaidCode.trim()) return;
// Create a div to hold the rendered diagram
const diagramDiv = document.createElement('div');
diagramDiv.className = 'mermaid';
diagramDiv.id = `mermaid-diagram-${index}`;
diagramDiv.textContent = mermaidCode;
// Store the original code as an attribute so we can access it later
diagramDiv.setAttribute('data-original-code', mermaidCode);
// Replace the original element with the diagram div
if (isPreElement && element.parentElement) {
element.parentElement.replaceChild(diagramDiv, element);
} else if (element.parentElement) {
element.parentElement.replaceChild(diagramDiv, element);
}
};
// Process standard mermaid blocks
mermaidCodeBlocks.forEach(processBlock);
// Process potential mermaid blocks not correctly marked
potentialMermaidBlocks.forEach((block, index) => {
// Only process if it wasn't already processed as a standard mermaid block
if (!block.querySelector('code.language-mermaid')) {
processBlock(block, mermaidCodeBlocks.length + index);
}
});
// Now run mermaid rendering
mermaid.run();
} catch (error) {
console.error('Error rendering mermaid diagrams:', error);
}
}, 100); // Small delay to ensure DOM is fully updated
}
}, [htmlContent]);
// This function is no longer needed with the new approach
const copyToClipboard = () => {
if (!htmlContent) return;
// Clone the current content with rendered diagrams
const contentToExport = resultRef.current.cloneNode(true);
document.body.appendChild(contentToExport);
// Select the content
const range = document.createRange();
range.selectNode(contentToExport);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
// Copy the selected content as HTML
document.execCommand('copy');
// Clean up
window.getSelection().removeAllRanges();
document.body.removeChild(contentToExport);
alert('Formatted content copied to clipboard! You can now paste it into Word or other applications.');
};
const [isPdfLoading, setIsPdfLoading] = useState(false);
const downloadPdf = async () => {
if (!htmlContent) return;
setIsPdfLoading(true);
try {
// Wait for any final rendering
await new Promise(resolve => setTimeout(resolve, 1000));
// Force re-render if necessary and wait
if (resultRef.current && resultRef.current.querySelectorAll('.mermaid:not(:empty) svg').length !== resultRef.current.querySelectorAll('.mermaid').length) {
// console.log("Forcing mermaid.run() for PDF export.");
try {
mermaid.run({ nodes: resultRef.current.querySelectorAll('.mermaid') });
await new Promise(resolve => setTimeout(resolve, 1500)); // Longer wait after explicit run
} catch(e) {
console.error("Error during mermaid.run() for PDF:", e);
}
}
const diagramPngs = {}; // Store base64 PNGs keyed by diagram ID
const textDiagrams = {}; // Still useful for alt text or if PNG fails
// It's crucial to work on the live DOM state that mermaid.js has modified.
// Clone the resultRef.current to avoid altering the displayed content if we assign IDs.
const contentToExport = resultRef.current.cloneNode(true);
const mermaidDivs = contentToExport.querySelectorAll('.mermaid');
// console.log(`Found ${mermaidDivs.length} .mermaid elements for PDF export.`);
// Create an array of promises for all conversions
const conversionPromises = Array.from(mermaidDivs).map(async (div, index) => {
let diagramId = div.id;
if (!diagramId) {
// If a div.mermaid doesn't have an ID, assign one.
diagramId = `mermaid-export-${index}`;
div.id = diagramId; // Assign ID to the div in our cloned content
// console.log(`Assigned dynamic ID ${diagramId} to a .mermaid div for export.`);
}
const originalCode = div.getAttribute('data-original-code') ||
(div.firstChild && div.firstChild.nodeType === Node.TEXT_NODE ? div.firstChild.textContent.trim() : div.textContent.trim());
if (originalCode) {
textDiagrams[diagramId] = originalCode;
} else {
console.warn(`No original code found for .mermaid div with ID: ${diagramId}`);
}
const svgElement = div.querySelector('svg');
if (svgElement) {
try {
const svgString = new XMLSerializer().serializeToString(svgElement);
const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
const image = new Image();
// Create a promise for each image load and canvas conversion
await new Promise((resolve, reject) => {
image.onload = () => {
const canvas = document.createElement('canvas');
// --- Determine canvas size ---
// Option 1: Use SVG's explicit width/height if they are pixel values
let svgWidth = parseFloat(svgElement.getAttribute('width'));
let svgHeight = parseFloat(svgElement.getAttribute('height'));
// Option 2: If no explicit width/height, use viewBox (more robust)
if (isNaN(svgWidth) || isNaN(svgHeight) || svgWidth <= 0 || svgHeight <= 0) {
const viewBox = svgElement.getAttribute('viewBox');
if (viewBox) {
const parts = viewBox.split(' ');
svgWidth = parseFloat(parts[2]);
svgHeight = parseFloat(parts[3]);
}
}
// Fallback if dimensions still not found
if (isNaN(svgWidth) || isNaN(svgHeight) || svgWidth <= 0 || svgHeight <= 0) {
console.warn(`Could not determine dimensions for SVG ${diagramId}, using fallback.`);
svgWidth = 600; // Default/fallback width
svgHeight = 400; // Default/fallback height
}
// Apply a scale factor for better resolution
const scaleFactor = 2; // 2x resolution
canvas.width = svgWidth * scaleFactor;
canvas.height = svgHeight * scaleFactor;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Could not get 2D context from canvas');
reject(new Error('Canvas 2D context unavailable'));
return;
}
// Fill background with white to ensure opaque PNG
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
diagramPngs[diagramId] = canvas.toDataURL('image/png');
// console.log(`Converted SVG ${diagramId} to PNG (length: ${diagramPngs[diagramId].length})`);
resolve();
};
image.onerror = (err) => {
console.error(`Error loading SVG into Image object for ${diagramId}:`, err);
reject(new Error(`Image loading error for SVG ${diagramId}`));
};
image.src = svgDataUrl;
});
} catch (e) {
console.error(`Error converting SVG ${diagramId} to PNG:`, e);
// No PNG for this diagram if conversion fails
}
} else {
console.warn(`No SVG element found in .mermaid div ID: ${diagramId}. Original code snippet: ${(originalCode || '').substring(0,30)}`);
}
});
// Wait for all SVG to PNG conversions to complete
await Promise.all(conversionPromises);
// The HTML to send is the innerHTML of our (potentially ID-modified) cloned container.
const htmlToSend = contentToExport.innerHTML;
// Debug logging commented out
// console.log("Sending to backend for PDF generation:", {
// htmlLength: htmlToSend.length,
// numTextDiagrams: Object.keys(textDiagrams).length,
// numDiagramPngs: Object.keys(diagramPngs).length,
// });
// if (Object.keys(diagramPngs).length > 0) {
// console.log("Diagram PNG IDs:", Object.keys(diagramPngs));
// }
// Debug logging commented out
// console.log("HTML CONTENT START -------------------");
// console.log(htmlToSend);
// console.log("HTML CONTENT END ---------------------");
// Make API request to generate PDF
const authApiClient = require('../auth/authApiClient').authApiClient;
const response = await authApiClient.post(
'https://ai-sandbox.oliver.solutions/video_query_back/api/generate-pdf',
{
html: htmlToSend,
textDiagrams: textDiagrams,
diagramPngs: diagramPngs // Send the base64 PNGs instead of SVGs
},
{
headers: {
'Content-Type': 'application/json'
}
}
);
if (response.data.success) {
// Convert base64 PDF to blob
const pdfData = atob(response.data.pdf);
const pdfBytes = new Uint8Array(pdfData.length);
for (let i = 0; i < pdfData.length; i++) {
pdfBytes[i] = pdfData.charCodeAt(i);
}
const pdfBlob = new Blob([pdfBytes], { type: 'application/pdf' });
const pdfUrl = URL.createObjectURL(pdfBlob);
// Create download link and trigger download
const downloadLink = document.createElement('a');
downloadLink.href = pdfUrl;
downloadLink.download = response.data.filename || 'video_query_result.pdf';
document.body.appendChild(downloadLink);
downloadLink.click();
// Clean up
document.body.removeChild(downloadLink);
setTimeout(() => {
URL.revokeObjectURL(pdfUrl);
}, 100);
} else {
throw new Error(response.data.message || 'PDF generation failed');
}
} catch (error) {
console.error('Error downloading PDF:', error);
alert('Failed to generate PDF. Please try again later.');
} finally {
setIsPdfLoading(false);
}
};
if (isLoading) {
const isUploading = uploadProgress < 100;
return (
<div className="processing-spinner">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p>
{isUploading
? `Uploading video: ${uploadProgress}% complete...`
: 'Processing video... This may take several minutes depending on the video length.'}
</p>
<div className="progress w-75 mb-3">
<div
className="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style={{ width: isUploading ? `${uploadProgress}%` : '100%' }}
aria-valuenow={isUploading ? uploadProgress : 100}
aria-valuemin="0"
aria-valuemax="100"
></div>
</div>
{!isUploading && (
<div className="alert alert-info">
<small>
<strong>Note:</strong> Your video has been uploaded successfully and is being processed by Gemini AI.
This may take several minutes for longer videos (up to 55 minutes supported).
</small>
</div>
)}
</div>
);
}
if (!result) {
return null;
}
return (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h3>Result</h3>
<div className="d-flex gap-2">
<button
className="btn btn-primary btn-sm"
onClick={copyToClipboard}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-clipboard me-1" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
Copy Formatted
</button>
<button
className="btn btn-danger btn-sm"
onClick={downloadPdf}
disabled={isPdfLoading}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-file-pdf me-1" viewBox="0 0 16 16">
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
<path d="M4.603 14.087a.81.81 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.198-.307.526-.568.897-.787a7.68 7.68 0 0 1 1.482-.645 19.697 19.697 0 0 0 1.062-2.227 7.269 7.269 0 0 1-.43-1.295c-.086-.4-.119-.796-.046-1.136.075-.354.274-.672.65-.823.192-.077.4-.12.602-.077a.7.7 0 0 1 .477.365c.088.164.12.356.127.538.007.188-.012.396-.047.614-.084.51-.27 1.134-.52 1.794a10.954 10.954 0 0 0 .98 1.686 5.753 5.753 0 0 1 1.334.05c.364.066.734.195.96.465.12.144.193.32.2.518.007.192-.047.382-.138.563a1.04 1.04 0 0 1-.354.416.856.856 0 0 1-.51.138c-.331-.014-.654-.196-.933-.417a5.712 5.712 0 0 1-.911-.95 11.651 11.651 0 0 0-1.997.406 11.307 11.307 0 0 1-1.02 1.51c-.292.35-.609.656-.927.787a.793.793 0 0 1-.58.029zm1.379-1.901c-.166.076-.32.156-.459.238-.328.194-.541.383-.647.547-.094.145-.096.25-.04.361.01.022.02.036.026.044a.266.266 0 0 0 .035-.012c.137-.056.355-.235.635-.572a8.18 8.18 0 0 0 .45-.606zm1.64-1.33a12.71 12.71 0 0 1 1.01-.193 11.744 11.744 0 0 1-.51-.858 20.801 20.801 0 0 1-.5 1.05zm2.446.45c.15.163.296.3.435.41.24.19.407.253.498.256a.107.107 0 0 0 .07-.015.307.307 0 0 0 .094-.125.436.436 0 0 0 .059-.2.095.095 0 0 0-.026-.063c-.052-.062-.2-.152-.518-.209a3.876 3.876 0 0 0-.612-.053zM8.078 7.8a6.7 6.7 0 0 0 .2-.828c.031-.188.043-.343.038-.465a.613.613 0 0 0-.032-.198.517.517 0 0 0-.145.04c-.087.035-.158.106-.196.283-.04.192-.03.469.046.822.024.111.054.227.09.346z"/>
</svg>
{isPdfLoading ? 'Generating...' : 'Download PDF'}
</button>
</div>
</div>
<div
className="result-container"
ref={resultRef}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
<div className="copy-instruction mt-3">
<p className="mb-0"><strong>Tip:</strong> Click "Copy Formatted" to copy the content in a format suitable for pasting into Word or other document editors.</p>
</div>
</div>
);
};
export default ResultDisplay;

View file

@ -0,0 +1,75 @@
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5GB
const VideoUpload = ({ onVideoSelect }) => {
const [error, setError] = useState('');
const onDrop = useCallback((acceptedFiles) => {
setError('');
// Handle the uploaded files
if (acceptedFiles && acceptedFiles.length > 0) {
const file = acceptedFiles[0];
// Check file size
if (file.size > MAX_FILE_SIZE) {
setError(`File is too large. Maximum size is 5GB.`);
return;
}
// Check file type
const validTypes = ['video/mp4', 'video/avi', 'video/quicktime', 'video/x-ms-wmv', 'video/x-matroska', 'video/webm'];
if (!validTypes.includes(file.type)) {
setError('Please upload a valid video file (MP4, AVI, MOV, WMV, MKV, WEBM)');
return;
}
// Pass the file to parent component
onVideoSelect(file);
}
}, [onVideoSelect]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'video/*': ['.mp4', '.avi', '.mov', '.wmv', '.mkv', '.webm']
},
maxFiles: 1
});
return (
<div className="mb-4">
<div
{...getRootProps()}
className={`dropzone ${isDragActive ? 'active' : ''}`}
style={{
borderColor: error ? '#dc3545' : isDragActive ? '#0d6efd' : '#ced4da'
}}
>
<input {...getInputProps()} />
<div className="mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" className="bi bi-camera-video" viewBox="0 0 16 16">
<path fillRule="evenodd" d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2V5zm11.5 5.175 3.5 1.556V4.269l-3.5 1.556v4.35zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H2z"/>
</svg>
</div>
<p>
{isDragActive ?
'Drop the video here...' :
'Drag and drop a video file here, or click to select a file'}
</p>
<p className="text-muted small">Supported formats: MP4, AVI, MOV, WMV, MKV, WEBM (max 5GB)</p>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<div className="notice">
<strong>Important:</strong> The Gemini AI model can only process videos up to 55 minutes in length.
Longer videos will fail to process.
</div>
</div>
);
};
export default VideoUpload;

213
frontend/src/index.css Normal file
View file

@ -0,0 +1,213 @@
body {
margin: 0;
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: 20px;
}
code {
/* Preserve monospace font for code blocks */
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
background-color: #f5f5f5;
padding: 2px 5px;
border-radius: 3px;
font-size: 0.9em;
letter-spacing: 0; /* Reset letter spacing for code */
}
pre {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
pre code {
background-color: transparent;
padding: 0;
}
.dropzone {
border: 2px dashed #ccc;
border-radius: 5px;
padding: 30px;
text-align: center;
cursor: pointer;
margin-bottom: 20px;
transition: all 0.3s ease;
}
.dropzone:hover, .dropzone.active {
border-color: #0d6efd;
background-color: rgba(13, 110, 253, 0.1);
}
.result-container {
padding: 20px;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
margin-top: 20px;
max-height: 600px;
overflow-y: auto;
line-height: 1.7;
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, sans-serif;
letter-spacing: 0.1px;
}
/* Mermaid diagram styling */
.mermaid {
text-align: center;
margin: 20px 0;
overflow-x: auto;
}
.mermaid svg {
max-width: 100%;
height: auto;
margin: 0 auto;
display: block;
}
/* Universal Font Styling */
h1, h2, h3, h4, h5, h6, p, div, span, button, input, textarea, select, label, a {
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* Adjust font weights for better readability with Montserrat */
h1, h2, h3 {
font-weight: 600;
letter-spacing: -0.5px;
}
h4, h5, h6 {
font-weight: 500;
letter-spacing: -0.25px;
}
p, div, span, label {
font-weight: 400;
letter-spacing: 0.1px;
}
button, input[type="button"], input[type="submit"] {
font-weight: 500;
letter-spacing: 0.3px;
}
/* Bootstrap overrides for Montserrat */
.btn {
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, sans-serif !important;
font-weight: 500 !important;
letter-spacing: 0.3px;
}
.form-control, .form-select {
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, sans-serif !important;
font-weight: 400;
}
.alert, .card, .modal-content {
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, sans-serif !important;
}
/* Styling for markdown rendered content */
.result-container h1,
.result-container h2,
.result-container h3,
.result-container h4,
.result-container h5,
.result-container h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
line-height: 1.25;
font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, sans-serif;
}
.result-container h1 {
font-size: 2em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
.result-container h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
.result-container p {
margin-top: 0;
margin-bottom: 16px;
}
.result-container ul,
.result-container ol {
padding-left: 2em;
margin-bottom: 16px;
}
.result-container blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 16px 0;
}
.result-container table {
display: block;
width: 100%;
overflow: auto;
margin-bottom: 16px;
border-spacing: 0;
border-collapse: collapse;
}
.result-container table th,
.result-container table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
.result-container table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.result-container table tr:nth-child(2n) {
background-color: #f6f8fa;
}
.notice {
background-color: #fff3cd;
color: #856404;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
border-left: 5px solid #ffeeba;
}
.processing-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 40px 0;
}
/* Progress Bar */
.progress {
height: 10px;
}
.copy-instruction {
background-color: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-top: 15px;
font-size: 0.9rem;
}

15
frontend/src/index.js Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
import App from './App';
import { AuthProvider } from './auth/AuthProvider';
const root = createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,210 @@
/**
* Handles chunked file uploads for large files
*/
import { authApiClient } from '../auth/authApiClient';
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB chunks
const BACKEND_URL = 'https://ai-sandbox.oliver.solutions/video_query_back';
class ChunkedUploader {
constructor(file, onProgress) {
this.file = file;
this.onProgress = onProgress || (() => {});
this.uploadId = null;
this.aborted = false;
}
/**
* Initialize upload session
*/
async initUpload() {
try {
const response = await authApiClient.post(
`${BACKEND_URL}/api/init-upload`,
{
filename: this.file.name,
size: this.file.size,
},
{
headers: {
'Content-Type': 'application/json'
}
}
);
if (!response.data.success) {
throw new Error(response.data.message || 'Failed to initialize upload');
}
this.uploadId = response.data.upload_id;
console.log(`Upload initialized with ID: ${this.uploadId}`);
return this.uploadId;
} catch (err) {
console.error('Error initializing upload:', err);
// Check for auth error (The auth client should handle the redirect)
if (err.response && err.response.status === 401) {
this.aborted = true;
throw new Error('Authentication error: Your session expired. Please log in again.');
}
throw err;
}
}
/**
* Upload a chunk of the file
*/
async uploadChunk(chunk, chunkNumber) {
if (this.aborted) {
throw new Error('Upload aborted');
}
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunk_number', chunkNumber);
try {
const response = await authApiClient.post(
`${BACKEND_URL}/api/upload-chunk/${this.uploadId}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
if (!response.data.success) {
throw new Error(response.data.message || 'Failed to upload chunk');
}
return response.data;
} catch (err) {
console.error(`Error uploading chunk ${chunkNumber}:`, err);
// Check for auth error (The auth client should handle the redirect)
if (err.response && err.response.status === 401) {
this.aborted = true; // Mark upload as aborted so other chunks don't continue
throw new Error('Authentication error: Your session expired during the upload. Please log in again.');
}
throw err;
}
}
/**
* Complete the upload process
*/
async completeUpload() {
try {
const response = await authApiClient.post(
`${BACKEND_URL}/api/complete-upload/${this.uploadId}`,
{},
{
headers: {
'Content-Type': 'application/json'
}
}
);
if (!response.data.success) {
throw new Error(response.data.message || 'Failed to complete upload');
}
return response.data;
} catch (err) {
console.error('Error completing upload:', err);
// Check for auth error (The auth client should handle the redirect)
if (err.response && err.response.status === 401) {
this.aborted = true;
throw new Error('Authentication error: Your session expired. Please log in again.');
}
throw err;
}
}
/**
* Cancel the upload
*/
async cancelUpload() {
this.aborted = true;
if (!this.uploadId) {
return { success: true, message: 'No active upload to cancel' };
}
try {
const response = await authApiClient.post(
`${BACKEND_URL}/api/cancel-upload/${this.uploadId}`,
{},
{
headers: {
'Content-Type': 'application/json'
}
}
);
return response.data;
} catch (err) {
console.error('Error canceling upload:', err);
throw err;
}
}
/**
* Upload the entire file in chunks
*/
async uploadFile() {
try {
// Initialize the upload
await this.initUpload();
const totalChunks = Math.ceil(this.file.size / CHUNK_SIZE);
console.log(`Uploading file in ${totalChunks} chunks...`);
// Upload each chunk
for (let i = 0; i < totalChunks; i++) {
if (this.aborted) {
throw new Error('Upload aborted');
}
const start = i * CHUNK_SIZE;
const end = Math.min(this.file.size, start + CHUNK_SIZE);
const chunk = this.file.slice(start, end);
const result = await this.uploadChunk(chunk, i);
// Report progress
this.onProgress(result.progress);
console.log(`Chunk ${i+1}/${totalChunks} uploaded (${result.progress}%)`);
}
// Complete the upload
const result = await this.completeUpload();
console.log('Upload completed successfully:', result);
// Final progress update
this.onProgress(100);
return result;
} catch (err) {
console.error('Upload failed:', err);
// Try to cancel/cleanup on failure
try {
if (this.uploadId) {
await this.cancelUpload();
}
} catch (cancelErr) {
console.error('Error during upload cleanup:', cancelErr);
}
throw err;
}
}
}
export default ChunkedUploader;

17
quick_extract.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
# Quick one-liner to extract user emails from logs
# Usage: ./quick_extract.sh [service_name]
SERVICE_NAME="${1:-veo-video-generator}"
echo "Extracting user emails from $SERVICE_NAME logs..."
echo "user_email,prompt" > quick_users.csv
journalctl -u "$SERVICE_NAME" --no-pager | \
grep "Raw JSON data received:" | \
sed -n "s/.*Raw JSON data received: \(.*\)/\1/p" | \
jq -r 'select(.user_email != null) | [.user_email, .prompt] | @csv' 2>/dev/null >> quick_users.csv
echo "Results saved to quick_users.csv"
echo "Total records: $(($(wc -l < quick_users.csv) - 1))"

43
requirements.txt Normal file
View file

@ -0,0 +1,43 @@
annotated-types==0.7.0
blinker==1.9.0
cachetools==5.5.2
certifi==2025.4.26
charset-normalizer==3.4.2
click==8.2.0
Flask==3.1.0
google-ai-generativelanguage==0.6.15
google-api-core==2.25.0rc0
google-api-python-client==2.169.0
google-auth==2.40.0
google-auth-httplib2==0.2.0
google-generativeai==0.8.5
googleapis-common-protos==1.70.0
grpcio==1.71.0
grpcio-status==1.71.0
h11==0.16.0
h2==4.2.0
hpack==4.1.0
httplib2==0.22.0
Hypercorn==0.17.3
hyperframe==6.1.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
priority==2.0.0
proto-plus==1.26.1
protobuf==5.29.4
pyasn1==0.6.1
pyasn1_modules==0.4.2
pydantic==2.11.4
pydantic_core==2.33.2
pyparsing==3.2.3
requests==2.32.3
rsa==4.9.1
tqdm==4.67.1
typing-inspection==0.4.0
typing_extensions==4.13.2
uritemplate==4.1.1
urllib3==2.4.0
Werkzeug==3.1.3
wsproto==1.2.0

26
restart.sh Executable file
View file

@ -0,0 +1,26 @@
#!/bin/bash
echo "Stopping any existing servers..."
pkill -f "python.*run.py" || true
pkill -f "test_endpoint.py" || true
# Make the script executable
chmod +x ./backend/run.py
chmod +x ./backend/test_endpoint.py
# Start the test endpoint in a separate terminal window
echo "Starting test endpoint server..."
cd backend
python test_endpoint.py &
cd ..
echo "Waiting for test server to start..."
sleep 2
echo "You can now restart the React development server with:"
echo "cd frontend && npm start"
echo "To test the full application with large files, run:"
echo "cd backend && python run.py"
echo "Done!"

141
video_query.py Normal file
View file

@ -0,0 +1,141 @@
import google.generativeai as genai
import mimetypes
import time
import os
import argparse
# --- CONFIGURATION ---
# !!! REPLACE WITH YOUR ACTUAL API KEY !!!
# It's best practice to set this as an environment variable
# e.g., export GOOGLE_API_KEY="YOUR_API_KEY"
# If you do that, the next line can be removed.
API_KEY = "AIzaSyBF3Ia1nVS4PLuLpWt-85ct_heJ7FrlvkQ" # <--- REPLACE THIS!
# Default prompt if none is provided
DEFAULT_PROMPT = "What is happening in this video? Describe the main objects and actions."
# File size in bytes for chunked upload (10MB)
CHUNKED_UPLOAD_CUTOFF = 10 * 1024 * 1024
# --- END CONFIGURATION ---
def upload_video_and_query(api_key, video_path, prompt):
"""
Uploads a video to Gemini, queries it with a prompt, and prints the response.
"""
if api_key == "YOUR_GEMINI_API_KEY":
print("ERROR: Please replace 'YOUR_GEMINI_API_KEY' with your actual API key.")
return
if not os.path.exists(video_path):
print(f"ERROR: Video file not found at '{video_path}'")
return
try:
# Configure the Gemini client
genai.configure(api_key=api_key)
print(f"Attempting to upload '{video_path}'...")
# 1. Upload the video file
file_size = os.path.getsize(video_path)
# Log file size relative to threshold (for informational purposes only)
if file_size > CHUNKED_UPLOAD_CUTOFF:
print(f"File size ({file_size} bytes) exceeds {CHUNKED_UPLOAD_CUTOFF} bytes threshold...")
else:
print(f"File size ({file_size} bytes) is below threshold...")
# Upload the file - all uploads use same method, but we always log the threshold
video_file = genai.upload_file(
path=video_path,
display_name=os.path.basename(video_path)
)
print(f"Successfully uploaded file: {video_file.display_name} as {video_file.uri}")
print(f"File state: {video_file.state.name}") # Should ideally be ACTIVE
# Ensure the file is ready for use (though upload_file usually handles this)
# This is a safety check loop.
while video_file.state.name == "PROCESSING":
print("File is still processing. Waiting...")
time.sleep(5) # Wait for 5 seconds before checking again
video_file = genai.get_file(name=video_file.name) # Re-fetch file state
print(f"File state: {video_file.state.name}")
if video_file.state.name != "ACTIVE":
print(f"Error: File '{video_file.name}' did not become active. Current state: {video_file.state.name}")
print("It might still be processing, or an error occurred during upload/processing.")
print("Please check the file status in Google AI Studio or try again later.")
return
# 2. Prepare the prompt for the vision model
# We need the MIME type for the video file.
mime_type, _ = mimetypes.guess_type(video_path)
if not mime_type:
print(f"Could not determine MIME type for {video_path}. Assuming 'video/mp4'.")
mime_type = "video/mp4" # Fallback, adjust if needed
# Create the content parts: the text prompt and the video file reference
# In newer versions of the API, we use a dictionary format for content
prompt_parts = [
{"text": prompt}, # Your text prompt
{"file_data": {
"file_uri": video_file.uri,
"mime_type": mime_type
}}
]
# 3. Initialize the Generative Model (gemini-2.5-pro for video)
model = genai.GenerativeModel(model_name="gemini-2.5-pro")
print("\nSending prompt to Gemini 2.5 Pro model...")
# 4. Generate content
response = model.generate_content(prompt_parts)
# 5. Print the response
print("\n--- Gemini Response ---")
if response.parts:
for part in response.parts:
if hasattr(part, 'text'):
print(part.text)
else:
print("No content parts in the response. Full response:")
print(response)
if hasattr(response, 'prompt_feedback') and response.prompt_feedback:
print(f"\nPrompt Feedback: {response.prompt_feedback}")
except Exception as e:
print(f"\nAn error occurred: {e}")
import traceback
traceback.print_exc()
finally:
# Optional: Delete the file from Gemini storage if you no longer need it.
# Be careful with this in a real application.
# if 'video_file' in locals() and video_file:
# try:
# print(f"\nAttempting to delete file: {video_file.name}")
# genai.delete_file(name=video_file.name)
# print("File deleted successfully.")
# except Exception as e_del:
# print(f"Error deleting file: {e_del}")
pass
if __name__ == "__main__":
# Setup argument parser
parser = argparse.ArgumentParser(description="Upload a video to Gemini and query it")
parser.add_argument("video_path", help="Path to the video file")
parser.add_argument("--prompt", "-p", help="Query prompt for the video", default=DEFAULT_PROMPT)
args = parser.parse_args()
# Try to get API key from environment variable first
env_api_key = os.getenv("GOOGLE_API_KEY")
if env_api_key:
print("Using API key from GOOGLE_API_KEY environment variable.")
current_api_key = env_api_key
else:
current_api_key = API_KEY # Use the one defined in the script
upload_video_and_query(current_api_key, args.video_path, args.prompt)