Initial release

This commit is contained in:
masslevel
2026-03-07 10:42:40 +01:00
commit 8b57717806
7 changed files with 797 additions and 0 deletions
+21
View File
@@ -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.
+107
View File
@@ -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
+10
View File
@@ -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"]
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+94
View File
@@ -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)",
}
+235
View File
@@ -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);
},
})),
},
});
};
},
});