diff --git a/src/lib/ComfyGraphCanvas.ts b/src/lib/ComfyGraphCanvas.ts index f09953e..17e23ba 100644 --- a/src/lib/ComfyGraphCanvas.ts +++ b/src/lib/ComfyGraphCanvas.ts @@ -20,6 +20,7 @@ export default class ComfyGraphCanvas extends LGraphCanvas { constructor( app: ComfyApp, + graph: LGraph, canvas: HTMLCanvasElement | string, options: { skip_render?: boolean; @@ -28,7 +29,7 @@ export default class ComfyGraphCanvas extends LGraphCanvas { viewport?: Vector4; } = {} ) { - super(canvas, app.lGraph, options); + super(canvas, graph, options); this.app = app; this._unsubscribe = selectionState.subscribe(ss => { for (const node of Object.values(this.selected_nodes)) { diff --git a/src/lib/ComfyNodeDef.ts b/src/lib/ComfyNodeDef.ts index de281ab..7a9a25d 100644 --- a/src/lib/ComfyNodeDef.ts +++ b/src/lib/ComfyNodeDef.ts @@ -27,7 +27,18 @@ export type ComfyNodeDefInput = [ComfyNodeDefInputType, ComfyNodeDefInputOptions export type ComfyNodeDefInputType = any[] | keyof typeof ComfyWidgets | string export type ComfyNodeDefInputOptions = { - forceInput?: boolean + forceInput?: boolean; + + // NOTE: For COMBO type inputs, the default value is always the first entry the list. + default?: any, + + // INT/FLOAT options + min?: number, + max?: number, + step?: number, + + // STRING options + multiline?: boolean, } // TODO if/when comfy refactors diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index ebf3381..7a78573 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -1,4 +1,4 @@ -import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts, type SlotIndex } from "@litegraph-ts/core"; +import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType, type INodeInputSlot, type NodeID, type NodeTypeSpec, type NodeTypeOpts, type SlotIndex, type UUID } from "@litegraph-ts/core"; import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core"; import ComfyAPI, { type ComfyAPIStatusResponse, type ComfyBoxPromptExtraData, type ComfyPromptRequest, type ComfyNodeID, type PromptID } from "$lib/api" import { getPngMetadata, importA1111 } from "$lib/pnginfo"; @@ -21,7 +21,7 @@ import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode"; import queueState from "$lib/stores/queueState"; import { type SvelteComponentDev } from "svelte/internal"; import type IComfyInputSlot from "$lib/IComfyInputSlot"; -import type { SerializedLayoutState } from "$lib/stores/layoutState"; +import type { LayoutState, SerializedLayoutState, WritableLayoutStateStore } from "$lib/stores/layoutState"; import layoutState from "$lib/stores/layoutState"; import { toast } from '@zerodevx/svelte-toast' import ComfyGraph from "$lib/ComfyGraph"; @@ -33,7 +33,7 @@ import { download, graphToGraphVis, jsonToJsObject, promptToGraphVis, range, wor import notify from "$lib/notify"; import configState from "$lib/stores/configState"; import { blankGraph } from "$lib/defaultGraph"; -import type { ComfyExecutionResult } from "$lib/utils"; +import type { SerializedPromptOutput } from "$lib/utils"; import ComfyPromptSerializer, { UpstreamNodeLocator, isActiveBackendNode } from "./ComfyPromptSerializer"; import { iterateNodeDefInputs, type ComfyNodeDef, isBackendNodeDefInputType, iterateNodeDefOutputs } from "$lib/ComfyNodeDef"; import { ComfyComboNode } from "$lib/nodes/widgets"; @@ -41,6 +41,7 @@ import parseA1111, { type A1111ParsedInfotext } from "$lib/parseA1111"; import convertA1111ToStdPrompt from "$lib/convertA1111ToStdPrompt"; import type { ComfyBoxStdPrompt } from "$lib/ComfyBoxStdPrompt"; import ComfyBoxStdPromptSerializer from "$lib/ComfyBoxStdPromptSerializer"; +import { v4 as uuidv4 } from "uuid"; export const COMFYBOX_SERIAL_VERSION = 1; @@ -51,12 +52,11 @@ if (typeof window !== "undefined") { /* * Queued prompt that hasn't been sent to the backend yet. - * TODO: Assumes the currently active graph will be serialized, needs to change - * for multiple loaded workflow support */ -type QueueItem = { +type PromptQueueItem = { num: number, batchCount: number + workflow: ComfyWorkflow } export type A1111PromptAndInfo = { @@ -111,7 +111,7 @@ export type SerializedPrompt = { /* * Outputs for each node. */ -export type SerializedPromptOutputs = Record +export type SerializedPromptOutputs = Record export type Progress = { value: number, @@ -134,20 +134,156 @@ type CanvasState = { canvas: ComfyGraphCanvas, } -type WorkflowState = { - title: string, - graph: ComfyGraph, +type ActiveCanvas = { + canvas: LGraphCanvas | null; + canvasHandler: () => void | null; + state: SerializedGraphCanvasState; +} + +export type SerializedWorkflowState = { + graph: SerializedLGraph, + layout: SerializedLayoutState +} + +/* + * ID for an opened workflow. + * + * Unlike NodeID and PromptID, these are *not* saved to the workflow itself. + * They are only used for identifying an open workflow in the program. If the + * workflow is closed and reopened, a different workflow ID will be assigned to + * it. + */ +export type WorkflowInstID = UUID; + +export class ComfyWorkflow { + /* + * Used for uniquely identifying the instance of the opened workflow in the frontend. + */ + id: WorkflowInstID; + title: string; + graph: ComfyGraph; + layout: WritableLayoutStateStore; + + canvases: Record = {}; + + constructor(title: string, graph: ComfyGraph, layout: WritableLayoutStateStore) { + this.id = uuidv4(); + this.title = title; + this.layout = layout; + this.graph = graph; + } + + start(key: string, canvas: ComfyGraphCanvas) { + if (this.canvases[key] != null) + throw new Error(`This workflow is already being displayed on canvas ${key}`) + + const canvasHandler = () => canvas.draw(true); + + this.canvases[key] = { + canvas, + canvasHandler, + state: { + // TODO + offset: [0, 0], + scale: 1 + } + } + + this.graph.attachCanvas(canvas); + this.graph.eventBus.on("afterExecute", canvasHandler) + + if (Object.keys(this.canvases).length === 1) + this.graph.start(); + } + + stop(key: string) { + const canvas = this.canvases[key] + if (canvas == null) + throw new Error(`This workflow is not being displayed on canvas ${key}`) + + this.graph.detachCanvas(canvas.canvas); + this.graph.eventBus.removeListener("afterExecute", canvas.canvasHandler) + + delete this.canvases[key] + + if (Object.keys(this.canvases).length === 0) + this.graph.stop(); + } + + stopAll() { + for (const key of Object.keys(this.canvases)) + this.stop(key) + this.graph.stop() + } + + serialize(): SerializedWorkflowState { + const graph = this.graph; + + const serializedGraph = graph.serialize() + const serializedLayout = this.layout.serialize() + + return { + graph: serializedGraph, + layout: serializedLayout + } + } + + static deserialize(data: SerializedWorkflowState): ComfyWorkflow { + const layout = layoutState; // TODO + // Ensure loadGraphData does not trigger any state changes in layoutState + // (isConfiguring is set to true here) + // lGraph.configure will add new nodes, triggering onNodeAdded, but we + // want to restore the layoutState ourselves + layout.onStartConfigure(); + + // Patch T2IAdapterLoader to ControlNetLoader since they are the same node now + for (let n of data.graph.nodes) { + if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader"; + } + + const graph = new ComfyGraph(); + graph.configure(data.graph); + + for (const node of graph._nodes) { + const size = node.computeSize(); + size[0] = Math.max(node.size[0], size[0]); + size[1] = Math.max(node.size[1], size[1]); + node.size = size; + // this.#invokeExtensions("loadedGraphNode", node); + } + + // Now restore the layout + // Subsequent added nodes will add the UI data to layoutState + // TODO + layout.deserialize(data.layout, graph) + + return new ComfyWorkflow("Workflow X", graph, layout); + } } export default class ComfyApp { api: ComfyAPI; + rootEl: HTMLDivElement | null = null; canvasEl: HTMLCanvasElement | null = null; canvasCtx: CanvasRenderingContext2D | null = null; - lGraph: ComfyGraph | null = null; lCanvas: ComfyGraphCanvas | null = null; - dropZone: HTMLElement | null = null; - nodeOutputs: Record = {}; + + openedWorkflows: ComfyWorkflow[] = []; + openedWorkflowsByID: Record = {}; + activeWorkflowIdx: number = -1; + + get activeWorkflow(): ComfyWorkflow | null { + return this.openedWorkflows[this.activeWorkflowIdx] + } + + get activeGraph(): ComfyGraph | null { + return this.activeWorkflow?.graph; + } + + getWorkflow(id: WorkflowInstID): ComfyWorkflow | null { + return this.openedWorkflowsByID[id]; + } shiftDown: boolean = false; ctrlDown: boolean = false; @@ -155,7 +291,7 @@ export default class ComfyApp { alreadySetup: Writable = writable(false); a1111Prompt: Writable = writable(null); - private queueItems: QueueItem[] = []; + private queueItems: PromptQueueItem[] = []; private processingQueue: boolean = false; private promptSerializer: ComfyPromptSerializer; private stdPromptSerializer: ComfyBoxStdPromptSerializer; @@ -176,8 +312,7 @@ export default class ComfyApp { this.rootEl = document.getElementById("app-root") as HTMLDivElement; this.canvasEl = document.getElementById("graph-canvas") as HTMLCanvasElement; - this.lGraph = new ComfyGraph(); - this.lCanvas = new ComfyGraphCanvas(this, this.canvasEl); + this.lCanvas = new ComfyGraphCanvas(this, null, this.canvasEl); this.canvasCtx = this.canvasEl.getContext("2d"); const uiUnlocked = get(uiState).uiUnlocked; @@ -193,7 +328,7 @@ export default class ComfyApp { const json = localStorage.getItem("workflow"); if (json) { const state = JSON.parse(json) as SerializedAppState; - await this.deserialize(state) + await this.openWorkflow(state) restored = true; } } catch (err) { @@ -236,6 +371,24 @@ export default class ComfyApp { this.lCanvas.draw(true, true); } + serialize(): SerializedAppState { + const workflow = this.activeWorkflow + if (workflow == null) + throw new Error("No workflow active!") + + const { graph, layout } = workflow.serialize(); + const canvas = this.lCanvas.serialize(); + + return { + createdBy: "ComfyBox", + version: COMFYBOX_SERIAL_VERSION, + commitHash: __GIT_COMMIT_HASH__, + workflow: graph, + layout, + canvas + } + } + saveStateToLocalStorage() { try { uiState.update(s => { s.isSavingToLocalStorage = true; return s; }) @@ -362,8 +515,12 @@ export default class ComfyApp { } catch (error) { } } - if (workflow && workflow.version && workflow.nodes && workflow.extra) { - this.loadGraphData(workflow); + if (workflow && workflow.createdBy === "ComfyBox") { + this.openWorkflow(workflow); + } + else { + // TODO handle vanilla workflows + throw new Error("Workflow was not in ComfyBox format!") } }); } @@ -386,21 +543,27 @@ export default class ComfyApp { this.api.addEventListener("progress", (progress: Progress) => { queueState.progressUpdated(progress); - this.lGraph.setDirtyCanvas(true, false); + this.activeGraph?.setDirtyCanvas(true, false); // TODO PromptID }); this.api.addEventListener("executing", (promptID: PromptID | null, nodeID: ComfyNodeID | null) => { - queueState.executingUpdated(promptID, nodeID); - this.lGraph.setDirtyCanvas(true, false); + const queueEntry = queueState.executingUpdated(promptID, nodeID); + if (queueEntry != null) { + const workflow = this.getWorkflow(queueEntry.workflowID) + workflow?.graph.setDirtyCanvas(true, false); + } }); - this.api.addEventListener("executed", (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => { - this.nodeOutputs[nodeID] = output; - const node = this.lGraph.getNodeByIdRecursive(nodeID) as ComfyGraphNode; - if (node?.onExecuted) { - node.onExecuted(output); + this.api.addEventListener("executed", (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutput) => { + const queueEntry = queueState.onExecuted(promptID, nodeID, output) + if (queueEntry != null) { + const workflow = this.getWorkflow(queueEntry.workflowID) + workflow?.graph.setDirtyCanvas(true, false); + const node = workflow?.graph.getNodeByIdRecursive(nodeID) as ComfyGraphNode; + if (node?.onExecuted) { + node.onExecuted(output); + } } - queueState.onExecuted(promptID, nodeID, output) }); this.api.addEventListener("execution_start", (promptID: PromptID) => { @@ -467,46 +630,68 @@ export default class ComfyApp { setColor(BuiltInSlotType.ACTION, "lightseagreen") } - serialize(): SerializedAppState { - const graph = this.lGraph; - - const serializedGraph = graph.serialize() - const serializedLayout = layoutState.serialize() - const serializedCanvas = this.lCanvas.serialize(); - - return { - createdBy: "ComfyBox", - version: COMFYBOX_SERIAL_VERSION, - workflow: serializedGraph, - layout: serializedLayout, - canvas: serializedCanvas - } + createNewWorkflow(): ComfyWorkflow { + // TODO remove + const workflow = ComfyWorkflow.deserialize({ graph: blankGraph.workflow, layout: blankGraph.layout }) + this.openedWorkflows.push(workflow); + this.setActiveWorkflow(this.openedWorkflows.length - 1) + return workflow; } - async deserialize(data: SerializedAppState) { + async openWorkflow(data: SerializedAppState): Promise { if (data.version !== COMFYBOX_SERIAL_VERSION) { throw `Invalid ComfyBox saved data format: ${data.version}` } - // Ensure loadGraphData does not trigger any state changes in layoutState - // (isConfiguring is set to true here) - // lGraph.configure will add new nodes, triggering onNodeAdded, but we - // want to restore the layoutState ourselves - layoutState.onStartConfigure(); + this.clean(); - this.loadGraphData(data.workflow) - - // Now restore the layout - // Subsequent added nodes will add the UI data to layoutState - layoutState.deserialize(data.layout, this.lGraph) + const workflow = ComfyWorkflow.deserialize({ graph: data.workflow, layout: data.layout }) // Restore canvas offset/zoom this.lCanvas.deserialize(data.canvas) - await this.refreshComboInNodes(); + await this.refreshComboInNodes(workflow); - this.lGraph.start(); - this.lGraph.eventBus.on("afterExecute", () => this.lCanvas.draw(true)) + this.openedWorkflows.push(workflow); + this.setActiveWorkflow(this.openedWorkflows.length - 1) + + return workflow; + } + + closeWorkflow(index: number) { + if (index < 0 || index >= this.openedWorkflows.length) + return; + + const workflow = this.openedWorkflows[index]; + workflow.stopAll(); + + this.openedWorkflows.splice(index, 1) + this.setActiveWorkflow(0); + } + + closeAllWorkflows() { + while (this.openedWorkflows.length > 0) + this.closeWorkflow(0) + } + + setActiveWorkflow(index: number) { + if (this.openedWorkflows.length === 0) { + this.activeWorkflowIdx = -1; + return; + } + + if (index < 0 || index >= this.openedWorkflows.length || this.activeWorkflowIdx === index) + return; + + if (this.activeWorkflow != null) + this.activeWorkflow.stop("app") + + const workflow = this.openedWorkflows[index] + this.activeWorkflowIdx = index; + + console.warn("START") + workflow.start("app", this.lCanvas); + this.lCanvas.deserialize(workflow.canvases["app"].state) } async initDefaultGraph() { @@ -520,50 +705,14 @@ export default class ComfyApp { notify(`Failed to load default graph: ${error}`, { type: "error" }) state = structuredClone(blankGraph) } - await this.deserialize(state) - } - - /** - * Populates the graph with the specified workflow data - * @param {*} graphData A serialized graph object - */ - loadGraphData(graphData: SerializedLGraph) { - this.clean(); - - // Patch T2IAdapterLoader to ControlNetLoader since they are the same node now - for (let n of graphData.nodes) { - if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader"; - } - - this.lGraph.configure(graphData); - - for (const node of this.lGraph._nodes) { - const size = node.computeSize(); - size[0] = Math.max(node.size[0], size[0]); - size[1] = Math.max(node.size[1], size[1]); - node.size = size; - // this.#invokeExtensions("loadedGraphNode", node); - } + await this.openWorkflow(state) } clear() { this.clean(); - const blankGraph: SerializedLGraph = { - last_node_id: 0, - last_link_id: 0, - nodes: [], - links: [], - groups: [], - config: {}, - extra: {}, - version: 0 - } - - layoutState.onStartConfigure(); this.lCanvas.closeAllSubgraphs(); - this.lGraph.configure(blankGraph) - layoutState.initDefaultLayout(); + this.closeAllWorkflows(); uiState.update(s => { s.uiUnlocked = true; s.uiEditMode = "widgets"; @@ -572,7 +721,10 @@ export default class ComfyApp { } runDefaultQueueAction() { - for (const node of this.lGraph.iterateNodesInOrderRecursive()) { + if (this.activeWorkflow == null) + return; + + for (const node of this.activeGraph.iterateNodesInOrderRecursive()) { if ("onDefaultQueueAction" in node) { (node as ComfyGraphNode).onDefaultQueueAction() } @@ -614,12 +766,17 @@ export default class ComfyApp { * Converts the current graph workflow for sending to the API * @returns The workflow and node links */ - graphToPrompt(tag: string | null = null): SerializedPrompt { - return this.promptSerializer.serialize(this.lGraph, tag) + graphToPrompt(workflow: ComfyWorkflow, tag: string | null = null): SerializedPrompt { + return this.promptSerializer.serialize(workflow.graph, tag) } async queuePrompt(num: number, batchCount: number = 1, tag: string | null = null) { - this.queueItems.push({ num, batchCount }); + if (this.activeWorkflow === null) { + notify("No workflow is opened!", { type: "error" }) + return; + } + + this.queueItems.push({ num, batchCount, workflow: this.activeWorkflow }); // Only have one action process the items so each one gets a unique seed correctly if (this.processingQueue) { @@ -630,13 +787,15 @@ export default class ComfyApp { tag = null; this.processingQueue = true; + let workflow; + try { while (this.queueItems.length) { - ({ num, batchCount } = this.queueItems.pop()); + ({ num, batchCount, workflow } = this.queueItems.pop()); console.debug(`Queue get! ${num} ${batchCount} ${tag}`); const thumbnails = [] - for (const node of this.lGraph.iterateNodesInOrderRecursive()) { + for (const node of workflow.graph.iterateNodesInOrderRecursive()) { if (node.mode !== NodeMode.ALWAYS || (tag != null && Array.isArray(node.properties.tags) @@ -651,7 +810,7 @@ export default class ComfyApp { } for (let i = 0; i < batchCount; i++) { - for (const node of this.lGraph.iterateNodesInOrderRecursive()) { + for (const node of workflow.graph.iterateNodesInOrderRecursive()) { if (node.mode !== NodeMode.ALWAYS) continue; @@ -660,9 +819,9 @@ export default class ComfyApp { } } - const p = this.graphToPrompt(tag); + const p = this.graphToPrompt(workflow, tag); const l = layoutState.serialize(); - console.debug(graphToGraphVis(this.lGraph)) + console.debug(graphToGraphVis(workflow.graph)) console.debug(promptToGraphVis(p)) const stdPrompt = this.stdPromptSerializer.serialize(p); @@ -692,7 +851,7 @@ export default class ComfyApp { error = response.error; } else { - queueState.afterQueued(response.promptID, num, p.output, extraData) + queueState.afterQueued(workflow.id, response.promptID, num, p.output, extraData) } } catch (err) { error = err?.toString(); @@ -701,13 +860,13 @@ export default class ComfyApp { if (error != null) { const mes: string = error; notify(`Error queuing prompt:\n${mes}`, { type: "error" }) - console.error(graphToGraphVis(this.lGraph)) + console.error(graphToGraphVis(workflow.graph)) console.error(promptToGraphVis(p)) console.error("Error queuing prompt", error, num, p) break; } - for (const node of this.lGraph.iterateNodesInOrderRecursive()) { + for (const node of workflow.graph.iterateNodesInOrderRecursive()) { if ("afterQueued" in node) { (node as ComfyGraphNode).afterQueued(p, tag); } @@ -730,7 +889,7 @@ export default class ComfyApp { const pngInfo = await getPngMetadata(file); if (pngInfo) { if (pngInfo.comfyBoxConfig) { - this.deserialize(JSON.parse(pngInfo.comfyBoxConfig)); + await this.openWorkflow(JSON.parse(pngInfo.comfyBoxConfig)); } else if (pngInfo.parameters) { const parsed = parseA1111(pngInfo.parameters) if ("error" in parsed) { @@ -752,8 +911,8 @@ export default class ComfyApp { } } else if (file.type === "application/json" || file.name.endsWith(".json")) { const reader = new FileReader(); - reader.onload = () => { - this.deserialize(JSON.parse(reader.result as string)); + reader.onload = async () => { + await this.openWorkflow(JSON.parse(reader.result as string)); }; reader.readAsText(file); } @@ -772,7 +931,7 @@ export default class ComfyApp { /** * Refresh combo list on whole nodes */ - async refreshComboInNodes(flashUI: boolean = false) { + async refreshComboInNodes(workflow: ComfyWorkflow, flashUI: boolean = false) { const defs = await this.api.getNodeDefs(); const isComfyComboNode = (node: LGraphNode): node is ComfyComboNode => { @@ -816,7 +975,7 @@ export default class ComfyApp { return result } - for (const node of this.lGraph.iterateNodesInOrderRecursive()) { + for (const node of workflow.graph.iterateNodesInOrderRecursive()) { if (!isActiveBackendNode(node)) continue; @@ -838,7 +997,7 @@ export default class ComfyApp { console.debug("[refreshComboInNodes] found:", backendUpdatedCombos.length, backendUpdatedCombos) // Mark combo nodes without backend configs as being loaded already. - for (const node of this.lGraph.iterateNodesOfClassRecursive(ComfyComboNode)) { + for (const node of workflow.graph.iterateNodesOfClassRecursive(ComfyComboNode)) { if (backendUpdatedCombos[node.id] != null) { continue; } @@ -870,12 +1029,15 @@ export default class ComfyApp { // Load definitions from the backend. for (const { comboNode, comfyInput, backendNode } of Object.values(backendUpdatedCombos)) { const def = defs[backendNode.type]; - const rawValues = def["input"]["required"][comfyInput.name][0]; + const [rawValues, opts] = def.input.required[comfyInput.name]; console.debug("[ComfyApp] Reconfiguring combo widget", backendNode.type, "=>", comboNode.type, rawValues.length) comboNode.doAutoConfig(comfyInput, { includeProperties: new Set(["values"]), setWidgetTitle: false }) - comboNode.formatValues(rawValues as string[], true) + const values = rawValues as string[] + const defaultValue = rawValues[0]; + + comboNode.formatValues(values, defaultValue, true) if (!rawValues?.includes(get(comboNode.value))) { comboNode.setValue(rawValues[0], comfyInput.config.defaultValue) } @@ -886,7 +1048,6 @@ export default class ComfyApp { * Clean current state */ clean() { - this.nodeOutputs = {}; this.a1111Prompt.set(null); } } diff --git a/src/lib/components/ComfyWorkflowsView.svelte b/src/lib/components/ComfyWorkflowsView.svelte index ed6dcbc..8604dcc 100644 --- a/src/lib/components/ComfyWorkflowsView.svelte +++ b/src/lib/components/ComfyWorkflowsView.svelte @@ -275,6 +275,12 @@ Error loading app
{error}
+ {#if error.stack} + {@const lines = error.stack.split("\n")} + {#each lines as line} +
{line}
+ {/each} + {/if} {/await} {/if} diff --git a/src/lib/defaultGraph.ts b/src/lib/defaultGraph.ts index a296a76..9becb14 100644 --- a/src/lib/defaultGraph.ts +++ b/src/lib/defaultGraph.ts @@ -1,4 +1,4 @@ -import type SerializedAppState from "./ComfyApp" +import type { SerializedAppState } from "./components/ComfyApp" const blankGraph: SerializedAppState = { createdBy: "ComfyBox", @@ -13,7 +13,14 @@ const blankGraph: SerializedAppState = { extra: {}, version: 0 }, - panes: {} + layout: { + root: null, + allItems: {}, + attrs: { + queuePromptButtonName: "Queue Prompt", + queuePromptButtonRunWorkflow: true + } + } } export { blankGraph } diff --git a/src/lib/nodes/ComfyActionNodes.ts b/src/lib/nodes/ComfyActionNodes.ts index 9e083a3..ba08046 100644 --- a/src/lib/nodes/ComfyActionNodes.ts +++ b/src/lib/nodes/ComfyActionNodes.ts @@ -8,7 +8,7 @@ import ComfyGraphNode, { type ComfyGraphNodeProperties } from "./ComfyGraphNode" import type { ComfyWidgetNode } from "$lib/nodes/widgets"; import type { NotifyOptions } from "$lib/notify"; import type { FileData as GradioFileData } from "@gradio/upload"; -import { type ComfyExecutionResult, type ComfyImageLocation, convertComfyOutputToGradio, type ComfyUploadImageAPIResponse, parseWhateverIntoComfyImageLocations } from "$lib/utils"; +import { type SerializedPromptOutput, type ComfyImageLocation, convertComfyOutputToGradio, type ComfyUploadImageAPIResponse, parseWhateverIntoComfyImageLocations } from "$lib/utils"; export class ComfyQueueEvents extends ComfyGraphNode { static slotLayout: SlotLayout = { @@ -63,7 +63,7 @@ LiteGraph.registerNodeType({ }) export interface ComfyStoreImagesActionProperties extends ComfyGraphNodeProperties { - images: ComfyExecutionResult | null + images: SerializedPromptOutput | null } export class ComfyStoreImagesAction extends ComfyGraphNode { @@ -90,7 +90,7 @@ export class ComfyStoreImagesAction extends ComfyGraphNode { if (action !== "store" || !param || !("images" in param)) return; - this.setProperty("images", param as ComfyExecutionResult) + this.setProperty("images", param as SerializedPromptOutput) this.setOutputData(0, this.properties.images) } } @@ -223,7 +223,7 @@ export class ComfyNotifyAction extends ComfyGraphNode { // native notifications. if (param != null && typeof param === "object") { if ("images" in param) { - const output = param as ComfyExecutionResult; + const output = param as SerializedPromptOutput; const converted = convertComfyOutputToGradio(output); if (converted.length > 0) options.imageUrl = converted[0].data; diff --git a/src/lib/nodes/ComfyBackendNode.ts b/src/lib/nodes/ComfyBackendNode.ts index c899b19..4c8ac2f 100644 --- a/src/lib/nodes/ComfyBackendNode.ts +++ b/src/lib/nodes/ComfyBackendNode.ts @@ -6,7 +6,7 @@ import { BuiltInSlotShape, BuiltInSlotType, type SerializedLGraphNode } from "@l import type IComfyInputSlot from "$lib/IComfyInputSlot"; import type { ComfyInputConfig } from "$lib/IComfyInputSlot"; import { iterateNodeDefOutputs, type ComfyNodeDef, iterateNodeDefInputs } from "$lib/ComfyNodeDef"; -import type { ComfyExecutionResult } from "$lib/utils"; +import type { SerializedPromptOutput } from "$lib/utils"; /* * Base class for any node with configuration sent by the backend. @@ -111,7 +111,7 @@ export class ComfyBackendNode extends ComfyGraphNode { } } - override onExecuted(outputData: ComfyExecutionResult) { + override onExecuted(outputData: SerializedPromptOutput) { console.warn("onExecuted outputs", outputData) this.triggerSlot(0, outputData) } diff --git a/src/lib/nodes/ComfyGraphNode.ts b/src/lib/nodes/ComfyGraphNode.ts index 1fb1883..6670a73 100644 --- a/src/lib/nodes/ComfyGraphNode.ts +++ b/src/lib/nodes/ComfyGraphNode.ts @@ -3,7 +3,7 @@ import type { SerializedPrompt } from "$lib/components/ComfyApp"; import { LGraph, LGraphNode, LLink, LiteGraph, NodeMode, type INodeInputSlot, type SerializedLGraphNode, type Vector2, type INodeOutputSlot, LConnectionKind, type SlotType, LGraphCanvas, getStaticPropertyOnInstance, type PropertyLayout, type SlotLayout } from "@litegraph-ts/core"; import type { SvelteComponentDev } from "svelte/internal"; import type { ComfyWidgetNode } from "$lib/nodes/widgets"; -import type { ComfyExecutionResult, ComfyImageLocation } from "$lib/utils" +import type { SerializedPromptOutput, ComfyImageLocation } from "$lib/utils" import type IComfyInputSlot from "$lib/IComfyInputSlot"; import uiState from "$lib/stores/uiState"; import { get } from "svelte/store"; @@ -48,7 +48,7 @@ export default class ComfyGraphNode extends LGraphNode { * Triggered when the backend sends a finished output back with this node's ID. * Valid for output nodes like SaveImage and PreviewImage. */ - onExecuted?(output: ComfyExecutionResult): void; + onExecuted?(output: SerializedPromptOutput): void; /* * When a prompt is queued, this will be called on the node if it can diff --git a/src/lib/stores/queueState.ts b/src/lib/stores/queueState.ts index ebe8a3d..6c085f2 100644 --- a/src/lib/stores/queueState.ts +++ b/src/lib/stores/queueState.ts @@ -1,5 +1,5 @@ import type { ComfyAPIHistoryEntry, ComfyAPIHistoryItem, ComfyAPIHistoryResponse, ComfyAPIQueueResponse, ComfyAPIStatusResponse, ComfyBoxPromptExtraData, ComfyNodeID, PromptID } from "$lib/api"; -import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs } from "$lib/components/ComfyApp"; +import type { Progress, SerializedPromptInputsAll, SerializedPromptOutputs, WorkflowInstID } from "$lib/components/ComfyApp"; import type { ComfyExecutionResult } from "$lib/nodes/ComfyWidgetNodes"; import notify from "$lib/notify"; import { get, writable, type Writable } from "svelte/store"; @@ -11,12 +11,13 @@ type QueueStateOps = { historyUpdated: (resp: ComfyAPIHistoryResponse) => void, statusUpdated: (status: ComfyAPIStatusResponse | null) => void, executionStart: (promptID: PromptID) => void, - executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => void, + executingUpdated: (promptID: PromptID | null, runningNodeID: ComfyNodeID | null) => QueueEntry | null; executionCached: (promptID: PromptID, nodes: ComfyNodeID[]) => void, executionError: (promptID: PromptID, message: string) => void, progressUpdated: (progress: Progress) => void - afterQueued: (promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void - onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => void + getQueueEntry: (promptID: PromptID) => QueueEntry | null; + afterQueued: (workflowID: WorkflowInstID, promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) => void + onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) => QueueEntry | null } /* @@ -36,6 +37,8 @@ export type QueueEntry = { /*** Data not sent by ComfyUI's API, lost on page refresh ***/ + /* Workflow tab that sent the prompt. */ + workflowID?: WorkflowInstID, /* Prompt outputs, collected while the prompt is still executing */ outputs: SerializedPromptOutputs, /* Nodes of the workflow that have finished running so far. */ @@ -150,6 +153,21 @@ function statusUpdated(status: ComfyAPIStatusResponse | null) { }) } +function getQueueEntry(promptID: PromptID): QueueEntry | null { + const state = get(store); + + let found = get(state.queuePending).find(e => e.promptID === promptID) + if (found != null) return found; + + found = get(state.queueRunning).find(e => e.promptID === promptID) + if (found != null) return found; + + let foundCompleted = get(state.queueCompleted).find(e => e.entry.promptID === promptID) + if (foundCompleted != null) return foundCompleted.entry; + + return null; +} + function findEntryInPending(promptID: PromptID): [number, QueueEntry | null, Writable | null] { const state = get(store); let index = get(state.queuePending).findIndex(e => e.promptID === promptID) @@ -180,8 +198,10 @@ function moveToCompleted(index: number, queue: Writable, status: Q store.set(state) } -function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null) { +function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null): QueueEntry | null { console.debug("[queueState] executingUpdated", promptID, runningNodeID) + let entry_ = null; + store.update((s) => { s.progress = null; @@ -214,8 +234,11 @@ function executingUpdated(promptID: PromptID, runningNodeID: ComfyNodeID | null) s.progress = null; s.runningNodeID = null; } + entry_ = entry; return s }) + + return entry_; } function executionCached(promptID: PromptID, nodes: ComfyNodeID[]) { @@ -283,16 +306,18 @@ function executionStart(promptID: PromptID) { }) } -function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) { +function afterQueued(workflowID: WorkflowInstID, promptID: PromptID, number: number, prompt: SerializedPromptInputsAll, extraData: any) { console.debug("[queueState] afterQueued", promptID, Object.keys(prompt)) store.update(s => { const [index, entry, queue] = findEntryInPending(promptID); if (entry == null) { const entry = createNewQueueEntry(promptID, number, prompt, extraData); + entry.workflowID = workflowID; s.queuePending.update(qp => { qp.push(entry); return qp }) console.debug("[queueState] ADD PROMPT", promptID) } else { + entry.workflowID = workflowID; entry.number = number; entry.prompt = prompt entry.extraData = extraData @@ -304,19 +329,22 @@ function afterQueued(promptID: PromptID, number: number, prompt: SerializedPromp }) } -function onExecuted(promptID: PromptID, nodeID: ComfyNodeID, output: ComfyExecutionResult) { - console.debug("[queueState] onExecuted", promptID, nodeID, output) +function onExecuted(promptID: PromptID, nodeID: ComfyNodeID, outputs: ComfyExecutionResult): QueueEntry | null { + console.debug("[queueState] onExecuted", promptID, nodeID, outputs) + let entry_ = null; store.update(s => { const [index, entry, queue] = findEntryInPending(promptID) if (entry != null) { - entry.outputs[nodeID] = output; + entry.outputs[nodeID] = outputs; queue.set(get(queue)) } else { console.error("[queueState] Could not find in pending! (onExecuted)", promptID) } + entry_ = entry; return s }) + return entry; } const queueStateStore: WritableQueueStateStore = @@ -331,6 +359,7 @@ const queueStateStore: WritableQueueStateStore = executionCached, executionError, afterQueued, + getQueueEntry, onExecuted } export default queueStateStore; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4509436..79fc7f0 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -203,12 +203,12 @@ export function promptToGraphVis(prompt: SerializedPrompt): string { export function getNodeInfo(nodeId: ComfyNodeID): string { let app = (window as any).app; - if (!app || !app.lGraph) + if (!app?.activeGraph) return String(nodeId); const displayNodeID = nodeId ? (nodeId.split("-")[0]) : String(nodeId); - const title = app.lGraph.getNodeByIdRecursive(nodeId)?.title || String(nodeId); + const title = app.activeGraph.getNodeByIdRecursive(nodeId)?.title || String(nodeId); return title + " (" + displayNodeID + ")" } @@ -222,7 +222,7 @@ export const debounce = (callback: Function, wait = 250) => { }; }; -export function convertComfyOutputToGradio(output: ComfyExecutionResult): GradioFileData[] { +export function convertComfyOutputToGradio(output: SerializedPromptOutput): GradioFileData[] { return output.images.map(convertComfyOutputEntryToGradio); } @@ -329,11 +329,16 @@ export async function uploadImageToComfyUI(blob: Blob, filename: string, type: C } /** Raw output as received from ComfyUI's backend */ -export interface ComfyExecutionResult { +export interface SerializedPromptOutput { // Technically this response can contain arbitrary data, but "images" is the // most frequently used as it's output by LoadImage and PreviewImage, the // only two output nodes in base ComfyUI. images: ComfyImageLocation[] | null, + + /* + * Other data + */ + [key: string]: any } /** Raw output entry as received from ComfyUI's backend */ @@ -375,7 +380,7 @@ export function isComfyBoxImageMetadataArray(value: any): value is ComfyBoxImage return Array.isArray(value) && value.every(isComfyBoxImageMetadata); } -export function isComfyExecutionResult(value: any): value is ComfyExecutionResult { +export function isComfyExecutionResult(value: any): value is SerializedPromptOutput { return value && typeof value === "object" && Array.isArray(value.images) } @@ -415,7 +420,7 @@ export function comfyFileToAnnotatedFilepath(comfyUIFile: ComfyImageLocation): s return path; } -export function executionResultToImageMetadata(result: ComfyExecutionResult): ComfyBoxImageMetadata[] { +export function executionResultToImageMetadata(result: SerializedPromptOutput): ComfyBoxImageMetadata[] { return result.images.map(comfyFileToComfyBoxMetadata) } diff --git a/src/lib/widgets.ts b/src/lib/widgets.ts index 8a67428..3635d20 100644 --- a/src/lib/widgets.ts +++ b/src/lib/widgets.ts @@ -2,11 +2,12 @@ import { LGraphNode, LiteGraph } from "@litegraph-ts/core"; import type IComfyInputSlot from "./IComfyInputSlot"; import type { ComfyInputConfig } from "./IComfyInputSlot"; import { ComfyComboNode, ComfyNumberNode, ComfyTextNode } from "./nodes/widgets"; +import type { ComfyNodeDefInput } from "./ComfyNodeDef"; -type WidgetFactory = (node: LGraphNode, inputName: string, inputData: any) => IComfyInputSlot; +type WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput) => IComfyInputSlot; -function getNumberDefaults(inputData: any, defaultStep: number): ComfyInputConfig { - let defaultValue = inputData[1]["default"]; +function getNumberDefaults(inputData: ComfyNodeDefInput, defaultStep: number): ComfyInputConfig { + let defaultValue = inputData[1].default; let { min, max, step } = inputData[1]; if (defaultValue == undefined) defaultValue = 0; @@ -33,25 +34,25 @@ function addComfyInput(node: LGraphNode, inputName: string, extraInfo: Partial { +const FLOAT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => { const config = getNumberDefaults(inputData, 0.5); return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode }) } -const INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => { +const INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => { const config = getNumberDefaults(inputData, 1); return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfyNumberNode }) }; -const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => { +const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => { const defaultValue = inputData[1].default || ""; const multiline = !!inputData[1].multiline; return addComfyInput(node, inputName, { type: "string", config: { defaultValue, multiline }, defaultWidgetNode: ComfyTextNode }) }; -const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => { - const type = inputData[0]; +const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => { + const type = inputData[0] as string[]; let defaultValue = type[0]; if (inputData[1] && inputData[1].default) { defaultValue = inputData[1].default; @@ -59,7 +60,7 @@ const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: an return addComfyInput(node, inputName, { type: "string", config: { values: type, defaultValue }, defaultWidgetNode: ComfyComboNode }) } -const IMAGEUPLOAD: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => { +const IMAGEUPLOAD: WidgetFactory = (node: LGraphNode, inputName: string, inputData: ComfyNodeDefInput): IComfyInputSlot => { return addComfyInput(node, inputName, { type: "number", config: {} }) } diff --git a/src/mobile/routes/graph.svelte b/src/mobile/routes/graph.svelte index 170c018..ba0bbb0 100644 --- a/src/mobile/routes/graph.svelte +++ b/src/mobile/routes/graph.svelte @@ -18,11 +18,11 @@ lCanvas.draw(true, true); } - $: if (app != null && app.lGraph && canvasEl != null) { + $: if (app?.activeGraph != null && canvasEl != null) { if (!lCanvas) { - lCanvas = new ComfyGraphCanvas(app, canvasEl); + lCanvas = new ComfyGraphCanvas(app, app.activeGraph, canvasEl); lCanvas.allow_interaction = false; - app.lGraph.eventBus.on("afterExecute", () => lCanvas.draw(true)) + app.activeGraph.eventBus.on("afterExecute", () => lCanvas.draw(true)) } resizeCanvas(); }