commit 88c0469fe259ee33d22aa378a37ee8a73d5e6974 Author: Manish Tanwar Date: Thu Sep 4 05:53:16 2025 +0530 Change the video Length Silder + Video Working diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d615581 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31a745f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..effdba7 --- /dev/null +++ b/.htaccess @@ -0,0 +1,108 @@ +# Runway Gen4 Web App - Production .htaccess Configuration + +# 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" + Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" + + +# Hide sensitive files and directories + + Order allow,deny + Deny from all + + + + Order allow,deny + Deny from all + + + + Order allow,deny + Deny from all + + +# Block access to source directories + + 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] + + +# Enable HTTPS redirect (recommended for production) + + RewriteEngine On + RewriteCond %{HTTPS} off + RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + + +# Main application routing + + 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] + + +# Enable Gzip compression for better performance + + 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 + + +# Browser Caching for static assets + + 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" + + +# Prevent access to backup files + + Order allow,deny + Deny from all + + +# Limit file upload size (adjust as needed) + + php_value upload_max_filesize 10M + php_value post_max_size 12M + php_value max_input_time 300 + php_value max_execution_time 300 + \ No newline at end of file diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..691ebea --- /dev/null +++ b/INSTALLATION.md @@ -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 + + Order allow,deny + Deny from all + + + + Order allow,deny + Deny from all + + + + Order allow,deny + Deny from all + + +# 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 + + 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 + + Order deny,allow + Deny from all + + + + Options -Indexes + AllowOverride None + Order allow,deny + Allow from all + + + # Error and Access Logs + ErrorLog ${APACHE_LOG_DIR}/yourdomain.com_error.log + CustomLog ${APACHE_LOG_DIR}/yourdomain.com_access.log combined + +``` + +## 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 + + 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 + +``` + +### 2. Browser Caching +```apache +# Leverage Browser Caching + + 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" + +``` + +## 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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..951a363 --- /dev/null +++ b/README.md @@ -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 + + DocumentRoot "/path/to/runway-video" + ServerName runway-video.local + + ``` + + **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 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..2412654 --- /dev/null +++ b/backend/.env.example @@ -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. diff --git a/backend/.htaccess b/backend/.htaccess new file mode 100644 index 0000000..29b1dd2 --- /dev/null +++ b/backend/.htaccess @@ -0,0 +1,13 @@ +Order allow,deny +Allow from all + +# Allow PHP files to be executed + + Allow from all + + +# Set proper content type for PHP files +AddType application/x-httpd-php .php + +# Enable script execution +Options +ExecCGI \ No newline at end of file diff --git a/backend/api.php b/backend/api.php new file mode 100644 index 0000000..b6d7799 --- /dev/null +++ b/backend/api.php @@ -0,0 +1,158 @@ + '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); +} diff --git a/backend/check_status.php b/backend/check_status.php new file mode 100644 index 0000000..09ce00f --- /dev/null +++ b/backend/check_status.php @@ -0,0 +1,150 @@ + '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); +} \ No newline at end of file diff --git a/backend/config.php b/backend/config.php new file mode 100644 index 0000000..dce6d94 --- /dev/null +++ b/backend/config.php @@ -0,0 +1,53 @@ + getenv('AZURE_CLIENT_ID'), + 'azure_tenant_id' => getenv('AZURE_TENANT_ID'), + 'redirect_uri' => 'http://localhost:3000/' // MAMP server redirect +]; + +echo json_encode($config); +?> \ No newline at end of file diff --git a/backend/test_config.php b/backend/test_config.php new file mode 100644 index 0000000..932f272 --- /dev/null +++ b/backend/test_config.php @@ -0,0 +1,12 @@ + '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') +]); \ No newline at end of file diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..f9013af Binary files /dev/null and b/favicon.ico differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dc70418 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9c5a5c9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/backend/.env.example b/public/backend/.env.example new file mode 100644 index 0000000..2412654 --- /dev/null +++ b/public/backend/.env.example @@ -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. diff --git a/public/backend/.htaccess b/public/backend/.htaccess new file mode 100644 index 0000000..29b1dd2 --- /dev/null +++ b/public/backend/.htaccess @@ -0,0 +1,13 @@ +Order allow,deny +Allow from all + +# Allow PHP files to be executed + + Allow from all + + +# Set proper content type for PHP files +AddType application/x-httpd-php .php + +# Enable script execution +Options +ExecCGI \ No newline at end of file diff --git a/public/backend/api.php b/public/backend/api.php new file mode 100644 index 0000000..03b9f33 --- /dev/null +++ b/public/backend/api.php @@ -0,0 +1,143 @@ + '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); +} diff --git a/public/backend/check_status.php b/public/backend/check_status.php new file mode 100644 index 0000000..09ce00f --- /dev/null +++ b/public/backend/check_status.php @@ -0,0 +1,150 @@ + '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); +} \ No newline at end of file diff --git a/public/backend/config.php b/public/backend/config.php new file mode 100644 index 0000000..dce6d94 --- /dev/null +++ b/public/backend/config.php @@ -0,0 +1,53 @@ + $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()]); +} +?> \ No newline at end of file diff --git a/public/backend/test_config.php b/public/backend/test_config.php new file mode 100644 index 0000000..932f272 --- /dev/null +++ b/public/backend/test_config.php @@ -0,0 +1,12 @@ + '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') +]); \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..d856b45 --- /dev/null +++ b/public/css/style.css @@ -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; +} diff --git a/public/css/tailwind-build.css b/public/css/tailwind-build.css new file mode 100644 index 0000000..0e7b70f --- /dev/null +++ b/public/css/tailwind-build.css @@ -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; + } +} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..d6fb72a --- /dev/null +++ b/public/index.html @@ -0,0 +1,156 @@ + + + + + + Runway Gen4 Web App + + + + + + + + + + + + + + +
+ + +
+
+ + +
+

+ Runway Gen4 Web App +

+
+ + +
+ +
+
+ +
+ +
+ + +
+ + + +

Drag & Drop Image Here or

+

Click to Upload

+
+ +
+ + +
+ + +
+
+ + +
+ +
+ + API Options + + + + + + +
+ +
+
+
+
+ + + + + + +
+ + + +
+ + + + + + + + diff --git a/public/js/script.js b/public/js/script.js new file mode 100644 index 0000000..640c150 --- /dev/null +++ b/public/js/script.js @@ -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 | ← Back to Portal'; + 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. ← Back to Portal'; + 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 = ` ${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} 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 = ` +
+ + + ${key === 'seed' ? `` : ''} +
+ `; + break; + case 'boolean': + const checkedAttr = currentValue ? 'checked' : ''; + html = ` +
+ + +
+ `; + break; + case 'string': + html = ` +
+ + +
+ `; + 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 ``; + }).join(''); + html = ` +
+ + +
+ `; + 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 = ` +
+ + ${param.label} + + + + + + +
+ ${nestedPropertiesHtml} +
+
+ `; + 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 = ` 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 = ` ${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 = ' 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) +}); diff --git a/src/input.css b/src/input.css new file mode 100644 index 0000000..fba2d27 --- /dev/null +++ b/src/input.css @@ -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; +} \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..5530e10 --- /dev/null +++ b/tailwind.config.js @@ -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' +} \ No newline at end of file diff --git a/tailwindcss-macos-x64 b/tailwindcss-macos-x64 new file mode 100644 index 0000000..49afae9 Binary files /dev/null and b/tailwindcss-macos-x64 differ