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