419 lines
15 KiB
Markdown
419 lines
15 KiB
Markdown
# ComfyUI-ScriptFlow
|
||
|
||
**Version:** 1.2.0
|
||
**License:** GPL-3.0
|
||
|
||
A general-purpose calculation node that evaluates a safe Python-like script subset and returns multiple text and numeric outputs based on the inputs.
|
||
|
||
This custom node accepts numbers (int/float) and text (string) as inputs, runs calculations and logical operations internally, and returns multiple values through separate output ports. It consolidates workflows that previously required combining several basic nodes into a single node.
|
||
|
||
## Installation
|
||
|
||
1. Navigate to the `ComfyUI/custom_nodes/` directory.
|
||
2. Clone this repository:
|
||
```bash
|
||
git clone https://github.com/kantan-kanto/ComfyUI-ScriptFlow
|
||
```
|
||
3. Restart ComfyUI.
|
||
|
||
## Key Features
|
||
- Multiple inputs: freely combine numeric and text inputs.
|
||
- Calculations and logic: supports arithmetic, comparison, and simple conditional branching.
|
||
- Multiple outputs: pass results to downstream nodes via separate output ports.
|
||
- Safer execution: AST interpreter with restricted syntax and no file/OS access.
|
||
|
||
## Use Cases
|
||
- In Wan2.2 I2V workflows, automatically select a recommended resolution
|
||
(512×384 or 384×512) based on whether the input image is portrait or landscape.
|
||
- Batch-generate file names (e.g., local LLM prompt history, output logs).
|
||
- Apply conditional logic to control workflow behavior (e.g., switch parameters or routes based on inputs).
|
||
- Parse and transform LLM outputs into structured values for downstream nodes.
|
||
Dynamically generate numeric parameters (e.g., seeds, thresholds, scaling values) using custom calculations.
|
||
|
||
## Nodes
|
||
|
||
### `MultiOutputScript`
|
||
**Category:** `utils`
|
||
|
||
Inputs (optional, connectable):
|
||
- `in_text_1` (accepts any input; validated as `str` at runtime)
|
||
- `in_text_2` (accepts any input; validated as `str` at runtime)
|
||
- `in_text_3` (accepts any input; validated as `str` at runtime)
|
||
- `in_value_1` (accepts any input; validated as `int` or `float` at runtime)
|
||
- `in_value_2` (accepts any input; validated as `int` or `float` at runtime)
|
||
- `in_value_3` (accepts any input; validated as `int` or `float` at runtime)
|
||
- `code` (multiline string)
|
||
|
||
Outputs:
|
||
- `out_text_1` (STRING)
|
||
- `out_text_2` (STRING)
|
||
- `out_text_3` (STRING)
|
||
- `out_value_1` (INT)
|
||
- `out_value_2` (INT)
|
||
- `out_value_3` (INT)
|
||
|
||
Notes on numeric outputs:
|
||
- Outputs are `INT`.
|
||
- If you need `FLOAT` values from integer inputs, use the `centi` node.
|
||
- If a value is a float, the fractional part is truncated on output.
|
||
- If you prefer rounding or ceiling, handle it in the script (e.g., `round(...)`, `math.ceil(...)`).
|
||
|
||
### Script Variables
|
||
Use the following variables inside `code`:
|
||
- Inputs: `it1`, `it2`, `it3`, `iv1`, `iv2`, `iv3`
|
||
- Outputs: `ot1`, `ot2`, `ot3`, `ov1`, `ov2`, `ov3`
|
||
|
||
Unassigned outputs default to `None`.
|
||
|
||
### Script Example
|
||
```python
|
||
# Example: keep landscape at 512x384, portrait at 384x512
|
||
# Connect GetImageSize width/height to in_value_1/in_value_2
|
||
# iv1: image width, iv2: image height
|
||
w, h = iv1, iv2
|
||
ov1, ov2 = (512, 384) if w >= h else (384, 512)
|
||
```
|
||
|
||
### `MultiOutputScript (Jev)`
|
||
**Category:** `utils`
|
||
|
||
Same inputs, outputs, and script rules as `MultiOutputScript`, plus a `jev` namespace that answers typed questions about text with a local GGUF language model. It works like [Jev](https://typesafe.ai) (TypeSafe AI's decision model) but runs on your machine: the decision is read from the model's next-token probabilities for lettered options in one forward pass, following [SemIf (OpenJev)](https://github.com/TheoLeeCJ/SemIf-OpenJev). No text is generated and nothing is sent over the network.
|
||
|
||
Setup:
|
||
- Install [llama-cpp-python](https://github.com/abetlen/llama-cpp-python) with GPU support for your platform in the Python environment that runs ComfyUI.
|
||
- Put an instruction-tuned GGUF model with a chat template in `ComfyUI/models/LLM` (or `models/text_encoders`). Tested with Qwen3.5-9B Q8_0 and Gemma-4-E4B Q8.
|
||
|
||
Extra inputs:
|
||
- `model`: GGUF model to use (`mmproj-*` files are hidden).
|
||
- `max_jev_calls` (INT, default 8): maximum model evaluations per run. Identical questions within a run are evaluated only once.
|
||
- `keep_model_loaded` (BOOLEAN, default true): keep the model in memory between runs. Turn it off to free VRAM after each run.
|
||
|
||
Functions (`state` is a string, dict, or list; text only):
|
||
- `jev.yes(state, question[, threshold])` → `bool` (yes-probability ≥ `threshold`, default `0.5`)
|
||
- `jev.noul(state, question)` → yes-probability (`0.0`–`1.0`). Compare it with a threshold; using it directly as a condition raises an error.
|
||
- `jev.choice(state, question, options)` → the most likely option (`str`). `options` is a list of 2–16 labels, or a dict of labels to descriptions.
|
||
- `jev.score(state, question, levels)` → expected level (`float`, `0` to `len(levels) - 1`) for 2–16 ordered level descriptions
|
||
|
||
```python
|
||
# it1: a prompt text
|
||
if jev.yes(it1, "Does this prompt describe a night scene?", 0.7):
|
||
ov1 = 30
|
||
else:
|
||
ov1 = 20
|
||
|
||
kind = jev.choice(it1, "Which orientation suits this prompt?", ["portrait", "landscape"])
|
||
ov2, ov3 = (384, 512) if kind == "portrait" else (512, 384)
|
||
```
|
||
|
||
Notes:
|
||
- Each question takes one forward pass (about 1–1.5 s with a 4B–9B model on GPU); the first question also loads the model.
|
||
- Probabilities are uncalibrated and depend on the model; test thresholds on your own inputs.
|
||
- `MultiOutputScript` itself never loads a model; `jev` is not available there.
|
||
|
||
### `centi`
|
||
**Category:** `utils`
|
||
|
||
Inputs (optional, connectable):
|
||
- `int_1` (INT)
|
||
- `int_2` (INT)
|
||
- `int_3` (INT)
|
||
|
||
Outputs:
|
||
- `float_1` (FLOAT) = `int_1 / 100`
|
||
- `float_2` (FLOAT) = `int_2 / 100`
|
||
- `float_3` (FLOAT) = `int_3 / 100`
|
||
|
||
Notes:
|
||
- If `int_n` is not connected, the corresponding `float_n` output is `None`.
|
||
- The node is intentionally minimal and connector-only.
|
||
|
||
---
|
||
|
||
## Execution Environment
|
||
The script is parsed with Python AST and evaluated by ScriptFlow's safe interpreter. It supports common workflow logic such as assignment, arithmetic, branching, loops, user-defined helper functions, dictionaries, lists, strings, and selected methods.
|
||
|
||
### Allowed functions
|
||
- `len`, `min`, `max`, `sum`, `abs`, `round`
|
||
- `int`, `float`, `str`, `bool`
|
||
- `sorted`, `reversed`
|
||
- `enumerate`, `range`, `zip`
|
||
- `any`, `all`, `pow`, `divmod`
|
||
- `list`, `dict`, `tuple`
|
||
|
||
### Allowed namespaces
|
||
- `random`: `random`, `randint`, `uniform`, `choice`
|
||
- `datetime`: `datetime.now`, `date.today`, date/time fields such as `year`, `month`, `day`, `hour`, `minute`, `second`, plus `isoformat` and `strftime` (`strftime` requires v1.2.0 or later)
|
||
- `math`: `ceil`, `floor`, `sqrt`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `log`, `log10`, `exp`, `pow`, `fabs`, `isfinite`, `pi`, `e`, `tau`
|
||
|
||
### Allowed methods
|
||
- `str`: `replace`, `find`, `strip`, `split`, `splitlines`, `startswith`, `endswith`, `join`, `lower`, `upper`
|
||
- `list`: `append`
|
||
|
||
### Not allowed
|
||
- `import` statements
|
||
- file access (`open`, etc.)
|
||
- OS operations (`os`, `sys`)
|
||
- dynamic Python execution
|
||
- blocked syntax in safe mode: `Import`, `ImportFrom`, `Global`, `Nonlocal`, `ClassDef`, `Try`, `With`, `AsyncWith`, `Lambda`, `Delete`, `Yield`, `Await`
|
||
- blocked calls in safe mode: dynamic execution, file/input access, runtime namespace inspection, and dynamic attribute mutation
|
||
|
||
### Execution Notes
|
||
- Using `random` or `datetime` makes outputs non-deterministic.
|
||
- `datetime.strftime(...)` is supported in v1.2.0 or later for concise timestamp formatting:
|
||
```python
|
||
now = datetime.datetime.now()
|
||
ot1 = now.strftime("%Y%m%d%H%M%S")
|
||
```
|
||
- For versions earlier than v1.2.0, format the same timestamp manually:
|
||
```python
|
||
now = datetime.datetime.now()
|
||
ot1 = f'{now.year:04d}{now.month:02d}{now.day:02d}{now.hour:02d}{now.minute:02d}{now.second:02d}'
|
||
```
|
||
- Type mismatches raise an error and stop execution.
|
||
- Safe mode validates AST before evaluation and stops scripts that exceed the step or loop limit.
|
||
- Use only trusted scripts/workflows. Do not run untrusted code from unknown sources.
|
||
|
||
## Examples
|
||
|
||
### Demo
|
||

|
||
|
||
Demonstrates running the sample workflow with a landscape image and a portrait image.
|
||
|
||
### Example Workflow
|
||
|
||
```
|
||
examples/
|
||
├─ example_workflow.json
|
||
```
|
||
|
||
## Application Recipes
|
||
|
||
### Convert `LLM Dialogue Cycle` transcript text to `dialogue_segments_json`
|
||
|
||
<details>
|
||
<summary>Show ComfyUI-LLM-Session transcript conversion recipe</summary>
|
||
|
||
`ComfyUI-LLM-Session` returns the full result of `LLM Dialogue Cycle` as `transcript_text`.
|
||
You can connect that text to `MultiOutputScript.in_text_1` and paste the script below into
|
||
the `code` field to generate a speaker-tagged JSON text for downstream dialogue TTS nodes.
|
||
|
||
Recommended workflow:
|
||
|
||
```text
|
||
LLM Dialogue Cycle
|
||
-> transcript_text
|
||
-> MultiOutputScript.in_text_1
|
||
-> MultiOutputScript.out_text_1
|
||
-> dialogue_segments_json input of a TTS node
|
||
```
|
||
|
||
Paste this code into `MultiOutputScript.code`:
|
||
|
||
```python
|
||
# Convert ComfyUI-LLM-Session "LLM Dialogue Cycle" transcript_text
|
||
# into dialogue_segments_json for TTS.
|
||
# Only text inside Japanese corner quotes 「...」 is used as spoken dialogue.
|
||
#
|
||
# Input:
|
||
# it1: transcript_text
|
||
#
|
||
# Output:
|
||
# ot1: JSON text with schema "kantan.dialogue.v1"
|
||
#
|
||
# Expected transcript lines:
|
||
# [2026-05-16T12:00:00] USER -> A: initial prompt
|
||
# [2026-05-16T12:00:01] A: 彼女は少し考えた。「こんにちは。」
|
||
# [2026-05-16T12:00:02] B: 彼は笑った。「やあ。」「調子はどう?」
|
||
#
|
||
# The parser also accepts the unicode arrow form:
|
||
# USER → A:
|
||
|
||
def json_escape(value):
|
||
s = str(value)
|
||
s = s.replace("\\", "\\\\")
|
||
s = s.replace('"', '\\"')
|
||
s = s.replace("\n", "\\n")
|
||
s = s.replace("\r", "\\r")
|
||
s = s.replace("\t", "\\t")
|
||
return s
|
||
|
||
def extract_spoken_text(value):
|
||
source = str(value)
|
||
parts = []
|
||
position = 0
|
||
|
||
while position < len(source):
|
||
start = source.find("「", position)
|
||
if start < 0:
|
||
position = len(source)
|
||
else:
|
||
end = source.find("」", start + 1)
|
||
if end < 0:
|
||
position = len(source)
|
||
else:
|
||
spoken = source[start + 1:end].strip()
|
||
if spoken:
|
||
parts.append(spoken)
|
||
position = end + 1
|
||
|
||
return "\n".join(parts)
|
||
|
||
transcript = str(it1 or "")
|
||
lines = transcript.splitlines()
|
||
utterances = []
|
||
current = None
|
||
|
||
for raw_line in lines:
|
||
line = str(raw_line)
|
||
speaker = None
|
||
text = None
|
||
timestamp = ""
|
||
|
||
if line.startswith("[") and "] " in line:
|
||
close_index = line.find("] ")
|
||
timestamp = line[1:close_index]
|
||
rest = line[close_index + 2:]
|
||
|
||
if rest.startswith("A:"):
|
||
speaker = "A"
|
||
text = rest[2:].strip()
|
||
elif rest.startswith("B:"):
|
||
speaker = "B"
|
||
text = rest[2:].strip()
|
||
elif rest.startswith("USER -> A:"):
|
||
speaker = "USER"
|
||
text = rest[len("USER -> A:"):].strip()
|
||
elif rest.startswith("USER → A:"):
|
||
speaker = "USER"
|
||
text = rest[len("USER → A:"):].strip()
|
||
|
||
if speaker == "A" or speaker == "B":
|
||
if current is not None:
|
||
utterances.append(current)
|
||
current = {
|
||
"speaker": speaker,
|
||
"text": text,
|
||
"timestamp": timestamp,
|
||
}
|
||
else:
|
||
if current is not None:
|
||
if current["text"]:
|
||
current["text"] = current["text"] + "\n" + line
|
||
else:
|
||
current["text"] = line
|
||
|
||
if current is not None:
|
||
utterances.append(current)
|
||
|
||
items = []
|
||
index = 1
|
||
for u in utterances:
|
||
spoken_text = extract_spoken_text(u["text"])
|
||
if not spoken_text:
|
||
continue
|
||
|
||
item = (
|
||
'{"index":'
|
||
+ str(index)
|
||
+ ',"speaker":"'
|
||
+ json_escape(u["speaker"])
|
||
+ '","text":"'
|
||
+ json_escape(spoken_text)
|
||
+ '","timestamp":"'
|
||
+ json_escape(u["timestamp"])
|
||
+ '"}'
|
||
)
|
||
items.append(item)
|
||
index = index + 1
|
||
|
||
ot1 = (
|
||
'{"schema":"kantan.dialogue.v1",'
|
||
+ '"source":"ComfyUI-LLM-Session LLM Dialogue Cycle",'
|
||
+ '"utterances":['
|
||
+ ",".join(items)
|
||
+ "]}"
|
||
)
|
||
```
|
||
|
||
The resulting `out_text_1` is a JSON string like this:
|
||
|
||
```json
|
||
{
|
||
"schema": "kantan.dialogue.v1",
|
||
"source": "ComfyUI-LLM-Session LLM Dialogue Cycle",
|
||
"utterances": [
|
||
{
|
||
"index": 1,
|
||
"speaker": "A",
|
||
"text": "こんにちは。",
|
||
"timestamp": "2026-05-16T12:00:01"
|
||
},
|
||
{
|
||
"index": 2,
|
||
"speaker": "B",
|
||
"text": "やあ。\n調子はどう?",
|
||
"timestamp": "2026-05-16T12:00:02"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Notes:
|
||
|
||
- `USER -> A` / `USER → A` is intentionally skipped so TTS reads only character dialogue.
|
||
- Multiline model output is kept inside the previous `A` or `B` utterance.
|
||
- Only text inside `「...」` is emitted to `utterances[].text`; narration and inner thoughts outside quotes are skipped.
|
||
- If one utterance contains multiple quoted lines, they are joined with newlines.
|
||
- Utterances without `「...」` are omitted from the TTS script.
|
||
- `out_text_2`, `out_text_3`, and numeric outputs are unused by this recipe.
|
||
|
||
</details>
|
||
|
||
## License
|
||
|
||
This project is licensed under the **GNU General Public License v3.0**.
|
||
|
||
**Copyright (C) 2026 kantan-kanto**
|
||
GitHub: https://github.com/kantan-kanto
|
||
|
||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||
|
||
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
|
||
|
||
## Support
|
||
|
||
- **Issues**: Report bugs or request features via GitHub Issues
|
||
- **Examples**: Check [examples/](examples/) for workflow templates
|
||
|
||
---
|
||
|
||
## Release Notes
|
||
### 1.2.0
|
||
- Replaced direct Python execution with a safe AST interpreter for ComfyUI Registry compatibility.
|
||
- Added Python-subset support for helper functions, loops, dictionaries, lists, string methods, f-strings, `random`, `datetime`, and `math`.
|
||
- Replaced trace-based timeout handling with step and loop limits.
|
||
- Kept existing `MultiOutputScript` inputs and outputs unchanged.
|
||
|
||
### 1.1.1
|
||
- Added safe-mode AST validation before script execution.
|
||
- Blocked unsafe syntax (`Import`, `ImportFrom`, `Global`, `Nonlocal`, `ClassDef`, `Try`, `With`, `AsyncWith`).
|
||
- Blocked unsafe dynamic execution, file/input access, namespace inspection, and dynamic attribute mutation calls.
|
||
- Added execution timeout guard (`1.5s` default) to stop runaway scripts.
|
||
- Updated security notes for trusted-workflow usage.
|
||
|
||
### 1.1.0
|
||
Added `centi` node (`utils`).
|
||
- Minimal connector-only utility node.
|
||
- Three optional integer inputs: `int_1`, `int_2`, `int_3`.
|
||
- Three float outputs: `float_1`, `float_2`, `float_3`.
|
||
- Each connected input is converted by `int_n / 100`; unconnected inputs return `None`.
|
||
|
||
### 1.0.1
|
||
- Improved documentation and project metadata.
|
||
|
||
### 1.0.0
|
||
- Initial release
|