Change the video Length Silder + Video Working
This commit is contained in:
commit
88c0469fe2
29 changed files with 4158 additions and 0 deletions
13
.env.example
Normal file
13
.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Runway Gen4 Web App - Environment Variables
|
||||
# Copy this file to .env and fill in your actual values
|
||||
|
||||
# Required: Runway API Key
|
||||
# Get this from: https://app.runwayml.com/account/api-keys
|
||||
RUNWAY_API_KEY=your_runway_api_key_here
|
||||
|
||||
# Optional: Additional API keys for future features
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# Optional: Environment setting
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
70
.gitignore
vendored
Normal file
70
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Environment files
|
||||
.env
|
||||
backend/.env
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
error.log
|
||||
access.log
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
|
||||
# Cache files
|
||||
cache/
|
||||
*.cache
|
||||
|
||||
# Debug files
|
||||
debug-*.html
|
||||
debug-*.js
|
||||
|
||||
# Compiled files
|
||||
*.class
|
||||
*.py[cod]
|
||||
|
||||
# Package files
|
||||
*.jar
|
||||
|
||||
# Maven
|
||||
target/
|
||||
|
||||
# Unit test reports
|
||||
TEST*.xml
|
||||
|
||||
# Applications
|
||||
*.app
|
||||
*.exe
|
||||
*.war
|
||||
|
||||
# Large media files
|
||||
*.mp4
|
||||
*.tiff
|
||||
*.avi
|
||||
*.flv
|
||||
*.mov
|
||||
*.wmv
|
||||
108
.htaccess
Normal file
108
.htaccess
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Runway Gen4 Web App - Production .htaccess Configuration
|
||||
|
||||
# Security Headers
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
</IfModule>
|
||||
|
||||
# Hide sensitive files and directories
|
||||
<Files ".env">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</Files>
|
||||
|
||||
<Files "*.log">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</Files>
|
||||
|
||||
<FilesMatch "^(\.env|\.htaccess|composer\.(json|lock)|package\.json|tailwind\.config\.js|INSTALLATION\.md)$">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
|
||||
# Block access to source directories
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Block direct access to backend directory
|
||||
RewriteRule ^backend/ - [F,L]
|
||||
|
||||
# Block access to src directory (Tailwind source files)
|
||||
RewriteRule ^src/ - [F,L]
|
||||
|
||||
# Block access to node_modules if present
|
||||
RewriteRule ^node_modules/ - [F,L]
|
||||
</IfModule>
|
||||
|
||||
# Enable HTTPS redirect (recommended for production)
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTPS} off
|
||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
</IfModule>
|
||||
|
||||
# Main application routing
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Serve main page
|
||||
RewriteRule ^$ /public/index.html [L]
|
||||
RewriteRule ^index\.html$ /public/index.html [L]
|
||||
|
||||
# Allow direct access to public assets
|
||||
RewriteRule ^(css|js)/(.*)$ /public/$1/$2 [L]
|
||||
|
||||
# API routing for cleaner URLs
|
||||
RewriteRule ^api/generate$ /backend/api.php [L]
|
||||
RewriteRule ^api/status$ /backend/check_status.php [L]
|
||||
|
||||
# Deny access to hidden files
|
||||
RewriteRule ^\..*$ - [F,L]
|
||||
</IfModule>
|
||||
|
||||
# Enable Gzip compression for better performance
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/plain
|
||||
AddOutputFilterByType DEFLATE text/html
|
||||
AddOutputFilterByType DEFLATE text/xml
|
||||
AddOutputFilterByType DEFLATE text/css
|
||||
AddOutputFilterByType DEFLATE application/xml
|
||||
AddOutputFilterByType DEFLATE application/xhtml+xml
|
||||
AddOutputFilterByType DEFLATE application/rss+xml
|
||||
AddOutputFilterByType DEFLATE application/javascript
|
||||
AddOutputFilterByType DEFLATE application/x-javascript
|
||||
AddOutputFilterByType DEFLATE application/json
|
||||
</IfModule>
|
||||
|
||||
# Browser Caching for static assets
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive on
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/jpg "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/gif "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
ExpiresByType application/font-woff "access plus 1 year"
|
||||
ExpiresByType application/font-woff2 "access plus 1 year"
|
||||
</IfModule>
|
||||
|
||||
# Prevent access to backup files
|
||||
<FilesMatch "\.(bak|backup|old|orig|save|tmp)$">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
|
||||
# Limit file upload size (adjust as needed)
|
||||
<IfModule mod_php.c>
|
||||
php_value upload_max_filesize 10M
|
||||
php_value post_max_size 12M
|
||||
php_value max_input_time 300
|
||||
php_value max_execution_time 300
|
||||
</IfModule>
|
||||
320
INSTALLATION.md
Normal file
320
INSTALLATION.md
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
# Installation Guide - Runway Gen4 Web App
|
||||
|
||||
This guide will help you deploy the Runway Gen4 Web App from MAMP to a production Apache server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Apache 2.4+ with PHP 7.4+ support
|
||||
- SSL certificate (recommended for API security)
|
||||
- SSH access to your server
|
||||
- Domain name pointed to your server
|
||||
|
||||
## Step 1: Prepare Your Files
|
||||
|
||||
### Files to Upload
|
||||
Upload these directories and files to your web server:
|
||||
|
||||
```
|
||||
/your-domain.com/
|
||||
├── backend/ # Upload entire folder
|
||||
├── public/ # Upload entire folder
|
||||
├── .env # Create this on server
|
||||
├── .htaccess # Upload to root
|
||||
└── README.md # Optional
|
||||
```
|
||||
|
||||
### Files to NOT Upload (Keep Local Only)
|
||||
- `node_modules/` (if present)
|
||||
- `src/` (Tailwind source files)
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `tailwind.config.js`
|
||||
- `.env.example`
|
||||
|
||||
## Step 2: Environment Configuration
|
||||
|
||||
### Create .env File
|
||||
Create a `.env` file in your root directory:
|
||||
|
||||
```bash
|
||||
# Runway API Configuration
|
||||
RUNWAY_API_KEY=your_runway_api_key_here
|
||||
|
||||
# Optional: Additional API keys
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
```
|
||||
|
||||
### Set Proper Permissions
|
||||
```bash
|
||||
chmod 644 .env
|
||||
chmod 755 backend/
|
||||
chmod 644 backend/*.php
|
||||
chmod 755 public/
|
||||
chmod -R 644 public/*
|
||||
```
|
||||
|
||||
## Step 3: Apache Configuration
|
||||
|
||||
### .htaccess (Root Directory)
|
||||
```apache
|
||||
# Security Headers
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
|
||||
# Hide sensitive files and directories
|
||||
<Files ".env">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</Files>
|
||||
|
||||
<Files "*.log">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</Files>
|
||||
|
||||
<FilesMatch "^(config|\.env|\.htaccess|composer\.(json|lock)|package\.json)$">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
|
||||
# Block access to backend directory from direct web access
|
||||
RedirectMatch 404 ^/backend/
|
||||
|
||||
# Enable HTTPS redirect (recommended)
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTPS} off
|
||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# Main application routing
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^$ /public/index.html [L]
|
||||
|
||||
# Allow direct access to public assets
|
||||
RewriteRule ^(css|js|images)/(.*)$ /public/$1/$2 [L]
|
||||
|
||||
# API routing
|
||||
RewriteRule ^api/generate$ /backend/api.php [L]
|
||||
RewriteRule ^api/status$ /backend/check_status.php [L]
|
||||
|
||||
# Deny access to hidden files
|
||||
RewriteRule ^\..*$ - [F,L]
|
||||
```
|
||||
|
||||
### Alternative: VirtualHost Configuration
|
||||
If you have access to Apache virtual host configuration:
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName yourdomain.com
|
||||
DocumentRoot /var/www/yourdomain.com
|
||||
|
||||
# SSL Configuration
|
||||
SSLEngine on
|
||||
SSLCertificateFile /path/to/certificate.crt
|
||||
SSLCertificateKeyFile /path/to/private.key
|
||||
|
||||
# Security Headers
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
|
||||
# Directory specific settings
|
||||
<Directory "/var/www/yourdomain.com/backend">
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</Directory>
|
||||
|
||||
<Directory "/var/www/yourdomain.com/public">
|
||||
Options -Indexes
|
||||
AllowOverride None
|
||||
Order allow,deny
|
||||
Allow from all
|
||||
</Directory>
|
||||
|
||||
# Error and Access Logs
|
||||
ErrorLog ${APACHE_LOG_DIR}/yourdomain.com_error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/yourdomain.com_access.log combined
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
## Step 4: PHP Configuration
|
||||
|
||||
### Recommended php.ini Settings
|
||||
```ini
|
||||
# File Upload Settings
|
||||
upload_max_filesize = 10M
|
||||
post_max_size = 12M
|
||||
max_input_time = 300
|
||||
max_execution_time = 300
|
||||
|
||||
# Error Reporting (Production)
|
||||
display_errors = Off
|
||||
log_errors = On
|
||||
error_log = /path/to/your/error.log
|
||||
|
||||
# Security
|
||||
expose_php = Off
|
||||
allow_url_fopen = On
|
||||
allow_url_include = Off
|
||||
```
|
||||
|
||||
## Step 5: Update File Paths
|
||||
|
||||
### Update API Endpoints (if needed)
|
||||
If your API endpoints need to be different, update in `/public/js/script.js`:
|
||||
|
||||
```javascript
|
||||
// Change from:
|
||||
const RUNWAY_API_ENDPOINT = '/backend/api.php';
|
||||
|
||||
// To (if using .htaccess routing):
|
||||
const RUNWAY_API_ENDPOINT = '/api/generate';
|
||||
```
|
||||
|
||||
### Update Status Check Endpoint
|
||||
```javascript
|
||||
// In pollJobStatus function, change:
|
||||
const response = await fetch(`/backend/check_status.php?job_id=${jobId}`);
|
||||
|
||||
// To:
|
||||
const response = await fetch(`/api/status?job_id=${jobId}`);
|
||||
```
|
||||
|
||||
## Step 6: Testing
|
||||
|
||||
### Test Checklist
|
||||
- [ ] Main page loads correctly
|
||||
- [ ] Dark/light mode toggle works
|
||||
- [ ] Image upload functions (drag & drop + click)
|
||||
- [ ] Form validation works
|
||||
- [ ] API endpoints respond correctly
|
||||
- [ ] Video generation process works
|
||||
- [ ] Status polling functions
|
||||
- [ ] Download functionality works
|
||||
- [ ] Responsive design on mobile devices
|
||||
|
||||
### Debug Common Issues
|
||||
|
||||
**API Key Issues:**
|
||||
```bash
|
||||
# Check if .env is loaded
|
||||
tail -f /var/log/apache2/error.log
|
||||
```
|
||||
|
||||
**Permission Issues:**
|
||||
```bash
|
||||
# Fix file permissions
|
||||
find /var/www/yourdomain.com -type f -exec chmod 644 {} \;
|
||||
find /var/www/yourdomain.com -type d -exec chmod 755 {} \;
|
||||
```
|
||||
|
||||
**CORS Issues:**
|
||||
Add to your backend config.php if needed:
|
||||
```php
|
||||
header("Access-Control-Allow-Origin: https://yourdomain.com");
|
||||
```
|
||||
|
||||
## Step 7: Security Best Practices
|
||||
|
||||
### 1. Environment Variables
|
||||
- Never commit .env files to version control
|
||||
- Use strong, unique API keys
|
||||
- Rotate API keys regularly
|
||||
|
||||
### 2. File Protection
|
||||
- Ensure .env and config files are not web-accessible
|
||||
- Keep logs outside the web root when possible
|
||||
- Use HTTPS for all API communications
|
||||
|
||||
### 3. Monitoring
|
||||
- Monitor error logs regularly
|
||||
- Set up log rotation for error logs
|
||||
- Monitor API usage and rate limits
|
||||
|
||||
### 4. Backup Strategy
|
||||
- Regular database backups (if applicable)
|
||||
- Backup configuration files
|
||||
- Version control for code changes
|
||||
|
||||
## Step 8: Performance Optimization
|
||||
|
||||
### 1. Enable Compression
|
||||
Add to .htaccess:
|
||||
```apache
|
||||
# Enable Gzip compression
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/plain
|
||||
AddOutputFilterByType DEFLATE text/html
|
||||
AddOutputFilterByType DEFLATE text/xml
|
||||
AddOutputFilterByType DEFLATE text/css
|
||||
AddOutputFilterByType DEFLATE application/xml
|
||||
AddOutputFilterByType DEFLATE application/xhtml+xml
|
||||
AddOutputFilterByType DEFLATE application/rss+xml
|
||||
AddOutputFilterByType DEFLATE application/javascript
|
||||
AddOutputFilterByType DEFLATE application/x-javascript
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
### 2. Browser Caching
|
||||
```apache
|
||||
# Leverage Browser Caching
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive on
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/jpg "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
</IfModule>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**"Runway API key not configured" Error:**
|
||||
- Check .env file exists and has correct permissions
|
||||
- Verify API key is valid and active
|
||||
- Check error logs for detailed information
|
||||
|
||||
**"Failed to generate video" Error:**
|
||||
- Verify Runway API key has sufficient credits
|
||||
- Check network connectivity to Runway API
|
||||
- Verify image format and size requirements
|
||||
|
||||
**CORS Errors:**
|
||||
- Update backend/config.php with correct domain
|
||||
- Ensure HTTPS is properly configured
|
||||
- Check browser console for specific CORS errors
|
||||
|
||||
**File Upload Issues:**
|
||||
- Check PHP upload limits in php.ini
|
||||
- Verify file permissions on upload directory
|
||||
- Ensure proper form encoding (multipart/form-data)
|
||||
|
||||
### Log Locations
|
||||
- **Apache Error Log**: `/var/log/apache2/error.log`
|
||||
- **Apache Access Log**: `/var/log/apache2/access.log`
|
||||
- **PHP Error Log**: As configured in php.ini
|
||||
|
||||
### Getting Help
|
||||
- Check Apache error logs first
|
||||
- Verify all file paths and permissions
|
||||
- Test API endpoints directly using curl
|
||||
- Enable PHP error display temporarily for debugging (remember to disable in production)
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] SSL certificate installed and configured
|
||||
- [ ] .env file created with production API keys
|
||||
- [ ] .htaccess file properly configured
|
||||
- [ ] File permissions set correctly
|
||||
- [ ] PHP error reporting configured for production
|
||||
- [ ] API endpoints tested and working
|
||||
- [ ] Security headers implemented
|
||||
- [ ] Backup strategy in place
|
||||
- [ ] Monitoring and logging configured
|
||||
- [ ] Performance optimizations applied
|
||||
176
README.md
Normal file
176
README.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Runway Gen4 Web App
|
||||
|
||||
A web application for generating videos using Runway's Gen4 API. Features a clean interface with image upload, prompt input, and real-time video generation with progress tracking.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎥 **Video Generation**: Convert images to videos using Runway Gen4 API
|
||||
- 🖼️ **Image Upload**: Drag & drop or click to upload images (auto-resizes and compresses)
|
||||
- 🎨 **Custom UI**: Orange-themed interface with dark/light mode toggle
|
||||
- 📱 **Responsive Design**: Works on desktop, tablet, and mobile devices
|
||||
- ⚡ **Real-time Progress**: Live progress updates during video generation
|
||||
- 🔄 **Async Processing**: Non-blocking video generation with status polling
|
||||
- 🎛️ **API Options**: Configurable duration, aspect ratio, and seed settings
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- PHP 7.4+ with cURL extension
|
||||
- Web server (Apache/Nginx) or MAMP/XAMPP
|
||||
- Runway API key from [RunwayML](https://runwayml.com)
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone git@bitbucket.org:zlalani/runway-video.git
|
||||
cd runway-video
|
||||
```
|
||||
|
||||
2. **Set up environment variables**
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
Edit `backend/.env` and add your Runway API key:
|
||||
```
|
||||
RUNWAY_API_KEY=your_runway_api_key_here
|
||||
```
|
||||
|
||||
3. **Configure your web server**
|
||||
|
||||
**Option A: MAMP/XAMPP**
|
||||
- Set document root to the project directory
|
||||
- Access via: `http://localhost:8888/public/` (or your MAMP port)
|
||||
|
||||
**Option B: Apache Virtual Host**
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
DocumentRoot "/path/to/runway-video"
|
||||
ServerName runway-video.local
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
**Option C: PHP Built-in Server**
|
||||
```bash
|
||||
cd runway-video
|
||||
php -S localhost:8000
|
||||
# Access via: http://localhost:8000/public/
|
||||
```
|
||||
|
||||
4. **Verify installation**
|
||||
- Navigate to `/public/backend/test_config.php` to verify configuration
|
||||
- Should return JSON with API key status
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Access the application** via your configured web server
|
||||
2. **Upload an image** by dragging and dropping or clicking the upload zone
|
||||
- Supports: PNG, JPEG, WebP
|
||||
- Images are automatically resized and compressed for optimal API usage
|
||||
3. **Enter a video prompt** describing the motion/animation you want
|
||||
4. **Configure options** (optional):
|
||||
- Video duration (5-10 seconds)
|
||||
- Aspect ratio (various formats supported)
|
||||
- Seed for reproducible results
|
||||
5. **Click "Generate Video"** and wait for processing
|
||||
6. **Download or generate another video** once completed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Button won't click / stays disabled:**
|
||||
- Ensure both image and prompt are provided
|
||||
- Check browser console for JavaScript errors
|
||||
- Verify backend files are accessible
|
||||
|
||||
**403 Forbidden errors:**
|
||||
- Check file permissions (755 for directories, 644 for files)
|
||||
- Ensure backend files are in accessible location
|
||||
- Verify web server configuration
|
||||
|
||||
**API errors:**
|
||||
- Verify Runway API key in `.env` file
|
||||
- Check API quota/billing status at RunwayML
|
||||
- Review error logs in browser console
|
||||
|
||||
**Slow generation:**
|
||||
- Large images are automatically compressed
|
||||
- Generation time depends on Runway's queue
|
||||
- Typical processing: 30 seconds to 2 minutes
|
||||
|
||||
### Debug Tools
|
||||
|
||||
- **Test Config**: Visit `/public/backend/test_config.php`
|
||||
- **Browser Console**: Check for JavaScript errors (F12)
|
||||
- **Network Tab**: Monitor API requests and responses
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Frontend**: HTML5, CSS3, JavaScript (ES6+)
|
||||
- **Styling**: Tailwind CSS (local build), Custom CSS
|
||||
- **Backend**: PHP 7.4+
|
||||
- **API**: Runway Gen4 Image-to-Video API
|
||||
- **Icons**: Font Awesome
|
||||
|
||||
## API Options
|
||||
|
||||
- **Duration**: 5-10 seconds
|
||||
- **Aspect Ratios**:
|
||||
- 1280:720 (Landscape 16:9)
|
||||
- 720:1280 (Portrait 9:16)
|
||||
- 1104:832 (Landscape 4:3)
|
||||
- 832:1104 (Portrait 3:4)
|
||||
- 960:960 (Square 1:1)
|
||||
- 1584:672 (Ultrawide)
|
||||
- **Seed**: Optional random seed for reproducible results
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
runway-video/
|
||||
├── backend/
|
||||
│ ├── api.php # Main API endpoint
|
||||
│ ├── check_status.php # Status polling endpoint
|
||||
│ └── config.php # Configuration & CORS
|
||||
├── public/
|
||||
│ ├── css/
|
||||
│ │ ├── style.css # Custom styles & dark mode
|
||||
│ │ └── tailwind-build.css # Tailwind CSS (production)
|
||||
│ ├── js/
|
||||
│ │ └── script.js # Main application logic
|
||||
│ └── index.html # Main HTML file
|
||||
├── src/
|
||||
│ └── input.css # Tailwind source file
|
||||
├── .env # Environment variables (not included)
|
||||
├── .htaccess # Apache configuration
|
||||
├── package.json # Node.js dependencies
|
||||
├── tailwind.config.js # Tailwind configuration
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
- Input validation and sanitization
|
||||
- CSRF protection via proper headers
|
||||
- File type validation for uploads
|
||||
- Environment variable protection
|
||||
- Error logging without sensitive data exposure
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
|
||||
## Support
|
||||
|
||||
For issues related to:
|
||||
- **Runway API**: Check [Runway Documentation](https://docs.dev.runwayml.com/api)
|
||||
- **Application bugs**: Create an issue in this repository
|
||||
3
backend/.env.example
Normal file
3
backend/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Rename this file to .env and fill in your actual API Keys
|
||||
RUNWAY_API_KEY=your_runway_api_key_here
|
||||
GEMINI_API_KEY= # Only needed for local simulation, can be empty in Canvas environment.
|
||||
13
backend/.htaccess
Normal file
13
backend/.htaccess
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
Order allow,deny
|
||||
Allow from all
|
||||
|
||||
# Allow PHP files to be executed
|
||||
<Files "*.php">
|
||||
Allow from all
|
||||
</Files>
|
||||
|
||||
# Set proper content type for PHP files
|
||||
AddType application/x-httpd-php .php
|
||||
|
||||
# Enable script execution
|
||||
Options +ExecCGI
|
||||
158
backend/api.php
Normal file
158
backend/api.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
// backend/api.php
|
||||
|
||||
require_once __DIR__ . '/config.php'; // Load environment variables and set CORS headers
|
||||
|
||||
/**
|
||||
* Handles error responses for the API.
|
||||
* @param string $message The error message.
|
||||
* @param int $statusCode The HTTP status code (default 500).
|
||||
*/
|
||||
function sendErrorResponse($message, $statusCode = 500) {
|
||||
http_response_code($statusCode);
|
||||
echo json_encode(['status' => 'error', 'message' => $message]);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Ensure the request method is POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendErrorResponse('Method Not Allowed', 405);
|
||||
}
|
||||
|
||||
// Check for Authorization header
|
||||
$headers = getallheaders();
|
||||
$authHeader = $headers['Authorization'] ?? '';
|
||||
|
||||
if (!preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) {
|
||||
sendErrorResponse('Authentication required', 401);
|
||||
}
|
||||
|
||||
$accessToken = $matches[1];
|
||||
|
||||
// Validate the token (basic validation - check if it looks like a JWT)
|
||||
if (empty($accessToken) || substr_count($accessToken, '.') !== 2) {
|
||||
sendErrorResponse('Invalid authentication token', 401);
|
||||
}
|
||||
|
||||
// Get the raw POST data
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
|
||||
// --- Input Validation ---
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
sendErrorResponse('Invalid JSON payload.', 400);
|
||||
}
|
||||
|
||||
$imageBase64 = $data['image_base64'] ?? null;
|
||||
$prompt = $data['prompt'] ?? null;
|
||||
$apiOptions = $data['api_options'] ?? [];
|
||||
|
||||
if (empty($imageBase64) || !is_string($imageBase64) || !str_starts_with($imageBase64, 'data:image/')) {
|
||||
sendErrorResponse('Invalid or missing image_base64. Must be a base64 encoded image data URL.', 400);
|
||||
}
|
||||
if (empty($prompt) || !is_string($prompt) || trim($prompt) === '') {
|
||||
sendErrorResponse('Prompt cannot be empty.', 400);
|
||||
}
|
||||
if (!is_array($apiOptions)) {
|
||||
sendErrorResponse('Invalid api_options. Must be an object/array.', 400);
|
||||
}
|
||||
|
||||
error_log("Received generation request: " . json_encode([
|
||||
'prompt' => $prompt,
|
||||
'image_size' => round(strlen($imageBase64) / 1024) . ' KB',
|
||||
'api_options' => $apiOptions
|
||||
]));
|
||||
|
||||
// --- ACTUAL RUNWAY API INTEGRATION ---
|
||||
try {
|
||||
// --- CONFIGURE THIS SECTION WITH YOUR ACTUAL RUNWAY GEN4 API DETAILS ---
|
||||
// Refer to RunwayML's official Gen4 API documentation for the correct model ID and parameters.
|
||||
|
||||
// Endpoint for Image to Video as per documentation:
|
||||
$runwayApiEndpoint = 'https://api.dev.runwayml.com/v1/image_to_video';
|
||||
|
||||
// Validate image size (base64 data URI should be under 5MB)
|
||||
$imageSizeBytes = strlen($imageBase64);
|
||||
if ($imageSizeBytes > 5 * 1024 * 1024) {
|
||||
throw new Exception('Image too large. Base64 data URI must be under 5MB.');
|
||||
}
|
||||
|
||||
// Validate the data URI format
|
||||
if (!preg_match('/^data:image\/(jpeg|jpg|png|webp);base64,/', $imageBase64)) {
|
||||
throw new Exception('Invalid image format. Must be JPEG, PNG, or WebP.');
|
||||
}
|
||||
|
||||
$runwayPayload = [
|
||||
'promptImage' => $imageBase64, // Send full data URL format
|
||||
'promptText' => $prompt,
|
||||
'model' => 'gen4_turbo',
|
||||
'ratio' => $apiOptions['ratio'] ?? '1280:720' // Required parameter
|
||||
];
|
||||
|
||||
// Add optional parameters if provided
|
||||
if (isset($apiOptions['duration']) && $apiOptions['duration'] !== null) {
|
||||
$runwayPayload['duration'] = (int)$apiOptions['duration'];
|
||||
}
|
||||
if (isset($apiOptions['seed']) && $apiOptions['seed'] !== null) {
|
||||
$runwayPayload['seed'] = (int)$apiOptions['seed'];
|
||||
}
|
||||
|
||||
// Log payload without the full image data to avoid huge logs
|
||||
$logPayload = $runwayPayload;
|
||||
$logPayload['promptImage'] = substr($imageBase64, 0, 50) . '... (' . strlen($imageBase64) . ' chars)';
|
||||
error_log("Sending to Runway API: " . json_encode($logPayload));
|
||||
|
||||
$ch = curl_init($runwayApiEndpoint);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Return response as string
|
||||
curl_setopt($ch, CURLOPT_POST, true); // Set as POST request
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($runwayPayload)); // Encode payload to JSON
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . RUNWAY_API_KEY, // Use your RUNWAY_API_KEY from .env
|
||||
'X-Runway-Version: 2024-11-06' // Required API version header
|
||||
]);
|
||||
// Optional: for debugging cURL
|
||||
// curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||
// $verbose = fopen('php://temp', 'rw+');
|
||||
// curl_setopt($ch, CURLOPT_STDERR, $verbose);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new Exception("cURL error connecting to Runway API: " . $curlError);
|
||||
}
|
||||
|
||||
$runwayResult = json_decode($response, true);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
// Attempt to extract more specific error message from Runway API response
|
||||
$errorMessage = 'Unknown Runway API error';
|
||||
if (isset($runwayResult['error']['message'])) {
|
||||
$errorMessage = $runwayResult['error']['message'];
|
||||
} elseif (isset($runwayResult['message'])) {
|
||||
$errorMessage = $runwayResult['message'];
|
||||
}
|
||||
throw new Exception("Runway API returned error ($httpCode): " . $errorMessage . ". Full response: " . json_encode($runwayResult));
|
||||
}
|
||||
|
||||
// --- Handle Runway's async response ---
|
||||
$jobId = $runwayResult['id'] ?? null;
|
||||
|
||||
if (empty($jobId)) {
|
||||
throw new Exception('Runway API did not return a job ID. Full response: ' . json_encode($runwayResult));
|
||||
}
|
||||
|
||||
// For async generation, return job ID for polling
|
||||
echo json_encode([
|
||||
'status' => 'processing',
|
||||
'job_id' => $jobId,
|
||||
'message' => 'Video generation started. Please wait...'
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Backend processing error: " . $e->getMessage());
|
||||
sendErrorResponse("Failed to generate video: " . $e->getMessage(), 500);
|
||||
}
|
||||
150
backend/check_status.php
Normal file
150
backend/check_status.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
// backend/check_status.php
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
/**
|
||||
* Handles error responses for the API.
|
||||
* @param string $message The error message.
|
||||
* @param int $statusCode The HTTP status code (default 500).
|
||||
*/
|
||||
function sendErrorResponse($message, $statusCode = 500) {
|
||||
http_response_code($statusCode);
|
||||
echo json_encode(['status' => 'error', 'message' => $message]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from array using dot notation
|
||||
* @param array $array The array to search
|
||||
* @param string $path Dot-separated path like 'output.0.url'
|
||||
* @return mixed|null The value or null if not found
|
||||
*/
|
||||
function getNestedValue($array, $path) {
|
||||
$keys = explode('.', $path);
|
||||
$current = $array;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (is_array($current) && isset($current[$key])) {
|
||||
$current = $current[$key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
// Ensure the request method is GET
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
sendErrorResponse('Method Not Allowed', 405);
|
||||
}
|
||||
|
||||
$jobId = $_GET['job_id'] ?? null;
|
||||
|
||||
if (empty($jobId)) {
|
||||
sendErrorResponse('Job ID is required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if API key is available
|
||||
if (!defined('RUNWAY_API_KEY') || empty(RUNWAY_API_KEY)) {
|
||||
throw new Exception('Runway API key not configured. Please check your .env file.');
|
||||
}
|
||||
|
||||
// Validate job ID format (should be a UUID)
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $jobId)) {
|
||||
throw new Exception('Invalid job ID format');
|
||||
}
|
||||
|
||||
error_log("Checking status for job ID: " . $jobId);
|
||||
|
||||
// Check job status with Runway API
|
||||
$runwayStatusEndpoint = "https://api.dev.runwayml.com/v1/tasks/{$jobId}";
|
||||
|
||||
$ch = curl_init($runwayStatusEndpoint);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . RUNWAY_API_KEY,
|
||||
'X-Runway-Version: 2024-11-06'
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new Exception("cURL error checking job status: " . $curlError);
|
||||
}
|
||||
|
||||
error_log("Status check response HTTP code: " . $httpCode);
|
||||
error_log("Status check response body: " . $response);
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$errorMessage = 'Unknown error checking job status';
|
||||
if (isset($result['error']['message'])) {
|
||||
$errorMessage = $result['error']['message'];
|
||||
} elseif (isset($result['message'])) {
|
||||
$errorMessage = $result['message'];
|
||||
}
|
||||
throw new Exception("Error checking job status ($httpCode): " . $errorMessage . ". Response: " . $response);
|
||||
}
|
||||
|
||||
// Parse the task status response
|
||||
$status = $result['status'] ?? 'unknown';
|
||||
$videoUrl = null;
|
||||
|
||||
// Check if task is completed and has output
|
||||
if ($status === 'SUCCEEDED' || $status === 'completed') {
|
||||
// Look for video URL in various possible locations based on Runway API docs
|
||||
// Handle the actual Runway response format from logs
|
||||
if (isset($result['output']) && is_array($result['output']) && !empty($result['output'])) {
|
||||
// Runway returns output as array of URLs, take the first one
|
||||
$videoUrl = $result['output'][0];
|
||||
}
|
||||
|
||||
// Fallback to other possible locations if not found
|
||||
if (!$videoUrl) {
|
||||
$possiblePaths = [
|
||||
'output.url',
|
||||
'artifacts.0.url',
|
||||
'artifacts.url',
|
||||
'video.url',
|
||||
'video_url',
|
||||
'url',
|
||||
'result.url',
|
||||
'result.video_url'
|
||||
];
|
||||
|
||||
foreach ($possiblePaths as $path) {
|
||||
$videoUrl = getNestedValue($result, $path);
|
||||
if ($videoUrl) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log what we found for debugging
|
||||
error_log("Task completed with status: $status");
|
||||
error_log("Video URL found: " . ($videoUrl ?: 'NONE'));
|
||||
error_log("Full response keys: " . implode(', ', array_keys($result)));
|
||||
}
|
||||
|
||||
// Return status to frontend
|
||||
echo json_encode([
|
||||
'job_id' => $jobId,
|
||||
'status' => $status,
|
||||
'video_url' => $videoUrl,
|
||||
'progress' => $result['progress'] ?? null,
|
||||
'message' => $result['message'] ?? null,
|
||||
'raw_response' => $result // For debugging
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Status check error: " . $e->getMessage());
|
||||
sendErrorResponse("Failed to check status: " . $e->getMessage(), 500);
|
||||
}
|
||||
53
backend/config.php
Normal file
53
backend/config.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
// backend/config.php
|
||||
|
||||
// Simple function to load environment variables from a .env file.
|
||||
// In a production environment, you might use a more robust solution like 'dotenv' library
|
||||
// or server-level environment variables (e.g., in Apache/Nginx config, Docker, Kubernetes).
|
||||
function loadEnv($path = __DIR__ . '/.env') {
|
||||
if (!file_exists($path)) {
|
||||
throw new Exception("'.env' file not found at $path");
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) { // Skip comments
|
||||
continue;
|
||||
}
|
||||
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
|
||||
putenv(sprintf('%s=%s', $name, $value));
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load environment variables when config.php is included
|
||||
loadEnv();
|
||||
|
||||
// Set CORS headers
|
||||
// In production, replace '*' with your actual frontend domain(s)
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: POST, GET, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization");
|
||||
header("Content-Type: application/json"); // Default to JSON response
|
||||
|
||||
// Handle OPTIONS request for CORS preflight
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Basic error reporting for development. Disable in production.
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Define API Keys from environment variables
|
||||
define('RUNWAY_API_KEY', getenv('RUNWAY_API_KEY'));
|
||||
define('GEMINI_API_KEY', getenv('GEMINI_API_KEY')); // For simulation
|
||||
15
backend/config_client.php
Normal file
15
backend/config_client.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
// backend/config_client.php
|
||||
require_once 'config.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Return only the client-safe configuration
|
||||
$config = [
|
||||
'azure_client_id' => getenv('AZURE_CLIENT_ID'),
|
||||
'azure_tenant_id' => getenv('AZURE_TENANT_ID'),
|
||||
'redirect_uri' => 'http://localhost:3000/' // MAMP server redirect
|
||||
];
|
||||
|
||||
echo json_encode($config);
|
||||
?>
|
||||
12
backend/test_config.php
Normal file
12
backend/test_config.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
// backend/test_config.php - Debug endpoint to check configuration
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'ok',
|
||||
'runway_api_key_defined' => defined('RUNWAY_API_KEY'),
|
||||
'runway_api_key_empty' => empty(RUNWAY_API_KEY),
|
||||
'runway_api_key_length' => defined('RUNWAY_API_KEY') ? strlen(RUNWAY_API_KEY) : 0,
|
||||
'env_file_exists' => file_exists(__DIR__ . '/.env')
|
||||
]);
|
||||
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
23
package-lock.json
generated
Normal file
23
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "runway-video",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "runway-video",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz",
|
||||
"integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
package.json
Normal file
18
package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "runway-video",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build-css": "tailwindcss -i ./src/input.css -o ./public/css/tailwind.css --watch",
|
||||
"build": "tailwindcss -i ./src/input.css -o ./public/css/tailwind.css",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^4.1.10"
|
||||
}
|
||||
}
|
||||
3
public/backend/.env.example
Normal file
3
public/backend/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Rename this file to .env and fill in your actual API Keys
|
||||
RUNWAY_API_KEY=your_runway_api_key_here
|
||||
GEMINI_API_KEY= # Only needed for local simulation, can be empty in Canvas environment.
|
||||
13
public/backend/.htaccess
Normal file
13
public/backend/.htaccess
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
Order allow,deny
|
||||
Allow from all
|
||||
|
||||
# Allow PHP files to be executed
|
||||
<Files "*.php">
|
||||
Allow from all
|
||||
</Files>
|
||||
|
||||
# Set proper content type for PHP files
|
||||
AddType application/x-httpd-php .php
|
||||
|
||||
# Enable script execution
|
||||
Options +ExecCGI
|
||||
143
public/backend/api.php
Normal file
143
public/backend/api.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
// backend/api.php
|
||||
|
||||
require_once __DIR__ . '/config.php'; // Load environment variables and set CORS headers
|
||||
|
||||
/**
|
||||
* Handles error responses for the API.
|
||||
* @param string $message The error message.
|
||||
* @param int $statusCode The HTTP status code (default 500).
|
||||
*/
|
||||
function sendErrorResponse($message, $statusCode = 500) {
|
||||
http_response_code($statusCode);
|
||||
echo json_encode(['status' => 'error', 'message' => $message]);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Ensure the request method is POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
sendErrorResponse('Method Not Allowed', 405);
|
||||
}
|
||||
|
||||
// Get the raw POST data
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
|
||||
// --- Input Validation ---
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
sendErrorResponse('Invalid JSON payload.', 400);
|
||||
}
|
||||
|
||||
$imageBase64 = $data['image_base64'] ?? null;
|
||||
$prompt = $data['prompt'] ?? null;
|
||||
$apiOptions = $data['api_options'] ?? [];
|
||||
|
||||
if (empty($imageBase64) || !is_string($imageBase64) || !str_starts_with($imageBase64, 'data:image/')) {
|
||||
sendErrorResponse('Invalid or missing image_base64. Must be a base64 encoded image data URL.', 400);
|
||||
}
|
||||
if (empty($prompt) || !is_string($prompt) || trim($prompt) === '') {
|
||||
sendErrorResponse('Prompt cannot be empty.', 400);
|
||||
}
|
||||
if (!is_array($apiOptions)) {
|
||||
sendErrorResponse('Invalid api_options. Must be an object/array.', 400);
|
||||
}
|
||||
|
||||
error_log("Received generation request: " . json_encode([
|
||||
'prompt' => $prompt,
|
||||
'image_size' => round(strlen($imageBase64) / 1024) . ' KB',
|
||||
'api_options' => $apiOptions
|
||||
]));
|
||||
|
||||
// --- ACTUAL RUNWAY API INTEGRATION ---
|
||||
try {
|
||||
// --- CONFIGURE THIS SECTION WITH YOUR ACTUAL RUNWAY GEN4 API DETAILS ---
|
||||
// Refer to RunwayML's official Gen4 API documentation for the correct model ID and parameters.
|
||||
|
||||
// Endpoint for Image to Video as per documentation:
|
||||
$runwayApiEndpoint = 'https://api.dev.runwayml.com/v1/image_to_video';
|
||||
|
||||
// Validate image size (base64 data URI should be under 5MB)
|
||||
$imageSizeBytes = strlen($imageBase64);
|
||||
if ($imageSizeBytes > 5 * 1024 * 1024) {
|
||||
throw new Exception('Image too large. Base64 data URI must be under 5MB.');
|
||||
}
|
||||
|
||||
// Validate the data URI format
|
||||
if (!preg_match('/^data:image\/(jpeg|jpg|png|webp);base64,/', $imageBase64)) {
|
||||
throw new Exception('Invalid image format. Must be JPEG, PNG, or WebP.');
|
||||
}
|
||||
|
||||
$runwayPayload = [
|
||||
'promptImage' => $imageBase64, // Send full data URL format
|
||||
'promptText' => $prompt,
|
||||
'model' => 'gen4_turbo',
|
||||
'ratio' => $apiOptions['ratio'] ?? '1280:720' // Required parameter
|
||||
];
|
||||
|
||||
// Add optional parameters if provided
|
||||
if (isset($apiOptions['duration']) && $apiOptions['duration'] !== null) {
|
||||
$runwayPayload['duration'] = (int)$apiOptions['duration'];
|
||||
}
|
||||
if (isset($apiOptions['seed']) && $apiOptions['seed'] !== null) {
|
||||
$runwayPayload['seed'] = (int)$apiOptions['seed'];
|
||||
}
|
||||
|
||||
// Log payload without the full image data to avoid huge logs
|
||||
$logPayload = $runwayPayload;
|
||||
$logPayload['promptImage'] = substr($imageBase64, 0, 50) . '... (' . strlen($imageBase64) . ' chars)';
|
||||
error_log("Sending to Runway API: " . json_encode($logPayload));
|
||||
|
||||
$ch = curl_init($runwayApiEndpoint);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Return response as string
|
||||
curl_setopt($ch, CURLOPT_POST, true); // Set as POST request
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($runwayPayload)); // Encode payload to JSON
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . RUNWAY_API_KEY, // Use your RUNWAY_API_KEY from .env
|
||||
'X-Runway-Version: 2024-11-06' // Required API version header
|
||||
]);
|
||||
// Optional: for debugging cURL
|
||||
// curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||
// $verbose = fopen('php://temp', 'rw+');
|
||||
// curl_setopt($ch, CURLOPT_STDERR, $verbose);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new Exception("cURL error connecting to Runway API: " . $curlError);
|
||||
}
|
||||
|
||||
$runwayResult = json_decode($response, true);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
// Attempt to extract more specific error message from Runway API response
|
||||
$errorMessage = 'Unknown Runway API error';
|
||||
if (isset($runwayResult['error']['message'])) {
|
||||
$errorMessage = $runwayResult['error']['message'];
|
||||
} elseif (isset($runwayResult['message'])) {
|
||||
$errorMessage = $runwayResult['message'];
|
||||
}
|
||||
throw new Exception("Runway API returned error ($httpCode): " . $errorMessage . ". Full response: " . json_encode($runwayResult));
|
||||
}
|
||||
|
||||
// --- Handle Runway's async response ---
|
||||
$jobId = $runwayResult['id'] ?? null;
|
||||
|
||||
if (empty($jobId)) {
|
||||
throw new Exception('Runway API did not return a job ID. Full response: ' . json_encode($runwayResult));
|
||||
}
|
||||
|
||||
// For async generation, return job ID for polling
|
||||
echo json_encode([
|
||||
'status' => 'processing',
|
||||
'job_id' => $jobId,
|
||||
'message' => 'Video generation started. Please wait...'
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Backend processing error: " . $e->getMessage());
|
||||
sendErrorResponse("Failed to generate video: " . $e->getMessage(), 500);
|
||||
}
|
||||
150
public/backend/check_status.php
Normal file
150
public/backend/check_status.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
// backend/check_status.php
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
/**
|
||||
* Handles error responses for the API.
|
||||
* @param string $message The error message.
|
||||
* @param int $statusCode The HTTP status code (default 500).
|
||||
*/
|
||||
function sendErrorResponse($message, $statusCode = 500) {
|
||||
http_response_code($statusCode);
|
||||
echo json_encode(['status' => 'error', 'message' => $message]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from array using dot notation
|
||||
* @param array $array The array to search
|
||||
* @param string $path Dot-separated path like 'output.0.url'
|
||||
* @return mixed|null The value or null if not found
|
||||
*/
|
||||
function getNestedValue($array, $path) {
|
||||
$keys = explode('.', $path);
|
||||
$current = $array;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (is_array($current) && isset($current[$key])) {
|
||||
$current = $current[$key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
// Ensure the request method is GET
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
sendErrorResponse('Method Not Allowed', 405);
|
||||
}
|
||||
|
||||
$jobId = $_GET['job_id'] ?? null;
|
||||
|
||||
if (empty($jobId)) {
|
||||
sendErrorResponse('Job ID is required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if API key is available
|
||||
if (!defined('RUNWAY_API_KEY') || empty(RUNWAY_API_KEY)) {
|
||||
throw new Exception('Runway API key not configured. Please check your .env file.');
|
||||
}
|
||||
|
||||
// Validate job ID format (should be a UUID)
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $jobId)) {
|
||||
throw new Exception('Invalid job ID format');
|
||||
}
|
||||
|
||||
error_log("Checking status for job ID: " . $jobId);
|
||||
|
||||
// Check job status with Runway API
|
||||
$runwayStatusEndpoint = "https://api.dev.runwayml.com/v1/tasks/{$jobId}";
|
||||
|
||||
$ch = curl_init($runwayStatusEndpoint);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . RUNWAY_API_KEY,
|
||||
'X-Runway-Version: 2024-11-06'
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new Exception("cURL error checking job status: " . $curlError);
|
||||
}
|
||||
|
||||
error_log("Status check response HTTP code: " . $httpCode);
|
||||
error_log("Status check response body: " . $response);
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$errorMessage = 'Unknown error checking job status';
|
||||
if (isset($result['error']['message'])) {
|
||||
$errorMessage = $result['error']['message'];
|
||||
} elseif (isset($result['message'])) {
|
||||
$errorMessage = $result['message'];
|
||||
}
|
||||
throw new Exception("Error checking job status ($httpCode): " . $errorMessage . ". Response: " . $response);
|
||||
}
|
||||
|
||||
// Parse the task status response
|
||||
$status = $result['status'] ?? 'unknown';
|
||||
$videoUrl = null;
|
||||
|
||||
// Check if task is completed and has output
|
||||
if ($status === 'SUCCEEDED' || $status === 'completed') {
|
||||
// Look for video URL in various possible locations based on Runway API docs
|
||||
// Handle the actual Runway response format from logs
|
||||
if (isset($result['output']) && is_array($result['output']) && !empty($result['output'])) {
|
||||
// Runway returns output as array of URLs, take the first one
|
||||
$videoUrl = $result['output'][0];
|
||||
}
|
||||
|
||||
// Fallback to other possible locations if not found
|
||||
if (!$videoUrl) {
|
||||
$possiblePaths = [
|
||||
'output.url',
|
||||
'artifacts.0.url',
|
||||
'artifacts.url',
|
||||
'video.url',
|
||||
'video_url',
|
||||
'url',
|
||||
'result.url',
|
||||
'result.video_url'
|
||||
];
|
||||
|
||||
foreach ($possiblePaths as $path) {
|
||||
$videoUrl = getNestedValue($result, $path);
|
||||
if ($videoUrl) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log what we found for debugging
|
||||
error_log("Task completed with status: $status");
|
||||
error_log("Video URL found: " . ($videoUrl ?: 'NONE'));
|
||||
error_log("Full response keys: " . implode(', ', array_keys($result)));
|
||||
}
|
||||
|
||||
// Return status to frontend
|
||||
echo json_encode([
|
||||
'job_id' => $jobId,
|
||||
'status' => $status,
|
||||
'video_url' => $videoUrl,
|
||||
'progress' => $result['progress'] ?? null,
|
||||
'message' => $result['message'] ?? null,
|
||||
'raw_response' => $result // For debugging
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log("Status check error: " . $e->getMessage());
|
||||
sendErrorResponse("Failed to check status: " . $e->getMessage(), 500);
|
||||
}
|
||||
53
public/backend/config.php
Normal file
53
public/backend/config.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
// backend/config.php
|
||||
|
||||
// Simple function to load environment variables from a .env file.
|
||||
// In a production environment, you might use a more robust solution like 'dotenv' library
|
||||
// or server-level environment variables (e.g., in Apache/Nginx config, Docker, Kubernetes).
|
||||
function loadEnv($path = __DIR__ . '/.env') {
|
||||
if (!file_exists($path)) {
|
||||
throw new Exception("'.env' file not found at $path");
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) { // Skip comments
|
||||
continue;
|
||||
}
|
||||
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
|
||||
putenv(sprintf('%s=%s', $name, $value));
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load environment variables when config.php is included
|
||||
loadEnv();
|
||||
|
||||
// Set CORS headers
|
||||
// In production, replace '*' with your actual frontend domain(s)
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: POST, GET, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization");
|
||||
header("Content-Type: application/json"); // Default to JSON response
|
||||
|
||||
// Handle OPTIONS request for CORS preflight
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Basic error reporting for development. Disable in production.
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Define API Keys from environment variables
|
||||
define('RUNWAY_API_KEY', getenv('RUNWAY_API_KEY'));
|
||||
define('GEMINI_API_KEY', getenv('GEMINI_API_KEY')); // For simulation
|
||||
47
public/backend/config_client.php
Normal file
47
public/backend/config_client.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
|
||||
// Handle preflight requests
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Load environment variables from .env file
|
||||
function loadEnv($path) {
|
||||
if (!file_exists($path)) {
|
||||
throw new Exception('.env file not found');
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$env = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '#') === 0) continue; // Skip comments
|
||||
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$env[trim($key)] = trim($value);
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
try {
|
||||
$env = loadEnv(__DIR__ . '/.env');
|
||||
|
||||
// Return client-side configuration (no secrets)
|
||||
$config = [
|
||||
'azure_client_id' => $env['AZURE_CLIENT_ID'] ?? '',
|
||||
'azure_tenant_id' => $env['AZURE_TENANT_ID'] ?? '',
|
||||
'redirect_uri' => 'http://localhost:3000/runway-video/public/'
|
||||
];
|
||||
|
||||
echo json_encode($config);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Configuration error: ' . $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
12
public/backend/test_config.php
Normal file
12
public/backend/test_config.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
// backend/test_config.php - Debug endpoint to check configuration
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'ok',
|
||||
'runway_api_key_defined' => defined('RUNWAY_API_KEY'),
|
||||
'runway_api_key_empty' => empty(RUNWAY_API_KEY),
|
||||
'runway_api_key_length' => defined('RUNWAY_API_KEY') ? strlen(RUNWAY_API_KEY) : 0,
|
||||
'env_file_exists' => file_exists(__DIR__ . '/.env')
|
||||
]);
|
||||
390
public/css/style.css
Normal file
390
public/css/style.css
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
/* public/css/style.css */
|
||||
|
||||
/* Import Montserrat font from Google Fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* CSS Variables for theme colors */
|
||||
:root {
|
||||
--primary-btn-color: #fc9729;
|
||||
--primary-btn-hover-color: #df8925;
|
||||
--primary-text-color: #fc9729;
|
||||
}
|
||||
|
||||
/* Apply Montserrat to the body and ensure Tailwind's base is applied */
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Custom styles for the range slider thumb (cannot be purely inline Tailwind) */
|
||||
/* Shared across browsers for consistency */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; /* Safari, Chrome */
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 8px; /* Track height */
|
||||
background: #E5E7EB; /* Track color */
|
||||
border-radius: 9999px; /* Rounded track */
|
||||
outline: none; /* Remove default focus outline */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Thumb for Webkit (Chrome, Safari) */
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 9999px; /* Fully rounded thumb */
|
||||
background: var(--primary-btn-color); /* Thumb color (orange-600) */
|
||||
border: 2px solid white; /* White border */
|
||||
cursor: grab;
|
||||
margin-top: -4px; /* Center thumb vertically on track */
|
||||
box-shadow: 0 0 0 2px rgba(252, 151, 41, 0.3); /* Subtle focus ring */
|
||||
transition: background 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Thumb for Firefox */
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
background: var(--primary-btn-color);
|
||||
border: 2px solid white;
|
||||
cursor: grab;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.3);
|
||||
transition: background 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Hover state for thumb */
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: var(--primary-btn-hover-color); /* indigo-700 */
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb:hover {
|
||||
background: var(--primary-btn-hover-color);
|
||||
}
|
||||
|
||||
/* Focus state for thumb */
|
||||
input[type="range"]::-webkit-slider-thumb:focus {
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.5);
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb:focus {
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.5);
|
||||
}
|
||||
|
||||
/* Track for Webkit */
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: #E5E7EB;
|
||||
border-radius: 9999px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Track for Firefox */
|
||||
input[type="range"]::-moz-range-track {
|
||||
background: #E5E7EB;
|
||||
border-radius: 9999px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Hide default checkbox */
|
||||
.peer {
|
||||
display: none;
|
||||
}
|
||||
/* Style for the custom toggle switch div */
|
||||
.peer + div {
|
||||
position: relative;
|
||||
width: 44px; /* w-11 */
|
||||
height: 24px; /* h-6 */
|
||||
background-color: #E5E7EB; /* gray-200 */
|
||||
border-radius: 9999px; /* rounded-full */
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
/* Style for the toggle switch circle (after pseudo-element) */
|
||||
.peer + div::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px; /* h-5, w-5 */
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #D1D5DB; /* gray-300 */
|
||||
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
}
|
||||
/* Styles when checkbox is checked */
|
||||
.peer:checked + div {
|
||||
background-color: var(--primary-btn-color); /* indigo-600 */
|
||||
}
|
||||
.peer:checked + div::after {
|
||||
transform: translateX(20px); /* Move 20px to the right */
|
||||
border-color: white;
|
||||
}
|
||||
/* Focus ring for accessibility */
|
||||
.peer:focus + div {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.4); /* orange-300 with transparency */
|
||||
}
|
||||
|
||||
/* Dark Mode Toggle Button Styling */
|
||||
.dark-mode-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-btn-color);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
color: #000;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.dark-mode .dark-mode-toggle {
|
||||
color: #fff;
|
||||
background-color: var(--primary-btn-color);
|
||||
}
|
||||
|
||||
/* Dark Mode Styles */
|
||||
.dark-mode {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dark-mode body,
|
||||
body.dark-mode {
|
||||
background-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
/* Force override any remaining gray backgrounds */
|
||||
body.dark-mode.bg-gray-100 {
|
||||
background-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.dark-mode header {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.dark-mode main {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.dark-mode .bg-gray-50 {
|
||||
background-color: #2a2a2a !important;
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
.dark-mode .bg-gray-100,
|
||||
.dark-mode.bg-gray-100 {
|
||||
background-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.dark-mode .text-gray-700 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark-mode .text-gray-900 {
|
||||
color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
.dark-mode .border-gray-300 {
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
.dark-mode .border-gray-400 {
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
.dark-mode .bg-white {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
.dark-mode .bg-gray-200 {
|
||||
background-color: #404040 !important;
|
||||
}
|
||||
|
||||
.dark-mode input[type="text"],
|
||||
.dark-mode textarea,
|
||||
.dark-mode select {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #f5f5f5 !important;
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
.dark-mode input[type="text"]:focus,
|
||||
.dark-mode textarea:focus,
|
||||
.dark-mode select:focus {
|
||||
border-color: var(--primary-btn-color) !important;
|
||||
box-shadow: 0 0 0 3px rgba(252, 151, 41, 0.3) !important;
|
||||
}
|
||||
|
||||
.dark-mode .text-orange-600,
|
||||
.dark-mode .text-orange-700 {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
.dark-mode input[type="range"] {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.dark-mode input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.dark-mode input[type="range"]::-moz-range-track {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.dark-mode .border-gray-200 {
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
/* Fix button colors in dark mode */
|
||||
.dark-mode .bg-orange-600 {
|
||||
background-color: var(--primary-btn-color) !important;
|
||||
}
|
||||
|
||||
.dark-mode .hover\:bg-orange-700:hover {
|
||||
background-color: var(--primary-btn-hover-color) !important;
|
||||
}
|
||||
|
||||
/* Override Tailwind orange colors with our custom colors */
|
||||
.text-orange-600,
|
||||
.text-orange-700,
|
||||
.text-orange-800 {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
.bg-orange-600 {
|
||||
background-color: var(--primary-btn-color) !important;
|
||||
}
|
||||
|
||||
.hover\:bg-orange-700:hover {
|
||||
background-color: var(--primary-btn-hover-color) !important;
|
||||
}
|
||||
|
||||
.border-orange-500 {
|
||||
border-color: var(--primary-btn-color) !important;
|
||||
}
|
||||
|
||||
.bg-orange-50 {
|
||||
background-color: rgba(252, 151, 41, 0.1) !important;
|
||||
}
|
||||
|
||||
.focus\:ring-orange-500:focus {
|
||||
--tw-ring-color: rgba(252, 151, 41, 0.3) !important;
|
||||
}
|
||||
|
||||
.focus\:border-orange-500:focus {
|
||||
border-color: var(--primary-btn-color) !important;
|
||||
}
|
||||
|
||||
/* Fix text colors that should stay orange in dark mode */
|
||||
.dark-mode .text-orange-600,
|
||||
.dark-mode .text-orange-700,
|
||||
.dark-mode .text-orange-800 {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
/* More comprehensive dark mode overrides */
|
||||
.dark-mode .bg-white,
|
||||
.dark-mode details {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
.dark-mode .border-gray-200,
|
||||
.dark-mode .border-gray-300 {
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
.dark-mode .text-gray-500,
|
||||
.dark-mode .text-gray-600 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark-mode summary {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
.dark-mode .shadow-sm,
|
||||
.dark-mode .shadow-md {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Fix image upload zone in dark mode */
|
||||
.dark-mode #image-upload-zone {
|
||||
background-color: #2a2a2a !important;
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
.dark-mode #image-upload-zone .text-gray-500 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
/* Force button styling with maximum specificity */
|
||||
button#logout-btn {
|
||||
background-color: #dc2626 !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
button#logout-btn:hover {
|
||||
background-color: #b91c1c !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
button#login-btn {
|
||||
background-color: #2563eb !important;
|
||||
color: #ffffff !important;
|
||||
border: none !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
button#login-btn:hover {
|
||||
background-color: #1d4ed8 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Dark mode variants */
|
||||
.dark-mode button#logout-btn.bg-red-600 {
|
||||
background-color: #dc2626 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark-mode button#logout-btn.bg-red-600:hover {
|
||||
background-color: #b91c1c !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark-mode button#login-btn.bg-blue-600 {
|
||||
background-color: #2563eb !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark-mode button#login-btn.bg-blue-600:hover {
|
||||
background-color: #1d4ed8 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Add missing Tailwind utility classes for other elements */
|
||||
.bg-red-500 {
|
||||
background-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.hover\:bg-red-600:hover {
|
||||
background-color: #dc2626 !important;
|
||||
}
|
||||
|
||||
.text-white {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
786
public/css/tailwind-build.css
Normal file
786
public/css/tailwind-build.css
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
/*! tailwindcss v3.4.0 | MIT License | https://tailwindcss.com */
|
||||
|
||||
/*
|
||||
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Reset styles */
|
||||
html {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
font-feature-settings: normal;
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Button reset */
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
font-size: 100%;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Form element resets */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
font-size: 100%;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 1;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Summary reset */
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Utility Classes - Only the ones used in your HTML */
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-2 {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.right-2 {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.bottom-2 {
|
||||
bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.left-\[2px\] {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.top-\[2px\] {
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.z-1000 {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.m-4 {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.-ml-1 {
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
|
||||
.mr-3 {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.-mt-4px {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.aspect-video {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.h-2 {
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.h-5 {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.h-12 {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.h-16px {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.h-24 {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.h-40px {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.h-64 {
|
||||
height: 16rem;
|
||||
}
|
||||
|
||||
.max-h-full {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.min-h-\[100px\] {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.w-5 {
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.w-11 {
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
||||
.w-12 {
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.w-16px {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.w-40px {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.w-44px {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.max-w-xl {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.max-w-4xl {
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.max-w-6xl {
|
||||
max-width: 72rem;
|
||||
}
|
||||
|
||||
.max-w-full {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.gap-8 {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.cursor-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.resize-y {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.appearance-none {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.rounded-b-lg {
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-2 {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.border-dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.border-solid {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.border-gray-400 {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.border-red-400 {
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
.border-white {
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.bg-black {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.bg-gray-200 {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.bg-gray-300 {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.bg-gray-400 {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
.bg-gray-500 {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
|
||||
.bg-green-500 {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
/* Ensure processing button styles work properly */
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.opacity-60 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bg-red-100 {
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.bg-orange-50 {
|
||||
background-color: rgba(252, 151, 41, 0.1);
|
||||
}
|
||||
|
||||
.bg-orange-600 {
|
||||
background-color: #fc9729;
|
||||
}
|
||||
|
||||
.bg-opacity-70 {
|
||||
--tw-bg-opacity: 0.7;
|
||||
}
|
||||
|
||||
.object-contain {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.p-1 {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.py-3 {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.py-6 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.py-8 {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.pt-3 {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-gray-500 {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.text-gray-900 {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.text-red-500 {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.text-red-700 {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.text-white {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.text-orange-600 {
|
||||
color: #fc9729;
|
||||
}
|
||||
|
||||
.text-orange-700 {
|
||||
color: #fc9729;
|
||||
}
|
||||
|
||||
.text-orange-800 {
|
||||
color: #fc9729;
|
||||
}
|
||||
|
||||
.antialiased {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.opacity-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.shadow-sm {
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.shadow-md {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.shadow-xl {
|
||||
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.outline-none {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.transition-transform {
|
||||
transition-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.duration-150 {
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.duration-200ms {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.ease-in-out {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover\:bg-gray-600:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.hover\:bg-green-600:hover {
|
||||
background-color: #059669;
|
||||
}
|
||||
|
||||
.hover\:bg-orange-700:hover {
|
||||
background-color: #df8925;
|
||||
}
|
||||
|
||||
.hover\:border-orange-500:hover {
|
||||
border-color: #fc9729;
|
||||
}
|
||||
|
||||
.focus\:border-orange-500:focus {
|
||||
border-color: #fc9729;
|
||||
}
|
||||
|
||||
.focus\:ring-orange-500:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(252 151 41 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-2:focus {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.focus\:outline-none:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.group-open\:rotate-90 {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (min-width: 768px) {
|
||||
.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.md\:gap-8 {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.md\:p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.md\:text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.md\:text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.md\:text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
|
||||
.md\:h-80 {
|
||||
height: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sm\:inline {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:max-w-6xl {
|
||||
max-width: 72rem;
|
||||
}
|
||||
}
|
||||
156
public/index.html
Normal file
156
public/index.html
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Runway Gen4 Web App</title>
|
||||
<!-- Tailwind CSS - Local Build -->
|
||||
<link rel="stylesheet" href="css/tailwind-build.css">
|
||||
<!-- Custom CSS for Font and other minimal global styles -->
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<!-- Font Awesome for icons (e.g., loading spinner) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
<!-- MSAL.js Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@azure/msal-browser@3.5.0/lib/msal-browser.min.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 text-gray-900 antialiased flex flex-col items-center py-8">
|
||||
<!-- Dark Mode Toggle Button -->
|
||||
<button id="darkModeToggle" class="dark-mode-toggle" title="Toggle Dark Mode">
|
||||
<span id="lightModeIcon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span id="darkModeIcon" style="display: none;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Authentication Section -->
|
||||
<div id="auth-section" class="mb-4 text-center">
|
||||
<button id="login-btn" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg shadow-md transition duration-150 ease-in-out hidden" style="background-color: #2563eb !important; color: #ffffff !important;">
|
||||
Sign In with Microsoft
|
||||
</button>
|
||||
<button id="logout-btn" class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg shadow-md transition duration-150 ease-in-out hidden" style="background-color: #dc2626 !important; color: #ffffff !important;">
|
||||
Sign Out
|
||||
</button>
|
||||
<div id="user-info" class="mt-2 text-sm font-medium"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="w-full py-6 bg-gray-50 shadow-sm rounded-b-lg">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-orange-600 text-center px-4">
|
||||
Runway Gen4 Web App
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="w-full max-w-4xl lg:max-w-6xl mx-auto p-4 md:p-8 flex flex-col gap-6 md:gap-8 mt-8">
|
||||
<!-- Input Form Section (always visible) -->
|
||||
<div id="input-form-section" class="w-full">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
||||
<!-- Left column for image upload and prompt input -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Image Upload Zone -->
|
||||
<div id="image-upload-zone"
|
||||
class="relative flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-lg border-gray-400 hover:border-indigo-500 transition-all duration-200 ease-in-out cursor-pointer h-64 md:h-80">
|
||||
<input type="file" id="image-input" accept="image/png, image/jpeg" class="hidden" aria-label="Upload Image">
|
||||
<div id="image-preview-container" class="hidden max-h-full max-w-full">
|
||||
<img id="image-preview" src="#" alt="Image Preview" class="max-h-full max-w-full object-contain rounded-lg">
|
||||
<button id="remove-image-btn"
|
||||
class="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1 text-xs w-6 h-6 flex items-center justify-center shadow-md transition-colors"
|
||||
aria-label="Remove image">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div id="image-upload-placeholder" class="flex flex-col items-center text-gray-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="text-sm md:text-base">Drag & Drop Image Here or</p>
|
||||
<p class="text-sm md:text-base font-medium text-orange-600">Click to Upload</p>
|
||||
</div>
|
||||
<p id="image-error-message" class="absolute bottom-2 text-red-500 text-xs mt-2 hidden"></p>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Input Field -->
|
||||
<div>
|
||||
<label for="prompt-input" class="block text-sm md:text-base font-medium text-gray-700 mb-1">
|
||||
Video Prompt
|
||||
</label>
|
||||
<textarea id="prompt-input"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-orange-500 focus:border-orange-500 resize-y min-h-[100px] text-sm md:text-base"
|
||||
rows="4"
|
||||
placeholder="Enter your video generation prompt..."
|
||||
aria-label="Video Generation Prompt"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column for API options panel -->
|
||||
<div id="api-options-panel" class="bg-gray-50 p-4 rounded-lg shadow-sm">
|
||||
<!-- Dynamic API options will be injected here by JavaScript -->
|
||||
<details class="mb-4 group" open>
|
||||
<summary class="flex justify-between items-center cursor-pointer font-bold text-lg text-orange-800">
|
||||
API Options
|
||||
<span class="transform transition-transform duration-200 group-open:rotate-90">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
<div id="dynamic-options-container" class="mt-4 border-t border-gray-200 pt-4">
|
||||
<!-- Options will be rendered here by script.js -->
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Error Message -->
|
||||
<div id="general-error-message" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg relative mt-6 hidden" role="alert">
|
||||
<strong class="font-bold">Error!</strong>
|
||||
<span class="block sm:inline ml-2" id="general-error-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Generate Button -->
|
||||
<button id="generate-video-btn"
|
||||
class="w-full py-3 px-6 rounded-lg shadow-md transition duration-150 ease-in-out flex items-center justify-center font-bold text-lg mt-6 bg-gray-400 cursor-not-allowed"
|
||||
disabled
|
||||
aria-label="Generate Video">
|
||||
Generate Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Video Display Section (hidden initially) -->
|
||||
<div id="video-display-section" class="w-full flex-col items-center p-4 bg-gray-50 rounded-lg shadow-md hidden mt-8">
|
||||
<h2 class="text-xl md:text-2xl font-bold text-orange-700 mb-4">Your Generated Video</h2>
|
||||
<video id="video-player" controls class="w-full max-w-xl rounded-lg shadow-xl mb-6 aspect-video bg-black"></video>
|
||||
<div class="flex flex-col sm:flex-row gap-4 w-full max-w-xl">
|
||||
<a id="download-video-btn" href="#" download="generated-video.mp4"
|
||||
class="flex-1 text-center bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-150 ease-in-out"
|
||||
aria-label="Download generated video">
|
||||
Download Video
|
||||
</a>
|
||||
<button id="start-new-btn"
|
||||
class="flex-1 text-center bg-gray-500 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-150 ease-in-out"
|
||||
aria-label="Generate another video">
|
||||
Generate Another Video
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loading-overlay" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 hidden" role="status" aria-live="polite">
|
||||
<div class="flex flex-col items-center text-white">
|
||||
<i class="fas fa-spinner fa-spin fa-3x mb-4"></i>
|
||||
<p class="text-xl md:text-2xl font-semibold">Generating your video...</p>
|
||||
<p class="text-sm md:text-base mt-2">This may take a moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for interactivity -->
|
||||
<script src="js/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
950
public/js/script.js
Normal file
950
public/js/script.js
Normal file
|
|
@ -0,0 +1,950 @@
|
|||
// public/js/script.js
|
||||
|
||||
// MSAL Configuration and Authentication
|
||||
let msalInstance = null;
|
||||
let currentUser = null;
|
||||
let azureConfig = null;
|
||||
let selectedImageFile = null;
|
||||
|
||||
// Initialize MSAL or check for existing auth
|
||||
async function initializeMSAL() {
|
||||
try {
|
||||
// First, check if we have auth state from landing page
|
||||
const storedToken = localStorage.getItem('runway_access_token');
|
||||
const storedAuthState = localStorage.getItem('runway_auth_state');
|
||||
|
||||
if (storedToken && storedAuthState) {
|
||||
const tokenData = JSON.parse(storedToken);
|
||||
const authState = JSON.parse(storedAuthState);
|
||||
|
||||
// Check if token is still valid (not expired)
|
||||
if (Date.now() < tokenData.expires_at) {
|
||||
currentUser = tokenData.user;
|
||||
updateAuthUI();
|
||||
return; // Skip MSAL initialization if we have valid token
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize MSAL for this page
|
||||
const configResponse = await fetch('backend/config_client.php');
|
||||
azureConfig = await configResponse.json();
|
||||
|
||||
const msalConfig = {
|
||||
auth: {
|
||||
clientId: azureConfig.azure_client_id,
|
||||
authority: `https://login.microsoftonline.com/${azureConfig.azure_tenant_id}`,
|
||||
redirectUri: azureConfig.redirect_uri
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: "localStorage",
|
||||
storeAuthStateInCookie: false
|
||||
}
|
||||
};
|
||||
|
||||
msalInstance = new msal.PublicClientApplication(msalConfig);
|
||||
await msalInstance.initialize();
|
||||
|
||||
// Handle redirect promise (from MSAL authentication)
|
||||
const redirectResponse = await msalInstance.handleRedirectPromise();
|
||||
if (redirectResponse) {
|
||||
// User just authenticated, set current user and store token
|
||||
currentUser = redirectResponse.account;
|
||||
msalInstance.setActiveAccount(currentUser);
|
||||
|
||||
// Store the authentication data
|
||||
const tokenData = {
|
||||
access_token: redirectResponse.accessToken,
|
||||
expires_at: Date.now() + (60 * 60 * 1000), // 1 hour
|
||||
user: currentUser
|
||||
};
|
||||
localStorage.setItem('runway_access_token', JSON.stringify(tokenData));
|
||||
|
||||
// Clean up the URL (remove auth parameters)
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
} else {
|
||||
// Check if user was already authenticated
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0) {
|
||||
currentUser = accounts[0];
|
||||
msalInstance.setActiveAccount(currentUser);
|
||||
}
|
||||
}
|
||||
|
||||
updateAuthUI();
|
||||
} catch (error) {
|
||||
console.error('MSAL initialization failed:', error);
|
||||
showAuthError();
|
||||
}
|
||||
}
|
||||
|
||||
// Sign In function
|
||||
async function signIn() {
|
||||
if (!msalInstance) {
|
||||
// If MSAL not initialized, redirect to portal for authentication
|
||||
window.location.href = 'http://localhost:3000/';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const loginRequest = {
|
||||
scopes: ["User.Read"],
|
||||
prompt: "select_account"
|
||||
};
|
||||
|
||||
const loginResponse = await msalInstance.loginPopup(loginRequest);
|
||||
currentUser = loginResponse.account;
|
||||
msalInstance.setActiveAccount(currentUser);
|
||||
|
||||
// Store the authentication data
|
||||
const tokenData = {
|
||||
access_token: loginResponse.accessToken,
|
||||
expires_at: Date.now() + (60 * 60 * 1000), // 1 hour
|
||||
user: currentUser
|
||||
};
|
||||
localStorage.setItem('runway_access_token', JSON.stringify(tokenData));
|
||||
|
||||
updateAuthUI();
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
showErrorMessage(generalErrorMessage, "Login failed: " + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sign Out function - redirect to portal
|
||||
async function signOut() {
|
||||
// Clear any stored tokens
|
||||
localStorage.removeItem('runway_access_token');
|
||||
localStorage.removeItem('runway_auth_state');
|
||||
|
||||
// Redirect to portal
|
||||
window.location.href = 'http://localhost:3000/';
|
||||
}
|
||||
|
||||
// Update Authentication UI
|
||||
function updateAuthUI() {
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const userInfo = document.getElementById('user-info');
|
||||
|
||||
if (currentUser) {
|
||||
if (loginBtn) loginBtn.classList.add('hidden');
|
||||
if (logoutBtn) logoutBtn.classList.remove('hidden');
|
||||
if (userInfo) {
|
||||
userInfo.textContent = `Welcome, ${currentUser.name || currentUser.username}`;
|
||||
userInfo.classList.remove('text-red-600');
|
||||
userInfo.classList.add('text-green-600');
|
||||
}
|
||||
} else {
|
||||
// Show sign in option without auto-redirect
|
||||
if (loginBtn) loginBtn.classList.remove('hidden');
|
||||
if (logoutBtn) logoutBtn.classList.add('hidden');
|
||||
if (userInfo) {
|
||||
userInfo.innerHTML = 'Please sign in to generate videos | <a href="http://localhost:3000/" class="text-blue-600 hover:underline">← Back to Portal</a>';
|
||||
userInfo.classList.remove('text-green-600');
|
||||
userInfo.classList.add('text-red-600');
|
||||
}
|
||||
}
|
||||
|
||||
// Update generate button state only if the function exists
|
||||
if (typeof updateGenerateButtonState === 'function') {
|
||||
updateGenerateButtonState();
|
||||
}
|
||||
}
|
||||
|
||||
// Show authentication error
|
||||
function showAuthError() {
|
||||
const userInfo = document.getElementById('user-info');
|
||||
userInfo.innerHTML = 'Authentication initialization failed. <a href="http://localhost:3000/" class="text-blue-600 hover:underline">← Back to Portal</a>';
|
||||
userInfo.classList.add('text-red-600');
|
||||
}
|
||||
|
||||
// Navigate back to portal
|
||||
function goBackToPortal() {
|
||||
window.location.href = 'http://localhost:8888/';
|
||||
}
|
||||
|
||||
// Get Access Token
|
||||
async function getAccessToken() {
|
||||
// First, try to use stored token from landing page
|
||||
const storedToken = localStorage.getItem('runway_access_token');
|
||||
if (storedToken) {
|
||||
const tokenData = JSON.parse(storedToken);
|
||||
if (Date.now() < tokenData.expires_at) {
|
||||
return tokenData.access_token;
|
||||
} else {
|
||||
// Token expired, remove it
|
||||
localStorage.removeItem('runway_access_token');
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid stored token, use MSAL
|
||||
if (!msalInstance || !currentUser) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenRequest = {
|
||||
scopes: ["User.Read"],
|
||||
account: currentUser
|
||||
};
|
||||
|
||||
const response = await msalInstance.acquireTokenSilent(tokenRequest);
|
||||
return response.accessToken;
|
||||
} catch (error) {
|
||||
// If silent token acquisition fails, try interactive
|
||||
try {
|
||||
const response = await msalInstance.acquireTokenPopup(tokenRequest);
|
||||
return response.accessToken;
|
||||
} catch (interactiveError) {
|
||||
console.error('Token acquisition failed:', interactiveError);
|
||||
throw interactiveError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state of the generate button (disabled/enabled, loading state).
|
||||
*/
|
||||
function updateGenerateButtonState() {
|
||||
const generateVideoBtn = document.getElementById('generate-video-btn');
|
||||
const promptInput = document.getElementById('prompt-input');
|
||||
|
||||
const hasImage = typeof selectedImageFile !== 'undefined' && selectedImageFile !== null;
|
||||
const hasPrompt = promptInput && promptInput.value.trim().length > 0;
|
||||
const isAuthenticated = currentUser !== null;
|
||||
const canGenerate = hasImage && hasPrompt && isAuthenticated;
|
||||
|
||||
// Check if generateVideoBtn exists before accessing it
|
||||
if (!generateVideoBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't update if we're currently processing (loading overlay visible)
|
||||
const loadingOverlay = document.getElementById('loading-overlay');
|
||||
if (loadingOverlay && !loadingOverlay.classList.contains('hidden')) {
|
||||
return; // Keep current processing state
|
||||
}
|
||||
|
||||
generateVideoBtn.disabled = !canGenerate;
|
||||
if (canGenerate) {
|
||||
generateVideoBtn.classList.remove('bg-gray-400', 'bg-gray-300', 'bg-green-500', 'cursor-not-allowed', 'opacity-60', 'pointer-events-none', 'text-gray-600');
|
||||
generateVideoBtn.classList.add('bg-orange-600', 'hover:bg-orange-700', 'text-white');
|
||||
} else {
|
||||
generateVideoBtn.classList.add('bg-gray-400', 'cursor-not-allowed');
|
||||
generateVideoBtn.classList.remove('bg-orange-600', 'hover:bg-orange-700', 'text-white', 'bg-gray-300', 'bg-green-500', 'opacity-60', 'pointer-events-none', 'text-gray-600');
|
||||
}
|
||||
|
||||
// Only update text if not currently loading
|
||||
generateVideoBtn.innerHTML = 'Generate Video';
|
||||
generateVideoBtn.setAttribute('aria-label', 'Generate Video');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize MSAL first
|
||||
await initializeMSAL();
|
||||
// Dark mode toggle functionality
|
||||
const darkModeEnabled = localStorage.getItem('darkMode') === 'enabled';
|
||||
|
||||
if (darkModeEnabled) {
|
||||
document.body.classList.add('dark-mode');
|
||||
document.getElementById('lightModeIcon').style.display = 'none';
|
||||
document.getElementById('darkModeIcon').style.display = 'block';
|
||||
}
|
||||
|
||||
// Toggle dark mode when button is clicked
|
||||
document.getElementById('darkModeToggle').addEventListener('click', function() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
|
||||
// Save preference and toggle icons
|
||||
if (document.body.classList.contains('dark-mode')) {
|
||||
localStorage.setItem('darkMode', 'enabled');
|
||||
document.getElementById('lightModeIcon').style.display = 'none';
|
||||
document.getElementById('darkModeIcon').style.display = 'block';
|
||||
} else {
|
||||
localStorage.setItem('darkMode', 'disabled');
|
||||
document.getElementById('lightModeIcon').style.display = 'block';
|
||||
document.getElementById('darkModeIcon').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Auth event listeners
|
||||
document.getElementById('login-btn').addEventListener('click', signIn);
|
||||
document.getElementById('logout-btn').addEventListener('click', signOut);
|
||||
|
||||
// --- DOM Elements ---
|
||||
const imageInput = document.getElementById('image-input');
|
||||
const imageUploadZone = document.getElementById('image-upload-zone');
|
||||
const imagePreviewContainer = document.getElementById('image-preview-container');
|
||||
const imagePreview = document.getElementById('image-preview');
|
||||
const imageUploadPlaceholder = document.getElementById('image-upload-placeholder');
|
||||
const removeImageBtn = document.getElementById('remove-image-btn');
|
||||
const imageErrorMessage = document.getElementById('image-error-message');
|
||||
|
||||
const promptInput = document.getElementById('prompt-input');
|
||||
const dynamicOptionsContainer = document.getElementById('dynamic-options-container');
|
||||
const generateVideoBtn = document.getElementById('generate-video-btn');
|
||||
const generalErrorMessage = document.getElementById('general-error-message');
|
||||
const generalErrorText = document.getElementById('general-error-text');
|
||||
|
||||
const videoDisplaySection = document.getElementById('video-display-section');
|
||||
const inputFormSection = document.getElementById('input-form-section');
|
||||
const videoPlayer = document.getElementById('video-player');
|
||||
const downloadVideoBtn = document.getElementById('download-video-btn');
|
||||
const startNewBtn = document.getElementById('start-new-btn');
|
||||
|
||||
// --- State Variables ---
|
||||
let apiOptions = {}; // Will store the current values of API options
|
||||
// CORRECTED: Your MAMP project folder 'Runway-video' should be your Document Root.
|
||||
// The path to the backend API is then relative to this root.
|
||||
const RUNWAY_API_ENDPOINT = 'backend/api.php'; // Your PHP backend endpoint
|
||||
|
||||
// Runway API supported parameters only
|
||||
const runwayGen4APISchema = {
|
||||
"duration": { "type": "number", "label": "Video Duration (seconds)", "min": 5, "max": 10, "step": 5, "default": 5 },
|
||||
"ratio": {
|
||||
"type": "enum",
|
||||
"label": "Aspect Ratio",
|
||||
"options": [
|
||||
{value: "1280:720", label: "1280:720 (Landscape 16:9)"},
|
||||
{value: "720:1280", label: "720:1280 (Portrait 9:16)"},
|
||||
{value: "1104:832", label: "1104:832 (Landscape 4:3)"},
|
||||
{value: "832:1104", label: "832:1104 (Portrait 3:4)"},
|
||||
{value: "960:960", label: "960:960 (Square 1:1)"},
|
||||
{value: "1584:672", label: "1584:672 (Ultrawide)"}
|
||||
],
|
||||
"default": "1280:720"
|
||||
},
|
||||
"seed": { "type": "number", "label": "Seed (optional)", "min": 0, "max": 999999, "step": 1, "default": null }
|
||||
};
|
||||
|
||||
// --- Utility Functions ---
|
||||
|
||||
/**
|
||||
* Shows a specific error message element.
|
||||
* @param {HTMLElement} element The HTML element to show (e.g., imageErrorMessage, generalErrorMessage).
|
||||
* @param {string} message The error message text.
|
||||
*/
|
||||
function showErrorMessage(element, message) {
|
||||
element.textContent = message;
|
||||
element.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides a specific error message element.
|
||||
* @param {HTMLElement} element The HTML element to hide.
|
||||
*/
|
||||
function hideErrorMessage(element) {
|
||||
element.classList.add('hidden');
|
||||
element.textContent = '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Displays or hides the loading overlay and updates button state.
|
||||
* @param {boolean} show True to show, false to hide.
|
||||
* @param {string} message Optional custom message for the button
|
||||
*/
|
||||
function toggleLoadingOverlay(show, message = 'Generating Video...') {
|
||||
const loadingOverlay = document.getElementById('loading-overlay');
|
||||
const generateVideoBtn = document.getElementById('generate-video-btn');
|
||||
|
||||
if (show) {
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.classList.remove('hidden');
|
||||
}
|
||||
if (generateVideoBtn) {
|
||||
generateVideoBtn.disabled = true;
|
||||
generateVideoBtn.classList.remove('bg-orange-600', 'hover:bg-orange-700', 'bg-gray-400', 'bg-gray-300', 'bg-green-500');
|
||||
generateVideoBtn.classList.add('cursor-not-allowed', 'pointer-events-none', 'text-white');
|
||||
generateVideoBtn.style.backgroundColor = '#10b981'; // Force green
|
||||
generateVideoBtn.style.borderColor = '#10b981';
|
||||
generateVideoBtn.innerHTML = `<i class="fas fa-spinner fa-spin -ml-1 mr-3"></i> ${message}`;
|
||||
generateVideoBtn.setAttribute('aria-label', message);
|
||||
}
|
||||
} else {
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.classList.add('hidden');
|
||||
}
|
||||
if (generateVideoBtn) {
|
||||
generateVideoBtn.classList.remove('cursor-not-allowed', 'pointer-events-none');
|
||||
generateVideoBtn.style.backgroundColor = ''; // Clear inline style
|
||||
generateVideoBtn.style.borderColor = '';
|
||||
updateGenerateButtonState(); // Re-evaluate button state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all form inputs and resets state to allow a new generation.
|
||||
*/
|
||||
function resetFormAndState() {
|
||||
selectedImageFile = null;
|
||||
imageInput.value = ''; // Clear file input
|
||||
imagePreviewContainer.classList.add('hidden');
|
||||
imageUploadPlaceholder.classList.remove('hidden');
|
||||
imagePreview.src = '#';
|
||||
hideErrorMessage(imageErrorMessage);
|
||||
hideErrorMessage(generalErrorMessage);
|
||||
|
||||
promptInput.value = '';
|
||||
|
||||
// Reset API options to their defaults
|
||||
initializeApiOptions();
|
||||
renderApiOptions(); // Re-render options to show defaults
|
||||
|
||||
videoDisplaySection.classList.add('hidden');
|
||||
videoPlayer.src = '#';
|
||||
|
||||
updateGenerateButtonState();
|
||||
}
|
||||
|
||||
// --- Image Upload Handlers ---
|
||||
|
||||
/**
|
||||
* Resizes and compresses an image to stay under 5MB base64 limit
|
||||
* @param {File} file The image file object
|
||||
* @param {number} maxWidth Maximum width
|
||||
* @param {number} maxHeight Maximum height
|
||||
* @returns {Promise<File>} Resized and compressed image file
|
||||
*/
|
||||
function resizeImage(file, maxWidth = 1584, maxHeight = 1280) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = async function() {
|
||||
// Calculate new dimensions while maintaining aspect ratio
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const aspectRatio = width / height;
|
||||
|
||||
if (width > height) {
|
||||
width = Math.min(width, maxWidth);
|
||||
height = width / aspectRatio;
|
||||
} else {
|
||||
height = Math.min(height, maxHeight);
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Try different quality levels to stay under 5MB base64 limit
|
||||
const targetSize = 3.5 * 1024 * 1024; // 3.5MB to account for base64 overhead
|
||||
let quality = 0.9;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
const tryCompress = () => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to compress image'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we need to compress further
|
||||
if (blob.size > targetSize && attempts < maxAttempts) {
|
||||
quality -= 0.1;
|
||||
attempts++;
|
||||
tryCompress();
|
||||
return;
|
||||
}
|
||||
|
||||
const resizedFile = new File([blob], file.name, {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
|
||||
console.log(`Image compressed: ${(blob.size / 1024 / 1024).toFixed(2)}MB, quality: ${quality.toFixed(1)}`);
|
||||
resolve(resizedFile);
|
||||
}, 'image/jpeg', quality);
|
||||
};
|
||||
|
||||
tryCompress();
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error('Failed to load image'));
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a selected image file for preview and upload.
|
||||
* @param {File} file The image file object.
|
||||
*/
|
||||
async function processImageFile(file) {
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
try {
|
||||
// Resize large images to fit Runway's requirements
|
||||
const resizedFile = await resizeImage(file);
|
||||
|
||||
selectedImageFile = resizedFile;
|
||||
imagePreview.src = URL.createObjectURL(resizedFile);
|
||||
imagePreviewContainer.classList.remove('hidden');
|
||||
imageUploadPlaceholder.classList.add('hidden');
|
||||
hideErrorMessage(imageErrorMessage);
|
||||
updateGenerateButtonState();
|
||||
} catch (error) {
|
||||
showErrorMessage(imageErrorMessage, "Error processing image. Please try a different image.");
|
||||
console.error('Image processing error:', error);
|
||||
}
|
||||
} else {
|
||||
selectedImageFile = null;
|
||||
imagePreview.src = '#';
|
||||
imagePreviewContainer.classList.add('hidden');
|
||||
imageUploadPlaceholder.classList.remove('hidden');
|
||||
showErrorMessage(imageErrorMessage, "Invalid file type. Please upload an image (JPG, PNG).");
|
||||
updateGenerateButtonState();
|
||||
}
|
||||
}
|
||||
|
||||
imageInput.addEventListener('change', async (event) => {
|
||||
await processImageFile(event.target.files[0]);
|
||||
});
|
||||
|
||||
imageUploadZone.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
imageUploadZone.classList.add('border-orange-500', 'bg-orange-50'); // Add visual feedback for drag-over
|
||||
});
|
||||
|
||||
imageUploadZone.addEventListener('dragleave', () => {
|
||||
imageUploadZone.classList.remove('border-orange-500', 'bg-orange-50'); // Remove feedback
|
||||
});
|
||||
|
||||
imageUploadZone.addEventListener('drop', async (event) => {
|
||||
event.preventDefault();
|
||||
imageUploadZone.classList.remove('border-orange-500', 'bg-orange-50'); // Remove feedback
|
||||
await processImageFile(event.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
imageUploadZone.addEventListener('click', () => {
|
||||
imageInput.click(); // Trigger hidden file input click
|
||||
});
|
||||
|
||||
removeImageBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation(); // Prevent triggering imageUploadZone's click
|
||||
selectedImageFile = null;
|
||||
imageInput.value = '';
|
||||
imagePreview.src = '#';
|
||||
imagePreviewContainer.classList.add('hidden');
|
||||
imageUploadPlaceholder.classList.remove('hidden');
|
||||
hideErrorMessage(imageErrorMessage);
|
||||
updateGenerateButtonState();
|
||||
});
|
||||
|
||||
// --- Prompt Input Handler ---
|
||||
if (promptInput) {
|
||||
promptInput.addEventListener('input', updateGenerateButtonState);
|
||||
}
|
||||
|
||||
// --- API Options Panel Functions ---
|
||||
|
||||
/**
|
||||
* Initializes the global apiOptions object with default values from the schema.
|
||||
*/
|
||||
function initializeApiOptions() {
|
||||
apiOptions = {};
|
||||
for (const key in runwayGen4APISchema) {
|
||||
const param = runwayGen4APISchema[key];
|
||||
if (param.type === 'object') {
|
||||
apiOptions[key] = {};
|
||||
for (const prop in param.properties) {
|
||||
apiOptions[key][prop] = param.properties[prop].default;
|
||||
}
|
||||
} else {
|
||||
apiOptions[key] = param.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles changes to API option controls and updates the global apiOptions state.
|
||||
* @param {string} key The key of the API option.
|
||||
* @param {*} value The new value of the option.
|
||||
* @param {string|null} parentKey The key of the parent object if nested.
|
||||
*/
|
||||
function handleApiOptionChange(key, value, parentKey = null) {
|
||||
if (parentKey) {
|
||||
apiOptions[parentKey][key] = value;
|
||||
} else {
|
||||
apiOptions[key] = value;
|
||||
}
|
||||
// Re-render only the affected control or potentially the whole panel for simplicity in this example
|
||||
// For complex UIs, a more granular update mechanism might be used.
|
||||
renderApiOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single API option control based on its type and current value.
|
||||
* @param {string} key The key of the API option.
|
||||
* @param {object} param The parameter definition from the schema.
|
||||
* @param {string|null} parentKey The key of the parent object if nested.
|
||||
* @returns {string} HTML string for the control.
|
||||
*/
|
||||
function renderOptionControl(key, param, parentKey = null) {
|
||||
const id = parentKey ? `${parentKey}-${key}` : key;
|
||||
const currentValue = parentKey ? apiOptions[parentKey][key] : apiOptions[key];
|
||||
|
||||
let html = '';
|
||||
switch (param.type) {
|
||||
case 'number':
|
||||
const displayValue = currentValue !== null ? currentValue : 'Random';
|
||||
const inputValue = currentValue !== null ? currentValue : param.min;
|
||||
html = `
|
||||
<div class="mb-4">
|
||||
<label for="${id}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
${param.label}: <span class="font-semibold" id="value-${id}">${displayValue}</span>
|
||||
</label>
|
||||
<input type="range" id="${id}" min="${param.min}" max="${param.max}" step="${param.step || 1}" value="${inputValue}"
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer range-thumb-indigo">
|
||||
${key === 'seed' ? `<label class="flex items-center mt-2"><input type="checkbox" id="${id}-random" ${currentValue === null ? 'checked' : ''} class="mr-2"> Use random seed</label>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'boolean':
|
||||
const checkedAttr = currentValue ? 'checked' : '';
|
||||
html = `
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label for="${id}" class="text-sm font-medium text-gray-700 cursor-pointer">
|
||||
${param.label}
|
||||
</label>
|
||||
<label for="${id}" class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="${id}" ${checkedAttr} class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-orange-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:border-gray-300 after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'string':
|
||||
html = `
|
||||
<div class="mb-4">
|
||||
<label for="${id}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
${param.label}
|
||||
</label>
|
||||
<input type="text" id="${id}" value="${currentValue}" placeholder="${param.placeholder || ''}"
|
||||
class="w-full p-2 border border-gray-300 rounded-md focus:ring-orange-500 focus:border-orange-500 text-sm">
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'enum':
|
||||
const optionsHtml = param.options.map(option => {
|
||||
const value = typeof option === 'object' ? option.value : option;
|
||||
const label = typeof option === 'object' ? option.label : option;
|
||||
return `<option value="${value}" ${currentValue === value ? 'selected' : ''}>${label}</option>`;
|
||||
}).join('');
|
||||
html = `
|
||||
<div class="mb-4">
|
||||
<label for="${id}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
${param.label}
|
||||
</label>
|
||||
<select id="${id}"
|
||||
class="w-full p-2 border border-gray-300 rounded-md bg-white focus:ring-orange-500 focus:border-orange-500 text-sm">
|
||||
${optionsHtml}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'object':
|
||||
// For nested objects, recursively call renderOptionControl for its properties
|
||||
const nestedPropertiesHtml = Object.entries(param.properties).map(([propKey, propParam]) =>
|
||||
renderOptionControl(propKey, propParam, key)
|
||||
).join('');
|
||||
|
||||
// Outer HTML for the collapsible details element for the object
|
||||
html = `
|
||||
<details class="bg-white p-3 rounded-lg shadow-sm mb-4 border border-gray-200 group">
|
||||
<summary class="flex justify-between items-center cursor-pointer font-medium text-orange-700">
|
||||
${param.label}
|
||||
<span class="transform transition-transform duration-200 group-open:rotate-90">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="mt-3 border-t border-gray-200 pt-3">
|
||||
${nestedPropertiesHtml}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown parameter type: ${param.type}`);
|
||||
break;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all API options into the dynamicOptionsContainer based on the schema and current values.
|
||||
* Attaches event listeners to the dynamically created elements.
|
||||
*/
|
||||
function renderApiOptions() {
|
||||
let allOptionsHtml = '';
|
||||
for (const key in runwayGen4APISchema) {
|
||||
allOptionsHtml += renderOptionControl(key, runwayGen4APISchema[key]);
|
||||
}
|
||||
dynamicOptionsContainer.innerHTML = allOptionsHtml;
|
||||
|
||||
// Attach event listeners to newly created elements
|
||||
dynamicOptionsContainer.querySelectorAll('input, select').forEach(control => {
|
||||
const idParts = control.id.split('-');
|
||||
let key = idParts[0];
|
||||
let parentKey = null;
|
||||
|
||||
// Handle seed random checkbox specially
|
||||
if (control.id === 'seed-random') {
|
||||
control.addEventListener('change', (event) => {
|
||||
const seedSlider = document.getElementById('seed');
|
||||
const valueSpan = document.getElementById('value-seed');
|
||||
if (event.target.checked) {
|
||||
handleApiOptionChange('seed', null);
|
||||
if (valueSpan) valueSpan.textContent = 'Random';
|
||||
seedSlider.disabled = true;
|
||||
} else {
|
||||
const seedValue = parseInt(seedSlider.value);
|
||||
handleApiOptionChange('seed', seedValue);
|
||||
if (valueSpan) valueSpan.textContent = seedValue;
|
||||
seedSlider.disabled = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (idParts.length > 1) { // If it's a nested key like "camera_motion-pan"
|
||||
parentKey = key;
|
||||
key = idParts[1];
|
||||
}
|
||||
|
||||
const param = parentKey ? runwayGen4APISchema[parentKey].properties[key] : runwayGen4APISchema[key];
|
||||
|
||||
if (control.type === 'range' || control.type === 'text' || control.tagName === 'SELECT') {
|
||||
if (control.type === 'range' && key === 'duration') {
|
||||
// Special handling for duration slider - snap to 5 or 10
|
||||
control.addEventListener('input', (event) => {
|
||||
let value = parseFloat(event.target.value);
|
||||
let snappedValue = value < 7.5 ? 5 : 10;
|
||||
|
||||
// Update display value immediately
|
||||
const valueSpan = document.getElementById(`value-${control.id}`);
|
||||
if (valueSpan) valueSpan.textContent = snappedValue;
|
||||
|
||||
handleApiOptionChange(key, snappedValue, parentKey);
|
||||
});
|
||||
|
||||
control.addEventListener('change', (event) => {
|
||||
let value = parseFloat(event.target.value);
|
||||
let snappedValue = value < 7.5 ? 5 : 10;
|
||||
|
||||
// Snap the actual slider position
|
||||
event.target.value = snappedValue;
|
||||
|
||||
const valueSpan = document.getElementById(`value-${control.id}`);
|
||||
if (valueSpan) valueSpan.textContent = snappedValue;
|
||||
|
||||
handleApiOptionChange(key, snappedValue, parentKey);
|
||||
});
|
||||
} else {
|
||||
control.addEventListener('input', (event) => {
|
||||
let value = event.target.value;
|
||||
if (param.type === 'number') {
|
||||
value = parseFloat(value);
|
||||
// Update the displayed value next to the slider
|
||||
const valueSpan = document.getElementById(`value-${control.id}`);
|
||||
if (valueSpan) valueSpan.textContent = value;
|
||||
}
|
||||
handleApiOptionChange(key, value, parentKey);
|
||||
});
|
||||
}
|
||||
} else if (control.type === 'checkbox') {
|
||||
control.addEventListener('change', (event) => {
|
||||
handleApiOptionChange(key, event.target.checked, parentKey);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Job Polling Logic ---
|
||||
|
||||
/**
|
||||
* Polls Runway API for job completion status
|
||||
* @param {string} jobId The job ID to poll
|
||||
*/
|
||||
async function pollJobStatus(jobId) {
|
||||
const maxAttempts = 60; // Poll for up to 5 minutes
|
||||
const pollInterval = 5000; // 5 seconds
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const response = await fetch(`backend/check_status.php?job_id=${jobId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check job status');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'SUCCEEDED' || result.status === 'completed') {
|
||||
console.log('Task completed, full response:', result);
|
||||
|
||||
if (result.video_url) {
|
||||
videoPlayer.src = result.video_url;
|
||||
downloadVideoBtn.href = result.video_url;
|
||||
videoDisplaySection.classList.remove('hidden');
|
||||
toggleLoadingOverlay(false); // Unlock button on success
|
||||
return; // Success!
|
||||
} else {
|
||||
console.error('No video URL found in completed response:', result.raw_response);
|
||||
throw new Error('Video completed but no URL provided. Check console for response details.');
|
||||
}
|
||||
} else if (result.status === 'FAILED' || result.status === 'failed') {
|
||||
throw new Error('Video generation failed on server');
|
||||
}
|
||||
|
||||
// Update loading message with progress if available
|
||||
if (result.progress) {
|
||||
const progressPercent = Math.round(result.progress * 100);
|
||||
const loadingText = document.querySelector('#loading-overlay p');
|
||||
if (loadingText) {
|
||||
loadingText.textContent = `Generating video... ${progressPercent}%`;
|
||||
}
|
||||
// Update button text with progress
|
||||
const generateVideoBtn = document.getElementById('generate-video-btn');
|
||||
if (generateVideoBtn) {
|
||||
generateVideoBtn.innerHTML = `<i class="fas fa-spinner fa-spin -ml-1 mr-3"></i> Processing... ${progressPercent}%`;
|
||||
}
|
||||
} else {
|
||||
// Show different messages based on status
|
||||
let statusMessage = 'Processing...';
|
||||
if (result.status === 'RUNNING') {
|
||||
statusMessage = 'Generating Video...';
|
||||
} else if (result.status === 'PENDING') {
|
||||
statusMessage = 'Queued...';
|
||||
}
|
||||
|
||||
// Ensure button stays green during processing
|
||||
const generateVideoBtn = document.getElementById('generate-video-btn');
|
||||
if (generateVideoBtn) {
|
||||
generateVideoBtn.classList.remove('bg-orange-600', 'bg-gray-400', 'bg-gray-300', 'bg-green-500');
|
||||
generateVideoBtn.classList.add('text-white', 'pointer-events-none');
|
||||
generateVideoBtn.style.backgroundColor = '#10b981'; // Force green
|
||||
generateVideoBtn.innerHTML = `<i class="fas fa-spinner fa-spin -ml-1 mr-3"></i> ${statusMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
attempts++;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
throw new Error(`Failed to check video generation status: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Video generation timed out. Please try again.');
|
||||
}
|
||||
|
||||
// --- Video Generation Logic ---
|
||||
|
||||
const generateVideoBtn = document.getElementById('generate-video-btn');
|
||||
if (generateVideoBtn) {
|
||||
generateVideoBtn.addEventListener('click', async () => {
|
||||
// Immediately lock the button to prevent double clicks
|
||||
if (generateVideoBtn.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock button immediately with green processing color
|
||||
generateVideoBtn.disabled = true;
|
||||
generateVideoBtn.classList.remove('bg-orange-600', 'hover:bg-orange-700', 'text-white');
|
||||
generateVideoBtn.classList.add('cursor-not-allowed', 'text-white', 'pointer-events-none');
|
||||
generateVideoBtn.style.backgroundColor = '#10b981'; // Force green
|
||||
generateVideoBtn.style.borderColor = '#10b981';
|
||||
generateVideoBtn.innerHTML = '<i class="fas fa-spinner fa-spin -ml-1 mr-3"></i> Starting...';
|
||||
|
||||
toggleLoadingOverlay(true); // Show loading spinner and lock button
|
||||
|
||||
hideErrorMessage(generalErrorMessage); // Clear previous general errors
|
||||
|
||||
if (!selectedImageFile) {
|
||||
showErrorMessage(generalErrorMessage, "Please upload an image.");
|
||||
toggleLoadingOverlay(false); // Unlock if validation fails
|
||||
return;
|
||||
}
|
||||
if (!promptInput.value.trim()) {
|
||||
showErrorMessage(generalErrorMessage, "Please enter a prompt.");
|
||||
toggleLoadingOverlay(false); // Unlock if validation fails
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read image file as Data URL (Base64)
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(selectedImageFile);
|
||||
reader.onloadend = async () => {
|
||||
const imageBase64 = reader.result; // data:image/png;base64,...
|
||||
|
||||
const payload = {
|
||||
image_base64: imageBase64,
|
||||
prompt: promptInput.value.trim(),
|
||||
api_options: apiOptions // All the collected API options
|
||||
};
|
||||
|
||||
// Get access token and send request to PHP backend
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch(RUNWAY_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to generate video.');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'processing' && result.job_id) {
|
||||
// Start polling for job completion
|
||||
await pollJobStatus(result.job_id);
|
||||
} else if (result.status === 'success' && result.video_url) {
|
||||
// Direct success (fallback)
|
||||
videoPlayer.src = result.video_url;
|
||||
downloadVideoBtn.href = result.video_url;
|
||||
inputFormSection.classList.add('hidden');
|
||||
videoDisplaySection.classList.remove('hidden');
|
||||
} else {
|
||||
throw new Error(result.message || 'Video generation failed with an unknown error.');
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
throw new Error("Failed to read image file.");
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("Video generation error:", error);
|
||||
showErrorMessage(generalErrorMessage, `Error: ${error.message}. Please try again.`);
|
||||
} finally {
|
||||
toggleLoadingOverlay(false); // Hide loading spinner and unlock button
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Video Display & New Generation Handlers ---
|
||||
startNewBtn.addEventListener('click', resetFormAndState);
|
||||
|
||||
// --- Initial Setup ---
|
||||
initializeApiOptions(); // Set initial API option defaults
|
||||
renderApiOptions(); // Render the API options UI
|
||||
updateGenerateButtonState(); // Set initial button state (disabled)
|
||||
});
|
||||
297
src/input.css
Normal file
297
src/input.css
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Import Montserrat font from Google Fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* CSS Variables for theme colors */
|
||||
:root {
|
||||
--primary-btn-color: #fc9729;
|
||||
--primary-btn-hover-color: #df8925;
|
||||
--primary-text-color: #fc9729;
|
||||
}
|
||||
|
||||
/* Apply Montserrat to the body and ensure Tailwind's base is applied */
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Custom styles for the range slider thumb (cannot be purely inline Tailwind) */
|
||||
/* Shared across browsers for consistency */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; /* Safari, Chrome */
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 8px; /* Track height */
|
||||
background: #E5E7EB; /* Track color */
|
||||
border-radius: 9999px; /* Rounded track */
|
||||
outline: none; /* Remove default focus outline */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Thumb for Webkit (Chrome, Safari) */
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 9999px; /* Fully rounded thumb */
|
||||
background: var(--primary-btn-color); /* Thumb color (orange-600) */
|
||||
border: 2px solid white; /* White border */
|
||||
cursor: grab;
|
||||
margin-top: -4px; /* Center thumb vertically on track */
|
||||
box-shadow: 0 0 0 2px rgba(252, 151, 41, 0.3); /* Subtle focus ring */
|
||||
transition: background 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Thumb for Firefox */
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
background: var(--primary-btn-color);
|
||||
border: 2px solid white;
|
||||
cursor: grab;
|
||||
box-shadow: 0 0 0 2px rgba(252, 151, 41, 0.3);
|
||||
transition: background 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Hover state for thumb */
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: var(--primary-btn-hover-color);
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb:hover {
|
||||
background: var(--primary-btn-hover-color);
|
||||
}
|
||||
|
||||
/* Focus state for thumb */
|
||||
input[type="range"]::-webkit-slider-thumb:focus {
|
||||
box-shadow: 0 0 0 3px rgba(252, 151, 41, 0.5);
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb:focus {
|
||||
box-shadow: 0 0 0 3px rgba(252, 151, 41, 0.5);
|
||||
}
|
||||
|
||||
/* Track for Webkit */
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: #E5E7EB;
|
||||
border-radius: 9999px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Track for Firefox */
|
||||
input[type="range"]::-moz-range-track {
|
||||
background: #E5E7EB;
|
||||
border-radius: 9999px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Hide default checkbox */
|
||||
.peer {
|
||||
display: none;
|
||||
}
|
||||
/* Style for the custom toggle switch div */
|
||||
.peer + div {
|
||||
position: relative;
|
||||
width: 44px; /* w-11 */
|
||||
height: 24px; /* h-6 */
|
||||
background-color: #E5E7EB; /* gray-200 */
|
||||
border-radius: 9999px; /* rounded-full */
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
/* Style for the toggle switch circle (after pseudo-element) */
|
||||
.peer + div::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px; /* h-5, w-5 */
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #D1D5DB; /* gray-300 */
|
||||
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
}
|
||||
/* Styles when checkbox is checked */
|
||||
.peer:checked + div {
|
||||
background-color: var(--primary-btn-color);
|
||||
}
|
||||
.peer:checked + div::after {
|
||||
transform: translateX(20px); /* Move 20px to the right */
|
||||
border-color: white;
|
||||
}
|
||||
/* Focus ring for accessibility */
|
||||
.peer:focus + div {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.4); /* orange-300 with transparency */
|
||||
}
|
||||
|
||||
/* Dark Mode Toggle Button Styling */
|
||||
.dark-mode-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-btn-color);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
color: #000;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.dark-mode .dark-mode-toggle {
|
||||
color: #fff;
|
||||
background-color: var(--primary-btn-color);
|
||||
}
|
||||
|
||||
/* Dark Mode Styles */
|
||||
.dark-mode {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dark-mode body,
|
||||
body.dark-mode {
|
||||
background-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
/* Force override any remaining gray backgrounds */
|
||||
body.dark-mode.bg-gray-100 {
|
||||
background-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.dark-mode header {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.dark-mode main {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.dark-mode .bg-gray-50 {
|
||||
background-color: #2a2a2a !important;
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
.dark-mode .bg-gray-100,
|
||||
.dark-mode.bg-gray-100 {
|
||||
background-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.dark-mode .text-gray-700 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark-mode .text-gray-900 {
|
||||
color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
.dark-mode .border-gray-300 {
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
.dark-mode .border-gray-400 {
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
.dark-mode .bg-white {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
.dark-mode .bg-gray-200 {
|
||||
background-color: #404040 !important;
|
||||
}
|
||||
|
||||
.dark-mode input[type="text"],
|
||||
.dark-mode textarea,
|
||||
.dark-mode select {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #f5f5f5 !important;
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
.dark-mode input[type="text"]:focus,
|
||||
.dark-mode textarea:focus,
|
||||
.dark-mode select:focus {
|
||||
border-color: var(--primary-btn-color) !important;
|
||||
box-shadow: 0 0 0 3px rgba(252, 151, 41, 0.3) !important;
|
||||
}
|
||||
|
||||
.dark-mode input[type="range"] {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.dark-mode input[type="range"]::-webkit-slider-runnable-track {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.dark-mode input[type="range"]::-moz-range-track {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.dark-mode .border-gray-200 {
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
/* Fix button colors in dark mode */
|
||||
.dark-mode .bg-orange-600 {
|
||||
background-color: var(--primary-btn-color) !important;
|
||||
}
|
||||
|
||||
.dark-mode .hover\:bg-orange-700:hover {
|
||||
background-color: var(--primary-btn-hover-color) !important;
|
||||
}
|
||||
|
||||
/* More comprehensive dark mode overrides */
|
||||
.dark-mode .bg-white,
|
||||
.dark-mode details {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
.dark-mode .border-gray-200,
|
||||
.dark-mode .border-gray-300 {
|
||||
border-color: #444 !important;
|
||||
}
|
||||
|
||||
.dark-mode .text-gray-500,
|
||||
.dark-mode .text-gray-600 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark-mode summary {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
.dark-mode .shadow-sm,
|
||||
.dark-mode .shadow-md {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Fix image upload zone in dark mode */
|
||||
.dark-mode #image-upload-zone {
|
||||
background-color: #2a2a2a !important;
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
.dark-mode #image-upload-zone .text-gray-500 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
/* Fix text colors that should stay orange in dark mode */
|
||||
.dark-mode .text-orange-600,
|
||||
.dark-mode .text-orange-700,
|
||||
.dark-mode .text-orange-800 {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
26
tailwind.config.js
Normal file
26
tailwind.config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./public/**/*.{html,js}",
|
||||
"./public/index.html",
|
||||
"./public/js/script.js"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'orange': {
|
||||
50: 'rgba(252, 151, 41, 0.1)',
|
||||
500: '#fc9729',
|
||||
600: '#fc9729',
|
||||
700: '#df8925',
|
||||
800: '#fc9729',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
'sans': ['Montserrat', 'sans-serif'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
darkMode: 'class'
|
||||
}
|
||||
BIN
tailwindcss-macos-x64
Normal file
BIN
tailwindcss-macos-x64
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue