initial commit
This commit is contained in:
commit
3008d8f8fc
47 changed files with 24313 additions and 0 deletions
384
.gitignore
vendored
Normal file
384
.gitignore
vendored
Normal 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
20
CLAUDE.md
Normal 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
105
DEPLOYMENT.md
Normal 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
111
LOG_EXTRACTION_README.md
Normal 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
143
README.md
Normal 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
1
backend/.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
GOOGLE_API_KEY=AIzaSyBF3Ia1nVS4PLuLpWt-85ct_heJ7FrlvkQ
|
||||
41
backend/README.md
Normal file
41
backend/README.md
Normal 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
1112
backend/app.py
Normal file
File diff suppressed because it is too large
Load diff
198
backend/auth.py
Normal file
198
backend/auth.py
Normal 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
166
backend/chunked_upload.py
Normal 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
24
backend/fix_jose.sh
Executable 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
50
backend/install_wkhtmltopdf.sh
Executable 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
|
||||
10
backend/requirements-py310.txt
Normal file
10
backend/requirements-py310.txt
Normal 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
55
backend/requirements.txt
Normal 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
40
backend/run.py
Executable 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
23
backend/test_api.py
Normal 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
137
backend/test_webhook.py
Normal 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()
|
||||
46
backend/test_webhook_manual.py
Normal file
46
backend/test_webhook_manual.py
Normal 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()
|
||||
17
backend/video-query.service
Normal file
17
backend/video-query.service
Normal 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
224
backend/video_processor.py
Normal 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
66
extract_user_logs.sh
Executable 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
175
extract_user_logs_robust.sh
Executable 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
2
frontend/.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
PUBLIC_URL=/video_query
|
||||
REACT_APP_BASE_URL=/video_query
|
||||
54
frontend/README.md
Normal file
54
frontend/README.md
Normal 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
18
frontend/build.sh
Executable 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
18770
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal 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
10
frontend/public/.htaccess
Normal 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>
|
||||
23
frontend/public/index.html
Normal file
23
frontend/public/index.html
Normal 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>
|
||||
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal 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
48
frontend/requirements.txt
Normal 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
388
frontend/src/App.js
Normal 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;
|
||||
167
frontend/src/auth/AuthProvider.js
Normal file
167
frontend/src/auth/AuthProvider.js
Normal 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;
|
||||
146
frontend/src/auth/authApiClient.js
Normal file
146
frontend/src/auth/authApiClient.js
Normal 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;
|
||||
44
frontend/src/auth/authConfig.js
Normal file
44
frontend/src/auth/authConfig.js
Normal 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"
|
||||
};
|
||||
99
frontend/src/components/AuthenticatedContent.js
Normal file
99
frontend/src/components/AuthenticatedContent.js
Normal 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;
|
||||
137
frontend/src/components/Login.js
Normal file
137
frontend/src/components/Login.js
Normal 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;
|
||||
53
frontend/src/components/PromptSelector.js
Normal file
53
frontend/src/components/PromptSelector.js
Normal 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;
|
||||
401
frontend/src/components/ResultDisplay.js
Normal file
401
frontend/src/components/ResultDisplay.js
Normal 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;
|
||||
75
frontend/src/components/VideoUpload.js
Normal file
75
frontend/src/components/VideoUpload.js
Normal 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
213
frontend/src/index.css
Normal 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
15
frontend/src/index.js
Normal 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>
|
||||
);
|
||||
210
frontend/src/utils/chunkedUploader.js
Normal file
210
frontend/src/utils/chunkedUploader.js
Normal 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
17
quick_extract.sh
Executable 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
43
requirements.txt
Normal 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
26
restart.sh
Executable 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
141
video_query.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue