commit ef740c64f619228bc4095d8947c99609e473ce78 Author: pixelworld AI Date: Sun Aug 10 08:01:59 2025 -0500 Initial commit: Workflow Graphics custom node for ComfyUI diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b8fd68 --- /dev/null +++ b/README.md @@ -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` + + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..529f0af --- /dev/null +++ b/__init__.py @@ -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"] + diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..29de0b6 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/web/comfyui/branding_image.js b/web/comfyui/branding_image.js new file mode 100644 index 0000000..3065c72 --- /dev/null +++ b/web/comfyui/branding_image.js @@ -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 ` +

+ 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. +

+ `; + } + + 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 (_) {} +}; +