Initial commit: Workflow Graphics custom node for ComfyUI

This commit is contained in:
pixelworld AI
2025-08-10 08:01:59 -05:00
commit ef740c64f6
4 changed files with 415 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
## Workflow Graphics (ComfyUI)
Add static images to your ComfyUI canvas as non-executable overlay nodes. Useful for logos, watermarks, or visual guides inside your workflow.
### Install
- Place this folder (`ComfyUI_Workflow_Branding`) in `ComfyUI/custom_nodes/`.
- Restart ComfyUI.
### Node
- Category: `branding`
- Name: `Workflow Graphics`
### Usage
1. Add the node by searching for `Workflow Graphics`.
2. Open the node’s panel (double‑click the node or use the right‑side properties).
3. Paste an image as either:
- A complete data URL: `data:image/png;base64,...`
- Raw base64 (assumed PNG)
4. Adjust:
- `Scale (%)`: 0–100 of the image’s natural size
- `Opacity`: 0..1
- `Background Color`: any CSS color or `transparent`
- `Border Radius`: radius in pixels
5. Pin the node (ComfyUI pin) to allow click‑through over the canvas while keeping it visible.
6. Use the context menu item `Reload Workflow Graphics` if you manually changed the base64 and want to refresh.
Notes:
- This is a frontend‑only node; it does not participate in execution.
- The node size auto‑matches the displayed image dimensions.
### Dev helpers
In the browser console you can run `window.refreshBrandingImages()` to reload all Workflow Graphics nodes on the canvas.
### Support
If this saved you time, you can support development here: `https://ko-fi.com/pixelworldai`
+18
View File
@@ -0,0 +1,18 @@
"""
@author: custom
@title: Workflow Branding
@nickname: branding
@description: Virtual nodes to add branding elements to the ComfyUI canvas. Includes a base64 image label node.
"""
# No backend nodes are required; this package only exposes a web extension.
# Any .js file in this directory will be auto-loaded by ComfyUI as a frontend extension.
WEB_DIRECTORY = "./web/comfyui"
# Keep mappings empty; this package doesn't register server-side nodes.
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
__all__ = ["WEB_DIRECTORY", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
Binary file not shown.
+360
View File
@@ -0,0 +1,360 @@
import { app } from "../../scripts/app.js";
const TYPE = "WorkflowGraphics";
const TITLE = "Workflow Graphics";
/**
* BrandingImage draws a user-supplied base64 image onto the canvas as a virtual node.
* It mirrors the rgthree Label node UX: adjustable via properties, double-click opens
* the properties panel, and pinned nodes allow click-through.
*/
export class BrandingImage extends LGraphNode {
static title = TITLE;
static type = TYPE;
comfyClass = TITLE;
static title_mode = LiteGraph.NO_TITLE;
static collapsable = false;
static ["@imageData"] = { type: "string" };
static ["@scalePercent"] = { type: "number" };
static ["@opacity"] = { type: "number" };
static ["@backgroundColor"] = { type: "string" };
static ["@borderRadius"] = { type: "number" };
resizable = false;
constructor(title = BrandingImage.title) {
super(title);
this.properties = this.properties || {};
this.properties["imageData"] = this.properties["imageData"] || "";
this.properties["scalePercent"] = this.properties["scalePercent"] ?? 100;
this.properties["opacity"] = 1.0;
this.properties["backgroundColor"] = "transparent";
this.properties["borderRadius"] = 0;
this._img = null;
this._imgNatural = { w: 64, h: 64 };
this.color = "#fff0";
this.bgcolor = "#fff0";
this.widgets = this.widgets || [];
this.properties = this.properties || {};
this.serialize_widgets = true;
if (!this.title || this.title === TYPE) {
this.title = TITLE;
}
this.isVirtualNode = true;
}
get imageData() {
return this.properties["imageData"] || "";
}
set imageData(v) {
this.properties["imageData"] = v || "";
this._loadImage();
}
configure(info) {
super.configure(info);
if (this.properties?.imageData && !this._img) {
this._loadImage();
}
}
_normalizeDataUrl(data) {
if (!data) return "";
const trimmed = String(data).trim();
if (trimmed.startsWith("data:")) {
const comma = trimmed.indexOf(",");
if (comma === -1) return trimmed;
const prefix = trimmed.slice(0, comma + 1);
const payload = trimmed.slice(comma + 1).replace(/\s+/g, "");
return prefix + payload;
}
return `data:image/png;base64,${trimmed.replace(/\s+/g, "")}`;
}
_loadImage() {
const src = this._normalizeDataUrl(this.properties["imageData"]);
if (!src) {
this._img = null;
return;
}
const img = new Image();
img.onload = () => {
this._img = img;
this._imgNatural = { w: img.naturalWidth || img.width, h: img.naturalHeight || img.height };
this.setDirtyCanvas(true, true);
};
img.onerror = () => {
this._img = null;
this.setDirtyCanvas(true, true);
};
img.src = src;
}
draw(ctx) {
this.flags = this.flags || {};
this.flags.allow_interaction = !this.flags.pinned;
this.color = "#fff0";
this.bgcolor = "#fff0";
const opacity = Math.max(0, Math.min(1, Number(this.properties["opacity"]) ?? 1));
const borderRadius = Number(this.properties["borderRadius"]) || 0;
const bg = this.properties["backgroundColor"] || "";
// Compute draw size
const nat = this._imgNatural;
let w = 0;
let h = 0;
const legacyW = Number(this.properties["width"]) || 0;
const legacyH = Number(this.properties["height"]) || 0;
if (legacyW || legacyH) {
if (legacyW && !legacyH) {
const ratio = nat.h > 0 ? nat.h / nat.w : 1;
w = legacyW;
h = Math.max(1, Math.round(legacyW * ratio));
} else if (!legacyW && legacyH) {
const ratio = nat.w > 0 ? nat.w / nat.h : 1;
h = legacyH;
w = Math.max(1, Math.round(legacyH * ratio));
} else {
w = legacyW;
h = legacyH;
}
} else {
const scale = Math.max(0, Math.min(100, Number(this.properties["scalePercent"]) ?? 100)) / 100;
w = Math.max(1, Math.round(nat.w * scale));
h = Math.max(1, Math.round(nat.h * scale));
}
// Update node box size
this.size[0] = Math.max(1, w);
this.size[1] = Math.max(1, h);
const hasBg = !!bg && bg !== "transparent";
if (hasBg) {
ctx.save();
ctx.beginPath();
ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
ctx.fillStyle = bg;
ctx.fill();
ctx.restore();
}
if (this._img) {
ctx.save();
ctx.globalAlpha = opacity;
if (borderRadius > 0) {
ctx.beginPath();
ctx.roundRect(0, 0, w, h, [borderRadius]);
ctx.clip();
}
ctx.drawImage(this._img, 0, 0, w, h);
ctx.restore();
} else {
ctx.save();
ctx.strokeStyle = "#8886";
ctx.setLineDash([4, 4]);
ctx.strokeRect(0, 0, w, h);
ctx.setLineDash([]);
ctx.fillStyle = "#8888";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "12px Arial";
ctx.fillText(TITLE, w / 2, h / 2);
ctx.restore();
}
}
onDblClick() {
LGraphCanvas.active_canvas.showShowNodePanel(this);
}
inResizeCorner() {
return this.resizable;
}
getHelp() {
return `
<p>
The Workflow Graphics node draws a base64-encoded image directly on the canvas. Paste a data URL
(e.g. data:image/png;base64,...) or raw base64 (assumed PNG) into the Image Data property.
</p>
<ul>
<li><strong>Scale (%)</strong>: 0–100 of the image's natural size.</li>
<li><strong>Opacity</strong>: 0..1</li>
<li><strong>Background Color</strong>: hex or CSS color; use transparent for none.</li>
<li><strong>Pin</strong>: use ComfyUI pin to allow click-through.</li>
</ul>`;
}
onShowCustomPanelInfo(panel) {
panel.querySelector('div.property[data-property="Mode"]')?.remove();
panel.querySelector('div.property[data-property="Color"]')?.remove();
const addRow = (label, inputEl) => {
const row = document.createElement("div");
row.className = "property";
const title = document.createElement("span");
title.className = "name";
title.textContent = label;
const value = document.createElement("div");
value.className = "value";
value.appendChild(inputEl);
row.appendChild(title);
row.appendChild(value);
panel.appendChild(row);
};
const ta = document.createElement("textarea");
ta.value = this.properties.imageData || "";
ta.rows = 4;
ta.placeholder = "data:image/png;base64,.... or base64";
const applyImageData = () => {
this.imageData = ta.value;
this.setDirtyCanvas(true, true);
};
ta.addEventListener("change", applyImageData);
ta.addEventListener("input", applyImageData);
addRow("Image Data", ta);
const refreshBtn = document.createElement("button");
refreshBtn.textContent = "Reload Image";
refreshBtn.className = "comfy-btn";
refreshBtn.addEventListener("click", () => {
this._loadImage();
this.setDirtyCanvas(true, true);
});
addRow("Refresh", refreshBtn);
try {
const titleInput = panel.querySelector('div.property[data-property="Title"] input');
if (titleInput) {
if (this.title === TYPE) {
this.title = TITLE;
}
titleInput.value = this.title;
}
} catch (_) {}
const makeNumberInput = (prop, min, max, step = 1) => {
const el = document.createElement("input");
el.type = "number";
if (min != null) el.min = String(min);
if (max != null) el.max = String(max);
el.step = String(step);
el.value = String(this.properties[prop] ?? 0);
const applyNumber = () => {
const v = Number(el.value);
this.properties[prop] = isNaN(v) ? 0 : v;
this.setDirtyCanvas(true, true);
};
el.addEventListener("change", applyNumber);
el.addEventListener("input", applyNumber);
return el;
};
const scale = makeNumberInput("scalePercent", 0, 100, 1);
const applyScale = () => {
const v = Number(scale.value);
this.properties.scalePercent = Math.max(0, Math.min(100, isNaN(v) ? 100 : v));
this.setDirtyCanvas(true, true);
};
scale.addEventListener("change", applyScale);
scale.addEventListener("input", applyScale);
addRow("Scale (%)", scale);
const op = makeNumberInput("opacity", 0, 1, 0.01);
const applyOpacity = () => {
const v = Number(op.value);
this.properties.opacity = Math.max(0, Math.min(1, isNaN(v) ? 1 : v));
this.setDirtyCanvas(true, true);
};
op.addEventListener("change", applyOpacity);
op.addEventListener("input", applyOpacity);
addRow("Opacity", op);
const bg = document.createElement("input");
bg.type = "text";
bg.value = this.properties.backgroundColor || "transparent";
bg.placeholder = "transparent | #RRGGBB[AA] | css color";
const applyBg = () => {
this.properties.backgroundColor = bg.value || "transparent";
this.setDirtyCanvas(true, true);
};
bg.addEventListener("change", applyBg);
bg.addEventListener("input", applyBg);
addRow("Background Color", bg);
addRow("Border Radius", makeNumberInput("borderRadius", 0, null, 1));
}
getExtraMenuOptions(canvas, options) {
options = options || [];
options.unshift({
content: "Reload Workflow Graphics",
callback: () => {
this._loadImage();
canvas?.setDirty(true, true);
},
});
return [];
}
}
const oldDrawNode = LGraphCanvas.prototype.drawNode;
LGraphCanvas.prototype.drawNode = function (node, ctx) {
if (node && node.constructor === BrandingImage.prototype.constructor) {
node.bgcolor = "transparent";
node.color = "transparent";
const v = oldDrawNode.apply(this, arguments);
node.draw(ctx);
return v;
}
return oldDrawNode.apply(this, arguments);
};
const oldGetNodeOnPos = LGraph.prototype.getNodeOnPos;
LGraph.prototype.getNodeOnPos = function (x, y, nodes_list) {
if (nodes_list && LGraphCanvas.active_canvas?.last_mouseclick != null) {
const isDoubleClick = LiteGraph.getTime() - LGraphCanvas.active_canvas.last_mouseclick < 300;
if (!isDoubleClick) {
nodes_list = [...nodes_list].filter((n) => !(n instanceof BrandingImage) || !n.flags?.pinned);
}
}
return oldGetNodeOnPos.apply(this, [x, y, nodes_list]);
};
app.registerExtension({
name: "WorkflowGraphics",
registerCustomNodes() {
const api = window.app?.api;
try {
api?.registerCustomNodeDefinition?.({
name: TITLE,
display_name: TITLE,
category: "branding",
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
});
} catch (_) {}
BrandingImage.category = "branding";
LiteGraph.registerNodeType(BrandingImage.type, BrandingImage);
},
});
window.refreshBrandingImages = function () {
try {
const nodes = app?.graph?._nodes || [];
for (const n of nodes) {
if (n && n.constructor === BrandingImage.prototype.constructor) {
n._loadImage?.();
}
}
app?.canvas?.setDirty(true, true);
} catch (_) {}
};