runway-video/public/js/script.js
2025-09-09 09:38:04 -05:00

986 lines
42 KiB
JavaScript

// 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 {
// Always start by showing the login screen
showLoginScreen();
// 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);
// Show main app since user just authenticated
updateAuthUI();
return;
}
// Don't automatically log in - always show login screen first
currentUser = null;
} catch (error) {
console.error('MSAL initialization failed:', error);
showAuthError();
}
}
// Sign In function
async function signIn() {
// Show loading state immediately
showLoginLoading();
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));
// Hide loading state and update UI
hideLoginLoading();
updateAuthUI();
} catch (error) {
console.error("Login failed:", error);
hideLoginLoading();
// Could show error message on login screen if needed
const generalErrorMessage = document.getElementById('general-error-message');
if (generalErrorMessage) {
showErrorMessage(generalErrorMessage, "Login failed: " + error.message);
}
}
}
// Sign Out function - return to login screen
async function signOut() {
// Clear any stored tokens
localStorage.removeItem('runway_access_token');
localStorage.removeItem('runway_auth_state');
// Clear current user state
currentUser = null;
// If MSAL is initialized, logout from Microsoft silently
if (msalInstance && msalInstance.getAllAccounts().length > 0) {
try {
await msalInstance.logoutSilent();
} catch (error) {
console.log('Silent logout failed, continuing with local logout');
}
}
// Update UI to show login screen
updateAuthUI();
}
// Show Login Screen
function showLoginScreen() {
const loginScreen = document.getElementById('login-screen-container');
const mainApp = document.getElementById('main-app-container');
const loginLoading = document.getElementById('login-loading');
if (loginScreen) loginScreen.classList.remove('hidden');
if (mainApp) mainApp.classList.add('hidden');
if (loginLoading) loginLoading.classList.add('hidden');
}
// Show Main App
function showMainApp() {
const loginScreen = document.getElementById('login-screen-container');
const mainApp = document.getElementById('main-app-container');
if (loginScreen) loginScreen.classList.add('hidden');
if (mainApp) mainApp.classList.remove('hidden');
}
// Show Login Loading State
function showLoginLoading() {
const loginLoading = document.getElementById('login-loading');
const loginBtn = document.getElementById('login-btn');
if (loginBtn) loginBtn.classList.add('hidden');
if (loginLoading) loginLoading.classList.remove('hidden');
}
// Hide Login Loading State
function hideLoginLoading() {
const loginLoading = document.getElementById('login-loading');
const loginBtn = document.getElementById('login-btn');
if (loginLoading) loginLoading.classList.add('hidden');
if (loginBtn) loginBtn.classList.remove('hidden');
}
// Update Authentication UI
function updateAuthUI() {
const userInfo = document.getElementById('user-info');
if (currentUser) {
// User is authenticated - show main app
showMainApp();
if (userInfo) {
userInfo.textContent = `Welcome, ${currentUser.name || currentUser.username}`;
userInfo.classList.remove('text-red-600');
userInfo.classList.add('text-green-600');
}
} else {
// User is not authenticated - show login screen
showLoginScreen();
}
// Update generate button state only if the function exists
if (typeof updateGenerateButtonState === 'function') {
updateGenerateButtonState();
}
}
// Show authentication error
function showAuthError() {
// Show login screen with error state
showLoginScreen();
hideLoginLoading();
}
// Navigate back to portal
function goBackToPortal() {
window.location.href = 'http://localhost:8888/';
}
// Get Access Token
async function getAccessToken() {
// First, try to use stored token from landing page
const storedToken = localStorage.getItem('runway_access_token');
if (storedToken) {
const tokenData = JSON.parse(storedToken);
if (Date.now() < tokenData.expires_at) {
return tokenData.access_token;
} else {
// Token expired, remove it
localStorage.removeItem('runway_access_token');
}
}
// If no valid stored token, use MSAL
if (!msalInstance || !currentUser) {
throw new Error('User not authenticated');
}
try {
const tokenRequest = {
scopes: ["User.Read"],
account: currentUser
};
const response = await msalInstance.acquireTokenSilent(tokenRequest);
return response.accessToken;
} catch (error) {
// If silent token acquisition fails, try interactive
try {
const response = await msalInstance.acquireTokenPopup(tokenRequest);
return response.accessToken;
} catch (interactiveError) {
console.error('Token acquisition failed:', interactiveError);
throw interactiveError;
}
}
}
/**
* Updates the state of the generate button (disabled/enabled, loading state).
*/
function updateGenerateButtonState() {
const generateVideoBtn = document.getElementById('generate-video-btn');
const promptInput = document.getElementById('prompt-input');
const hasImage = typeof selectedImageFile !== 'undefined' && selectedImageFile !== null;
const hasPrompt = promptInput && promptInput.value.trim().length > 0;
const isAuthenticated = currentUser !== null;
const canGenerate = hasImage && hasPrompt && isAuthenticated;
// Check if generateVideoBtn exists before accessing it
if (!generateVideoBtn) {
return;
}
// Don't update if we're currently processing (loading overlay visible)
const loadingOverlay = document.getElementById('loading-overlay');
if (loadingOverlay && !loadingOverlay.classList.contains('hidden')) {
return; // Keep current processing state
}
generateVideoBtn.disabled = !canGenerate;
if (canGenerate) {
generateVideoBtn.classList.remove('bg-gray-400', 'bg-gray-300', 'bg-green-500', 'cursor-not-allowed', 'opacity-60', 'pointer-events-none', 'text-gray-600');
generateVideoBtn.classList.add('bg-orange-600', 'hover:bg-orange-700', 'text-white');
} else {
generateVideoBtn.classList.add('bg-gray-400', 'cursor-not-allowed');
generateVideoBtn.classList.remove('bg-orange-600', 'hover:bg-orange-700', 'text-white', 'bg-gray-300', 'bg-green-500', 'opacity-60', 'pointer-events-none', 'text-gray-600');
}
// Only update text if not currently loading
generateVideoBtn.innerHTML = 'Generate Video';
generateVideoBtn.setAttribute('aria-label', 'Generate Video');
}
document.addEventListener('DOMContentLoaded', async () => {
// Initialize MSAL first
await initializeMSAL();
// Dark mode toggle functionality
const darkModeEnabled = localStorage.getItem('darkMode') === 'enabled';
if (darkModeEnabled) {
document.body.classList.add('dark-mode');
document.getElementById('lightModeIcon').style.display = 'none';
document.getElementById('darkModeIcon').style.display = 'block';
}
// Toggle dark mode when button is clicked
document.getElementById('darkModeToggle').addEventListener('click', function() {
document.body.classList.toggle('dark-mode');
// Save preference and toggle icons
if (document.body.classList.contains('dark-mode')) {
localStorage.setItem('darkMode', 'enabled');
document.getElementById('lightModeIcon').style.display = 'none';
document.getElementById('darkModeIcon').style.display = 'block';
} else {
localStorage.setItem('darkMode', 'disabled');
document.getElementById('lightModeIcon').style.display = 'block';
document.getElementById('darkModeIcon').style.display = 'none';
}
});
// Auth event listeners
document.getElementById('login-btn').addEventListener('click', signIn);
document.getElementById('logout-btn').addEventListener('click', signOut);
// --- DOM Elements ---
const imageInput = document.getElementById('image-input');
const imageUploadZone = document.getElementById('image-upload-zone');
const imagePreviewContainer = document.getElementById('image-preview-container');
const imagePreview = document.getElementById('image-preview');
const imageUploadPlaceholder = document.getElementById('image-upload-placeholder');
const removeImageBtn = document.getElementById('remove-image-btn');
const imageErrorMessage = document.getElementById('image-error-message');
const promptInput = document.getElementById('prompt-input');
const dynamicOptionsContainer = document.getElementById('dynamic-options-container');
const generateVideoBtn = document.getElementById('generate-video-btn');
const generalErrorMessage = document.getElementById('general-error-message');
const generalErrorText = document.getElementById('general-error-text');
const videoDisplaySection = document.getElementById('video-display-section');
const inputFormSection = document.getElementById('input-form-section');
const videoPlayer = document.getElementById('video-player');
const downloadVideoBtn = document.getElementById('download-video-btn');
const startNewBtn = document.getElementById('start-new-btn');
// --- State Variables ---
let apiOptions = {}; // Will store the current values of API options
// CORRECTED: Your MAMP project folder 'Runway-video' should be your Document Root.
// The path to the backend API is then relative to this root.
const RUNWAY_API_ENDPOINT = 'backend/api.php'; // Your PHP backend endpoint
// Runway API supported parameters only
const runwayGen4APISchema = {
"duration": { "type": "number", "label": "Video Duration (seconds)", "min": 5, "max": 10, "step": 5, "default": 5 },
"ratio": {
"type": "enum",
"label": "Aspect Ratio",
"options": [
{value: "1280:720", label: "1280:720 (Landscape 16:9)"},
{value: "720:1280", label: "720:1280 (Portrait 9:16)"},
{value: "1104:832", label: "1104:832 (Landscape 4:3)"},
{value: "832:1104", label: "832:1104 (Portrait 3:4)"},
{value: "960:960", label: "960:960 (Square 1:1)"},
{value: "1584:672", label: "1584:672 (Ultrawide)"}
],
"default": "1280:720"
},
"seed": { "type": "number", "label": "Seed (optional)", "min": 0, "max": 999999, "step": 1, "default": null }
};
// --- Utility Functions ---
/**
* Shows a specific error message element.
* @param {HTMLElement} element The HTML element to show (e.g., imageErrorMessage, generalErrorMessage).
* @param {string} message The error message text.
*/
function showErrorMessage(element, message) {
element.textContent = message;
element.classList.remove('hidden');
}
/**
* Hides a specific error message element.
* @param {HTMLElement} element The HTML element to hide.
*/
function hideErrorMessage(element) {
element.classList.add('hidden');
element.textContent = '';
}
/**
* Displays or hides the loading overlay and updates button state.
* @param {boolean} show True to show, false to hide.
* @param {string} message Optional custom message for the button
*/
function toggleLoadingOverlay(show, message = 'Generating Video...') {
const loadingOverlay = document.getElementById('loading-overlay');
const generateVideoBtn = document.getElementById('generate-video-btn');
if (show) {
if (loadingOverlay) {
loadingOverlay.classList.remove('hidden');
}
if (generateVideoBtn) {
generateVideoBtn.disabled = true;
generateVideoBtn.classList.remove('bg-orange-600', 'hover:bg-orange-700', 'bg-gray-400', 'bg-gray-300', 'bg-green-500');
generateVideoBtn.classList.add('cursor-not-allowed', 'pointer-events-none', 'text-white');
generateVideoBtn.style.backgroundColor = '#10b981'; // Force green
generateVideoBtn.style.borderColor = '#10b981';
generateVideoBtn.innerHTML = `<i class="fas fa-spinner fa-spin -ml-1 mr-3"></i> ${message}`;
generateVideoBtn.setAttribute('aria-label', message);
}
} else {
if (loadingOverlay) {
loadingOverlay.classList.add('hidden');
}
if (generateVideoBtn) {
generateVideoBtn.classList.remove('cursor-not-allowed', 'pointer-events-none');
generateVideoBtn.style.backgroundColor = ''; // Clear inline style
generateVideoBtn.style.borderColor = '';
updateGenerateButtonState(); // Re-evaluate button state
}
}
}
/**
* Clears all form inputs and resets state to allow a new generation.
*/
function resetFormAndState() {
selectedImageFile = null;
imageInput.value = ''; // Clear file input
imagePreviewContainer.classList.add('hidden');
imageUploadPlaceholder.classList.remove('hidden');
imagePreview.src = '#';
hideErrorMessage(imageErrorMessage);
hideErrorMessage(generalErrorMessage);
promptInput.value = '';
// Reset API options to their defaults
initializeApiOptions();
renderApiOptions(); // Re-render options to show defaults
videoDisplaySection.classList.add('hidden');
videoPlayer.src = '#';
updateGenerateButtonState();
}
// --- Image Upload Handlers ---
/**
* Resizes and compresses an image to stay under 5MB base64 limit
* @param {File} file The image file object
* @param {number} maxWidth Maximum width
* @param {number} maxHeight Maximum height
* @returns {Promise<File>} Resized and compressed image file
*/
function resizeImage(file, maxWidth = 1584, maxHeight = 1280) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = async function() {
// Calculate new dimensions while maintaining aspect ratio
let { width, height } = img;
if (width > maxWidth || height > maxHeight) {
const aspectRatio = width / height;
if (width > height) {
width = Math.min(width, maxWidth);
height = width / aspectRatio;
} else {
height = Math.min(height, maxHeight);
width = height * aspectRatio;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
// Try different quality levels to stay under 5MB base64 limit
const targetSize = 3.5 * 1024 * 1024; // 3.5MB to account for base64 overhead
let quality = 0.9;
let attempts = 0;
const maxAttempts = 10;
const tryCompress = () => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to compress image'));
return;
}
// Check if we need to compress further
if (blob.size > targetSize && attempts < maxAttempts) {
quality -= 0.1;
attempts++;
tryCompress();
return;
}
const resizedFile = new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now()
});
console.log(`Image compressed: ${(blob.size / 1024 / 1024).toFixed(2)}MB, quality: ${quality.toFixed(1)}`);
resolve(resizedFile);
}, 'image/jpeg', quality);
};
tryCompress();
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = URL.createObjectURL(file);
});
}
/**
* Processes a selected image file for preview and upload.
* @param {File} file The image file object.
*/
async function processImageFile(file) {
if (file && file.type.startsWith('image/')) {
try {
// Resize large images to fit Runway's requirements
const resizedFile = await resizeImage(file);
selectedImageFile = resizedFile;
imagePreview.src = URL.createObjectURL(resizedFile);
imagePreviewContainer.classList.remove('hidden');
imageUploadPlaceholder.classList.add('hidden');
hideErrorMessage(imageErrorMessage);
updateGenerateButtonState();
} catch (error) {
showErrorMessage(imageErrorMessage, "Error processing image. Please try a different image.");
console.error('Image processing error:', error);
}
} else {
selectedImageFile = null;
imagePreview.src = '#';
imagePreviewContainer.classList.add('hidden');
imageUploadPlaceholder.classList.remove('hidden');
showErrorMessage(imageErrorMessage, "Invalid file type. Please upload an image (JPG, PNG).");
updateGenerateButtonState();
}
}
imageInput.addEventListener('change', async (event) => {
await processImageFile(event.target.files[0]);
});
imageUploadZone.addEventListener('dragover', (event) => {
event.preventDefault();
imageUploadZone.classList.add('border-orange-500', 'bg-orange-50'); // Add visual feedback for drag-over
});
imageUploadZone.addEventListener('dragleave', () => {
imageUploadZone.classList.remove('border-orange-500', 'bg-orange-50'); // Remove feedback
});
imageUploadZone.addEventListener('drop', async (event) => {
event.preventDefault();
imageUploadZone.classList.remove('border-orange-500', 'bg-orange-50'); // Remove feedback
await processImageFile(event.dataTransfer.files[0]);
});
imageUploadZone.addEventListener('click', () => {
imageInput.click(); // Trigger hidden file input click
});
removeImageBtn.addEventListener('click', (event) => {
event.stopPropagation(); // Prevent triggering imageUploadZone's click
selectedImageFile = null;
imageInput.value = '';
imagePreview.src = '#';
imagePreviewContainer.classList.add('hidden');
imageUploadPlaceholder.classList.remove('hidden');
hideErrorMessage(imageErrorMessage);
updateGenerateButtonState();
});
// --- Prompt Input Handler ---
if (promptInput) {
promptInput.addEventListener('input', updateGenerateButtonState);
}
// --- API Options Panel Functions ---
/**
* Initializes the global apiOptions object with default values from the schema.
*/
function initializeApiOptions() {
apiOptions = {};
for (const key in runwayGen4APISchema) {
const param = runwayGen4APISchema[key];
if (param.type === 'object') {
apiOptions[key] = {};
for (const prop in param.properties) {
apiOptions[key][prop] = param.properties[prop].default;
}
} else {
apiOptions[key] = param.default;
}
}
}
/**
* Handles changes to API option controls and updates the global apiOptions state.
* @param {string} key The key of the API option.
* @param {*} value The new value of the option.
* @param {string|null} parentKey The key of the parent object if nested.
*/
function handleApiOptionChange(key, value, parentKey = null) {
if (parentKey) {
apiOptions[parentKey][key] = value;
} else {
apiOptions[key] = value;
}
// Re-render only the affected control or potentially the whole panel for simplicity in this example
// For complex UIs, a more granular update mechanism might be used.
renderApiOptions();
}
/**
* Renders a single API option control based on its type and current value.
* @param {string} key The key of the API option.
* @param {object} param The parameter definition from the schema.
* @param {string|null} parentKey The key of the parent object if nested.
* @returns {string} HTML string for the control.
*/
function renderOptionControl(key, param, parentKey = null) {
const id = parentKey ? `${parentKey}-${key}` : key;
const currentValue = parentKey ? apiOptions[parentKey][key] : apiOptions[key];
let html = '';
switch (param.type) {
case 'number':
const displayValue = currentValue !== null ? currentValue : 'Random';
const inputValue = currentValue !== null ? currentValue : param.min;
html = `
<div class="mb-4">
<label for="${id}" class="block text-sm font-medium text-gray-700 mb-1">
${param.label}: <span class="font-semibold" id="value-${id}">${displayValue}</span>
</label>
<input type="range" id="${id}" min="${param.min}" max="${param.max}" step="${param.step || 1}" value="${inputValue}"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer range-thumb-indigo">
${key === 'seed' ? `<label class="flex items-center mt-2"><input type="checkbox" id="${id}-random" ${currentValue === null ? 'checked' : ''} class="mr-2"> Use random seed</label>` : ''}
</div>
`;
break;
case 'boolean':
const checkedAttr = currentValue ? 'checked' : '';
html = `
<div class="flex items-center justify-between mb-4">
<label for="${id}" class="text-sm font-medium text-gray-700 cursor-pointer">
${param.label}
</label>
<label for="${id}" class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="${id}" ${checkedAttr} class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-orange-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:border-gray-300 after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-600"></div>
</label>
</div>
`;
break;
case 'string':
html = `
<div class="mb-4">
<label for="${id}" class="block text-sm font-medium text-gray-700 mb-1">
${param.label}
</label>
<input type="text" id="${id}" value="${currentValue}" placeholder="${param.placeholder || ''}"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-orange-500 focus:border-orange-500 text-sm">
</div>
`;
break;
case 'enum':
const optionsHtml = param.options.map(option => {
const value = typeof option === 'object' ? option.value : option;
const label = typeof option === 'object' ? option.label : option;
return `<option value="${value}" ${currentValue === value ? 'selected' : ''}>${label}</option>`;
}).join('');
html = `
<div class="mb-4">
<label for="${id}" class="block text-sm font-medium text-gray-700 mb-1">
${param.label}
</label>
<select id="${id}"
class="w-full p-2 border border-gray-300 rounded-md bg-white focus:ring-orange-500 focus:border-orange-500 text-sm">
${optionsHtml}
</select>
</div>
`;
break;
case 'object':
// For nested objects, recursively call renderOptionControl for its properties
const nestedPropertiesHtml = Object.entries(param.properties).map(([propKey, propParam]) =>
renderOptionControl(propKey, propParam, key)
).join('');
// Outer HTML for the collapsible details element for the object
html = `
<details class="bg-white p-3 rounded-lg shadow-sm mb-4 border border-gray-200 group">
<summary class="flex justify-between items-center cursor-pointer font-medium text-orange-700">
${param.label}
<span class="transform transition-transform duration-200 group-open:rotate-90">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</span>
</summary>
<div class="mt-3 border-t border-gray-200 pt-3">
${nestedPropertiesHtml}
</div>
</details>
`;
break;
default:
console.warn(`Unknown parameter type: ${param.type}`);
break;
}
return html;
}
/**
* Renders all API options into the dynamicOptionsContainer based on the schema and current values.
* Attaches event listeners to the dynamically created elements.
*/
function renderApiOptions() {
let allOptionsHtml = '';
for (const key in runwayGen4APISchema) {
allOptionsHtml += renderOptionControl(key, runwayGen4APISchema[key]);
}
dynamicOptionsContainer.innerHTML = allOptionsHtml;
// Attach event listeners to newly created elements
dynamicOptionsContainer.querySelectorAll('input, select').forEach(control => {
const idParts = control.id.split('-');
let key = idParts[0];
let parentKey = null;
// Handle seed random checkbox specially
if (control.id === 'seed-random') {
control.addEventListener('change', (event) => {
const seedSlider = document.getElementById('seed');
const valueSpan = document.getElementById('value-seed');
if (event.target.checked) {
handleApiOptionChange('seed', null);
if (valueSpan) valueSpan.textContent = 'Random';
seedSlider.disabled = true;
} else {
const seedValue = parseInt(seedSlider.value);
handleApiOptionChange('seed', seedValue);
if (valueSpan) valueSpan.textContent = seedValue;
seedSlider.disabled = false;
}
});
return;
}
if (idParts.length > 1) { // If it's a nested key like "camera_motion-pan"
parentKey = key;
key = idParts[1];
}
const param = parentKey ? runwayGen4APISchema[parentKey].properties[key] : runwayGen4APISchema[key];
if (control.type === 'range' || control.type === 'text' || control.tagName === 'SELECT') {
if (control.type === 'range' && key === 'duration') {
// Special handling for duration slider - snap to 5 or 10
control.addEventListener('input', (event) => {
let value = parseFloat(event.target.value);
let snappedValue = value < 7.5 ? 5 : 10;
// Update display value immediately
const valueSpan = document.getElementById(`value-${control.id}`);
if (valueSpan) valueSpan.textContent = snappedValue;
handleApiOptionChange(key, snappedValue, parentKey);
});
control.addEventListener('change', (event) => {
let value = parseFloat(event.target.value);
let snappedValue = value < 7.5 ? 5 : 10;
// Snap the actual slider position
event.target.value = snappedValue;
const valueSpan = document.getElementById(`value-${control.id}`);
if (valueSpan) valueSpan.textContent = snappedValue;
handleApiOptionChange(key, snappedValue, parentKey);
});
} else {
control.addEventListener('input', (event) => {
let value = event.target.value;
if (param.type === 'number') {
value = parseFloat(value);
// Update the displayed value next to the slider
const valueSpan = document.getElementById(`value-${control.id}`);
if (valueSpan) valueSpan.textContent = value;
}
handleApiOptionChange(key, value, parentKey);
});
}
} else if (control.type === 'checkbox') {
control.addEventListener('change', (event) => {
handleApiOptionChange(key, event.target.checked, parentKey);
});
}
});
}
// --- Job Polling Logic ---
/**
* Polls Runway API for job completion status
* @param {string} jobId The job ID to poll
*/
async function pollJobStatus(jobId) {
const maxAttempts = 60; // Poll for up to 5 minutes
const pollInterval = 5000; // 5 seconds
let attempts = 0;
while (attempts < maxAttempts) {
try {
const response = await fetch(`backend/check_status.php?job_id=${jobId}`);
if (!response.ok) {
throw new Error('Failed to check job status');
}
const result = await response.json();
if (result.status === 'SUCCEEDED' || result.status === 'completed') {
console.log('Task completed, full response:', result);
if (result.video_url) {
videoPlayer.src = result.video_url;
downloadVideoBtn.href = result.video_url;
videoDisplaySection.classList.remove('hidden');
toggleLoadingOverlay(false); // Unlock button on success
return; // Success!
} else {
console.error('No video URL found in completed response:', result.raw_response);
throw new Error('Video completed but no URL provided. Check console for response details.');
}
} else if (result.status === 'FAILED' || result.status === 'failed') {
throw new Error('Video generation failed on server');
}
// Update loading message with progress if available
if (result.progress) {
const progressPercent = Math.round(result.progress * 100);
const loadingText = document.querySelector('#loading-overlay p');
if (loadingText) {
loadingText.textContent = `Generating video... ${progressPercent}%`;
}
// Update button text with progress
const generateVideoBtn = document.getElementById('generate-video-btn');
if (generateVideoBtn) {
generateVideoBtn.innerHTML = `<i class="fas fa-spinner fa-spin -ml-1 mr-3"></i> Processing... ${progressPercent}%`;
}
} else {
// Show different messages based on status
let statusMessage = 'Processing...';
if (result.status === 'RUNNING') {
statusMessage = 'Generating Video...';
} else if (result.status === 'PENDING') {
statusMessage = 'Queued...';
}
// Ensure button stays green during processing
const generateVideoBtn = document.getElementById('generate-video-btn');
if (generateVideoBtn) {
generateVideoBtn.classList.remove('bg-orange-600', 'bg-gray-400', 'bg-gray-300', 'bg-green-500');
generateVideoBtn.classList.add('text-white', 'pointer-events-none');
generateVideoBtn.style.backgroundColor = '#10b981'; // Force green
generateVideoBtn.innerHTML = `<i class="fas fa-spinner fa-spin -ml-1 mr-3"></i> ${statusMessage}`;
}
}
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, pollInterval));
attempts++;
} catch (error) {
console.error('Polling error:', error);
throw new Error(`Failed to check video generation status: ${error.message}`);
}
}
throw new Error('Video generation timed out. Please try again.');
}
// --- Video Generation Logic ---
if (generateVideoBtn) {
generateVideoBtn.addEventListener('click', async () => {
// Immediately lock the button to prevent double clicks
if (generateVideoBtn.disabled) {
return;
}
// Lock button immediately with green processing color
generateVideoBtn.disabled = true;
generateVideoBtn.classList.remove('bg-orange-600', 'hover:bg-orange-700', 'text-white');
generateVideoBtn.classList.add('cursor-not-allowed', 'text-white', 'pointer-events-none');
generateVideoBtn.style.backgroundColor = '#10b981'; // Force green
generateVideoBtn.style.borderColor = '#10b981';
generateVideoBtn.innerHTML = '<i class="fas fa-spinner fa-spin -ml-1 mr-3"></i> Starting...';
toggleLoadingOverlay(true); // Show loading spinner and lock button
hideErrorMessage(generalErrorMessage); // Clear previous general errors
if (!promptInput.value.trim()) {
showErrorMessage(generalErrorMessage, "Please enter a prompt.");
toggleLoadingOverlay(false); // Unlock if validation fails
return;
}
if (!selectedImageFile) {
showErrorMessage(generalErrorMessage, "Please upload an image.");
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)
});