diff --git a/Makefile b/Makefile index b9d67e7..3d709b0 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ # comfyui-axces2000 — Makefile # Run `make js` to sync widget JS files from their source folders # into the top-level js/ directory that ComfyUI serves. +# StringCombine has no JS — pure Python node. .PHONY: js clean @@ -8,9 +9,10 @@ js: @echo "→ Syncing JS files to js/" cp audio_loader/js/audio_loader.js js/audio_loader.js cp resolution_master/js/resolution_master.js js/resolution_master.js + cp string_extractor/js/string_extractor.js js/string_extractor.js @echo "✓ Done" clean: @echo "→ Cleaning synced JS files" - rm -f js/audio_loader.js js/resolution_master.js + rm -f js/audio_loader.js js/resolution_master.js js/string_extractor.js @echo "✓ Done" diff --git a/__init__.py b/__init__.py index 2229e2f..c875438 100644 --- a/__init__.py +++ b/__init__.py @@ -5,6 +5,8 @@ Custom ComfyUI nodes by axces2000. Nodes: - AudioLoader (🎵 Audio Loader) - ResolutionMaster (📐 Resolution Master) + - StringExtractor (✂️ String Extractor) + - StringCombine (🔗 String Combine) """ from .audio_loader.audio_loader import ( @@ -15,20 +17,29 @@ from .resolution_master.resolution_master import ( NODE_CLASS_MAPPINGS as RESOLUTION_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as RESOLUTION_NAMES, ) +from .string_extractor.string_extractor import ( + NODE_CLASS_MAPPINGS as EXTRACTOR_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as EXTRACTOR_NAMES, +) +from .string_combine.string_combine import ( + NODE_CLASS_MAPPINGS as COMBINE_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as COMBINE_NAMES, +) NODE_CLASS_MAPPINGS = { **AUDIO_MAPPINGS, **RESOLUTION_MAPPINGS, + **EXTRACTOR_MAPPINGS, + **COMBINE_MAPPINGS, } NODE_DISPLAY_NAME_MAPPINGS = { **AUDIO_NAMES, **RESOLUTION_NAMES, + **EXTRACTOR_NAMES, + **COMBINE_NAMES, } -# ComfyUI serves JS from a single WEB_DIRECTORY. -# The canonical sources live in each node's own js/ subfolder. -# Run `make js` to sync them here before committing. WEB_DIRECTORY = "./js" -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] \ No newline at end of file diff --git a/js/string_extractor.js b/js/string_extractor.js new file mode 100644 index 0000000..d97bca5 --- /dev/null +++ b/js/string_extractor.js @@ -0,0 +1,107 @@ +/** + * String Extractor Widget for ComfyUI v1.9 + * + * Fix: Python now returns a `ui` dict which is the only data ComfyUI + * sends to the frontend in the "executed" websocket event. + * We read detail.output.index_out[0] and write it into the index widget. + */ + +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; + +const NODE_W = 380; + +const C = { + green: "#4ade80", + text: "#94a3b8", + textDim: "#334155", + red: "#f87171", +}; + +app.registerExtension({ + name: "Axces2000.StringExtractor", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "StringExtractor") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply(this, arguments); + + const node = this; + node.serialize_widgets = true; + + function getTextWidget() { return node.widgets?.find(w => w.name === "text"); } + function getIndexWidget() { return node.widgets?.find(w => w.name === "index"); } + function getModeWidget() { return node.widgets?.find(w => w.name === "mode"); } + + function getLines() { + return String(getTextWidget()?.value ?? "").split("\n").filter(l => l.trim() !== ""); + } + function getIndexValue() { return Number(getIndexWidget()?.value ?? 0); } + function getModeValue() { return getModeWidget()?.value ?? "Keep"; } + function isTextConnected() { return node.inputs?.find(i => i.name === "text")?.link != null; } + + // ── Write index_out back into the index widget ───────────────────────── + // Python returns { "ui": { "index_out": [N] }, "result": (...) } + // ComfyUI sends the `ui` dict to the frontend in the "executed" event. + // detail.output is the ui dict, so detail.output.index_out[0] is our value. + function onExecuted(event) { + const detail = event.detail ?? event; + if (String(detail.node) !== String(node.id)) return; + const indexOut = detail.output?.index_out?.[0]; + if (indexOut == null) return; + const iw = getIndexWidget(); + if (iw) { + iw.value = indexOut; + node.setDirtyCanvas(true, false); + } + } + + api.addEventListener("executed", onExecuted); + + const origOnRemoved = node.onRemoved?.bind(node); + node.onRemoved = function () { + api.removeEventListener("executed", onExecuted); + origOnRemoved?.(); + }; + + // ── Status line widget ──────────────────────────────────────────────── + const statusWidget = node.addWidget("SE_STATUS", "_status", null, () => {}, { serialize: false }); + statusWidget.computeSize = () => [NODE_W, 22]; + + statusWidget.draw = function (ctx, node, width, y) { + const px = 10; + const lines = getLines(); + const count = lines.length; + const idx = getIndexValue(); + const modeVal = getModeValue(); + const textConn = isTextConnected(); + + ctx.font = "9px monospace"; ctx.textAlign = "left"; + + if (textConn) { + ctx.fillStyle = C.textDim; + ctx.fillText("text connected via input ↑", px + 2, y + 13); + } else if (count === 0) { + ctx.fillStyle = C.textDim; + ctx.fillText("No lines entered", px + 2, y + 13); + } else if (idx === 0) { + ctx.fillStyle = C.text; + ctx.fillText(`${count} line${count !== 1 ? "s" : ""} · index 0 → full text passthrough`, px + 2, y + 13); + } else if (idx > count) { + ctx.fillStyle = C.red; + ctx.fillText(`index ${idx} out of range (max ${count}) → full text, reset to 0`, px + 2, y + 13); + } else { + const prev = lines[idx - 1]; + const preview = prev.slice(0, 34) + (prev.length > 34 ? "…" : ""); + ctx.fillStyle = C.green; + ctx.fillText(`Line ${idx}/${count} · "${preview}" [${modeVal}]`, px + 2, y + 13); + } + ctx.textAlign = "left"; + }; + + node.size = [NODE_W + 20, node.computeSize()[1]]; + }; + }, +}); diff --git a/string_combine/__init__.py b/string_combine/__init__.py new file mode 100644 index 0000000..917e516 --- /dev/null +++ b/string_combine/__init__.py @@ -0,0 +1,3 @@ +from .string_combine import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/string_combine/string_combine.py b/string_combine/string_combine.py new file mode 100644 index 0000000..a2d5acb --- /dev/null +++ b/string_combine/string_combine.py @@ -0,0 +1,50 @@ +""" +String Combine Node for ComfyUI +Concatenates up to 10 string inputs into a single output, preserving all +content exactly as-is (no trimming, no separators added). +Unconnected optional inputs are simply ignored. +""" + + +class StringCombineNode: + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "string_1": ("STRING", {"forceInput": True}), + "string_2": ("STRING", {"forceInput": True}), + "string_3": ("STRING", {"forceInput": True}), + "string_4": ("STRING", {"forceInput": True}), + "string_5": ("STRING", {"forceInput": True}), + "string_6": ("STRING", {"forceInput": True}), + "string_7": ("STRING", {"forceInput": True}), + "string_8": ("STRING", {"forceInput": True}), + "string_9": ("STRING", {"forceInput": True}), + "string_10": ("STRING", {"forceInput": True}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("combined",) + FUNCTION = "combine" + CATEGORY = "text" + OUTPUT_NODE = False + + def combine(self, + string_1=None, string_2=None, string_3=None, + string_4=None, string_5=None, string_6=None, + string_7=None, string_8=None, string_9=None, + string_10=None): + + parts = [ + string_1, string_2, string_3, string_4, string_5, + string_6, string_7, string_8, string_9, string_10, + ] + result = "".join(s for s in parts if s is not None) + return (result,) + + +NODE_CLASS_MAPPINGS = {"StringCombine": StringCombineNode} +NODE_DISPLAY_NAME_MAPPINGS = {"StringCombine": "🔗 String Combine"} diff --git a/string_extractor/__init__.py b/string_extractor/__init__.py new file mode 100644 index 0000000..6f67d09 --- /dev/null +++ b/string_extractor/__init__.py @@ -0,0 +1,3 @@ +from .string_extractor import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/string_extractor/js/string_extractor.js b/string_extractor/js/string_extractor.js new file mode 100644 index 0000000..d97bca5 --- /dev/null +++ b/string_extractor/js/string_extractor.js @@ -0,0 +1,107 @@ +/** + * String Extractor Widget for ComfyUI v1.9 + * + * Fix: Python now returns a `ui` dict which is the only data ComfyUI + * sends to the frontend in the "executed" websocket event. + * We read detail.output.index_out[0] and write it into the index widget. + */ + +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; + +const NODE_W = 380; + +const C = { + green: "#4ade80", + text: "#94a3b8", + textDim: "#334155", + red: "#f87171", +}; + +app.registerExtension({ + name: "Axces2000.StringExtractor", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "StringExtractor") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply(this, arguments); + + const node = this; + node.serialize_widgets = true; + + function getTextWidget() { return node.widgets?.find(w => w.name === "text"); } + function getIndexWidget() { return node.widgets?.find(w => w.name === "index"); } + function getModeWidget() { return node.widgets?.find(w => w.name === "mode"); } + + function getLines() { + return String(getTextWidget()?.value ?? "").split("\n").filter(l => l.trim() !== ""); + } + function getIndexValue() { return Number(getIndexWidget()?.value ?? 0); } + function getModeValue() { return getModeWidget()?.value ?? "Keep"; } + function isTextConnected() { return node.inputs?.find(i => i.name === "text")?.link != null; } + + // ── Write index_out back into the index widget ───────────────────────── + // Python returns { "ui": { "index_out": [N] }, "result": (...) } + // ComfyUI sends the `ui` dict to the frontend in the "executed" event. + // detail.output is the ui dict, so detail.output.index_out[0] is our value. + function onExecuted(event) { + const detail = event.detail ?? event; + if (String(detail.node) !== String(node.id)) return; + const indexOut = detail.output?.index_out?.[0]; + if (indexOut == null) return; + const iw = getIndexWidget(); + if (iw) { + iw.value = indexOut; + node.setDirtyCanvas(true, false); + } + } + + api.addEventListener("executed", onExecuted); + + const origOnRemoved = node.onRemoved?.bind(node); + node.onRemoved = function () { + api.removeEventListener("executed", onExecuted); + origOnRemoved?.(); + }; + + // ── Status line widget ──────────────────────────────────────────────── + const statusWidget = node.addWidget("SE_STATUS", "_status", null, () => {}, { serialize: false }); + statusWidget.computeSize = () => [NODE_W, 22]; + + statusWidget.draw = function (ctx, node, width, y) { + const px = 10; + const lines = getLines(); + const count = lines.length; + const idx = getIndexValue(); + const modeVal = getModeValue(); + const textConn = isTextConnected(); + + ctx.font = "9px monospace"; ctx.textAlign = "left"; + + if (textConn) { + ctx.fillStyle = C.textDim; + ctx.fillText("text connected via input ↑", px + 2, y + 13); + } else if (count === 0) { + ctx.fillStyle = C.textDim; + ctx.fillText("No lines entered", px + 2, y + 13); + } else if (idx === 0) { + ctx.fillStyle = C.text; + ctx.fillText(`${count} line${count !== 1 ? "s" : ""} · index 0 → full text passthrough`, px + 2, y + 13); + } else if (idx > count) { + ctx.fillStyle = C.red; + ctx.fillText(`index ${idx} out of range (max ${count}) → full text, reset to 0`, px + 2, y + 13); + } else { + const prev = lines[idx - 1]; + const preview = prev.slice(0, 34) + (prev.length > 34 ? "…" : ""); + ctx.fillStyle = C.green; + ctx.fillText(`Line ${idx}/${count} · "${preview}" [${modeVal}]`, px + 2, y + 13); + } + ctx.textAlign = "left"; + }; + + node.size = [NODE_W + 20, node.computeSize()[1]]; + }; + }, +}); diff --git a/string_extractor/string_extractor.py b/string_extractor/string_extractor.py new file mode 100644 index 0000000..b784584 --- /dev/null +++ b/string_extractor/string_extractor.py @@ -0,0 +1,94 @@ +""" +String Extractor Node for ComfyUI +""" + +import random + +MODES = ["Keep", "Increment", "Randomise"] + +# Server-side state store keyed by node unique_id. +# This persists between executions within a ComfyUI session, allowing +# Increment/Randomise to advance correctly across batched/scheduled runs +# without depending on the frontend widget value being updated first. +_node_state: dict = {} + + +class StringExtractorNode: + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"multiline": True, "default": ""}), + "index": ("INT", {"default": 0, "min": 0, "max": 9999}), + "mode": (MODES, {"default": "Keep"}), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + } + } + + RETURN_TYPES = ("STRING", "INT") + RETURN_NAMES = ("text_out", "index_out") + FUNCTION = "extract" + CATEGORY = "text" + OUTPUT_NODE = True + + def extract(self, text: str, index: int, mode: str, unique_id: str = "0"): + lines = [ln for ln in text.splitlines() if ln.strip() != ""] + count = len(lines) + + # ── Resolve the working index ───────────────────────────────────────── + # For Keep mode (or out-of-range), use the widget value directly. + # For Increment/Randomise, use server-side state so batched runs each + # get a different value regardless of what the frontend widget shows. + state = _node_state.get(unique_id, {}) + + if mode == "Keep" or index == 0 or count == 0: + # Keep or passthrough — use widget value, clear stored state + working_index = index + _node_state.pop(unique_id, None) + else: + # Increment / Randomise — use stored state if available, + # otherwise seed from widget value on first run + working_index = state.get("index", index) + + # Clamp in case the text changed and count shrunk + if working_index > count: + working_index = 1 + + # ── Compute output ──────────────────────────────────────────────────── + if working_index == 0 or working_index > count: + text_out = text + index_out = 0 + _node_state.pop(unique_id, None) + else: + line = lines[working_index - 1] + if not line.endswith(" "): + line = line + " " + text_out = line + + if mode == "Increment": + index_out = (working_index % count) + 1 + elif mode == "Randomise": + index_out = random.randint(1, count) + else: + index_out = working_index + + # Store the next index for the following execution + _node_state[unique_id] = {"index": index_out} + + return { + "ui": {"index_out": [index_out]}, + "result": (text_out, index_out), + } + + @classmethod + def IS_CHANGED(cls, text, index, mode, unique_id="0"): + if mode in ("Increment", "Randomise") and index > 0: + return float("nan") + return f"{text}|{index}|{mode}" + + +NODE_CLASS_MAPPINGS = {"StringExtractor": StringExtractorNode} +NODE_DISPLAY_NAME_MAPPINGS = {"StringExtractor": "✂️ String Extractor"}