Compare commits

...
Author SHA1 Message Date
asagi4 b24d93b778 v3.0.0-beta.9 2026-08-22 18:56:51 +03:00
asagi4 c8f925c4ea Lazify NODE helper 2026-08-22 18:53:02 +03:00
asagi4 1a733e71af Make NODE helper autogrow 2026-08-22 18:52:58 +03:00
asagi4 e36eff4356 Note whitespace change in README 2026-08-20 16:08:09 +03:00
asagi4 c6137ddc49 Strip whitespace from scheduled prompts by default 2026-08-20 16:04:01 +03:00
asagi4 64417230a3 SEGs need to be expanded *before* NODE is evaluated 2026-08-19 23:07:12 +03:00
asagi4 0a2ceb94e9 doc: Note caveat about missing functionality when using NODE 2026-08-19 14:02:21 +03:00
asagi4 6e3fce9dcb v3.0.0-beta.8 2026-08-19 13:42:56 +03:00
asagi4 b581cf7f24 NODE: Fix SEG expansion
See: #150
2026-08-19 13:33:46 +03:00
asagi4 d7a992b96d NODE: Test for SEG expansion 2026-08-19 13:32:04 +03:00
asagi4 19123570e1 Fix parsing of < and > in schedules, see #149 2026-08-16 17:17:38 +03:00
asagi4 e6bb57cd25 Formatting 2026-08-14 18:13:49 +03:00
asagi4 8864201be5 v3.0.0-beta.7 2026-08-14 18:09:05 +03:00
asagi4 4c9cc9e44c Support empty inputs in NODE helper 2026-08-14 18:09:05 +03:00
asagi4 71a7ae5ca6 Add tests for NODE 2026-08-14 17:54:13 +03:00
asagi4 3ce15e45a5 Doc cleanup 2026-08-09 18:12:32 +03:00
asagi4 ec30db6208 Document NODE better 2026-08-09 17:22:21 +03:00
asagi4 e6b617a7cb Note source of custom concat node in H3 workflow, take 2 2026-08-08 20:29:47 +03:00
asagi4 e069747170 Note source of custom concat node in H3 workflow 2026-08-08 20:24:13 +03:00
asagi4 b30af843c7 Add MiniMax prompt control example workflow 2026-08-08 20:14:05 +03:00
asagi4 6bb80c563a Add a link helper for NODE 2026-08-08 13:12:05 +03:00
asagi4 24d4596c4f Fix breakage 2026-08-08 10:08:35 +03:00
asagi4 ebc53ae6e9 Experiment: Arbitrary extra parameters to NODE
This is extremely cursed, but you can do prompt scheduling with video models using this.

For example:

NODE(UC_AdvancedMiniMaxH3ImageToVideo, prompt, vae 161:152 0; reference_images.reference_image_1 [114:201:0.3] 0; width 120 0; height 120 1; length 176 1)

This changes the reference image parameter mid-sampling.

Syntax for the last parameter is a ;-separated list of paramname node_id output_slot tuples. The given slot from the node will then be plugged in as the named parameter)

Export your workflow in API format to find the node IDs.

This will probably change later.
2026-08-08 00:08:39 +03:00
asagi4 ea7a61a52b Don't spam DEF expansions at INFO log level 2026-07-07 18:12:23 +03:00
asagi4 139808033b Add a test for LoRALoader with SEGs 2026-07-04 21:41:10 +03:00
asagi4 42db26f04e Typo 2026-07-04 21:28:45 +03:00
12 changed files with 2213 additions and 15 deletions
+4 -4
View File
@@ -11,6 +11,9 @@ A `Basic Text to Image` template is included with the extension, and can be load
> The parser was rewritten using parsy. It is intended to have the same behaviour as the old parser, but is **significantly** faster.
> Please report any bugs or incompatibilities you find.
## Notable changes
- `PC: Schedule Prompt` now strips surrounding whitespace by default, which may change some prompts. Add `NOSTRIP()` to your prompt to restore previous behaviour.
## What can it do?
@@ -23,6 +26,7 @@ A `Basic Text to Image` template is included with the extension, and can be load
- Different weight interpretation types (ComfyUI, A1111, compel, etc.)
- Prompt masking with an implementation of [cutoff](https://github.com/BlenderNeko/ComfyUI_Cutoff).
- Organize complicated prompts with [segments and prompt macros](/doc/macros.md).
- [Schedule your own encoder nodes](/doc/node_function.md), allowing prompt control of eg. video or audio models with non-text inputs.
All features are fully schedulable unless otherwise stated. See the [scheduling syntax documentation](doc/schedules.md) to get started.
@@ -52,10 +56,6 @@ If you run into problems, update ComfyUI first.
for example, if you first encode `[cat:dog:0.1]` and later change that to `[cat:dog:0.5]`, no re-encoding takes place.
for added fun, put `NODE(NodeClassName, textinputname)` in a prompt to generate a graph using **any other node** that's compatible. The node can't have required parameters besides a single CLIP parameter (which must be named `clip`) and the text prompt, and it must return a `CONDITIONING` as its first return value. The "default" values are `PCTextEncode` and `text`.
For example, if you for some reason do not want the advanced features of `PCTextEncode`, use `NODE(CLIPTextEncode)` in the prompt and you'll still get scheduling with ComfyUI's regular TE node.
The advanced node enables filtering the prompt for multi-pass workflows.
## PCLazyLoraLoader and PCLazyLoraLoaderAdvanced
+1 -1
View File
@@ -29,7 +29,7 @@ For example:
dog FILL() COUPLE(0.5 1) cat
```
The full syntax looks as follow (to use `IMASK` you need to attach a custom mask)
The full syntax looks as follows (to use `IMASK` you need to attach a custom mask)
`base_prompt COUPLE MASK(0 0.5) coupled prompt 1 with mask COUPLE IMASK(0) coupled prompt 2 with custom mask`
+41
View File
@@ -0,0 +1,41 @@
# The NODE function
The `NODE` function allows you to use any other text encoding node within `PC: Schedule Prompt`, replacing the default `PCTextEncode` and allowing for example video model scheduling.
> [!NOTE]
> When using NODE, you lose access to *all* special syntax provided by `PCTextEncode`. Only SEGs, macros and scheduling will continue to work since those are processed at graph expansion time before the text prompt is passed into the node.
## Basic usage
Use `NODE(NodeClassName, textinputname)` in a prompt to generate a graph using any node that's compatible. The requirements are as follows:
- The node must have a CLIP parameter (which must be named `clip`)
- It must have a text field
- It must return a `CONDITIONING` as its first return value.
For example, if you for some reason do not want the advanced features of `PCTextEncode`, use `NODE(CLIPTextEncode)` in the prompt and you'll still get scheduling with ComfyUI's regular TE node.
The default parameters are `PCTextEncode` and `text`.
## Advanced Usage with arbitrary parameters
Advanced usage of `NODE` can be complicated. For an example, see [The H3 workflow](/example_workflows/Prompt%20Control%20with%20MiniMax%20H3.json?raw=1). You can also find it in the template library.
The full synopsis of the function is `NODE(NodeClassName, textinputname, arg_spec)` where `arg_spec` is a semicolon-separated list of `parameter_name json_value` pairs. In raw form, it looks like this:
```
NODE(MiniMaxH3ImageToVideo, prompt, vae ["1", 0]; width 1024; height 1024; first_frame ["2", 0])
```
The names and inputs must match the ComfyUI API format which **may differ from frontend names**. You can export your workflow in API format and inspect it to see how inputs are passed in to nodes.
The arrays are literal ComfyUI node links, meaning the `vae` parameter is taken from node ID "1" first output and `first_frame` from node ID "2" first output.
The values are arbitrary JSON literals, meaning that you can also pass in constant values. To pass in literal strings for example, you need to use quotes `"like this"`.
This is intended to be used with the helper node `PC: NODE Input Helper`, which can be used to pass arbitrary parameters (named `$a` to `$n`) to the encoder. The recommended pattern is to put something like the following:
```
SEG(node)
NODE(MiniMaxH3ImageToVideo, prompt, vae $a; width $b; height $c; length $d; first_frame $e)
```
to the helper and then concatenate it at the end of your prompt (use whitespace as a separator). You can then trigger the node with `$node` in your prompt. (see [documentation](/doc/macros.md) for `SEG`)
The helper will replace the parameters with the correct ComfyUI link values.
+3
View File
@@ -14,6 +14,9 @@ Besides the syntax documented below, the [basic syntax](/doc/basic.md) and [prom
a [large::0.1] [cat|dog:0.05] [<lora:somelora:0.5:0.6>::0.5]
[in a park:in space:0.4]
```
## Note on whitespace
`PC: Schedule Prompt` will strip leading and following whitespace from the prompt automatically. If you really want whitespace in your prompt, include `NOSTRIP()` in your prompt.
## Comments and escaping
In schedules, any text on a line following a `#` is considered a comment and removed, including the `#` character.
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -88,7 +88,6 @@ def expand_macros(text):
iterations += 1
if iterations > 10:
raise ValueError("Unable to resolve DEFs, make sure there are no cycles!")
return text
for search, replace in replacements:
res = substitute_defcall(res, search, replace)
if res == prevres:
@@ -96,7 +95,7 @@ def expand_macros(text):
prevres = res
if res.strip() != text.strip():
res = res.strip()
log.info("DEFs expanded to: %s", res)
log.debug("DEFs expanded to: %s", res)
return res
+34 -6
View File
@@ -197,14 +197,42 @@ def build_scheduled_prompts(graph, schedules, clip):
start_pct = 0.0
for end_pct, c in schedules:
p = c["prompt"]
p, classnames = get_function(p, "NODE", ["PCTextEncode", "text"])
classname = "PCTextEncode"
paramname = "text"
# Need to explicitly expand SEGs here *before* NODE is processed
p = expand_segs(p)
p, classnames = get_function(p, "NODE", defaults=None)
realargs = ["PCTextEncode", "text", ""]
if len(classnames) > 1:
log.warning("You have more than one NODE call in your prompt. Only the first one will be used")
if classnames:
classname, paramname = classnames[0].args
node = graph.node(classname)
args = classnames[0].args[0]
if not args.strip():
raise ValueError("NODE can't be empty!")
for i, v in enumerate(args.split(",", maxsplit=2)):
realargs[i] = v
classname, paramname, magic_spec = realargs
node = graph.node(classname.strip())
node.set_input("clip", clip)
node.set_input(paramname, p)
# We should strip extra whitespace so that people don't have to worry about functions.
p = p.replace("NOSTRIP()", "") if "NOSTRIP()" in p else p.strip()
node.set_input(paramname.strip(), p)
magic_spec.replace(r"\;", "__ESCAPED_SEMICOLON__")
extra_inputs = magic_spec.split(";") if magic_spec.strip() else []
for e in extra_inputs:
e = e.strip()
if not e:
continue
e = e.replace("__ESCAPED_SEMICOLON__", ";")
name, jsondata = e.split(maxsplit=1)
jsondata = jsondata.strip()
if not jsondata.strip():
continue
# From helper node:
if jsondata == "__EMPTY__":
continue
try:
node.set_input(name.strip(), json.loads(jsondata.strip()))
except ValueError as e:
raise ValueError(f"Invalid JSON input: '{jsondata}'") from e
timestep = graph.node("ConditioningSetTimestepRange")
timestep.set_input("conditioning", node.out(0))
timestep.set_input("start", start_pct)
+65
View File
@@ -1,3 +1,4 @@
import json
import logging
from comfy_api.latest import io
@@ -5,6 +6,7 @@ from comfy_api.latest import io
from .macros import expand_macros as macroexpand
from .macros import expand_segs as segexpand
from .macros import expand_subs as subexpand
from .macros import substitute_var
from .parser import parse_prompt_schedules
log = logging.getLogger("comfyui-prompt-control")
@@ -182,6 +184,68 @@ class PCMacroExpand(io.ComfyNode):
return io.NodeOutput(macroexpand(text))
class PCLinkHelper(io.ComfyNode):
# a-z
NAMES = [chr(97 + i) for i in range(26)]
@classmethod
def define_schema(cls):
t1 = io.Autogrow.TemplateNames(io.AnyType.Input("link", raw_link=True), min=0, names=cls.NAMES)
t2 = io.Autogrow.TemplateNames(
io.AnyType.Input("value", lazy=True), min=0, names=[f"var{i + 1}" for i in range(50)]
)
return io.Schema(
node_id="PCNODELinkHelper",
display_name="PC: Extra argument helper for NODE",
category="promptcontrol/tools",
description="Takes in arbitrary inputs and renders them as NODE-compatible values, replacing $a -> $z with JSON link values.",
is_experimental=True,
inputs=[
io.Autogrow.Input("links", template=t1),
io.Autogrow.Input(
"vars",
template=t2,
),
io.String.Input(
"template",
tooltip="The variables $a to $z will be replaced in this text with their corresponding input's JSON link value",
placeholder="In this text you can refer to the input links as $a, $b etc. and the var inputs as either $var1 or $json1 etc. (the latter will be rendered through Python's json.dumps function which will cause strings to be quoted)",
multiline=True,
),
],
outputs=[io.String.Output()],
)
# This requires https://github.com/Comfy-Org/ComfyUI/pull/15103 to work properly
@classmethod
def check_lazy_status(cls, template, links, vars):
r = []
for name, (v, input_name) in vars.items():
if v is None and f"${name}" in template or v is None and f"$json{name[3:]}" in template:
r.append(input_name)
return r
@classmethod
def execute(cls, template, links, vars) -> io.NodeOutput:
text = template
for k in cls.NAMES:
v = "__EMPTY__"
if k in links:
v = json.dumps(links[k])
text = substitute_var(text, k, v)
for i in range(50):
v = "__EMPTY__"
k = f"var{i + 1}"
if k in vars:
v = vars[k]
text = substitute_var(text, k, str(v))
if f"$json{i + 1}" in text:
v = v if v == "__EMPTY__" else json.dumps(v)
text = substitute_var(text, f"json{i + 1}", v)
return io.NodeOutput(text)
NODES = [
PCSetPCTextEncodeSettings,
PCAddMaskToCLIP,
@@ -189,4 +253,5 @@ NODES = [
PCSetLogLevel,
PCExtractScheduledPrompt,
PCMacroExpand,
PCLinkHelper,
]
+11 -1
View File
@@ -370,7 +370,17 @@ ctlweight = seq(number, (string("@") >> number).optional(0)).sep_by(comma, min=1
loractl = (string("<loractl:") >> filename * 1 + lora_weights(ctlweight) << string(">")).combine(LoRACTL)
emb = (string("<emb:") >> filename << string(">")).map(lambda f: Text(f"embedding:{f}"))
expr = escape | comment | non_special | bracketed | emphasis.combine(combine_prompt) | lora | loractl | emb
expr = (
escape
| comment
| non_special
| bracketed
| emphasis.combine(combine_prompt)
| lora
| loractl
| emb
| char_from("<>").map(Text)
)
prompt_ = expr.at_least(1).combine(combine_prompt)
prompt.become(prompt_)
# Treat any character that isn't valid prompt syntax as just text
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-prompt-control"
description = "Nodes for prompt editing and LoRA scheduling, advanced regional prompting (including attention masking) and advanced prompt encoding, all controlled through your text prompt. Feature keywords: comfyui-prompt-control, schedule, macros, attention couple, loractl, A1111"
version = "3.0.0-beta.6"
version = "3.0.0-beta.9"
license = { file = "LICENSE" }
requires-python = ">= 3.10"
+102
View File
@@ -460,6 +460,102 @@ def test_textencode_lora_with_schedule():
}
def test_textencode_custom():
r = te("NODE(CLIPTextEncode)simple [test:0.1,0.5] $p SEG(p) prompt")
assert r == {
"result": (["UID.0.0.8", 0],),
"expand": {
"UID.0.0.1": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": [0, 0], "text": "simple prompt"},
},
"UID.0.0.2": {
"class_type": "ConditioningSetTimestepRange",
"inputs": {"conditioning": ["UID.0.0.1", 0], "start": 0.0, "end": 0.1},
},
"UID.0.0.3": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": [0, 0], "text": "simple test prompt"},
},
"UID.0.0.4": {
"class_type": "ConditioningSetTimestepRange",
"inputs": {"conditioning": ["UID.0.0.3", 0], "start": 0.1, "end": 0.5},
},
"UID.0.0.5": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": [0, 0], "text": "simple prompt"},
},
"UID.0.0.6": {
"class_type": "ConditioningSetTimestepRange",
"inputs": {"conditioning": ["UID.0.0.5", 0], "start": 0.5, "end": 1.0},
},
"UID.0.0.7": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["UID.0.0.2", 0], "conditioning_2": ["UID.0.0.4", 0]},
},
"UID.0.0.8": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["UID.0.0.7", 0], "conditioning_2": ["UID.0.0.6", 0]},
},
},
}
def test_textencode_custom_extra():
r = te(
'NODE(CustomTextEncode, prompt, image ["1", 0]; option "test"; float [10.0:__EMPTY__:0.5])simple [test:0.1,0.5] prompt'
)
assert r == {
"result": (["UID.0.0.8", 0],),
"expand": {
"UID.0.0.1": {
"class_type": "CustomTextEncode",
"inputs": {
"clip": [0, 0],
"prompt": "simple prompt",
"image": ["1", 0],
"option": "test",
"float": 10.0,
},
},
"UID.0.0.2": {
"class_type": "ConditioningSetTimestepRange",
"inputs": {"conditioning": ["UID.0.0.1", 0], "start": 0.0, "end": 0.1},
},
"UID.0.0.3": {
"class_type": "CustomTextEncode",
"inputs": {
"clip": [0, 0],
"prompt": "simple test prompt",
"image": ["1", 0],
"option": "test",
"float": 10.0,
},
},
"UID.0.0.4": {
"class_type": "ConditioningSetTimestepRange",
"inputs": {"conditioning": ["UID.0.0.3", 0], "start": 0.1, "end": 0.5},
},
"UID.0.0.5": {
"class_type": "CustomTextEncode",
"inputs": {"clip": [0, 0], "prompt": "simple prompt", "image": ["1", 0], "option": "test"},
},
"UID.0.0.6": {
"class_type": "ConditioningSetTimestepRange",
"inputs": {"conditioning": ["UID.0.0.5", 0], "start": 0.5, "end": 1.0},
},
"UID.0.0.7": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["UID.0.0.2", 0], "conditioning_2": ["UID.0.0.4", 0]},
},
"UID.0.0.8": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["UID.0.0.7", 0], "conditioning_2": ["UID.0.0.6", 0]},
},
},
}
def test_loraloader_empty(monkeypatch, caplog):
result = loraloader("prompt here <lora:nonexistent:1.0:0.5>")["expand"]
result_adv = loraloader("prompt here <lora:nonexistent:1.0:0.5>", adv=True)["expand"]
@@ -582,3 +678,9 @@ def test_loraloader_adv_start():
def test_loraloader_end_zero():
result2 = loraloader("prompt [<lora:test:0.5>:0.5]", adv=True, end=0.5)["expand"]
assert result2 == {}
def test_loraloader_segs():
result = loraloader("prompt [<lora:test:0.5>:0.5]")["expand"]
result2 = loraloader("prompt [$lora:0.5]\nSEG(lora)<lora:test:0.5>\nSEG(lora2)<lora:ignored:1>")["expand"]
assert result == result2
+6
View File
@@ -342,6 +342,12 @@ def test_cornercase_corrected(parse):
assert p.parsed_prompt[1:] == p2.parsed_prompt
def test_ltgt_in_schedule(parse):
p = parse("This should [<parse> correctly:be <Picture 1>:0.1]<lora:test:1>")
assert_prompt(p, 0.1, 0.1, "This should <parse> correctly", ("test", 1.0, 1.0))
assert_prompt(p, 0.15, 1.0, "This should be <Picture 1>", ("test", 1.0, 1.0))
def test_floats(parse):
p = parse("[a:b:0.5] [c:d:e:0.2,0.7] <lora:test:-0.3>")
p2 = parse("[a:b:.5] [c:d:e:.2,.7] <lora:test:-.3>")