Change the video Length Silder + Video Working

This commit is contained in:
Manish Tanwar 2025-09-04 05:53:16 +05:30
commit 88c0469fe2
29 changed files with 4158 additions and 0 deletions

13
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

23
package-lock.json generated Normal file
View 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
View 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"
}
}

View 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
View 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
View 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);
}

View 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
View 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

View 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()]);
}
?>

View 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
View 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;
}

View 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
View 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">
&times;
</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
View 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
View 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
View 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

Binary file not shown.