Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24d93b778 | ||
|
|
c8f925c4ea | ||
|
|
1a733e71af | ||
|
|
e36eff4356 | ||
|
|
c6137ddc49 | ||
|
|
64417230a3 | ||
|
|
0a2ceb94e9 | ||
|
|
6e3fce9dcb | ||
|
|
b581cf7f24 | ||
|
|
d7a992b96d | ||
|
|
19123570e1 | ||
|
|
e6bb57cd25 | ||
|
|
8864201be5 | ||
|
|
4c9cc9e44c | ||
|
|
71a7ae5ca6 | ||
|
|
3ce15e45a5 | ||
|
|
ec30db6208 | ||
|
|
e6b617a7cb | ||
|
|
e069747170 | ||
|
|
b30af843c7 | ||
|
|
6bb80c563a | ||
|
|
24d4596c4f | ||
|
|
ebc53ae6e9 | ||
|
|
ea7a61a52b | ||
|
|
139808033b | ||
|
|
42db26f04e |
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>")
|
||||
|
||||
Reference in New Issue
Block a user