Compare commits

..
Author SHA1 Message Date
asagi4 9857c662af tests: COUPLE mask shortcut 2026-02-02 19:33:34 +02:00
asagi4 32f18ef338 Tests for step count 2026-02-02 19:26:13 +02:00
asagi4 d86d9f902c test: Verify that basic paren escapes don't get stripped 2026-02-02 19:26:13 +02:00
asagi4 f600b1ef90 Fix type complaint 2026-02-02 19:26:13 +02:00
asagi4 72484d1034 test only new parser 2026-02-02 19:26:13 +02:00
asagi4 e7b444316f Test multiple averages 2026-02-02 19:26:13 +02:00
asagi4 b9db1c7c4c Make github tests work again 2026-02-02 19:26:13 +02:00
asagi4 3ee001a3fa Disable method override complaint 2026-02-02 19:26:13 +02:00
asagi4 3fcd708dc6 Working importing for v3 nodes 2026-02-02 19:26:13 +02:00
asagi4 cb5ef8d97a v3: nodes_lazy.py 2026-02-02 19:26:13 +02:00
asagi4 24fe09ba4e v3: nodes_tools 2026-02-02 19:26:13 +02:00
asagi4 1c7149a811 v3: nodes_hooks.py 2026-02-02 19:26:13 +02:00
asagi4 b01bdc3d50 v3: nodes_base.py 2026-02-02 19:26:13 +02:00
asagi4 5ffd94d7d2 Initial v3 migration 2026-02-02 19:26:13 +02:00
asagi4 a433b246a7 handle setting steps 2026-02-02 19:26:13 +02:00
asagi4 bdd0c62665 parsy ruff fixes 2026-02-02 19:26:13 +02:00
asagi4 1c763f743c parsy loractl 2026-02-02 19:23:55 +02:00
asagi4 c223603100 simplify LoRA weight parsing 2026-02-02 19:23:55 +02:00
asagi4 c9efa87291 Rewrite parser to use parsy 2026-02-02 19:23:50 +02:00
asagi4 e15d4d693c Remove broken expand_graph.py 2026-02-02 19:19:21 +02:00
asagi4 26d5bfc961 Split cutoff parser to its own file 2026-02-02 19:19:21 +02:00
asagi4 cdb6c418eb split macros out of parser 2026-02-02 19:19:21 +02:00
asagi4 dc08fa93b7 Fix NOISE doing nothing 2026-02-02 19:19:21 +02:00
asagi4 e29f3ca2d0 Fix github workflows 2026-02-02 19:19:21 +02:00
asagi4 ee186a73e5 Remove old tests 2026-02-02 19:19:17 +02:00
asagi4 7440b309fc Fix LazyLoraLoader tests 2026-02-02 19:18:36 +02:00
asagi4 2ccc697311 Test for discovered corner case behaviour 2026-02-02 19:18:36 +02:00
asagi4 4559060535 Add a graph test for alternating 2026-02-02 19:18:36 +02:00
asagi4 60ceddbd73 Refactor tests for new parser 2026-02-02 19:18:36 +02:00
asagi4 6fd28cfca7 Use pytest tests 2026-02-02 19:18:36 +02:00
asagi4 11b1313ed4 Convert tests to pytest
Not 100% sure these fully work yet
2026-02-02 19:18:36 +02:00
asagi4 9be7133e48 Ruff fixes etc. 2026-02-02 19:18:36 +02:00
asagi4 22e78f08eb Typing fixes etc 2026-02-02 19:18:36 +02:00
asagi4 39befe2a34 Remove cache hack, it's broken anyway 2026-02-02 19:18:36 +02:00
asagi4 ea9017862f Add ruff and ty 2026-02-02 19:18:36 +02:00
asagi4 6ae6cf0d65 Add pyright 2026-02-02 19:18:36 +02:00
30 changed files with 2478 additions and 1079 deletions
+1 -1
View File
@@ -19,5 +19,5 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install pytest typing-extensions
- run: pip install pytest typing-extensions -r requirements.txt
- run: PYTHONPATH=ComfyUI pytest tests/test_parser.py
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: install-torch
run: pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
- name: install ComfyUI
run: pip install pytest typing-extensions -r ComfyUI/requirements.txt
run: pip install pytest typing-extensions -r requirements.txt -r ComfyUI/requirements.txt
- name: Download clip_l.safetensors
run: curl -LO https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors
- name: Force Comfy to use the CPU
+1 -4
View File
@@ -12,7 +12,7 @@ format:
ruff format
test:
PYTHONPATH=../../ pytest tests/test_parser.py tests/test_cutout.py tests/test_macros.py $(ARGS)
PYTHONPATH=../../ pytest tests/test_parser.py $(ARGS)
test_graph:
PYTHONPATH=../../ pytest tests/test_graph.py $(ARGS)
@@ -20,9 +20,6 @@ test_graph:
test_encode:
PYTHONPATH=../../ pytest tests/test_encode.py $(ARGS)
test_workflow:
PYTHONPATH=../../ pytest tests/test_workflow.py $(ARGS)
test_encode_both:
TEST_TE="clip_l t5" PYTHONPATH=../../ pytest tests/test_encode.py $(ARGS)
+16 -13
View File
@@ -1,28 +1,24 @@
# ComfyUI prompt control
Control LoRA and prompt scheduling, advanced text encoding, regional prompting, and much more, through your text prompt. Prompt Control generates dynamic graphs that are literally identical to handcrafted noodle soup, condensing complicated workflows with dozens of nodes into simple text prompts.
Control LoRA and prompt scheduling, advanced text encoding, regional prompting, and much more, through your text prompt. Generates dynamic graphs that are literally identical to handcrafted noodle soup.
Prompt Control comes with `PCTextEncode`, which provides advanced text encoding with many additional features compared to ComfyUI's base `CLIPTextEncode`.
A `Basic Text to Image` template is included with the extension, and can be loaded from ComfyUI's template library.
> [!NOTE]
> v3.0.0 is backwards compatible with existing workflows, but requires at least ComfyUI v0.8.0
> 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.
## What can it do?
You can use text prompts to control the following:
- A1111-style prompt scheduling and filtering without noodle soup.
- LoRA loading and [scheduling](/doc/schedules.md) using ComfyUI's built-in hook system.
- LoRA loading and [scheduling](/doc/schedules.md) via the prompt, using ComfyUI's hook system
- Masking, composition and area control ([regional prompting](/doc/regional_prompts.md)) with an implementation of [Attention Couple](/doc/attention_couple.md), also fully schedulable.
- [Advanced prompt encoding](/doc/basic.md)
- Per-encoder prompts for models with multiple text encoders, such as SDXL and Flux.
- Per-encoder prompts for models with multiple text encoders, such as SDXL and Flux
- Prompt combinators like `BREAK`, as well as `CAT`, `AVG()` and `AND` corresponding to ComfyUI's `ConditioningConcat`, `ConditioningAverage` and `ConditioningCombine` nodes.
- 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).
- Prompt masking with an implementation of [cutoff](https://github.com/BlenderNeko/ComfyUI_Cutoff)
- Simple [prompt macros](/doc/macros.md) with `DEF`
All features are fully schedulable unless otherwise stated. See the [scheduling syntax documentation](doc/schedules.md) to get started.
@@ -38,9 +34,16 @@ If you encounter issues as a user or if you're a node developer and Prompt Contr
## Requirements
The v3 node schema uses features that require at least ComfyUI v0.8.0
For LoRA scheduling to work, you'll need at least version 0.3.7 of ComfyUI (0.3.36 of ComfyUI desktop).
If you run into problems, update ComfyUI first.
You need to have `lark` installed in your Python environment for parsing to work (If you reuse A1111's venv, it'll already be there).
If you use the portable version of ComfyUI on Windows with its embedded Python, you must open a terminal in the ComfyUI installation directory and run the command:
```
.\python_embeded\python.exe -m pip install lark
```
Then restart ComfyUI afterwards.
# Core nodes
+1 -1
View File
@@ -30,7 +30,7 @@ if "PYTEST_CURRENT_TEST" not in os.environ:
h = logging.StreamHandler(sys.stdout)
h.setFormatter(logging.Formatter("[PromptControl] %(levelname)s: %(message)s"))
log.addHandler(h)
for node in ["base", "hooks", "tools", "lazy", "anima"]:
for node in ["base", "hooks", "tools", "lazy"]:
mod = importlib.import_module(f".prompt_control.nodes_{node}", package=__name__)
v3_modules.append(mod)
+8 -20
View File
@@ -1,5 +1,7 @@
# Attention Couple
NOTE: This is still considered an experimental feature, so the syntax may change.
Attention Couple is an attention-based implementation of regional prompting. it is faster and often more flexible than latent-based masking.
The implementation is based on the one by [pamparamm](https://github.com/pamparamm/ComfyUI-ppm.git), modified to use ComfyUI's hook system. This enables it to work with prompt scheduling.
@@ -10,11 +12,6 @@ As a consequence of this, however, you can also use `COUPLE` in your negative pr
To enable batching negative prompts, run your positive and negative prompt through the `PPCAttentionCoupleBatchNegative` node. This will make the outputs identical to pamparamm's implementation and will also improve performance. It will fall back to the default behaviour in cases where batching can't be done, so it should always be safe to use.
## Anima
There is a **very experimental** port of pamparamm's Anima support for Attention Couple in Prompt Control. Because ComfyUI lacks the built-in schedulable hooks required, you must first patch your model with `PC: Anima Attention Couple Model Patch` in addition to using `COUPLE` as usual.
The code was hacked together with minimal thought, so expect bugs and misbehaviour. The port is also currently *not* compatible with NegPIP.
## Syntax
@@ -24,13 +21,6 @@ See also the [regional prompting documentation](/doc/regional_prompts.md) for in
You can use `COUPLE` to attach attention-coupled prompts to a base prompt:
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)
`base_prompt COUPLE MASK(0 0.5) coupled prompt 1 with mask COUPLE IMASK(0) coupled prompt 2 with custom mask`
as a shortcut, `COUPLE(maskparams)` is expanded to `COUPLE MASK(maskparams)`, so the above prompt can also be written as:
@@ -38,17 +28,15 @@ as a shortcut, `COUPLE(maskparams)` is expanded to `COUPLE MASK(maskparams)`, so
`base_prompt COUPLE(0 0.5) coupled prompt 1 with mask COUPLE IMASK(0) coupled prompt 2 with custom mask`
Behaviour:
- If no mask is specified, an implicit `MASK()` is assumed, meaning that the prompt affects the entire image.
- If no mask is specified, an implicit `MASK()` is assumed.
- For the base prompt, you can use `FILL()` to automatically mask all parts not masked by other coupled prompts
- For the base prompt, you can also use `FILL()` to automatically mask all parts not masked by coupled prompts
- If the base prompt has weight set to zero (ie. ´:0` at the end), then the first coupled prompt with non-zero weight becomes the base prompt:
- If the base prompt has weight set to zero (ie. ´:0` at the end), then the first coupled prompt with non-zero weight becomes the base prompt.
For example:
```
disabled prompt :0 COUPLE new base prompt COUPLE coupled prompt
dog FILL() COUPLE(0.5 1) cat
```
You can also schedule the weight normally: `prompt :[1:0:0.35]`
> ![NOTE]
> Note that because the generation still sees and diffuses the full latent, attention coupling is not guaranteed to perfectly limit the effect of your prompt to the masked area.
Note that because the generation still sees and diffuses the full latent, attention coupling is not guaranteed to perfectly limit the effect of your prompt to the masked area.
-1
View File
@@ -31,7 +31,6 @@ Prompt operators are processed in the following order, meaning that all features
- DEF macros are expanded
- Scheduling is expanded, and for each scheduled prompt:
- SEGs are processed and the template is expanded
- The prompt is split by AND, and for each:
- Prompts are split by COUPLE. and for each:
- Most functions (like MASK) and cutoffs are evaluated
-39
View File
@@ -58,42 +58,3 @@ a "$1" b "$2"
a "" b "$2"
a "A" b "$2"
```
## SEG: Split your prompt into named segments
Syntax: `SEG(segment_name)`
To help with organizing prompts, you can use the `SEG` function. For example:
```
This is a comic
Top panel: $CAT. $SEG3
Bottom panel: $DOG
SEG(DOG)
A dog chasing its
tail in a living room.
SEG(CAT)
a sleeping cat
SEG
The cat has orange fur with white stripes
```
This produces:
```
This is a comic
Top panel: a sleeping cat. The cat has orange fur with white stripes
Bottom panel: A dog chasing its
tail in a living room.
```
> [!NOTE]
> Unlike macros, SEGs are processed *after* scheduling syntax has been expanded, except in the LoRA loader (this may change later, but requires a bit of refactoring)
In this case, the first section before any `SEG` becomes the *template* and any text after a `SEG` call becomes part of that segment. Whitespace is stripped from the start and end of segments and the template.
In the template, you can refer to segments by either their index (starting from 1) or the given name, prefixed with a `$SEG`, so in this example, `$SEG1` is the same as `$PANEL1`
Segments can also refer to each other. Recursion will terminate, but produces weird outputs.
Naming segments is optional, in which case you will have to refer to it by its index.
File diff suppressed because it is too large Load Diff
-167
View File
@@ -1,167 +0,0 @@
# Adapted from https://github.com/pamparamm/ComfyUI-ppm
import itertools
from collections.abc import Callable
from functools import partial
from math import lcm
import torch
import torch.nn.functional as F
from comfy.ldm.anima.model import Anima as AnimaDIT
from comfy.ldm.cosmos.predict2 import Attention as CosmosAttention
from comfy.patcher_extension import WrapperExecutor
from comfy.sampler_helpers import convert_cond
from comfy.samplers import process_conds
COND = 0
UNCOND = 1
def reshape_mask(mask: torch.Tensor, size: tuple[int, int], bs: int, num_tokens: int) -> torch.Tensor:
num_conds = mask.shape[0]
mask_downsample = F.interpolate(mask, size=size, mode="nearest")
mask_downsample_reshaped = mask_downsample.view(num_conds, num_tokens, 1).repeat_interleave(bs, dim=0)
return mask_downsample_reshaped
def wrap_forwards(anima_model):
backups = {}
for block_name, b in (
(n, b) for n, b in anima_model.named_modules() if "cross_attn" in n and isinstance(b, CosmosAttention)
):
backups[block_name] = b.forward
b.forward = partial(cosmos_attention_forward_couple, b.forward)
return backups
def unwrap_forwards(anima_model, backups):
for block_name, b in (
(n, b) for n, b in anima_model.named_modules() if "cross_attn" in n and isinstance(b, CosmosAttention)
):
b.forward = backups[block_name]
def anima_sample_wrapper(executor, *args, **kwargs):
guider, _, extra_options, _, noise, latent_image, denoise_mask, *_ = args
seed = extra_options["seed"]
device = "cuda" # TODO: fix
def pc_process_conds(pc_conds):
conds = [convert_cond([c])[0] for c in pc_conds]
conds = process_conds(
guider.inner_model,
noise,
{"positive": conds},
device,
latent_image,
denoise_mask,
seed,
latent_shapes=[latent_image.shape],
)
return [
c["model_conds"]["c_crossattn"].cond * pc_conds[i][1].get("strength", 1.0)
for i, c in enumerate(conds["positive"])
]
extra_options["model_options"]["transformer_options"]["pc_process_conds"] = pc_process_conds
return executor(*args, **kwargs)
def anima_forward_wrapper(executor: WrapperExecutor, *args, **kwargs):
"""Model wrapper does something with activation shapes?"""
anima_model: AnimaDIT = executor.class_obj # type: ignore
x: torch.Tensor = args[0]
transformer_options: dict = kwargs.get("transformer_options", {}).copy()
pc = transformer_options.get("pc_couple")
if pc and "processed_conds" not in pc:
pc["processed_conds"] = transformer_options["pc_process_conds"](pc["conds"])
patch_spatial = anima_model.patch_spatial
activations_shape = list(x.shape)
activations_shape[-2] = activations_shape[-2] // patch_spatial
activations_shape[-1] = activations_shape[-1] // patch_spatial
transformer_options["activations_shape"] = activations_shape
kwargs["transformer_options"] = transformer_options
b = {}
if pc:
b = wrap_forwards(anima_model)
r = executor(*args, **kwargs)
if pc:
unwrap_forwards(anima_model, b)
return r
def cosmos_attention_forward_couple(_forward: Callable, x, context, rope_emb, transformer_options):
"""attention block wrapper"""
if "pc_couple" not in transformer_options:
return _forward(x, context, rope_emb, transformer_options)
c: torch.Tensor = context
# FIXME: base cond weight
# c = args["processed_conds"][0]
args = transformer_options["pc_couple"]
mask = args["mask"]
conds = args["processed_conds"][1:]
num_conds = len(conds) + 1
num_tokens_c: list[int] = [c.shape[1] for c in conds]
cond_or_uncond = transformer_options["cond_or_uncond"]
cond_or_uncond_couple = []
num_chunks = len(cond_or_uncond)
bs = x.shape[0] // num_chunks
x_chunks = x.chunk(num_chunks, dim=0)
c_chunks = c.chunk(num_chunks, dim=0)
lcm_tokens_c = lcm(c.shape[1], *num_tokens_c)
conds_c_tensor = torch.cat(
[cond.repeat(bs, lcm_tokens_c // num_tokens_c[i], 1) for i, cond in enumerate(conds)],
dim=0,
)
xs, cs = [], []
for i, cond_type in enumerate(cond_or_uncond):
x_target = x_chunks[i]
c_target = c_chunks[i].repeat(1, lcm_tokens_c // c.shape[1], 1)
if cond_type == UNCOND:
xs.append(x_target)
cs.append(c_target)
cond_or_uncond_couple.append(UNCOND)
else:
xs.append(x_target.repeat(num_conds, 1, 1))
cs.append(torch.cat([c_target, conds_c_tensor], dim=0))
cond_or_uncond_couple.extend(itertools.repeat(COND, num_conds))
xs = torch.cat(xs, dim=0)
cs = torch.cat(cs, dim=0)
out = _forward(xs, cs, rope_emb, transformer_options)
size = tuple(transformer_options["activations_shape"][-2:])
num_tokens = out.shape[1]
mask_downsample = reshape_mask(mask, size, bs, num_tokens)
outputs = []
cond_outputs = []
i_cond = 0
for i, cond_type in enumerate(cond_or_uncond_couple):
pos, next_pos = i * bs, (i + 1) * bs
if cond_type == UNCOND:
outputs.append(out[pos:next_pos])
else:
pos_cond, next_pos_cond = i_cond * bs, (i_cond + 1) * bs
masked_output = out[pos:next_pos] * mask_downsample[pos_cond:next_pos_cond]
cond_outputs.append(masked_output)
i_cond += 1
if len(cond_outputs) > 0:
cond_output = torch.stack(cond_outputs).sum(0)
outputs.append(cond_output)
return torch.cat(outputs, dim=0)
+4 -12
View File
@@ -63,23 +63,20 @@ class AttentionCoupleHook(TransformerOptionsHook):
def __init__(self):
super().__init__(hook_scope=EnumHookScope.HookedOnly)
self.transformers_dict: dict[str, Any] = {
self.transformers_dict = {
"patches": {
"attn2_output_patch": [Proxy(self.attn2_output_patch)],
"attn2_patch": [Proxy(self.attn2_patch)],
},
"pc_couple": {},
}
}
self.has_negpip = False
# The list will be calculated later. All clones must refer to the same kv dict
self.kv: dict[str, list] = {"k": None, "v": None} # type: ignore
# calculate later. All clones must refer to the same kv dict
self.kv = {"k": [], "v": []}
def initialize_regions(self, base_cond, conds, fill):
self.num_conds = len(conds) + 1
self.base_strength = base_cond[1].get("strength", 1.0)
self.strengths: list[float] = [cond[1].get("strength", 1.0) for cond in conds]
self.comfy_conds = [base_cond] + conds
self.conds: list[torch.Tensor] = [base_cond[0]] + [cond[0] for cond in conds]
base_mask = base_cond[1].get("mask", None)
masks = [cond[1].get("mask") * cond[1].get("mask_strength") for cond in conds]
@@ -119,11 +116,6 @@ class AttentionCoupleHook(TransformerOptionsHook):
self.mask = mask / mask.sum(dim=0, keepdim=True)
def on_apply_hooks(self, model: ModelPatcher, transformer_options: dict[str, Any]):
self.transformers_dict["pc_couple"] = {
"conds": self.comfy_conds,
"num_conds": self.num_conds,
"mask": self.mask,
}
if self.kv["k"] is None:
self.has_negpip = model.model_options.get("ppm_negpip", False)
log.debug("AttentionCouple has_negpip=%s", self.has_negpip)
+51 -18
View File
@@ -1,25 +1,58 @@
import re
from typing import TypeAlias
from .utils import parse_args
import lark
CUTOFF_RE = re.compile(r"\[CUT:((.*?):(.*?))\]")
from .parser import flatten
cut_parser = lark.Lark(
r"""
!start: (cut | prompt | /[][:()]/+)*
prompt: (PLAIN | WHITESPACE)+
cut: "[CUT:" prompt ":" prompt [":" NUMBER [ ":" NUMBER [":" NUMBER [ ":" PLAIN ] ] ] ]"]"
WHITESPACE: /\s+/
PLAIN: /([^\[\]:])+/
%import common.SIGNED_NUMBER -> NUMBER
"""
)
def noop(x):
return x
class CutTransform(lark.Transformer):
def __default__(self, data, children, meta):
return children
def NUMBER(self, args):
return float(args)
def parse_cuts(string):
text = CUTOFF_RE.sub(r"\2", string)
cutoffs = CUTOFF_RE.findall(string)
cs = []
for x, *_ in cutoffs:
p = x.split(":")
args = parse_args(
p, [(str, ""), (str, ""), (float, 0), (float, None), (float, None), (noop, None)], strip=False
def cut(self, args):
prompt, cutout, weight, strict_mask, start_from_masked, mask_token = args
# prompts and cutouts are always sequences of str
return (
"".join(prompt),
"".join(cutout),
weight,
strict_mask,
start_from_masked,
mask_token,
)
args = tuple(args)
if not args[0] or not args[1] or (args[5] is not None and not args[5].strip()):
raise ValueError(f"Invalid CUT spec: [CUT:{x}]")
cs.append(args)
return text, cs
def start(self, args):
prompt = []
cuts = []
for a in flatten(args):
if isinstance(a, str):
prompt.append(a)
else:
prompt.append(a[0])
cuts.append(a)
return "".join(prompt), cuts
def PLAIN(self, args: str) -> str:
return str(args)
CutResult: TypeAlias = tuple[str, str, float, float, float, str]
def parse_cuts(text: str) -> tuple[str, CutResult]:
return CutTransform().transform(cut_parser.parse(text))
+4 -52
View File
@@ -4,52 +4,12 @@ from __future__ import annotations
import logging
import re
from .utils import find_closing_paren, get_function, split_by_function
from .utils import find_closing_paren, get_function
logging.basicConfig()
log = logging.getLogger("comfyui-prompt-control")
def substitute_template(template, segments, do_subs):
def _substitute(template, segments, stack):
name = ""
if "$" in template:
for name, value in sorted(segments):
value = substitute_var(value, name, "")
if name not in stack:
stack.add(name)
value = _substitute(value, segments, stack)
stack.remove(name)
template = substitute_var(template, name, value)
if do_subs and name not in stack:
template = expand_subs(template)
return template
return _substitute(template, segments, set())
def expand_segs(text, do_subs=True):
template, segments = split_by_function(text, "SEG", defaults=[""], require_args=True)
named_segs = [(f.args[0].strip() or f"SEG{i + 1}", c.strip()) for i, (c, f) in enumerate(segments)]
new_text = substitute_template(template, named_segs, do_subs).strip()
if new_text != text.strip():
log.debug("Template expanded to: %s", new_text)
return new_text
def expand_subs(text):
text, subs = get_function(text, "SUB", defaults=None)
subs = [spec.strip() for f in subs for spec in f.args[0].split(";")]
for spec in subs:
if len(spec) <= 3 or spec[0] != "s":
log.warning("Invalid SUB spec ignored: '%s'", spec)
continue
splitchar = spec[1]
search, replace, *_ = spec[2:].split(splitchar)
text = re.sub(search, replace, text)
return text
def parse_search(search):
arg_start = search.find("(")
args = ""
@@ -100,13 +60,6 @@ def expand_macros(text):
return res
def substitute_var(text, name, replace, boundary=r"\b"):
if f"${name}" not in text:
return text
name = re.escape(str(name))
return re.sub(rf"\${name}{boundary}", replace, text)
def substitute_defcall(text, search, replace):
name, default_args = search
text, defns = get_function(text, name, defaults=None, placeholder=f"DEFNCALL{name}", require_args=False)
@@ -118,12 +71,11 @@ def substitute_defcall(text, search, replace):
if parameters:
paramvals = [x.strip() for x in parameters[0].split(";")]
r = replace
end_re = r"(?![0-9])"
for i, v in enumerate(paramvals):
r = substitute_var(r, i + 1, v, boundary=end_re)
r = re.sub(rf"\${i + 1}\b", v, r)
for i, v in enumerate(default_args):
r = substitute_var(r, i + 1, v, boundary=end_re)
r = re.sub(rf"\${i + 1}\b", v, r)
text = text.replace(ph, r)
return text
-52
View File
@@ -1,52 +0,0 @@
# Adapted from ComfyUI-ppm into hook form
import comfy.model_management
import comfy.patcher_extension
from comfy.model_base import Anima
from comfy.model_patcher import ModelPatcher
from comfy_api.latest import io
from .anima_couple import (
anima_forward_wrapper,
anima_sample_wrapper,
)
class PCAnimaAttnCouplePatch(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="PCAnimaAttnCouplePatch",
display_name="PC: Anima attention Couple Model Patch",
category="promptcontrol/experimental",
inputs=[
io.Model.Input("model"),
],
outputs=[
io.Model.Output(),
],
)
@classmethod
def execute(cls, model: ModelPatcher) -> io.NodeOutput:
model_type = type(model.model)
m = model
if issubclass(model_type, Anima):
m = model.clone()
m.add_wrapper_with_key(
comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL,
cls.__name__,
anima_forward_wrapper,
)
m.add_wrapper_with_key(
comfy.patcher_extension.WrappersMP.SAMPLER_SAMPLE,
cls.__name__,
anima_sample_wrapper,
)
return io.NodeOutput(m)
NODES = [PCAnimaAttnCouplePatch]
+2 -4
View File
@@ -2,7 +2,6 @@ import logging
from comfy_api.latest import io
from .macros import expand_segs
from .prompts import encode_prompt
log = logging.getLogger("comfyui-prompt-control")
@@ -26,11 +25,10 @@ class PCTextEncodeWithRange(io.ComfyNode):
)
@classmethod
def execute(cls, clip, text, start=0.0, end=1.0) -> io.NodeOutput:
def execute(cls, clip, text, start=0.0, end=1.0) -> io.NodeOutput: # ty: ignore[invalid-method-override]
log.debug("PCTextEncode: Encoding '%s'", text)
defaults = clip.patcher.model_options.get("x-promptcontrol.defaults", {})
masks = clip.patcher.model_options.get("x-promptcontrol.masks", None)
text = expand_segs(text)
out = encode_prompt(clip, text, start, end, defaults, masks)
return io.NodeOutput(out)
@@ -51,7 +49,7 @@ class PCTextEncode(io.ComfyNode):
)
@classmethod
def execute(cls, clip, text) -> io.NodeOutput:
def execute(cls, clip, text) -> io.NodeOutput: # ty: ignore[invalid-method-override]
# Use the WithRange node for the range 0.0, 1.0
return PCTextEncodeWithRange.execute(clip, text, 0.0, 1.0)
+2 -2
View File
@@ -29,7 +29,7 @@ class PCLoraHooksFromText(io.ComfyNode):
)
@classmethod
def execute(cls, text) -> io.NodeOutput:
def execute(cls, text) -> io.NodeOutput: # ty: ignore[invalid-method-override]
prompt_schedule = parse_prompt_schedules(text)
consolidated = consolidate_schedule(prompt_schedule)
hooks = lora_hooks_from_schedule(consolidated, {})
@@ -104,7 +104,7 @@ class PCAttentionCoupleBatchNegative(io.ComfyNode):
@classmethod
@override
def execute(cls, positive, negative) -> io.NodeOutput:
def execute(cls, positive, negative) -> io.NodeOutput: # ty: ignore[invalid-method-override]
if len(negative) != 1:
log.warning("Batching scheduled negatives is not supported yet")
return io.NodeOutput(positive, negative)
+6 -3
View File
@@ -3,16 +3,20 @@ from __future__ import annotations
import json
import logging
import os
from comfy_api.latest import io
from comfy_execution.graph import ExecutionBlocker
from comfy_execution.graph_utils import GraphBuilder
from .macros import expand_macros, expand_segs
from .parser import parse_prompt_schedules
from .utils import consolidate_schedule, find_nonscheduled_loras, get_function
log = logging.getLogger("comfyui-prompt-control")
if os.environ.get("PC_USE_NEW_PARSER", "0") == "1":
log.info("Using new parsy parser")
from .parser_parsy import parse_prompt_schedules as parse_prompt_schedules
else:
from .parser import parse_prompt_schedules
def create_lora_loader_nodes(graph, model, clip, loras):
@@ -159,7 +163,6 @@ class PCLazyLoraLoaderAdvanced(io.ComfyNode):
@classmethod
def execute(cls, model=None, clip=None, text="", apply_hooks=True, tags="", start=0.0, end=1.0, num_steps=0):
text = expand_segs(expand_macros(text))
schedule = parse_prompt_schedules(text, filters=tags, start=start, end=end, num_steps=num_steps)
graph = GraphBuilder()
r = build_lora_schedule(graph, schedule, model, clip, apply_hooks=apply_hooks)
+5 -18
View File
@@ -2,10 +2,7 @@ import logging
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 .parser import parse_prompt_schedules
from .parser import expand_macros, parse_prompt_schedules
log = logging.getLogger("comfyui-prompt-control")
@@ -134,32 +131,22 @@ class PCExtractScheduledPrompt(io.ComfyNode):
def define_schema(cls):
return io.Schema(
node_id="PCExtractScheduledPrompt",
display_name="PC: Show Prompt",
display_name="PC: Extract Scheduled Prompt",
category="promptcontrol/tools",
description="Parses the input prompt and returns the prompt scheduled at the specified point",
inputs=[
io.String.Input("text", multiline=True),
io.Float.Input("at", min=0.0, max=1.0, default=1.0, step=0.01),
io.String.Input("tags", default="", optional=True),
io.Boolean.Input("expand_segs", default=False, optional=True),
io.Boolean.Input("expand_subs", default=False, optional=True),
io.Boolean.Input("expand_macros", default=False, optional=True),
],
outputs=[io.String.Output()],
search_aliases=["extract scheduled prompt"],
)
@classmethod
def execute(cls, text, at, tags="", expand_segs=False, expand_subs=False, expand_macros=False) -> io.NodeOutput:
if expand_macros:
text = macroexpand(text)
def execute(cls, text, at, tags="") -> io.NodeOutput:
schedule = parse_prompt_schedules(text, filters=tags)
_, entry = schedule.at_step(at)
_, entry = schedule.at_step(at, total_steps=1)
prompt_text = entry.get("prompt", "")
if expand_segs:
prompt_text = segexpand(prompt_text, do_subs=expand_subs)
if expand_subs:
prompt_text = subexpand(prompt_text)
return io.NodeOutput(prompt_text)
@@ -179,7 +166,7 @@ class PCMacroExpand(io.ComfyNode):
@classmethod
def execute(cls, text) -> io.NodeOutput:
return io.NodeOutput(macroexpand(text))
return io.NodeOutput(expand_macros(text))
NODES = [
+364 -7
View File
@@ -1,10 +1,367 @@
import logging
import os
# vim: sw=4 ts=4
from __future__ import annotations
import logging
from functools import lru_cache
from math import ceil
import lark
from .macros import expand_macros
logging.basicConfig()
log = logging.getLogger("comfyui-prompt-control")
if os.environ.get("PC_USE_OLD_PARSER", "0") != "1":
from .parser_parsy import parse_prompt_schedules # noqa
else:
log.warning("Using old Lark parser (UNSUPPORTED)")
from .parser_lark import parse_prompt_schedules # noqa
if lark.__version__ == "0.12.0":
from sys import executable
x = "\n".join(
[
"Your lark package reports an ancient version (0.12.0) and will not work.",
"If you have the 'lark-parser' package in your Python environment, remove that and *reinstall* lark!",
f"{executable} -m pip uninstall lark-parser lark",
f"{executable} -m pip install lark",
]
)
log.error(x)
raise ImportError(x)
ESCAPES = [
("XxPCBackslashESCAPExX", "\\"),
("XxPCColonESCAPExX", ":"),
("XxPCCommentESCAPExX", "#"),
]
def escape_specials(string: str) -> str:
for ph, c in ESCAPES:
string = string.replace(rf"\{c}", ph)
return string
def restore_escaped(string: str) -> str:
for ph, c in ESCAPES:
string = string.replace(ph, c)
return string
def remove_comments(string: str) -> str:
r = []
for line in string.split("\n"):
comment = line.find("#")
if comment >= 0:
r.append(line[:comment])
else:
r.append(line)
return "\n".join(r)
prompt_parser = lark.Lark(
r"""
!start: (prompt | /[][():|]/+)*
prompt: (emphasized | embedding | scheduled | alternate | sequence | loraspec | PLAIN | | /\\:/ | /</ | />/ | WHITESPACE)+
!emphasized: "(" prompt? ")"
| "(" prompt ":" prompt ")"
| "[" prompt "]"
promptlist: ([prompt] ":")~1..3
scheduled: "[" promptlist _WS? NUMBER ["," NUMBER] "]"
| "[" promptlist _WS? TAG "]"
sequence.5: "[SEQ" ":" [prompt] ":" NUMBER (":" [prompt] ":" NUMBER)* "]"
alternate: "[" [prompt] ("|" [prompt])+ [":" NUMBER] "]"
loraspec.99: "<lora:" FILENAME lora_weights [lora_block_weights] ">"
lora_weights.1: (":" _WS? NUMBER)~1..2
lora_block_weights.-1: ":" PLAIN
embedding.100: "<emb:" FILENAME ">"
WHITESPACE: /\s+/
_WS: WHITESPACE
PLAIN: /([^<>\\\[\]():|]|\\.)+/
FILENAME: /[^<>:]+/
TAG: /[A-Z_]+/
%import common.SIGNED_NUMBER -> NUMBER
""",
lexer="dynamic",
)
def flatten(x):
if type(x) in [str, tuple, int, type(None)] or isinstance(x, dict) and "type" in x:
yield x
else:
for g in x:
yield from flatten(g)
def clamp(a, b, c):
"""clamp b between a and c"""
return min(max(a, b), c)
def get_steps(tree, num_steps):
res = [num_steps or 100]
def tostep(s):
steps = num_steps or 100
if "." in str(s) or not num_steps:
w = float(s)
value = w * steps
else:
w = int(s)
value = w
if w > 1 and not num_steps:
log.warning(
"You haven't configured the number of steps for Prompt Control to use, %s will be clipped to 1.0", w
)
value = steps
return int(clamp(0, value, steps))
class CollectSteps(lark.Visitor):
def scheduled(self, tree):
i = tree.children[-1]
if i and i.type == "TAG":
return
for i in [-1, -2]:
if tree.children[i] is not None:
tree.children[i] = tostep(tree.children[i])
res.append(tree.children[i])
def interp_steps(self, tree):
tree.children[-1] = tostep(tree.children[-1] or 0.1)
for i, _ in enumerate(tree.children[:-1]):
tree.children[i] = tostep(tree.children[i])
res.extend(tree.children[:-1])
def sequence(self, tree):
steps = tree.children[1::2]
for i, _ in enumerate(steps):
w = tostep(tree.children[i * 2 + 1])
tree.children[i * 2 + 1] = w
res.append(w)
def alternate(self, tree):
step_size = tostep(round(float(tree.children[-1] or 0.1), 2))
tree.children[-1] = step_size
res.extend([x for x in range(step_size, num_steps or 100, step_size)])
CollectSteps().visit(tree)
return sorted(set(res))
def at_step(step, filters, tree):
class AtStep(lark.Transformer):
def scheduled(self, args):
before = None
during = None
after = None
when_end = None
pl, when, *rest = args
if rest:
when_end = rest[0]
pl = list(pl)
if len(pl) == 1:
(during,) = pl # [after:0.5] == [::after:0.5,0.5]
if when_end is None:
when_end = when
after = during
elif len(pl) == 2:
during, after = pl # [during:after:0.5] = [before::after:0.5,0.5]
if when_end is None:
when_end = when
before = during
else:
before, during, after = pl # [before:during:after:0.5,0.8]
if isinstance(when, str):
return before or "" if when not in filters else after or ""
if when_end is None:
when_end = 1000_000
if step <= when:
return before or ""
if when < step <= when_end:
return during or ""
else:
return after or ""
def sequence(self, args):
previous_step = 0.0
prompts = args[::2]
steps = args[1::2]
for s, p in zip(steps, prompts, strict=False):
if s >= step and step >= previous_step:
previous_step = step
return p or ""
else:
previous_step = s
return ""
def alternate(self, args):
step_size = args[-1]
idx = ceil(step / step_size)
return args[(idx - 1) % (len(args) - 1)] or ""
def start(self, args):
prompt = []
loraspecs = {}
args = flatten(args)
for a in args:
if isinstance(a, str):
prompt.append(a)
elif isinstance(a, tuple):
# sum identical specs together
n = a[0]
# if clip weight is not provided, use unet weight
w, w_clip = a[1][0], a[1][1 % len(a[1])]
e = loraspecs.get(n, {})
loraspecs[n] = {
"weight": round(e.get("weight", 0.0) + w, 2),
"weight_clip": round(e.get("weight_clip", 0.0) + w_clip, 2),
}
lbw = a[2]
if lbw:
loraspecs[n]["lbw"] = lbw
if loraspecs[n]["weight"] == 0 and loraspecs[n]["weight_clip"] == 0 and not lbw:
del loraspecs[n]
else:
pass
p = "".join(prompt)
return {"prompt": p, "loras": loraspecs}
def PLAIN(self, args):
return restore_escaped(args)
def FILENAME(self, value):
return str(value)
def embedding(self, args):
return "embedding:" + str(args[0])
def lora_weights(self, args):
return [float(str(a)) for a in args]
def lora_block_weights(self, args):
vals = args[0].split(";")
r = {}
for v in vals:
x = v.split("=", 2)
if len(x) != 2:
continue
k, v = x[0].strip().upper(), x[1].strip()
r[k] = v
return r
def loraspec(self, args):
name = args[0]
params = args[1]
lbw = args[2]
return name, params, lbw
def __default__(self, data, children, meta):
return children
return AtStep().transform(tree)
class PromptSchedule:
# 0 num_steps means unconfigured
def __init__(self, prompt, filters="", start=0.0, end=1.0, num_steps=0):
self.filters = filters
self.start = start
self.end = end
self.num_steps = num_steps
# placeholder is restored on parse
self.prompt = remove_comments(escape_specials(prompt.strip()))
self.defaults = {}
self.loaded_loras = {}
self.parsed_prompt = self._parse(num_steps)
def __iter__(self):
# Filter out zero, it's only useful for interpolation
return (x for x in self.parsed_prompt if x[0] != 0)
def _parse(self, num_steps):
filters = [x.strip() for x in self.filters.upper().split(",")]
try:
parsed = []
tree = prompt_parser.parse(self.prompt)
steps = get_steps(tree, num_steps=num_steps)
def f(x):
return round(x / (num_steps or 100), 2)
for t in steps:
p = at_step(t, filters, tree)
parsed.append([f(t), p])
except lark.exceptions.LarkError as e:
log.error("Prompt editing parse error: %s", e)
parsed = [[1.0, {"prompt": self.prompt, "loras": {}}]]
raise
# Tag filtering may return redundant prompts, so filter them out here
res = []
prev_end = -1
for end_at, p in parsed:
if end_at < self.start:
continue
elif end_at <= self.end:
res.append([end_at, p])
prev_end = end_at
elif end_at > self.end and prev_end < self.end:
res.append([end_at, p])
break
# Always use the last prompt if everything was filtered
if len(res) == 0:
res = [[1.0, parsed[-1][1]]]
final = [res[0]]
# Clean up duplicates
for p in res[1:]:
if p[1] != final[-1][1]:
final.append(p)
else:
final[-1][0] = p[0]
return final
def clone(self):
return self.with_filters()
def with_filters(self, filters=None, start=None, end=None, defaults=None):
def ifspecified(x, defval):
return x if x is not None else defval
p = PromptSchedule(
self.prompt,
filters=ifspecified(filters, self.filters),
start=ifspecified(start, self.start),
end=ifspecified(end, self.end),
num_steps=self.num_steps,
)
return p
def at_step(self, step, total_steps=1):
_, x = self.at_step_idx(step, total_steps)
return x
def at_step_idx(self, step, total_steps=1):
for i, x in enumerate(self.parsed_prompt):
if x[0] * total_steps >= step:
return i, x
return len(self.parsed_prompt) - 1, self.parsed_prompt[-1]
@lru_cache
def parse_prompt_schedules(prompt, **kwargs):
prompt = expand_macros(prompt)
return PromptSchedule(prompt, **kwargs)
-359
View File
@@ -1,359 +0,0 @@
# vim: sw=4 ts=4
from __future__ import annotations
import logging
from functools import lru_cache
from math import ceil
import lark
from .macros import expand_macros
from .utils import flatten
log = logging.getLogger("comfyui-prompt-control")
if lark.__version__ == "0.12.0":
from sys import executable
x = "\n".join(
[
"Your lark package reports an ancient version (0.12.0) and will not work.",
"If you have the 'lark-parser' package in your Python environment, remove that and *reinstall* lark!",
f"{executable} -m pip uninstall lark-parser lark",
f"{executable} -m pip install lark",
]
)
log.error(x)
raise ImportError(x)
ESCAPES = [
("XxPCBackslashESCAPExX", "\\"),
("XxPCColonESCAPExX", ":"),
("XxPCCommentESCAPExX", "#"),
]
def escape_specials(string: str) -> str:
for ph, c in ESCAPES:
string = string.replace(rf"\{c}", ph)
return string
def restore_escaped(string: str) -> str:
for ph, c in ESCAPES:
string = string.replace(ph, c)
return string
def remove_comments(string: str) -> str:
r = []
for line in string.split("\n"):
comment = line.find("#")
if comment >= 0:
r.append(line[:comment])
else:
r.append(line)
return "\n".join(r)
prompt_parser = lark.Lark(
r"""
!start: (prompt | /[][():|]/+)*
prompt: (emphasized | embedding | scheduled | alternate | sequence | loraspec | PLAIN | | /\\:/ | /</ | />/ | WHITESPACE)+
!emphasized: "(" prompt? ")"
| "(" prompt ":" prompt ")"
| "[" prompt "]"
promptlist: ([prompt] ":")~1..3
scheduled: "[" promptlist _WS? NUMBER ["," NUMBER] "]"
| "[" promptlist _WS? TAG "]"
sequence.5: "[SEQ" ":" [prompt] ":" NUMBER (":" [prompt] ":" NUMBER)* "]"
alternate: "[" [prompt] ("|" [prompt])+ [":" NUMBER] "]"
loraspec.99: "<lora:" FILENAME lora_weights [lora_block_weights] ">"
lora_weights.1: (":" _WS? NUMBER)~1..2
lora_block_weights.-1: ":" PLAIN
embedding.100: "<emb:" FILENAME ">"
WHITESPACE: /\s+/
_WS: WHITESPACE
PLAIN: /([^<>\\\[\]():|]|\\.)+/
FILENAME: /[^<>:]+/
TAG: /[A-Z_]+/
%import common.SIGNED_NUMBER -> NUMBER
""",
lexer="dynamic",
)
def clamp(a, b, c):
"""clamp b between a and c"""
return min(max(a, b), c)
def get_steps(tree, num_steps):
res = [num_steps or 100]
def tostep(s):
steps = num_steps or 100
if "." in str(s) or not num_steps:
w = float(s)
value = w * steps
else:
w = int(s)
value = w
if w > 1 and not num_steps:
log.warning(
"You haven't configured the number of steps for Prompt Control to use, %s will be clipped to 1.0", w
)
value = steps
return int(clamp(0, value, steps))
class CollectSteps(lark.Visitor):
def scheduled(self, tree):
i = tree.children[-1]
if i and i.type == "TAG":
return
for i in [-1, -2]:
if tree.children[i] is not None:
tree.children[i] = tostep(tree.children[i])
res.append(tree.children[i])
def interp_steps(self, tree):
tree.children[-1] = tostep(tree.children[-1] or 0.1)
for i, _ in enumerate(tree.children[:-1]):
tree.children[i] = tostep(tree.children[i])
res.extend(tree.children[:-1])
def sequence(self, tree):
steps = tree.children[1::2]
for i, _ in enumerate(steps):
w = tostep(tree.children[i * 2 + 1])
tree.children[i * 2 + 1] = w
res.append(w)
def alternate(self, tree):
step_size = tostep(round(float(tree.children[-1] or 0.1), 2))
tree.children[-1] = step_size
res.extend([x for x in range(step_size, num_steps or 100, step_size)])
CollectSteps().visit(tree)
return sorted(set(res))
def at_step(step, filters, tree):
class AtStep(lark.Transformer):
def scheduled(self, args):
before = None
during = None
after = None
when_end = None
pl, when, *rest = args
if rest:
when_end = rest[0]
pl = list(pl)
if len(pl) == 1:
(during,) = pl # [after:0.5] == [::after:0.5,0.5]
if when_end is None:
when_end = when
after = during
elif len(pl) == 2:
during, after = pl # [during:after:0.5] = [before::after:0.5,0.5]
if when_end is None:
when_end = when
before = during
else:
before, during, after = pl # [before:during:after:0.5,0.8]
if isinstance(when, str):
return before or "" if when not in filters else after or ""
if when_end is None:
when_end = 1000_000
if step <= when:
return before or ""
if when < step <= when_end:
return during or ""
else:
return after or ""
def sequence(self, args):
previous_step = 0.0
prompts = args[::2]
steps = args[1::2]
for s, p in zip(steps, prompts, strict=False):
if s >= step and step >= previous_step:
previous_step = step
return p or ""
else:
previous_step = s
return ""
def alternate(self, args):
step_size = args[-1]
idx = ceil(step / step_size)
return args[(idx - 1) % (len(args) - 1)] or ""
def start(self, args):
prompt = []
loraspecs = {}
args = flatten(args)
for a in args:
if isinstance(a, str):
prompt.append(a)
elif isinstance(a, tuple):
# sum identical specs together
n = a[0]
# if clip weight is not provided, use unet weight
w, w_clip = a[1][0], a[1][1 % len(a[1])]
e = loraspecs.get(n, {})
loraspecs[n] = {
"weight": round(e.get("weight", 0.0) + w, 2),
"weight_clip": round(e.get("weight_clip", 0.0) + w_clip, 2),
}
lbw = a[2]
if lbw:
loraspecs[n]["lbw"] = lbw
if loraspecs[n]["weight"] == 0 and loraspecs[n]["weight_clip"] == 0 and not lbw:
del loraspecs[n]
else:
pass
p = "".join(prompt)
return {"prompt": p, "loras": loraspecs}
def PLAIN(self, args):
return restore_escaped(args)
def FILENAME(self, value):
return str(value)
def embedding(self, args):
return "embedding:" + str(args[0])
def lora_weights(self, args):
return [float(str(a)) for a in args]
def lora_block_weights(self, args):
vals = args[0].split(";")
r = {}
for v in vals:
x = v.split("=", 2)
if len(x) != 2:
continue
k, v = x[0].strip().upper(), x[1].strip()
r[k] = v
return r
def loraspec(self, args):
name = args[0]
params = args[1]
lbw = args[2]
return name, params, lbw
def __default__(self, data, children, meta):
return children
return AtStep().transform(tree)
class PromptSchedule:
# 0 num_steps means unconfigured
def __init__(self, prompt, filters="", start=0.0, end=1.0, num_steps=0):
self.filters = filters
self.start = start
self.end = end
self.num_steps = num_steps
# placeholder is restored on parse
self.prompt = remove_comments(escape_specials(prompt.strip()))
self.defaults = {}
self.loaded_loras = {}
self.parsed_prompt = self._parse(num_steps)
def __iter__(self):
# Filter out zero, it's only useful for interpolation
return (x for x in self.parsed_prompt if x[0] != 0)
def _parse(self, num_steps):
filters = [x.strip() for x in self.filters.upper().split(",")]
try:
parsed = []
tree = prompt_parser.parse(self.prompt)
steps = get_steps(tree, num_steps=num_steps)
def f(x):
return round(x / (num_steps or 100), 2)
for t in steps:
p = at_step(t, filters, tree)
parsed.append([f(t), p])
except lark.exceptions.LarkError as e:
log.error("Prompt editing parse error: %s", e)
parsed = [[1.0, {"prompt": self.prompt, "loras": {}}]]
raise
# Tag filtering may return redundant prompts, so filter them out here
res = []
prev_end = -1
for end_at, p in parsed:
if end_at < self.start:
continue
elif end_at <= self.end:
res.append([end_at, p])
prev_end = end_at
elif end_at > self.end and prev_end < self.end:
res.append([end_at, p])
break
# Always use the last prompt if everything was filtered
if len(res) == 0:
res = [[1.0, parsed[-1][1]]]
final = [res[0]]
# Clean up duplicates
for p in res[1:]:
if p[1] != final[-1][1]:
final.append(p)
else:
final[-1][0] = p[0]
return final
def clone(self):
return self.with_filters()
def with_filters(self, filters=None, start=None, end=None, defaults=None):
def ifspecified(x, defval):
return x if x is not None else defval
p = PromptSchedule(
self.prompt,
filters=ifspecified(filters, self.filters),
start=ifspecified(start, self.start),
end=ifspecified(end, self.end),
num_steps=self.num_steps,
)
return p
def at_step(self, step, total_steps=1):
_, x = self.at_step_idx(step, total_steps)
return x
def at_step_idx(self, step, total_steps=1):
for i, x in enumerate(self.parsed_prompt):
if x[0] * total_steps >= step:
return i, x
return len(self.parsed_prompt) - 1, self.parsed_prompt[-1]
@lru_cache
def parse_prompt_schedules(prompt, **kwargs):
prompt = expand_macros(prompt)
return PromptSchedule(prompt, **kwargs)
+2 -7
View File
@@ -348,14 +348,9 @@ non_special = regex(r"[^:\[\]()|\\<>#]+").map(Text)
filename = regex(r"[^:<>]+")
comment = string("#") >> any_char.until(eof | char_from("\n")) >> success(empty)
escape = (string("\\") >> char_from("\\[]:#") | string(r"\(") | string(r"\)")).map(Text)
escape = (string("\\") >> char_from("\\[]:#")).map(Text)
emphasis = seq(lpar, (prompt | col).at_least(0), rpar)
sign = string("+") | string("-")
number = (
(sign.optional("") + (digit.many() + string(".") * 1 + digit.many() | digit.at_least(1)).concat())
.concat()
.map(float)
)
number = (digit.at_least(1) + string(".") * 1 + digit.many() | digit.at_least(1)).concat().map(float)
opt_prompt = prompt.optional(empty)
step_range = seq(number | tag, (comma >> number).optional())
+2 -2
View File
@@ -241,7 +241,7 @@ def encode_prompt_segment(
can_break = {}
for k in empty:
tokenizer = getattr(clip.tokenizer, f"clip_{k}", getattr(clip.tokenizer, k, None))
can_break[k] = tokenizer and getattr(tokenizer, "pad_to_max_length", False)
can_break[k] = tokenizer and tokenizer.pad_to_max_length
clip = hook_te(clip, empty.keys(), style, normalization, extra)
@@ -604,7 +604,7 @@ def encode_prompt(clip, text, start_pct, end_pct, defaults, masks):
return f"MASK({args[0]})"
for prompt in prompts:
prompt, noise_w, generator = get_noise(prompt)
text, noise_w, generator = get_noise(text)
base_prompt, attn_couple_prompts = split_by_function(prompt, "COUPLE", defaults=None, require_args=False)
prompts = [base_prompt] + [couple_mask(f.args) + chunk for (chunk, f) in attn_couple_prompts]
-8
View File
@@ -35,14 +35,6 @@ except ImportError:
log = logging.getLogger("comfyui-prompt-control")
def flatten(x):
if type(x) in [str, tuple, int, type(None)] or isinstance(x, dict) and "type" in x:
yield x
else:
for g in x:
yield from flatten(g)
def call_node(cls, *args, **kwargs):
if hasattr(cls, "execute"):
# v3 node
+4 -2
View File
@@ -1,8 +1,10 @@
[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"
description = "Provides nodes for prompt editing and LoRA scheduling, advanced regional prompting (including attention masking) and more, all controlled through your text prompt"
version = "2.1.2"
license = { file = "LICENSE" }
# some lark versions older than 1.1.9 apparently have a bug that breaks things, see https://github.com/asagi4/comfyui-prompt-control/issues/35
dependencies = ["lark >= 1.1.9"]
requires-python = ">= 3.10"
+2
View File
@@ -0,0 +1,2 @@
# some lark versions older than 1.1.9 apparently have a bug that breaks things, see https://github.com/asagi4/comfyui-prompt-control/issues/35
lark >= 1.1.9
-22
View File
@@ -73,16 +73,6 @@ def tensors_equal(t1, t2):
npt.assert_equal(t1.detach().numpy(), t2.detach().numpy())
def cond_neq(c1, c2, key=None, key_assert=None):
ok = False
try:
cond_equal(c1, c2, key=key, key_assert=key_assert)
except AssertionError:
ok = True
if not ok:
raise ValueError("Tensors should not be equal")
def cond_equal(c1, c2, key=None, key_assert=None):
assert len(c1) == len(c2)
for i in range(len(c1)):
@@ -251,15 +241,3 @@ class TestPCTextEncode:
(c2,) = run(pc_text_encode, clip, "test COUPLE MASK(0 0.2, 0.5) prompt1")
cond_equal(c, c2)
cond_equal(c, c2, "hooks", compare_hookgroup_mask)
def test_noise_weight0(self, text_encoder_clips, pc_text_encode, node_class_objs):
for _k, clip in text_encoder_clips:
(c1,) = run(pc_text_encode, clip, "test")
(c2,) = run(pc_text_encode, clip, "test NOISE(0, 0)")
cond_equal(c1, c2)
def test_noise(self, text_encoder_clips, pc_text_encode, node_class_objs):
for _k, clip in text_encoder_clips:
(c1,) = run(pc_text_encode, clip, "test")
(c2,) = run(pc_text_encode, clip, "test NOISE(1, 0)")
cond_neq(c1, c2)
-72
View File
@@ -1,72 +0,0 @@
from textwrap import dedent
import pytest
from prompt_control.macros import expand_macros, expand_segs
@pytest.mark.parametrize(
"text, result",
[
("DEF(X(a;b)=$1 $2 $3 d)X(A) X(A;B;C)", "A b $3 d A B C d"),
(
"DEF(MACRO()=[empty:$1:$2])MACRO MACRO(;) MACRO(;0.5) MACRO(a;0.5)",
"[empty::$2] [empty::] [empty::0.5] [empty:a:0.5]",
),
("DEF(X=$1)DEF(Y()=$1)[X Y][X() Y()][X(1) Y(1)]", "[$1 ][ ][1 1]"),
(
"DEF(C_ANIMAL=cat)DEF(D_ANIMAL=dog)DEF(IT=It is a $1_ANIMAL $10_ANIMAL)IT(D) IT(C)",
"It is a dog $10_ANIMAL It is a cat $10_ANIMAL",
),
],
)
def test_basic_macro(text, result):
assert expand_macros(text) == result
def test_macro_recursion():
with pytest.raises(ValueError) as c:
expand_macros("DEF(X=recurse Y) DEF(Y=recurse X) X")
assert "Unable to resolve DEFs" in str(c.value)
@pytest.mark.parametrize(
"input, output",
[
(
"""\
A red $b and
a blue $a
SEG(a)
cat
SEG(b)
dog
SEG(c)""",
"A red dog and\na blue cat",
),
(
"""\
$a and $b
SEG(a)
cat, $b
SEG(b)
dog, $c
SEG(c)
tiger
""",
"cat, dog, tiger and dog, tiger",
),
(
"""\
$a
SEG(a)
a $b
SEG(b)
b $a""",
"a b a b $a",
),
],
)
def test_segments(input, output):
assert expand_segs(dedent(input)) == output
+23 -13
View File
@@ -2,6 +2,10 @@ import os
import pytest
from prompt_control.parser import expand_macros
from prompt_control.parser import parse_prompt_schedules as old_parse # noqa
from prompt_control.parser_parsy import parse_prompt_schedules as new_parse # noqa
def lora_dict(*loras):
return {lora: {"weight": unet, "weight_clip": te} for lora, unet, te in loras}
@@ -23,13 +27,9 @@ parsers_to_test = os.environ.get("PC_PARSERS_TO_TEST", "new").split()
params = []
if "old" in parsers_to_test:
from prompt_control.parser_lark import parse_prompt_schedules as old_parse # noqa
params.append(old_parse)
if "new" in parsers_to_test:
from prompt_control.parser_parsy import parse_prompt_schedules as new_parse # noqa
params.append(new_parse)
@@ -111,9 +111,9 @@ def test_basic_ok(parse):
@pytest.mark.parametrize("step", [0, 0.5, 1])
def test_lora(step, parse):
p = parse("This is a (lora:0.6) (prompt) with [no scheduling] features <lora:foo:0.5> <lora:bar:0.5:-1.0>")
p = parse("This is a (lora:0.6) (prompt) with [no scheduling] features <lora:foo:0.5> <lora:bar:0.5:1.0>")
expected = prompt(
1.0, "This is a (lora:0.6) (prompt) with [no scheduling] features ", ("foo", 0.5, 0.5), ("bar", 0.5, -1.0)
1.0, "This is a (lora:0.6) (prompt) with [no scheduling] features ", ("foo", 0.5, 0.5), ("bar", 0.5, 1.0)
)
assert prompts_match(p.at_step(step), expected)
@@ -221,6 +221,23 @@ def test_def(parse):
p2 = parse("[(test):(test:0.7):0.7] [(test):(test:0.5):0.5]")
assert p.parsed_prompt == p2.parsed_prompt
p = expand_macros("DEF(X(a;b)=$1 $2 $3 d)X(A) X(A;B;C)")
assert p == "A b $3 d A B C d"
p = expand_macros("DEF(MACRO()=[empty:$1:$2])MACRO MACRO(;) MACRO(;0.5) MACRO(a;0.5)")
assert p == "[empty::$2] [empty::] [empty::0.5] [empty:a:0.5]"
p = expand_macros("DEF(X=$1)DEF(Y()=$1)[X Y][X() Y()][X(1) Y(1)]")
assert p == "[$1 ][ ][1 1]"
p = parse("DEF(test(1)=prompt $1)DEF(test2((a); (test))=[$1:$2:0.5])test test2")
p2 = parse("prompt 1 [(a):(prompt 1):0.5]")
assert p.parsed_prompt == p2.parsed_prompt
with pytest.raises(ValueError) as c:
expand_macros("DEF(X=recurse Y) DEF(Y=recurse X) X")
assert "Unable to resolve DEFs" in str(c.value)
@pytest.mark.parametrize(
"text, cases",
@@ -232,7 +249,6 @@ def test_def(parse):
),
(r"[a\:b\\:c:0.5]", [(0.0, 0.5, "a:b\\"), (0.55, 1, r"c")]),
(r"[a:\#b:0.5]", [(0.0, 0.5, "a"), (0.55, 1, "#b")]),
(r"[a:b \(test\):0.2]", [(0, 0.2, r"a"), (0.25, 1, r"b \(test\)")]),
],
)
def test_escapes(text, cases, parse):
@@ -342,12 +358,6 @@ def test_cornercase_corrected(parse):
assert p.parsed_prompt[1:] == p2.parsed_prompt
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>")
assert p.parsed_prompt == p2.parsed_prompt
def test_alternating_lora(parse):
p4 = parse("[cat|[dog:wolf<lora:canine:1>:0.5]:0.2]")
for i, (text, *_loras) in enumerate(
-139
View File
@@ -1,139 +0,0 @@
{
"1": {
"inputs": {
"text": "positive prompt",
"clip": [
"4",
1
]
},
"class_type": "PCLazyTextEncode",
"_meta": {
"title": "PC: Schedule prompt"
}
},
"2": {
"inputs": {
"ckpt_name": "$TEST_CHECKPOINT"
},
"class_type": "CheckpointLoaderSimple",
"_meta": {
"title": "Load Checkpoint"
}
},
"3": {
"inputs": {
"seed": 0,
"steps": 8,
"cfg": 3,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": [
"4",
0
],
"positive": [
"9",
0
],
"negative": [
"9",
1
],
"latent_image": [
"5",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"4": {
"inputs": {
"text": "<lora:$TEST_LORA:1>",
"model": [
"2",
0
],
"clip": [
"2",
1
]
},
"class_type": "PCLazyLoraLoader",
"_meta": {
"title": "PC: Schedule LoRAs"
}
},
"5": {
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
},
"class_type": "EmptyLatentImage",
"_meta": {
"title": "Empty Latent Image"
}
},
"6": {
"inputs": {
"samples": [
"3",
0
],
"vae": [
"2",
2
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"7": {
"inputs": {
"images": [
"6",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "Preview Image"
}
},
"8": {
"inputs": {
"text": "worst quality,",
"clip": [
"4",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
},
"9": {
"inputs": {
"positive": [
"1",
0
],
"negative": [
"8",
0
]
},
"class_type": "PCAttentionCoupleBatchNegative",
"_meta": {
"title": "PC: Attention Couple (batch negative)"
}
}
}
-40
View File
@@ -1,40 +0,0 @@
import json
import os
import uuid
from time import sleep
import pytest
import requests
@pytest.fixture(scope="module", autouse=True)
def workflow(request):
with open(str(request.path).replace(".py", ".json")) as f:
data = f.read()
data = data.replace("$TEST_CHECKPOINT", os.environ["PC_TEST_CHECKPOINT"])
data = data.replace("$TEST_LORA", os.environ["PC_TEST_LORA"])
return json.loads(data)
def assert_prompt(url, p):
timeout = 60
r = requests.post(f"{url}/prompt", json={"prompt": p, "client_id": str(uuid.uuid4())}).json()
prompt_id = r["prompt_id"]
r = {"status": "pending"}
while r["status"] in ["pending", "in_progress"]:
sleep(1)
assert timeout > 0
timeout -= 1
r = requests.get(f"{url}/api/jobs/{prompt_id}").json()
assert r["status"] == "completed"
@pytest.fixture
def comfyui():
return os.environ.get("PC_TEST_COMFYUI", "http://localhost:8188")
def test_workflow(workflow, comfyui):
prompt = "DEF(blue=green)a blue dog and a cat sitting [COUPLE(0 0.5, 0 1) red (cat,:1.3) COUPLE(0.5 1, 0 1) (blue:1.2) dog,:0.1]"
workflow["1"]["inputs"]["text"] = prompt
assert_prompt(comfyui, workflow)