import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; // ComfyUI RunpodDirect Extension // Version: 1.0.6 console.log('[RunpodDirect] v1.0.6'); // Track download states const downloadStates = new Map(); let downloadQueue = []; let isDownloadingAll = false; let completedDownloads = 0; let totalDownloads = 0; let downloadStartTimes = new Map(); // Format bytes to human readable function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; } // Calculate download speed function calculateSpeed(downloadId, downloaded) { const startTime = downloadStartTimes.get(downloadId); if (!startTime) return '0 MB/s'; const elapsedSeconds = (Date.now() - startTime) / 1000; if (elapsedSeconds < 1) return 'Calculating...'; const bytesPerSecond = downloaded / elapsedSeconds; return formatBytes(bytesPerSecond) + '/s'; } // Listen for server download events api.addEventListener("server_download_progress", ({ detail }) => { const { download_id, progress, downloaded, total } = detail; if (!downloadStartTimes.has(download_id)) { downloadStartTimes.set(download_id, Date.now()); } const speed = calculateSpeed(download_id, downloaded); downloadStates.set(download_id, { status: 'downloading', progress, downloaded, total, speed }); // Trigger update event for UI components window.dispatchEvent(new CustomEvent('serverDownloadUpdate', { detail: { download_id, ...downloadStates.get(download_id) } })); }); api.addEventListener("server_download_complete", ({ detail }) => { const { download_id, path, size } = detail; // Increment counter BEFORE updating UI if (isDownloadingAll) { completedDownloads++; console.log(`[RunpodDirect] Progress: ${completedDownloads}/${totalDownloads} completed`); } downloadStates.set(download_id, { status: 'completed', progress: 100, path, size }); window.dispatchEvent(new CustomEvent('serverDownloadUpdate', { detail: { download_id, ...downloadStates.get(download_id) } })); console.log(`Download completed: ${download_id} -> ${path}`); // Check if all downloads are done if (isDownloadingAll && completedDownloads >= totalDownloads) { console.log('[RunpodDirect] All downloads completed!'); isDownloadingAll = false; showRefreshPrompt(); } }); api.addEventListener("server_download_error", ({ detail }) => { const { download_id, error } = detail; // Increment counter BEFORE updating UI if (isDownloadingAll) { completedDownloads++; console.log(`[RunpodDirect] Progress: ${completedDownloads}/${totalDownloads} completed (1 error)`); } downloadStates.set(download_id, { status: 'error', error }); window.dispatchEvent(new CustomEvent('serverDownloadUpdate', { detail: { download_id, ...downloadStates.get(download_id) } })); console.error(`Download error: ${download_id} - ${error}`); // Check if all downloads are done (including failed ones) if (isDownloadingAll && completedDownloads >= totalDownloads) { console.log('[RunpodDirect] All downloads completed!'); isDownloadingAll = false; showRefreshPrompt(); } }); // Function to start a server download async function startServerDownload(url, savePath, filename, markAsQueued = false) { try { const download_id = `${savePath}/${filename}`; // Mark as queued immediately if requested (for Download All) if (markAsQueued) { downloadStates.set(download_id, { status: 'queued', progress: 0 }); window.dispatchEvent(new CustomEvent('serverDownloadUpdate', { detail: { download_id, ...downloadStates.get(download_id) } })); } const response = await api.fetchApi("/server_download/start", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ url, save_path: savePath, filename }) }); const result = await response.json(); if (response.ok) { // If not already marked as queued, set as queued now if (!markAsQueued) { downloadStates.set(download_id, { status: 'queued', progress: 0 }); window.dispatchEvent(new CustomEvent('serverDownloadUpdate', { detail: { download_id, ...downloadStates.get(download_id) } })); } return { success: true, download_id }; } else { return { success: false, error: result.error }; } } catch (error) { console.error("Failed to start download:", error); return { success: false, error: error.message }; } } // Get download status function getDownloadStatus(downloadId) { return downloadStates.get(downloadId) || null; } // Pause download async function pauseDownload(downloadId) { try { const response = await api.fetchApi("/server_download/pause", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ download_id: downloadId }) }); const result = await response.json(); return { success: response.ok, ...result }; } catch (error) { console.error("Failed to pause download:", error); return { success: false, error: error.message }; } } // Resume download async function resumeDownload(downloadId) { try { const response = await api.fetchApi("/server_download/resume", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ download_id: downloadId }) }); const result = await response.json(); return { success: response.ok, ...result }; } catch (error) { console.error("Failed to resume download:", error); return { success: false, error: error.message }; } } // Cancel download async function cancelDownload(downloadId) { try { const response = await api.fetchApi("/server_download/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ download_id: downloadId }) }); const result = await response.json(); return { success: response.ok, ...result }; } catch (error) { console.error("Failed to cancel download:", error); return { success: false, error: error.message }; } } // Process download queue - Sends all downloads to backend which handles queue management async function processDownloadQueue() { if (downloadQueue.length === 0) { console.log('[RunpodDirect] No downloads in queue'); return; } // Send all downloads to the backend (backend handles queue and priorities) console.log(`[RunpodDirect] Starting ${downloadQueue.length} downloads (backend will queue and prioritize)`); const downloadsToStart = [...downloadQueue]; downloadQueue = []; // Clear queue as we're sending all to backend // Start all downloads - backend will queue and manage priorities // Pass markAsQueued=true so buttons show "Queued" status immediately for (const download of downloadsToStart) { console.log(`[RunpodDirect] Queuing download ${download.filename}`); await startServerDownload(download.url, download.directory, download.filename, true); } console.log(`[RunpodDirect] All ${downloadsToStart.length} downloads queued on backend`); } // Show refresh prompt function showRefreshPrompt() { const dialog = document.querySelector('[role="dialog"]'); if (!dialog) return; // Find the dialog content area const dialogContent = dialog.querySelector('.p-dialog-content'); if (!dialogContent) return; // Check if prompt already exists if (document.querySelector('.server-download-refresh-prompt')) { console.log('[RunpodDirect] Refresh prompt already shown'); return; } // Create refresh prompt const refreshPrompt = document.createElement('div'); refreshPrompt.className = 'server-download-refresh-prompt'; refreshPrompt.style.cssText = ` margin-top: 20px; padding: 16px; background: #4caf50; color: white; border-radius: 8px; text-align: center; font-weight: 500; `; refreshPrompt.innerHTML = `
✅ All models downloaded successfully!
`; dialogContent.appendChild(refreshPrompt); } // Create global progress area with individual progress bars for each download function createProgressArea(listbox) { // Remove existing progress area if any const existing = document.querySelector('.server-download-progress-area'); if (existing) existing.remove(); const progressArea = document.createElement('div'); progressArea.className = 'server-download-progress-area'; progressArea.style.cssText = ` margin-top: 20px; padding: 16px; background: var(--p-content-background, #1e1e1e); border: 1px solid var(--p-content-border-color, #333); border-radius: 8px; `; progressArea.innerHTML = `
Download Progress
Overall: 0/${totalDownloads} models completed
`; listbox.parentElement.appendChild(progressArea); // Listen for updates const updateHandler = (event) => { const { download_id, status, progress, downloaded, total, speed } = event.detail; if (!isDownloadingAll) { return; } // Update overall progress const overallProgress = document.getElementById('server-download-overall-progress'); if (overallProgress) { overallProgress.textContent = `Overall: ${completedDownloads}/${totalDownloads} models completed`; } // Update or create individual progress item updateDownloadProgressItem(download_id, status, progress, downloaded, total, speed); }; window.addEventListener('serverDownloadUpdate', updateHandler); } // Update or create a progress item for a specific download function updateDownloadProgressItem(download_id, status, progress, downloaded, total, speed) { // Declare variables at function scope so they're accessible across try blocks let item = null; let container = null; const itemId = `download-item-${download_id.replace(/\//g, '-')}`; try { container = document.getElementById('server-download-items-container'); if (!container) return; item = document.getElementById(itemId); // Don't show queued items if (status === 'queued') { if (item) item.remove(); return; } // Remove completed/error items after a delay if (status === 'completed' || status === 'error') { if (item && !item.dataset.removing) { item.dataset.removing = 'true'; setTimeout(() => { try { if (item && item.parentNode) item.remove(); } catch (e) { console.error('[RunpodDirect] Error removing progress item:', e); } }, 2000); } } // Create new item if it doesn't exist if (!item) { item = document.createElement('div'); item.id = itemId; item.style.cssText = ` padding: 12px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.1); `; container.appendChild(item); } } catch (e) { console.error('[RunpodDirect] Error in updateDownloadProgressItem:', e); return; } try { // Status icon and color (no priority badge needed - downloading one at a time) let statusIcon = ''; let statusColor = '#2196F3'; if (status === 'downloading') { statusIcon = ''; statusColor = '#2196F3'; } else if (status === 'completed') { statusIcon = ''; statusColor = '#4CAF50'; } else if (status === 'error') { statusIcon = ''; statusColor = '#ef4444'; } else if (status === 'paused') { statusIcon = ''; statusColor = '#FF9800'; } const progressPercent = progress || 0; const speedText = speed || '--'; const sizeText = downloaded && total ? `${formatBytes(downloaded)} / ${formatBytes(total)}` : '--'; if (item) { item.innerHTML = `
${statusIcon}${download_id}
${progressPercent.toFixed(1)}%
Speed: ${speedText} ${sizeText}
`; } } catch (e) { console.error('[RunpodDirect] Error updating progress item HTML:', e); } } // Export functions for use in other modules window.serverDownload = { start: startServerDownload, getStatus: getDownloadStatus, states: downloadStates }; // Helper to inject buttons using MutationObserver function setupDialogObserver() { console.log('[RunpodDirect] Setting up dialog observer'); // Watch for the missing models dialog to appear const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.addedNodes.length) { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if this node or its descendants contain the missing models dialog const hasDialog = node.querySelector && ( node.querySelector('.comfy-missing-models') || node.classList?.contains('comfy-missing-models') ); if (hasDialog) { console.log('[RunpodDirect] Detected missing models dialog, injecting buttons...'); setTimeout(() => { injectServerDownloadButtons(); }, 500); } } }); } } }); // Start observing the document body for dialog additions observer.observe(document.body, { childList: true, subtree: true }); console.log('[RunpodDirect] Observer active'); } function injectServerDownloadButtons() { console.log('[RunpodDirect] injectServerDownloadButtons called'); // Find the missing models listbox const listbox = document.querySelector('.comfy-missing-models'); if (!listbox) { console.log('[RunpodDirect] Missing models listbox not found'); return; } console.log('[RunpodDirect] Found .comfy-missing-models listbox'); // Check if we already added our UI if (document.querySelector('.server-download-all-btn')) { console.log('[RunpodDirect] Buttons already injected'); return; } const listItems = listbox.querySelectorAll('.p-listbox-option'); console.log(`[RunpodDirect] Found ${listItems.length} list items`); if (listItems.length === 0) { console.log('[RunpodDirect] No list items found'); return; } // Add "Download All" button before the listbox const downloadAllContainer = document.createElement('div'); downloadAllContainer.style.cssText = 'margin-bottom: 16px; display: flex; justify-content: center;'; const downloadAllBtn = document.createElement('button'); downloadAllBtn.className = 'server-download-all-btn p-button p-component p-button-sm'; downloadAllBtn.type = 'button'; downloadAllBtn.style.cssText = 'background: #2196F3; color: white; border: none; padding: 10px 20px; font-weight: 600;'; const downloadAllLabel = document.createElement('span'); downloadAllLabel.className = 'p-button-label'; downloadAllLabel.textContent = `Download All Models to Pod (${listItems.length})`; downloadAllBtn.appendChild(downloadAllLabel); downloadAllBtn.onclick = async (e) => { e.stopPropagation(); downloadAllBtn.disabled = true; downloadAllLabel.textContent = 'Starting downloads...'; // Collect all models downloadQueue = []; const models = []; listItems.forEach((item) => { const labelElement = item.querySelector('[title]'); if (!labelElement) return; const label = labelElement.textContent.trim(); const url = labelElement.getAttribute('title'); const parts = label.split('/').map(p => p.trim()); if (parts.length !== 2) return; const directory = parts[0]; const filename = parts[1]; models.push({ url, directory, filename }); }); downloadQueue = [...models]; totalDownloads = models.length; completedDownloads = 0; isDownloadingAll = true; // Create progress area createProgressArea(listbox); // Start first download if (downloadQueue.length > 0) { processDownloadQueue(); } }; downloadAllContainer.appendChild(downloadAllBtn); listbox.parentElement.insertBefore(downloadAllContainer, listbox); listItems.forEach((item, index) => { console.log(`[RunpodDirect] Processing item ${index + 1}`); // Check if we already added the button if (item.querySelector('.server-download-btn')) { console.log(`[RunpodDirect] Item ${index + 1} already has server download button, skipping`); return; } // The HTML structure is: //
  • //
    <- main container //
    model info
    //
    //
    //
    //
  • // Find the main flex container const mainContainer = item.querySelector('.flex.flex-row.items-center.gap-2'); if (!mainContainer) { console.log('[RunpodDirect] Main flex container not found'); return; } console.log('[RunpodDirect] Found main container'); // We'll create a new div for our button (following the same pattern) const buttonWrapper = document.createElement('div'); // Get model info from the item const labelElement = item.querySelector('[title]'); if (!labelElement) { console.log('[RunpodDirect] No title element found'); return; } const label = labelElement.textContent.trim(); const url = labelElement.getAttribute('title'); console.log(`[RunpodDirect] Model: ${label}, URL: ${url}`); // Parse "checkpoints / model.safetensors" format const parts = label.split('/').map(p => p.trim()); if (parts.length !== 2) { console.log(`[RunpodDirect] Could not parse label format: ${label}`); return; } const directory = parts[0]; const filename = parts[1]; const download_id = `${directory}/${filename}`; console.log(`[RunpodDirect] Creating button for ${download_id}`); // Create server download button const serverDownloadBtn = document.createElement('button'); serverDownloadBtn.className = 'server-download-btn p-button p-component p-button-outlined p-button-sm'; serverDownloadBtn.type = 'button'; // Create button content const btnLabel = document.createElement('span'); btnLabel.className = 'p-button-label'; btnLabel.textContent = 'Download to Pod'; serverDownloadBtn.appendChild(btnLabel); // Status indicator (icon) const statusIcon = document.createElement('i'); statusIcon.style.cssText = 'margin-left: 6px; font-size: 14px; display: none;'; serverDownloadBtn.appendChild(statusIcon); // Button click handler serverDownloadBtn.onclick = async (e) => { e.stopPropagation(); serverDownloadBtn.disabled = true; btnLabel.textContent = 'Starting...'; const result = await startServerDownload(url, directory, filename); if (result.success) { btnLabel.textContent = 'Queued'; statusIcon.className = 'pi pi-clock'; statusIcon.style.display = 'inline'; statusIcon.style.color = '#FF9800'; } else { btnLabel.textContent = 'Error'; statusIcon.className = 'pi pi-times-circle'; statusIcon.style.display = 'inline'; statusIcon.style.color = '#ef4444'; console.error('Download start failed:', result.error); } }; // Listen for download updates const updateHandler = (event) => { if (event.detail.download_id === download_id) { const { status, error } = event.detail; if (status === 'queued') { serverDownloadBtn.disabled = true; btnLabel.textContent = 'Queued'; statusIcon.className = 'pi pi-clock'; statusIcon.style.display = 'inline'; statusIcon.style.color = '#FF9800'; } else if (status === 'downloading') { serverDownloadBtn.disabled = true; btnLabel.textContent = 'Downloading'; statusIcon.className = 'pi pi-spin pi-spinner'; statusIcon.style.display = 'inline'; statusIcon.style.color = '#2196F3'; } else if (status === 'completed') { btnLabel.textContent = 'Completed'; statusIcon.className = 'pi pi-check-circle'; statusIcon.style.display = 'inline'; statusIcon.style.color = '#4caf50'; serverDownloadBtn.style.borderColor = '#4caf50'; } else if (status === 'error') { btnLabel.textContent = 'Failed'; statusIcon.className = 'pi pi-times-circle'; statusIcon.style.display = 'inline'; statusIcon.style.color = '#ef4444'; serverDownloadBtn.title = error; } } }; window.addEventListener('serverDownloadUpdate', updateHandler); // Add button to wrapper div buttonWrapper.appendChild(serverDownloadBtn); // Add wrapper to main container (alongside Download and Copy URL) mainContainer.appendChild(buttonWrapper); console.log(`[RunpodDirect] Button added to main container for ${download_id}`); }); console.log('[RunpodDirect] Button injection complete'); } // Register the extension app.registerExtension({ name: "ComfyUI.RunpodDirect", async setup() { console.log("[RunpodDirect] Extension setup starting"); // Set up observer to watch for missing models dialog setupDialogObserver(); // Also try to inject immediately if dialog already exists setTimeout(() => { console.log('[RunpodDirect] Checking for existing dialog...'); injectServerDownloadButtons(); }, 1000); // Try again after a longer delay setTimeout(() => { console.log('[RunpodDirect] Second check for dialog...'); injectServerDownloadButtons(); }, 3000); console.log("[RunpodDirect] Extension setup complete"); } });