986 lines
42 KiB
JavaScript
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)
|
|
});
|