From 3637e19effc55d80ec0a642833d29b21d39c5dcd Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Thu, 2 Mar 2023 19:58:59 +0000 Subject: [PATCH 01/18] Add mime type for js files --- server.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/server.py b/server.py index 307352fe9aa1..345663764ee3 100644 --- a/server.py +++ b/server.py @@ -16,8 +16,12 @@ print("pip install -r requirements.txt") sys.exit() +import mimetypes; + class PromptServer(): def __init__(self, loop): + mimetypes.init(); + mimetypes.types_map['.js'] = 'application/javascript; charset=utf-8' self.prompt_queue = None self.loop = loop self.messages = asyncio.Queue() @@ -25,7 +29,7 @@ def __init__(self, loop): self.app = web.Application() self.sockets = dict() self.web_root = os.path.join(os.path.dirname( - os.path.realpath(__file__)), "webshit") + os.path.realpath(__file__)), "web") routes = web.RouteTableDef() @routes.get('/ws') @@ -140,12 +144,7 @@ async def post_queue(request): self.prompt_queue.delete_queue_item(delete_func) return web.Response(status=200) - - @routes.post("/interrupt") - async def post_interrupt(request): - nodes.interrupt_processing() - return web.Response(status=200) - + @routes.post("/history") async def post_history(request): json_data = await request.json() From 5e25c770740ddfbbd2674d4e9917b98c439885a2 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Thu, 2 Mar 2023 20:00:06 +0000 Subject: [PATCH 02/18] Initial refactoring changes - Moved to web folder - Splitting into individual files --- web/index.html | 476 ++++++++++ {webshit => web/lib}/litegraph.core.js | 0 {webshit => web/lib}/litegraph.css | 0 web/scripts/api.js | 36 + web/scripts/app.js | 569 ++++++++++++ web/scripts/defaultGraph.js | 119 +++ web/scripts/widgets.js | 118 +++ web/style.css | 81 ++ webshit/index.html | 1117 ------------------------ 9 files changed, 1399 insertions(+), 1117 deletions(-) create mode 100644 web/index.html rename {webshit => web/lib}/litegraph.core.js (100%) rename {webshit => web/lib}/litegraph.css (100%) create mode 100644 web/scripts/api.js create mode 100644 web/scripts/app.js create mode 100644 web/scripts/defaultGraph.js create mode 100644 web/scripts/widgets.js create mode 100644 web/style.css delete mode 100644 webshit/index.html diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000000..278eeb2e49f0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,476 @@ + + + + + + + + + + + + + +Queue size: X
+
+ + + + +
+
+ + + +
+
+ +
+
+
+
+ + diff --git a/webshit/litegraph.core.js b/web/lib/litegraph.core.js similarity index 100% rename from webshit/litegraph.core.js rename to web/lib/litegraph.core.js diff --git a/webshit/litegraph.css b/web/lib/litegraph.css similarity index 100% rename from webshit/litegraph.css rename to web/lib/litegraph.css diff --git a/web/scripts/api.js b/web/scripts/api.js new file mode 100644 index 000000000000..896c15cade33 --- /dev/null +++ b/web/scripts/api.js @@ -0,0 +1,36 @@ +class ComfyApi { + async getNodeDefs() { + const resp = await fetch("object_info", { cache: "no-store" }); + return await resp.json(); + } + + async queuePrompt(number, { output, workflow }) { + const body = { + client_id: this.clientId, + prompt: output, + extra_data: { extra_pnginfo: { workflow } }, + }; + + if (number === -1) { + body.front = true; + } else if (number != 0) { + body.number = number; + } + + const res = await fetch("/prompt", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (res.status !== 200) { + throw { + response: await res.text(), + }; + } + } +} + +export const api = new ComfyApi(); diff --git a/web/scripts/app.js b/web/scripts/app.js new file mode 100644 index 000000000000..cc97f4d4062c --- /dev/null +++ b/web/scripts/app.js @@ -0,0 +1,569 @@ +import { ComfyWidgets } from "./widgets.js"; +import { api } from "./api.js"; +import { defaultGraph } from "./defaultGraph.js"; + +class ComfyDialog { + constructor() { + this.element = document.createElement("div"); + this.element.classList.add("comfy-modal"); + + const content = document.createElement("div"); + content.classList.add("comfy-modal-content"); + this.textElement = document.createElement("p"); + content.append(this.textElement); + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.textContent = "CLOSE"; + content.append(closeBtn); + closeBtn.onclick = () => this.close(); + + this.element.append(content); + document.body.append(this.element); + } + + close() { + this.element.style.display = "none"; + } + + show(html) { + this.textElement.innerHTML = html; + this.element.style.display = "flex"; + } +} + +class ComfyQueue { + constructor() { + this.element = document.createElement("div"); + } + + async update() { + if (this.element.style.display !== "none") { + await this.load(); + } + } + + async show() { + this.element.style.display = "block"; + await this.load(); + } + + async load() { + const queue = await api.getQueue(); + } + + hide() { + this.element.style.display = "none"; + } +} + + +class ComfyUI { + constructor(app) { + this.app = app; + this.menuContainer = document.createElement("div"); + this.menuContainer.classList.add("comfy-menu"); + document.body.append(this.menuContainer); + + this.dialog = new ComfyDialog(); + this.queue = new ComfyQueue(); + } +} + +class ComfyApp { + constructor() { + this.ui = new ComfyUI(this); + this.nodeOutputs = {}; + this.extensions = [ + { + name: "TestExtension", + init(app) { + console.log("[ext:init]", app); + }, + setup(app) { + console.log("[ext:setup]", app); + }, + addCustomNodeDefs(defs, app) { + console.log("[ext:addCustomNodeDefs]", defs, app); + }, + loadedGraphNode(node, app) { + // console.log("[ext:loadedGraphNode]", node, app); + }, + getCustomWidgets(app) { + console.log("[ext:getCustomWidgets]", app); + return {}; + }, + beforeRegisterNode(nodeType, nodeData, app) { + // console.log("[ext:beforeRegisterNode]", nodeType, nodeData, app); + }, + registerCustomNodes(app) { + console.log("[ext:registerCustomNodes]", app); + }, + }, + ]; + } + + #log(message, ...other) { + console.log("[comfy]", message, ...other); + } + + #error(message, ...other) { + console.error("[comfy]", message, ...other); + } + + #invokeExtensions(method, ...args) { + let results = []; + for (const ext of this.extensions) { + if (method in ext) { + try { + results.push(ext[method](...args, this)); + } catch (error) { + this.#error( + `Error calling extension '${ext.name}' method '${method}'`, + { error }, + { extension: ext }, + { args } + ); + } + } + } + return results; + } + + async #invokeExtensionsAsync(method, ...args) { + return await Promise.all( + this.extensions.map(async (ext) => { + if (method in ext) { + try { + return await ext[method](...args, this); + } catch (error) { + this.#error( + `Error calling extension '${ext.name}' method '${method}'`, + { error }, + { extension: ext }, + { args } + ); + } + } + }) + ); + } + + #addNodeContextMenuHandler(node) { + node.prototype.getExtraMenuOptions = function (_, options) { + if (this.imgs) { + // If this node has images then we add an open in new tab item + let img; + if (this.imageIndex != null) { + // An image is selected so select that + img = this.imgs[this.imageIndex]; + } else if (this.overIndex != null) { + // No image is selected but one is hovered + img = this.imgs[this.overIndex]; + } + if (img) { + options.unshift({ + content: "Open Image", + callback: () => window.open(img.src, "_blank"), + }); + } + } + }; + } + + #addDrawBackgroundHandler(node) { + const app = this; + node.prototype.onDrawBackground = function (ctx) { + if (!this.flags.collapsed) { + const output = app.nodeOutputs[this.id + ""]; + if (output && output.images) { + if (this.images !== output.images) { + this.images = output.images; + this.imgs = null; + this.imageIndex = null; + Promise.all( + output.images.map((src) => { + return new Promise((r) => { + const img = new Image(); + img.onload = () => r(img); + img.onerror = () => r(null); + img.src = "/view/" + src; + }); + }) + ).then((imgs) => { + if (this.images === output.images) { + this.imgs = imgs.filter(Boolean); + if (this.size[1] < 100) { + this.size[1] = 250; + } + app.graph.setDirtyCanvas(true); + } + }); + } + + if (this.imgs) { + const canvas = graph.list_of_graphcanvas[0]; + const mouse = canvas.graph_mouse; + if (!canvas.pointer_is_down && this.pointerDown) { + if (mouse[0] === this.pointerDown.pos[0] && mouse[1] === this.pointerDown.pos[1]) { + this.imageIndex = this.pointerDown.index; + } + this.pointerDown = null; + } + + let w = this.imgs[0].naturalWidth; + let h = this.imgs[0].naturalHeight; + let imageIndex = this.imageIndex; + const numImages = this.imgs.length; + if (numImages === 1 && !imageIndex) { + this.imageIndex = imageIndex = 0; + } + let shiftY = this.type === "SaveImage" ? 55 : 0; + let dw = this.size[0]; + let dh = this.size[1]; + dh -= shiftY; + + if (imageIndex == null) { + let best = 0; + let cellWidth; + let cellHeight; + let cols = 0; + let shiftX = 0; + for (let c = 1; c <= numImages; c++) { + const rows = Math.ceil(numImages / c); + const cW = dw / c; + const cH = dh / rows; + const scaleX = cW / w; + const scaleY = cH / h; + + const scale = Math.min(scaleX, scaleY, 1); + const imageW = w * scale; + const imageH = h * scale; + const area = imageW * imageH * numImages; + + if (area > best) { + best = area; + cellWidth = imageW; + cellHeight = imageH; + cols = c; + shiftX = c * ((cW - imageW) / 2); + } + } + + let anyHovered = false; + this.imageRects = []; + for (let i = 0; i < numImages; i++) { + const img = this.imgs[i]; + const row = Math.floor(i / cols); + const col = i % cols; + const x = col * cellWidth + shiftX; + const y = row * cellHeight + shiftY; + if (!anyHovered) { + anyHovered = LiteGraph.isInsideRectangle( + mouse[0], + mouse[1], + x + this.pos[0], + y + this.pos[1], + cellWidth, + cellHeight + ); + if (anyHovered) { + this.overIndex = i; + let value = 110; + if (canvas.pointer_is_down) { + if (!this.pointerDown || this.pointerDown.index !== i) { + this.pointerDown = { index: i, pos: [...mouse] }; + } + value = 125; + } + ctx.filter = `contrast(${value}%) brightness(${value}%)`; + canvas.canvas.style.cursor = "pointer"; + } + } + this.imageRects.push([x, y, cellWidth, cellHeight]); + ctx.drawImage(img, x, y, cellWidth, cellHeight); + ctx.filter = "none"; + } + + if (!anyHovered) { + this.pointerDown = null; + this.overIndex = null; + } + } else { + // Draw individual + const scaleX = dw / w; + const scaleY = dh / h; + const scale = Math.min(scaleX, scaleY, 1); + + w *= scale; + h *= scale; + + let x = (dw - w) / 2; + let y = (dh - h) / 2 + shiftY; + ctx.drawImage(this.imgs[imageIndex], x, y, w, h); + + const drawButton = (x, y, sz, text) => { + const hovered = LiteGraph.isInsideRectangle( + mouse[0], + mouse[1], + x + this.pos[0], + y + this.pos[1], + sz, + sz + ); + let fill = "#333"; + let textFill = "#fff"; + let isClicking = false; + if (hovered) { + canvas.canvas.style.cursor = "pointer"; + if (canvas.pointer_is_down) { + fill = "#1e90ff"; + isClicking = true; + } else { + fill = "#eee"; + textFill = "#000"; + } + } else { + this.pointerWasDown = null; + } + + ctx.fillStyle = fill; + ctx.beginPath(); + ctx.roundRect(x, y, sz, sz, [4]); + ctx.fill(); + ctx.fillStyle = textFill; + ctx.font = "12px Arial"; + ctx.textAlign = "center"; + ctx.fillText(text, x + 15, y + 20); + + return isClicking; + }; + + if (numImages > 1) { + if (drawButton(x + w - 35, y + h - 35, 30, `${this.imageIndex + 1}/${numImages}`)) { + let i = this.imageIndex + 1 >= numImages ? 0 : this.imageIndex + 1; + if (!this.pointerDown || !this.pointerDown.index === i) { + this.pointerDown = { index: i, pos: [...mouse] }; + } + } + + if (drawButton(x + w - 35, y + 5, 30, `x`)) { + if (!this.pointerDown || !this.pointerDown.index === null) { + this.pointerDown = { index: null, pos: [...mouse] }; + } + } + } + } + } + } + } + }; + } + + /** + * Set up the app on the page + */ + async setup() { + // Create and mount the LiteGraph in the DOM + const canvasEl = Object.assign(document.createElement("canvas"), { id: "graph-canvas" }); + document.body.prepend(canvasEl); + + this.graph = new LGraph(); + const canvas = (this.canvas = new LGraphCanvas(canvasEl, this.graph)); + this.ctx = canvasEl.getContext("2d"); + + this.graph.start(); + + function resizeCanvas() { + canvasEl.width = canvasEl.offsetWidth; + canvasEl.height = canvasEl.offsetHeight; + canvas.draw(true, true); + } + + // Ensure the canvas fills the window + resizeCanvas(); + window.addEventListener("resize", resizeCanvas); + + await this.#invokeExtensionsAsync("init"); + await this.registerNodes(); + + // Load previous workflow + let restored = false; + try { + const json = localStorage.getItem("workflow"); + if (json) { + const workflow = JSON.parse(json); + this.loadGraphData(workflow); + restored = true; + } + } catch (err) {} + + // We failed to restore a workflow so load the default + if (!restored) { + this.loadGraphData(defaultGraph); + } + + // Save current workflow automatically + setInterval(() => localStorage.setItem("workflow", JSON.stringify(this.graph.serialize())), 1000); + + await this.#invokeExtensionsAsync("setup"); + } + + async registerNodes() { + const app = this; + // Load node definitions from the backend + const defs = await api.getNodeDefs(); + await this.#invokeExtensionsAsync("addCustomNodeDefs", defs); + + // Generate list of known widgets + const widgets = Object.assign( + {}, + ComfyWidgets, + ...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean) + ); + + // Register a node for each definition + for (const nodeId in defs) { + const nodeData = defs[nodeId]; + const node = Object.assign( + function ComfyNode() { + const inputs = nodeData["input"]["required"]; + const config = { minWidth: 1, minHeight: 1 }; + for (const inputName in inputs) { + const inputData = inputs[inputName]; + const type = inputData[0]; + + if (Array.isArray(type)) { + // Enums e.g. latent rotation + this.addWidget("combo", inputName, type[0], () => {}, { values: type }); + } else if (`${type}:${inputName}` in widgets) { + // Support custom widgets by Type:Name + Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, app) || {}); + } else if (type in widgets) { + // Standard type widgets + Object.assign(config, widgets[type](this, inputName, inputData, app) || {}); + } else { + // Node connection inputs + this.addInput(inputName, type); + } + } + + const s = this.computeSize(); + s[0] = Math.max(config.minWidth, s[0] * 1.5); + s[1] = Math.max(config.minHeight, s[1]); + this.size = s; + this.serialize_widgets = true; + }, + { + title: nodeData.name, + comfyClass: nodeData.name, + } + ); + node.prototype.comfyClass = nodeData.name; + + this.#addNodeContextMenuHandler(node); + this.#addDrawBackgroundHandler(node, app); + + await this.#invokeExtensionsAsync("beforeRegisterNode", node, nodeData); + LiteGraph.registerNodeType(nodeId, node); + node.category = nodeData.category; + } + + await this.#invokeExtensionsAsync("registerCustomNodes"); + } + + /** + * Populates the graph with the specified workflow data + * @param {*} graphData A serialized graph object + */ + loadGraphData(graphData) { + this.graph.configure(graphData); + + for (const node of this.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; + + if (node.widgets) { + // If you break something in the backend and want to patch workflows in the frontend + // This is the place to do this + for (let widget of node.widgets) { + if (node.type == "KSampler" || node.type == "KSamplerAdvanced") { + if (widget.name == "sampler_name") { + if (widget.value.startsWith("sample_")) { + wid.value = widget.value.slice(7); + } + } + } + } + } + + this.#invokeExtensions("loadedGraphNode", node); + } + } + + graphToPrompt() { + // TODO: Implement dynamic prompts + const workflow = this.graph.serialize(); + const output = {}; + for (const n of workflow.nodes) { + const inputs = {}; + const node = this.graph.getNodeById(n.id); + const widgets = node.widgets; + + // Store all widget values + if (widgets) { + for (const widget of widgets) { + if (widget.options.serialize !== false) { + inputs[widget.name] = widget.value; + } + } + } + + // Store all node links + for (let i in node.inputs) { + const link = node.getInputLink(i); + if (link) { + inputs[node.inputs[i].name] = [String(link.origin_id), parseInt(link.origin_slot)]; + } + } + + output[String(node.id)] = { + inputs, + class_type: node.comfyClass, + }; + } + + return { workflow, output }; + } + + async queuePrompt(number) { + const p = this.graphToPrompt(); + + try { + await api.queuePrompt(number, p); + } catch (error) { + this.ui.dialog.show(error.response || error.toString()); + return; + } + + for (const n of p.workflow.nodes) { + const node = graph.getNodeById(n.id); + if (node.widgets) { + for (const widget of node.widgets) { + // Allow widgets to run callbacks after a prompt has been queued + // e.g. random seed after every gen + if (widget.afterQueued) { + widget.afterQueued(); + } + } + } + } + + this.canvas.draw(true, true); + await this.ui.queue.update(); + } +} + +export const app = new ComfyApp(); diff --git a/web/scripts/defaultGraph.js b/web/scripts/defaultGraph.js new file mode 100644 index 000000000000..865f1ca0eea7 --- /dev/null +++ b/web/scripts/defaultGraph.js @@ -0,0 +1,119 @@ +export const defaultGraph = { + last_node_id: 9, + last_link_id: 9, + nodes: [ + { + id: 7, + type: "CLIPTextEncode", + pos: [413, 389], + size: { 0: 425.27801513671875, 1: 180.6060791015625 }, + flags: {}, + order: 3, + mode: 0, + inputs: [{ name: "clip", type: "CLIP", link: 5 }], + outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [6], slot_index: 0 }], + properties: {}, + widgets_values: ["bad hands"], + }, + { + id: 6, + type: "CLIPTextEncode", + pos: [415, 186], + size: { 0: 422.84503173828125, 1: 164.31304931640625 }, + flags: {}, + order: 2, + mode: 0, + inputs: [{ name: "clip", type: "CLIP", link: 3 }], + outputs: [{ name: "CONDITIONING", type: "CONDITIONING", links: [4], slot_index: 0 }], + properties: {}, + widgets_values: ["masterpiece best quality girl"], + }, + { + id: 5, + type: "EmptyLatentImage", + pos: [473, 609], + size: { 0: 315, 1: 106 }, + flags: {}, + order: 1, + mode: 0, + outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }], + properties: {}, + widgets_values: [512, 512, 1], + }, + { + id: 3, + type: "KSampler", + pos: [863, 186], + size: { 0: 315, 1: 262 }, + flags: {}, + order: 4, + mode: 0, + inputs: [ + { name: "model", type: "MODEL", link: 1 }, + { name: "positive", type: "CONDITIONING", link: 4 }, + { name: "negative", type: "CONDITIONING", link: 6 }, + { name: "latent_image", type: "LATENT", link: 2 }, + ], + outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }], + properties: {}, + widgets_values: [8566257, true, 20, 8, "euler", "normal", 1], + }, + { + id: 8, + type: "VAEDecode", + pos: [1209, 188], + size: { 0: 210, 1: 46 }, + flags: {}, + order: 5, + mode: 0, + inputs: [ + { name: "samples", type: "LATENT", link: 7 }, + { name: "vae", type: "VAE", link: 8 }, + ], + outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }], + properties: {}, + }, + { + id: 9, + type: "SaveImage", + pos: [1451, 189], + size: { 0: 210, 1: 26 }, + flags: {}, + order: 6, + mode: 0, + inputs: [{ name: "images", type: "IMAGE", link: 9 }], + properties: {}, + }, + { + id: 4, + type: "CheckpointLoader", + pos: [26, 474], + size: { 0: 315, 1: 122 }, + flags: {}, + order: 0, + mode: 0, + outputs: [ + { name: "MODEL", type: "MODEL", links: [1], slot_index: 0 }, + { name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 }, + { name: "VAE", type: "VAE", links: [8], slot_index: 2 }, + ], + properties: {}, + widgets_values: ["v1-inference.yaml", "v1-5-pruned-emaonly.ckpt"], + }, + ], + links: [ + [1, 4, 0, 3, 0, "MODEL"], + [2, 5, 0, 3, 3, "LATENT"], + [3, 4, 1, 6, 0, "CLIP"], + [4, 6, 0, 3, 1, "CONDITIONING"], + [5, 4, 1, 7, 0, "CLIP"], + [6, 7, 0, 3, 2, "CONDITIONING"], + [7, 3, 0, 8, 0, "LATENT"], + [8, 4, 2, 8, 1, "VAE"], + [9, 8, 0, 9, 0, "IMAGE"], + ], + groups: [], + config: {}, + extra: {}, + version: 0.4, +}; diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js new file mode 100644 index 000000000000..1177f82f7b14 --- /dev/null +++ b/web/scripts/widgets.js @@ -0,0 +1,118 @@ +function getNumberDefaults(inputData, defaultStep) { + let defaultVal = inputData[1]["default"]; + let { min, max, step } = inputData[1]; + + if (defaultVal == undefined) defaultVal = 0; + if (min == undefined) min = 0; + if (max == undefined) max = 2048; + if (step == undefined) step = defaultStep; + + return { val: defaultVal, config: { min, max, step: 10.0 * step } }; +} + +function seedWidget(node, inputName, inputData) { + const seed = ComfyWidgets.INT(node, inputName, inputData); + const randomize = node.addWidget("toggle", "Random seed after every gen", true, function (v) {}, { + on: "enabled", + off: "disabled", + serialize: false, // Don't include this in prompt. + }); + + randomize.afterQueued = () => { + if (randomize.value) { + seed.widget.value = Math.floor(Math.random() * 1125899906842624); + } + }; + + return { widget: seed, randomize }; +} + +function addMultilineWidget(node, name, defaultVal, dynamicPrompt, app) { + const widget = { + type: "customtext", + name, + get value() { + return this.inputEl.value; + }, + set value(x) { + this.inputEl.value = x; + }, + options: { + dynamicPrompt, + }, + draw: function (ctx, _, widgetWidth, y, widgetHeight) { + const visible = app.canvas.ds.scale > 0.5; + const t = ctx.getTransform(); + const margin = 10; + console.log("back you go") + Object.assign(this.inputEl.style, { + left: `${t.a * margin + t.e}px`, + top: `${t.d * (y + widgetHeight - margin) + t.f}px`, + width: `${(widgetWidth - margin * 2 - 3) * t.a}px`, + height: `${(this.parent.size[1] - (y + widgetHeight) - 3) * t.d}px`, + position: "absolute", + zIndex: 1, + fontSize: `${t.d * 10.0}px`, + }); + this.inputEl.hidden = !visible; + }, + }; + widget.inputEl = document.createElement("textarea"); + widget.inputEl.className = "comfy-multiline-input"; + widget.inputEl.value = defaultVal; + document.addEventListener("click", function (event) { + if (!widget.inputEl.contains(event.target)) { + widget.inputEl.blur(); + } + }); + widget.parent = node; + document.body.appendChild(widget.inputEl); + + node.addCustomWidget(widget); + + node.onRemoved = function () { + // When removing this node we need to remove the input from the DOM + for (let y in this.widgets) { + if (this.widgets[y].inputEl) { + this.widgets[y].inputEl.remove(); + } + } + }; + + return { minWidth: 400, minHeight: 200, widget }; +} + +export const ComfyWidgets = { + "INT:seed": seedWidget, + "INT:noise_seed": seedWidget, + FLOAT(node, inputName, inputData) { + const { val, config } = getNumberDefaults(inputData, 0.5); + return { widget: node.addWidget("number", inputName, val, () => {}, config) }; + }, + INT(node, inputName, inputData) { + const { val, config } = getNumberDefaults(inputData, 1); + return { + widget: node.addWidget( + "number", + inputName, + val, + function (v) { + const s = this.options.step / 10; + this.value = Math.round(v / s) * s; + }, + config + ), + }; + }, + STRING(node, inputName, inputData, app) { + const defaultVal = inputData[1].default || ""; + const multiline = !!inputData[1].multiline; + const dynamicPrompt = !!inputData[1].dynamic_prompt; + + if (multiline) { + return addMultilineWidget(node, inputName, defaultVal, dynamicPrompt, app); + } else { + return { widget: node.addWidget("text", inputName, defaultVal, () => {}, { dynamicPrompt }) }; + } + }, +}; diff --git a/web/style.css b/web/style.css new file mode 100644 index 000000000000..373447887371 --- /dev/null +++ b/web/style.css @@ -0,0 +1,81 @@ +body { + width: 100vw; + height: 100vh; + margin: 0; + overflow: hidden; +} + +#graph-canvas { + width: 100%; + height: 100%; +} + +.comfy-multiline-input { + background-color: #ffffff; + overflow: hidden; + overflow-y: auto; + padding: 2px; + resize: none; + border: none; +} + +.comfy-modal { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 100; /* Sit on top */ + padding: 30px 30px 10px 30px; + background-color: #ff0000; /* Modal background */ + box-shadow: 0px 0px 20px #888888; + border-radius: 10px; + text-align: center; + top: 50%; + left: 50%; + max-width: 80vw; + max-height: 80vh; + transform: translate(-50%, -50%); + overflow: hidden; +} + +.comfy-modal-content { + display: flex; + flex-direction: column; +} + +.comfy-modal p { + overflow: auto; + white-space: pre-line; /* This will respect line breaks */ + margin-bottom: 20px; /* Add some margin between the text and the close button*/ +} + +.comfy-modal button { + cursor: pointer; + color: #aaaaaa; + border: none; + background-color: transparent; + font-size: 24px; + font-weight: bold; + width: 100%; +} + +.comfy-modal button:hover, +.comfy-modal button:focus { + color: #000; + text-decoration: none; + cursor: pointer; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #202020; + } + .comfy-multiline-input { + background-color: #202020; + color: white; + } +} + +@media only screen and (max-height: 850px) { + #menu { + margin-top: -70px; + } +} diff --git a/webshit/index.html b/webshit/index.html deleted file mode 100644 index 410fb744d3bf..000000000000 --- a/webshit/index.html +++ /dev/null @@ -1,1117 +0,0 @@ - - - - - - - - - - - - - - -Queue size: X
-
- - - - -
-
- - - -
-
- -
-
-
-
- - From 2eaa664089cb036fc6123b5276714d427aa739ce Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Thu, 2 Mar 2023 21:33:50 +0000 Subject: [PATCH 03/18] Refactored sockets --- web/scripts/api.js | 81 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/web/scripts/api.js b/web/scripts/api.js index 896c15cade33..322cd95f6948 100644 --- a/web/scripts/api.js +++ b/web/scripts/api.js @@ -1,4 +1,83 @@ -class ComfyApi { +class ComfyApi extends EventTarget { + constructor() { + super(); + } + + #pollQueue() { + setInterval(async () => { + try { + const resp = await fetch("/prompt"); + const status = await resp.json(); + this.dispatchEvent(new CustomEvent("status", { detail: status })); + } catch (error) { + this.dispatchEvent(new CustomEvent("status", { detail: null })); + } + }, 1000); + } + + #createSocket(isReconnect) { + if (this.socket) { + return; + } + + let opened = false; + this.socket = new WebSocket(`ws${window.location.protocol === "https:" ? "s" : ""}://${location.host}/ws`); + + this.socket.addEventListener("open", () => { + opened = true; + if (isReconnect) { + this.dispatchEvent(new CustomEvent("reconnected")); + } + }); + + this.socket.addEventListener("error", () => { + if (this.socket) this.socket.close(); + this.#pollQueue(); + }); + + this.socket.addEventListener("close", () => { + setTimeout(() => { + this.socket = null; + this.#createSocket(true); + }, 300); + if (opened) { + this.dispatchEvent(new CustomEvent("status", { detail: null })); + this.dispatchEvent(new CustomEvent("reconnecting")); + } + }); + + this.socket.addEventListener("message", (event) => { + try { + const msg = JSON.parse(event.data); + switch (msg.type) { + case "status": + if (msg.data.sid) { + this.clientId = msg.data.sid; + } + this.dispatchEvent(new CustomEvent("status", { detail: msg.data.status })); + break; + case "progress": + this.dispatchEvent(new CustomEvent("progress", { detail: msg.data })); + break; + case "executing": + this.dispatchEvent(new CustomEvent("executing", { detail: msg.data.node })); + break; + case "executed": + this.dispatchEvent(new CustomEvent("executed", { detail: msg.data })); + break; + default: + throw new Error("Unknown message type"); + } + } catch (error) { + console.warn("Unhandled message:", event.data); + } + }); + } + + init() { + this.#createSocket(); + } + async getNodeDefs() { const resp = await fetch("object_info", { cache: "no-store" }); return await resp.json(); From 7e436ba9cceb6fe3427a223ade498f50472ca368 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Thu, 2 Mar 2023 21:34:29 +0000 Subject: [PATCH 04/18] Added handling of sockets Started rework of UI elements Added pnginfo handling --- web/index.html | 684 +++++++++++++---------------------------- web/scripts/app.js | 172 +++++++---- web/scripts/pnginfo.js | 45 +++ web/scripts/ui.js | 152 +++++++++ web/scripts/widgets.js | 1 - web/style.css | 32 ++ 6 files changed, 544 insertions(+), 542 deletions(-) create mode 100644 web/scripts/pnginfo.js create mode 100644 web/scripts/ui.js diff --git a/web/index.html b/web/index.html index 278eeb2e49f0..af0646b94bc2 100644 --- a/web/index.html +++ b/web/index.html @@ -1,476 +1,214 @@ - - - - - - - - - - - -Queue size: X
-
- - - - -
-
- - - -
-
- -
-
-
-
- + + + + + + + + + + diff --git a/web/scripts/app.js b/web/scripts/app.js index cc97f4d4062c..d3904a9598d5 100644 --- a/web/scripts/app.js +++ b/web/scripts/app.js @@ -1,74 +1,8 @@ import { ComfyWidgets } from "./widgets.js"; +import { ComfyUI } from "./ui.js"; import { api } from "./api.js"; import { defaultGraph } from "./defaultGraph.js"; - -class ComfyDialog { - constructor() { - this.element = document.createElement("div"); - this.element.classList.add("comfy-modal"); - - const content = document.createElement("div"); - content.classList.add("comfy-modal-content"); - this.textElement = document.createElement("p"); - content.append(this.textElement); - - const closeBtn = document.createElement("button"); - closeBtn.type = "button"; - closeBtn.textContent = "CLOSE"; - content.append(closeBtn); - closeBtn.onclick = () => this.close(); - - this.element.append(content); - document.body.append(this.element); - } - - close() { - this.element.style.display = "none"; - } - - show(html) { - this.textElement.innerHTML = html; - this.element.style.display = "flex"; - } -} - -class ComfyQueue { - constructor() { - this.element = document.createElement("div"); - } - - async update() { - if (this.element.style.display !== "none") { - await this.load(); - } - } - - async show() { - this.element.style.display = "block"; - await this.load(); - } - - async load() { - const queue = await api.getQueue(); - } - - hide() { - this.element.style.display = "none"; - } -} - - -class ComfyUI { - constructor(app) { - this.app = app; - this.menuContainer = document.createElement("div"); - this.menuContainer.classList.add("comfy-menu"); - document.body.append(this.menuContainer); - - this.dialog = new ComfyDialog(); - this.queue = new ComfyQueue(); - } -} +import { getPngMetadata } from "./pnginfo.js"; class ComfyApp { constructor() { @@ -360,6 +294,103 @@ class ComfyApp { }; } + #addDropHandler() { + // Get prompt from dropped PNG or json + document.addEventListener("drop", async (event) => { + event.preventDefault(); + event.stopPropagation(); + const file = event.dataTransfer.files[0]; + + if (file.type === "image/png") { + const pngInfo = await getPngMetadata(file); + if (pngInfo && pngInfo.workflow) { + this.loadGraphData(JSON.parse(pngInfo.workflow)); + } + } else if (file.type === "application/json" || file.name.endsWith(".json")) { + const reader = new FileReader(); + reader.onload = () => { + this.loadGraphData(JSON.parse(reader.result)); + }; + reader.readAsText(file); + } + + prompt_file_load(file); + }); + } + + #addDrawNodeProgressHandler() { + const orig = LGraphCanvas.prototype.drawNodeShape; + const self = this; + LGraphCanvas.prototype.drawNodeShape = function (node, ctx, size, fgcolor, bgcolor, selected, mouse_over) { + const res = orig.apply(this, arguments); + + if (node.id + "" === self.runningNodeId) { + const shape = node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.8; + ctx.beginPath(); + if (shape == LiteGraph.BOX_SHAPE) + ctx.rect(-6, -6 + LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT); + else if (shape == LiteGraph.ROUND_SHAPE || (shape == LiteGraph.CARD_SHAPE && node.flags.collapsed)) + ctx.roundRect( + -6, + -6 - LiteGraph.NODE_TITLE_HEIGHT, + 12 + size[0] + 1, + 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT, + this.round_radius * 2 + ); + else if (shape == LiteGraph.CARD_SHAPE) + ctx.roundRect( + -6, + -6 + LiteGraph.NODE_TITLE_HEIGHT, + 12 + size[0] + 1, + 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT, + this.round_radius * 2, + 2 + ); + else if (shape == LiteGraph.CIRCLE_SHAPE) + ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2); + ctx.strokeStyle = "#0f0"; + ctx.stroke(); + ctx.strokeStyle = fgcolor; + ctx.globalAlpha = 1; + + if (self.progress) { + ctx.fillStyle = "green"; + ctx.fillRect(0, 0, size[0] * (self.progress.value / self.progress.max), 6); + ctx.fillStyle = bgcolor; + } + } + + return res; + }; + } + + #addApiUpdateHandlers() { + api.addEventListener("status", (status) => { + console.log(status); + }); + + api.addEventListener("reconnecting", () => {}); + + api.addEventListener("reconnected", () => {}); + + api.addEventListener("progress", ({ detail }) => { + this.progress = detail; + this.graph.setDirtyCanvas(true, false); + }); + + api.addEventListener("executing", ({ detail }) => { + this.progress = null; + this.runningNodeId = detail; + this.graph.setDirtyCanvas(true, false); + }); + + api.addEventListener("executed", (e) => {}); + + api.init(); + } + /** * Set up the app on the page */ @@ -406,6 +437,9 @@ class ComfyApp { // Save current workflow automatically setInterval(() => localStorage.setItem("workflow", JSON.stringify(this.graph.serialize())), 1000); + this.#addDrawNodeProgressHandler(); + this.#addApiUpdateHandlers(); + this.#addDropHandler(); await this.#invokeExtensionsAsync("setup"); } @@ -561,6 +595,8 @@ class ComfyApp { } } + // TODO: check dynamic prompts here + this.canvas.draw(true, true); await this.ui.queue.update(); } diff --git a/web/scripts/pnginfo.js b/web/scripts/pnginfo.js new file mode 100644 index 000000000000..923f87450037 --- /dev/null +++ b/web/scripts/pnginfo.js @@ -0,0 +1,45 @@ +export function getPngMetadata(file) { + return new Promise((r) => { + const reader = new FileReader(); + reader.onload = (event) => { + // Get the PNG data as a Uint8Array + const pngData = new Uint8Array(event.target.result); + const dataView = new DataView(pngData.buffer); + + // Check that the PNG signature is present + if (dataView.getUint32(0) !== 0x89504e47) { + console.error("Not a valid PNG file"); + r(); + return; + } + + // Start searching for chunks after the PNG signature + let offset = 8; + let txt_chunks = {}; + // Loop through the chunks in the PNG file + while (offset < pngData.length) { + // Get the length of the chunk + const length = dataView.getUint32(offset); + // Get the chunk type + const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8)); + if (type === "tEXt") { + // Get the keyword + let keyword_end = offset + 8; + while (pngData[keyword_end] !== 0) { + keyword_end++; + } + const keyword = String.fromCharCode(...pngData.slice(offset + 8, keyword_end)); + // Get the text + const text = String.fromCharCode(...pngData.slice(keyword_end + 1, offset + 8 + length)); + txt_chunks[keyword] = text; + } + + offset += 12 + length; + } + + r(txt_chunks); + }; + + reader.readAsArrayBuffer(file); + }); +} diff --git a/web/scripts/ui.js b/web/scripts/ui.js new file mode 100644 index 000000000000..b7f24c4f70f2 --- /dev/null +++ b/web/scripts/ui.js @@ -0,0 +1,152 @@ +import { api } from "./api.js"; + +class ComfyDialog { + constructor() { + this.element = document.createElement("div"); + this.element.classList.add("comfy-modal"); + + const content = document.createElement("div"); + content.classList.add("comfy-modal-content"); + this.textElement = document.createElement("p"); + content.append(this.textElement); + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.textContent = "CLOSE"; + content.append(closeBtn); + closeBtn.onclick = () => this.close(); + + this.element.append(content); + document.body.append(this.element); + } + + close() { + this.element.style.display = "none"; + } + + show(html) { + this.textElement.innerHTML = html; + this.element.style.display = "flex"; + } +} + +class ComfyList { + constructor() { + this.element = document.createElement("div"); + this.element.style.display = "none"; + this.element.textContent = "hello"; + } + + get visible() { + return this.element.style.display !== "none"; + } + + async load() { + // const queue = await api.getQueue(); + } + + async update() { + if (this.visible) { + await this.load(); + } + } + + async show() { + this.element.style.display = "block"; + await this.load(); + } + + hide() { + this.element.style.display = "none"; + } + + toggle() { + if (this.visible) { + this.hide(); + return false; + } else { + this.show(); + return true; + } + } +} + +export class ComfyUI { + constructor(app) { + this.app = app; + this.dialog = new ComfyDialog(); + this.queue = new ComfyList(); + this.history = new ComfyList(); + + this.menuContainer = document.createElement("div"); + this.menuContainer.classList.add("comfy-menu"); + + this.queueSize = document.createElement("span"); + this.menuContainer.append(this.queueSize); + + this.addAction("Queue Prompt", () => { + app.queuePrompt(0); + }, "queue"); + + this.btnContainer = document.createElement("div"); + this.btnContainer.classList.add("comfy-menu-btns"); + this.menuContainer.append(this.btnContainer); + + this.addAction( + "Queue Front", + () => { + app.queuePrompt(-1); + }, + "sm" + ); + + this.addAction( + "See Queue", + (btn) => { + btn.textContent = this.queue.toggle() ? "Close" : "See Queue"; + }, + "sm" + ); + + this.addAction( + "See History", + (btn) => { + btn.textContent = this.history.toggle() ? "Close" : "See History"; + }, + "sm" + ); + + this.menuContainer.append(this.queue.element); + this.menuContainer.append(this.history.element); + + this.addAction("Save", () => { + app.queuePrompt(-1); + }); + this.addAction("Load", () => { + app.queuePrompt(-1); + }); + this.addAction("Clear", () => { + app.queuePrompt(-1); + }); + this.addAction("Load Default", () => { + app.queuePrompt(-1); + }); + + document.body.append(this.menuContainer); + this.setStatus({ exec_info: { queue_remaining: "X" } }); + } + + addAction(text, cb, cls) { + const btn = document.createElement("button"); + btn.classList.add("comfy-menu-btn-" + (cls || "lg")); + btn.textContent = text; + btn.onclick = () => { + cb(btn); + }; + (cls === "sm" ? this.btnContainer : this.menuContainer).append(btn); + } + + setStatus(status) { + this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR"); + } +} diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 1177f82f7b14..8c680c843e06 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -44,7 +44,6 @@ function addMultilineWidget(node, name, defaultVal, dynamicPrompt, app) { const visible = app.canvas.ds.scale > 0.5; const t = ctx.getTransform(); const margin = 10; - console.log("back you go") Object.assign(this.inputEl.style, { left: `${t.a * margin + t.e}px`, top: `${t.d * (y + widgetHeight - margin) + t.f}px`, diff --git a/web/style.css b/web/style.css index 373447887371..bb28ad9fcdba 100644 --- a/web/style.css +++ b/web/style.css @@ -64,6 +64,38 @@ body { cursor: pointer; } +.comfy-menu { + width: 200px; + font-size: 15px; + position: absolute; + top: 50%; + right: 0%; + background-color: white; + text-align: center; + z-index: 100; + width: 170px; + display: flex; + flex-direction: column; + align-items: center; +} + +.comfy-menu-btns { + margin-bottom: 10px; +} + +.comfy-menu-btn-sm { + font-size: 10px; + width: 50%; +} + +.comfy-menu-btn-lg, .comfy-menu-btn-queue { + font-size: 20px; +} + +.comfy-menu-btn-queue { + width: 100%; +} + @media (prefers-color-scheme: dark) { body { background-color: #202020; From 5e66b68d9ecd1904ccc4337bbb926984d2432263 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 3 Mar 2023 15:19:56 +0000 Subject: [PATCH 05/18] add loras to ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7961356d8b25..6da32900f74a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ output/ models/checkpoints models/vae models/embeddings +models/loras From a5c5c97ded03500041729856d128187c1cf23fcd Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 3 Mar 2023 15:20:49 +0000 Subject: [PATCH 06/18] More work on UI --- server.py | 7 +- web/index.html | 175 ---------------------------- web/scripts/api.js | 66 ++++++++++- web/scripts/app.js | 46 ++++++-- web/scripts/extensions.js | 9 ++ web/scripts/ui.js | 238 +++++++++++++++++++++++++------------- web/style.css | 49 +++++++- 7 files changed, 315 insertions(+), 275 deletions(-) create mode 100644 web/scripts/extensions.js diff --git a/server.py b/server.py index 345663764ee3..cfc103cbd004 100644 --- a/server.py +++ b/server.py @@ -144,7 +144,12 @@ async def post_queue(request): self.prompt_queue.delete_queue_item(delete_func) return web.Response(status=200) - + + @routes.post("/interrupt") + async def post_interrupt(request): + nodes.interrupt_processing() + return web.Response(status=200) + @routes.post("/history") async def post_history(request): json_data = await request.json() diff --git a/web/index.html b/web/index.html index af0646b94bc2..da611ddc5e08 100644 --- a/web/index.html +++ b/web/index.html @@ -20,30 +20,6 @@ let progress = null; let clientId = null; - function clearGraph() { - graph.clear(); - } - - function loadTxt2Img() { - loadGraphData(graph, default_graph); - } - - function saveGraph() { - var json = JSON.stringify(graph.serialize()); // convert the data to a JSON string - var blob = new Blob([json], { type: "application/json" }); - var url = URL.createObjectURL(blob); - var a = document.createElement("a"); - a.style = "display: none"; - a.href = url; - a.download = "workflow.json"; - document.body.appendChild(a); - a.click(); - setTimeout(function () { - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - }, 0); - } - var input = document.createElement("input"); input.setAttribute("type", "file"); input.setAttribute("accept", ".json,image/png"); @@ -58,157 +34,6 @@ function loadGraph() { input.click(); } - - document.addEventListener("paste", (e) => { - let data = (e.clipboardData || window.clipboardData).getData("text/plain"); - console.log(data); - - try { - data = data.slice(data.indexOf("{")); - j = JSON.parse(data); - } catch (err) { - data = data.slice(data.indexOf("workflow\n")); - data = data.slice(data.indexOf("{")); - j = JSON.parse(data); - } - - if (Object.hasOwn(j, "version") && Object.hasOwn(j, "nodes") && Object.hasOwn(j, "extra")) { - loadGraphData(graph, j); - } - }); - - function deleteQueueElement(type, delete_id, then) { - fetch("/" + type, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ delete: [delete_id] }), - }) - .then((data) => { - console.log(data); - then(); - }) - .catch((error) => console.error(error)); - } - - function loadQueue() { - loadItems("queue"); - } - function loadHistory() { - loadItems("history"); - } - function loadItems(type) { - fetch("/" + type) - .then((response) => response.json()) - .then((data) => { - var queue_div = document.getElementById(type + "button-content"); - queue_div.style.display = "block"; - var see_queue_button = document.getElementById("see" + type + "button"); - let old_w = see_queue_button.style.width; - see_queue_button.innerHTML = "Close"; - - let runningcontents; - if (type === "queue") { - runningcontents = document.getElementById("runningcontents"); - runningcontents.innerHTML = ""; - } - let queuecontents = document.getElementById(type + "contents"); - queuecontents.innerHTML = ""; - function append_to_list(list_element, append_to_element, append_delete, state) { - let number = list_element[0]; - let id = list_element[1]; - let prompt = list_element[2]; - let workflow = list_element[3].extra_pnginfo.workflow; - let a = document.createElement("a"); - a.innerHTML = number + ": "; - append_to_element.appendChild(a); - let button = document.createElement("button"); - button.innerHTML = "Load"; - button.style.fontSize = "10px"; - button.workflow = workflow; - button.onclick = function (event) { - loadGraphData(graph, event.target.workflow); - if (state) { - nodeOutputs = state; - } - }; - - append_to_element.appendChild(button); - if (append_delete) { - let button = document.createElement("button"); - button.innerHTML = "Delete"; - button.style.fontSize = "10px"; - button.delete_id = id; - button.onclick = function (event) { - deleteQueueElement(type, event.target.delete_id, () => loadItems(type)); - }; - append_to_element.appendChild(button); - } - append_to_element.appendChild(document.createElement("br")); - } - - if (runningcontents) { - for (let x in data.queue_running) { - append_to_list(data.queue_running[x], runningcontents, false); - } - } - - let items; - if (type === "queue") { - items = data.queue_pending; - } else { - items = Object.values(data); - } - items.sort((a, b) => a[0] - b[0]); - for (let i of items) { - append_to_list(type === "queue" ? i : i.prompt, queuecontents, true, i.outputs); - } - }) - .catch((response) => { - console.log(response); - }); - } - - function seeItems(type) { - var queue_div = document.getElementById(type + "button-content"); - if (queue_div.style.display == "block") { - closeItems(type); - } else { - loadItems(type); - } - } - - function seeQueue() { - closeItems("history"); - seeItems("queue"); - } - - function seeHistory() { - closeItems("queue"); - seeItems("history"); - } - - function closeItems(type) { - var queue_div = document.getElementById(type + "button-content"); - queue_div.style.display = "none"; - var see_queue_button = document.getElementById("see" + type + "button"); - see_queue_button.innerHTML = "See " + type[0].toUpperCase() + type.substr(1); - } - - function clearItems(type) { - fetch("/" + type, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ clear: true }), - }) - .then((data) => { - loadItems(type); - }) - .catch((error) => console.error(error)); - } diff --git a/web/scripts/api.js b/web/scripts/api.js index 322cd95f6948..b08f95d699cc 100644 --- a/web/scripts/api.js +++ b/web/scripts/api.js @@ -1,6 +1,6 @@ class ComfyApi extends EventTarget { constructor() { - super(); + super(); } #pollQueue() { @@ -74,9 +74,9 @@ class ComfyApi extends EventTarget { }); } - init() { - this.#createSocket(); - } + init() { + this.#createSocket(); + } async getNodeDefs() { const resp = await fetch("object_info", { cache: "no-store" }); @@ -110,6 +110,64 @@ class ComfyApi extends EventTarget { }; } } + + async getItems(type) { + if (type === "queue") { + return this.getQueue(); + } + return this.getHistory(); + } + + async getQueue() { + try { + const res = await fetch("/queue"); + const data = await res.json(); + return { + // Running action uses a different endpoint for cancelling + Running: data.queue_running.map((prompt) => ({ prompt, remove: { name: "Cancel", cb: () => api.interrupt() } })), + Pending: data.queue_pending.map((prompt) => ({ prompt })), + }; + } catch (error) { + console.error(error); + return { Running: [], Pending: [] }; + } + } + + async getHistory() { + try { + const res = await fetch("/history"); + return { History: Object.values(await res.json()) }; + } catch (error) { + console.error(error); + return { History: [] }; + } + } + + async #postItem(type, body) { + try { + await fetch("/" + type, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (error) { + console.error(error); + } + } + + async deleteItem(type, id) { + await this.#postItem(type, { delete: [id] }); + } + + async clearItems(type) { + await this.#postItem(type, { clear: true }); + } + + async interrupt() { + await this.#postItem("interrupt", null); + } } export const api = new ComfyApi(); diff --git a/web/scripts/app.js b/web/scripts/app.js index d3904a9598d5..4c0eb5103898 100644 --- a/web/scripts/app.js +++ b/web/scripts/app.js @@ -313,8 +313,27 @@ class ComfyApp { }; reader.readAsText(file); } + }); + } + + #addPasteHandler() { + document.addEventListener("paste", (e) => { + let data = (e.clipboardData || window.clipboardData).getData("text/plain"); + let workflow; + try { + data = data.slice(data.indexOf("{")); + workflow = JSON.parse(data); + } catch (err) { + try { + data = data.slice(data.indexOf("workflow\n")); + data = data.slice(data.indexOf("{")); + workflow = JSON.parse(data); + } catch (error) {} + } - prompt_file_load(file); + if (workflow && workflow.version && workflow.nodes && workflow.extra) { + this.loadGraphData(workflow); + } }); } @@ -367,13 +386,17 @@ class ComfyApp { } #addApiUpdateHandlers() { - api.addEventListener("status", (status) => { - console.log(status); + api.addEventListener("status", ({ detail }) => { + this.ui.setStatus(detail); }); - api.addEventListener("reconnecting", () => {}); + api.addEventListener("reconnecting", () => { + this.ui.dialog.show("Reconnecting..."); + }); - api.addEventListener("reconnected", () => {}); + api.addEventListener("reconnected", () => { + this.ui.dialog.close(); + }); api.addEventListener("progress", ({ detail }) => { this.progress = detail; @@ -386,7 +409,9 @@ class ComfyApp { this.graph.setDirtyCanvas(true, false); }); - api.addEventListener("executed", (e) => {}); + api.addEventListener("executed", ({ detail }) => { + this.nodeOutputs[detail.node] = detail.output; + }); api.init(); } @@ -431,7 +456,7 @@ class ComfyApp { // We failed to restore a workflow so load the default if (!restored) { - this.loadGraphData(defaultGraph); + this.loadGraphData(); } // Save current workflow automatically @@ -440,6 +465,8 @@ class ComfyApp { this.#addDrawNodeProgressHandler(); this.#addApiUpdateHandlers(); this.#addDropHandler(); + this.#addPasteHandler(); + await this.#invokeExtensionsAsync("setup"); } @@ -511,6 +538,9 @@ class ComfyApp { * @param {*} graphData A serialized graph object */ loadGraphData(graphData) { + if (!graphData) { + graphData = defaultGraph; + } this.graph.configure(graphData); for (const node of this.graph._nodes) { @@ -526,7 +556,7 @@ class ComfyApp { if (node.type == "KSampler" || node.type == "KSamplerAdvanced") { if (widget.name == "sampler_name") { if (widget.value.startsWith("sample_")) { - wid.value = widget.value.slice(7); + widget.value = widget.value.slice(7); } } } diff --git a/web/scripts/extensions.js b/web/scripts/extensions.js new file mode 100644 index 000000000000..fb106ae00c30 --- /dev/null +++ b/web/scripts/extensions.js @@ -0,0 +1,9 @@ +export class ComfyExtension { + init(app) {} + setup(app) {} + loadedGraphNode(node, app) {} + addCustomNodeDefs(defs, app) {} + getCustomWidgets(app) {} + beforeRegisterNode(nodeType, nodeData, app) {} + registerCustomNodes(app) {} +} \ No newline at end of file diff --git a/web/scripts/ui.js b/web/scripts/ui.js index b7f24c4f70f2..42da67724e14 100644 --- a/web/scripts/ui.js +++ b/web/scripts/ui.js @@ -1,23 +1,47 @@ import { api } from "./api.js"; +function $el(tag, propsOrChildren, children) { + const split = tag.split("."); + const element = document.createElement(split.shift()); + element.classList.add(...split); + if (propsOrChildren) { + if (Array.isArray(propsOrChildren)) { + element.append(...propsOrChildren); + } else { + const parent = propsOrChildren.parent; + delete propsOrChildren.parent; + const cb = propsOrChildren.$; + delete propsOrChildren.$; + + Object.assign(element, propsOrChildren); + if (children) { + element.append(...children); + } + + if (parent) { + parent.append(element); + } + + if (cb) { + cb(element); + } + } + } + return element; +} + class ComfyDialog { constructor() { - this.element = document.createElement("div"); - this.element.classList.add("comfy-modal"); - - const content = document.createElement("div"); - content.classList.add("comfy-modal-content"); - this.textElement = document.createElement("p"); - content.append(this.textElement); - - const closeBtn = document.createElement("button"); - closeBtn.type = "button"; - closeBtn.textContent = "CLOSE"; - content.append(closeBtn); - closeBtn.onclick = () => this.close(); - - this.element.append(content); - document.body.append(this.element); + this.element = $el("div.comfy-modal", { parent: document.body }, [ + $el("div.comfy-modal-content", [ + $el("p", { $: (p) => (this.textElement = p) }), + $el("button", { + type: "button", + textContent: "CLOSE", + onclick: () => this.close(), + }), + ]), + ]); } close() { @@ -31,10 +55,14 @@ class ComfyDialog { } class ComfyList { - constructor() { - this.element = document.createElement("div"); + #type; + #text; + + constructor(text, type) { + this.#text = text; + this.#type = type || text.toLowerCase(); + this.element = $el("div.comfy-list"); this.element.style.display = "none"; - this.element.textContent = "hello"; } get visible() { @@ -42,7 +70,51 @@ class ComfyList { } async load() { - // const queue = await api.getQueue(); + const items = await api.getItems(this.#type); + this.element.replaceChildren( + ...Object.keys(items).flatMap((section) => [ + $el("h4", { + textContent: section, + }), + $el("div.comfy-list-items", [ + ...items[section].map((item) => { + // Allow items to specify a custom remove action (e.g. for interrupt current prompt) + const removeAction = item.remove || { + name: "Delete", + cb: () => api.deleteItem(this.#type, item.prompt[1]), + }; + return $el("div", { textContent: item.prompt[0] + ": " }, [ + $el("button", { + textContent: "Load", + onclick: () => { + if (item.outputs) { + app.nodeOutputs = item.outputs; + } + app.loadGraphData(item.prompt[3].extra_pnginfo.workflow); + }, + }), + $el("button", { + textContent: removeAction.name, + onclick: async () => { + await removeAction.cb(); + await this.update(); + }, + }), + ]); + }), + ]), + ]), + $el("div.comfy-list-actions", [ + $el("button", { + textContent: "Clear " + this.#text, + onclick: async () => { + await api.clearItems(this.#type); + await this.load(); + }, + }), + $el("button", { textContent: "Refresh", onclick: () => this.load() }), + ]) + ); } async update() { @@ -53,11 +125,14 @@ class ComfyList { async show() { this.element.style.display = "block"; + this.button.textContent = "Close"; + await this.load(); } hide() { this.element.style.display = "none"; + this.button.textContent = "See " + this.#text; } toggle() { @@ -75,77 +150,78 @@ export class ComfyUI { constructor(app) { this.app = app; this.dialog = new ComfyDialog(); - this.queue = new ComfyList(); - this.history = new ComfyList(); - - this.menuContainer = document.createElement("div"); - this.menuContainer.classList.add("comfy-menu"); - - this.queueSize = document.createElement("span"); - this.menuContainer.append(this.queueSize); - - this.addAction("Queue Prompt", () => { - app.queuePrompt(0); - }, "queue"); - this.btnContainer = document.createElement("div"); - this.btnContainer.classList.add("comfy-menu-btns"); - this.menuContainer.append(this.btnContainer); + this.queue = new ComfyList("Queue"); + this.history = new ComfyList("History"); - this.addAction( - "Queue Front", - () => { - app.queuePrompt(-1); - }, - "sm" - ); + api.addEventListener("status", () => { + this.queue.update(); + this.history.update(); + }); - this.addAction( - "See Queue", - (btn) => { - btn.textContent = this.queue.toggle() ? "Close" : "See Queue"; - }, - "sm" - ); + var input = document.createElement("input"); + input.setAttribute("type", "file"); + input.setAttribute("accept", ".json,image/png"); + input.style.display = "none"; + document.body.appendChild(input); - this.addAction( - "See History", - (btn) => { - btn.textContent = this.history.toggle() ? "Close" : "See History"; - }, - "sm" - ); + input.addEventListener("change", function () { + var file = input.files[0]; + prompt_file_load(file); + }); - this.menuContainer.append(this.queue.element); - this.menuContainer.append(this.history.element); - this.addAction("Save", () => { - app.queuePrompt(-1); - }); - this.addAction("Load", () => { - app.queuePrompt(-1); - }); - this.addAction("Clear", () => { - app.queuePrompt(-1); - }); - this.addAction("Load Default", () => { - app.queuePrompt(-1); - }); + this.menuContainer = $el("div.comfy-menu", { parent: document.body }, [ + $el("span", { $: (q) => (this.queueSize = q) }), + $el("button.comfy-queue-btn", { textContent: "Queue Prompt", onclick: () => app.queuePrompt(0) }), + $el("div.comfy-menu-btns", [ + $el("button", { textContent: "Queue Front", onclick: () => app.queuePrompt(-1) }), + $el("button", { + $: (b) => (this.queue.button = b), + textContent: "View Queue", + onclick: () => { + this.history.hide(); + this.queue.toggle(); + }, + }), + $el("button", { + $: (b) => (this.history.button = b), + textContent: "View History", + onclick: () => { + this.queue.hide(); + this.history.toggle(); + }, + }), + ]), + this.queue.element, + this.history.element, + $el("button", { + textContent: "Save", + onclick: () => { + const json = JSON.stringify(app.graph.serialize()); // convert the data to a JSON string + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = $el("a", { + href: url, + download: "workflow.json", + style: "display: none", + parent: document.body, + }); + a.click(); + setTimeout(function () { + a.remove(); + window.URL.revokeObjectURL(url); + }, 0); + }, + }), + $el("button", { textContent: "Load", onclick: () => {} }), + $el("button", { textContent: "Clear", onclick: () => app.graph.clear() }), + $el("button", { textContent: "Load Default", onclick: () => app.loadGraphData() }), + ]); - document.body.append(this.menuContainer); this.setStatus({ exec_info: { queue_remaining: "X" } }); } - addAction(text, cb, cls) { - const btn = document.createElement("button"); - btn.classList.add("comfy-menu-btn-" + (cls || "lg")); - btn.textContent = text; - btn.onclick = () => { - cb(btn); - }; - (cls === "sm" ? this.btnContainer : this.menuContainer).append(btn); - } - setStatus(status) { this.queueSize.textContent = "Queue size: " + (status ? status.exec_info.queue_remaining : "ERR"); } diff --git a/web/style.css b/web/style.css index bb28ad9fcdba..c262b2f46bbd 100644 --- a/web/style.css +++ b/web/style.css @@ -34,6 +34,8 @@ body { max-height: 80vh; transform: translate(-50%, -50%); overflow: hidden; + min-width: 60%; + justify-content: center; } .comfy-modal-content { @@ -79,21 +81,56 @@ body { align-items: center; } +.comfy-menu button { + font-size: 20px; +} + .comfy-menu-btns { margin-bottom: 10px; + width: 100%; } -.comfy-menu-btn-sm { +.comfy-menu-btns button { font-size: 10px; width: 50%; } -.comfy-menu-btn-lg, .comfy-menu-btn-queue { - font-size: 20px; +.comfy-queue-btn { + width: 100%; } -.comfy-menu-btn-queue { - width: 100%; +.comfy-list { + background-color: rgb(225, 225, 225); + margin-bottom: 10px; +} + +.comfy-list-items { + overflow-y: scroll; + max-height: 100px; + background-color: #d0d0d0; + padding: 5px; +} + +.comfy-list h4 { + min-width: 160px; + margin: 0; + padding: 3px; + font-weight: normal; +} + +.comfy-list-items button { + font-size: 10px; +} + +.comfy-list-actions { + margin: 5px; + display: flex; + gap: 5px; + justify-content: center; +} + +.comfy-list-actions button { + font-size: 12px; } @media (prefers-color-scheme: dark) { @@ -107,7 +144,7 @@ body { } @media only screen and (max-height: 850px) { - #menu { + .comfy-menu { margin-top: -70px; } } From 72cdd83c06d99d6727bd7bbf23a2e655ead4ef1a Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 3 Mar 2023 15:27:08 +0000 Subject: [PATCH 07/18] Readded loading file --- web/index.html | 25 +------------------------ web/scripts/app.js | 29 ++++++++++++++++------------- web/scripts/ui.js | 20 +++++++++----------- 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/web/index.html b/web/index.html index da611ddc5e08..cd95594f85a5 100644 --- a/web/index.html +++ b/web/index.html @@ -12,28 +12,5 @@ window.graph = app.graph; - - - + diff --git a/web/scripts/app.js b/web/scripts/app.js index 4c0eb5103898..580482d00845 100644 --- a/web/scripts/app.js +++ b/web/scripts/app.js @@ -300,19 +300,7 @@ class ComfyApp { event.preventDefault(); event.stopPropagation(); const file = event.dataTransfer.files[0]; - - if (file.type === "image/png") { - const pngInfo = await getPngMetadata(file); - if (pngInfo && pngInfo.workflow) { - this.loadGraphData(JSON.parse(pngInfo.workflow)); - } - } else if (file.type === "application/json" || file.name.endsWith(".json")) { - const reader = new FileReader(); - reader.onload = () => { - this.loadGraphData(JSON.parse(reader.result)); - }; - reader.readAsText(file); - } + await this.handleFile(file); }); } @@ -630,6 +618,21 @@ class ComfyApp { this.canvas.draw(true, true); await this.ui.queue.update(); } + + async handleFile(file) { + if (file.type === "image/png") { + const pngInfo = await getPngMetadata(file); + if (pngInfo && pngInfo.workflow) { + this.loadGraphData(JSON.parse(pngInfo.workflow)); + } + } else if (file.type === "application/json" || file.name.endsWith(".json")) { + const reader = new FileReader(); + reader.onload = () => { + this.loadGraphData(JSON.parse(reader.result)); + }; + reader.readAsText(file); + } + } } export const app = new ComfyApp(); diff --git a/web/scripts/ui.js b/web/scripts/ui.js index 42da67724e14..0bfb692736b1 100644 --- a/web/scripts/ui.js +++ b/web/scripts/ui.js @@ -159,18 +159,16 @@ export class ComfyUI { this.history.update(); }); - var input = document.createElement("input"); - input.setAttribute("type", "file"); - input.setAttribute("accept", ".json,image/png"); - input.style.display = "none"; - document.body.appendChild(input); - - input.addEventListener("change", function () { - var file = input.files[0]; - prompt_file_load(file); + const fileInput = $el("input", { + type: "file", + accept: ".json,image/png", + style: "display: none", + parent: document.body, + onchange: () => { + app.handleFile(fileInput.files[0]); + }, }); - this.menuContainer = $el("div.comfy-menu", { parent: document.body }, [ $el("span", { $: (q) => (this.queueSize = q) }), $el("button.comfy-queue-btn", { textContent: "Queue Prompt", onclick: () => app.queuePrompt(0) }), @@ -214,7 +212,7 @@ export class ComfyUI { }, 0); }, }), - $el("button", { textContent: "Load", onclick: () => {} }), + $el("button", { textContent: "Load", onclick: () => fileInput.click() }), $el("button", { textContent: "Clear", onclick: () => app.graph.clear() }), $el("button", { textContent: "Load Default", onclick: () => app.loadGraphData() }), ]); From 2e52d01cdc443b746268125e46b46b32f5fb2ccf Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 3 Mar 2023 15:47:33 +0000 Subject: [PATCH 08/18] Documenting methods --- web/scripts/api.js | 54 +++++++++++++++++++++++++++++++++++++++++++++- web/scripts/app.js | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/web/scripts/api.js b/web/scripts/api.js index b08f95d699cc..1b3bbecd5f8a 100644 --- a/web/scripts/api.js +++ b/web/scripts/api.js @@ -3,6 +3,9 @@ class ComfyApi extends EventTarget { super(); } + /** + * Poll status for colab and other things that don't support websockets. + */ #pollQueue() { setInterval(async () => { try { @@ -15,6 +18,10 @@ class ComfyApi extends EventTarget { }, 1000); } + /** + * Creates and connects a WebSocket for realtime updates + * @param {boolean} isReconnect If the socket is connection is a reconnect attempt + */ #createSocket(isReconnect) { if (this.socket) { return; @@ -74,15 +81,27 @@ class ComfyApi extends EventTarget { }); } + /** + * Initialises sockets and realtime updates + */ init() { this.#createSocket(); } + /** + * Loads node object definitions for the graph + * @returns The node definitions + */ async getNodeDefs() { const resp = await fetch("object_info", { cache: "no-store" }); return await resp.json(); } + /** + * + * @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue + * @param {object} prompt The prompt data to queue + */ async queuePrompt(number, { output, workflow }) { const body = { client_id: this.clientId, @@ -111,6 +130,11 @@ class ComfyApi extends EventTarget { } } + /** + * Loads a list of items (queue or history) + * @param {string} type The type of items to load, queue or history + * @returns The items of the specified type grouped by their status + */ async getItems(type) { if (type === "queue") { return this.getQueue(); @@ -118,13 +142,20 @@ class ComfyApi extends EventTarget { return this.getHistory(); } + /** + * Gets the current state of the queue + * @returns The currently running and queued items + */ async getQueue() { try { const res = await fetch("/queue"); const data = await res.json(); return { // Running action uses a different endpoint for cancelling - Running: data.queue_running.map((prompt) => ({ prompt, remove: { name: "Cancel", cb: () => api.interrupt() } })), + Running: data.queue_running.map((prompt) => ({ + prompt, + remove: { name: "Cancel", cb: () => api.interrupt() }, + })), Pending: data.queue_pending.map((prompt) => ({ prompt })), }; } catch (error) { @@ -133,6 +164,10 @@ class ComfyApi extends EventTarget { } } + /** + * Gets the prompt execution history + * @returns Prompt history including node outputs + */ async getHistory() { try { const res = await fetch("/history"); @@ -143,6 +178,11 @@ class ComfyApi extends EventTarget { } } + /** + * Sends a POST request to the API + * @param {*} type The endpoint to post to + * @param {*} body Optional POST data + */ async #postItem(type, body) { try { await fetch("/" + type, { @@ -157,14 +197,26 @@ class ComfyApi extends EventTarget { } } + /** + * Deletes an item from the specified list + * @param {string} type The type of item to delete, queue or history + * @param {number} id The id of the item to delete + */ async deleteItem(type, id) { await this.#postItem(type, { delete: [id] }); } + /** + * Clears the specified list + * @param {string} type The type of list to clear, queue or history + */ async clearItems(type) { await this.#postItem(type, { clear: true }); } + /** + * Interrupts the execution of the running prompt + */ async interrupt() { await this.#postItem("interrupt", null); } diff --git a/web/scripts/app.js b/web/scripts/app.js index 580482d00845..a39d6abe52bf 100644 --- a/web/scripts/app.js +++ b/web/scripts/app.js @@ -45,6 +45,12 @@ class ComfyApp { console.error("[comfy]", message, ...other); } + /** + * Invoke an extension callback + * @param {string} method The extension callback to execute + * @param {...any} args Any arguments to pass to the callback + * @returns + */ #invokeExtensions(method, ...args) { let results = []; for (const ext of this.extensions) { @@ -64,6 +70,13 @@ class ComfyApp { return results; } + /** + * Invoke an async extension callback + * Each callback will be invoked concurrently + * @param {string} method The extension callback to execute + * @param {...any} args Any arguments to pass to the callback + * @returns + */ async #invokeExtensionsAsync(method, ...args) { return await Promise.all( this.extensions.map(async (ext) => { @@ -83,6 +96,11 @@ class ComfyApp { ); } + /** + * Adds special context menu handling for nodes + * e.g. this adds Open Image functionality for nodes that show images + * @param {*} node The node to add the menu handler + */ #addNodeContextMenuHandler(node) { node.prototype.getExtraMenuOptions = function (_, options) { if (this.imgs) { @@ -105,6 +123,11 @@ class ComfyApp { }; } + /** + * Adds Custom drawing logic for nodes + * e.g. Draws images and handles thumbnail navigation on nodes that output images + * @param {*} node The node to add the draw handler + */ #addDrawBackgroundHandler(node) { const app = this; node.prototype.onDrawBackground = function (ctx) { @@ -294,6 +317,9 @@ class ComfyApp { }; } + /** + * Adds a handler allowing drag+drop of files onto the window to load workflows + */ #addDropHandler() { // Get prompt from dropped PNG or json document.addEventListener("drop", async (event) => { @@ -304,6 +330,9 @@ class ComfyApp { }); } + /** + * Adds a handler on paste that extracts and loads workflows from pasted JSON data + */ #addPasteHandler() { document.addEventListener("paste", (e) => { let data = (e.clipboardData || window.clipboardData).getData("text/plain"); @@ -325,6 +354,9 @@ class ComfyApp { }); } + /** + * Draws currently executing node highlight and progress bar + */ #addDrawNodeProgressHandler() { const orig = LGraphCanvas.prototype.drawNodeShape; const self = this; @@ -373,6 +405,9 @@ class ComfyApp { }; } + /** + * Handles updates from the API socket + */ #addApiUpdateHandlers() { api.addEventListener("status", ({ detail }) => { this.ui.setStatus(detail); @@ -458,6 +493,9 @@ class ComfyApp { await this.#invokeExtensionsAsync("setup"); } + /** + * Registers nodes with the graph + */ async registerNodes() { const app = this; // Load node definitions from the backend @@ -555,6 +593,10 @@ class ComfyApp { } } + /** + * Converts the current graph workflow for sending to the API + * @returns The workflow and node links + */ graphToPrompt() { // TODO: Implement dynamic prompts const workflow = this.graph.serialize(); @@ -619,6 +661,10 @@ class ComfyApp { await this.ui.queue.update(); } + /** + * Loads workflow data from the specified file + * @param {File} file + */ async handleFile(file) { if (file.type === "image/png") { const pngInfo = await getPngMetadata(file); From 4ef4cf913f587600b8349e65c0a03f5afcbead0e Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 3 Mar 2023 18:28:34 +0000 Subject: [PATCH 09/18] Adding built in extensions + example --- nodes.py | 2 +- web/extensions/core/dynamicPrompts.js | 40 ++++++++++++ web/extensions/core/rerouteNode.js | 92 +++++++++++++++++++++++++++ web/extensions/logging.js.example | 54 ++++++++++++++++ web/index.html | 2 + web/scripts/app.js | 91 ++++++++++++++------------ web/scripts/extensions.js | 9 --- web/scripts/widgets.js | 10 +-- 8 files changed, 243 insertions(+), 57 deletions(-) create mode 100644 web/extensions/core/dynamicPrompts.js create mode 100644 web/extensions/core/rerouteNode.js create mode 100644 web/extensions/logging.js.example delete mode 100644 web/scripts/extensions.js diff --git a/nodes.py b/nodes.py index 02cb7e8ecbaf..dd9f06d417b0 100644 --- a/nodes.py +++ b/nodes.py @@ -51,7 +51,7 @@ def interrupt_processing(value=True): class CLIPTextEncode: @classmethod def INPUT_TYPES(s): - return {"required": {"text": ("STRING", {"multiline": True, "dynamic_prompt": True}), "clip": ("CLIP", )}} + return {"required": {"text": ("STRING", {"multiline": True}), "clip": ("CLIP", )}} RETURN_TYPES = ("CONDITIONING",) FUNCTION = "encode" diff --git a/web/extensions/core/dynamicPrompts.js b/web/extensions/core/dynamicPrompts.js new file mode 100644 index 000000000000..3729cf968e53 --- /dev/null +++ b/web/extensions/core/dynamicPrompts.js @@ -0,0 +1,40 @@ +import { app } from "../../scripts/app.js"; + +// Allows for simple dynamic prompt replacement +// Inputs in the format {a|b} will have a random value of a or b chosen when the prompt is queued. + +app.registerExtension({ + name: "Comfy.DynamicPrompts", + nodeCreated(node) { + // TODO: Change this to replace the value and restore it after posting + + + if (node.widgets) { + // Locate dynamic prompt text widgets + // Include any widgets with dynamicPrompts set to true, and customtext + const widgets = node.widgets.filter( + (n) => (n.type === "customtext" && n.dynamicPrompts !== false) || n.dynamicPrompts + ); + for (const widget of widgets) { + // Override the serialization of the value to resolve dynamic prompts for all widgets supporting it in this node + widget.serializeValue = () => { + let prompt = widget.value; + while (prompt.replace("\\{", "").includes("{") && prompt.replace("\\}", "").includes("}")) { + const startIndex = prompt.replace("\\{", "00").indexOf("{"); + const endIndex = prompt.replace("\\}", "00").indexOf("}"); + + const optionsString = prompt.substring(startIndex + 1, endIndex); + const options = optionsString.split("|"); + + const randomIndex = Math.floor(Math.random() * options.length); + const randomOption = options[randomIndex]; + + prompt = prompt.substring(0, startIndex) + randomOption + prompt.substring(endIndex + 1); + } + + return prompt; + }; + } + } + }, +}); diff --git a/web/extensions/core/rerouteNode.js b/web/extensions/core/rerouteNode.js new file mode 100644 index 000000000000..45b63d878548 --- /dev/null +++ b/web/extensions/core/rerouteNode.js @@ -0,0 +1,92 @@ +import { app } from "../../scripts/app.js"; + +// Node that allows you to redirect connections for cleaner graphs + +app.registerExtension({ + name: "Comfy.RerouteNode", + registerCustomNodes() { + class RerouteNode { + constructor() { + if (!this.properties) { + this.properties = {}; + } + this.properties.showOutputText = RerouteNode.defaultVisibility; + + this.addInput("", "*"); + this.addOutput(this.properties.showOutputText ? "*" : "", "*"); + this.onConnectInput = function (_, type) { + if (type !== this.outputs[0].type) { + this.removeOutput(0); + this.addOutput(this.properties.showOutputText ? type : "", type); + this.size = this.computeSize(); + } + }; + + this.clone = function () { + const cloned = RerouteNode.prototype.clone.apply(this); + cloned.removeOutput(0); + cloned.addOutput(this.properties.showOutputText ? "*" : "", "*"); + cloned.size = cloned.computeSize(); + return cloned; + }; + + // This node is purely frontend and does not impact the resulting prompt so should not be serialized + this.isVirtualNode = true; + } + + getExtraMenuOptions(_, options) { + options.unshift( + { + content: (this.properties.showOutputText ? "Hide" : "Show") + " Type", + callback: () => { + this.properties.showOutputText = !this.properties.showOutputText; + if (this.properties.showOutputText) { + this.outputs[0].name = this.outputs[0].type; + } else { + this.outputs[0].name = ""; + } + this.size = this.computeSize(); + }, + }, + { + content: (RerouteNode.defaultVisibility ? "Hide" : "Show") + " Type By Default", + callback: () => { + RerouteNode.setDefaultTextVisibility(!RerouteNode.defaultVisibility); + }, + } + ); + } + + computeSize() { + return [ + this.properties.showOutputText && this.outputs && this.outputs.length + ? Math.max(55, LiteGraph.NODE_TEXT_SIZE * this.outputs[0].name.length * 0.6 + 40) + : 55, + 26, + ]; + } + + static setDefaultTextVisibility(visible) { + RerouteNode.defaultVisibility = visible; + if (visible) { + localStorage["Comfy.RerouteNode.DefaultVisibility"] = "true"; + } else { + delete localStorage["Comfy.RerouteNode.DefaultVisibility"]; + } + } + } + + // Load default visibility + RerouteNode.setDefaultTextVisibility(!!localStorage["Comfy.RerouteNode.DefaultVisibility"]); + + LiteGraph.registerNodeType( + "Reroute", + Object.assign(RerouteNode, { + title_mode: LiteGraph.NO_TITLE, + title: "Reroute", + }) + ); + + RerouteNode.category = "utils"; + }, +}); diff --git a/web/extensions/logging.js.example b/web/extensions/logging.js.example new file mode 100644 index 000000000000..ce0b346e113f --- /dev/null +++ b/web/extensions/logging.js.example @@ -0,0 +1,54 @@ +import { app } from "../scripts/app.js"; + +const ext = { + name: "Example.LoggingExtension", + async init(app) { + // Any initial setup to run as soon as the page loads + console.log("[logging]", "extension init"); + }, + async setup(app) { + // Any setup to run after the app is created + console.log("[logging]", "extension setup"); + }, + async addCustomNodeDefs(defs, app) { + // Add custom node definitions + // These definitions will be configured and registered automatically + // defs is a lookup core nodes, add yours into this + console.log("[logging]", "add custom node definitions", "current nodes:", Object.keys(defs)); + }, + async getCustomWidgets(app) { + // Return custom widget types + // See ComfyWidgets for widget examples + console.log("[logging]", "provide custom widgets"); + }, + async beforeRegisterNodeDef(nodeType, nodeData, app) { + // Run custom logic before a node definition is registered with the graph + console.log("[logging]", "before register node: ", nodeType, nodeData); + + // This fires for every node definition so only log once + delete ext.beforeRegisterNodeDef; + }, + async registerCustomNodes(app) { + // Register any custom node implementations here allowing for more flexability than a custom node def + console.log("[logging]", "register custom nodes"); + }, + loadedGraphNode(node, app) { + // Fires for each node when loading/dragging/etc a workflow json or png + // If you break something in the backend and want to patch workflows in the frontend + // This is the place to do this + console.log("[logging]", "loaded graph node: ", node); + + // This fires for every node on each load so only log once + delete ext.loadedGraphNode; + }, + nodeCreated(node, app) { + // Fires every time a node is constructed + // You can modify widgets/add handlers/etc here + console.log("[logging]", "node created: ", node); + + // This fires for every node so only log once + delete ext.nodeCreated; + } +}; + +app.registerExtension(ext); diff --git a/web/index.html b/web/index.html index cd95594f85a5..67c7d1e8ca15 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,8 @@