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