>} configurations
*/
function registerRemoteConfiguratorPlan(promptId, assignments, configurations) {
const promptState = ensurePromptState(promptId);
if (!promptState) {
return;
}
promptState.remoteExecutionPlanAssignments = { ...(assignments ?? {}) };
promptState.remoteExecutionConfigurations = [...(configurations ?? [])];
registerPromptExecutionAssignments(promptId, assignments ?? {});
const panel = remoteConfiguratorPanel(promptState.configuratorNodeId);
if (!panel) {
return;
}
panel.promptId = promptId;
renderRemoteConfiguratorPlan(
panel,
promptState.remoteExecutionPlanAssignments,
promptState.remoteExecutionConfigurations,
);
}
/** Restore the newest retained prompt onto a newly mounted configurator panel. */
function restoreRemoteConfiguratorPanel(panel) {
const nodeIdentifier = nodeId(panel?.node);
const candidates = Array.from(modalPromptStates.entries())
.filter(([, state]) => state?.configuratorNodeId === nodeIdentifier)
.sort(([, left], [, right]) => Number(right?.startedAt ?? 0) - Number(left?.startedAt ?? 0));
const newest = candidates[0];
if (!newest) {
return;
}
const [promptId, promptState] = newest;
panel.promptId = promptId;
renderRemoteConfiguratorPlan(
panel,
promptState.remoteExecutionPlanAssignments,
promptState.remoteExecutionConfigurations,
);
renderRemoteConfiguratorStatus(panel, modalGlobalStatusStates.get(promptId) ?? {});
}
/**
* Refresh the node-local status from the same durable state used by the global pill.
* @param {string} promptId
*/
function refreshRemoteConfiguratorPanelForPrompt(promptId) {
const promptState = modalPromptStates.get(promptId);
const panel = remoteConfiguratorPanel(promptState?.configuratorNodeId);
if (!panel || panel.promptId !== promptId) {
return;
}
renderRemoteConfiguratorStatus(panel, modalGlobalStatusStates.get(promptId) ?? {});
}
/**
* Preserve a terminal outcome on the configurator after prompt state is cleaned up.
* @param {string} promptId
* @param {string} phase
* @param {string | null} message
*/
function setRemoteConfiguratorTerminalStatus(promptId, phase, message = null) {
const promptState = modalPromptStates.get(promptId);
const panel = remoteConfiguratorPanel(promptState?.configuratorNodeId);
if (!panel || panel.promptId !== promptId) {
return;
}
renderRemoteConfiguratorStatus(panel, { phase, statusMessage: message });
}
/**
* Remove Configurator DOM widgets left behind by an older extension module.
* @param {LGraphNode} node
*/
function removeStaleRemoteConfiguratorWidgets(node) {
const staleWidgets = (node.widgets ?? []).filter(
(widget) => String(widget?.name ?? "") === "remote_execution_plan",
);
for (const widget of staleWidgets) {
widget?.element?.remove?.();
if (typeof node.removeWidget === "function") {
node.removeWidget(widget);
continue;
}
widget?.onRemove?.();
const widgetIndex = node.widgets?.indexOf(widget) ?? -1;
if (widgetIndex >= 0) {
node.widgets.splice(widgetIndex, 1);
}
}
}
/**
* Mount the non-serialized planning and status surface on the configurator node.
* @param {LGraphNode} node
*/
function mountRemoteExecutionConfiguratorPanel(node) {
const safeNodeId = nodeId(node);
if (
!isRemoteExecutionConfiguratorNode(node) ||
(node.__remoteConfiguratorPanelMounted && remoteConfiguratorPanels.has(safeNodeId)) ||
typeof node.addDOMWidget !== "function" ||
typeof document === "undefined"
) {
return;
}
removeStaleRemoteConfiguratorWidgets(node);
const root = document.createElement("div");
root.className = "comfy-remote-configurator-panel";
root.innerHTML = `
Ready to plan remote execution
Execution plan
The selected environments will appear here after planning.
`;
const panel = {
node,
root,
promptId: null,
minHeight: 149,
statusText: root.querySelector(".remote-configurator-status-text"),
progress: root.querySelector(".remote-configurator-progress"),
progressFill: root.querySelector(".remote-configurator-progress-fill"),
progressValue: root.querySelector(".remote-configurator-progress-value"),
emptyText: root.querySelector(".remote-configurator-empty"),
table: root.querySelector(".remote-configurator-table"),
tableBody: root.querySelector("tbody"),
};
let widget;
try {
widget = node.addDOMWidget(
"remote_execution_plan",
"remote-execution-plan",
root,
{
serialize: false,
hideOnZoom: false,
getMinHeight: () => panel.minHeight,
},
);
} catch (error) {
node.__remoteConfiguratorPanelMounted = false;
console.warn("Unable to mount the remote execution configurator panel.", error);
return;
}
if (widget) {
widget.serialize = false;
}
node.setSize?.([
Math.max(Number(node.size?.[0]) || 0, 500),
Math.max(Number(node.size?.[1]) || 0, panel.minHeight),
]);
remoteConfiguratorPanels.set(safeNodeId, panel);
node.__remoteConfiguratorPanelMounted = true;
restoreRemoteConfiguratorPanel(panel);
const originalOnRemoved = node.onRemoved;
node.onRemoved = function onRemoved() {
remoteConfiguratorPanels.delete(nodeId(this));
return originalOnRemoved?.apply(this, arguments);
};
}
/**
* 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(":");
}
/**
* Rebuild the workflow-level index of Modal-enabled leaves beneath each subgraph node.
*/
function rebuildRemoteDescendantIndex() {
modalRemoteDescendantNodeIdsByAncestor.clear();
for (const node of allWorkflowNodes()) {
if (!isRemoteNode(node)) {
continue;
}
const remoteNodePath = workflowNodePath(node);
for (const ancestorNodeId of ancestorNodeIds(remoteNodePath)) {
if (!modalRemoteDescendantNodeIdsByAncestor.has(ancestorNodeId)) {
modalRemoteDescendantNodeIdsByAncestor.set(ancestorNodeId, new Set());
}
modalRemoteDescendantNodeIdsByAncestor.get(ancestorNodeId).add(remoteNodePath);
}
}
}
/**
* Rebuild descendant containment after ComfyUI completes a graph mutation.
*/
function scheduleRemoteDescendantIndexRebuild() {
if (remoteDescendantIndexRebuildScheduled) {
return;
}
remoteDescendantIndexRebuildScheduled = true;
setTimeout(() => {
remoteDescendantIndexRebuildScheduled = false;
rebuildRemoteDescendantIndex();
refreshNodeDecorations();
}, 0);
}
/**
* 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,
});
}
/**
* Request one local Modal maintenance action from the backend.
* @param {string} route
* @param {string} successMessage
*/
async function requestModalMaintenance(route, successMessage) {
if (typeof api.fetchApi !== "function") {
throw new Error("ComfyUI API fetch is unavailable.");
}
const response = await api.fetchApi(route, { method: "POST" });
if (response.status !== 200) {
throw new PromptExecutionError(await response.json());
}
notifyModal(successMessage);
}
/**
* 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;
}
refreshNodeDecorations();
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);
setSandwichedLocalNodeIds(value ? (result.sandwiched_local_node_ids ?? []) : []);
refreshNodeDecorations();
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) &&
!LOCAL_MODAL_NODE_IDS.has(String(nodeData.name))
);
}
/**
* 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 === "Remote Execution")) {
const executionPreferences = selectedExecutionPreferences();
const configuredCapacity = usesRemoteExecutionConfigurator();
const executionPolicyLabel =
EXECUTION_POLICIES.find(([value]) => value === executionPreferences.policy)?.[1]
?? executionPreferences.policy;
targetOptions.push(null, {
content: "Remote Execution",
has_submenu: true,
submenu: {
options: [
...(configuredCapacity
? [{
content: "Providers and capacity come from Remote Execution Configurator",
disabled: true,
}]
: [
{
content: `Legacy provider policy (${executionPolicyLabel})`,
disabled: true,
},
...EXECUTION_POLICIES.map(([value, label]) => ({
content: label,
checked: executionPreferences.policy === value,
callback: () => setExecutionPreferences({ policy: value }),
})),
]),
null,
{
content: "Automatically place eligible nodes",
checked: executionPreferences.autoPlace,
callback: () => setExecutionPreferences({
autoPlace: !selectedExecutionPreferences().autoPlace,
}),
},
null,
{
content: "Manage SSH hosts…",
callback: () => {
void openRemoteEnvironmentManager().catch((error) => {
console.error("Remote environment manager failed.", error);
notifyModal(`Remote environment manager failed: ${String(error?.message ?? error)}`);
});
},
},
],
},
});
}
if (!targetOptions.some((option) => option?.content === "Remote Execution Tools")) {
const currentModalGpu = selectedModalGpu();
const configuredCapacity = usesRemoteExecutionConfigurator();
targetOptions.push(null, {
content: "Remote Execution Tools",
has_submenu: true,
submenu: {
options: [
...(configuredCapacity
? []
: [
{
content: `Legacy Modal GPU (${currentModalGpu})`,
disabled: true,
},
...MODAL_GPU_TYPES.map((modalGpu) => ({
content: modalGpu,
checked: modalGpu === currentModalGpu,
callback: () => setSelectedModalGpu(modalGpu),
})),
null,
]),
{
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);
},
},
{
content: "Delete Modal Caches",
callback: () => {
void requestModalMaintenance(
MODAL_DELETE_CACHES_ROUTE,
"Deleted Modal caches.",
).catch((error) => {
console.error("Modal cache deletion failed.", error);
notifyModal(`Modal cache deletion failed: ${String(error?.message ?? error)}`);
});
},
},
{
content: "Delete Modal Volume",
callback: () => {
if (typeof window !== "undefined" && typeof window.confirm === "function") {
const confirmed = window.confirm("Delete the configured Modal volume?");
if (!confirmed) {
return;
}
}
void requestModalMaintenance(
MODAL_DELETE_VOLUME_ROUTE,
"Deleted Modal volume.",
).catch((error) => {
console.error("Modal volume deletion failed.", error);
notifyModal(`Modal volume deletion failed: ${String(error?.message ?? error)}`);
});
},
},
],
},
});
}
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;
}
rebuildRemoteDescendantIndex();
clearSandwichedLocalNodeWarnings();
refreshNodeDecorations();
}
/**
* Reconcile a restored node property with the toggle value displayed to the user.
* @param {LGraphNode} node
*/
function synchronizeRemoteFlagFromWidget(node) {
if (!isEligibleNode(node) || !node.__modalToggleWidget) {
return;
}
const enabled = Boolean(node.__modalToggleWidget.value);
node.properties ||= {};
if (node.properties[REMOTE_PROPERTY] === enabled) {
return;
}
node.properties[REMOTE_PROPERTY] = enabled;
rebuildRemoteDescendantIndex();
clearSandwichedLocalNodeWarnings();
refreshNodeDecorations();
}
/**
* Return the current remote visual state for a node.
* @param {LGraphNode} node
* @returns {{ phase: string, promptId: string } | null}
*/
function getRemoteVisualState(node) {
const visualNodeId = workflowNodePath(node) || nodeId(node);
const storedState = modalNodeStates.get(visualNodeId) ?? null;
const promptContainerState = storedState?.promptId
? remoteContainerVisualState(storedState.promptId, visualNodeId, storedState.errorMessage)
: null;
const staticRemoteDescendantCount = hasRemoteDescendants(node)
? (modalRemoteDescendantNodeIdsByAncestor.get(visualNodeId)?.size ?? 0)
: 0;
const state =
promptContainerState ??
storedState ??
(staticRemoteDescendantCount > 0
? {
phase: "idle",
promptId: null,
isRemoteContainer: true,
isMixedRemoteContainer: false,
phaseCounts: {},
remoteDescendantCount: staticRemoteDescendantCount,
}
: null);
if (!state?.promptId) {
return state;
}
const promptState = modalPromptStates.get(state.promptId);
const progressState = nodeProgressState(visualNodeId, state.promptId);
const progressLanes = nodeProgressLanes(visualNodeId, state.promptId);
const cachedState = nodeCachedState(visualNodeId, state.promptId);
const batchProgressState = modalNodeBatchProgress.get(visualNodeId) ?? null;
const hasLiveProgress = hasLiveNodeProgress(visualNodeId, state.promptId);
const executionAssignment =
promptState?.executionAssignmentByMember.get(visualNodeId) ?? null;
return {
...state,
phase: deriveRemoteNodePhase(state.phase, hasLiveProgress),
isActiveRemoteNode: hasLiveProgress || promptState?.activeNodeId === visualNodeId,
isActiveComponentMember: isNodeInActiveComponent(state.promptId, visualNodeId),
isCachedRemoteNode: Boolean(cachedState),
componentLabel: state.isRemoteContainer
? null
: (promptState?.componentLabelByMember.get(visualNodeId) ?? null),
cachedAt: cachedState?.cachedAt ?? null,
progress: progressState,
batchProgress: batchProgressState?.promptId === state.promptId ? batchProgressState : null,
progressLanes,
scheduledEnvironmentCount: promptState?.scheduledEnvironmentCount ?? 0,
executionLocation: executionAssignment?.location
? {
provider: executionAssignment.provider,
label: executionAssignment.location,
}
: null,
};
}
/**
* 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));
}
/**
* Return preparation-phase colors for one remote visual state.
* @param {Record | null} state
* @param {number} elapsed
* @returns {{ borderColor: string, shadowColor: string, fillColor: string | null } | null}
*/
function remotePreparationPalette(state, elapsed) {
let borderColor;
let shadowColor;
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 {
return null;
}
return { borderColor, shadowColor, fillColor };
}
/**
* Return execution-phase colors for one remote visual state.
* @param {Record | null} state
* @param {number} elapsed
* @returns {{ borderColor: string, shadowColor: string, fillColor: string | null } | null}
*/
function remoteExecutionPalette(state, elapsed) {
let borderColor;
let shadowColor;
let fillColor = null;
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})`;
} else {
return null;
}
return { borderColor, shadowColor, fillColor };
}
/**
* Return the border, glow, and fill colors for one remote visual state.
* @param {Record | null} state
* @param {number} elapsed
* @returns {{ borderColor: string, shadowColor: string, fillColor: string | null }}
*/
function remoteDecorationPalette(state, elapsed) {
return (
remotePreparationPalette(state, elapsed) ??
remoteExecutionPalette(state, elapsed) ?? {
borderColor: IDLE_BORDER_COLOR,
shadowColor: "rgba(29, 155, 240, 0.35)",
fillColor: null,
}
);
}
/**
* Return the visible LiteGraph node matching a Nodes 2.0 DOM element.
* @param {HTMLElement} nodeElement
* @returns {LGraphNode | null}
*/
function vueNodeForElement(nodeElement) {
const graph = app.canvas?.graph;
const graphNodes = graph?.nodes ?? graph?._nodes ?? [];
const elementNodeId = String(nodeElement.dataset.nodeId ?? "");
return graphNodes.find((node) => nodeId(node) === elementNodeId) ?? null;
}
/**
* Remove Modal's Nodes 2.0 decoration from one DOM node.
* @param {HTMLElement} nodeElement
*/
function clearVueNodeDecoration(nodeElement) {
nodeElement.classList.remove("comfy-modal-vue-node");
delete nodeElement.dataset.modalPhase;
delete nodeElement.dataset.modalContainer;
nodeElement.querySelector(":scope > .comfy-modal-vue-node-decoration")?.remove();
}
/**
* Describe the remote work summarized by a subgraph container.
* @param {Record | null | undefined} state
* @returns {string}
*/
function remoteContainerTooltip(state) {
const descendantCount = Number(state?.remoteDescendantCount ?? 0);
const phaseCounts = state?.phaseCounts ?? {};
const phaseSummary = [
STATE_ERROR,
STATE_CANCELLING,
STATE_ACTIVE,
STATE_STARTING,
STATE_SETUP,
STATE_READY,
STATE_FINALIZING,
STATE_COMPLETE,
]
.filter((phase) => (phaseCounts[phase] ?? 0) > 0)
.map((phase) => `${phaseCounts[phase]} ${phase}`)
.join(", ");
const nodeLabel = descendantCount === 1 ? "node" : "nodes";
if (!phaseSummary) {
return `${descendantCount} descendant ${nodeLabel} set to execute on Modal.`;
}
return `${descendantCount} Modal descendant ${nodeLabel}: ${phaseSummary}.`;
}
/**
* Return the decoration element for one Nodes 2.0 node, creating it when needed.
* @param {HTMLElement} nodeElement
* @returns {HTMLDivElement}
*/
function ensureVueNodeDecoration(nodeElement) {
let decoration = nodeElement.querySelector(":scope > .comfy-modal-vue-node-decoration");
if (!decoration) {
decoration = document.createElement("div");
decoration.className = "comfy-modal-vue-node-decoration";
decoration.setAttribute("aria-hidden", "true");
const badge = document.createElement("span");
badge.className = "comfy-modal-vue-node-badge";
decoration.appendChild(badge);
nodeElement.appendChild(decoration);
}
if (decoration.querySelector(".comfy-modal-vue-execution-location")) {
return decoration;
}
const executionLocation = document.createElement("div");
executionLocation.className = "comfy-modal-vue-execution-location";
executionLocation.hidden = true;
const executionLocationIcon = document.createElement("img");
executionLocationIcon.className = "comfy-modal-vue-execution-location-icon";
executionLocationIcon.alt = "";
executionLocationIcon.setAttribute("aria-hidden", "true");
const executionLocationLabel = document.createElement("span");
executionLocationLabel.className = "comfy-modal-vue-execution-location-label";
executionLocation.append(executionLocationIcon, executionLocationLabel);
decoration.appendChild(executionLocation);
return decoration;
}
/**
* Update the execution-location badge rendered by the Nodes 2.0 DOM layer.
* @param {HTMLDivElement} decoration
* @param {Record | null} state
*/
function updateVueExecutionLocation(decoration, state) {
const executionLocation = decoration.querySelector(
".comfy-modal-vue-execution-location",
);
if (!executionLocation) {
return;
}
const location =
Number(state?.scheduledEnvironmentCount ?? 0) > 1 &&
(state?.isActiveRemoteNode || state?.isActiveComponentMember)
? state?.executionLocation
: null;
const label = String(location?.label ?? "");
const provider = String(location?.provider ?? "");
const iconSource = REMOTE_LOCATION_ICON_SOURCES[provider] ?? "";
executionLocation.hidden = !label;
if (!label) {
executionLocation.removeAttribute("title");
return;
}
executionLocation.dataset.provider = provider;
executionLocation.title = label;
const icon = executionLocation.querySelector(".comfy-modal-vue-execution-location-icon");
const labelElement = executionLocation.querySelector(".comfy-modal-vue-execution-location-label");
if (icon) {
icon.hidden = !iconSource;
if (iconSource && icon.getAttribute("src") !== iconSource) {
icon.setAttribute("src", iconSource);
}
}
if (labelElement) {
labelElement.textContent = label;
}
}
/**
* Apply Modal's current visual state to one Nodes 2.0 DOM node.
* @param {HTMLElement} nodeElement
* @param {LGraphNode} node
* @param {number} timestamp
*/
function updateVueNodeDecoration(nodeElement, node, timestamp) {
const localBottleneck = isSandwichedLocalNode(node);
const state = getRemoteVisualState(node);
if (!isRemoteNode(node) && !state?.isRemoteContainer && !localBottleneck) {
clearVueNodeDecoration(nodeElement);
return;
}
const palette = localBottleneck ? null : remoteDecorationPalette(state, timestamp / 1000);
const decoration = ensureVueNodeDecoration(nodeElement);
const innerWrapper = nodeElement.querySelector(':scope > [data-testid="node-inner-wrapper"]');
const borderRadius =
innerWrapper && typeof getComputedStyle === "function"
? getComputedStyle(innerWrapper).borderRadius
: "12px";
decoration.style.borderColor = palette?.borderColor ?? "transparent";
decoration.style.backgroundColor = palette?.fillColor ?? "transparent";
decoration.style.borderRadius = borderRadius || "12px";
decoration.style.boxShadow = palette ? `0 0 8px ${palette.shadowColor}` : "none";
const phase = localBottleneck ? "local-bottleneck" : (state?.phase ?? "idle");
nodeElement.classList.add("comfy-modal-vue-node");
nodeElement.dataset.modalPhase = phase;
if (state?.isRemoteContainer) {
nodeElement.dataset.modalContainer = "true";
} else {
delete nodeElement.dataset.modalContainer;
}
const badge = decoration.querySelector(".comfy-modal-vue-node-badge");
const badgeText = localBottleneck
? "!"
: (state?.isRemoteContainer ? "Σ" : String(state?.componentLabel ?? ""));
badge.textContent = badgeText;
badge.hidden = !badgeText;
badge.title = localBottleneck
? LOCAL_BOTTLENECK_TOOLTIP
: (state?.isRemoteContainer ? remoteContainerTooltip(state) : "");
badge.style.borderColor = localBottleneck
? LOCAL_BOTTLENECK_BADGE_BORDER_COLOR
: palette.borderColor;
badge.style.boxShadow = localBottleneck ? "none" : `0 0 8px ${palette.shadowColor}`;
updateVueExecutionLocation(decoration, state);
}
/**
* Synchronize Modal decorations onto all currently rendered Nodes 2.0 nodes.
* @param {number | undefined} timestamp
*/
function syncVueNodeDecorations(timestamp = performance.now()) {
if (typeof document === "undefined") {
return;
}
for (const nodeElement of document.querySelectorAll(".lg-node[data-node-id]")) {
const node = vueNodeForElement(nodeElement);
if (node) {
updateVueNodeDecoration(nodeElement, node, timestamp);
} else {
clearVueNodeDecoration(nodeElement);
}
}
}
/**
* Schedule one Nodes 2.0 DOM synchronization after Vue has rendered.
*/
function queueVueNodeDecorationSync() {
if (typeof document === "undefined" || vueNodeSyncScheduled) {
return;
}
vueNodeSyncScheduled = true;
const callback = (timestamp) => {
vueNodeSyncScheduled = false;
syncVueNodeDecorations(timestamp);
};
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(callback);
} else {
setTimeout(() => callback(performance.now()), 0);
}
}
/**
* Redraw both the legacy canvas and the Nodes 2.0 DOM layer.
*/
function refreshNodeDecorations() {
app.graph?.setDirtyCanvas(true, true);
queueVueNodeDecorationSync();
}
/**
* Watch for Vue nodes mounted after workflow loads or renderer switches.
*/
function installVueNodeDecorationObserver() {
if (vueNodeObserver || typeof MutationObserver === "undefined" || !document.body) {
return;
}
vueNodeObserver = new MutationObserver((records) => {
const addedVueNode = records.some((record) =>
Array.from(record.addedNodes).some(
(addedNode) =>
addedNode.nodeType === 1 &&
(addedNode.matches?.(".lg-node[data-node-id]") ||
addedNode.querySelector?.(".lg-node[data-node-id]")),
),
);
if (addedVueNode) {
queueVueNodeDecorationSync();
}
});
vueNodeObserver.observe(document.body, { childList: true, subtree: true });
queueVueNodeDecorationSync();
}
/**
* 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();
}
/**
* Return whether a node-local pointer position is over the local-warning badge.
* @param {LGraphNode} node
* @param {number[] | undefined} localPosition
* @param {number} scale
* @returns {boolean}
*/
function localBottleneckBadgeContainsPoint(node, localPosition, scale = 1) {
if (!Array.isArray(localPosition) || localPosition.length < 2) {
return false;
}
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const titleHeight = node.constructor?.title_height ?? LiteGraph.NODE_TITLE_HEIGHT ?? 24;
const badgeRadius = 10 / safeScale;
const badgeX = 10 / safeScale;
const badgeY = -titleHeight + 10 / safeScale;
const deltaX = Number(localPosition[0]) - badgeX;
const deltaY = Number(localPosition[1]) - badgeY;
return deltaX * deltaX + deltaY * deltaY <= badgeRadius * badgeRadius;
}
/**
* Set or clear native hover text for a legacy canvas Modal badge.
* @param {LGraphCanvas | undefined} graphCanvas
* @param {string} tooltip
*/
function updateLegacyModalTooltip(graphCanvas, tooltip) {
const canvasElement = graphCanvas?.canvas ?? app.canvas?.canvas;
if (!canvasElement) {
return;
}
if (tooltip) {
canvasElement.title = tooltip;
} else if (
canvasElement.title === LOCAL_BOTTLENECK_TOOLTIP ||
canvasElement.title?.includes("Modal descendant") ||
canvasElement.title?.includes("descendant node")
) {
canvasElement.removeAttribute("title");
}
}
/**
* Draw a clipped provider icon and runtime identity beneath node progress bars.
* @param {CanvasRenderingContext2D} ctx
* @param {{ provider?: string, label?: string }} location
* @param {number} rowY
* @param {number} rowHeight
* @param {number} nodeWidth
* @param {number} paddingX
* @param {number} scale
*/
function drawRemoteExecutionLocation(
ctx,
location,
rowY,
rowHeight,
nodeWidth,
paddingX,
scale,
) {
const provider = String(location?.provider ?? "");
const label = String(location?.label ?? "");
if (!label) {
return;
}
const icon = remoteLocationIcon(provider);
const iconHeight = 10 / scale;
const iconWidth = (provider === "modal" ? 19 : 10) / scale;
const iconX = paddingX;
const iconY = rowY + (rowHeight - iconHeight) / 2;
if (icon?.complete && Number(icon.naturalWidth) > 0 && typeof ctx.drawImage === "function") {
ctx.drawImage(icon, iconX, iconY, iconWidth, iconHeight);
}
const textX = iconX + iconWidth + 5 / scale;
const availableTextWidth = Math.max(0, nodeWidth - paddingX - textX);
ctx.fillStyle = "#cbd5e1";
ctx.font = `${Math.max(8 / scale, 6.5)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(
fitCanvasText(ctx, label, availableTextWidth),
textX,
rowY + rowHeight / 2,
);
}
/**
* Draw the Modal execution decoration or neutral local-bottleneck badge for a node.
* @param {LGraphNode} node
* @param {CanvasRenderingContext2D} ctx
*/
function drawModalNodeDecoration(node, ctx) {
const localBottleneck = isSandwichedLocalNode(node);
const state = getRemoteVisualState(node);
if (!isRemoteNode(node) && !state?.isRemoteContainer && !localBottleneck) {
return;
}
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;
const palette = localBottleneck ? null : remoteDecorationPalette(state, elapsed);
if (palette) {
ctx.save();
if (palette.fillColor) {
ctx.fillStyle = palette.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 = palette.borderColor;
ctx.lineWidth = borderWidth;
ctx.shadowColor = palette.shadowColor;
ctx.shadowBlur = 8 / scale;
traceRoundedRectPath(
ctx,
-borderWidth,
-titleHeight,
node.size[0] + borderWidth * 2,
node.size[1] + titleHeight + borderWidth,
cornerRadius,
);
ctx.stroke();
ctx.restore();
}
const nodeBadgeText = localBottleneck
? "!"
: (state?.isRemoteContainer ? "Σ" : state?.componentLabel);
if (nodeBadgeText) {
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 = localBottleneck
? LOCAL_BOTTLENECK_BADGE_BORDER_COLOR
: palette.borderColor;
ctx.lineWidth = 1.5 / scale;
ctx.shadowColor = localBottleneck ? "transparent" : palette.shadowColor;
ctx.shadowBlur = localBottleneck ? 0 : 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(nodeBadgeText), 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) ||
(state.phase === STATE_STARTING && state.progress.preGpu)
),
);
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,
);
const executionLocation =
Number(state?.scheduledEnvironmentCount ?? 0) > 1 &&
(state?.isActiveRemoteNode || state?.isActiveComponentMember)
? state?.executionLocation
: null;
const hasExecutionLocation = Boolean(executionLocation?.label);
if (
!hasAggregateProgress &&
!hasLaneProgress &&
!hasSetupLaneProgress &&
!hasBatchBadge &&
!hasExecutionLocation
) {
return;
}
ctx.save();
ctx.globalAlpha *= Math.max(progressPanelOpacity, hasExecutionLocation ? 1 : 0);
const barWidth = node.size[0] + borderWidth * 2;
const aggregateHeight = 12 / scale;
const laneHeight = 12 / scale;
const laneGap = 3 / scale;
const panelY = node.size[1] + 6 / scale;
const panelPaddingX = 6 / scale;
const panelPaddingY = 6 / scale;
const visibleLaneProgress = hasLaneProgress ? visibleActiveProgressLanes : setupProgressLanes;
const hasVisibleLaneProgress = visibleLaneProgress.length > 0;
const hasProgressContent =
hasAggregateProgress || hasVisibleLaneProgress || hasBatchBadge;
const headerHeight = hasProgressContent ? 16 / scale : 0;
const hasIterationRateLabels = hasAggregateProgress || hasLaneProgress;
const progressBarWidth = barWidth;
const laneBlockHeight = hasVisibleLaneProgress
? visibleLaneProgress.length * laneHeight + (visibleLaneProgress.length - 1) * laneGap
: 0;
const progressBodyHeight =
(hasVisibleLaneProgress ? laneBlockHeight + laneGap : 0) + (hasAggregateProgress ? aggregateHeight : 0);
const progressTopGap = progressBodyHeight > 0 ? laneGap : 0;
const locationRowHeight = 13 / scale;
const locationTopGap = hasExecutionLocation && hasProgressContent ? 5 / scale : 0;
const panelHeight =
panelPaddingY * 2 +
headerHeight +
progressTopGap +
progressBodyHeight +
locationTopGap +
(hasExecutionLocation ? locationRowHeight : 0);
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 = palette?.shadowColor ?? LOCAL_BOTTLENECK_BADGE_BORDER_COLOR;
ctx.lineWidth = 1 / scale;
ctx.stroke();
const aggregateProgress = hasAggregateProgress ? state.progress : null;
const progressPercent = aggregateProgress
? Math.round((aggregateProgress.value / aggregateProgress.max) * 100)
: null;
const llmTimingLabel = aggregateProgress?.timeToFirstTokenSeconds != null
? `${aggregateProgress.stage === "complete" ? "Done" : "Gen"} • TTFT ${aggregateProgress.timeToFirstTokenSeconds.toFixed(1)}s`
: null;
const headerText = llmTimingLabel
? llmTimingLabel
: aggregateProgress?.message
? aggregateProgress.message
: aggregateProgress
? `${progressPercent}%`
: hasVisibleLaneProgress
? `${visibleLaneProgress.length}x`
: null;
const progressUnit = aggregateProgress?.unit === "tokens"
? "tok"
: aggregateProgress?.unit || "";
const headerMetric = aggregateProgress && !aggregateProgress.indeterminate
? `${Math.round(aggregateProgress.value)}/${Math.round(aggregateProgress.max)}${progressUnit ? ` ${progressUnit}` : ""}`
: 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";
const metricWidth = headerMetric ? ctx.measureText(headerMetric).width + panelPaddingX : 0;
const availableLabelWidth = Math.max(0, barWidth - panelPaddingX * 2 - metricWidth);
ctx.fillText(
fitCanvasText(ctx, headerText, availableLabelWidth),
panelPaddingX,
headerBaselineY,
);
if (headerMetric) {
ctx.textAlign = "right";
ctx.fillStyle = "#cbd5e1";
ctx.fillText(headerMetric, node.size[0] - 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 + progressTopGap;
if (hasIterationRateLabels) {
ctx.font = `${Math.max(9 / scale, 7)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "right";
ctx.textBaseline = "middle";
}
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, progressBarWidth * laneRatio);
ctx.fillStyle = "rgba(15, 23, 42, 0.66)";
ctx.fillRect(-borderWidth, laneY, progressBarWidth, 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);
if (!laneProgress.setupOnly) {
drawIterationRateOverlay(
ctx,
laneProgress.iterationRate,
node.size[0] + borderWidth,
laneY + laneHeight / 2,
laneHeight,
scale,
laneProgress.unit,
);
}
laneY += laneHeight + laneGap;
}
}
if (hasAggregateProgress) {
const progressRatio = Math.max(0, Math.min(1, state.progress.value / state.progress.max));
const progressWidth = Math.max(0, progressBarWidth * progressRatio);
const aggregateY = hasVisibleLaneProgress ? barY + laneBlockHeight + laneGap : barY;
ctx.fillStyle = "rgba(15, 23, 42, 0.72)";
ctx.fillRect(-borderWidth, aggregateY, progressBarWidth, aggregateHeight);
ctx.fillStyle = "rgba(216, 180, 254, 0.92)";
if (state.progress.indeterminate) {
const pulseWidth = Math.max(progressBarWidth * 0.18, 12 / scale);
const travelWidth = Math.max(0, progressBarWidth - pulseWidth);
const pulseX = -borderWidth + ((elapsed * 0.65) % 1) * travelWidth;
ctx.fillRect(pulseX, aggregateY, pulseWidth, aggregateHeight);
} else {
ctx.fillRect(-borderWidth, aggregateY, progressWidth, aggregateHeight);
}
drawIterationRateOverlay(
ctx,
state.progress.iterationRate,
node.size[0] + borderWidth,
aggregateY + aggregateHeight / 2,
aggregateHeight,
scale,
state.progress.unit,
);
}
if (hasExecutionLocation) {
const locationY =
panelY +
panelPaddingY +
headerHeight +
progressTopGap +
progressBodyHeight +
locationTopGap;
drawRemoteExecutionLocation(
ctx,
executionLocation,
locationY,
locationRowHeight,
node.size[0],
panelPaddingX,
scale,
);
}
ctx.restore();
}
/**
* Install renderer and lifecycle hooks needed by Modal decorations on any graph node.
* @param {LGraphNode} node
*/
function installNodeDecorationHooks(node) {
if (node.__modalDecorationInjected) {
return;
}
const originalDrawForeground = node.onDrawForeground;
node.onDrawForeground = function onDrawForeground(ctx) {
originalDrawForeground?.apply(this, arguments);
drawModalNodeDecoration(this, ctx);
};
const originalOnMouseMove = node.onMouseMove;
node.onMouseMove = function onMouseMove(event, localPosition, graphCanvas) {
const result = originalOnMouseMove?.apply(this, arguments);
const scale = graphCanvas?.ds?.scale ?? app.canvas?.ds?.scale ?? 1;
const state = getRemoteVisualState(this);
const badgeHovered = localBottleneckBadgeContainsPoint(this, localPosition, scale);
const tooltip = !badgeHovered
? ""
: isSandwichedLocalNode(this)
? LOCAL_BOTTLENECK_TOOLTIP
: (state?.isRemoteContainer ? remoteContainerTooltip(state) : "");
updateLegacyModalTooltip(graphCanvas, tooltip);
return result;
};
const originalOnMouseLeave = node.onMouseLeave;
node.onMouseLeave = function onMouseLeave(event, graphCanvas) {
const result = originalOnMouseLeave?.apply(this, arguments);
updateLegacyModalTooltip(graphCanvas, "");
return result;
};
const originalOnRemoved = node.onRemoved;
node.onRemoved = function onRemoved() {
const result = originalOnRemoved?.apply(this, arguments);
scheduleRemoteDescendantIndexRebuild();
return result;
};
node.__modalDecorationInjected = true;
}
/**
* Inject the Modal decoration hooks and, where eligible, the remote toggle widget.
* @param {LGraphNode} node
*/
function decorateNode(node) {
installNodeDecorationHooks(node);
if (!isEligibleNode(node) || node.__modalToggleInjected) {
scheduleRemoteDescendantIndexRebuild();
return;
}
node.properties ||= {};
node.properties[REMOTE_PROPERTY] = Boolean(node.properties[REMOTE_PROPERTY]);
const widget = node.addWidget(
"toggle",
REMOTE_WIDGET_NAME,
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;
scheduleRemoteDescendantIndexRebuild();
queueVueNodeDecorationSync();
}
/**
* 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.configurator_node_id) {
registerPromptConfigurator(promptId, detail.configurator_node_id);
}
if (detail.remote_execution_assignments) {
registerRemoteConfiguratorPlan(
promptId,
detail.remote_execution_assignments,
detail.remote_execution_configurations ?? [],
);
}
const executionProvider = String(detail.execution_provider ?? "").trim();
const modalGpu =
(!executionProvider || executionProvider === "modal") &&
MODAL_GPU_TYPES.includes(detail.modal_gpu)
? detail.modal_gpu
: null;
if (modalGpu) {
promptState.modalGpu = modalGpu;
}
if (detail.phase === STATE_SETUP) {
if (isPromptQueuedBehindActiveModal(promptId)) {
return;
}
beginSyntheticExecutionUi(promptId, nodeIds, modalGpu);
setGlobalStatusPhase(promptId, STATE_SETUP, nodeIds.length, {
message: detail.status_message ?? null,
current: detail.status_current ?? null,
total: detail.status_total ?? null,
modalGpu,
});
setPromptActiveNode(promptId, null);
setNodesPhase(nodeIds, STATE_SETUP, promptId);
return;
}
if (["llm_staging", "llm_staged"].includes(detail.phase)) {
if (isPromptQueuedBehindActiveModal(promptId)) {
return;
}
setGlobalStatusPhase(promptId, STATE_STARTING, nodeIds.length, {
message: detail.status_message ?? "Staging LLM on CPU",
current: detail.status_current ?? null,
total: detail.status_total ?? null,
modalGpu,
});
setPromptActiveNode(promptId, null);
setNodesPhase(nodeIds, STATE_STARTING, 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,
modalGpu,
});
setPromptActiveNode(promptId, null);
return;
}
if (detail.phase === STATE_STARTING) {
if (isPromptQueuedBehindActiveModal(promptId)) {
return;
}
setGlobalStatusPhase(promptId, STATE_STARTING, nodeIds.length, {
message:
detail.status_message ??
(executionProvider === "vast"
? "Starting Vast.ai component"
: executionProvider === "ssh_docker"
? "Starting self-hosted component"
: executionProvider === "modal"
? "Starting Modal component"
: "Starting remote component"),
current: detail.status_current ?? null,
total: detail.status_total ?? null,
modalGpu,
});
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,
modalGpu,
});
setPromptActiveNode(promptId, null);
setNodesPhase(nodeIds, STATE_FINALIZING, promptId);
return;
}
if (detail.phase === STATE_ERROR) {
const errorMessage = String(
detail.error_message ?? detail.status_message ?? "Remote workflow failed",
);
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId, true, errorMessage);
setGlobalStatusPhase(promptId, STATE_ERROR, nodeIds.length, {
message: errorMessage,
modalGpu,
});
setRemoteConfiguratorTerminalStatus(promptId, STATE_ERROR, errorMessage);
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, { modalGpu });
} else {
setGlobalStatusPhase(promptId, STATE_WAITING, nodeIds.length, {
message:
detail.status_message ??
(executionProvider === "vast"
? "Waiting for Vast.ai capacity"
: executionProvider === "ssh_docker"
? "Waiting for self-hosted capacity"
: executionProvider === "modal"
? "Waiting for Modal container"
: "Waiting for remote capacity"),
modalGpu,
});
}
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) {
updateNodeExecutionLocation(promptId, nextActiveNodeId, detail);
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 && !detail.pre_gpu) {
promptState.hasRemoteExecutionStarted = true;
}
updateNodeExecutionLocation(promptId, progressNodeId, detail);
const componentNodeIds = resolveComponentNodeIds(promptId, progressNodeId);
const readyNodeIds = (componentNodeIds ?? []).filter((nodeIdValue) => nodeIdValue !== progressNodeId);
promptState.hasStreamedProgress = true;
if (detail.pre_gpu) {
setGlobalStatusPhase(promptId, STATE_STARTING, promptState.remoteNodeIds.length || 1, {
message: detail.message ? String(detail.message) : "Staging LLM on CPU",
});
setPromptActiveNode(promptId, null);
setNodesPhase(componentNodeIds ?? [progressNodeId], STATE_STARTING, promptId);
setNodeProgress(
progressNodeId,
promptId,
Number(detail.value ?? 0),
Number(detail.max ?? 1),
detail,
);
return;
}
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, {
message: detail.message ? String(detail.message) : undefined,
});
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),
detail,
);
}
/**
* 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) {
const plannedProviders = Array.from(
promptState.executionAssignmentsByRepresentative.values(),
(assignment) => assignment?.provider,
).filter(Boolean);
setGlobalStatusPhase(
promptId,
promptState.hasRemoteExecutionStarted ? EXECUTION_PHASE : STATE_WAITING,
componentNodeIds.length,
promptState.hasRemoteExecutionStarted
? null
: { message: remoteCapacityWaitingMessage(plannedProviders) },
);
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) {
clearPromptProgressStates(promptId);
for (const [nodeIdValue, state] of Array.from(modalNodeStates.entries())) {
if (state?.promptId === promptId) {
clearNodeTimer(nodeIdValue);
modalNodeStates.delete(nodeIdValue);
}
}
pruneGlobalStatusStates();
refreshGlobalStatusElement();
stopAnimationLoopIfIdle();
refreshNodeDecorations();
return;
}
clearPromptProgressStates(promptId);
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();
stopAnimationLoopIfIdle();
refreshNodeDecorations();
}
/**
* 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);
stopAnimationLoopIfIdle();
refreshNodeDecorations();
}
/**
* 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, queueErrorMessage(error));
}
/**
* Preserve an actionable server queue failure when PromptExecutionError is generic.
* @param {Error | any} error
* @returns {string}
*/
function queueErrorMessage(error) {
const responsePayload = error?.modalQueueResponse;
const serverError = responsePayload?.error;
if (typeof serverError === "string" && serverError.trim()) {
return serverError.trim();
}
if (typeof serverError?.message === "string" && serverError.message.trim()) {
return serverError.message.trim();
}
return 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
* @param {string | null} modalGpu
*/
function beginSyntheticExecutionUi(promptId, remoteNodeIds, modalGpu = null) {
if (remoteNodeIds.length === 0 || syntheticPromptUiStates.has(promptId)) {
return;
}
clearPromptTerminal(promptId);
const displayNode = remoteNodeIds[0];
syntheticPromptUiStates.set(promptId, { displayNode });
setGlobalStatusPhase(promptId, STATE_SETUP, remoteNodeIds.length, { modalGpu });
dispatchSyntheticApiEvent("status", statusPayload(1));
dispatchSyntheticApiEvent("notification", {
id: promptId,
value: "Waiting for remote capacity.",
});
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
* @param {string | null} failureMessage
*/
function endSyntheticExecutionUi(promptId, failed = false, failureMessage = null) {
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: "Remote setup finished.",
});
dispatchSyntheticApiEvent("status", statusPayload(0));
if (failed) {
dispatchSyntheticApiEvent("execution_error", {
prompt_id: promptId,
node_id: syntheticState.displayNode,
node_type: "ModalRemoteComponent",
executed: [],
exception_message:
failureMessage || "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 detail = eventDetail(event);
const promptId = String(detail.prompt_id ?? "");
setRemoteConfiguratorTerminalStatus(
promptId,
STATE_ERROR,
detail.exception_message ?? "Remote workflow failed",
);
endSyntheticExecutionUi(promptId, true);
handleExecutionPhase(event, STATE_ERROR);
markPromptTerminal(promptId, STATE_ERROR);
});
api.addEventListener("execution_interrupted", (event) => {
const promptId = String(eventDetail(event).prompt_id ?? "");
setRemoteConfiguratorTerminalStatus(
promptId,
STATE_ERROR,
"Remote workflow interrupted",
);
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;
}
setRemoteConfiguratorTerminalStatus(promptId, STATE_COMPLETE);
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;
stampModalGpuOnWorkflow(workflow);
const modalGpu = selectedModalGpu(workflow);
const configuratorNodeId = serializedRemoteConfiguratorNodeId(prompt);
const usesConfiguredCapacity = Boolean(configuratorNodeId);
const initialModalGpu = usesConfiguredCapacity ? null : modalGpu;
const promptId = createPromptId();
clearPromptTerminal(promptId);
clearSupersededCancellingPrompts(promptId);
const remoteNodeIds = effectiveRemoteNodeIds(prompt, workflow);
registerPromptComponents(promptId, remoteNodeIds, []);
if (configuratorNodeId) {
registerPromptConfigurator(promptId, configuratorNodeId);
}
const queuedBehindActiveModal =
remoteNodeIds.length > 0 && markPromptQueuedBehindActiveModal(promptId);
if (remoteNodeIds.length > 0) {
if (!queuedBehindActiveModal) {
setNodesPhase(remoteNodeIds, STATE_SETUP, promptId);
beginSyntheticExecutionUi(promptId, remoteNodeIds, initialModalGpu);
}
}
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) {
const responsePayload = await response.json();
const promptError = new PromptExecutionError(responsePayload);
promptError.modalQueueResponse = responsePayload;
throw promptError;
}
const responsePayload = await response.json();
const acceptedConfiguratorNodeId = String(
responsePayload.remote_execution_configurator_node_id ?? configuratorNodeId ?? "",
);
if (acceptedConfiguratorNodeId) {
registerPromptConfigurator(promptId, acceptedConfiguratorNodeId);
}
const sandwichedLocalNodeIds = Array.isArray(
responsePayload.modal_sandwiched_local_node_ids,
)
? responsePayload.modal_sandwiched_local_node_ids
: [];
setSandwichedLocalNodeIds(sandwichedLocalNodeIds);
if (remoteNodeIds.length > 0) {
const executionAssignmentsByRepresentative =
responsePayload.remote_execution_assignments ?? {};
const executionAssignments = Object.values(executionAssignmentsByRepresentative);
const selectedModalGpus = Array.isArray(
responsePayload.remote_execution_modal_gpus,
)
? responsePayload.remote_execution_modal_gpus.filter((value) =>
MODAL_GPU_TYPES.includes(value),
)
: [];
const configurationsById = new Map(
(responsePayload.remote_execution_configurations ?? []).map(
(configuration) => [String(configuration?.configuration_id ?? ""), configuration],
),
);
const usesModalExecution = executionAssignments.some(
(assignment) => assignment?.provider === "modal",
);
const acceptedModalGpu = usesModalExecution
? selectedModalGpus.length === 1
? selectedModalGpus[0]
: MODAL_GPU_TYPES.includes(responsePayload.modal_gpu)
? responsePayload.modal_gpu
: usesConfiguredCapacity
? null
: modalGpu
: null;
const selectedProviders = new Set(
executionAssignments
.map((assignment) => String(assignment?.provider ?? ""))
.filter(Boolean),
);
const executionEnvironmentLabels = Array.from(
new Set(
executionAssignments.map((assignment) =>
remoteExecutionEnvironmentLabel(
assignment,
configurationsById,
acceptedModalGpu,
),
),
),
);
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);
}
registerRemoteConfiguratorPlan(
promptId,
executionAssignmentsByRepresentative,
responsePayload.remote_execution_configurations ?? [],
);
const promptState = ensurePromptState(promptId);
if (!promptState) {
return responsePayload;
}
promptState.modalGpu = acceptedModalGpu;
promptState.executionLabel = executionEnvironmentLabels.join(" + ") || null;
const acceptedRemoteNodeIds =
promptState.remoteNodeIds.length > 0 ? promptState.remoteNodeIds : remoteNodeIds;
if (!isPromptQueuedBehindActiveModal(promptId)) {
endSyntheticExecutionUi(promptId);
setGlobalStatusPhase(promptId, STATE_WAITING, acceptedRemoteNodeIds.length, {
message: remoteCapacityWaitingMessage(selectedProviders),
modalGpu: usesModalExecution ? acceptedModalGpu : null,
});
setNodesPhase(acceptedRemoteNodeIds, STATE_READY, promptId);
}
}
return responsePayload;
} catch (error) {
clearPromptQueued(promptId);
endSyntheticExecutionUi(promptId, true, queueErrorMessage(error));
markQueueFailure(remoteNodeIds, promptId, error);
setRemoteConfiguratorTerminalStatus(
promptId,
STATE_ERROR,
queueErrorMessage(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; }
}
#comfy-modal-global-status .modal-status-copy {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
line-height: 1.15;
}
#comfy-modal-global-status .modal-status-gpu {
margin-top: 3px;
color: rgba(226, 232, 240, 0.76);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
line-height: 1;
}
#comfy-modal-global-status .modal-status-gpu[hidden] {
display: none;
}
#comfy-modal-global-status .modal-status-cost {
margin-top: 4px;
color: rgba(191, 219, 254, 0.9);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.1;
white-space: nowrap;
}
#comfy-modal-global-status .modal-status-cost[hidden] {
display: none;
}
#comfy-modal-global-status .modal-status-billing {
margin-top: 3px;
color: rgba(196, 181, 253, 0.92);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.1;
white-space: nowrap;
}
#comfy-modal-global-status .modal-status-billing[hidden] {
display: none;
}
#comfy-modal-global-status .modal-status-containers {
display: flex;
min-width: 210px;
margin-top: 7px;
padding-top: 6px;
flex-direction: column;
gap: 4px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
#comfy-modal-global-status .modal-status-containers[hidden] {
display: none;
}
#comfy-modal-global-status .modal-status-container {
color: rgba(226, 232, 240, 0.82);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.01em;
line-height: 1.2;
white-space: nowrap;
}
#comfy-modal-global-status .modal-status-container-running {
color: rgba(187, 247, 208, 0.92);
}
#comfy-modal-global-status .modal-status-container-starting {
color: rgba(254, 240, 138, 0.92);
}
#comfy-modal-global-status .modal-status-container-error {
color: rgba(254, 202, 202, 0.88);
}
.comfy-modal-vue-node-decoration {
position: absolute;
inset: -3px;
z-index: 30;
box-sizing: border-box;
border: 3px solid transparent;
pointer-events: none;
}
.comfy-modal-vue-node-badge {
position: absolute;
top: 7px;
left: 7px;
display: grid;
width: 20px;
height: 20px;
place-items: center;
box-sizing: border-box;
border: 1.5px solid transparent;
border-radius: 9999px;
background: rgba(15, 23, 42, 0.92);
color: #f8fafc;
font: 10px/1 ui-sans-serif, system-ui, sans-serif;
}
.comfy-modal-vue-node[data-modal-phase="local-bottleneck"] .comfy-modal-vue-node-badge,
.comfy-modal-vue-node[data-modal-container="true"] .comfy-modal-vue-node-badge {
pointer-events: auto;
cursor: help;
}
.comfy-modal-vue-node-badge[hidden] {
display: none;
}
.comfy-modal-vue-execution-location {
position: absolute;
top: calc(100% + 6px);
left: 6px;
right: 6px;
display: flex;
min-width: 0;
height: 24px;
align-items: center;
gap: 6px;
box-sizing: border-box;
overflow: hidden;
padding: 5px 7px;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 8px;
background: rgba(15, 23, 42, 0.94);
box-shadow: 0 3px 10px rgba(2, 6, 23, 0.42);
color: #cbd5e1;
font: 500 10px/1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.01em;
white-space: nowrap;
}
.comfy-modal-vue-execution-location[hidden] {
display: none;
}
.comfy-modal-vue-execution-location-icon {
width: 12px;
height: 12px;
flex: 0 0 auto;
object-fit: contain;
}
.comfy-modal-vue-execution-location[data-provider="modal"]
.comfy-modal-vue-execution-location-icon {
width: 22px;
}
.comfy-modal-vue-execution-location-icon[hidden] {
display: none;
}
.comfy-modal-vue-execution-location-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comfy-remote-configurator-panel {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
padding: 9px 10px 11px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 9px;
background: rgba(15, 23, 42, 0.9);
color: #e2e8f0;
font: 11px/1.3 ui-sans-serif, system-ui, sans-serif;
pointer-events: none;
}
.remote-configurator-status {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
font-weight: 650;
}
.remote-configurator-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 999px;
background: #1d9bf0;
box-shadow: 0 0 0 4px rgba(29, 155, 240, 0.14);
}
.comfy-remote-configurator-panel[data-phase="setup"] .remote-configurator-dot,
.comfy-remote-configurator-panel[data-phase="waiting"] .remote-configurator-dot {
background: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.16);
}
.comfy-remote-configurator-panel[data-phase="starting"] .remote-configurator-dot {
background: #eab308;
box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.16);
}
.comfy-remote-configurator-panel[data-phase="executing"] .remote-configurator-dot {
background: #a855f7;
box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.16);
}
.comfy-remote-configurator-panel[data-phase="complete"] .remote-configurator-dot {
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.16);
}
.comfy-remote-configurator-panel[data-phase="error"] .remote-configurator-dot,
.comfy-remote-configurator-panel[data-phase="cancelling"] .remote-configurator-dot {
background: #ef4444;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.16);
}
.remote-configurator-status-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-progress {
position: relative;
height: 12px;
overflow: hidden;
border-radius: 6px;
background: rgba(2, 6, 23, 0.78);
}
.remote-configurator-progress-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, #f59e0b, #eab308);
transition: width 160ms ease-out;
}
.remote-configurator-progress-value {
position: absolute;
inset: 0 5px 0 auto;
color: #f8fafc;
font: 650 9px/12px ui-monospace, SFMono-Regular, monospace;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.remote-configurator-plan-title {
color: #94a3b8;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.remote-configurator-empty {
color: #94a3b8;
font-size: 10px;
}
.remote-configurator-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
}
.remote-configurator-table th,
.remote-configurator-table td {
overflow: hidden;
padding: 5px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-configurator-table th {
color: #94a3b8;
font-size: 9px;
font-weight: 650;
}
.remote-configurator-table th:first-child,
.remote-configurator-table td:first-child {
width: 22%;
}
.remote-configurator-table th:last-child,
.remote-configurator-table td:last-child {
width: 20%;
text-align: right;
}
`;
document.head.appendChild(style);
}
app.registerExtension({
name: "Comfy.ModalSync.Toggle",
async init() {
installGlobalStatusStyles();
installVueNodeDecorationObserver();
patchInterruptFeedback();
patchQueuePrompt();
registerExecutionListeners();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
installModalContextMenu(nodeType, nodeData);
},
async nodeCreated(node) {
decorateNode(node);
mountRemoteExecutionConfiguratorPanel(node);
},
async loadedGraphNode(node) {
synchronizeRemoteFlagFromWidget(node);
},
async afterConfigureGraph() {
for (const node of allWorkflowNodes()) {
synchronizeRemoteFlagFromWidget(node);
mountRemoteExecutionConfiguratorPanel(node);
}
rebuildRemoteDescendantIndex();
refreshNodeDecorations();
selectedModalGpu();
},
});