From 0371969c699a0d91781c464fc7bca198be066f42 Mon Sep 17 00:00:00 2001
From: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Date: Mon, 6 Mar 2023 19:42:04 +0000
Subject: [PATCH] Added a few custom nodes
---
export-workflow-svg/exportAsSvg.js | 243 +++++++++++++++++++++++++
latent-upscale-by/latent_upscale_by.py | 29 +++
preview-image/notes.txt | 2 +
preview-image/preview_image.py | 42 +++++
quick-nodes/quickNodes.js | 198 ++++++++++++++++++++
upload-image/notes.txt | 28 +++
upload-image/uploadImage.js | 54 ++++++
upload-image/upload_image.py | 35 ++++
8 files changed, 631 insertions(+)
create mode 100644 export-workflow-svg/exportAsSvg.js
create mode 100644 latent-upscale-by/latent_upscale_by.py
create mode 100644 preview-image/notes.txt
create mode 100644 preview-image/preview_image.py
create mode 100644 quick-nodes/quickNodes.js
create mode 100644 upload-image/notes.txt
create mode 100644 upload-image/uploadImage.js
create mode 100644 upload-image/upload_image.py
diff --git a/export-workflow-svg/exportAsSvg.js b/export-workflow-svg/exportAsSvg.js
new file mode 100644
index 0000000..a5c9380
--- /dev/null
+++ b/export-workflow-svg/exportAsSvg.js
@@ -0,0 +1,243 @@
+import { app } from "../scripts/app.js";
+import { ComfyWidgets } from "../scripts/widgets.js";
+
+// Adds support for import + export as SVG including input + output images
+// Adds two context menu items to the canvas
+// Supports drag + drop import
+
+// https://codepen.io/peterhry/pen/nbMaYg
+function wrapText(context, text, x, y, maxWidth, lineHeight) {
+ var words = text.split(" "),
+ line = "",
+ i,
+ test,
+ metrics;
+
+ for (i = 0; i < words.length; i++) {
+ test = words[i];
+ metrics = context.measureText(test);
+ while (metrics.width > maxWidth) {
+ // Determine how much of the word will fit
+ test = test.substring(0, test.length - 1);
+ metrics = context.measureText(test);
+ }
+ if (words[i] != test) {
+ words.splice(i + 1, 0, words[i].substr(test.length));
+ words[i] = test;
+ }
+
+ test = line + words[i] + " ";
+ metrics = context.measureText(test);
+
+ if (metrics.width > maxWidth && i > 0) {
+ context.fillText(line, x, y);
+ line = words[i] + " ";
+ y += lineHeight;
+ } else {
+ line = test;
+ }
+ }
+
+ context.fillText(line, x, y);
+}
+
+function escapeXml(unsafe) {
+ return unsafe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+}
+function unescapeXml(safe) {
+ return safe.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+}
+
+let saving = false;
+app.registerExtension({
+ name: "pysssss.ExportAsSvg",
+ init() {
+ const stringWidget = ComfyWidgets.STRING;
+ // Override multiline string widgets to draw text using canvas while saving as svg
+ ComfyWidgets.STRING = function () {
+ const w = stringWidget.apply(this, arguments);
+ if (w.widget && w.widget.type === "customtext") {
+ const draw = w.widget.draw;
+ w.widget.draw = function (ctx) {
+ draw.apply(this, arguments);
+
+ if (saving) {
+ const t = ctx.getTransform();
+ ctx.save();
+ ctx.resetTransform();
+
+ const style = document.defaultView.getComputedStyle(this.inputEl, null);
+ const x = parseInt(this.inputEl.style.left);
+ const y = parseInt(this.inputEl.style.top);
+ const w = parseInt(this.inputEl.style.width);
+ const h = parseInt(this.inputEl.style.height);
+ ctx.fillStyle = style.getPropertyValue("background-color");
+ ctx.fillRect(x, y, w, h);
+
+ ctx.fillStyle = style.getPropertyValue("color");
+ ctx.font = style.getPropertyValue("font");
+
+ wrapText(ctx, this.inputEl.value, x, y + t.d * 12, w, t.d * 12);
+
+ ctx.restore();
+ }
+ };
+ }
+ return w;
+ };
+ },
+ setup(app) {
+ const script = document.createElement("script");
+ script.onload = function () {
+ function exportSvg() {
+ // Calculate the min max bounds for the nodes on the graph
+ const bounds = app.graph._nodes.reduce(
+ (p, n) => {
+ if (n.pos[0] < p[0]) p[0] = n.pos[0];
+ if (n.pos[1] < p[1]) p[1] = n.pos[1];
+ const r = n.pos[0] + n.size[0];
+ const b = n.pos[1] + n.size[1];
+ if (r > p[2]) p[2] = r;
+ if (b > p[3]) p[3] = b;
+ return p;
+ },
+ [99999, 99999, -99999, -99999]
+ );
+
+ bounds[0] -= 100;
+ bounds[1] -= 100;
+ bounds[2] += 100;
+ bounds[3] += 100;
+
+ // Store current canvas values to reset after drawing
+ const ctx = app.canvas.ctx;
+ const scale = app.canvas.ds.scale;
+ const width = app.canvas.canvas.width;
+ const height = app.canvas.canvas.height;
+ const offset = app.canvas.ds.offset;
+
+ const svgCtx = new C2S(bounds[2] - bounds[0], bounds[3] - bounds[1]);
+
+ // Override the c2s handling of images to draw images as canvases
+ const drawImage = svgCtx.drawImage;
+ svgCtx.drawImage = function (...args) {
+ const image = args[0];
+ // If we are an image node and not a datauri then we need to replace with a canvas
+ // we cant convert to data uri here as it is an async process
+ if (image.nodeName === "IMG" && !image.src.startsWith("data:image/")) {
+ const canvas = document.createElement("canvas");
+ canvas.width = image.width;
+ canvas.height = image.height;
+ const imgCtx = canvas.getContext("2d");
+ imgCtx.drawImage(image, 0, 0);
+ args[0] = canvas;
+ }
+
+ return drawImage.apply(this, args);
+ };
+
+ // Implement missing required functions
+ svgCtx.getTransform = function () {
+ return ctx.getTransform();
+ };
+ svgCtx.resetTransform = function () {
+ return ctx.resetTransform();
+ };
+ svgCtx.roundRect = svgCtx.rect;
+
+ // Force the canvas to render the whole graph to the svg context
+ app.canvas.ds.scale = 1;
+ app.canvas.canvas.width = bounds[2] - bounds[0];
+ app.canvas.canvas.height = bounds[3] - bounds[1];
+ app.canvas.ds.offset = [-bounds[0], -bounds[1]];
+ app.canvas.ctx = svgCtx;
+
+ // Trigger saving
+ saving = true;
+ app.canvas.draw(true, true);
+ saving = false;
+
+ // Restore original settings
+ app.canvas.ds.scale = scale;
+ app.canvas.canvas.width = width;
+ app.canvas.canvas.height = height;
+ app.canvas.ds.offset = offset;
+ app.canvas.ctx = ctx;
+
+ app.canvas.draw(true, true);
+
+ // Convert to SVG, embed graph and save
+ const json = JSON.stringify(app.graph.serialize());
+ const svg = svgCtx.getSerializedSvg(true).replace("", `${escapeXml(json)}`);
+ const blob = new Blob([svg], { type: "image/svg+xml" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ Object.assign(a, {
+ href: url,
+ download: "workflow.svg",
+ style: "display: none",
+ });
+ document.body.append(a);
+ a.click();
+ setTimeout(function () {
+ a.remove();
+ window.URL.revokeObjectURL(url);
+ }, 0);
+ }
+
+ let fileInput;
+ function importSvg() {
+ if (!fileInput) {
+ fileInput = document.createElement("input");
+ Object.assign(fileInput, {
+ type: "file",
+ accept: ".svg,image/svg+xml",
+ style: "display: none",
+ onchange: () => {
+ app.handleFile(fileInput.files[0]);
+ },
+ });
+ document.body.append(fileInput);
+ }
+ fileInput.click();
+ }
+
+ // Override file handling to allow drag & drop of SVG
+ const handleFile = app.handleFile;
+ app.handleFile = function (file) {
+ if (file.type === "image/svg+xml" || file.name.endsWith(".svg")) {
+ const reader = new FileReader();
+ reader.onload = () => {
+ // Extract embedded workflow from desc tags
+ const descEnd = reader.result.lastIndexOf("");
+ if (descEnd !== -1) {
+ const descStart = reader.result.lastIndexOf("", descEnd);
+ if (descStart !== -1) {
+ const json = reader.result.substring(descStart + 6, descEnd);
+ this.loadGraphData(JSON.parse(unescapeXml(json)));
+ }
+ }
+ };
+ reader.readAsText(file);
+ } else {
+ return handleFile.apply(this, arguments);
+ }
+ };
+
+ // Add canvas menu options
+ const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
+ LGraphCanvas.prototype.getCanvasMenuOptions = function () {
+ const options = orig.apply(this, arguments);
+ options.push(
+ null,
+ { content: "SVG -> Import", callback: importSvg },
+ { content: "SVG -> Export", callback: exportSvg }
+ );
+ return options;
+ };
+ };
+
+ script.src = "http://gliffy.github.io/canvas2svg/canvas2svg.js";
+ document.body.append(script);
+ },
+});
diff --git a/latent-upscale-by/latent_upscale_by.py b/latent-upscale-by/latent_upscale_by.py
new file mode 100644
index 0000000..5b9002f
--- /dev/null
+++ b/latent-upscale-by/latent_upscale_by.py
@@ -0,0 +1,29 @@
+import comfy.utils
+
+
+class LatentUpscaleBy:
+ upscale_methods = ["nearest-exact", "bilinear", "area"]
+ crop_methods = ["disabled", "center"]
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {"required": {"samples": ("LATENT",), "upscale_method": (s.upscale_methods,),
+ "scale": ("FLOAT", {"default": 1.5, "min": 0.1, "max": 10, "step": 0.05}),
+ "crop": (s.crop_methods,)}}
+ RETURN_TYPES = ("LATENT",)
+ FUNCTION = "upscale"
+
+ CATEGORY = "latent"
+
+ def upscale(self, samples, upscale_method, scale, crop):
+ s = samples.copy()
+ w = round(samples["samples"].shape[3] * 8 * scale)
+ h = round(samples["samples"].shape[2] * 8 * scale)
+ s["samples"] = comfy.utils.common_upscale(
+ samples["samples"], w // 8, h // 8, upscale_method, crop)
+ return (s,)
+
+
+NODE_CLASS_MAPPINGS = {
+ "LatentUpscaleBy": LatentUpscaleBy,
+}
diff --git a/preview-image/notes.txt b/preview-image/notes.txt
new file mode 100644
index 0000000..7151c63
--- /dev/null
+++ b/preview-image/notes.txt
@@ -0,0 +1,2 @@
+This will store the base64 encoded version of every input image in memory until it is removed from history
+May need some kind of limit on this...
\ No newline at end of file
diff --git a/preview-image/preview_image.py b/preview-image/preview_image.py
new file mode 100644
index 0000000..56cfe6a
--- /dev/null
+++ b/preview-image/preview_image.py
@@ -0,0 +1,42 @@
+import numpy as np
+import json
+from PIL.PngImagePlugin import PngInfo
+from PIL import Image
+import base64
+from io import BytesIO
+
+class PreviewImage:
+ @classmethod
+ def INPUT_TYPES(s):
+ return {"required":
+ {"images": ("IMAGE", ), },
+ "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
+ }
+
+ RETURN_TYPES = ()
+ FUNCTION = "save_images"
+
+ OUTPUT_NODE = True
+
+ CATEGORY = "image"
+
+ def save_images(self, images, prompt=None, extra_pnginfo=None):
+ paths = list()
+ for image in images:
+ i = 255. * image.cpu().numpy()
+ img = Image.fromarray(i.astype(np.uint8))
+ metadata = PngInfo()
+ if prompt is not None:
+ metadata.add_text("prompt", json.dumps(prompt))
+ if extra_pnginfo is not None:
+ for x in extra_pnginfo:
+ metadata.add_text(x, json.dumps(extra_pnginfo[x]))
+ buffered = BytesIO()
+ img.save(buffered, format="PNG", pnginfo=metadata, optimize=True)
+ paths.append("data:image/png;base64," + base64.b64encode(buffered.getvalue()).decode('ascii'))
+ return {"ui": {"images": paths}}
+
+
+NODE_CLASS_MAPPINGS = {
+ "PreviewImage": PreviewImage,
+}
diff --git a/quick-nodes/quickNodes.js b/quick-nodes/quickNodes.js
new file mode 100644
index 0000000..8391723
--- /dev/null
+++ b/quick-nodes/quickNodes.js
@@ -0,0 +1,198 @@
+import { app } from "../../scripts/app.js";
+
+// Adds a bunch of context menu entries for quickly adding common steps
+
+// Any node with VAE input: "Use Vae" - Will find or add a VAE Loader and attach it
+// KSampler: "Add Blank Input" - Connects an EmptyLatentImage
+// KSampler: "Add Hi-res Fix" - Connects a LatentUpscale + second KSampler
+// KSampler: "Add 2nd Pass" - Connects a new Ckpt Loader, CLIP Texts, Upscale and Sampler
+// KSampler: "Add Save Image" - Connects a VAEDecode + SaveImage
+// CheckpointLoaderSimple: "Add Clip Skip" - Connects a CLIPSetLastLayer node
+// CheckpointLoaderSimple, CheckpointLoader, LoraLoader - "Add LORA" - Connects a new LoraLoader
+// CheckpointLoaderSimple, CheckpointLoader, LoraLoader - "Add Promps" - Connects two new CLIPTextEncodes
+
+function addMenuHandler(nodeType, cb) {
+ const getOpts = nodeType.prototype.getExtraMenuOptions;
+ nodeType.prototype.getExtraMenuOptions = function () {
+ const r = getOpts.apply(this, arguments);
+ cb.apply(this, arguments);
+ return r;
+ };
+}
+
+function getOrAddVAELoader(node) {
+ let vaeNode = app.graph._nodes.find((n) => n.type === "VAELoader");
+ if (!vaeNode) {
+ vaeNode = addNode("VAELoader", node);
+ }
+ return vaeNode;
+}
+
+function addNode(name, nextTo, options) {
+ options = { select: true, shiftY: 0, before: false, ...(options || {}) };
+ const node = LiteGraph.createNode(name);
+ app.graph.add(node);
+ node.pos = [
+ options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30,
+ nextTo.pos[1] + options.shiftY,
+ ];
+ if (options.select) {
+ app.canvas.selectNode(node, false);
+ }
+ return node;
+}
+
+app.registerExtension({
+ name: "pysssss.QuickNodes",
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ if (nodeData.input && nodeData.input.required) {
+ const keys = Object.keys(nodeData.input.required);
+ for (let i = 0; i < keys.length; i++) {
+ if (nodeData.input.required[keys[i]][0] === "VAE") {
+ addMenuHandler(nodeType, function (_, options) {
+ options.unshift({
+ content: "Use VAE",
+ callback: () => {
+ getOrAddVAELoader(this).connect(0, this, i);
+ },
+ });
+ });
+ break;
+ }
+ }
+ }
+
+ if (nodeData.name === "KSampler") {
+ addMenuHandler(nodeType, function (_, options) {
+ options.unshift(
+ {
+ content: "Add Blank Input",
+ callback: () => {
+ const imageNode = addNode("EmptyLatentImage", this, { before: true });
+ imageNode.connect(0, this, 3);
+ },
+ },
+ {
+ content: "Add Hi-res Fix",
+ callback: () => {
+ const upscaleNode = addNode("LatentUpscale", this);
+ this.connect(0, upscaleNode, 0);
+
+ const sampleNode = addNode("KSampler", upscaleNode);
+
+ for (let i = 0; i < 3; i++) {
+ const l = this.getInputLink(i);
+ if (l) {
+ app.graph.getNodeById(l.origin_id).connect(l.origin_slot, sampleNode, i);
+ }
+ }
+
+ upscaleNode.connect(0, sampleNode, 3);
+ },
+ },
+ {
+ content: "Add 2nd Pass",
+ callback: () => {
+ const upscaleNode = addNode("LatentUpscale", this);
+ this.connect(0, upscaleNode, 0);
+
+ const ckptNode = addNode("CheckpointLoaderSimple", this);
+ const sampleNode = addNode("KSampler", ckptNode);
+
+ const positiveLink = this.getInputLink(1);
+ const negativeLink = this.getInputLink(2);
+ const positiveNode = positiveLink
+ ? app.graph.add(app.graph.getNodeById(positiveLink.origin_id).clone())
+ : addNode("CLIPTextEncode");
+ const negativeNode = negativeLink
+ ? app.graph.add(app.graph.getNodeById(negativeLink.origin_id).clone())
+ : addNode("CLIPTextEncode");
+
+ ckptNode.connect(0, sampleNode, 0);
+ ckptNode.connect(1, positiveNode, 0);
+ ckptNode.connect(1, negativeNode, 0);
+ positiveNode.connect(0, sampleNode, 1);
+ negativeNode.connect(0, sampleNode, 2);
+ upscaleNode.connect(0, sampleNode, 3);
+ },
+ },
+ {
+ content: "Add Save Image",
+ callback: () => {
+ const decodeNode = addNode("VAEDecode", this);
+ this.connect(0, decodeNode, 0);
+
+ getOrAddVAELoader(decodeNode).connect(0, decodeNode, 1);
+
+ const saveNode = addNode("SaveImage", decodeNode);
+ decodeNode.connect(0, saveNode, 0);
+ },
+ }
+ );
+ });
+ }
+
+ if (nodeData.name === "CheckpointLoaderSimple") {
+ addMenuHandler(nodeType, function (_, options) {
+ options.unshift({
+ content: "Add Clip Skip",
+ callback: () => {
+ const clipSkipNode = addNode("CLIPSetLastLayer", this);
+ const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : [];
+
+ this.disconnectOutput(1);
+ this.connect(1, clipSkipNode, 0);
+
+ for (const clipLink of clipLinks) {
+ clipSkipNode.connect(0, clipLink.target_id, clipLink.target_slot);
+ }
+ }
+ });
+ });
+ }
+
+ if (
+ nodeData.name === "CheckpointLoaderSimple" ||
+ nodeData.name === "CheckpointLoader" ||
+ nodeData.name === "LoraLoader"
+ ) {
+ addMenuHandler(nodeType, function (_, options) {
+ options.unshift(
+ {
+ content: "Add LORA",
+ callback: () => {
+ const loraNode = addNode("LoraLoader", this);
+
+ const modelLinks = this.outputs[0].links ? this.outputs[0].links.map((l) => ({ ...graph.links[l] })) : [];
+ const clipLinks = this.outputs[1].links ? this.outputs[1].links.map((l) => ({ ...graph.links[l] })) : [];
+
+ this.disconnectOutput(0);
+ this.disconnectOutput(1);
+
+ this.connect(0, loraNode, 0);
+ this.connect(1, loraNode, 1);
+
+ for (const modelLink of modelLinks) {
+ loraNode.connect(0, modelLink.target_id, modelLink.target_slot);
+ }
+
+ for (const clipLink of clipLinks) {
+ loraNode.connect(1, clipLink.target_id, clipLink.target_slot);
+ }
+ },
+ },
+ {
+ content: "Add Prompts",
+ callback: () => {
+ const positiveNode = addNode("CLIPTextEncode", this);
+ const negativeNode = addNode("CLIPTextEncode", this, { shiftY: positiveNode.size[1] + 30 });
+
+ this.connect(1, positiveNode, 0);
+ this.connect(1, negativeNode, 0);
+ },
+ }
+ );
+ });
+ }
+ },
+});
diff --git a/upload-image/notes.txt b/upload-image/notes.txt
new file mode 100644
index 0000000..d074e93
--- /dev/null
+++ b/upload-image/notes.txt
@@ -0,0 +1,28 @@
+This will embed the selected image into the generated images which probably isnt desired
+Current fix for this is to add this to execution.py:
+
+def prune_prompt(prompt):
+ pruned_prompt = {}
+ for n in prompt:
+ class_type = prompt[n]['class_type']
+ class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
+ valid_inputs = class_def.INPUT_TYPES()
+ pruned_prompt[n] = copy.deepcopy(prompt[n])
+
+ if "inputs" in pruned_prompt[n]:
+ for x in pruned_prompt[n]["inputs"]:
+ input_type = None
+ if ("required" in valid_inputs and x in valid_inputs["required"]):
+ input_type = valid_inputs["required"][x]
+ elif ("optional" in valid_inputs and x in valid_inputs["optional"]):
+ input_type = valid_inputs["optional"][x]
+
+ if input_type is not None and input_type[0] == "B64IMAGE":
+ pruned_prompt[n]["inputs"][x] = None
+
+ return pruned_prompt
+
+
+And call that from
+ if h[x] == "PROMPT":
+ input_data_all[x] = prune_prompt(prompt)
\ No newline at end of file
diff --git a/upload-image/uploadImage.js b/upload-image/uploadImage.js
new file mode 100644
index 0000000..a1fe207
--- /dev/null
+++ b/upload-image/uploadImage.js
@@ -0,0 +1,54 @@
+import { app } from "../scripts/app.js";
+
+// Adds a new UploadImage node
+
+const toBase64 = (file) =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = (error) => reject(error);
+ });
+
+app.registerExtension({
+ name: "Comfy.UploadImage",
+ async getCustomWidgets() {
+ return {
+ B64IMAGE(node) {
+ let uploadWidget;
+ const fileInput = document.createElement("input");
+
+ Object.assign(fileInput, {
+ type: "file",
+ accept: "image/jpeg,image/png",
+ style: "display: none",
+ onchange: () => {
+ if (fileInput.files.length) {
+ const img = new Image();
+ img.onload = () => {
+ node.imgs = [img];
+ };
+ toBase64(fileInput.files[0]).then((d) => {
+ img.src = d;
+ });
+ }
+ },
+ });
+ document.body.append(fileInput);
+
+ uploadWidget = node.addWidget("button", "image", "image", () => {
+ fileInput.click();
+ });
+
+ uploadWidget.serializeValue = () => {
+ if(node.imgs && node.imgs.length) {
+ return node.imgs[0].src;
+ }
+ return null;
+ };
+
+ return { widget: uploadWidget };
+ },
+ };
+ },
+});
diff --git a/upload-image/upload_image.py b/upload-image/upload_image.py
new file mode 100644
index 0000000..236f17e
--- /dev/null
+++ b/upload-image/upload_image.py
@@ -0,0 +1,35 @@
+from PIL import Image
+import numpy as np
+import torch
+
+
+class UploadImage:
+ @classmethod
+ def INPUT_TYPES(s):
+ return {"required":
+ {"image": ("B64IMAGE",)},
+ }
+
+ CATEGORY = "image"
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "load_image"
+
+ def load_image(self, image):
+ from io import BytesIO
+ import re
+ import base64
+ if image.startswith("data:image/"):
+ image_data = re.sub('^data:image/.+;base64,', '', image)
+ i = Image.open(BytesIO(base64.b64decode(image_data)))
+ else:
+ raise Exception("Invalid image data")
+
+ image = i.convert("RGB")
+ image = np.array(image).astype(np.float32) / 255.0
+ image = torch.from_numpy(image)[None,]
+ return (image,)
+
+NODE_CLASS_MAPPINGS = {
+ "UploadImage": UploadImage,
+}