# 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 ![Demo](images/MultiOutputScript.gif) 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`
Show ComfyUI-LLM-Session transcript conversion recipe `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.
## 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