import { app } from "../../scripts/app.js"; import { PromptExecutionError, api } from "../../scripts/api.js"; const REMOTE_PROPERTY = "is_modal_remote"; const MODAL_ROUTE = "/modal/queue_prompt"; const MODAL_ANALYZE_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/analyze_remote_nodes"); const MODAL_PROGRESS_STATE_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/progress_state"); const COMFY_QUEUE_ROUTE = "/queue"; const COMFY_HISTORY_ROUTE = "/history"; const INTERNAL_NODE_PREFIX = "ModalUniversalExecutor"; const IDLE_BORDER_COLOR = "#1d9bf0"; const SETUP_BORDER_COLOR = "#f59e0b"; const STARTING_BORDER_COLOR = "#eab308"; const FINALIZING_BORDER_COLOR = "#3b82f6"; const CANCELLING_BORDER_COLOR = "#fb7185"; const READY_ACTIVE_COMPONENT_BORDER_COLOR = "#22c55e"; const READY_INACTIVE_COMPONENT_BORDER_COLOR = "#166534"; const ACTIVE_BORDER_COLOR = "#a855f7"; const COMPLETE_BORDER_COLOR = "#004FA4"; const COMPLETE_FILL_COLOR = "#001C71"; const FINALIZING_NODE_BORDER_COLOR = "#00358A"; const ERROR_BORDER_COLOR = "#ef4444"; const STATE_SETUP = "setup"; const STATE_STARTING = "starting"; const STATE_WAITING = "waiting"; const STATE_FINALIZING = "finalizing"; const STATE_CANCELLING = "cancelling"; const EXECUTION_PHASE = "executing"; const STATE_READY = "ready"; const STATE_ACTIVE = "active"; const STATE_COMPLETE = "complete"; const STATE_ERROR = "error"; const ERROR_CLEAR_DELAY_MS = 5000; const TERMINAL_PROMPT_RETENTION_MS = 60000; const PROGRESS_FADE_MS = 900; const REFOCUS_STALE_PROMPT_GRACE_MS = 30000; const modalNodeStates = new Map(); const modalNodeProgress = new Map(); const modalNodeProgressLanes = new Map(); const modalNodeBatchProgress = new Map(); const modalNodeCachedStates = new Map(); const modalNodeClearTimers = new Map(); const modalPromptStates = new Map(); const modalTerminalPromptStates = new Map(); const modalCancellingPromptIds = new Set(); const modalQueuedPromptIds = new Set(); const syntheticPromptUiStates = new Map(); const modalGlobalStatusStates = new Map(); let animationFrameHandle = null; let modalGlobalStatusElement = null; let modalVisibilityRefreshInFlight = null; /** * Return whether a node should show the Modal toggle. * @param {LGraphNode} node * @returns {boolean} */ function isEligibleNode(node) { return Boolean(node?.comfyClass) && !String(node.comfyClass).startsWith(INTERNAL_NODE_PREFIX); } /** * Read the remote execution flag from node properties. * @param {LGraphNode} node * @returns {boolean} */ function isRemoteNode(node) { return Boolean(node?.properties?.[REMOTE_PROPERTY]); } /** * Return a websocket event detail payload. * @param {CustomEvent | object} event * @returns {Record} */ function eventDetail(event) { return event?.detail ?? event ?? {}; } /** * Build a prompt id for queue requests. * @returns {string} */ function createPromptId() { if (globalThis.crypto?.randomUUID) { return globalThis.crypto.randomUUID(); } return `modal-${Date.now()}-${Math.random().toString(16).slice(2)}`; } /** * Return the current monotonic clock value for UI state ordering. * @returns {number} */ function nowMs() { return Date.now(); } /** * Remove terminal prompt markers after their stale-event guard window expires. */ function pruneTerminalPromptStates() { const cutoffMs = nowMs() - TERMINAL_PROMPT_RETENTION_MS; for (const [promptId, terminalState] of modalTerminalPromptStates.entries()) { if ((terminalState?.terminalAt ?? 0) < cutoffMs) { modalTerminalPromptStates.delete(promptId); } } } /** * Return whether late events for a prompt should be ignored. * @param {string} promptId * @returns {boolean} */ function isPromptTerminal(promptId) { if (!promptId) { return false; } pruneTerminalPromptStates(); return modalTerminalPromptStates.has(promptId); } /** * Mark a prompt as terminal so late remote events cannot resurrect stale UI state. * @param {string} promptId * @param {string} phase */ function markPromptTerminal(promptId, phase) { if (!promptId) { return; } modalCancellingPromptIds.delete(promptId); modalTerminalPromptStates.set(promptId, { phase, terminalAt: nowMs(), }); } /** * Allow a newly queued prompt id to receive state updates. * @param {string} promptId */ function clearPromptTerminal(promptId) { if (!promptId) { return; } modalTerminalPromptStates.delete(promptId); modalCancellingPromptIds.delete(promptId); clearPromptQueued(promptId); } /** * Return whether one prompt has a user-requested cancellation in progress. * @param {string} promptId * @returns {boolean} */ function isPromptCancelling(promptId) { return Boolean(promptId) && modalCancellingPromptIds.has(promptId); } /** * Read a node id as a stable string. * @param {LGraphNode} node * @returns {string} */ function nodeId(node) { return String(node?.id ?? ""); } /** * Ensure the global Modal execution status badge exists. * @returns {HTMLDivElement | null} */ function ensureGlobalStatusElement() { if (typeof document === "undefined") { return null; } if (modalGlobalStatusElement?.isConnected) { return modalGlobalStatusElement; } const element = document.createElement("div"); element.id = "comfy-modal-global-status"; element.style.position = "fixed"; element.style.top = "14px"; element.style.right = "18px"; element.style.zIndex = "9999"; element.style.display = "none"; element.style.alignItems = "center"; element.style.gap = "10px"; element.style.padding = "10px 14px"; element.style.borderRadius = "999px"; element.style.border = "1px solid rgba(255, 255, 255, 0.16)"; element.style.background = "rgba(15, 23, 42, 0.94)"; element.style.boxShadow = "0 10px 30px rgba(0, 0, 0, 0.28)"; element.style.color = "#f8fafc"; element.style.fontFamily = "ui-sans-serif, system-ui, sans-serif"; element.style.fontSize = "13px"; element.style.fontWeight = "600"; element.style.pointerEvents = "none"; element.innerHTML = ''; document.body.appendChild(element); modalGlobalStatusElement = element; return element; } /** * Remove orphaned global status entries that no longer have any live prompt state. */ function pruneGlobalStatusStates() { pruneTerminalPromptStates(); for (const promptId of Array.from(modalGlobalStatusStates.keys())) { if (isPromptTerminal(promptId)) { modalGlobalStatusStates.delete(promptId); continue; } if (modalPromptStates.has(promptId) || syntheticPromptUiStates.has(promptId)) { continue; } modalGlobalStatusStates.delete(promptId); } } /** * Return all current node states for a prompt id. * @param {string} promptId * @returns {Array<{ phase: string, promptId: string }>} */ function promptNodeStates(promptId) { return Array.from(modalNodeStates.values()).filter((state) => state?.promptId === promptId); } /** * Return the best known remote node count for one prompt. * @param {string} promptId * @param {number} fallbackCount * @returns {number} */ function promptRemoteNodeCount(promptId, fallbackCount = 1) { const promptState = modalPromptStates.get(promptId); const remoteCount = promptState?.remoteNodeIds?.length ?? 0; return Math.max(1, Number(remoteCount || fallbackCount) || 1); } /** * Derive the effective global phase for one prompt from its live node state. * @param {string} promptId * @param {string} phase * @returns {string} */ function effectiveGlobalStatusPhase(promptId, phase) { const promptState = modalPromptStates.get(promptId); const nodeStates = promptNodeStates(promptId); if (phase === STATE_ERROR) { return STATE_ERROR; } if (phase === STATE_CANCELLING || nodeStates.some((state) => state.phase === STATE_CANCELLING)) { return STATE_CANCELLING; } if (phase === STATE_SETUP || phase === STATE_STARTING || phase === STATE_WAITING) { return phase; } if (nodeStates.some((state) => state.phase === STATE_ERROR)) { return STATE_ERROR; } if (phase === STATE_FINALIZING) { if ( promptState?.hasRemoteExecutionStarted || promptActiveNodeIsLive(promptId) || nodeStates.some((state) => state.phase === STATE_ACTIVE || state.phase === STATE_READY) ) { return EXECUTION_PHASE; } return STATE_FINALIZING; } if (promptState?.hasRemoteExecutionStarted && promptActiveNodeIsLive(promptId)) { return EXECUTION_PHASE; } if (promptState?.hasRemoteExecutionStarted && nodeStates.some((state) => state.phase === STATE_ACTIVE)) { return EXECUTION_PHASE; } if ( promptState?.hasRemoteExecutionStarted && nodeStates.some((state) => state.phase === STATE_READY) ) { return EXECUTION_PHASE; } return phase; } /** * Return whether one prompt still has active remote work that should keep the global pill visible. * @param {string} promptId * @returns {boolean} */ function promptHasLiveRemoteWork(promptId) { const promptState = modalPromptStates.get(promptId); if (!promptState) { return false; } if (promptActiveNodeIsLive(promptId)) { return true; } if (promptState.remoteNodeIds.some((nodeIdValue) => hasLiveNodeProgress(nodeIdValue, promptId))) { return true; } return promptState.remoteNodeIds.some((nodeIdValue) => { const nodeState = modalNodeStates.get(String(nodeIdValue)); return ( nodeState?.promptId === promptId && [STATE_SETUP, STATE_STARTING, STATE_READY, STATE_ACTIVE].includes(nodeState.phase) ); }); } /** * Return whether the prompt-wide active node still represents live execution. * @param {string} promptId * @returns {boolean} */ function promptActiveNodeIsLive(promptId) { const promptState = modalPromptStates.get(promptId); const activeNodeId = promptState?.activeNodeId; if (!activeNodeId) { return false; } if (hasLiveNodeProgress(activeNodeId, promptId)) { return true; } const activeNodeState = modalNodeStates.get(String(activeNodeId)); return activeNodeState?.promptId === promptId && activeNodeState.phase === STATE_ACTIVE; } /** * Return whether any older Modal prompt is still visually or remotely active. * @param {string | null} excludedPromptId * @returns {boolean} */ function hasActiveModalPrompt(excludedPromptId = null) { for (const promptId of modalPromptStates.keys()) { if (promptId === excludedPromptId || isPromptTerminal(promptId)) { continue; } if (syntheticPromptUiStates.has(promptId) || promptHasLiveRemoteWork(promptId)) { return true; } } return false; } /** * Mark a submitted prompt as queued behind an already active Modal prompt. * @param {string} promptId * @returns {boolean} */ function markPromptQueuedBehindActiveModal(promptId) { if (!promptId || !hasActiveModalPrompt(promptId)) { return false; } modalQueuedPromptIds.add(promptId); return true; } /** * Return whether queue-time UI updates should be suppressed for one prompt. * @param {string} promptId * @returns {boolean} */ function isPromptQueuedBehindActiveModal(promptId) { return modalQueuedPromptIds.has(promptId); } /** * Allow normal execution UI updates for a prompt once ComfyUI actually starts it. * @param {string} promptId */ function clearPromptQueued(promptId) { modalQueuedPromptIds.delete(promptId); } /** * Drop one prompt's stale global-status entry once it no longer has live remote work. * @param {string} promptId */ function reconcilePromptGlobalStatus(promptId) { if (!promptId) { return; } const globalStatusState = modalGlobalStatusStates.get(promptId); if (!globalStatusState) { return; } if (syntheticPromptUiStates.has(promptId)) { return; } if (globalStatusState.phase === STATE_ERROR || globalStatusState.phase === STATE_SETUP) { return; } if (promptHasLiveRemoteWork(promptId)) { refreshGlobalStatusElement(); return; } modalGlobalStatusStates.delete(promptId); refreshGlobalStatusElement(); } /** * Return the most important active global Modal state. * @returns {{ phase: string, promptId: string, nodeCount: number } | null} */ function currentGlobalStatus() { pruneGlobalStatusStates(); if (modalGlobalStatusStates.size === 0) { return null; } const phases = Array.from(modalGlobalStatusStates.entries()).map(([promptId, state]) => ({ promptId, phase: effectiveGlobalStatusPhase(promptId, state.phase), nodeCount: state.nodeCount, batchValue: state.batchValue ?? null, batchMax: state.batchMax ?? null, statusMessage: state.statusMessage ?? null, statusCurrent: state.statusCurrent ?? null, statusTotal: state.statusTotal ?? null, updatedAt: state.updatedAt, })); phases.sort((left, right) => right.updatedAt - left.updatedAt); return ( phases.find((state) => state.phase === STATE_ERROR) ?? phases.find((state) => state.phase === STATE_CANCELLING) ?? phases.find((state) => state.phase === STATE_SETUP) ?? phases.find((state) => state.phase === STATE_STARTING) ?? phases.find((state) => state.phase === STATE_WAITING) ?? phases.find((state) => state.phase === EXECUTION_PHASE) ?? phases.find((state) => state.phase === STATE_FINALIZING) ?? phases[0] ); } /** * Redraw the global Modal execution badge. */ function refreshGlobalStatusElement() { const element = ensureGlobalStatusElement(); if (!element) { return; } const activeState = currentGlobalStatus(); if (!activeState) { element.style.display = "none"; element.dataset.phase = ""; return; } const dot = element.querySelector(".modal-status-dot"); const text = element.querySelector(".modal-status-text"); const nodeLabel = activeState.nodeCount === 1 ? "node" : "nodes"; const hasBatchProgress = activeState.phase === EXECUTION_PHASE && Number(activeState.batchMax ?? 0) > 1; const batchValue = hasBatchProgress ? Math.max(0, Math.min(Number(activeState.batchMax), Number(activeState.batchValue ?? 0))) : 0; const batchMax = hasBatchProgress ? Math.max(1, Number(activeState.batchMax)) : 1; const batchRatio = hasBatchProgress ? batchValue / batchMax : 0; const hasStatusProgress = Number(activeState.statusTotal ?? 0) > 1; const statusValue = hasStatusProgress ? Math.max( 0, Math.min(Number(activeState.statusTotal), Number(activeState.statusCurrent ?? 0)), ) : 0; const statusMax = hasStatusProgress ? Math.max(1, Number(activeState.statusTotal)) : 1; const statusRatio = hasStatusProgress ? statusValue / statusMax : 0; element.style.display = "inline-flex"; element.dataset.phase = activeState.phase; if (activeState.phase === STATE_SETUP) { element.style.borderColor = "rgba(245, 158, 11, 0.55)"; element.style.background = hasStatusProgress ? `linear-gradient(90deg, rgba(180, 83, 9, 0.94) 0%, rgba(180, 83, 9, 0.94) ${( statusRatio * 100 ).toFixed(2)}%, rgba(61, 42, 9, 0.94) ${(statusRatio * 100).toFixed(2)}%, rgba(61, 42, 9, 0.94) 100%)` : "rgba(61, 42, 9, 0.94)"; dot.style.background = SETUP_BORDER_COLOR; dot.style.boxShadow = "0 0 0 6px rgba(245, 158, 11, 0.18)"; dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite"; text.textContent = activeState.statusMessage ?? "Syncing graph with Modal"; } else if (activeState.phase === STATE_STARTING) { element.style.borderColor = "rgba(234, 179, 8, 0.58)"; element.style.background = "rgba(54, 45, 6, 0.94)"; dot.style.background = STARTING_BORDER_COLOR; dot.style.boxShadow = "0 0 0 6px rgba(234, 179, 8, 0.2)"; dot.style.animation = "modal-status-pulse 0.85s ease-in-out infinite"; text.textContent = activeState.statusMessage ?? "Starting Modal component"; } else if (activeState.phase === STATE_WAITING) { element.style.borderColor = "rgba(245, 158, 11, 0.55)"; element.style.background = "rgba(61, 42, 9, 0.94)"; dot.style.background = SETUP_BORDER_COLOR; dot.style.boxShadow = "0 0 0 6px rgba(245, 158, 11, 0.18)"; dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite"; text.textContent = activeState.statusMessage ?? "Waiting for Modal startup"; } else if (activeState.phase === EXECUTION_PHASE) { element.style.borderColor = "rgba(34, 197, 94, 0.55)"; element.style.background = hasBatchProgress ? `linear-gradient(90deg, rgba(22, 163, 74, 0.92) 0%, rgba(22, 163, 74, 0.92) ${( batchRatio * 100 ).toFixed(2)}%, rgba(8, 49, 28, 0.94) ${(batchRatio * 100).toFixed(2)}%, rgba(8, 49, 28, 0.94) 100%)` : "rgba(8, 49, 28, 0.94)"; dot.style.background = ACTIVE_BORDER_COLOR; dot.style.boxShadow = "0 0 0 6px rgba(34, 197, 94, 0.18)"; dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite"; text.textContent = hasBatchProgress ? `Modal workflow running on ${activeState.nodeCount} ${nodeLabel} ยท ${Math.round(batchValue)}/${Math.round(batchMax)}` : `Modal workflow running on ${activeState.nodeCount} ${nodeLabel}`; } else if (activeState.phase === STATE_FINALIZING) { element.style.borderColor = "rgba(59, 130, 246, 0.55)"; element.style.background = "rgba(15, 23, 42, 0.94)"; dot.style.background = FINALIZING_BORDER_COLOR; dot.style.boxShadow = "0 0 0 6px rgba(59, 130, 246, 0.18)"; dot.style.animation = "modal-status-pulse 1.1s ease-in-out infinite"; text.textContent = activeState.statusMessage ?? "Receiving Modal outputs"; } else if (activeState.phase === STATE_CANCELLING) { element.style.borderColor = "rgba(251, 113, 133, 0.58)"; element.style.background = "rgba(76, 5, 25, 0.94)"; dot.style.background = CANCELLING_BORDER_COLOR; dot.style.boxShadow = "0 0 0 6px rgba(251, 113, 133, 0.2)"; dot.style.animation = "modal-status-pulse 0.75s ease-in-out infinite"; text.textContent = activeState.statusMessage ?? "Cancelling Modal workflow"; } else if (activeState.phase === STATE_ERROR) { element.style.borderColor = "rgba(239, 68, 68, 0.55)"; element.style.background = "rgba(69, 10, 10, 0.94)"; dot.style.background = ERROR_BORDER_COLOR; dot.style.boxShadow = "0 0 0 6px rgba(239, 68, 68, 0.18)"; dot.style.animation = "none"; text.textContent = "Modal workflow failed"; } else { element.style.borderColor = "rgba(29, 155, 240, 0.5)"; element.style.background = "rgba(15, 23, 42, 0.94)"; dot.style.background = IDLE_BORDER_COLOR; dot.style.boxShadow = "0 0 0 6px rgba(29, 155, 240, 0.18)"; dot.style.animation = "none"; text.textContent = "Modal workflow active"; } dot.style.width = "10px"; dot.style.height = "10px"; dot.style.borderRadius = "999px"; dot.style.display = "inline-block"; } /** * Record one prompt's global Modal execution phase. * @param {string} promptId * @param {string} phase * @param {number} nodeCount * @param {{ message?: string | null, current?: number | null, total?: number | null } | null} details */ function setGlobalStatusPhase(promptId, phase, nodeCount, details = null) { if (!promptId) { return; } if (isPromptTerminal(promptId) && phase !== STATE_ERROR) { return; } const existingState = modalGlobalStatusStates.get(promptId); modalGlobalStatusStates.set(promptId, { phase: effectiveGlobalStatusPhase(promptId, phase), nodeCount: promptRemoteNodeCount(promptId, nodeCount), batchValue: existingState?.batchValue ?? null, batchMax: existingState?.batchMax ?? null, statusMessage: details?.message ?? existingState?.statusMessage ?? null, statusCurrent: details?.current ?? existingState?.statusCurrent ?? null, statusTotal: details?.total ?? existingState?.statusTotal ?? null, updatedAt: nowMs(), }); refreshGlobalStatusElement(); } /** * Record aggregate mapped-batch progress for one prompt's global Modal status pill. * @param {string} promptId * @param {number} value * @param {number} maxValue */ function setGlobalStatusBatchProgress(promptId, value, maxValue) { if (!promptId) { return; } if (isPromptTerminal(promptId)) { return; } const existingState = modalGlobalStatusStates.get(promptId); const safeMaxValue = Math.max(1, Number(maxValue) || 1); const safeValue = Math.max(0, Math.min(safeMaxValue, Number(value) || 0)); modalGlobalStatusStates.set(promptId, { phase: effectiveGlobalStatusPhase(promptId, existingState?.phase ?? EXECUTION_PHASE), nodeCount: promptRemoteNodeCount(promptId, existingState?.nodeCount ?? 1), batchValue: safeValue, batchMax: safeMaxValue, statusMessage: existingState?.statusMessage ?? null, statusCurrent: existingState?.statusCurrent ?? null, statusTotal: existingState?.statusTotal ?? null, updatedAt: nowMs(), }); refreshGlobalStatusElement(); } /** * Clear one prompt from the global Modal execution badge. * @param {string} promptId */ function clearGlobalStatusPhase(promptId) { if (!promptId) { return; } modalGlobalStatusStates.delete(promptId); refreshGlobalStatusElement(); } /** * Show immediate cancellation feedback for one Modal prompt. * @param {string} promptId */ function markPromptCancellationRequested(promptId) { if (!promptId || isPromptTerminal(promptId)) { return; } const promptState = modalPromptStates.get(promptId); const stateNodeIds = Array.from(modalNodeStates.entries()) .filter(([, state]) => state?.promptId === promptId) .map(([nodeIdValue]) => nodeIdValue); const remoteNodeIds = promptState?.remoteNodeIds?.length ? promptState.remoteNodeIds : stateNodeIds; if (remoteNodeIds.length === 0 && !modalGlobalStatusStates.has(promptId)) { return; } modalCancellingPromptIds.add(promptId); clearPromptQueued(promptId); endSyntheticExecutionUi(promptId); setPromptActiveNode(promptId, null); setGlobalStatusPhase(promptId, STATE_CANCELLING, remoteNodeIds.length || 1, { message: "Cancelling Modal workflow", }); if (remoteNodeIds.length > 0) { setNodesPhase(remoteNodeIds, STATE_CANCELLING, promptId); } } /** * Return prompt ids targeted by one ComfyUI interrupt request. * @param {any} resource * @param {any} options * @returns {string[]} */ function promptIdsFromInterruptRequest(resource, options) { const route = String(resource?.url ?? resource ?? ""); const method = String(options?.method ?? resource?.method ?? "GET").toUpperCase(); if (!route.includes("/interrupt") || method !== "POST") { return []; } const body = options?.body ?? resource?.body; if (typeof body === "string" && body.trim()) { try { const payload = JSON.parse(body); if (payload?.prompt_id != null) { return [String(payload.prompt_id)]; } } catch (error) { console.debug("Unable to parse ComfyUI interrupt request body for Modal cancellation UI.", error); } } return activeModalUiPromptIds(); } /** * Patch fetchApi so Modal prompts show cancellation feedback as soon as the user clicks cancel. */ function patchInterruptFeedback() { if (api.__modalInterruptFeedbackPatched || typeof api.fetchApi !== "function") { return; } const originalFetchApi = api.fetchApi; api.fetchApi = function modalFetchApi(resource, options) { const promptIds = promptIdsFromInterruptRequest(resource, options); for (const promptId of promptIds) { markPromptCancellationRequested(promptId); } return originalFetchApi.apply(this, arguments); }; api.__modalInterruptFeedbackPatched = true; } /** * Replay one Modal UI event received from the backend refocus buffer. * @param {{ event?: string, payload?: object }} eventRecord */ function replayModalUiEvent(eventRecord) { const eventName = String(eventRecord?.event ?? ""); const payload = eventRecord?.payload; if (!payload || typeof payload !== "object") { return; } if (eventName === "modal_status") { handleModalStatus({ detail: payload }); } else if (eventName === "modal_progress") { handleModalProgress({ detail: payload }); } } /** * Fetch recent Modal UI events that may have arrived while the browser was backgrounded. * @returns {Promise} */ async function replayModalUiEventsAfterVisibilityChange() { const clientId = api.clientId == null ? "" : String(api.clientId); if (!clientId || typeof api.fetchApi !== "function") { return; } const response = await api.fetchApi( `${MODAL_PROGRESS_STATE_ROUTE}?client_id=${encodeURIComponent(clientId)}`, { method: "GET" }, ); if (response.status !== 200) { return; } const payload = await response.json(); const events = Array.isArray(payload?.events) ? payload.events : []; for (const eventRecord of events) { replayModalUiEvent(eventRecord); } } /** * Return prompt ids that currently have temporary Modal UI state. * @returns {string[]} */ function activeModalUiPromptIds() { const promptIds = new Set(); for (const promptId of modalPromptStates.keys()) { promptIds.add(String(promptId)); } for (const promptId of modalGlobalStatusStates.keys()) { promptIds.add(String(promptId)); } for (const promptId of modalQueuedPromptIds.values()) { promptIds.add(String(promptId)); } for (const promptId of syntheticPromptUiStates.keys()) { promptIds.add(String(promptId)); } for (const state of modalNodeStates.values()) { if (state?.promptId) { promptIds.add(String(state.promptId)); } } return Array.from(promptIds).filter((promptId) => promptId && !isPromptTerminal(promptId)); } /** * Return the oldest age in milliseconds of a prompt's temporary Modal UI state. * @param {string} promptId * @returns {number} */ function promptUiStateAgeMs(promptId) { const updatedAts = []; const promptState = modalPromptStates.get(promptId); if (promptState?.startedAt) { updatedAts.push(promptState.startedAt); } const globalStatusState = modalGlobalStatusStates.get(promptId); if (globalStatusState?.updatedAt) { updatedAts.push(globalStatusState.updatedAt); } for (const state of modalNodeStates.values()) { if (state?.promptId === promptId && state?.updatedAt) { updatedAts.push(state.updatedAt); } } if (updatedAts.length === 0) { return 0; } return Math.max(0, nowMs() - Math.min(...updatedAts)); } /** * Return prompt ids reported by ComfyUI's queue endpoint. * @param {any} queuePayload * @returns {Set} */ function promptIdsFromQueuePayload(queuePayload) { const promptIds = new Set(); for (const queueName of ["queue_running", "queue_pending"]) { const queueEntries = Array.isArray(queuePayload?.[queueName]) ? queuePayload[queueName] : []; for (const entry of queueEntries) { if (Array.isArray(entry) && entry.length > 1) { promptIds.add(String(entry[1])); } else if (entry?.prompt_id != null) { promptIds.add(String(entry.prompt_id)); } } } return promptIds; } /** * Return whether one ComfyUI history response contains a prompt id. * @param {any} historyPayload * @param {string} promptId * @returns {boolean} */ function historyPayloadHasPrompt(historyPayload, promptId) { if (!historyPayload || typeof historyPayload !== "object") { return false; } if (Object.prototype.hasOwnProperty.call(historyPayload, promptId)) { return true; } return String(historyPayload.prompt_id ?? "") === promptId; } /** * Fetch JSON from the ComfyUI API when the endpoint returns a successful response. * @param {string} route * @returns {Promise} */ async function fetchComfyJson(route) { if (typeof api.fetchApi !== "function") { return null; } const response = await api.fetchApi(route, { method: "GET" }); if (response.status !== 200) { return null; } return response.json(); } /** * Clear refocus-stale Modal visuals once ComfyUI reports a prompt has finished. * @param {string} promptId * @param {string} phase */ function clearRefocusCompletedPrompt(promptId, phase) { markPromptTerminal(promptId, phase); endSyntheticExecutionUi(promptId); clearGlobalStatusPhase(promptId); clearPromptRemoteStates(promptId); } /** * Reconcile temporary Modal UI state against ComfyUI queue/history after refocus. * @returns {Promise} */ async function reconcileModalUiAfterVisibilityChange() { const promptIds = activeModalUiPromptIds(); if (promptIds.length === 0 || typeof api.fetchApi !== "function") { return; } const queuePayload = await fetchComfyJson(COMFY_QUEUE_ROUTE); if (!queuePayload) { return; } const queuedPromptIds = promptIdsFromQueuePayload(queuePayload); for (const promptId of promptIds) { if (queuedPromptIds.has(promptId)) { continue; } const historyPayload = await fetchComfyJson( `${COMFY_HISTORY_ROUTE}/${encodeURIComponent(promptId)}`, ); if (historyPayloadHasPrompt(historyPayload, promptId)) { clearRefocusCompletedPrompt(promptId, "execution_success"); } else if (promptUiStateAgeMs(promptId) > REFOCUS_STALE_PROMPT_GRACE_MS) { clearRefocusCompletedPrompt(promptId, "stale_refocus_cleanup"); } } } /** * Refresh the badge and canvas when the tab regains visibility. */ function refreshModalUiAfterVisibilityChange() { refreshGlobalStatusElement(); if (Array.from(modalNodeStates.values()).length > 0) { ensureAnimationLoop(); } app.graph?.setDirtyCanvas(true, true); if (modalVisibilityRefreshInFlight) { return; } modalVisibilityRefreshInFlight = replayModalUiEventsAfterVisibilityChange() .then(() => reconcileModalUiAfterVisibilityChange()) .catch((error) => { console.warn("Unable to refresh Modal UI state after visibility change.", error); }) .finally(() => { modalVisibilityRefreshInFlight = null; refreshGlobalStatusElement(); if (Array.from(modalNodeStates.values()).length > 0) { ensureAnimationLoop(); } app.graph?.setDirtyCanvas(true, true); }); } /** * Return the prompt metadata bucket, creating it if needed. * @param {string} promptId * @returns {{ startedAt: number, remoteNodeIds: string[], componentsByRepresentative: Map, componentNodeIdsByMember: Map, representativeNodeIdByMember: Map, componentLabelByMember: Map, laneNodeIdsByLane: Map }} */ function ensurePromptState(promptId) { if (isPromptTerminal(promptId)) { return null; } if (!modalPromptStates.has(promptId)) { modalPromptStates.set(promptId, { startedAt: nowMs(), remoteNodeIds: [], componentsByRepresentative: new Map(), componentNodeIdsByMember: new Map(), representativeNodeIdByMember: new Map(), componentLabelByMember: new Map(), descendantNodeIdsByAncestor: new Map(), laneNodeIdsByLane: new Map(), activeNodeId: null, hasStreamedProgress: false, hasRemoteExecutionStarted: false, }); } return modalPromptStates.get(promptId); } /** * Return whether an incoming prompt update should replace the current node state. * @param {string} nodeIdValue * @param {string} promptId * @returns {boolean} */ function shouldApplyPromptState(nodeIdValue, promptId) { if (isPromptTerminal(promptId)) { return false; } const incomingPromptState = modalPromptStates.get(promptId); if (!incomingPromptState) { return true; } const currentState = modalNodeStates.get(nodeIdValue); if (!currentState?.promptId) { return true; } const currentPromptState = modalPromptStates.get(currentState.promptId); if (!currentPromptState) { return true; } return incomingPromptState.startedAt >= currentPromptState.startedAt; } /** * Mark the canvas dirty and keep animation alive while visual states are active. */ function refreshCanvasAnimation() { app.graph?.setDirtyCanvas(true, true); const hasAnimatedState = Array.from(modalNodeStates.values()).some((state) => [STATE_SETUP, STATE_STARTING, STATE_READY, STATE_ACTIVE, STATE_CANCELLING, STATE_ERROR].includes( state.phase, ), ); const hasCachedPulse = Array.from(modalNodeCachedStates.values()).length > 0; const hasProgressState = modalNodeProgress.size > 0 || modalNodeProgressLanes.size > 0 || modalNodeBatchProgress.size > 0; if (!hasAnimatedState && !hasCachedPulse && !hasProgressState) { animationFrameHandle = null; return; } animationFrameHandle = requestAnimationFrame(refreshCanvasAnimation); } /** * Ensure the redraw loop is running while remote visual effects are active. */ function ensureAnimationLoop() { if (animationFrameHandle !== null) { return; } animationFrameHandle = requestAnimationFrame(refreshCanvasAnimation); } /** * Clear any pending visual-state timeout for a node. * @param {string} nodeIdValue */ function clearNodeTimer(nodeIdValue) { const timerId = modalNodeClearTimers.get(nodeIdValue); if (timerId !== undefined) { clearTimeout(timerId); modalNodeClearTimers.delete(nodeIdValue); } } /** * Schedule a node visual state to clear after a delay. * @param {string} nodeIdValue * @param {string} promptId * @param {number} delayMs */ function scheduleNodeClear(nodeIdValue, promptId, delayMs) { clearNodeTimer(nodeIdValue); const timerId = setTimeout(() => { const state = modalNodeStates.get(nodeIdValue); if (state?.promptId !== promptId) { return; } modalNodeStates.delete(nodeIdValue); modalNodeClearTimers.delete(nodeIdValue); ensureAnimationLoop(); reconcilePromptGlobalStatus(promptId); app.graph?.setDirtyCanvas(true, true); }, delayMs); modalNodeClearTimers.set(nodeIdValue, timerId); } /** * Set the current visual phase for a list of remote nodes. * @param {string[]} nodeIds * @param {string} phase * @param {string} promptId * @param {string | undefined} errorMessage */ function setNodesPhase(nodeIds, phase, promptId, errorMessage) { const affectedAncestorNodeIds = new Set(); const promptState = promptId ? modalPromptStates.get(promptId) : null; for (const currentNodeId of nodeIds) { if (!shouldApplyPromptState(currentNodeId, promptId)) { continue; } const existingState = modalNodeStates.get(currentNodeId); if ( phase === STATE_READY && existingState?.promptId === promptId && [STATE_COMPLETE, STATE_FINALIZING, STATE_ERROR].includes(existingState.phase) ) { continue; } clearNodeTimer(currentNodeId); modalNodeStates.set(currentNodeId, { phase, promptId, errorMessage, updatedAt: nowMs(), }); if (phase === STATE_ERROR) { scheduleNodeClear(currentNodeId, promptId, ERROR_CLEAR_DELAY_MS); } if ([STATE_SETUP, STATE_STARTING, STATE_CANCELLING, STATE_ERROR].includes(phase)) { clearNodeCached(currentNodeId, promptId); } if (phase === STATE_COMPLETE) { fadeNodeProgress(currentNodeId, promptId); if (promptState?.activeNodeId === String(currentNodeId)) { promptState.activeNodeId = null; } } if (phase === STATE_ERROR || phase === STATE_CANCELLING) { clearNodeProgress(currentNodeId, promptId); } for (const ancestorNodeId of ancestorNodeIds(currentNodeId)) { affectedAncestorNodeIds.add(ancestorNodeId); } } for (const ancestorNodeId of affectedAncestorNodeIds) { refreshAncestorNodePhase(promptId, ancestorNodeId, errorMessage); } ensureAnimationLoop(); if ([STATE_COMPLETE, STATE_FINALIZING, STATE_ERROR].includes(phase)) { reconcilePromptGlobalStatus(promptId); } app.graph?.setDirtyCanvas(true, true); } /** * Register remote component membership for a prompt. * @param {string} promptId * @param {string[]} remoteNodeIds * @param {{ representative_node_id: string, node_ids: string[] }[]} components */ function registerPromptComponents(promptId, remoteNodeIds, components) { const promptState = ensurePromptState(promptId); if (!promptState) { return; } if (components.length > 0) { const mergedRemoteNodeIds = new Set(remoteNodeIds.map((nodeIdValue) => String(nodeIdValue))); promptState.componentsByRepresentative.clear(); promptState.componentNodeIdsByMember.clear(); promptState.representativeNodeIdByMember.clear(); promptState.componentLabelByMember.clear(); promptState.activeNodeId = null; promptState.hasStreamedProgress = false; for (const [componentIndex, component] of components.entries()) { const componentNodeIds = Array.from( new Set(component.node_ids.map((nodeIdValue) => String(nodeIdValue))), ); const representativeNodeId = String(component.representative_node_id); const componentLabel = String(componentIndex + 1); promptState.componentsByRepresentative.set( representativeNodeId, componentNodeIds, ); promptState.componentNodeIdsByMember.set(representativeNodeId, componentNodeIds); promptState.representativeNodeIdByMember.set(representativeNodeId, representativeNodeId); promptState.componentLabelByMember.set(representativeNodeId, componentLabel); for (const componentNodeId of componentNodeIds) { promptState.componentNodeIdsByMember.set(componentNodeId, componentNodeIds); promptState.representativeNodeIdByMember.set(componentNodeId, representativeNodeId); promptState.componentLabelByMember.set(componentNodeId, componentLabel); mergedRemoteNodeIds.add(componentNodeId); } } if (mergedRemoteNodeIds.size > 0) { promptState.remoteNodeIds = Array.from(mergedRemoteNodeIds); } rebuildPromptAncestorMap(promptState); return; } if (remoteNodeIds.length > 0) { if (promptState.remoteNodeIds.length === 0) { promptState.remoteNodeIds = [...remoteNodeIds]; } else { const mergedRemoteNodeIds = new Set(promptState.remoteNodeIds); for (const remoteNodeId of remoteNodeIds) { mergedRemoteNodeIds.add(String(remoteNodeId)); } promptState.remoteNodeIds = Array.from(mergedRemoteNodeIds); } rebuildPromptAncestorMap(promptState); } } /** * Return ancestor node ids for a composed subgraph prompt id like `24:23`. * @param {string} nodeIdValue * @returns {string[]} */ function ancestorNodeIds(nodeIdValue) { const segments = String(nodeIdValue).split(":"); const ancestorNodeIds = []; for (let index = 1; index < segments.length; index += 1) { ancestorNodeIds.push(segments.slice(0, index).join(":")); } return ancestorNodeIds; } /** * Rebuild descendant-to-ancestor mappings for one prompt state. * @param {{ remoteNodeIds: string[], componentsByRepresentative: Map, descendantNodeIdsByAncestor: Map> }} promptState */ function rebuildPromptAncestorMap(promptState) { promptState.descendantNodeIdsByAncestor.clear(); const candidateNodeIds = new Set(promptState.remoteNodeIds); for (const componentNodeIds of promptState.componentsByRepresentative.values()) { for (const componentNodeId of componentNodeIds) { candidateNodeIds.add(String(componentNodeId)); } } for (const candidateNodeId of candidateNodeIds) { for (const ancestorNodeId of ancestorNodeIds(candidateNodeId)) { if (!promptState.descendantNodeIdsByAncestor.has(ancestorNodeId)) { promptState.descendantNodeIdsByAncestor.set(ancestorNodeId, new Set()); } promptState.descendantNodeIdsByAncestor.get(ancestorNodeId).add(candidateNodeId); } } } /** * Recompute one visible ancestor node's phase from its descendant remote prompt nodes. * @param {string} promptId * @param {string} ancestorNodeId * @param {string | undefined} errorMessage */ function refreshAncestorNodePhase(promptId, ancestorNodeId, errorMessage) { const promptState = modalPromptStates.get(promptId); const descendantNodeIds = promptState?.descendantNodeIdsByAncestor.get(ancestorNodeId); if (!promptState || !descendantNodeIds || descendantNodeIds.size === 0) { return; } const descendantStates = Array.from(descendantNodeIds) .map((descendantNodeId) => modalNodeStates.get(descendantNodeId)) .filter((state) => state?.promptId === promptId); if (descendantStates.length === 0) { return; } let ancestorPhase = STATE_SETUP; if (descendantStates.some((state) => state.phase === STATE_ERROR)) { ancestorPhase = STATE_ERROR; } else if (descendantStates.some((state) => state.phase === STATE_ACTIVE)) { ancestorPhase = STATE_ACTIVE; } else if (descendantStates.every((state) => state.phase === STATE_FINALIZING)) { ancestorPhase = STATE_FINALIZING; } else if (descendantStates.every((state) => state.phase === STATE_COMPLETE)) { ancestorPhase = STATE_COMPLETE; } else if ( descendantStates.some((state) => [STATE_READY, STATE_COMPLETE, STATE_FINALIZING].includes(state.phase), ) ) { ancestorPhase = STATE_READY; } if (!shouldApplyPromptState(ancestorNodeId, promptId)) { return; } clearNodeTimer(ancestorNodeId); modalNodeStates.set(ancestorNodeId, { phase: ancestorPhase, promptId, errorMessage, updatedAt: nowMs(), }); if (ancestorPhase === STATE_ERROR) { scheduleNodeClear(ancestorNodeId, promptId, ERROR_CLEAR_DELAY_MS); } } /** * Record the currently active remote node inside one prompt. * @param {string} promptId * @param {string | null} activeNodeId */ function setPromptActiveNode(promptId, activeNodeId) { const promptState = ensurePromptState(promptId); if (!promptState) { return; } promptState.activeNodeId = activeNodeId ? String(activeNodeId) : null; } /** * Mark known upstream remote nodes complete when a downstream node is executing. * @param {string} promptId * @param {any[] | undefined} ancestorNodeIds * @param {string | null} activeNodeId */ function completeRemoteAncestorsBeforeActiveNode(promptId, ancestorNodeIds, activeNodeId) { if (!Array.isArray(ancestorNodeIds) || ancestorNodeIds.length === 0) { return; } const completedNodeIds = Array.from( new Set( ancestorNodeIds .map((nodeIdValue) => String(nodeIdValue)) .filter((nodeIdValue) => nodeIdValue && nodeIdValue !== activeNodeId), ), ); if (completedNodeIds.length === 0) { return; } for (const completedNodeId of completedNodeIds) { fadeNodeProgress(completedNodeId, promptId); } setNodesPhase(completedNodeIds, STATE_COMPLETE, promptId); } /** * Return whether two nodes belong to the same known remote component. * @param {string} promptId * @param {string | null | undefined} leftNodeId * @param {string | null | undefined} rightNodeId * @returns {boolean} */ function nodesShareRemoteComponent(promptId, leftNodeId, rightNodeId) { if (!leftNodeId || !rightNodeId) { return false; } const leftComponentNodeIds = resolveComponentNodeIds(promptId, leftNodeId); if (!leftComponentNodeIds?.length) { return String(leftNodeId) === String(rightNodeId); } return leftComponentNodeIds.map((nodeIdValue) => String(nodeIdValue)).includes(String(rightNodeId)); } /** * Return the numeric progress payload for one node when it belongs to the prompt. * @param {string} nodeIdValue * @param {string} promptId * @returns {{ promptId: string, value: number, max: number, updatedAt: number } | null} */ function nodeProgressState(nodeIdValue, promptId) { const progressState = modalNodeProgress.get(String(nodeIdValue)) ?? null; return progressState?.promptId === promptId ? progressState : null; } /** * Return sorted per-lane progress payloads for one node when they belong to the prompt. * @param {string} nodeIdValue * @param {string} promptId * @returns {{ laneId: string, value: number, max: number, itemIndex: number | null, updatedAt: number, setupOnly: boolean }[]} */ function nodeProgressLanes(nodeIdValue, promptId) { const progressLaneState = modalNodeProgressLanes.get(String(nodeIdValue)) ?? null; if (progressLaneState?.promptId !== promptId) { return []; } return Array.from(progressLaneState.lanes.values()).sort( (left, right) => Number(left.laneId) - Number(right.laneId), ); } /** * Return whether one node currently has live numeric or per-lane progress. * @param {string} nodeIdValue * @param {string} promptId * @returns {boolean} */ function hasLiveNodeProgress(nodeIdValue, promptId) { return ( Boolean(nodeProgressState(nodeIdValue, promptId)) || nodeProgressLanes(nodeIdValue, promptId).length > 0 ); } /** * Return opacity for one progress payload while it fades after completion. * @param {{ fadingStartedAt?: number | null } | null | undefined} progressState * @returns {number} */ function progressVisualOpacity(progressState) { if (!progressState) { return 0; } const fadingStartedAt = Number(progressState?.fadingStartedAt ?? 0); if (!fadingStartedAt) { return 1; } const fadeRatio = (nowMs() - fadingStartedAt) / PROGRESS_FADE_MS; return Math.max(0, Math.min(1, 1 - fadeRatio)); } /** * Derive the displayed node phase from the stored phase plus live progress. * @param {string | undefined} phase * @param {boolean} hasLiveProgress * @returns {string | undefined} */ function deriveRemoteNodePhase(phase, hasLiveProgress) { if ([STATE_ERROR, STATE_SETUP, STATE_STARTING].includes(phase ?? "")) { return phase; } if (phase === STATE_COMPLETE || phase === STATE_FINALIZING) { return phase; } if (hasLiveProgress) { return STATE_ACTIVE; } return phase; } /** * Remove one worker lane from a node and its visible ancestors without triggering a redraw. * @param {string} nodeIdValue * @param {string} promptId * @param {string} laneId */ function deleteNodeProgressLane(nodeIdValue, promptId, laneId) { const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)]; for (const progressNodeId of progressNodeIds) { const laneState = modalNodeProgressLanes.get(progressNodeId); if (!laneState || laneState.promptId !== promptId) { continue; } laneState.lanes.delete(laneId); if (laneState.lanes.size === 0) { modalNodeProgressLanes.delete(progressNodeId); } } } /** * Remove stored numeric progress for one node and its visible ancestors. * @param {string} nodeIdValue * @param {string | undefined} promptId */ function clearNodeProgress(nodeIdValue, promptId) { const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)]; for (const progressNodeId of progressNodeIds) { const progressState = modalNodeProgress.get(progressNodeId); if (!progressState) { modalNodeProgress.delete(progressNodeId); } else if (!promptId || progressState.promptId === promptId) { modalNodeProgress.delete(progressNodeId); } const batchState = modalNodeBatchProgress.get(progressNodeId); if (batchState && (!promptId || batchState.promptId === promptId)) { modalNodeBatchProgress.delete(progressNodeId); } const laneState = modalNodeProgressLanes.get(progressNodeId); if (!laneState) { continue; } if (promptId && laneState.promptId !== promptId) { continue; } modalNodeProgressLanes.delete(progressNodeId); } } /** * Remove one faded progress entry if it still belongs to the same prompt. * @param {string} progressNodeId * @param {string} promptId * @param {number} fadingStartedAt */ function clearFadedNodeProgress(progressNodeId, promptId, fadingStartedAt) { const progressState = modalNodeProgress.get(progressNodeId); if (progressState?.promptId === promptId && progressState.fadingStartedAt === fadingStartedAt) { modalNodeProgress.delete(progressNodeId); } const batchState = modalNodeBatchProgress.get(progressNodeId); if (batchState?.promptId === promptId && batchState.fadingStartedAt === fadingStartedAt) { modalNodeBatchProgress.delete(progressNodeId); } const laneState = modalNodeProgressLanes.get(progressNodeId); if (laneState?.promptId === promptId) { for (const [laneId, laneProgress] of Array.from(laneState.lanes.entries())) { if (laneProgress.fadingStartedAt === fadingStartedAt) { laneState.lanes.delete(laneId); } } if (laneState.lanes.size === 0) { modalNodeProgressLanes.delete(progressNodeId); } } ensureAnimationLoop(); reconcilePromptGlobalStatus(promptId); app.graph?.setDirtyCanvas(true, true); } /** * Fade stored progress for one completed node and its visible ancestors. * @param {string} nodeIdValue * @param {string | undefined} promptId */ function fadeNodeProgress(nodeIdValue, promptId) { const fadingStartedAt = nowMs(); const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)]; let fadedAnyProgress = false; for (const progressNodeId of progressNodeIds) { let fadedNodeProgress = false; const progressState = modalNodeProgress.get(progressNodeId); if (progressState && (!promptId || progressState.promptId === promptId)) { progressState.fadingStartedAt = fadingStartedAt; progressState.value = progressState.max; fadedAnyProgress = true; fadedNodeProgress = true; } const batchState = modalNodeBatchProgress.get(progressNodeId); if (batchState && (!promptId || batchState.promptId === promptId)) { batchState.fadingStartedAt = fadingStartedAt; batchState.value = batchState.max; fadedAnyProgress = true; fadedNodeProgress = true; } const laneState = modalNodeProgressLanes.get(progressNodeId); if (laneState && (!promptId || laneState.promptId === promptId)) { for (const laneProgress of laneState.lanes.values()) { laneProgress.fadingStartedAt = fadingStartedAt; laneProgress.value = laneProgress.max; fadedAnyProgress = true; fadedNodeProgress = true; } } if (fadedNodeProgress && promptId) { setTimeout( () => clearFadedNodeProgress(progressNodeId, promptId, fadingStartedAt), PROGRESS_FADE_MS, ); } } if (fadedAnyProgress) { ensureAnimationLoop(); app.graph?.setDirtyCanvas(true, true); } } /** * Return one cached-node marker when it belongs to the prompt. * @param {string} nodeIdValue * @param {string} promptId * @returns {{ promptId: string, cachedAt: number } | null} */ function nodeCachedState(nodeIdValue, promptId) { const cachedState = modalNodeCachedStates.get(String(nodeIdValue)) ?? null; return cachedState?.promptId === promptId ? cachedState : null; } /** * Mark one node and its visible ancestors as restored from the remote Modal node-output cache. * @param {string} nodeIdValue * @param {string} promptId */ function markNodeCached(nodeIdValue, promptId) { const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)]; for (const progressNodeId of progressNodeIds) { if (!shouldApplyPromptState(progressNodeId, promptId)) { continue; } modalNodeCachedStates.set(progressNodeId, { promptId, cachedAt: nowMs(), }); } ensureAnimationLoop(); app.graph?.setDirtyCanvas(true, true); } /** * Remove one cached-node marker from a node and its visible ancestors. * @param {string} nodeIdValue * @param {string | undefined} promptId */ function clearNodeCached(nodeIdValue, promptId) { const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)]; for (const progressNodeId of progressNodeIds) { const cachedState = modalNodeCachedStates.get(progressNodeId); if (!cachedState) { continue; } if (promptId && cachedState.promptId !== promptId) { continue; } modalNodeCachedStates.delete(progressNodeId); } } /** * Record aggregate mapped-batch progress on the representative remote node only. * @param {string} nodeIdValue * @param {string} promptId * @param {number} value * @param {number} maxValue */ function setNodeBatchProgress(nodeIdValue, promptId, value, maxValue) { const safeMaxValue = Math.max(1, Number(maxValue) || 1); const safeValue = Math.max(0, Math.min(safeMaxValue, Number(value) || 0)); const progressNodeId = String(nodeIdValue); if (!shouldApplyPromptState(progressNodeId, promptId)) { return; } modalNodeBatchProgress.set(progressNodeId, { promptId, value: safeValue, max: safeMaxValue, updatedAt: nowMs(), }); ensureAnimationLoop(); app.graph?.setDirtyCanvas(true, true); } /** * Record numeric progress for one node and its visible ancestors. * @param {string} nodeIdValue * @param {string} promptId * @param {number} value * @param {number} maxValue */ function setNodeProgress(nodeIdValue, promptId, value, maxValue) { const safeMaxValue = Math.max(1, Number(maxValue) || 1); const safeValue = Math.max(0, Math.min(safeMaxValue, Number(value) || 0)); const progressNodeIds = [String(nodeIdValue), ...ancestorNodeIds(nodeIdValue)]; for (const progressNodeId of progressNodeIds) { if (!shouldApplyPromptState(progressNodeId, promptId)) { continue; } modalNodeProgress.set(progressNodeId, { promptId, value: safeValue, max: safeMaxValue, updatedAt: nowMs(), }); } ensureAnimationLoop(); app.graph?.setDirtyCanvas(true, true); } /** * Record numeric progress for one worker lane on a node and its visible ancestors. * @param {string} nodeIdValue * @param {string} promptId * @param {string} laneId * @param {number} value * @param {number} maxValue * @param {number | null | undefined} itemIndex * @param {boolean} setupOnly */ function setNodeProgressLane(nodeIdValue, promptId, laneId, value, maxValue, itemIndex, setupOnly = false) { const safeLaneId = String(laneId ?? ""); if (!safeLaneId) { return; } const safeNodeIdValue = String(nodeIdValue); const safeLaneKey = laneOwnerKey(promptId, safeNodeIdValue, safeLaneId); const safeMaxValue = Math.max(1, Number(maxValue) || 1); const safeValue = Math.max(0, Math.min(safeMaxValue, Number(value) || 0)); const promptState = ensurePromptState(promptId); if (!promptState) { return; } const previousNodeId = promptState.laneNodeIdsByLane.get(safeLaneKey); if (previousNodeId && previousNodeId !== safeNodeIdValue) { deleteNodeProgressLane(previousNodeId, promptId, safeLaneId); } promptState.laneNodeIdsByLane.set(safeLaneKey, safeNodeIdValue); const progressNodeIds = [safeNodeIdValue, ...ancestorNodeIds(safeNodeIdValue)]; for (const progressNodeId of progressNodeIds) { if (!shouldApplyPromptState(progressNodeId, promptId)) { continue; } const existingLaneState = modalNodeProgressLanes.get(progressNodeId); const laneState = existingLaneState?.promptId === promptId ? existingLaneState : { promptId, lanes: new Map(), }; laneState.lanes.set(safeLaneId, { laneId: safeLaneId, value: safeValue, max: safeMaxValue, itemIndex: Number.isFinite(Number(itemIndex)) ? Number(itemIndex) : null, updatedAt: nowMs(), setupOnly: Boolean(setupOnly), }); modalNodeProgressLanes.set(progressNodeId, laneState); } ensureAnimationLoop(); app.graph?.setDirtyCanvas(true, true); } /** * Remove one worker lane progress bar from a node and its visible ancestors. * @param {string} nodeIdValue * @param {string} promptId * @param {string} laneId */ function clearNodeProgressLane(nodeIdValue, promptId, laneId) { const safeLaneId = String(laneId ?? ""); if (!safeLaneId) { return; } const safeNodeIdValue = String(nodeIdValue); const safeLaneKey = laneOwnerKey(promptId, safeNodeIdValue, safeLaneId); const promptState = modalPromptStates.get(promptId); if (promptState?.laneNodeIdsByLane.get(safeLaneKey) === safeNodeIdValue) { promptState.laneNodeIdsByLane.delete(safeLaneKey); } deleteNodeProgressLane(safeNodeIdValue, promptId, safeLaneId); ensureAnimationLoop(); app.graph?.setDirtyCanvas(true, true); } /** * Return remote component node ids for an executing proxy node event. * @param {string} promptId * @param {string} representativeNodeId * @returns {string[] | null} */ function resolveComponentNodeIds(promptId, representativeNodeId) { const promptState = modalPromptStates.get(promptId); if (!promptState) { return null; } const candidateNodeId = String(representativeNodeId); if (promptState.componentNodeIdsByMember.has(candidateNodeId)) { return promptState.componentNodeIdsByMember.get(candidateNodeId) ?? null; } if (promptState.remoteNodeIds.includes(candidateNodeId)) { return [candidateNodeId]; } return null; } /** * Return the representative node id for one remote component member when known. * @param {string} promptId * @param {string} nodeIdValue * @returns {string | null} */ function resolveComponentRepresentativeNodeId(promptId, nodeIdValue) { const promptState = modalPromptStates.get(promptId); if (!promptState) { return null; } return promptState.representativeNodeIdByMember.get(String(nodeIdValue)) ?? null; } /** * Return the stable prompt-local lane owner key for one component worker lane. * @param {string} promptId * @param {string} nodeIdValue * @param {string} laneId * @returns {string} */ function laneOwnerKey(promptId, nodeIdValue, laneId) { const ownerNodeId = resolveComponentRepresentativeNodeId(promptId, nodeIdValue) ?? String(nodeIdValue); return `${ownerNodeId}:${String(laneId)}`; } /** * Extract remote node ids from a workflow snapshot. * @param {object | undefined} workflow * @returns {string[]} */ function extractRemoteNodeIds(workflow) { const remoteNodeIds = []; for (const node of workflow?.nodes ?? []) { if (node?.properties?.[REMOTE_PROPERTY]) { remoteNodeIds.push(String(node.id)); } } return remoteNodeIds; } /** * Return the root workflow graph, including subgraphs when available. * @returns {LGraph | null} */ function rootGraph() { return app.rootGraph ?? app.graph?.rootGraph ?? app.graph ?? null; } /** * Look up a graph-local node id without assuming whether ids are numeric or strings. * @param {LGraph | null | undefined} graph * @param {string} id * @returns {LGraphNode | null} */ function getGraphNodeById(graph, id) { if (!graph || id == null) { return null; } const directMatch = graph.getNodeById?.(id) ?? null; if (directMatch) { return directMatch; } const numericId = Number(id); if (Number.isFinite(numericId)) { return graph.getNodeById?.(numericId) ?? null; } return null; } /** * Search every live subgraph for one matching value. * @param {(graph: LGraph) => any} matcher * @returns {any} */ function findSomethingInAllSubgraphs(matcher) { const graph = rootGraph(); if (!graph) { return null; } const subgraphs = [graph]; if (graph.subgraphs?.values) { subgraphs.push(...graph.subgraphs.values()); } for (const subgraph of subgraphs) { const match = matcher(subgraph); if (match) { return match; } } return null; } /** * Return all live workflow nodes across the root graph and any nested subgraphs. * @returns {LGraphNode[]} */ function allWorkflowNodes() { const graph = rootGraph(); if (!graph) { return []; } const visitedGraphs = new Set(); const nodes = []; const visitGraph = (candidateGraph) => { if (!candidateGraph || visitedGraphs.has(candidateGraph)) { return; } visitedGraphs.add(candidateGraph); for (const node of candidateGraph.nodes ?? []) { nodes.push(node); visitGraph(node?.subgraph); } if (candidateGraph.subgraphs?.values) { for (const subgraph of candidateGraph.subgraphs.values()) { visitGraph(subgraph); } } }; visitGraph(graph); return nodes; } /** * Return the workflow node that owns one nested subgraph graph id. * @param {number | string | undefined} subgraphId * @returns {LGraphNode | null} */ function findContainingSubgraphNode(subgraphId) { if (subgraphId == null) { return null; } return ( findSomethingInAllSubgraphs((graph) => (graph?.nodes ?? []).find( (candidate) => typeof candidate?.isSubgraphNode === "function" && candidate.isSubgraphNode() && candidate.subgraph?.id === subgraphId, ), ) ?? null ); } /** * Return one node's composed workflow path, including any subgraph ancestors. * @param {LGraphNode} node * @returns {string} */ function workflowNodePath(node) { const pathSegments = [String(node?.id ?? "")]; let currentGraph = node?.graph ?? null; const currentRootGraph = rootGraph(); while (currentGraph && currentRootGraph && currentGraph !== currentRootGraph) { const parentNode = findContainingSubgraphNode(currentGraph.id); if (!parentNode) { break; } pathSegments.unshift(String(parentNode.id)); currentGraph = parentNode.graph ?? null; } return pathSegments.filter(Boolean).join(":"); } /** * Resolve a composed workflow path like `24:23` to a live node instance. * @param {string} workflowPath * @returns {LGraphNode | null} */ function findNodeByWorkflowPath(workflowPath) { const pathSegments = String(workflowPath) .split(":") .filter(Boolean); if (pathSegments.length === 0) { return null; } let currentGraph = rootGraph(); let currentNode = null; for (const pathSegment of pathSegments) { currentNode = getGraphNodeById(currentGraph, pathSegment); if (!currentNode) { return null; } currentGraph = currentNode.subgraph ?? null; } return currentNode; } /** * Return the workflow-node paths that a context-menu action should expand from. * @param {LGraphNode} node * @returns {string[]} */ function selectedWorkflowNodePaths(node) { const selectedNodes = Object.values(app.canvas?.selected_nodes ?? {}).filter( (candidate) => candidate?.graph === node?.graph && isEligibleNode(candidate), ); if (selectedNodes.some((candidate) => candidate === node) && selectedNodes.length > 1) { return selectedNodes.map((candidate) => workflowNodePath(candidate)); } return [workflowNodePath(node)]; } /** * Return the graph snapshot shape ComfyUI already uses for queue submission. * @returns {Promise<{ output: object, workflow: object }>} */ async function serializeCurrentGraphForModal() { if (typeof app.graphToPrompt !== "function") { throw new Error("ComfyUI graph serialization is unavailable."); } const prompt = await app.graphToPrompt(); if (!prompt?.output || !prompt?.workflow) { throw new Error("ComfyUI did not return prompt and workflow data."); } return { output: prompt.output, workflow: prompt.workflow, }; } /** * Show a short frontend notification without taking over the whole UI. * @param {string} value */ function notifyModal(value) { dispatchSyntheticApiEvent("notification", { id: `modal-analysis-${Date.now()}`, value, }); } /** * Apply the remote marker value to the workflow nodes named by composed workflow paths. * @param {string[]} workflowNodePaths * @param {boolean} value * @returns {number} */ function setWorkflowNodePathsRemote(workflowNodePaths, value) { let appliedCount = 0; for (const workflowPath of workflowNodePaths) { const node = findNodeByWorkflowPath(workflowPath); if (!node) { console.warn("Unable to find Modal workflow node path in the live graph.", workflowPath); continue; } setRemoteFlag(node, value); appliedCount += 1; } return appliedCount; } /** * Set Modal on every currently eligible node in the live workflow. * @param {boolean} value * @returns {number} */ function setAllEligibleWorkflowNodesRemote(value) { let appliedCount = 0; for (const node of allWorkflowNodes()) { if (!isEligibleNode(node)) { continue; } setRemoteFlag(node, value); appliedCount += 1; } app.graph?.setDirtyCanvas(true, true); const actionLabel = value ? "Enabled" : "Disabled"; notifyModal( appliedCount > 0 ? `${actionLabel} Modal on ${appliedCount} node${appliedCount === 1 ? "" : "s"}.` : "No Modal-eligible nodes were found in the workflow.", ); return appliedCount; } /** * Request required upstream nodes from the backend and set their Modal state in the UI. * @param {LGraphNode} node * @param {boolean} value */ async function analyzeAndSetUpstreamRemoteNodes(node, value) { const seedNodeIds = selectedWorkflowNodePaths(node); const graphSnapshot = await serializeCurrentGraphForModal(); const response = await api.fetchApi(MODAL_ANALYZE_ROUTE, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ prompt: graphSnapshot.output, workflow: graphSnapshot.workflow, seed_node_ids: seedNodeIds, }), }); if (response.status !== 200) { throw new PromptExecutionError(await response.json()); } const result = await response.json(); const resolvedWorkflowNodePaths = result.resolved_workflow_node_paths ?? []; const addedWorkflowNodePaths = result.added_workflow_node_paths ?? []; const appliedCount = setWorkflowNodePathsRemote(resolvedWorkflowNodePaths, value); app.graph?.setDirtyCanvas(true, true); if (!value) { notifyModal( appliedCount > 0 ? `Disabled Modal on ${appliedCount} upstream node${appliedCount === 1 ? "" : "s"}.` : "Modal analysis finished, but no matching live nodes were found to update.", ); return; } if (addedWorkflowNodePaths.length > 0) { notifyModal(`Enabled Modal on ${addedWorkflowNodePaths.length} upstream node${addedWorkflowNodePaths.length === 1 ? "" : "s"}.`); return; } notifyModal( appliedCount > 0 ? "No extra upstream Modal nodes were required." : "Modal analysis finished, but no matching live nodes were found to update.", ); } /** * Return whether one node definition should expose Modal UI affordances. * @param {object | undefined} nodeData * @returns {boolean} */ function isEligibleNodeDef(nodeData) { return Boolean(nodeData?.name) && !String(nodeData.name).startsWith(INTERNAL_NODE_PREFIX); } /** * Inject the Modal context-menu entry on a node type prototype. * @param {typeof LGraphNode} nodeType * @param {object | undefined} nodeData */ function installModalContextMenu(nodeType, nodeData) { if (!isEligibleNodeDef(nodeData) || nodeType?.prototype?.__modalContextMenuInjected) { return; } const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function getExtraMenuOptionsWithModalAnalysis( canvas, options, ) { const menuOptions = originalGetExtraMenuOptions?.call(this, canvas, options) ?? options ?? []; const targetOptions = options ?? menuOptions; if (!Array.isArray(targetOptions)) { return menuOptions; } const selectedNodePaths = selectedWorkflowNodePaths(this); const enableUpstreamMenuItemLabel = selectedNodePaths.length > 1 ? "Enable on Upstream Nodes for Selection" : "Enable on Upstream Nodes"; const disableUpstreamMenuItemLabel = selectedNodePaths.length > 1 ? "Disable on Upstream Nodes for Selection" : "Disable on Upstream Nodes"; if (!targetOptions.some((option) => option?.content === "Modal")) { targetOptions.push(null, { content: "Modal", has_submenu: true, submenu: { options: [ { content: enableUpstreamMenuItemLabel, callback: () => { void analyzeAndSetUpstreamRemoteNodes(this, true).catch((error) => { console.error("Modal remote-node analysis failed.", error); notifyModal(`Modal remote-node analysis failed: ${String(error?.message ?? error)}`); }); }, }, { content: disableUpstreamMenuItemLabel, callback: () => { void analyzeAndSetUpstreamRemoteNodes(this, false).catch((error) => { console.error("Modal remote-node analysis failed.", error); notifyModal(`Modal remote-node analysis failed: ${String(error?.message ?? error)}`); }); }, }, null, { content: "Enable All Nodes", callback: () => { setAllEligibleWorkflowNodesRemote(true); }, }, { content: "Disable All Nodes", callback: () => { setAllEligibleWorkflowNodesRemote(false); }, }, ], }, }); } return menuOptions; }; nodeType.prototype.__modalContextMenuInjected = true; } /** * Update the node state and redraw the canvas. * @param {LGraphNode} node * @param {boolean} value */ function setRemoteFlag(node, value) { node.properties ||= {}; const enabled = Boolean(value); node.properties[REMOTE_PROPERTY] = enabled; if (node.__modalToggleWidget) { node.__modalToggleWidget.value = enabled; } app.graph?.setDirtyCanvas(true, true); } /** * Return the current remote visual state for a node. * @param {LGraphNode} node * @returns {{ phase: string, promptId: string } | null} */ function getRemoteVisualState(node) { const state = modalNodeStates.get(nodeId(node)) ?? null; if (!state?.promptId) { return state; } const promptState = modalPromptStates.get(state.promptId); const progressState = nodeProgressState(nodeId(node), state.promptId); const progressLanes = nodeProgressLanes(nodeId(node), state.promptId); const cachedState = nodeCachedState(nodeId(node), state.promptId); const batchProgressState = modalNodeBatchProgress.get(nodeId(node)) ?? null; const hasLiveProgress = hasLiveNodeProgress(nodeId(node), state.promptId); return { ...state, phase: deriveRemoteNodePhase(state.phase, hasLiveProgress), isActiveRemoteNode: hasLiveProgress || promptState?.activeNodeId === nodeId(node), isActiveComponentMember: isNodeInActiveComponent(state.promptId, nodeId(node)), isCachedRemoteNode: Boolean(cachedState), componentLabel: promptState?.componentLabelByMember.get(nodeId(node)) ?? null, cachedAt: cachedState?.cachedAt ?? null, progress: progressState, batchProgress: batchProgressState?.promptId === state.promptId ? batchProgressState : null, progressLanes, }; } /** * Return the remote node ids that currently have live numeric or lane progress. * @param {string} promptId * @returns {string[]} */ function activeProgressNodeIds(promptId) { const promptState = modalPromptStates.get(promptId); if (!promptState) { return []; } return promptState.remoteNodeIds.filter((candidateNodeId) => hasLiveNodeProgress(candidateNodeId, promptId), ); } /** * Return whether one visible node belongs to any component that is currently executing. * @param {string} promptId * @param {string} nodeIdValue * @returns {boolean} */ function isNodeInActiveComponent(promptId, nodeIdValue) { const promptState = modalPromptStates.get(promptId); if (!promptState) { return false; } const activeComponentNodeIds = new Set(); const promptActiveNodeId = promptState.activeNodeId; if (promptActiveNodeId) { const promptActiveComponentNodeIds = resolveComponentNodeIds(promptId, promptActiveNodeId); if (promptActiveComponentNodeIds?.length) { for (const componentNodeId of promptActiveComponentNodeIds) { activeComponentNodeIds.add(String(componentNodeId)); } } else { activeComponentNodeIds.add(String(promptActiveNodeId)); } } for (const liveProgressNodeId of activeProgressNodeIds(promptId)) { const liveProgressComponentNodeIds = resolveComponentNodeIds(promptId, liveProgressNodeId); if (liveProgressComponentNodeIds?.length) { for (const componentNodeId of liveProgressComponentNodeIds) { activeComponentNodeIds.add(String(componentNodeId)); } } else { activeComponentNodeIds.add(String(liveProgressNodeId)); } } if (activeComponentNodeIds.size === 0) { return false; } const safeNodeIdValue = String(nodeIdValue); if (activeComponentNodeIds.has(safeNodeIdValue)) { return true; } const descendantNodeIds = promptState.descendantNodeIdsByAncestor.get(safeNodeIdValue); if (!descendantNodeIds || descendantNodeIds.size === 0) { return false; } return Array.from(activeComponentNodeIds).some((componentNodeId) => descendantNodeIds.has(componentNodeId)); } /** * Trace one rounded-rectangle path, using the native canvas helper when available. * @param {CanvasRenderingContext2D} ctx * @param {number} x * @param {number} y * @param {number} width * @param {number} height * @param {number} radius */ function traceRoundedRectPath(ctx, x, y, width, height, radius) { const safeRadius = Math.max(0, Math.min(radius, width / 2, height / 2)); ctx.beginPath(); if (typeof ctx.roundRect === "function") { ctx.roundRect(x, y, width, height, safeRadius); return; } ctx.moveTo(x + safeRadius, y); ctx.lineTo(x + width - safeRadius, y); ctx.arcTo(x + width, y, x + width, y + safeRadius, safeRadius); ctx.lineTo(x + width, y + height - safeRadius); ctx.arcTo(x + width, y + height, x + width - safeRadius, y + height, safeRadius); ctx.lineTo(x + safeRadius, y + height); ctx.arcTo(x, y + height, x, y + height - safeRadius, safeRadius); ctx.lineTo(x, y + safeRadius); ctx.arcTo(x, y, x + safeRadius, y, safeRadius); ctx.closePath(); } /** * Draw the remote execution border and shading for a node. * @param {LGraphNode} node * @param {CanvasRenderingContext2D} ctx */ function drawRemoteNodeDecoration(node, ctx) { if (!isRemoteNode(node)) { return; } const state = getRemoteVisualState(node); const titleHeight = node.constructor?.title_height ?? LiteGraph.NODE_TITLE_HEIGHT ?? 24; const scale = app.canvas?.ds?.scale ?? 1; const borderWidth = 3 / scale; const cornerRadius = 12 / scale; const elapsed = performance.now() / 1000; let borderColor = IDLE_BORDER_COLOR; let shadowColor = "rgba(29, 155, 240, 0.35)"; let fillColor = null; if (state?.phase === STATE_SETUP) { const pulse = (Math.sin(elapsed * 5) + 1) / 2; borderColor = `${SETUP_BORDER_COLOR}${Math.round((0.65 + pulse * 0.35) * 255) .toString(16) .padStart(2, "0")}`; shadowColor = `rgba(245, 158, 11, ${0.25 + pulse * 0.35})`; } else if (state?.phase === STATE_STARTING) { const pulse = (Math.sin(elapsed * 8) + 1) / 2; borderColor = `${STARTING_BORDER_COLOR}${Math.round((0.58 + pulse * 0.42) * 255) .toString(16) .padStart(2, "0")}`; shadowColor = `rgba(234, 179, 8, ${0.2 + pulse * 0.42})`; fillColor = `rgba(250, 204, 21, ${0.08 + pulse * 0.08})`; } else if (state?.phase === STATE_READY) { const pulseRate = state?.isCachedRemoteNode ? 2 : 6; const pulse = (Math.sin(elapsed * pulseRate) + 1) / 2; borderColor = state?.isActiveComponentMember ? READY_ACTIVE_COMPONENT_BORDER_COLOR : READY_INACTIVE_COMPONENT_BORDER_COLOR; shadowColor = state?.isActiveComponentMember ? `rgba(34, 197, 94, ${0.24 + pulse * 0.18})` : `rgba(22, 101, 52, ${0.18 + pulse * 0.12})`; fillColor = state?.isActiveComponentMember ? `rgba(134, 239, 172, ${0.1 + pulse * 0.07})` : `rgba(74, 222, 128, ${0.06 + pulse * 0.04})`; } else if (state?.phase === STATE_ACTIVE) { const pulse = (Math.sin(elapsed * 7) + 1) / 2; borderColor = `${ACTIVE_BORDER_COLOR}${Math.round((0.7 + pulse * 0.3) * 255) .toString(16) .padStart(2, "0")}`; shadowColor = `rgba(168, 85, 247, ${0.28 + pulse * 0.32})`; fillColor = `rgba(216, 180, 254, ${0.16 + pulse * 0.1})`; } else if (state?.phase === STATE_COMPLETE) { borderColor = COMPLETE_BORDER_COLOR; shadowColor = "rgba(0, 79, 164, 0.28)"; fillColor = `${COMPLETE_FILL_COLOR}33`; } else if (state?.phase === STATE_FINALIZING) { const pulse = (Math.sin(elapsed * 5) + 1) / 2; borderColor = FINALIZING_NODE_BORDER_COLOR; shadowColor = `rgba(0, 53, 138, ${0.2 + pulse * 0.26})`; fillColor = `${COMPLETE_FILL_COLOR}${Math.round((0.16 + pulse * 0.1) * 255) .toString(16) .padStart(2, "0")}`; } else if (state?.phase === STATE_CANCELLING) { const pulse = (Math.sin(elapsed * 9) + 1) / 2; borderColor = `${CANCELLING_BORDER_COLOR}${Math.round((0.64 + pulse * 0.36) * 255) .toString(16) .padStart(2, "0")}`; shadowColor = `rgba(251, 113, 133, ${0.24 + pulse * 0.36})`; fillColor = `rgba(251, 113, 133, ${0.1 + pulse * 0.08})`; } else if (state?.phase === STATE_ERROR) { const pulse = (Math.sin(elapsed * 6) + 1) / 2; borderColor = `${ERROR_BORDER_COLOR}${Math.round((0.7 + pulse * 0.3) * 255) .toString(16) .padStart(2, "0")}`; shadowColor = `rgba(239, 68, 68, ${0.22 + pulse * 0.28})`; } ctx.save(); if (fillColor) { ctx.fillStyle = fillColor; traceRoundedRectPath( ctx, borderWidth, -titleHeight + borderWidth, Math.max(0, node.size[0] - borderWidth * 2), Math.max(0, node.size[1] + titleHeight - borderWidth * 2), Math.max(0, cornerRadius - borderWidth), ); ctx.fill(); } ctx.strokeStyle = borderColor; ctx.lineWidth = borderWidth; ctx.shadowColor = shadowColor; ctx.shadowBlur = 8 / scale; traceRoundedRectPath( ctx, -borderWidth, -titleHeight, node.size[0] + borderWidth * 2, node.size[1] + titleHeight + borderWidth, cornerRadius, ); ctx.stroke(); ctx.restore(); if (state?.componentLabel) { ctx.save(); const badgeRadius = 10 / scale; const badgeX = 10 / scale; const badgeY = -titleHeight + 10 / scale; ctx.fillStyle = "rgba(15, 23, 42, 0.92)"; ctx.strokeStyle = borderColor; ctx.lineWidth = 1.5 / scale; ctx.shadowColor = shadowColor; ctx.shadowBlur = 8 / scale; ctx.beginPath(); ctx.arc(badgeX, badgeY, badgeRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; ctx.fillStyle = "#f8fafc"; ctx.font = `${Math.max(10 / scale, 8)}px ui-sans-serif, system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(String(state.componentLabel), badgeX, badgeY + 0.5 / scale); ctx.restore(); } const progressLanes = Array.isArray(state?.progressLanes) ? state.progressLanes : []; const setupProgressLanes = progressLanes.filter((laneProgress) => laneProgress.setupOnly); const activeProgressLanes = progressLanes.filter((laneProgress) => !laneProgress.setupOnly); const visibleActiveProgressLanes = activeProgressLanes.filter( (laneProgress) => progressVisualOpacity(laneProgress) > 0, ); const batchProgress = state?.batchProgress ?? null; const progressOpacity = progressVisualOpacity(state?.progress); const batchOpacity = progressVisualOpacity(batchProgress); const laneOpacity = Math.max( 0, ...visibleActiveProgressLanes.map((laneProgress) => progressVisualOpacity(laneProgress)), ...setupProgressLanes.map((laneProgress) => progressVisualOpacity(laneProgress)), ); const progressPanelOpacity = Math.max(progressOpacity, batchOpacity, laneOpacity); const hasAggregateProgress = Boolean( state?.progress && progressOpacity > 0 && [STATE_ACTIVE, STATE_COMPLETE].includes(state.phase), ); const hasSetupLaneProgress = setupProgressLanes.length > 0; const hasLaneProgress = visibleActiveProgressLanes.length > 0 && [STATE_ACTIVE, STATE_COMPLETE].includes(state?.phase); const hasBatchBadge = Boolean( batchProgress && batchOpacity > 0 && [STATE_ACTIVE, STATE_COMPLETE].includes(state?.phase) && !hasAggregateProgress, ); if (!hasAggregateProgress && !hasLaneProgress && !hasSetupLaneProgress && !hasBatchBadge) { return; } ctx.save(); ctx.globalAlpha *= progressPanelOpacity; const barWidth = node.size[0] + borderWidth * 2; const aggregateHeight = 8 / scale; const laneHeight = 5 / scale; const laneGap = 2 / scale; const panelY = node.size[1] + 6 / scale; const panelPaddingX = 6 / scale; const panelPaddingY = 6 / scale; const headerHeight = 16 / scale; const visibleLaneProgress = hasLaneProgress ? visibleActiveProgressLanes : setupProgressLanes; const hasVisibleLaneProgress = visibleLaneProgress.length > 0; const laneBlockHeight = hasVisibleLaneProgress ? visibleLaneProgress.length * laneHeight + (visibleLaneProgress.length - 1) * laneGap : 0; const bodyHeight = (hasVisibleLaneProgress ? laneBlockHeight + laneGap : 0) + (hasAggregateProgress ? aggregateHeight : 0); const panelHeight = panelPaddingY * 2 + headerHeight + bodyHeight; const laneColors = [ "rgba(196, 181, 253, 0.94)", "rgba(147, 197, 253, 0.94)", "rgba(110, 231, 183, 0.94)", "rgba(253, 224, 71, 0.94)", "rgba(251, 146, 60, 0.94)", "rgba(244, 114, 182, 0.94)", ]; const badgeText = hasBatchBadge ? `${Math.round(batchProgress.value)}/${Math.round(batchProgress.max)}` : null; ctx.fillStyle = "rgba(15, 23, 42, 0.88)"; ctx.beginPath(); ctx.roundRect(-borderWidth, panelY, barWidth, panelHeight, 10 / scale); ctx.fill(); ctx.strokeStyle = shadowColor; ctx.lineWidth = 1 / scale; ctx.stroke(); const headerText = hasAggregateProgress ? `${Math.round((state.progress.value / state.progress.max) * 100)}%` : hasVisibleLaneProgress ? `${visibleLaneProgress.length}x` : null; const headerBaselineY = panelY + panelPaddingY + headerHeight / 2; if (headerText) { ctx.fillStyle = "#f8fafc"; ctx.font = `${Math.max(10 / scale, 8)}px ui-sans-serif, system-ui, sans-serif`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(headerText, panelPaddingX, headerBaselineY); } if (badgeText) { ctx.font = `${Math.max(10 / scale, 8)}px ui-sans-serif, system-ui, sans-serif`; const badgePaddingX = 6 / scale; const badgeWidth = ctx.measureText(badgeText).width + badgePaddingX * 2; const badgeHeight = 16 / scale; const badgeX = Math.max(0, node.size[0] - badgeWidth - 4 / scale); const badgeY = panelY + panelPaddingY; ctx.fillStyle = "rgba(2, 6, 23, 0.82)"; ctx.beginPath(); ctx.roundRect(badgeX, badgeY, badgeWidth, badgeHeight, 8 / scale); ctx.fill(); ctx.strokeStyle = "rgba(34, 197, 94, 0.55)"; ctx.lineWidth = 1 / scale; ctx.stroke(); ctx.fillStyle = "#f8fafc"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(badgeText, badgeX + badgeWidth / 2, badgeY + badgeHeight / 2); } const barY = panelY + panelPaddingY + headerHeight + laneGap; if (hasVisibleLaneProgress) { let laneY = barY; const elapsedPulse = (Math.sin(elapsed * 6) + 1) / 2; for (const [laneIndex, laneProgress] of visibleLaneProgress.entries()) { const laneRatio = laneProgress.setupOnly ? 1 : Math.max(0, Math.min(1, laneProgress.value / laneProgress.max)); const laneWidth = Math.max(0, barWidth * laneRatio); ctx.fillStyle = "rgba(15, 23, 42, 0.66)"; ctx.fillRect(-borderWidth, laneY, barWidth, laneHeight); const laneColor = laneColors[laneIndex % laneColors.length]; if (laneProgress.setupOnly) { ctx.fillStyle = laneColor.replace("0.94)", `${0.28 + elapsedPulse * 0.22})`); } else { ctx.fillStyle = laneColor; } ctx.fillRect(-borderWidth, laneY, laneWidth, laneHeight); laneY += laneHeight + laneGap; } } if (hasAggregateProgress) { const progressRatio = Math.max(0, Math.min(1, state.progress.value / state.progress.max)); const progressWidth = Math.max(0, barWidth * progressRatio); const aggregateY = hasVisibleLaneProgress ? barY + laneBlockHeight + laneGap : barY; ctx.fillStyle = "rgba(15, 23, 42, 0.72)"; ctx.fillRect(-borderWidth, aggregateY, barWidth, aggregateHeight); ctx.fillStyle = "rgba(216, 180, 254, 0.92)"; ctx.fillRect(-borderWidth, aggregateY, progressWidth, aggregateHeight); } ctx.restore(); } /** * Inject the toggle widget and remote border behavior into a node. * @param {LGraphNode} node */ function decorateNode(node) { if (!isEligibleNode(node) || node.__modalToggleInjected) { return; } node.properties ||= {}; node.properties[REMOTE_PROPERTY] = Boolean(node.properties[REMOTE_PROPERTY]); const widget = node.addWidget( "toggle", "Run on Modal", node.properties[REMOTE_PROPERTY], (value) => setRemoteFlag(node, value), { on: "Enabled", off: "Disabled", serialize: false, }, ); widget.value = node.properties[REMOTE_PROPERTY]; node.__modalToggleInjected = true; node.__modalToggleWidget = widget; const originalDrawForeground = node.onDrawForeground; node.onDrawForeground = function onDrawForeground(ctx) { originalDrawForeground?.apply(this, arguments); drawRemoteNodeDecoration(this, ctx); }; } /** * Apply an incoming Modal websocket status event. * @param {CustomEvent} event */ function handleModalStatus(event) { const detail = eventDetail(event); const promptId = String(detail.prompt_id ?? ""); if (!promptId) { return; } if (isPromptCancelling(promptId) && ![STATE_ERROR, "execution_interrupted"].includes(detail.phase)) { return; } if (isPromptTerminal(promptId) && detail.phase !== STATE_SETUP) { return; } const nodeIds = (detail.node_ids ?? []).map((value) => String(value)); const components = detail.components ?? []; if (nodeIds.length === 0 && components.length === 0) { return; } if (components.length > 0 || nodeIds.length > 0) { registerPromptComponents(promptId, nodeIds, components); } const promptState = ensurePromptState(promptId); if (!promptState) { return; } if (detail.phase === STATE_SETUP) { if (isPromptQueuedBehindActiveModal(promptId)) { return; } beginSyntheticExecutionUi(promptId, nodeIds); setGlobalStatusPhase(promptId, STATE_SETUP, nodeIds.length, { message: detail.status_message ?? null, current: detail.status_current ?? null, total: detail.status_total ?? null, }); setPromptActiveNode(promptId, null); setNodesPhase(nodeIds, STATE_SETUP, promptId); return; } if (detail.phase === STATE_WAITING) { if (isPromptQueuedBehindActiveModal(promptId)) { return; } setGlobalStatusPhase(promptId, STATE_WAITING, nodeIds.length, { message: detail.status_message ?? null, current: detail.status_current ?? null, total: detail.status_total ?? null, }); setPromptActiveNode(promptId, null); return; } if (detail.phase === STATE_STARTING) { if (isPromptQueuedBehindActiveModal(promptId)) { return; } setGlobalStatusPhase(promptId, STATE_STARTING, nodeIds.length, { message: detail.status_message ?? "Starting Modal component", current: detail.status_current ?? null, total: detail.status_total ?? null, }); setPromptActiveNode(promptId, null); setNodesPhase(nodeIds, STATE_STARTING, promptId); return; } if (detail.phase === STATE_FINALIZING) { clearPromptQueued(promptId); setGlobalStatusPhase(promptId, STATE_FINALIZING, nodeIds.length, { message: detail.status_message ?? null, current: detail.status_current ?? null, total: detail.status_total ?? null, }); setPromptActiveNode(promptId, null); setNodesPhase(nodeIds, STATE_FINALIZING, promptId); return; } if (detail.phase === STATE_ERROR) { clearPromptQueued(promptId); endSyntheticExecutionUi(promptId, true); setGlobalStatusPhase(promptId, STATE_ERROR, nodeIds.length); setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS); clearPromptRemoteNodeVisuals(promptId); markPromptTerminal(promptId, STATE_ERROR); return; } if (detail.phase === "execution_interrupted") { markPromptTerminal(promptId, "execution_interrupted"); clearPromptQueued(promptId); endSyntheticExecutionUi(promptId); handlePromptInterruption(promptId); return; } if (detail.phase === EXECUTION_PHASE) { clearPromptQueued(promptId); endSyntheticExecutionUi(promptId); const nextActiveNodeId = detail.active_node_id != null ? String(detail.active_node_id) : null; const previousActiveNodeId = promptState.activeNodeId; promptState.hasStreamedProgress = true; if (nextActiveNodeId) { promptState.hasRemoteExecutionStarted = true; setGlobalStatusPhase(promptId, EXECUTION_PHASE, nodeIds.length); } else { setGlobalStatusPhase(promptId, STATE_WAITING, nodeIds.length, { message: detail.status_message ?? "Waiting for Modal container", }); } setNodesPhase(nodeIds, STATE_READY, promptId); if ( previousActiveNodeId && previousActiveNodeId !== nextActiveNodeId && nodesShareRemoteComponent(promptId, previousActiveNodeId, nextActiveNodeId) ) { fadeNodeProgress(previousActiveNodeId, promptId); setNodesPhase([previousActiveNodeId], STATE_COMPLETE, promptId); } completeRemoteAncestorsBeforeActiveNode( promptId, detail.completed_ancestor_node_ids, nextActiveNodeId, ); if (nextActiveNodeId) { setNodesPhase([nextActiveNodeId], STATE_ACTIVE, promptId); } setPromptActiveNode(promptId, nextActiveNodeId); return; } if (detail.phase === "execution_success") { clearPromptQueued(promptId); promptState.hasStreamedProgress = true; promptState.hasRemoteExecutionStarted = true; if (promptState.activeNodeId && nodeIds.includes(promptState.activeNodeId)) { fadeNodeProgress(promptState.activeNodeId, promptId); setNodesPhase([promptState.activeNodeId], STATE_COMPLETE, promptId); setPromptActiveNode(promptId, null); } setNodesPhase(nodeIds, STATE_COMPLETE, promptId); return; } } /** * Clear one interrupted prompt's temporary Modal UI state. * @param {string} promptId */ function handlePromptInterruption(promptId) { if (!promptId) { return; } modalCancellingPromptIds.delete(promptId); markPromptTerminal(promptId, "execution_interrupted"); clearGlobalStatusPhase(promptId); clearPromptRemoteStates(promptId); } /** * Apply a streamed numeric Modal node-progress event. * @param {CustomEvent} event */ function handleModalProgress(event) { const detail = eventDetail(event); const promptId = String(detail.prompt_id ?? ""); const progressNodeId = String(detail.real_node_id ?? detail.display_node_id ?? detail.node_id ?? ""); if (!promptId || !progressNodeId) { return; } if (isPromptCancelling(promptId)) { return; } if (isPromptTerminal(promptId)) { return; } clearPromptQueued(promptId); endSyntheticExecutionUi(promptId); const promptState = ensurePromptState(promptId); if (!promptState) { return; } if (!detail.cached_hit) { promptState.hasRemoteExecutionStarted = true; } const componentNodeIds = resolveComponentNodeIds(promptId, progressNodeId); const readyNodeIds = (componentNodeIds ?? []).filter((nodeIdValue) => nodeIdValue !== progressNodeId); promptState.hasStreamedProgress = true; if (detail.aggregate_only) { if (componentNodeIds?.length) { setNodesPhase(componentNodeIds, STATE_READY, promptId); } setGlobalStatusBatchProgress(promptId, Number(detail.value ?? 0), Number(detail.max ?? 1)); setNodeBatchProgress( progressNodeId, promptId, Number(detail.value ?? 0), Number(detail.max ?? 1), ); setGlobalStatusPhase(promptId, EXECUTION_PHASE, promptState.remoteNodeIds.length || 1); return; } if (detail.cached_hit) { const cachedNodeIds = new Set( [detail.node_id, detail.display_node_id, detail.real_node_id] .filter((nodeIdValue) => nodeIdValue != null) .map((nodeIdValue) => String(nodeIdValue)), ); if (cachedNodeIds.size === 0) { cachedNodeIds.add(progressNodeId); } for (const cachedNodeId of cachedNodeIds) { markNodeCached(cachedNodeId, promptId); } setGlobalStatusPhase(promptId, STATE_WAITING, promptState.remoteNodeIds.length || 1, { message: "Restoring Modal cache", }); return; } setGlobalStatusPhase(promptId, EXECUTION_PHASE, promptState.remoteNodeIds.length || 1); if (detail.lane_id != null) { setPromptActiveNode(promptId, null); if (componentNodeIds?.length) { setNodesPhase(componentNodeIds, STATE_READY, promptId); } else { setNodesPhase([progressNodeId], STATE_READY, promptId); } if (detail.clear) { clearNodeProgressLane(progressNodeId, promptId, String(detail.lane_id)); return; } setNodeProgressLane( progressNodeId, promptId, String(detail.lane_id), Number(detail.value ?? 0), Number(detail.max ?? 1), detail.item_index, Boolean(detail.setup_only), ); return; } if (detail.clear) { clearNodeProgress(progressNodeId, promptId); return; } if (readyNodeIds.length > 0) { setNodesPhase(readyNodeIds, STATE_READY, promptId); } const previousActiveNodeId = promptState.activeNodeId; if ( previousActiveNodeId && previousActiveNodeId !== progressNodeId && nodesShareRemoteComponent(promptId, previousActiveNodeId, progressNodeId) ) { fadeNodeProgress(previousActiveNodeId, promptId); setNodesPhase([previousActiveNodeId], STATE_COMPLETE, promptId); } completeRemoteAncestorsBeforeActiveNode( promptId, detail.completed_ancestor_node_ids, progressNodeId, ); setPromptActiveNode(promptId, progressNodeId); setNodesPhase([progressNodeId], STATE_ACTIVE, promptId); setNodeProgress( progressNodeId, promptId, Number(detail.value ?? 0), Number(detail.max ?? 1), ); } /** * Update remote component visuals from a native ComfyUI execution event. * @param {CustomEvent} event * @param {string} phase */ function handleExecutionPhase(event, phase) { const detail = eventDetail(event); const promptId = String(detail.prompt_id ?? ""); const representativeNodeId = String(detail.display_node ?? detail.node ?? detail.node_id ?? ""); if (!promptId || !representativeNodeId) { return; } if (isPromptCancelling(promptId) && phase !== STATE_ERROR) { return; } if (isPromptTerminal(promptId)) { return; } clearPromptQueued(promptId); const componentNodeIds = resolveComponentNodeIds(promptId, representativeNodeId); if (!componentNodeIds) { return; } const promptState = ensurePromptState(promptId); if (promptState.hasStreamedProgress && phase === EXECUTION_PHASE) { return; } if (phase === EXECUTION_PHASE) { setGlobalStatusPhase( promptId, promptState.hasRemoteExecutionStarted ? EXECUTION_PHASE : STATE_WAITING, componentNodeIds.length, promptState.hasRemoteExecutionStarted ? null : { message: "Waiting for Modal container" }, ); setNodesPhase(componentNodeIds, STATE_READY, promptId, detail.exception_message); return; } if (phase === STATE_ERROR) { setGlobalStatusPhase(promptId, STATE_ERROR, componentNodeIds.length); setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS); clearPromptRemoteNodeVisuals(promptId); return; } if (phase === STATE_COMPLETE) { setPromptActiveNode(promptId, null); for (const nodeIdValue of componentNodeIds) { fadeNodeProgress(nodeIdValue, promptId); } setNodesPhase(componentNodeIds, STATE_COMPLETE, promptId, detail.exception_message); reconcilePromptGlobalStatus(promptId); } } /** * Clear all temporary remote execution visuals for a completed prompt. * @param {string} promptId */ function clearPromptRemoteStates(promptId) { const promptState = modalPromptStates.get(promptId); if (!promptState) { pruneGlobalStatusStates(); refreshGlobalStatusElement(); return; } for (const remoteNodeId of promptState.remoteNodeIds) { clearNodeTimer(remoteNodeId); clearNodeProgress(remoteNodeId, promptId); clearNodeCached(remoteNodeId, promptId); const currentState = modalNodeStates.get(remoteNodeId); if (currentState?.promptId === promptId) { modalNodeStates.delete(remoteNodeId); } } for (const ancestorNodeId of promptState.descendantNodeIdsByAncestor.keys()) { clearNodeTimer(ancestorNodeId); clearNodeProgress(ancestorNodeId, promptId); clearNodeCached(ancestorNodeId, promptId); const currentState = modalNodeStates.get(ancestorNodeId); if (currentState?.promptId === promptId) { modalNodeStates.delete(ancestorNodeId); } } modalPromptStates.delete(promptId); clearPromptQueued(promptId); pruneGlobalStatusStates(); refreshGlobalStatusElement(); app.graph?.setDirtyCanvas(true, true); } /** * Clear prompt-scoped remote node visuals while leaving the global status entry intact. * @param {string} promptId */ function clearPromptRemoteNodeVisuals(promptId) { const promptState = modalPromptStates.get(promptId); const visualNodeIds = new Set(); if (promptState) { for (const remoteNodeId of promptState.remoteNodeIds) { visualNodeIds.add(String(remoteNodeId)); } for (const ancestorNodeId of promptState.descendantNodeIdsByAncestor.keys()) { visualNodeIds.add(String(ancestorNodeId)); } } for (const [nodeIdValue, state] of modalNodeStates.entries()) { if (state?.promptId === promptId) { visualNodeIds.add(String(nodeIdValue)); } } for (const nodeIdValue of visualNodeIds) { clearNodeTimer(nodeIdValue); clearNodeProgress(nodeIdValue, promptId); clearNodeCached(nodeIdValue, promptId); const currentState = modalNodeStates.get(nodeIdValue); if (currentState?.promptId === promptId) { modalNodeStates.delete(nodeIdValue); } } setPromptActiveNode(promptId, null); app.graph?.setDirtyCanvas(true, true); } /** * Apply a queue-time failure to all remote nodes in the just-submitted workflow. * @param {string[]} remoteNodeIds * @param {string} promptId * @param {Error} error */ function markQueueFailure(remoteNodeIds, promptId, error) { if (remoteNodeIds.length === 0) { return; } setNodesPhase(remoteNodeIds, STATE_ERROR, promptId, String(error?.message ?? error)); } /** * Dispatch a synthetic frontend API event when the underlying API supports EventTarget semantics. * @param {string} eventType * @param {any} detail */ function dispatchSyntheticApiEvent(eventType, detail) { if (typeof api.dispatchEvent !== "function") { return; } api.dispatchEvent(new CustomEvent(eventType, { detail })); } /** * Return the minimal queue status payload expected by ComfyUI's status listeners. * @param {number} queueRemaining * @returns {{ exec_info: { queue_remaining: number } }} */ function statusPayload(queueRemaining) { return { exec_info: { queue_remaining: queueRemaining, }, }; } /** * Start a synthetic running state so ComfyUI shows active execution while the Modal route is still preparing. * @param {string} promptId * @param {string[]} remoteNodeIds */ function beginSyntheticExecutionUi(promptId, remoteNodeIds) { if (remoteNodeIds.length === 0 || syntheticPromptUiStates.has(promptId)) { return; } clearPromptTerminal(promptId); const displayNode = remoteNodeIds[0]; syntheticPromptUiStates.set(promptId, { displayNode }); setGlobalStatusPhase(promptId, STATE_SETUP, remoteNodeIds.length); dispatchSyntheticApiEvent("status", statusPayload(1)); dispatchSyntheticApiEvent("notification", { id: promptId, value: "Waiting for a machine on Modal.", }); dispatchSyntheticApiEvent("execution_start", { prompt_id: promptId, timestamp: nowMs(), }); dispatchSyntheticApiEvent("executing", displayNode); } /** * End a synthetic running state after real queue/execution events take over or the request fails. * @param {string} promptId * @param {boolean} failed */ function endSyntheticExecutionUi(promptId, failed = false) { const syntheticState = syntheticPromptUiStates.get(promptId); if (!syntheticState) { return; } syntheticPromptUiStates.delete(promptId); if (failed) { setGlobalStatusPhase(promptId, STATE_ERROR, 1); setTimeout(() => clearGlobalStatusPhase(promptId), ERROR_CLEAR_DELAY_MS); } else { clearGlobalStatusPhase(promptId); } dispatchSyntheticApiEvent("notification", { id: promptId, value: "Modal setup finished.", }); dispatchSyntheticApiEvent("status", statusPayload(0)); if (failed) { dispatchSyntheticApiEvent("execution_error", { prompt_id: promptId, node_id: syntheticState.displayNode, node_type: "ModalRemoteComponent", executed: [], exception_message: "Modal queue request failed before prompt execution started.", exception_type: "ModalQueueError", traceback: [], current_inputs: [], current_outputs: [], }); } } /** * Register websocket listeners for Modal and execution status updates. */ function registerExecutionListeners() { if (api.__modalExecutionListenersRegistered) { return; } api.addEventListener("modal_status", handleModalStatus); api.addEventListener("modal_progress", handleModalProgress); api.addEventListener("executing", (event) => handleExecutionPhase(event, EXECUTION_PHASE)); api.addEventListener("executed", (event) => { handleExecutionPhase(event, STATE_COMPLETE); }); api.addEventListener("execution_error", (event) => { const promptId = String(eventDetail(event).prompt_id ?? ""); endSyntheticExecutionUi(promptId, true); handleExecutionPhase(event, STATE_ERROR); markPromptTerminal(promptId, STATE_ERROR); }); api.addEventListener("execution_interrupted", (event) => { const promptId = String(eventDetail(event).prompt_id ?? ""); markPromptTerminal(promptId, "execution_interrupted"); endSyntheticExecutionUi(promptId); handlePromptInterruption(promptId); }); api.addEventListener("execution_success", (event) => { const detail = eventDetail(event); const promptId = String(detail.prompt_id ?? ""); if (!promptId) { return; } markPromptTerminal(promptId, "execution_success"); endSyntheticExecutionUi(promptId); clearGlobalStatusPhase(promptId); clearPromptRemoteStates(promptId); }); if (typeof document !== "undefined") { document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { refreshModalUiAfterVisibilityChange(); } }); } if (typeof window !== "undefined") { window.addEventListener("focus", refreshModalUiAfterVisibilityChange); } api.__modalExecutionListenersRegistered = true; } /** * Patch the queue API so prompt submission goes through the Modal route. */ function patchQueuePrompt() { if (api.__modalQueuePromptPatched) { return; } api.queuePrompt = async function modalQueuePrompt(number, data, options) { const { output: prompt, workflow } = data; const promptId = createPromptId(); clearPromptTerminal(promptId); const remoteNodeIds = extractRemoteNodeIds(workflow); registerPromptComponents(promptId, remoteNodeIds, []); const queuedBehindActiveModal = remoteNodeIds.length > 0 && markPromptQueuedBehindActiveModal(promptId); if (remoteNodeIds.length > 0) { if (!queuedBehindActiveModal) { setNodesPhase(remoteNodeIds, STATE_SETUP, promptId); beginSyntheticExecutionUi(promptId, remoteNodeIds); } } const body = { client_id: this.clientId ?? "", prompt_id: promptId, prompt, ...(options?.partialExecutionTargets && { partial_execution_targets: options.partialExecutionTargets, }), extra_data: { auth_token_comfy_org: this.authToken, api_key_comfy_org: this.apiKey, extra_pnginfo: { workflow }, }, }; if (number === -1) { body.front = true; } else if (number !== 0) { body.number = number; } try { const response = await this.fetchApi(MODAL_ROUTE, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (response.status !== 200) { throw new PromptExecutionError(await response.json()); } const responsePayload = await response.json(); if (remoteNodeIds.length > 0) { const resolvedRemoteNodeIds = (responsePayload.modal_remote_node_ids ?? []).map((nodeIdValue) => String(nodeIdValue), ); const resolvedComponents = Array.isArray(responsePayload.modal_components) ? responsePayload.modal_components : []; if (resolvedRemoteNodeIds.length > 0 || resolvedComponents.length > 0) { registerPromptComponents(promptId, resolvedRemoteNodeIds, resolvedComponents); } const promptState = ensurePromptState(promptId); const acceptedRemoteNodeIds = promptState.remoteNodeIds.length > 0 ? promptState.remoteNodeIds : remoteNodeIds; if (!isPromptQueuedBehindActiveModal(promptId)) { endSyntheticExecutionUi(promptId); setGlobalStatusPhase(promptId, STATE_WAITING, acceptedRemoteNodeIds.length, { message: "Waiting for Modal startup", }); setNodesPhase(acceptedRemoteNodeIds, STATE_READY, promptId); } } return responsePayload; } catch (error) { clearPromptQueued(promptId); endSyntheticExecutionUi(promptId, true); markQueueFailure(remoteNodeIds, promptId, error); throw error; } }; api.__modalQueuePromptPatched = true; } /** * Install CSS keyframes used by the global Modal status badge. */ function installGlobalStatusStyles() { if (typeof document === "undefined" || document.getElementById("comfy-modal-status-styles")) { return; } const style = document.createElement("style"); style.id = "comfy-modal-status-styles"; style.textContent = ` @keyframes modal-status-pulse { 0% { transform: scale(0.9); opacity: 0.7; } 50% { transform: scale(1.08); opacity: 1; } 100% { transform: scale(0.9); opacity: 0.7; } } `; document.head.appendChild(style); } app.registerExtension({ name: "Comfy.ModalSync.Toggle", async init() { installGlobalStatusStyles(); patchInterruptFeedback(); patchQueuePrompt(); registerExecutionListeners(); }, async beforeRegisterNodeDef(nodeType, nodeData) { installModalContextMenu(nodeType, nodeData); }, async nodeCreated(node) { decorateNode(node); }, });