commit 8b57717806902a82e1d1a343da2754016e66b573 Author: masslevel Date: Sat Mar 7 10:42:40 2026 +0100 Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34b64f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 masslevel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..30dc590 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# ComfyUI-masslevel-TextProcessing + +Text processing utilities for ComfyUI, by [masslevel](https://github.com/masslevel). + +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) + +![Variable Text Processor example workflow](example_workflows/ComfyUI-masslevel-TextProcessing-example_workflow.png) + +--- + +## Nodes + +### Variable Text Processor (masslevel) + +Processes a multiline text template by replacing `{variable_name}` placeholders with dynamically wired input values. Pins can be added, removed, and renamed at any time — the pin name is the variable name. + +**Category:** `masslevel/Text` + +--- + +## Installation + +Clone or download this repository into your `ComfyUI/custom_nodes/` directory: + +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/masslevel/ComfyUI-masslevel-TextProcessing +``` + +Restart ComfyUI. + +--- + +## Variable Text Processor — Usage + +### Basic concept + +Write a template with `{placeholder}` tokens, wire any value into a named pin, and the node outputs the fully resolved string. + +``` +Template: + resolution: {video_width}x{video_height} + fps: {video_fps} + model: {model_name} + cfg: {sampler_cfg} + +Wired: + video_width → 1920 + video_height → 1080 + video_fps → 25 + model_name → "model.safetensors" + sampler_cfg → 25.0 + +Output: + resolution: 1920x1080 + fps: 25 + model: model.safetensors + cfg: 25.0 +``` + +### Adding variables + +Click the **+ Add Variable** button on the node body. A prompt will ask for a name — use letters, numbers, and underscores only (e.g. `fps`, `model_name`, `subject`). The name becomes the `{placeholder}` token in your template. + +### Removing variables + +Right-click the node body → **Remove Variable Pin** → select the variable to remove. Any connected wire is cleanly disconnected first. + +### Renaming variables + +Two methods — both keep the connected wire in place: + +- **Context menu:** Right-click the node body → **Rename Variable Pin** → select the variable and enter a new name. +- **Native rename:** Right-click the connection point on the pin → **Rename Slot** → enter a new name. + +### Inputs + +| Input | Type | Description | +|---|---|---| +| `template` | STRING (multiline) | The template text containing `{placeholder}` tokens. Can be typed directly or wired from an external multiline string node. | +| *(dynamic)* | STRING / INT / FLOAT | One pin per variable. The pin name must match the `{placeholder}` in the template exactly. INT and FLOAT values are automatically converted to string. | + +### Output + +| Output | Type | Description | +|---|---|---| +| `text` | STRING | The fully resolved template. Unmatched `{placeholders}` with no connected pin are passed through unchanged. | + +### Notes + +- Variable names and their order persist across workflow save and reload. +- Unresolved placeholders are passed through unchanged — they will not error. +- The template field itself can be wired from an external multiline text node. + +--- + +## Compatibility + +- ComfyUI (latest) +- Python 3.10+ +- No additional dependencies required + +--- + +## License + +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..2006a8e --- /dev/null +++ b/__init__.py @@ -0,0 +1,10 @@ +""" +masslevel-Text_processing — ComfyUI custom nodes by masslevel. +Text processing and template utilities. +""" + +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/example_workflows/ComfyUI-masslevel-TextProcessing-example_workflow.json b/example_workflows/ComfyUI-masslevel-TextProcessing-example_workflow.json new file mode 100644 index 0000000..77ccb23 --- /dev/null +++ b/example_workflows/ComfyUI-masslevel-TextProcessing-example_workflow.json @@ -0,0 +1,330 @@ +{ + "id": "d5f301a1-3d95-4760-8c89-58d16bdb9056", + "revision": 0, + "last_node_id": 10, + "last_link_id": 14, + "nodes": [ + { + "id": 3, + "type": "PrimitiveInt", + "pos": [ + 1300, + 8430 + ], + "size": [ + 300, + 82 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 1920, + "fixed" + ] + }, + { + "id": 4, + "type": "PrimitiveInt", + "pos": [ + 1300, + 8550 + ], + "size": [ + 300, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 11 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 1080, + "fixed" + ] + }, + { + "id": 5, + "type": "PrimitiveInt", + "pos": [ + 1300, + 8670 + ], + "size": [ + 300, + 82 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 25, + "fixed" + ] + }, + { + "id": 8, + "type": "PrimitiveString", + "pos": [ + 1300, + 8790 + ], + "size": [ + 300, + 60 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "model.safetensors" + ] + }, + { + "id": 6, + "type": "PrimitiveFloat", + "pos": [ + 1300, + 8890 + ], + "size": [ + 300, + 60 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 13 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveFloat" + }, + "widgets_values": [ + 25 + ] + }, + { + "id": 1, + "type": "PreviewAny", + "pos": [ + 2400, + 8530 + ], + "size": [ + 400, + 160 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 9 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [ + null, + null, + null + ] + }, + { + "id": 10, + "type": "VariableTextProcessor", + "pos": [ + 1800, + 8530 + ], + "size": [ + 400, + 270 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "video_width", + "type": "*", + "link": 10 + }, + { + "name": "video_height", + "type": "*", + "link": 11 + }, + { + "name": "video_fps", + "type": "*", + "link": 12 + }, + { + "name": "model_name", + "type": "*", + "link": 14 + }, + { + "name": "sampler_cfg", + "type": "*", + "link": 13 + } + ], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "VariableTextProcessor", + "variables": [ + "video_width", + "video_height", + "video_fps", + "model_name", + "sampler_cfg" + ] + }, + "widgets_values": [ + "resolution: {video_width}x{video_height}\nfps: {video_fps}\nmodel: {model_name}\ncfg: {sampler_cfg}", + null + ] + } + ], + "links": [ + [ + 9, + 10, + 0, + 1, + 0, + "STRING" + ], + [ + 10, + 3, + 0, + 10, + 0, + "INT" + ], + [ + 11, + 4, + 0, + 10, + 1, + "INT" + ], + [ + 12, + 5, + 0, + 10, + 2, + "INT" + ], + [ + 13, + 6, + 0, + 10, + 4, + "FLOAT" + ], + [ + 14, + 8, + 0, + 10, + 3, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "workflowRendererVersion": "LG", + "ds": { + "scale": 1, + "offset": [ + -1200, + -8330 + ] + }, + "frontendVersion": "1.39.19", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} \ No newline at end of file diff --git a/example_workflows/ComfyUI-masslevel-TextProcessing-example_workflow.png b/example_workflows/ComfyUI-masslevel-TextProcessing-example_workflow.png new file mode 100644 index 0000000..ad4058e Binary files /dev/null and b/example_workflows/ComfyUI-masslevel-TextProcessing-example_workflow.png differ diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..492e2c0 --- /dev/null +++ b/nodes.py @@ -0,0 +1,94 @@ +""" +Masslevel Nodes - Variable Text Processor +Processes a multiline text template, replacing {variable} placeholders +with dynamically wired input values. +""" + + +class AnyType(str): + """ + A special type that matches any ComfyUI type for wildcard inputs. + By overriding __ne__ to always return False, it satisfies ComfyUI's + type compatibility checks for any connected output type. + """ + def __ne__(self, other: object) -> bool: + return False + + +# Singleton wildcard type instance +any_type = AnyType("*") + + +class VariableTextProcessor: + """ + Processes a multiline text template by replacing {variable_name} + placeholders with values from dynamically named input pins. + + Dynamic inputs are managed entirely from the JS frontend — users + add/remove/rename variable pins via the node's UI. Each pin name + corresponds directly to a {placeholder} in the template. + + Unresolved placeholders (no connected pin) pass through unchanged. + INT and FLOAT inputs are automatically converted to string. + """ + + @classmethod + def INPUT_TYPES(cls) -> dict: + return { + "required": { + "template": ("STRING", { + "multiline": True, + "default": "Enter your template here.\nUse {variable_name} as placeholders.", + }), + }, + # Dynamic variable inputs are added by the JS frontend. + # They arrive here as **kwargs at execution time. + } + + @classmethod + def VALIDATE_INPUTS(cls, **kwargs) -> bool: + """ + Bypass ComfyUI's strict input validation so dynamically added + variable pins (not declared in INPUT_TYPES) are accepted without + type errors. + """ + return True + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("text",) + FUNCTION = "process" + CATEGORY = "masslevel/Text" + + def process(self, template: str, **kwargs) -> tuple[str]: + """ + Replace all {key} placeholders in the template with the + corresponding kwarg values. Unmatched placeholders are left as-is. + + Args: + template: The multiline template string containing {placeholders}. + **kwargs: Named variable inputs wired from the frontend pins. + + Returns: + A tuple containing the processed string. + """ + result = template + + for key, value in kwargs.items(): + placeholder = f"{{{key}}}" + if placeholder in result: + result = result.replace(placeholder, str(value)) + + return (result,) + + +# --------------------------------------------------------------------------- +# Node registration mappings +# --------------------------------------------------------------------------- + +NODE_CLASS_MAPPINGS = { + "VariableTextProcessor": VariableTextProcessor, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "VariableTextProcessor": "Variable Text Processor (masslevel)", +} diff --git a/web/variable_text_processor.js b/web/variable_text_processor.js new file mode 100644 index 0000000..b2bda35 --- /dev/null +++ b/web/variable_text_processor.js @@ -0,0 +1,235 @@ +/** + * Masslevel Nodes - Variable Text Processor + * + * Native LiteGraph "Rename Slot" sets input.label (display only) but NOT + * input.name (the backend kwarg key). We intercept the label setter via + * Object.defineProperty so native rename correctly updates both the pin name + * and properties.variables, keeping everything in sync. + */ + +import { app } from "/scripts/app.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function sanitizeName(raw) { + let name = raw.trim().replace(/[^a-zA-Z0-9_]/g, "_"); + if (/^[0-9]/.test(name)) name = "_" + name; + return name.length > 0 ? name : null; +} + +function variableExists(node, varName) { + return (node.properties?.variables ?? []).includes(varName); +} + +function getVariableInputs(node) { + const tracked = node.properties?.variables ?? []; + return (node.inputs ?? []).filter(i => tracked.includes(i.name)); +} + +// --------------------------------------------------------------------------- +// Label watcher — makes native "Rename Slot" behave correctly. +// +// LiteGraph's rename dialog does: input.label = userTypedName +// We intercept that assignment and route it through renameVariable() so: +// - input.name is updated (backend kwarg key) +// - properties.variables is updated (our tracking) +// - links are reconnected on the new slot +// +// The watcher is installed after every addInput() call and removed implicitly +// when renameVariable() destroys+recreates the slot. +// --------------------------------------------------------------------------- + +function installLabelWatcher(node, input) { + if (input.__masslevel_watched) return; + input.__masslevel_watched = true; + + let _label = input.label; + + Object.defineProperty(input, "label", { + configurable: true, + enumerable: true, + get() { return _label; }, + set(newLabel) { + const oldName = input.name; + const isTracked = (node.properties?.variables ?? []).includes(oldName); + + if (isTracked && newLabel && newLabel !== oldName) { + const sanitized = sanitizeName(newLabel); + if (!sanitized) { + console.warn(`[masslevel] Invalid rename target "${newLabel}" — ignoring.`); + return; + } + if (variableExists(node, sanitized)) { + console.warn(`[masslevel] Variable "${sanitized}" already exists — ignoring native rename.`); + return; + } + _label = newLabel; + setTimeout(() => renameVariable(node, oldName, sanitized), 0); + } else { + _label = newLabel; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// Core operations +// --------------------------------------------------------------------------- + +function addVariable(node, varName) { + if (!node.properties) node.properties = {}; + if (!node.properties.variables) node.properties.variables = []; + + node.properties.variables.push(varName); + node.addInput(varName, "*"); + + const input = node.inputs[node.inputs.length - 1]; + installLabelWatcher(node, input); + + node.setDirtyCanvas(true, true); +} + +function removeVariable(node, varName) { + node.properties.variables = (node.properties.variables ?? []).filter(v => v !== varName); + const slot = (node.inputs ?? []).findIndex(i => i.name === varName); + if (slot !== -1) { + const input = node.inputs[slot]; + if (input.link != null) node.graph.removeLink(input.link); + node.removeInput(slot); + } + node.setDirtyCanvas(true, true); +} + +function renameVariable(node, oldName, newName) { + const slot = (node.inputs ?? []).findIndex(i => i.name === oldName); + if (slot === -1) { + console.warn(`[masslevel] renameVariable: slot "${oldName}" not found`); + return; + } + + const input = node.inputs[slot]; + const existingLinkId = input.link; + let originId = null; + let originSlot = null; + + if (existingLinkId != null && node.graph) { + const link = node.graph.links[existingLinkId]; + if (link) { originId = link.origin_id; originSlot = link.origin_slot; } + node.graph.removeLink(existingLinkId); + } + + node.removeInput(slot); + + const idx = (node.properties.variables ?? []).indexOf(oldName); + if (idx !== -1) node.properties.variables[idx] = newName; + + node.addInput(newName, "*"); + + const newSlot = node.inputs.length - 1; + if (newSlot !== slot) { + const moved = node.inputs.splice(newSlot, 1)[0]; + node.inputs.splice(slot, 0, moved); + } + + installLabelWatcher(node, node.inputs[slot]); + + if (originId !== null && node.graph) { + const originNode = node.graph.getNodeById(originId); + if (originNode) originNode.connect(originSlot, node, slot); + } + + node.setDirtyCanvas(true, true); +} + +// --------------------------------------------------------------------------- +// Extension registration +// --------------------------------------------------------------------------- + +app.registerExtension({ + name: "Masslevel.VariableTextProcessor", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "VariableTextProcessor") return; + + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + origOnNodeCreated?.call(this); + if (!this.properties) this.properties = {}; + if (!this.properties.variables) this.properties.variables = []; + + this.addWidget("button", "+ Add Variable", null, () => { + const raw = prompt("Variable name\n(use as {variable_name} in template):"); + if (!raw) return; + const name = sanitizeName(raw); + if (!name) { + alert("Invalid variable name. Use letters, numbers, and underscores only."); + return; + } + if (variableExists(this, name)) { + alert(`Variable "${name}" already exists.`); + return; + } + addVariable(this, name); + }); + }; + + const origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (config) { + origOnConfigure?.call(this, config); + const variables = this.properties?.variables ?? []; + for (const varName of variables) { + let input = (this.inputs ?? []).find(i => i.name === varName); + if (!input) { + this.addInput(varName, "*"); + input = this.inputs[this.inputs.length - 1]; + } + installLabelWatcher(this, input); + } + }; + + const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (canvas, options) { + origGetExtraMenuOptions?.call(this, canvas, options); + const varInputs = getVariableInputs(this); + if (varInputs.length === 0) return; + options.push(null); + const self = this; + + options.push({ + content: "Remove Variable Pin", + submenu: { + options: varInputs.map(input => ({ + content: input.name, + callback() { + if (confirm(`Remove variable "${input.name}"?`)) { + removeVariable(self, input.name); + } + }, + })), + }, + }); + + options.push({ + content: "Rename Variable Pin", + submenu: { + options: varInputs.map(input => ({ + content: input.name, + callback() { + const raw = prompt(`Rename "${input.name}" to:`, input.name); + if (!raw || raw.trim() === input.name) return; + const newName = sanitizeName(raw); + if (!newName) { alert("Invalid name."); return; } + if (variableExists(self, newName)) { + alert(`Variable "${newName}" already exists.`); + return; + } + renameVariable(self, input.name, newName); + }, + })), + }, + }); + }; + }, +});