501 lines
20 KiB
JavaScript
501 lines
20 KiB
JavaScript
// @ts-ignore
|
|
import { api } from "../../../scripts/api.js";
|
|
// @ts-ignore
|
|
import { app } from "../../../scripts/app.js";
|
|
const PROGRESS_ROOT_ID = "lf-workflow-progress";
|
|
const API_QUEUE_PATCH_KEY = "__layerForgeWorkflowProgressPatch";
|
|
const COMPLETION_HIDE_DELAY_MS = 900;
|
|
const ERROR_HIDE_DELAY_MS = 2200;
|
|
const asDetail = (event) => {
|
|
const detail = event.detail;
|
|
return detail && typeof detail === "object" ? detail : {};
|
|
};
|
|
const asIdentifier = (value) => {
|
|
if (value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
const identifier = String(value).trim();
|
|
return identifier.length > 0 ? identifier : null;
|
|
};
|
|
const asFiniteNumber = (value) => {
|
|
const number = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
};
|
|
const clampPercent = (value) => Math.min(100, Math.max(0, value));
|
|
const getPromptFromArguments = (args) => {
|
|
for (const argument of args) {
|
|
if (!argument || typeof argument !== "object") {
|
|
continue;
|
|
}
|
|
const output = argument.output;
|
|
if (output && typeof output === "object" && !Array.isArray(output)) {
|
|
return { output: output };
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
/**
|
|
* Displays ComfyUI execution progress without touching the LayerForge render loop.
|
|
* The service listens to the same websocket events used by ComfyUI's own UI.
|
|
*/
|
|
export class WorkflowProgressController {
|
|
constructor() {
|
|
this.root = null;
|
|
this.completedFill = null;
|
|
this.activeFill = null;
|
|
this.labelElement = null;
|
|
this.promptInfos = new Map();
|
|
this.currentPromptId = null;
|
|
this.currentPromptInfo = null;
|
|
this.completedNodeIds = new Set();
|
|
this.totalNodes = 0;
|
|
this.currentNodeId = null;
|
|
this.currentNodeLabel = "";
|
|
this.currentStep = null;
|
|
this.maxSteps = null;
|
|
this.queueRemaining = null;
|
|
this.state = "idle";
|
|
this.statusText = "Idle";
|
|
this.hideTimer = null;
|
|
this.apiQueuePatch = null;
|
|
this.listenersAttached = false;
|
|
this.fullscreenActive = false;
|
|
this.handleStatus = (event) => {
|
|
const detail = asDetail(event);
|
|
const queueRemaining = asFiniteNumber(detail.exec_info?.queue_remaining);
|
|
if (queueRemaining === null) {
|
|
return;
|
|
}
|
|
this.queueRemaining = Math.max(0, Math.floor(queueRemaining));
|
|
if (this.state === "idle" && this.queueRemaining > 0) {
|
|
this.state = "queued";
|
|
this.statusText = "Workflow queued";
|
|
this.show();
|
|
}
|
|
this.render();
|
|
};
|
|
this.handleExecutionStart = (event) => {
|
|
const detail = asDetail(event);
|
|
this.cancelHide();
|
|
this.currentPromptId = asIdentifier(detail.prompt_id);
|
|
this.currentPromptInfo = this.currentPromptId
|
|
? this.promptInfos.get(this.currentPromptId) || null
|
|
: null;
|
|
this.completedNodeIds.clear();
|
|
this.totalNodes = this.currentPromptInfo?.totalNodes || this.getFallbackNodeCount();
|
|
this.currentNodeId = null;
|
|
this.currentNodeLabel = "";
|
|
this.currentStep = null;
|
|
this.maxSteps = null;
|
|
this.state = "running";
|
|
this.statusText = "Workflow running";
|
|
this.show();
|
|
this.render();
|
|
};
|
|
this.handleExecutionCached = (event) => {
|
|
const detail = asDetail(event);
|
|
if (!this.acceptPromptEvent(detail)) {
|
|
return;
|
|
}
|
|
if (Array.isArray(detail.nodes)) {
|
|
detail.nodes.forEach((nodeId) => {
|
|
const identifier = asIdentifier(nodeId);
|
|
if (identifier) {
|
|
this.completedNodeIds.add(identifier);
|
|
}
|
|
});
|
|
}
|
|
this.render();
|
|
};
|
|
this.handleExecuting = (event) => {
|
|
const detail = asDetail(event);
|
|
if (!this.acceptPromptEvent(detail)) {
|
|
return;
|
|
}
|
|
const nodeId = asIdentifier(detail.node);
|
|
if (!nodeId) {
|
|
if (this.currentNodeId) {
|
|
this.completedNodeIds.add(this.currentNodeId);
|
|
}
|
|
this.currentNodeId = null;
|
|
this.currentNodeLabel = "";
|
|
this.currentStep = null;
|
|
this.maxSteps = null;
|
|
this.render();
|
|
return;
|
|
}
|
|
if (this.currentNodeId && this.currentNodeId !== nodeId) {
|
|
this.completedNodeIds.add(this.currentNodeId);
|
|
}
|
|
this.currentNodeId = nodeId;
|
|
this.currentNodeLabel = this.getNodeLabel(nodeId, detail.display_node);
|
|
this.currentStep = null;
|
|
this.maxSteps = null;
|
|
this.state = "running";
|
|
this.show();
|
|
this.render();
|
|
};
|
|
this.handleProgress = (event) => {
|
|
const detail = asDetail(event);
|
|
if (!this.acceptPromptEvent(detail)) {
|
|
return;
|
|
}
|
|
const nodeId = asIdentifier(detail.node);
|
|
if (nodeId && nodeId !== this.currentNodeId) {
|
|
if (this.currentNodeId) {
|
|
this.completedNodeIds.add(this.currentNodeId);
|
|
}
|
|
this.currentNodeId = nodeId;
|
|
this.currentNodeLabel = this.getNodeLabel(nodeId);
|
|
}
|
|
const value = asFiniteNumber(detail.value);
|
|
const max = asFiniteNumber(detail.max);
|
|
this.currentStep = value === null ? null : Math.max(0, value);
|
|
this.maxSteps = max === null ? null : Math.max(0, max);
|
|
this.state = "running";
|
|
this.show();
|
|
this.render();
|
|
};
|
|
this.handleExecuted = (event) => {
|
|
const detail = asDetail(event);
|
|
if (!this.acceptPromptEvent(detail)) {
|
|
return;
|
|
}
|
|
const nodeId = asIdentifier(detail.node);
|
|
if (nodeId) {
|
|
this.completedNodeIds.add(nodeId);
|
|
if (nodeId === this.currentNodeId) {
|
|
this.currentNodeId = null;
|
|
this.currentStep = null;
|
|
this.maxSteps = null;
|
|
}
|
|
}
|
|
this.render();
|
|
};
|
|
this.handleExecutionSuccess = (event) => {
|
|
const detail = asDetail(event);
|
|
if (!this.acceptPromptEvent(detail)) {
|
|
return;
|
|
}
|
|
this.state = "success";
|
|
this.currentNodeId = null;
|
|
this.currentStep = null;
|
|
this.maxSteps = null;
|
|
this.statusText = "Workflow complete";
|
|
this.render(true);
|
|
this.scheduleHide(COMPLETION_HIDE_DELAY_MS);
|
|
this.clearPrompt(detail.prompt_id);
|
|
};
|
|
this.handleExecutionError = (event) => {
|
|
const detail = asDetail(event);
|
|
if (!this.acceptPromptEvent(detail)) {
|
|
return;
|
|
}
|
|
this.state = "error";
|
|
const errorType = asIdentifier(detail.exception_type);
|
|
const errorMessage = asIdentifier(detail.exception_message);
|
|
this.statusText = errorMessage || errorType
|
|
? `Workflow failed: ${errorMessage || errorType}`
|
|
: "Workflow failed";
|
|
this.render();
|
|
this.scheduleHide(ERROR_HIDE_DELAY_MS);
|
|
this.clearPrompt(detail.prompt_id);
|
|
};
|
|
this.handleExecutionInterrupted = (event) => {
|
|
const detail = asDetail(event);
|
|
if (!this.acceptPromptEvent(detail)) {
|
|
return;
|
|
}
|
|
this.state = "error";
|
|
this.statusText = "Workflow interrupted";
|
|
this.render();
|
|
this.scheduleHide(ERROR_HIDE_DELAY_MS);
|
|
this.clearPrompt(detail.prompt_id);
|
|
};
|
|
}
|
|
setFullscreenActive(active) {
|
|
this.fullscreenActive = active;
|
|
if (!active) {
|
|
this.root?.classList.remove("lf-workflow-progress-visible");
|
|
this.root?.setAttribute("aria-hidden", "true");
|
|
return;
|
|
}
|
|
if (this.state !== "idle") {
|
|
this.show();
|
|
this.render();
|
|
}
|
|
}
|
|
mount() {
|
|
if (this.listenersAttached || typeof document === "undefined" || !document.body) {
|
|
return;
|
|
}
|
|
this.createRoot();
|
|
this.attachApiListeners();
|
|
this.attachQueuePromptInterceptor();
|
|
this.listenersAttached = true;
|
|
}
|
|
destroy() {
|
|
if (!this.listenersAttached) {
|
|
return;
|
|
}
|
|
const comfyApi = api;
|
|
comfyApi.removeEventListener?.("status", this.handleStatus);
|
|
comfyApi.removeEventListener?.("execution_start", this.handleExecutionStart);
|
|
comfyApi.removeEventListener?.("execution_cached", this.handleExecutionCached);
|
|
comfyApi.removeEventListener?.("executing", this.handleExecuting);
|
|
comfyApi.removeEventListener?.("progress", this.handleProgress);
|
|
comfyApi.removeEventListener?.("executed", this.handleExecuted);
|
|
comfyApi.removeEventListener?.("execution_success", this.handleExecutionSuccess);
|
|
comfyApi.removeEventListener?.("execution_error", this.handleExecutionError);
|
|
comfyApi.removeEventListener?.("execution_interrupted", this.handleExecutionInterrupted);
|
|
if (this.apiQueuePatch) {
|
|
this.apiQueuePatch.listeners.delete(this);
|
|
if (this.apiQueuePatch.listeners.size === 0 && comfyApi[API_QUEUE_PATCH_KEY]?.original) {
|
|
comfyApi.queuePrompt = this.apiQueuePatch.original;
|
|
delete comfyApi[API_QUEUE_PATCH_KEY];
|
|
}
|
|
}
|
|
if (this.hideTimer !== null) {
|
|
window.clearTimeout(this.hideTimer);
|
|
this.hideTimer = null;
|
|
}
|
|
this.root?.remove();
|
|
this.root = null;
|
|
this.completedFill = null;
|
|
this.activeFill = null;
|
|
this.labelElement = null;
|
|
this.listenersAttached = false;
|
|
}
|
|
registerQueuedPrompt(promptId, prompt) {
|
|
const nodeLabels = new Map();
|
|
const nodeEntries = Object.entries(prompt.output);
|
|
for (const [nodeId, node] of nodeEntries) {
|
|
const title = asIdentifier(node?._meta?.title);
|
|
const classType = asIdentifier(node?.class_type);
|
|
if (title || classType) {
|
|
nodeLabels.set(nodeId, title || classType || nodeId);
|
|
}
|
|
}
|
|
const promptInfo = {
|
|
totalNodes: nodeEntries.length,
|
|
nodeLabels,
|
|
};
|
|
this.promptInfos.set(promptId, promptInfo);
|
|
if (this.currentPromptId === promptId) {
|
|
this.currentPromptInfo = promptInfo;
|
|
this.totalNodes = promptInfo.totalNodes;
|
|
this.render();
|
|
return;
|
|
}
|
|
if (this.state === "idle" || this.state === "queued") {
|
|
this.state = "queued";
|
|
this.statusText = "Workflow queued";
|
|
this.show();
|
|
this.render();
|
|
}
|
|
}
|
|
createRoot() {
|
|
const existingRoot = document.getElementById(PROGRESS_ROOT_ID);
|
|
if (existingRoot instanceof HTMLDivElement) {
|
|
this.root = existingRoot;
|
|
this.completedFill = existingRoot.querySelector(".lf-workflow-progress-completed");
|
|
this.activeFill = existingRoot.querySelector(".lf-workflow-progress-active");
|
|
this.labelElement = existingRoot.querySelector(".lf-workflow-progress-label");
|
|
return;
|
|
}
|
|
const root = document.createElement("div");
|
|
root.id = PROGRESS_ROOT_ID;
|
|
root.className = "lf-workflow-progress";
|
|
root.setAttribute("role", "progressbar");
|
|
root.setAttribute("aria-label", "Workflow progress");
|
|
root.setAttribute("aria-valuemin", "0");
|
|
root.setAttribute("aria-valuemax", "100");
|
|
root.setAttribute("aria-valuenow", "0");
|
|
root.setAttribute("aria-hidden", "true");
|
|
const completedFill = document.createElement("div");
|
|
completedFill.className = "lf-workflow-progress-completed";
|
|
completedFill.setAttribute("aria-hidden", "true");
|
|
const activeFill = document.createElement("div");
|
|
activeFill.className = "lf-workflow-progress-active";
|
|
activeFill.setAttribute("aria-hidden", "true");
|
|
const labelElement = document.createElement("span");
|
|
labelElement.className = "lf-workflow-progress-label";
|
|
labelElement.setAttribute("aria-hidden", "true");
|
|
root.append(completedFill, activeFill, labelElement);
|
|
document.body.appendChild(root);
|
|
this.root = root;
|
|
this.completedFill = completedFill;
|
|
this.activeFill = activeFill;
|
|
this.labelElement = labelElement;
|
|
}
|
|
attachApiListeners() {
|
|
const comfyApi = api;
|
|
if (typeof comfyApi.addEventListener !== "function") {
|
|
return;
|
|
}
|
|
comfyApi.addEventListener("status", this.handleStatus);
|
|
comfyApi.addEventListener("execution_start", this.handleExecutionStart);
|
|
comfyApi.addEventListener("execution_cached", this.handleExecutionCached);
|
|
comfyApi.addEventListener("executing", this.handleExecuting);
|
|
comfyApi.addEventListener("progress", this.handleProgress);
|
|
comfyApi.addEventListener("executed", this.handleExecuted);
|
|
comfyApi.addEventListener("execution_success", this.handleExecutionSuccess);
|
|
comfyApi.addEventListener("execution_error", this.handleExecutionError);
|
|
comfyApi.addEventListener("execution_interrupted", this.handleExecutionInterrupted);
|
|
}
|
|
attachQueuePromptInterceptor() {
|
|
const comfyApi = api;
|
|
const existingPatch = comfyApi[API_QUEUE_PATCH_KEY];
|
|
if (existingPatch) {
|
|
existingPatch.listeners.add(this);
|
|
this.apiQueuePatch = existingPatch;
|
|
return;
|
|
}
|
|
const originalQueuePrompt = comfyApi.queuePrompt;
|
|
if (typeof originalQueuePrompt !== "function") {
|
|
return;
|
|
}
|
|
const patch = {
|
|
original: originalQueuePrompt,
|
|
listeners: new Set([this]),
|
|
};
|
|
const wrappedQueuePrompt = async function (...args) {
|
|
const prompt = getPromptFromArguments(args);
|
|
const response = await patch.original.apply(this, args);
|
|
const promptId = asIdentifier(response?.prompt_id);
|
|
if (promptId && prompt) {
|
|
patch.listeners.forEach((listener) => listener.registerQueuedPrompt(promptId, prompt));
|
|
}
|
|
return response;
|
|
};
|
|
try {
|
|
comfyApi.queuePrompt = wrappedQueuePrompt;
|
|
comfyApi[API_QUEUE_PATCH_KEY] = patch;
|
|
this.apiQueuePatch = patch;
|
|
}
|
|
catch {
|
|
// A third-party frontend can expose a read-only queuePrompt. Events still provide
|
|
// useful node and step progress, so leave the controller active without the prompt hook.
|
|
}
|
|
}
|
|
acceptPromptEvent(detail) {
|
|
const promptId = asIdentifier(detail.prompt_id);
|
|
if (promptId && this.currentPromptId && promptId !== this.currentPromptId) {
|
|
return false;
|
|
}
|
|
if (!this.currentPromptId && promptId) {
|
|
this.currentPromptId = promptId;
|
|
this.currentPromptInfo = this.promptInfos.get(promptId) || null;
|
|
this.totalNodes = this.currentPromptInfo?.totalNodes || this.getFallbackNodeCount();
|
|
}
|
|
return true;
|
|
}
|
|
getFallbackNodeCount() {
|
|
const graph = app?.graph;
|
|
const nodes = Array.isArray(graph?._nodes)
|
|
? graph._nodes
|
|
: Array.isArray(graph?.nodes)
|
|
? graph.nodes
|
|
: [];
|
|
return nodes.length;
|
|
}
|
|
getNodeLabel(nodeId, displayNodeId) {
|
|
const displayIdentifier = asIdentifier(displayNodeId);
|
|
const labelFromPrompt = this.currentPromptInfo?.nodeLabels.get(nodeId)
|
|
|| (displayIdentifier ? this.currentPromptInfo?.nodeLabels.get(displayIdentifier) : undefined);
|
|
if (labelFromPrompt) {
|
|
return labelFromPrompt;
|
|
}
|
|
const graphNode = app?.graph?.getNodeById?.(Number(displayIdentifier || nodeId));
|
|
return asIdentifier(graphNode?.title)
|
|
|| asIdentifier(graphNode?.type)
|
|
|| `Node ${displayIdentifier || nodeId}`;
|
|
}
|
|
clearPrompt(promptId) {
|
|
const identifier = asIdentifier(promptId);
|
|
if (identifier) {
|
|
this.promptInfos.delete(identifier);
|
|
}
|
|
this.currentPromptId = null;
|
|
this.currentPromptInfo = null;
|
|
}
|
|
show() {
|
|
this.cancelHide();
|
|
if (!this.fullscreenActive) {
|
|
return;
|
|
}
|
|
this.root?.classList.add("lf-workflow-progress-visible");
|
|
this.root?.setAttribute("aria-hidden", "false");
|
|
}
|
|
scheduleHide(delay) {
|
|
this.cancelHide();
|
|
this.hideTimer = window.setTimeout(() => {
|
|
this.hideTimer = null;
|
|
this.state = "idle";
|
|
this.statusText = "Idle";
|
|
this.root?.classList.remove("lf-workflow-progress-visible", "lf-workflow-progress-indeterminate", "lf-workflow-progress-error", "lf-workflow-progress-success");
|
|
this.root?.setAttribute("aria-hidden", "true");
|
|
this.completedFill?.style.setProperty("width", "0%");
|
|
this.activeFill?.style.setProperty("width", "0%");
|
|
this.labelElement?.replaceChildren();
|
|
}, delay);
|
|
}
|
|
cancelHide() {
|
|
if (this.hideTimer !== null) {
|
|
window.clearTimeout(this.hideTimer);
|
|
this.hideTimer = null;
|
|
}
|
|
}
|
|
render(forceComplete = false) {
|
|
if (!this.root || !this.completedFill || !this.activeFill) {
|
|
return;
|
|
}
|
|
const completedCount = this.completedNodeIds.size;
|
|
const totalNodes = Math.max(this.totalNodes, completedCount);
|
|
const completedPercent = totalNodes > 0
|
|
? clampPercent((completedCount / totalNodes) * 100)
|
|
: 0;
|
|
let activePercent = completedPercent;
|
|
if (this.currentStep !== null && this.maxSteps !== null && this.maxSteps > 0 && totalNodes > 0) {
|
|
const stepFraction = Math.min(1, Math.max(0, this.currentStep / this.maxSteps));
|
|
activePercent = clampPercent(((completedCount + stepFraction) / totalNodes) * 100);
|
|
}
|
|
if (forceComplete || this.state === "success") {
|
|
activePercent = 100;
|
|
}
|
|
this.completedFill.style.width = `${completedPercent}%`;
|
|
this.activeFill.style.width = `${activePercent}%`;
|
|
this.root.classList.toggle("lf-workflow-progress-indeterminate", this.state === "running" && totalNodes === 0);
|
|
this.root.classList.toggle("lf-workflow-progress-error", this.state === "error");
|
|
this.root.classList.toggle("lf-workflow-progress-success", this.state === "success");
|
|
this.root.setAttribute("aria-valuenow", String(Math.round(activePercent)));
|
|
const queueSuffix = this.queueRemaining === null ? "" : ` · Queue ${this.queueRemaining}`;
|
|
const nodeSuffix = this.currentNodeLabel ? ` · ${this.currentNodeLabel}` : "";
|
|
const progressSuffix = totalNodes > 0 && this.state === "running"
|
|
? ` · ${Math.round(activePercent)}%`
|
|
: "";
|
|
const label = `${this.statusText}${progressSuffix}${nodeSuffix}${queueSuffix}`;
|
|
this.root.setAttribute("aria-label", label);
|
|
this.root.dataset.status = label;
|
|
if (this.labelElement) {
|
|
this.labelElement.textContent = label;
|
|
}
|
|
}
|
|
}
|
|
let installedController = null;
|
|
let fullscreenEditorCount = 0;
|
|
export const installWorkflowProgress = () => {
|
|
if (installedController) {
|
|
return;
|
|
}
|
|
installedController = new WorkflowProgressController();
|
|
installedController.mount();
|
|
installedController.setFullscreenActive(fullscreenEditorCount > 0);
|
|
};
|
|
export const setWorkflowProgressFullscreen = (active) => {
|
|
fullscreenEditorCount = active
|
|
? fullscreenEditorCount + 1
|
|
: Math.max(0, fullscreenEditorCount - 1);
|
|
installedController?.setFullscreenActive(fullscreenEditorCount > 0);
|
|
};
|