1
mirror of https://github.com/comfyanonymous/ComfyUI.git synced 2025-08-03 07:26:31 +08:00

New Menu & Workflow Management (#3112)

* menu

* wip

* wip

* wip

* wip

* wip

* workflow saving/loading

* Support inserting workflows
Move buttosn to top of lists

* fix session storage
implement renaming

* temp

* refactor, better workflow instance management

* wip

* progress on progress

* added send to workflow
various fixes

* Support multiple image loaders

* Support dynamic size breakpoints based on content

* various fixes
add close unsaved warning

* Add filtering tree

* prevent renaming unsaved

* fix zindex on hover

* fix top offset

* use filename as workflow name

* resize on setting change

* hide element until it is drawn

* remove glow

* Fix export name

* Fix test, revert accidental changes to groupNode

* Fix colors on all themes

* show hover items on smaller screen (mobile)

* remove debugging code

* dialog fix

* Dont reorder open workflows
Allow elements around canvas

* Toggle body display on setting change

* Fix menu disappearing on chrome

* Increase delay when typing, remove margin on Safari, fix dialog location

* Fix overflow issue on iOS

* Add reset view button
Prevent view changes causing history entries

* Bottom menu wip

* Various fixes

* Fix merge

* Fix breaking old menu position

* Fix merge adding restore view to loadGraphData
This commit is contained in:
pythongosssss
2024-06-25 11:49:25 +01:00
committed by GitHub
parent eab211bb1e
commit 90aebb6c86
35 changed files with 3986 additions and 312 deletions

View File

@@ -5,9 +5,11 @@ import { api } from "./api.js";
import { defaultGraph } from "./defaultGraph.js";
import { getPngMetadata, getWebpMetadata, importA1111, getLatentMetadata } from "./pnginfo.js";
import { addDomClippingSetting } from "./domWidget.js";
import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js"
export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview"
import { createImageHost, calculateImageGrid } from "./ui/imagePreview.js";
import { ComfyAppMenu } from "./ui/menu/index.js";
import { getStorageValue, setStorageValue } from "./utils.js";
import { ComfyWorkflowManager } from "./workflows.js";
export const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview";
function sanitizeNodeName(string) {
let entityMap = {
@@ -52,6 +54,12 @@ export class ComfyApp {
constructor() {
this.ui = new ComfyUI(this);
this.logging = new ComfyLogging(this);
this.workflowManager = new ComfyWorkflowManager(this);
this.bodyTop = $el("div.comfyui-body-top", { parent: document.body });
this.bodyLeft = $el("div.comfyui-body-left", { parent: document.body });
this.bodyRight = $el("div.comfyui-body-right", { parent: document.body });
this.bodyBottom = $el("div.comfyui-body-bottom", { parent: document.body });
this.menu = new ComfyAppMenu(this);
/**
* List of extensions that are registered with the app
@@ -1313,11 +1321,15 @@ export class ComfyApp {
});
api.addEventListener("progress", ({ detail }) => {
if (this.workflowManager.activePrompt?.workflow
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
this.progress = detail;
this.graph.setDirtyCanvas(true, false);
});
api.addEventListener("executing", ({ detail }) => {
if (this.workflowManager.activePrompt ?.workflow
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
this.progress = null;
this.runningNodeId = detail;
this.graph.setDirtyCanvas(true, false);
@@ -1325,6 +1337,8 @@ export class ComfyApp {
});
api.addEventListener("executed", ({ detail }) => {
if (this.workflowManager.activePrompt ?.workflow
&& this.workflowManager.activePrompt.workflow !== this.workflowManager.activeWorkflow) return;
const output = this.nodeOutputs[detail.node];
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
@@ -1433,6 +1447,11 @@ export class ComfyApp {
});
await Promise.all(extensionPromises);
try {
this.menu.workflows.registerExtension(this);
} catch (error) {
console.error(error);
}
}
async #migrateSettings() {
@@ -1520,15 +1539,17 @@ export class ComfyApp {
*/
async setup() {
await this.#setUser();
await this.ui.settings.load();
await this.#loadExtensions();
// Create and mount the LiteGraph in the DOM
const mainCanvas = document.createElement("canvas")
mainCanvas.style.touchAction = "none"
const canvasEl = (this.canvasEl = Object.assign(mainCanvas, { id: "graph-canvas" }));
canvasEl.tabIndex = "1";
document.body.prepend(canvasEl);
document.body.append(canvasEl);
this.resizeCanvas();
await Promise.all([this.workflowManager.loadWorkflows(), this.ui.settings.load()]);
await this.#loadExtensions();
addDomClippingSetting();
this.#addProcessMouseHandler();
@@ -1541,7 +1562,7 @@ export class ComfyApp {
this.#addAfterConfigureHandler();
const canvas = (this.canvas = new LGraphCanvas(canvasEl, this.graph));
this.canvas = new LGraphCanvas(canvasEl, this.graph);
this.ctx = canvasEl.getContext("2d");
LiteGraph.release_link_on_empty_shows_menu = true;
@@ -1549,19 +1570,14 @@ export class ComfyApp {
this.graph.start();
function resizeCanvas() {
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
const scale = Math.max(window.devicePixelRatio, 1);
const { width, height } = canvasEl.getBoundingClientRect();
canvasEl.width = Math.round(width * scale);
canvasEl.height = Math.round(height * scale);
canvasEl.getContext("2d").scale(scale, scale);
canvas.draw(true, true);
}
// Ensure the canvas fills the window
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
this.resizeCanvas();
window.addEventListener("resize", () => this.resizeCanvas());
const ro = new ResizeObserver(() => this.resizeCanvas());
ro.observe(this.bodyTop);
ro.observe(this.bodyLeft);
ro.observe(this.bodyRight);
ro.observe(this.bodyBottom);
await this.#invokeExtensionsAsync("init");
await this.registerNodes();
@@ -1573,7 +1589,8 @@ export class ComfyApp {
const loadWorkflow = async (json) => {
if (json) {
const workflow = JSON.parse(json);
await this.loadGraphData(workflow);
const workflowName = getStorageValue("Comfy.PreviousWorkflow");
await this.loadGraphData(workflow, true, workflowName);
return true;
}
};
@@ -1609,6 +1626,19 @@ export class ComfyApp {
await this.#invokeExtensionsAsync("setup");
}
resizeCanvas() {
// Limit minimal scale to 1, see https://github.com/comfyanonymous/ComfyUI/pull/845
const scale = Math.max(window.devicePixelRatio, 1);
// Clear fixed width and height while calculating rect so it uses 100% instead
this.canvasEl.height = this.canvasEl.width = "";
const { width, height } = this.canvasEl.getBoundingClientRect();
this.canvasEl.width = Math.round(width * scale);
this.canvasEl.height = Math.round(height * scale);
this.canvasEl.getContext("2d").scale(scale, scale);
this.canvas?.draw(true, true);
}
/**
* Registers nodes with the graph
*/
@@ -1795,12 +1825,29 @@ export class ComfyApp {
});
}
async changeWorkflow(callback, workflow = null) {
try {
this.workflowManager.activeWorkflow?.changeTracker?.store()
} catch (error) {
console.error(error);
}
await callback();
try {
this.workflowManager.setWorkflow(workflow);
this.workflowManager.activeWorkflow?.track()
} catch (error) {
console.error(error);
}
}
/**
* Populates the graph with the specified workflow data
* @param {*} graphData A serialized graph object
* @param { boolean } clean If the graph state, e.g. images, should be cleared
* @param { boolean } restore_view If the graph position should be restored
* @param { import("./workflows.js").ComfyWorkflowInstance | null } workflow The workflow
*/
async loadGraphData(graphData, clean = true, restore_view = true) {
async loadGraphData(graphData, clean = true, restore_view = true, workflow = null) {
if (clean !== false) {
this.clean();
}
@@ -1818,6 +1865,12 @@ export class ComfyApp {
{
graphData = structuredClone(graphData);
}
try {
this.workflowManager.setWorkflow(workflow);
} catch (error) {
console.error(error);
}
const missingNodeTypes = [];
await this.#invokeExtensionsAsync("beforeConfigureGraph", graphData, missingNodeTypes);
@@ -1840,6 +1893,11 @@ export class ComfyApp {
this.canvas.ds.offset = graphData.extra.ds.offset;
this.canvas.ds.scale = graphData.extra.ds.scale;
}
try {
this.workflowManager.activeWorkflow?.track()
} catch (error) {
}
} catch (error) {
let errorHint = [];
// Try extracting filename to see if it was caused by an extension script
@@ -1927,14 +1985,17 @@ export class ComfyApp {
this.showMissingNodesError(missingNodeTypes);
}
await this.#invokeExtensionsAsync("afterConfigureGraph", missingNodeTypes);
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true);
});
}
/**
* Converts the current graph workflow for sending to the API
* @returns The workflow and node links
*/
async graphToPrompt() {
for (const outerNode of this.graph.computeExecutionOrder(false)) {
async graphToPrompt(graph = this.graph, clean = true) {
for (const outerNode of graph.computeExecutionOrder(false)) {
if (outerNode.widgets) {
for (const widget of outerNode.widgets) {
// Allow widgets to run callbacks before a prompt has been queued
@@ -1954,10 +2015,10 @@ export class ComfyApp {
}
}
const workflow = this.graph.serialize();
const workflow = graph.serialize();
const output = {};
// Process nodes in order of execution
for (const outerNode of this.graph.computeExecutionOrder(false)) {
for (const outerNode of graph.computeExecutionOrder(false)) {
const skipNode = outerNode.mode === 2 || outerNode.mode === 4;
const innerNodes = (!skipNode && outerNode.getInnerNodes) ? outerNode.getInnerNodes() : [outerNode];
for (const node of innerNodes) {
@@ -2049,13 +2110,14 @@ export class ComfyApp {
}
// Remove inputs connected to removed nodes
for (const o in output) {
for (const i in output[o].inputs) {
if (Array.isArray(output[o].inputs[i])
&& output[o].inputs[i].length === 2
&& !output[output[o].inputs[i][0]]) {
delete output[o].inputs[i];
if(clean) {
for (const o in output) {
for (const i in output[o].inputs) {
if (Array.isArray(output[o].inputs[i])
&& output[o].inputs[i].length === 2
&& !output[output[o].inputs[i][0]]) {
delete output[o].inputs[i];
}
}
}
}
@@ -2123,6 +2185,14 @@ export class ComfyApp {
this.lastNodeErrors = res.node_errors;
if (this.lastNodeErrors.length > 0) {
this.canvas.draw(true, true);
} else {
try {
this.workflowManager.storePrompt({
id: res.prompt_id,
nodes: Object.keys(p.output)
});
} catch (error) {
}
}
} catch (error) {
const formattedError = this.#formatPromptError(error)
@@ -2155,6 +2225,7 @@ export class ComfyApp {
this.#processingQueue = false;
}
api.dispatchEvent(new CustomEvent("promptQueued", { detail: { number, batchCount } }));
return !this.lastNodeErrors;
}
showErrorOnFileLoad(file) {
@@ -2170,14 +2241,24 @@ export class ComfyApp {
* @param {File} file
*/
async handleFile(file) {
const removeExt = f => {
if(!f) return f;
const p = f.lastIndexOf(".");
if(p === -1) return f;
return f.substring(0, p);
};
const fileName = removeExt(file.name);
if (file.type === "image/png") {
const pngInfo = await getPngMetadata(file);
if (pngInfo?.workflow) {
await this.loadGraphData(JSON.parse(pngInfo.workflow));
await this.loadGraphData(JSON.parse(pngInfo.workflow), true, true, fileName);
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt));
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName);
} else if (pngInfo?.parameters) {
importA1111(this.graph, pngInfo.parameters);
this.changeWorkflow(() => {
importA1111(this.graph, pngInfo.parameters);
}, fileName)
} else {
this.showErrorOnFileLoad(file);
}
@@ -2188,9 +2269,9 @@ export class ComfyApp {
const prompt = pngInfo?.prompt || pngInfo?.Prompt;
if (workflow) {
this.loadGraphData(JSON.parse(workflow));
this.loadGraphData(JSON.parse(workflow), true, true, fileName);
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt));
this.loadApiJson(JSON.parse(prompt), fileName);
} else {
this.showErrorOnFileLoad(file);
}
@@ -2201,16 +2282,16 @@ export class ComfyApp {
if (jsonContent?.templates) {
this.loadTemplateData(jsonContent);
} else if(this.isApiJson(jsonContent)) {
this.loadApiJson(jsonContent);
this.loadApiJson(jsonContent, fileName);
} else {
await this.loadGraphData(jsonContent);
await this.loadGraphData(jsonContent, true, fileName);
}
};
reader.readAsText(file);
} else if (file.name?.endsWith(".latent") || file.name?.endsWith(".safetensors")) {
const info = await getLatentMetadata(file);
if (info.workflow) {
await this.loadGraphData(JSON.parse(info.workflow));
await this.loadGraphData(JSON.parse(info.workflow), true, fileName);
} else if (info.prompt) {
this.loadApiJson(JSON.parse(info.prompt));
} else {
@@ -2225,7 +2306,7 @@ export class ComfyApp {
return Object.values(data).every((v) => v.class_type);
}
loadApiJson(apiData) {
loadApiJson(apiData, fileName) {
const missingNodeTypes = Object.values(apiData).filter((n) => !LiteGraph.registered_node_types[n.class_type]);
if (missingNodeTypes.length) {
this.showMissingNodesError(missingNodeTypes.map(t => t.class_type), false);
@@ -2240,40 +2321,42 @@ export class ComfyApp {
node.id = isNaN(+id) ? id : +id;
node.title = data._meta?.title ?? node.title
app.graph.add(node);
graph.add(node);
}
for (const id of ids) {
const data = apiData[id];
const node = app.graph.getNodeById(id);
for (const input in data.inputs ?? {}) {
const value = data.inputs[input];
if (value instanceof Array) {
const [fromId, fromSlot] = value;
const fromNode = app.graph.getNodeById(fromId);
let toSlot = node.inputs?.findIndex((inp) => inp.name === input);
if (toSlot == null || toSlot === -1) {
try {
// Target has no matching input, most likely a converted widget
const widget = node.widgets?.find((w) => w.name === input);
if (widget && node.convertWidgetToInput?.(widget)) {
toSlot = node.inputs?.length - 1;
}
} catch (error) {}
}
if (toSlot != null || toSlot !== -1) {
fromNode.connect(fromSlot, node, toSlot);
}
} else {
const widget = node.widgets?.find((w) => w.name === input);
if (widget) {
widget.value = value;
widget.callback?.(value);
this.changeWorkflow(() => {
for (const id of ids) {
const data = apiData[id];
const node = app.graph.getNodeById(id);
for (const input in data.inputs ?? {}) {
const value = data.inputs[input];
if (value instanceof Array) {
const [fromId, fromSlot] = value;
const fromNode = app.graph.getNodeById(fromId);
let toSlot = node.inputs?.findIndex((inp) => inp.name === input);
if (toSlot == null || toSlot === -1) {
try {
// Target has no matching input, most likely a converted widget
const widget = node.widgets?.find((w) => w.name === input);
if (widget && node.convertWidgetToInput?.(widget)) {
toSlot = node.inputs?.length - 1;
}
} catch (error) {}
}
if (toSlot != null || toSlot !== -1) {
fromNode.connect(fromSlot, node, toSlot);
}
} else {
const widget = node.widgets?.find((w) => w.name === input);
if (widget) {
widget.value = value;
widget.callback?.(value);
}
}
}
}
}
app.graph.arrange();
app.graph.arrange();
}, fileName);
}
/**