diff --git a/README.md b/README.md index c7dbda4..fc16789 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,87 @@ A config-driven carrier node that forwards one pipe connection while exposing na --- +### BV Subgraph Heading +A UI node that renders a bold header text inside the node. +Designed to work well inside **Subgraphs/Subflows**: when exposed, the header is also rendered on the Subgraph node in the main graph. + +![BV Subgraph Heading](docs/screenshots/bv_subgraph_heading.png) + +![BV Subgraph Heading](docs/screenshots/bv_subgraph_heading_in_subgraph.png) + +![BV Subgraph Heading](docs/screenshots/bv_subgraph_heading_subgraph.png) + +**Inputs** +- `value` (STRING) — the header text +- `font_size` (INT) — font size used for rendering +- `show_divider` (BOOLEAN) — optional divider line below the heading + +**What it does** +- Displays a clean header label for visual separation +- Dynamically adjusts its node height to avoid text overlap/clipping +- Optional divider line for stronger section separation + +![BV Subgraph Heading](docs/screenshots/bv_subgraph_heading_divider.png) + +**How to use** +1. Add **BV Subgraph Heading** +2. Set the title via `value` +3. (Optional) increase `font_size` +4. (Optional) enable `show_divider` + +**Subgraph usage** +- Place it inside a Subgraph/Subflow and expose the widget input to show the same heading on the Subgraph node. + +--- + +### BV Spacer +A UI-only spacer node to add vertical whitespace in your graph or inside Subgraphs/Subflows. + +![BV Spacer](docs/screenshots/bv_spacer.png) + +![BV Spacer](docs/screenshots/bv_spacer_in_subgraph.png) + +![BV Spacer](docs/screenshots/bv_spacer_subgraph.png) + +**Inputs** +- `height` (INT) — spacer height in pixels +- `scale` (FLOAT, optional) — multiplier for the height (useful for quick global spacing adjustments) + +**What it does** +- Reserves vertical space without rendering text +- Updates its node height dynamically (also in Subgraphs/Subflows) + +**How to use** +1. Add **BV Spacer** +2. Adjust `height` to the desired spacing +3. (Optional) use `scale` for fine-tuning + +--- + +### BV Divider +A UI-only divider node that draws a horizontal line to visually separate sections. + +![BV Divider](docs/screenshots/bv_divider.png) + +![BV Divider](docs/screenshots/bv_divider_in_subgraph.png) + +![BV Divider](docs/screenshots/bv_divider_subgrpah.png) + +**Inputs** +- `thickness` (INT) — line thickness +- `padding` (INT) — left/right padding +- `alpha` (FLOAT) — line opacity + +**What it does** +- Draws a horizontal separator line +- Dynamically updates when inputs change (also in Subgraphs/Subflows) + +**How to use** +1. Add **BV Divider** +2. Adjust `thickness`, `padding`, and `alpha` to match your layout + +--- + ## Quick Start (Pipe) 1. Add **BV Pipe Config** and enter names, e.g.: @@ -93,7 +174,6 @@ prompt ## Notes - Pipes update dynamically (including Subgraphs/Subflows). - Slot socket IDs stay stable internally (`v_001…v_100`, `out_001…out_100`) while labels come from your config. +- UI nodes (**Heading / Spacer / Divider**) use JavaScript rendering and support being exposed through Subgraphs/Subflows. - -## Known issues -If a pipe config is within a subgraph and this generated pipe is connected to a pipe node in the maingraph or another subgraph, the values are only applied when the config is edited. +--- diff --git a/js/bv_subgraph_divider.js b/js/bv_subgraph_divider.js new file mode 100644 index 0000000..63e27ca --- /dev/null +++ b/js/bv_subgraph_divider.js @@ -0,0 +1,126 @@ +import {app} from "../../scripts/app.js"; + +function getWidget(node, name) { + return (node?.widgets || []).find((w) => w?.name === name) || null; +} + +function clamp(n, a, b) { + n = Number(n); + if (!Number.isFinite(n)) n = a; + return Math.max(a, Math.min(b, n)); +} + +function patchDivider(node) { + const thickW = getWidget(node, "thickness"); + const padW = getWidget(node, "padding"); + const alphaW = getWidget(node, "alpha"); + + if (!node || !thickW || thickW.__bvDividerPatched) return; + thickW.__bvDividerPatched = true; + + thickW.__bvNode = node; + thickW.__bvPadWidget = padW; + thickW.__bvAlphaWidget = alphaW; + + thickW.type = "BV_DIVIDER"; + thickW.options = thickW.options || {}; + thickW.options.serialize = true; + + thickW.draw = function (ctx, _node, width, posY, height) { + const thickness = clamp(thickW.value ?? 2, 1, 10); + const pad = clamp(this.__bvPadWidget?.value ?? 10, 0, 60); + const alpha = clamp(this.__bvAlphaWidget?.value ?? 0.35, 0.05, 1.0); + + const y = posY + height * 0.5; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.lineWidth = thickness; + ctx.strokeStyle = "#ddd"; + ctx.beginPath(); + ctx.moveTo(pad, y); + ctx.lineTo(width - pad, y); + ctx.stroke(); + ctx.restore(); + }; + + thickW.computeSize = function (width) { + const thickness = clamp(thickW.value ?? 2, 1, 10); + const padY = 10; + const h = Math.max(18, thickness + padY); + return [Math.max(220, width || 220), h]; + }; + + const hookRedraw = (w) => { + if (!w || w.__bvDividerHooked) return; + w.__bvDividerHooked = true; + + const oldCb = w.callback; + w.callback = function () { + const n = thickW.__bvNode || node; + n?.setSize?.(n.computeSize()); + n?.setDirtyCanvas(true, true); + n?.graph?.setDirtyCanvas(true, true); + return oldCb?.apply(this, arguments); + }; + }; + + hookRedraw(thickW); + hookRedraw(padW); + hookRedraw(alphaW); +} + +function hideAllOutputs(node) { + if (!node?.outputs?.length) return; + node.outputs.length = 0; // removes output slots visually + node.setDirtyCanvas?.(true, true); + node.graph?.setDirtyCanvas?.(true, true); +} + + +app.registerExtension({ + name: "bv_nodepack.bv_divider", + + getCustomWidgets() { + return { + BV_DIVIDER: () => ({widget: null}), + }; + }, + + async beforeRegisterNodeDef(nodeType, nodeData) { + const comfyClass = nodeType?.ComfyClass || nodeType?.comfyClass; + const nodeName = nodeData?.name; + + const isDivider = comfyClass === "BVSubgraphDivider" || nodeName === "BV Subgraph Divider"; + if (!isDivider) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated?.apply(this, arguments); + + patchDivider(this); + + + hideAllOutputs(this); + + this.setSize?.(this.computeSize()); + this.graph?.setDirtyCanvas(true, true); + + this.serialize_widgets = true; + this.setDirtyCanvas(true, true); + return r; + }; + + const oldConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const r = oldConfigure?.apply(this, arguments); + + // patch + hide outputs after graph loads/restores node + patchDivider(this); + hideAllOutputs(this); + + this.setSize?.(this.computeSize()); + return r; + }; + }, +}); diff --git a/js/bv_subgraph_heading.js b/js/bv_subgraph_heading.js new file mode 100644 index 0000000..f12328e --- /dev/null +++ b/js/bv_subgraph_heading.js @@ -0,0 +1,155 @@ +import {app} from "../../scripts/app.js"; + +function getWidget(node, name) { + return (node?.widgets || []).find((w) => w?.name === name) || null; +} + +function clamp(n, a, b) { + n = Number(n); + if (!Number.isFinite(n)) n = a; + return Math.max(a, Math.min(b, n)); +} + +function patchHeading(node) { + const valueW = + (node.widgets || []).find((w) => w?.name === "value") || + (node.widgets || []).find((w) => w?.config?.[0] === "STRING"); + + if (!node || !valueW || valueW.__bvHeadingPatched) return; + valueW.__bvHeadingPatched = true; + + valueW.__bvNode = node; + valueW.__bvFontSizeWidget = getWidget(node, "font_size"); + valueW.__bvShowDividerWidget = getWidget(node, "show_divider"); + + valueW.type = "BV_HEADING"; + valueW.options = valueW.options || {}; + valueW.options.serialize = true; + + valueW.draw = function (ctx, _node, width, posY, height) { + const text = String(this.value ?? ""); + const fontSize = clamp(this.__bvFontSizeWidget?.value ?? 20, 10, 80); + const showDivider = !!this.__bvShowDividerWidget?.value; + + const padX = 10; + const padY = 10; + const textH = fontSize * 1.35; + + const y = posY + Math.max(0, (height - (textH + (showDivider ? 10 : 0))) * 0.5); + + ctx.save(); + + // Clip only the text area + ctx.beginPath(); + ctx.rect(padX, posY + padY, width - padX * 2, textH + padY); + ctx.clip(); + + ctx.font = `700 ${fontSize}px Inter, system-ui, sans-serif`; + ctx.fillStyle = "#ddd"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(text, padX, y + padY); + + ctx.restore(); + + if (showDivider) { + const lineY = posY + textH + 5; + ctx.save(); + ctx.globalAlpha = 0.35; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(padX, lineY); + ctx.lineTo(width - padX, lineY); + ctx.strokeStyle = "#ddd"; + ctx.stroke(); + ctx.restore(); + } + }; + + valueW.computeSize = function (width) { + const fontSize = clamp(this.__bvFontSizeWidget?.value ?? 20, 10, 80); + const showDivider = !!this.__bvShowDividerWidget?.value; + + const padY = 10; + const textH = Math.ceil(fontSize * 1.35); + const dividerExtra = showDivider ? 12 : 0; + + const h = Math.max(34, textH + padY + dividerExtra); + return [Math.max(220, width || 220), h]; + }; + + // Keep widget->input conversion compatibility + valueW.config = valueW.config || ["STRING", {default: valueW.value ?? "", multiline: false}]; + + const hookRedraw = (w) => { + if (!w || w.__bvHeadingHooked) return; + w.__bvHeadingHooked = true; + + const oldCb = w.callback; + w.callback = function () { + const n = valueW.__bvNode || node; + n?.setSize?.(n.computeSize()); + n?.setDirtyCanvas(true, true); + n?.graph?.setDirtyCanvas(true, true); + return oldCb?.apply(this, arguments); + }; + }; + + hookRedraw(valueW.__bvFontSizeWidget); + hookRedraw(valueW.__bvShowDividerWidget); +} + +function hideAllOutputs(node) { + if (!node?.outputs?.length) return; + node.outputs.length = 0; // removes output slots visually + node.setDirtyCanvas?.(true, true); + node.graph?.setDirtyCanvas?.(true, true); +} + +app.registerExtension({ + name: "bv_nodepack.bv_subgraph_heading", + + getCustomWidgets() { + return { + BV_HEADING: () => ({widget: null}), + }; + }, + + async beforeRegisterNodeDef(nodeType, nodeData) { + const comfyClass = nodeType?.ComfyClass || nodeType?.comfyClass; + const nodeName = nodeData?.name; + + const isHeading = + comfyClass === "BVSubgraphHeading" || nodeName === "BV Subgraph Heading"; + + if (!isHeading) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated?.apply(this, arguments); + + patchHeading(this); + + hideAllOutputs(this); + + this.setSize?.(this.computeSize()); + this.graph?.setDirtyCanvas(true, true); + + this.serialize_widgets = true; + this.setDirtyCanvas(true, true); + return r; + }; + + const oldConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const r = oldConfigure?.apply(this, arguments); + + // patch + hide outputs after graph loads/restores node + patchHeading(this); + hideAllOutputs(this); + + this.setSize?.(this.computeSize()); + return r; + }; + }, +}); diff --git a/js/bv_subgraph_spacer.js b/js/bv_subgraph_spacer.js new file mode 100644 index 0000000..0728469 --- /dev/null +++ b/js/bv_subgraph_spacer.js @@ -0,0 +1,109 @@ +import {app} from "../../scripts/app.js"; + +function getWidget(node, name) { + return (node?.widgets || []).find((w) => w?.name === name) || null; +} + +function clamp(n, a, b) { + n = Number(n); + if (!Number.isFinite(n)) n = a; + return Math.max(a, Math.min(b, n)); +} + +function patchSpacer(node) { + const hW = getWidget(node, "height"); + if (!node || !hW || hW.__bvSpacerPatched) return; + + hW.__bvSpacerPatched = true; + hW.__bvNode = node; + + // NEW: cache scale widget + hW.__bvScaleWidget = getWidget(node, "scale"); + + hW.type = "BV_SPACER"; + hW.options = hW.options || {}; + hW.options.serialize = true; + + hW.draw = function () { + }; + + hW.computeSize = function (width) { + const base = clamp(hW.value ?? 24, 0, 400); + const scale = clamp(this.__bvScaleWidget?.value ?? 1.0, 0.0, 5.0); + const h = Math.max(8, Math.round(base * scale)); + return [Math.max(220, width || 220), h]; + }; + + // NEW: hookRedraw helper + hook both widgets + const hookRedraw = (w) => { + if (!w || w.__bvSpacerHooked) return; + w.__bvSpacerHooked = true; + + const oldCb = w.callback; + w.callback = function () { + const n = hW.__bvNode || node; + n?.setSize?.(n.computeSize()); + n?.setDirtyCanvas(true, true); + n?.graph?.setDirtyCanvas(true, true); + return oldCb?.apply(this, arguments); + }; + }; + + hookRedraw(hW); + hookRedraw(hW.__bvScaleWidget); +} + +function hideAllOutputs(node) { + if (!node?.outputs?.length) return; + node.outputs.length = 0; // removes output slots visually + node.setDirtyCanvas?.(true, true); + node.graph?.setDirtyCanvas?.(true, true); +} + + +app.registerExtension({ + name: "bv_nodepack.bv_spacer", + + getCustomWidgets() { + return { + BV_SPACER: () => ({widget: null}), + }; + }, + + async beforeRegisterNodeDef(nodeType, nodeData) { + const comfyClass = nodeType?.ComfyClass || nodeType?.comfyClass; + const nodeName = nodeData?.name; + + const isSpacer = comfyClass === "BVSubgraphSpacer" || nodeName === "BV Subgraph Spacer"; + if (!isSpacer) return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated?.apply(this, arguments); + + patchSpacer(this); + + + hideAllOutputs(this); + + this.setSize?.(this.computeSize()); + this.graph?.setDirtyCanvas(true, true); + + this.serialize_widgets = true; + this.setDirtyCanvas(true, true); + return r; + }; + + const oldConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const r = oldConfigure?.apply(this, arguments); + + // patch + hide outputs after graph loads/restores node + patchSpacer(this); + hideAllOutputs(this); + + this.setSize?.(this.computeSize()); + return r; + }; + }, +}); diff --git a/py/nodes/bv_subgraph_divider.py b/py/nodes/bv_subgraph_divider.py new file mode 100644 index 0000000..16aa2be --- /dev/null +++ b/py/nodes/bv_subgraph_divider.py @@ -0,0 +1,23 @@ +class BVSubgraphDivider: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "thickness": ("INT", {"default": 2, "min": 1, "max": 10, "step": 1}), + "padding": ("INT", {"default": 10, "min": 0, "max": 60, "step": 1}), + "alpha": ("FLOAT", {"default": 0.35, "min": 0.05, "max": 1.0, "step": 0.05}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("_",) + FUNCTION = "run" + CATEGORY = "🌀 BV Node Pack/subgraph" + + def run(self, thickness, padding, alpha): + return ("",) + + +NODE_CLASS_MAPPINGS = {"BV Subgraph Divider": BVSubgraphDivider} + +NODE_DISPLAY_NAME_MAPPINGS = {"BV Subgraph Divider": "🌀 BV Subgraph Divider"} diff --git a/py/nodes/bv_subgraph_heading.py b/py/nodes/bv_subgraph_heading.py new file mode 100644 index 0000000..1a0263c --- /dev/null +++ b/py/nodes/bv_subgraph_heading.py @@ -0,0 +1,28 @@ +# py/nodes/bv_subgraph_heading.py +from __future__ import annotations + + +class BVSubgraphHeading: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + # This is the value you edit + "value": ("STRING", {"default": "Heading"}), + "font_size": ("INT", {"default": 20, "min": 10, "max": 60, "step": 1}), + "show_divider": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("_",) + FUNCTION = "run" + CATEGORY = "🌀 BV Node Pack/subgraph" + + def run(self, value): + return (value,) + + +NODE_CLASS_MAPPINGS = {"BV Subgraph Heading": BVSubgraphHeading} + +NODE_DISPLAY_NAME_MAPPINGS = {"BV Subgraph Heading": "🌀 BV Subgraph Heading"} diff --git a/py/nodes/bv_subgraph_spacer.py b/py/nodes/bv_subgraph_spacer.py new file mode 100644 index 0000000..6048c09 --- /dev/null +++ b/py/nodes/bv_subgraph_spacer.py @@ -0,0 +1,22 @@ +class BVSubgraphSpacer: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "height": ("INT", {"default": 24, "min": 0, "max": 400, "step": 1}), + "scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("_",) + FUNCTION = "run" + CATEGORY = "🌀 BV Node Pack/subgraph" + + def run(self, height): + return (height,) + + +NODE_CLASS_MAPPINGS = {"BV Subgraph Spacer": BVSubgraphSpacer} + +NODE_DISPLAY_NAME_MAPPINGS = {"BV Subgraph Spacer": "🌀 BV Subgraph Spacer"} diff --git a/pyproject.toml b/pyproject.toml index ae0550c..a02672a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "bv_nodepack" -version = "0.0.5" -description = "A ComfyUI node pack providing a growing set of utility and workflow nodes, including a flexible config-driven Pipe system." +version = "0.0.6" +description = "A ComfyUI node pack with utility and workflow nodes: a flexible config-driven Pipe system plus Subgraph-friendly UI nodes (Heading, Spacer, Divider) with dynamic JavaScript rendering." authors = [ {name = "BlackVortex", email = "blackvortexx@proton.me"} ]