Compare commits
116
Commits
v2.0.0-rc.3
...
v2.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68766215f2 | ||
|
|
287f554a68 | ||
|
|
71465f914c | ||
|
|
d7be7bc29e | ||
|
|
329d4cf95f | ||
|
|
c52ace71aa | ||
|
|
4806cf5959 | ||
|
|
a0ab709f50 | ||
|
|
66fef1ffe8 | ||
|
|
3341e9f81e | ||
|
|
aa6b4608f0 | ||
|
|
d1cc60b00a | ||
|
|
efe8939250 | ||
|
|
39b353b916 | ||
|
|
485bc7f2ab | ||
|
|
1d03ded9dd | ||
|
|
94a4d076e0 | ||
|
|
228dc4b22b | ||
|
|
db523e1f16 | ||
|
|
6b08c7a90e | ||
|
|
76142c4b7e | ||
|
|
1d84fdaf9e | ||
|
|
1c50ae5297 | ||
|
|
51618289e7 | ||
|
|
cea1e5b30f | ||
|
|
55f0574ac7 | ||
|
|
167689cb8b | ||
|
|
2be3abed44 | ||
|
|
f86abb0816 | ||
|
|
a3537a5b2f | ||
|
|
af7e4542d1 | ||
|
|
f37f14b2a2 | ||
|
|
7b8231d36b | ||
|
|
9b5e15fde3 | ||
|
|
d97d30074f | ||
|
|
b2eb9b88ba | ||
|
|
c9459e39f9 | ||
|
|
7507b2b55f | ||
|
|
cf1efecf4c | ||
|
|
ffcf94bcaa | ||
|
|
ec8c40355c | ||
|
|
6e538e0abc | ||
|
|
25c44a1fbb | ||
|
|
72d5490498 | ||
|
|
3de4538326 | ||
|
|
f61af15d52 | ||
|
|
8e59f140ff | ||
|
|
44044e962c | ||
|
|
04f36687c5 | ||
|
|
3a8a360d03 | ||
|
|
d76331315a | ||
|
|
e3a6050536 | ||
|
|
7b001ace7b | ||
|
|
f1de65f257 | ||
|
|
11aaa0ac7b | ||
|
|
85de3ef0d3 | ||
|
|
a73260ff34 | ||
|
|
50a2e0abbf | ||
|
|
5cf45ca264 | ||
|
|
bd4a787400 | ||
|
|
c9e5bc25c3 | ||
|
|
d11ffa6e25 | ||
|
|
8cc73a2e49 | ||
|
|
27ae5f683e | ||
|
|
68cda3663e | ||
|
|
3d46f705b6 | ||
|
|
eec4bc4da9 | ||
|
|
3d3218e831 | ||
|
|
f4e57ec514 | ||
|
|
74f65c1b31 | ||
|
|
4d94cca88f | ||
|
|
4569ecccf9 | ||
|
|
192e6d30d4 | ||
|
|
67112f11e0 | ||
|
|
f761dfac86 | ||
|
|
278e733835 | ||
|
|
2bf65720eb | ||
|
|
99f3af92b7 | ||
|
|
2437cd4daf | ||
|
|
d262a7dc7a | ||
|
|
6c319ad5b4 | ||
|
|
34056cac19 | ||
|
|
8ae436abf1 | ||
|
|
c2ce2ce023 | ||
|
|
7a9e69ec31 | ||
|
|
aaff8dc7da | ||
|
|
3e4722278a | ||
|
|
11cb430396 | ||
|
|
9f1cbfd11c | ||
|
|
5b3a914f1d | ||
|
|
a5ffa1acd7 | ||
|
|
7f8783147b | ||
|
|
3c9b806e5f | ||
|
|
a485c2655a | ||
|
|
f888e69b00 | ||
|
|
b4b0858214 | ||
|
|
7a1cb2cf51 | ||
|
|
fbb6b5c8fa | ||
|
|
6115c095cb | ||
|
|
04d3d2e959 | ||
|
|
e4f64837ef | ||
|
|
4a785b294b | ||
|
|
b3195a6297 | ||
|
|
5c52bffc9d | ||
|
|
ac2d275dfb | ||
|
|
399f992a26 | ||
|
|
672a2a09bb | ||
|
|
e3629961ce | ||
|
|
ddac624ad1 | ||
|
|
99ddfe357e | ||
|
|
110d5248a0 | ||
|
|
61b1ecc88e | ||
|
|
05b0b2ad26 | ||
|
|
5831608c4e | ||
|
|
c815bb44f1 | ||
|
|
e55c50e9d7 |
@@ -4,13 +4,17 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- prompt_control/adv_encode.py
|
||||
- prompt_control/attention_couple_ppm.py
|
||||
- prompt_control/nodes_lazy.py
|
||||
- prompt_control/prompts.py
|
||||
- prompt_control/parser.py
|
||||
- prompt_control/utils.py
|
||||
|
||||
|
||||
jobs:
|
||||
run-graph-tests:
|
||||
name: Run graph tests
|
||||
name: Run tests requiring ComfyUI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
@@ -23,6 +27,16 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
- run: pip install -r requirements.txt -r ComfyUI/requirements.txt
|
||||
- run: PYTHONPATH=ComfyUI python -m prompt_control.test_graph
|
||||
cache: pip
|
||||
- name: install-torch
|
||||
run: pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
- name: install ComfyUI
|
||||
run: pip install -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
|
||||
run: sed -i "s/^cpu_state = CPUState.GPU/cpu_state = CPUState.CPU/g" ComfyUI/comfy/model_management.py
|
||||
- name: Run graph tests
|
||||
run: PYTHONPATH=ComfyUI python -m prompt_control.test_graph
|
||||
- name: Run encoder tests (clip_l only)
|
||||
run: PYTHONPATH=ComfyUI python -m prompt_control.test_encode
|
||||
|
||||
@@ -11,4 +11,15 @@ test:
|
||||
test_graph:
|
||||
PYTHONPATH=../../ python -m prompt_control.test_graph
|
||||
|
||||
test_encode:
|
||||
PYTHONPATH=../../ python -m prompt_control.test_encode --verbose
|
||||
|
||||
test_encode_both:
|
||||
TEST_TE="clip_l t5" PYTHONPATH=../../ python -m prompt_control.test_encode --verbose
|
||||
|
||||
test_heavy: test_graph test_encode_both
|
||||
|
||||
manual_test:
|
||||
PYTHONPATH=../../ python -im prompt_control.manual_test
|
||||
|
||||
.PHONY: check format all
|
||||
|
||||
@@ -10,17 +10,17 @@ A `Basic Text to Image` template is included with the extension, and can be load
|
||||
|
||||
You can use text prompts to control the following:
|
||||
|
||||
- Prompt scheduling and filtering without noodle soup.
|
||||
- LoRA loading and scheduling via ComfyUI's hook system
|
||||
- Masking, composition and area control (regional prompting) with an implementation of Attention Couple, also fully schedulable.
|
||||
- Per-encoder prompts for models with multiple text encoders, such as SDXL and Flux
|
||||
- Prompt operations like `BREAK` and `AND`
|
||||
- Different weight interpretation types (ComfyUI, A1111, compel, etc.)
|
||||
- Prompt masking with an implementation of [cutoff](https://github.com/BlenderNeko/ComfyUI_Cutoff)
|
||||
- Simple prompt macros with `DEF`
|
||||
- And a bunch more
|
||||
- A1111-style prompt scheduling and filtering without noodle soup.
|
||||
- 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
|
||||
- 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)
|
||||
- Simple [prompt macros](/doc/macros.md) with `DEF`
|
||||
|
||||
All features are fully schedulable unless otherwise stated. See the [syntax documentation](doc/syntax.md) for details on how to use each feature.
|
||||
All features are fully schedulable unless otherwise stated. See the [scheduling syntax documentation](doc/schedules.md) to get started.
|
||||
|
||||
If you find prompt scheduling inconvenient for some reason, `PCTextEncode` can be used as a drop-in replacement for `CLIPTextEncode` to get everything else.
|
||||
|
||||
@@ -32,21 +32,6 @@ Prompt Control uses graph generation, and tries to delegate functionality to co
|
||||
|
||||
If you encounter issues as a user or if you're a node developer and Prompt Control somehow breaks something, feel free to file a bug report.
|
||||
|
||||
## Prompt Control v2
|
||||
|
||||
Prompt control has been almost completely rewritten. It now uses ComfyUI's lazy execution to build graphs from the text prompt at runtime. The generated graph is often exactly equivalent to a manually built workflow using native ComfyUI nodes. There are no more weird sampling hooks that could cause problems with other nodes
|
||||
|
||||
### Removed features
|
||||
|
||||
- Prompt interpolation syntax; it was too cumbersome to maintain
|
||||
- LoRA block weight integration; ditto, for now.
|
||||
|
||||
### Everything broke, where are the old nodes?
|
||||
|
||||
If you really need them, you can install the [legacy nodes](https://github.com/asagi4/comfyui-prompt-control-legacy). However, I will not fix bugs in those nodes, and I strongly recommend just migrating your workflows to the new nodes.
|
||||
|
||||
You can have both installed at the same time; none of the nodes conflict.
|
||||
|
||||
## Requirements
|
||||
|
||||
For LoRA scheduling to work, you'll need at least version 0.3.7 of ComfyUI (0.3.36 of ComfyUI desktop).
|
||||
@@ -100,8 +85,4 @@ This node configures `PCTextEncode` default values for some functions by attachi
|
||||
|
||||
- ComfyUI's caching mechanism has an issue that makes it unnecessarily invalidate caches for certain inputs; you'll still get some benefit from the lazy nodes, but changing inputs that shouldn't affect downstream nodes (especially if using filtering) will still cause them to be recomputed because ComfyUI doesn't realize the inputs haven't changed.
|
||||
|
||||
If you want to enable a hack to fix this, set `PROMPTCONTROL_ENABLE_CACHE_HACK=1` in your environment. Unset it to disable.
|
||||
|
||||
It's a purely optional performance optimization that allows Prompt Control nodes to override their cache keys in a way that should not interfere with other nodes. Note that the optimization only works if the text input to the lazy nodes is a constant (so either directly on the node or from a primitive); outputs from other nodes can't be optimized.
|
||||
|
||||
- Cutoff does not work with models that use non-CLIP text encoders, like Flux. This might be fixable, but it's uncertain if cutoff even makes sense for those models.
|
||||
|
||||
+3
-4
@@ -23,13 +23,12 @@ if os.environ.get("PROMPTCONTROL_DEBUG"):
|
||||
else:
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
cache_hack = importlib.import_module(".prompt_control.cache_hack", package=__name__)
|
||||
cache_hack.init()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
nodes = ["base", "lazy", "tools"]
|
||||
WEB_DIRECTORY = "web"
|
||||
|
||||
nodes = ["base", "lazy", "tools", "hooks"]
|
||||
|
||||
for node in nodes:
|
||||
mod = importlib.import_module(f".prompt_control.nodes_{node}", package=__name__)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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.
|
||||
|
||||
By default, the implementation produces slightly different results from Pamparamm's implementation because ComfyUI will only run the hook for conds that have it attached and can't batch negative conditionings.
|
||||
|
||||
As a consequence of this, however, you can also use `COUPLE` in your negative prompt, and it will work correctly.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## Syntax
|
||||
|
||||
See also the [regional prompting documentation](/doc/regional_prompts.md) for information about `MASK` etc.
|
||||
|
||||
### COUPLE: Trigger Attention Couple
|
||||
|
||||
You can use `COUPLE` to attach attention-coupled prompts to a base prompt:
|
||||
|
||||
`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:
|
||||
|
||||
`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.
|
||||
|
||||
- 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.
|
||||
|
||||
For example:
|
||||
```
|
||||
dog FILL() COUPLE(0.5 1) cat
|
||||
```
|
||||
|
||||
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.
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
# Basic Prompt Syntax
|
||||
|
||||
The syntax below documents the features of `PCTextEncode`
|
||||
|
||||
## Combining prompts
|
||||
|
||||
### AND
|
||||
|
||||
`AND` can be used to create "prompt segments". By default, it works as if you had combined the different prompts with `ConditioningCombine`.
|
||||
|
||||
It is also used with regional prompting, see `MASK` and `COUPLE` below.
|
||||
|
||||
Prompts can have a weight at the end:
|
||||
```
|
||||
cat :1 AND dog :2
|
||||
```
|
||||
`AND` is processed after schedule parsing, so you can change the weight mid-prompt: `cat:[1:2:0.5] AND dog`
|
||||
|
||||
The weight defaults to 1. If a prompt's weight is set to 0, it's **skipped entirely.** This can be useful when scheduling to completely disable a prompt:
|
||||
|
||||
```
|
||||
cat [\:0::0.5] AND dog
|
||||
```
|
||||
Note that the `:` needs to be escaped with a `\` or it will be interpreted as scheduling syntax.
|
||||
|
||||
If `AND` is placed inside quotes (eg. `Text saying "CAT AND DOG"`) it will be treated as regular text.
|
||||
|
||||
## Note about processing order
|
||||
|
||||
Prompt operators are processed in the following order, meaning that all features "below" another can be affected by the feature above it. That is, `BREAK` can go inside a `TE()` call, but not `AND` or `CAT`.
|
||||
|
||||
- DEF macros are expanded
|
||||
- Scheduling is expanded, and for each scheduled prompt:
|
||||
- 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
|
||||
- prompts are split by `AVG()` or CAT
|
||||
- the TE() function is evaluated to set per-encoder prompts
|
||||
- BREAK is evaluated
|
||||
- Everything else
|
||||
- Prompts are combined with `ConditioningAverage` (for `AVG`) or `ConditioningConcat` (for `CAT`)
|
||||
- If coupled prompts exist, the base cond is set up for attention coupling and returned
|
||||
- Prompts split with `AND` are combined with `ConditioningCombine`
|
||||
- Each scheduled prompt is restricted to its effective range with `ConditioningSetTimestepRange`
|
||||
|
||||
## Functions
|
||||
|
||||
There are some "functions" that can be included in a prompt to affect how it is interpreted.
|
||||
|
||||
Functions have the form `FUNCNAME(param1, param2, ...)`. How parameters are interpreted is up to the function.
|
||||
|
||||
In general, function parameters will have default values that are used if the parameter is left empty.
|
||||
|
||||
Note: Whitespace is usually *not* stripped from string parameters by default. Commas can be escaped with `\,`
|
||||
|
||||
Like `AND`, functions are parsed after regular scheduling syntax has been expanded, allowing things like `[AREA:MASK:0.3](...)`, in case that's somehow useful.
|
||||
|
||||
like AND, if any function is placed inside quotes, it will *not* activate and is instead treated as regular text.
|
||||
|
||||
### BREAK
|
||||
The keyword `BREAK` causes the prompt to be tokenized in separate chunks, padding each chunk to the text encoder's maximum size before encoding.
|
||||
|
||||
For some text encoders (like t5), this operation doesn't really make sense and BREAKs are simply ignored.
|
||||
|
||||
### CAT
|
||||
|
||||
`CAT` encodes each prompt separately before concatenating the resulting tensors into a single conditioning. It behaves identically to ComfyUI's `ConditioningConcat`.
|
||||
|
||||
### AVG()
|
||||
|
||||
`prompt1 AVG(weight) prompt2` encodes prompt1 and prompt2 separately, and then combines them using `ConditioningAverage`. The default for `weight` is `0.5`.
|
||||
|
||||
`AVG` is processed before `BREAK` but after `AND`
|
||||
|
||||
`p1 AVG() p2 AVG() p3` combines `p1` and `p2` first, then combines the result with `p3`.
|
||||
|
||||
## Prompt weighting (also known as "Advanced CLIP Encode")
|
||||
|
||||
### STYLE
|
||||
|
||||
Use the syntax `STYLE(weight_interpretation, normalization)` in a prompt to affect how prompts are interpreted.
|
||||
|
||||
The weight interpretations available are:
|
||||
- comfy (default)
|
||||
- comfy++
|
||||
- compel
|
||||
- down_weight
|
||||
- A1111
|
||||
- perp
|
||||
|
||||
Normalizations are:
|
||||
- none (default)
|
||||
- length
|
||||
- mean
|
||||
|
||||
The normalization calculations are independent operations and you can combine them with `+`, eg `STYLE(A1111, length+mean)` or `STYLE(comfy, mean+length)`, or even something silly like `STYLE(perp, mean+length+mean+length)`
|
||||
|
||||
The style can be specified separately for each AND:ed prompt, but the first prompt is special; later prompts will "inherit" it as default. For example:
|
||||
|
||||
```
|
||||
STYLE(A1111) a (red:1.1) cat with (brown:0.9) spots and a long tail AND an (old:0.5) dog AND a (green:1.4) (balloon:1.1)
|
||||
```
|
||||
will interpret everything as A1111, but
|
||||
```
|
||||
a (red:1.1) cat with (brown:0.9) spots and a long tail AND STYLE(A1111) an (old:0.5) dog AND a (green:1.4) (balloon:1.1)
|
||||
```
|
||||
Will interpret the first one using the default ComfyUI behaviour, the second prompt with A1111 and the last prompt with the default again
|
||||
|
||||
### SDXL: Configure SDXL prompting parameters
|
||||
|
||||
The nodes do not treat SDXL models specially, but there are some utilities that enable SDXL specific functionality.
|
||||
|
||||
You can use the function `SDXL(width height, target_width target_height, crop_w crop_h)` to set SDXL prompt parameters. `SDXL()` is equivalent to `SDXL(1024 1024, 1024 1024, 0 0)` unless the default values have been overridden by `PCScheduleSettings`.
|
||||
|
||||
### TE: Per-encoder prompts for multi-encoder models
|
||||
|
||||
You can specify per-encoder prompts using the `TE` function. The syntax is as follows:
|
||||
`TE(encoder_name=prompt)`. Whitespace surrounding the prompt and encoder name are ignored.
|
||||
|
||||
For example:
|
||||
```
|
||||
TE(l=cat) TE(g = (dog:1.1)) TE(t5xxl=tiger)
|
||||
```
|
||||
The keys to use depend on what key ComfyUI uses for the encoder; for example `l` for CLIP L, `g` for CLIP G, and `t5xxl` for T5 XXL (Flux text encoder).
|
||||
|
||||
Use `TE(help)` to print a help text listing available keys.
|
||||
|
||||
Things to note:
|
||||
- If you set a prompt with `TE`, it will override the prompt outside the function for the specified text encoder.
|
||||
- Multiple instances of `TE` are joined with a space. That is, `TE(l=foo)TE(l=bar)` is the same as `TE(l=foo bar)`
|
||||
- `AND` and `BREAK` are processed before `TE`, so they do not do anything sensible; `TE(l=foo AND bar)` will parse as two prompts `TE(foo` and `bar)`. `SHIFT`, `SHUFFLE` and `OLDBREAK` do work, however.
|
||||
|
||||
### SHUFFLE and SHIFT: Create prompt permutations
|
||||
|
||||
Default parameters: `SHUFFLE(seed=0, separator=,, joiner=,)`, `SHIFT(steps=0, separator=,, joiner=,)`
|
||||
|
||||
`SHIFT` moves elements to the left by `steps`. The default is 0 so `SHIFT()` does nothing
|
||||
`SHUFFLE` generates a random permutation with `seed` as its seed.
|
||||
|
||||
These functions are applied to each prompt chunk **after** `BREAK`, `AND` etc. have been parsed. The prompt is split by `separator`, the operation is applied, and it's then joined back by `joiner`.
|
||||
|
||||
Multiple instances of these functions are applied in the order they appear in the prompt.
|
||||
|
||||
**NOTE** To avoid breaking emphasis syntax, the functions ignore any separators inside parentheses
|
||||
|
||||
For example:
|
||||
- `SHIFT(1) cat, dog, tiger, mouse` does a shift and results in `dog, tiger, mouse, cat`. (whitespace may vary)
|
||||
- `SHIFT(1,;) cat, dog ; tiger, mouse` results in `tiger, mouse, cat, dog`
|
||||
- `SHUFFLE() cat, dog, tiger, mouse` results in `cat, dog, mouse, tiger`
|
||||
- `SHUFFLE() SHIFT(1) cat, dog, tiger, mouse` results in `dog, mouse, tiger, cat`
|
||||
|
||||
- `SHIFT(1) cat,dog BREAK tiger,mouse` results in `dog,cat BREAK tiger,mouse`
|
||||
- `SHIFT(1) cat, dog AND SHIFT(1) tiger, mouse` results in `dog, cat BREAK mouse, tiger`
|
||||
|
||||
Whitespace is *not* stripped and may also be used as a joiner or separator
|
||||
- `SHIFT(1,, ) cat,dog` results in `dog cat`
|
||||
|
||||
### NOISE: Add noise to a prompt
|
||||
|
||||
The function `NOISE(weight, seed)` adds some random noise into the cond tensor. The seed is optional, and if not specified, the global RNG is used. `weight` should be between 0 and 1.
|
||||
|
||||
The usefulness of this is questionable, but it wasn't difficult to implement, so here it is.
|
||||
|
||||
## Regional prompting
|
||||
|
||||
See [Regional prompting](/doc/regional_prompting.md)
|
||||
|
||||
## Cutoff
|
||||
|
||||
NOTE: Cutoff syntax might change at some point; it's pretty clunky.
|
||||
|
||||
`PCTextEncode` reimplements cutoff from [ComfyUI Cutoff](https://github.com/BlenderNeko/ComfyUI_Cutoff).
|
||||
|
||||
The syntax is
|
||||
```
|
||||
a group of animals, [CUT:white cat:white], [CUT:brown dog:brown:0.5:1.0:1.0:_]
|
||||
```
|
||||
You should read the prompt as `a group of animals, white cat, brown dog`, but CUT causes the tokens in `target_tokens` to be masked off from the base prompt in `region_text`, so that their effect can be isolated, and you're less likely to get brown cats or white dogs.
|
||||
|
||||
Target tokens are treated individually, separated by space, for example, `[CUT:green apple, red apple, green leaf:green apple]` will mask *both* greens and the apple, giving you `+ +, red +, + leaf`. To mask out just `green apple`, use `[CUT:green apple, red apple:green_apple]` which will result in a masked prompt of `+ +, red apple`. Escape `_` with a `\`.
|
||||
|
||||
the parameters in the `CUT` section are `region_text:target_tokens:weight;strict_mask:start_from_masked:padding_token` of which only the first two are required. The default values are `weight=1.0`, `strict_mask=1.0` `start_from_masked=1.0`, `padding_token=+`
|
||||
|
||||
If `strict_mask`, `start_from_masked` or `padding_token` are specified in more than one CUT, the *last* one becomes the default for any CUTs afterwards that do not explicitly set the parameters. For example, in:
|
||||
|
||||
`[CUT:white cat:white:0.5] and [CUT:black parrot, flying:black:1.0:0.5] and [CUT:green apple:green]`
|
||||
|
||||
`white cat` will a weight of 0.5, and 1.0 for all parameters, and `black parrot` and `green apple` will *both* have a `strict_mask` parameter of 0.5.
|
||||
|
||||
The parameters affect how the masked and unmasked prompts are combined to produce the final embedding. Just play around with them.
|
||||
|
||||
## Miscellaneous
|
||||
- `<emb:xyz>` is alternative syntax for `embedding:xyz` to work around a syntax conflict with `[embedding:xyz:0.5]` which is parsed as a schedule that switches from `embedding` to `xyz`.
|
||||
|
||||
# Experimental features
|
||||
|
||||
> [!WARN]
|
||||
> These features are may change or disappear without warning
|
||||
|
||||
## COUPLE: Attention couple
|
||||
|
||||
See [here](/doc/attention_couple.md)
|
||||
|
||||
## TE_WEIGHT
|
||||
|
||||
For models using multiple text encoders, you can set weights per TE using the syntax `TE_WEIGHT(clipname=weight, clipname2=weight2, ...)` where `clipname` is one of the encoder names printed by `TE(help)`. For example with SDXL, try `TE_WEIGHT(g=0.25, l=0.75)`.
|
||||
|
||||
The weights are applied as a multiplier to the TE output. You can also override pooled output multipliers using eg. `l_pooled`.
|
||||
|
||||
To set a default value for all encoders, use `TE_WEIGHT(all=weight)`
|
||||
@@ -0,0 +1,60 @@
|
||||
## DEF: Lightweight prompt macros
|
||||
|
||||
You can define "prompt macros" by using `DEF`. Macros are expanded before any other parsing takes place. The expansion continues until no further changes occur. Recursion will raise an error.
|
||||
|
||||
`PCLazyTextEncode` and `PCLazyLoraLoader` expand macros, but `PCTextEncode` **does not**. If you need to expand macros for a single prompt, use `PCMacroExpand`
|
||||
|
||||
```
|
||||
DEF(MYMACRO=this is a prompt)
|
||||
[(MYMACRO:0.6):(MYMACRO:1.1):0.5]
|
||||
```
|
||||
is equivalent to
|
||||
```
|
||||
[(this is a prompt:0.5):(this is a prompt:1.1):0.5]
|
||||
```
|
||||
### Macro parameters
|
||||
It's also possible to give parameters to a macro:
|
||||
```
|
||||
DEF(MYMACRO=[(prompt $1:$2):(prompt $1:$3):$4])
|
||||
MYMACRO(test; 1.1; 0.7; 0.2)
|
||||
```
|
||||
gives
|
||||
```
|
||||
[(prompt test:1.1):(prompt test:0.7):0.2]
|
||||
```
|
||||
in this form, the variables $N (where N is any number corresponding to a positional parameter) will be replaced with the given parameter. The parameters must be separated with a semicolon, and can be empty.
|
||||
|
||||
You can also optionally specify default values:
|
||||
|
||||
```
|
||||
DEF(MACRO(example; 0; 1)=[$1:$2,$3])
|
||||
MACRO MACRO(test; 0.2)
|
||||
```
|
||||
gives
|
||||
```
|
||||
[example:0,1] [test:0.2,1]
|
||||
```
|
||||
|
||||
```
|
||||
DEF(MACRO() = [a:$1:0.5])
|
||||
```
|
||||
sets the default value of `$1` to an empty string.
|
||||
|
||||
### Unspecified parameters in macros
|
||||
|
||||
Unspecified parameters (either via defaults or explicitly given) will not be substituted. Compare:
|
||||
|
||||
```
|
||||
DEF(mything=a "$1" b "$2")
|
||||
mything
|
||||
mything()
|
||||
mything(A)
|
||||
```
|
||||
|
||||
gives
|
||||
|
||||
```
|
||||
a "$1" b "$2"
|
||||
a "" b "$2"
|
||||
a "A" b "$2"
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
# Regional prompting
|
||||
|
||||
This section documents the masking functionality of `PCTextEncode`
|
||||
|
||||
See also [Attention Couple](/doc/attention_couple.md)
|
||||
|
||||
Remember that when using the lazy nodes, prompt scheduling applies to masks as well, so you can change or enable/disable regional prompts at any point during sampling.
|
||||
|
||||
## Behaviour
|
||||
|
||||
For each prompt separated by `AND`, you can specify either latent masks or an area.
|
||||
|
||||
- When masked, ComfyUI generates the model output using the **full latent** as the input, and then applies the mask to the output before adding it to your latent for the next step.
|
||||
- When an area is specified, ComfyUI generates a separate model output using the **part of the latent specified by the area** and then composites it into the full latent afterwards.
|
||||
- You can have *both* an AREA and a MASK specified, in which case the mask is applied to the latent specified by the AREA.
|
||||
|
||||
For example, consider a 1024 by 1024 (width x height) generation:
|
||||
|
||||
- `cat MASK(0 0.5, 0 1) AND dog MASK(0.5 1, 0 1)` generates two outputs at 1024x1024 for "dog" and "cat", then masks half of them off and adds the results together. The following step still see both the dog and the cat from the previous step, so they may blend slightly.
|
||||
|
||||
- `cat AREA(0 0.5, 0 1) AND dog AREA(0.5 1, 0 1)` generates two completely separate outputs at **512**x1024 and then composites them together into the 1024x1024 latent. Because the areas do not overlap, the generation for `cat` will not see the output of `dog` and vice versa in subsequent steps as long as the area restriction is in effect.
|
||||
|
||||
## MASK, IMASK and AREA
|
||||
|
||||
You can use `MASK(x1 x2, y1 y2, weight, op)` to specify a region mask for a prompt. The values are specified as a percentage with a float between `0` and `1`, or as absolute pixel values (these can't be mixed). `1` will be interpreted as a percentage instead of a pixel value.
|
||||
|
||||
Multiple `MASK` or `IMASK` calls will be composited together using ComfyUI's `MaskComposite` node, using `op` as the `operation` parameter (defaulting to `multiply`).
|
||||
|
||||
Similarly, you can use `AREA(x1 x2, y1 y2, weight)` to specify an area for the prompt (see ComfyUI's area composition examples). The area is calculated by ComfyUI relative to your latent size.
|
||||
|
||||
### Custom masks: IMASK and `PCAddMaskToCLIP`
|
||||
|
||||
You can attach custom masks to a `CLIP` with the `PC: Attach Mask` nodes and then refer to those masks in the prompt using `IMASK(index, weight, op)`. Indexing starts from zero, so 0 is the first attached mask etc. `PCSCheduleAddMasks` ignores empty inputs, so if you only add a mask to the `mask4` input, it will still have index 0.
|
||||
|
||||
Applying the nodes multiple times *appends* masks rather than overriding existing ones, so if you need more than 4, you can just use it more than once.
|
||||
|
||||
### Behaviour of multiple masks
|
||||
If multiple `MASK`s are specified, they are combined together with ComfyUI's `MaskComposite` node, with `op` specifying the operation to use (default `multiply`). In this case, the combined mask weight can be set with `MASKW(weight)` (defaults to 1.0).
|
||||
|
||||
Masks assume a size of `(512, 512)`, unless overridden with `PC: Configure PCTextEncode` and pixel values will be relative to that. ComfyUI will scale the mask to match the image resolution. You can change it manually by using `MASK_SIZE(width, height)` anywhere in the prompt,
|
||||
|
||||
These are handled per `AND`-ed prompt, so in `prompt1 AND MASK(...) prompt2`, the mask will only affect prompt2.
|
||||
|
||||
The default values are `MASK(0 1, 0 1, 1)` and you can omit unnecessary ones, that is, `MASK(0 0.5, 0.3)` is `MASK(0 0.5, 0.3 1, 1)`
|
||||
|
||||
Note that because the default values are percentages, `MASK(0 256, 64 512)` is valid, but `MASK(0 200)` will raise an error.
|
||||
|
||||
Masking does not affect LoRA scheduling unless you set unet weights to 0 for a LoRA.
|
||||
|
||||
## FEATHER: Mask operations
|
||||
|
||||
When you use `MASK` or `IMASK`, you can also call `FEATHER(left top right bottom)` to apply feathering using ComfyUI's `FeatherMask` node. The values are in pixels and default to `0`.
|
||||
|
||||
If multiple masks are used, `FEATHER` is applied *before compositing* in the order they appear in the prompt, and any leftovers are applied to the combined mask. If you want to skip feathering a mask while compositing, just use `FEATHER()` with no arguments.
|
||||
|
||||
For example:
|
||||
```
|
||||
MASK(1) MASK(2) MASK(3) FEATHER(1) FEATHER() FEATHER(3) weirdmask FEATHER(4)
|
||||
```
|
||||
|
||||
gives you a mask that is a combination of 1, 2 and 3, where 1 and 3 are feathered before compositing and then `FEATHER(4)` is applied to the composite.
|
||||
|
||||
The order of the `FEATHER` and `MASK` calls doesn't matter; you can have `FEATHER` before `MASK` or even interleave them.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Prompt Schedule Syntax
|
||||
|
||||
> [!TIP]
|
||||
> If you're viewing this on GitHub, I recommend opening the outline by clicking the button in the top right corner of the text view (it is annoyingly easy to miss).
|
||||
|
||||
> [!NOTE]
|
||||
> The syntax documented in this section is only available with the `PC: Schedule Prompt` and `PC: Schedule LoRAs` nodes and their advanced variants.
|
||||
|
||||
Scheduling syntax is available with is similar to A1111, but only fractions are supported for steps. LoRAs are scheduled by including them in a scheduling expression.
|
||||
|
||||
Besides the syntax documented below, the [basic syntax](/doc/basic.md) and [prompt macro](/doc/macros.md) features are also automatically available.
|
||||
|
||||
```
|
||||
a [large::0.1] [cat|dog:0.05] [<lora:somelora:0.5:0.6>::0.5]
|
||||
[in a park:in space:0.4]
|
||||
```
|
||||
## Comments and escaping
|
||||
|
||||
In schedules, any text on a line following a `#` is considered a comment and removed, including the `#` character.
|
||||
You can escape the following characters in places where they would otherwise conflict with syntax:
|
||||
|
||||
- `#` with `\#`
|
||||
- `:` with `\:`
|
||||
- `\` with `\\`
|
||||
|
||||
Escaping is only required if it would otherwise be considered syntax, that is `\o/` will be interpreted literally and the `\` does not need to be escaped, but in `[embedding:a:0.5]` you would need to escape the `:`.
|
||||
|
||||
## Scheduled prompts
|
||||
|
||||
There are two forms of scheduled prompts.
|
||||
|
||||
### Basic scheduling expressions
|
||||
Basic expressions take the form `[before:after:X]` where `X` is the switch point, a decimal number between 0.0 and 1.0 inclusive, representing 0 to 100% of timesteps. Either prompt can also be empty.
|
||||
For example:
|
||||
```
|
||||
a [red:blue:0.5] cat
|
||||
```
|
||||
switches from `a red cat` to `a blue cat` at 0.5. `before` and `after` can be arbitrary prompts (`after` can also be empty), including other scheduling expressions, allowing nesting:
|
||||
```
|
||||
a [red:[blue::0.7]:0.5] cat
|
||||
```
|
||||
|
||||
switches from `a red cat` to `a blue cat` at 0.5 and to `a cat` at 0.7
|
||||
|
||||
For convenience `[cat:0.5]` is equivalent to `[:cat:0.5]` meaning it switches from empty to `cat` at 0.5.
|
||||
|
||||
### Range expressions
|
||||
|
||||
The most general form of a schedule is a range expression: For example, in `prompt [before:during:after:0.3,0.7]`, The prompt be `prompt before` until 0.3, `prompt during` until 0.7, and then `prompt after`. This form is equivalent to `prompt [before:[during:after:0.7]:0.3]`
|
||||
|
||||
For convenience, `[during:0.1,0.4]` is equivalent to `[:during::0.1,0.4]` and `[during:after:0.1,0.4]` is equivalent to `[:during:after:0.1,0.4]`.
|
||||
|
||||
`[before:during:after:0.1]` is the same as `[before:during:after:0.1,1.0]` which is same as `[before:during:0.1]`
|
||||
|
||||
|
||||
### Using step numbers with the Advanced nodes
|
||||
|
||||
If you provide a non-zero value to `num_steps` to the `Advanced` versions of the scheduling nodes, you will be able to use step numbers in prompts.
|
||||
|
||||
For now, a value between 0 and 1.0 will be interpreted as a percentage if it contains a ., and as an absolute step otherwise.
|
||||
|
||||
This is just syntactic sugar. Behind the scenes, the values are converted to percentages and have normal ComfyUI scheduling behaviour.
|
||||
|
||||
## Tag selection
|
||||
Using the `FilterSchedule` node, in addition to step percentages, you can use a *tag* to select part of an input:
|
||||
```
|
||||
a large [dog:cat<lora:catlora:0.5>:SECOND_PASS]
|
||||
```
|
||||
Set the `tags` parameter in the `FilterSchedule` node to filter the prompt. If the tag matches any tag `tags` (comma-separated), the second option is returned (`cat`, in this case, with the LoRA). Otherwise, the first option is chosen (`dog`, without LoRA).
|
||||
|
||||
the values in `tags` are case-insensitive, but the tags in the input **must** be uppercase A-Z and underscores only, or they won't be recognized. That is, `[dog:cat:hr]` will not work.
|
||||
|
||||
For example, a prompt
|
||||
```
|
||||
a [black:blue:X] [cat:dog:Y] [walking:running:Z] in space
|
||||
```
|
||||
with `tags` `x,z` would result in the prompt `a blue cat running in space`
|
||||
|
||||
The three prompt form `[a:b:c:TAG]` is parsed, but ignores `b` and is equivalent to `[a:c:TAG]`.
|
||||
|
||||
## LoRA Scheduling
|
||||
When using the lazy graph building nodes, LoRAs can be scheduled by referring to them in a scheduling expression, like so:
|
||||
|
||||
`<lora:fulllora:1> [<lora:partialora:1>::0.5]`
|
||||
|
||||
This will schedule `fulllora` for the entire duration of the prompt and `partiallora` until half of sampling is complete.
|
||||
|
||||
You can refer to LoRAs by using the filename without extension and subdirectories will also be searched. For example, `<lora:cats:1>`. will match both `cats.safetensors` and `sd15/animals/cats.safetensors`. If there are multiple LoRAs with the same name, the first match will be loaded.
|
||||
|
||||
Alternatively, the name can include the full directory path relative to ComfyUI's search paths, without extension: `<lora:XL/sdxllora:0.5>`. In this case, the *full* path must match.
|
||||
|
||||
You can also give the exact path (including the extension) as shown in `LoRALoader`.
|
||||
|
||||
If no match is found, the node will try to replace spaces with underscores and search again. That is, `<lora:cats and dogs:1>` will find `cats_and_dogs.safetensors`. This helps with some autocompletion scripts that replace underscores with spaces.
|
||||
|
||||
Finally, if none of the above produce a match, the search term will be split by whitespace and files that contain all of the parts in any order will be considered. If this returns only a single match, it will be loaded. For example, consider LoRAs:
|
||||
|
||||
- `xl/red_cats.safetensors`
|
||||
- `flux/blue_cats.safetensors`
|
||||
- `flux/red_cats.safetensors`
|
||||
|
||||
Then `<lora:cats xl:1>` would match the red cats LoRA, but `cats flux` would be ambiguous and not match.
|
||||
|
||||
## Alternating
|
||||
|
||||
Alternating syntax is `[a|b:pct_steps]`, causing the prompt to alternate every `pct_steps`. `pct_steps` defaults to 0.1 if not specified. You can also have more than two options.
|
||||
|
||||
|
||||
## Sequences
|
||||
|
||||
The syntax `[SEQ:a:N1:b:N2:c:N3]` is shorthand for `[a:[b:[c::N3]:N2]:N1]` ie. it switches from `a` to `b` to `c` to nothing at the specified points in sequence.
|
||||
|
||||
Might be useful with Jinja templating (see https://github.com/asagi4/comfyui-utility-nodes). For example:
|
||||
```
|
||||
[SEQ<% for x in steps(0.1, 0.9, 0.1) %>:<lora:test:<= sin(x*pi) + 0.1 =>>:<= x =><% endfor %>]
|
||||
```
|
||||
generates a LoRA schedule based on a sinewave
|
||||
-386
@@ -1,386 +0,0 @@
|
||||
# Prompt Control Syntax
|
||||
|
||||
If you're viewing this on GitHub, I recommend opening the outline by clicking the button in the top right corner of the text view (it is annoyingly easy to miss).
|
||||
|
||||
Scheduling syntax is similar to A1111, but only fractions are supported for steps. LoRAs are scheduled by including them in a scheduling expression.
|
||||
|
||||
```
|
||||
a [large::0.1] [cat|dog:0.05] [<lora:somelora:0.5:0.6>::0.5]
|
||||
[in a park:in space:0.4]
|
||||
```
|
||||
|
||||
## Scheduled prompts
|
||||
|
||||
There are two forms of scheduled prompts.
|
||||
|
||||
### Basic scheduling expressions
|
||||
Basic expressions take the form `[before:after:X]` where `X` is the switch point, a decimal number between 0.0 and 1.0 inclusive, representing 0 to 100% of timesteps. Either prompt can also be empty.
|
||||
For example:
|
||||
```
|
||||
a [red:blue:0.5] cat
|
||||
```
|
||||
switches from `a red cat` to `a blue cat` at 0.5. `before` and `after` can be arbitrary prompts (`after` can also be empty), including other scheduling expressions, allowing nesting:
|
||||
```
|
||||
a [red:[blue::0.7]:0.5] cat
|
||||
```
|
||||
|
||||
switches from `a red cat` to `a blue cat` at 0.5 and to `a cat` at 0.7
|
||||
|
||||
For convenience `[cat:0.5]` is equivalent to `[:cat:0.5]` meaning it switches from empty to `cat` at 0.5.
|
||||
|
||||
### Range expressions
|
||||
|
||||
The most general form of a schedule is a range expression: For example, in `[before:during:after:0.3,0.7]`, The prompt be `a before` until 0.3, `a during` until 0.7, and then `a after`. This form is equivalent to `[before:[during:after:0.7]:0.3]`
|
||||
|
||||
For convenience, `[during:0.1,0.4]` is equivalent to `[:during::0.1,0.4]` and `[during:after:0.1,0.4]` is equivalent to `[:during:after:0.1,0.4]`.
|
||||
|
||||
`[before:during:after:0.1]` is the same as `[before:during:after:0.1,1.0]` which is same as `[before:during:0.1]`
|
||||
|
||||
|
||||
### Using step numbers with the Advanced nodes
|
||||
|
||||
If you provide a non-zero value to `num_steps` to the `Advanced` versions of the scheduling nodes, you will be able to use step numbers in prompts.
|
||||
|
||||
For now, a value between 0 and 1.0 will be interpreted as a percentage if it contains a ., and as an absolute step otherwise.
|
||||
|
||||
This is just syntactic sugar. Behind the scenes, the values are converted to percentages and have normal ComfyUI scheduling behaviour.
|
||||
|
||||
## Tag selection
|
||||
Using the `FilterSchedule` node, in addition to step percentages, you can use a *tag* to select part of an input:
|
||||
```
|
||||
a large [dog:cat<lora:catlora:0.5>:SECOND_PASS]
|
||||
```
|
||||
Set the `tags` parameter in the `FilterSchedule` node to filter the prompt. If the tag matches any tag `tags` (comma-separated), the second option is returned (`cat`, in this case, with the LoRA). Otherwise, the first option is chosen (`dog`, without LoRA).
|
||||
|
||||
the values in `tags` are case-insensitive, but the tags in the input **must** be uppercase A-Z and underscores only, or they won't be recognized. That is, `[dog:cat:hr]` will not work.
|
||||
|
||||
For example, a prompt
|
||||
```
|
||||
a [black:blue:X] [cat:dog:Y] [walking:running:Z] in space
|
||||
```
|
||||
with `tags` `x,z` would result in the prompt `a blue cat running in space`
|
||||
|
||||
The three prompt form `[a:b:c:TAG]` is parsed, but ignores `b` and is equivalent to `[a:c:TAG]`.
|
||||
|
||||
## LoRA Scheduling
|
||||
When using the lazy graph building nodes, LoRAs can be scheduled by referring to them in a scheduling expression, like so:
|
||||
|
||||
`<lora:fulllora:1> [<lora:partialora:1>::0.5]`
|
||||
|
||||
This will schedule `fulllora` for the entire duration of the prompt and `partiallora` until half of sampling is complete.
|
||||
|
||||
You can refer to LoRAs by using the filename without extension and subdirectories will also be searched. For example, `<lora:cats:1>`. will match both `cats.safetensors` and `sd15/animals/cats.safetensors`. If there are multiple LoRAs with the same name, the first match will be loaded.
|
||||
|
||||
Alternatively, the name can include the full directory path relative to ComfyUI's search paths, without extension: `<lora:XL/sdxllora:0.5>`. In this case, the *full* path must match.
|
||||
|
||||
If no match is found, the node will try to replace spaces with underscores and search again. That is, `<lora:cats and dogs:1>` will find `cats_and_dogs.safetensors`. This helps with some autocompletion scripts that replace underscores with spaces.
|
||||
|
||||
Finally, you can give the exact path (including the extension) as shown in `LoRALoader`.
|
||||
|
||||
|
||||
## Alternating
|
||||
|
||||
Alternating syntax is `[a|b:pct_steps]`, causing the prompt to alternate every `pct_steps`. `pct_steps` defaults to 0.1 if not specified. You can also have more than two options.
|
||||
|
||||
|
||||
## Sequences
|
||||
|
||||
The syntax `[SEQ:a:N1:b:N2:c:N3]` is shorthand for `[a:[b:[c::N3]:N2]:N1]` ie. it switches from `a` to `b` to `c` to nothing at the specified points in sequence.
|
||||
|
||||
Might be useful with Jinja templating (see https://github.com/asagi4/comfyui-utility-nodes). For example:
|
||||
```
|
||||
[SEQ<% for x in steps(0.1, 0.9, 0.1) %>:<lora:test:<= sin(x*pi) + 0.1 =>>:<= x =><% endfor %>]
|
||||
```
|
||||
generates a LoRA schedule based on a sinewave
|
||||
|
||||
# Basic prompt syntax
|
||||
|
||||
This syntax is also available in outside scheduled prompts, where applicable.
|
||||
|
||||
## LoRA loading
|
||||
|
||||
The A111-style syntax `<lora:loraname:weight>` can be used to load LoRAs via the prompt. See LoRA scheduling above.
|
||||
|
||||
## Combining prompts, A1111-style
|
||||
|
||||
### BREAK
|
||||
The keyword `BREAK` causes the prompt to be tokenized in separate chunks, which results in each chunk being individually padded to the text encoder's maximum token length. This is mostly equivalent to the `ConditioningConcat` node.
|
||||
|
||||
### AND
|
||||
|
||||
`AND` can be used to create "prompt segments". By default, it works as if you had combined the different prompts with `ConditioningCombine`.
|
||||
|
||||
It is also used with regional prompting to separate different prompts; see `MASK` and `ATTN` below.
|
||||
|
||||
Prompts can have a weight at the end:
|
||||
```
|
||||
cat :1 AND dog :2
|
||||
```
|
||||
`AND` is processed after schedule parsing, so you can change the weight mid-prompt: `cat:[1:2:0.5] AND dog`
|
||||
|
||||
The weight defaults to 1. If a prompt's weight is set to 0, it's **skipped entirely.** This can be useful when scheduling to completely disable a prompt:
|
||||
|
||||
```
|
||||
cat [\:0::0.5] AND dog
|
||||
```
|
||||
Note that the `:` needs to be escaped with a `\` or it will be interpreted as scheduling syntax.
|
||||
|
||||
# Functions
|
||||
|
||||
There are some "functions" that can be included in a prompt to affect how it is interpreted.
|
||||
|
||||
Functions have the form `FUNCNAME(param1, param2, ...)`. How parameters are interpreted is up to the function.
|
||||
|
||||
In general, function parameters will have default values that are used if the parameter is left empty.
|
||||
|
||||
Note: Whitespace is usually *not* stripped from string parameters by default. Commas can be escaped with `\,`
|
||||
|
||||
Like `AND`, functions are parsed after regular scheduling syntax has been expanded, allowing things like `[AREA:MASK:0.3](...)`, in case that's somehow useful.
|
||||
|
||||
### STYLE: Configure prompt weighting (also known as "Advanced CLIP Encode")
|
||||
|
||||
Use the syntax `STYLE(weight_interpretation, normalization)` in a prompt to affect how prompts are interpreted.
|
||||
|
||||
The weight interpretations available are:
|
||||
- comfy (default)
|
||||
- comfy++
|
||||
- compel
|
||||
- down_weight
|
||||
- A1111
|
||||
- perp
|
||||
|
||||
Normalizations are:
|
||||
- none (default)
|
||||
- length
|
||||
- mean
|
||||
|
||||
The normalization calculations are independent operations and you can combine them with `+`, eg `STYLE(A1111, length+mean)` or `STYLE(comfy, mean+length)`, or even something silly like `STYLE(perp, mean+length+mean+length)`
|
||||
|
||||
The style can be specified separately for each AND:ed prompt, but the first prompt is special; later prompts will "inherit" it as default. For example:
|
||||
|
||||
```
|
||||
STYLE(A1111) a (red:1.1) cat with (brown:0.9) spots and a long tail AND an (old:0.5) dog AND a (green:1.4) (balloon:1.1)
|
||||
```
|
||||
will interpret everything as A1111, but
|
||||
```
|
||||
a (red:1.1) cat with (brown:0.9) spots and a long tail AND STYLE(A1111) an (old:0.5) dog AND a (green:1.4) (balloon:1.1)
|
||||
```
|
||||
Will interpret the first one using the default ComfyUI behaviour, the second prompt with A1111 and the last prompt with the default again
|
||||
|
||||
### SDXL: Configure SDXL prompting parameters
|
||||
|
||||
The nodes do not treat SDXL models specially, but there are some utilities that enable SDXL specific functionality.
|
||||
|
||||
You can use the function `SDXL(width height, target_width target_height, crop_w crop_h)` to set SDXL prompt parameters. `SDXL()` is equivalent to `SDXL(1024 1024, 1024 1024, 0 0)` unless the default values have been overridden by `PCScheduleSettings`.
|
||||
|
||||
### TE: Per-encoder prompts for multi-encoder models
|
||||
|
||||
You can specify per-encoder prompts using the `TE` function. The syntax is as follows:
|
||||
`TE(encoder_name=prompt)`. Whitespace surrounding the prompt and encoder name are ignored.
|
||||
|
||||
For example:
|
||||
```
|
||||
TE(l=cat) TE(g = (dog:1.1)) TE(t5xxl=tiger)
|
||||
```
|
||||
The keys to use depend on what key ComfyUI uses for the encoder; for example `l` for CLIP L, `g` for CLIP G, and `t5xxl` for T5 XXL (Flux text encoder).
|
||||
|
||||
Use `TE(help)` to print a help text listing available keys.
|
||||
|
||||
Things to note:
|
||||
- If you set a prompt with `TE`, it will override the prompt outside the function for the specified text encoder.
|
||||
- Multiple instances of `TE` are joined with a space. That is, `TE(l=foo)TE(l=bar)` is the same as `TE(l=foo bar)`
|
||||
- `AND` inside `TE` does not do anything sensible; `TE(l=foo AND bar)` will parse as two prompts `TE(foo` and `bar)`. `BREAK`, `SHIFT` and `SHUFFLE` do work, however
|
||||
|
||||
### SHUFFLE and SHIFT: Create prompt permutations
|
||||
|
||||
Default parameters: `SHUFFLE(seed=0, separator=,, joiner=,)`, `SHIFT(steps=0, separator=,, joiner=,)`
|
||||
|
||||
`SHIFT` moves elements to the left by `steps`. The default is 0 so `SHIFT()` does nothing
|
||||
`SHUFFLE` generates a random permutation with `seed` as its seed.
|
||||
|
||||
These functions are applied to each prompt chunk **after** `BREAK`, `AND` etc. have been parsed. The prompt is split by `separator`, the operation is applied, and it's then joined back by `joiner`.
|
||||
|
||||
Multiple instances of these functions are applied in the order they appear in the prompt.
|
||||
|
||||
**NOTE** To avoid breaking emphasis syntax, the functions ignore any separators inside parentheses
|
||||
|
||||
For example:
|
||||
- `SHIFT(1) cat, dog, tiger, mouse` does a shift and results in `dog, tiger, mouse, cat`. (whitespace may vary)
|
||||
- `SHIFT(1,;) cat, dog ; tiger, mouse` results in `tiger, mouse, cat, dog`
|
||||
- `SHUFFLE() cat, dog, tiger, mouse` results in `cat, dog, mouse, tiger`
|
||||
- `SHUFFLE() SHIFT(1) cat, dog, tiger, mouse` results in `dog, mouse, tiger, cat`
|
||||
|
||||
- `SHIFT(1) cat,dog BREAK tiger,mouse` results in `dog,cat BREAK tiger,mouse`
|
||||
- `SHIFT(1) cat, dog AND SHIFT(1) tiger, mouse` results in `dog, cat BREAK mouse, tiger`
|
||||
|
||||
Whitespace is *not* stripped and may also be used as a joiner or separator
|
||||
- `SHIFT(1,, ) cat,dog` results in `dog cat`
|
||||
|
||||
### NOISE: Add noise to a prompt
|
||||
|
||||
The function `NOISE(weight, seed)` adds some random noise into the prompt. The seed is optional, and if not specified, the global RNG is used. `weight` should be between 0 and 1.
|
||||
|
||||
|
||||
## Regional prompting
|
||||
|
||||
See also [Attention Couple](#attention-couple) below
|
||||
|
||||
### MASK, IMASK and AREA
|
||||
|
||||
You can use `MASK(x1 x2, y1 y2, weight, op)` to specify a region mask for a prompt. The values are specified as a percentage with a float between `0` and `1`, or as absolute pixel values (these can't be mixed). `1` will be interpreted as a percentage instead of a pixel value.
|
||||
|
||||
Multiple `MASK` or `IMASK` calls will be composited together using ComfyUI's `MaskComposite` node, using `op` as the `operation` parameter (defaulting to `multiply`).
|
||||
|
||||
Similarly, you can use `AREA(x1 x2, y1 y2, weight)` to specify an area for the prompt (see ComfyUI's area composition examples). The area is calculated by ComfyUI relative to your latent size.
|
||||
|
||||
### Custom masks: IMASK and `PCAddMaskToCLIP`
|
||||
|
||||
You can attach custom masks to a `CLIP` with the `PC: Attach Mask` nodes and then refer to those masks in the prompt using `IMASK(index, weight, op)`. Indexing starts from zero, so 0 is the first attached mask etc. `PCSCheduleAddMasks` ignores empty inputs, so if you only add a mask to the `mask4` input, it will still have index 0.
|
||||
|
||||
Applying the nodes multiple times *appends* masks rather than overriding existing ones, so if you need more than 4, you can just use it more than once.
|
||||
|
||||
### Behaviour of masks
|
||||
If multiple `MASK`s are specified, they are combined together with ComfyUI's `MaskComposite` node, with `op` specifying the operation to use (default `multiply`). In this case, the combined mask weight can be set with `MASKW(weight)` (defaults to 1.0).
|
||||
|
||||
Masks assume a size of `(512, 512)`, unless overridden with `PC: Configure PCTextEncode` and pixel values will be relative to that. ComfyUI will scale the mask to match the image resolution. You can change it manually by using `MASK_SIZE(width, height)` anywhere in the prompt,
|
||||
|
||||
These are handled per `AND`-ed prompt, so in `prompt1 AND MASK(...) prompt2`, the mask will only affect prompt2.
|
||||
|
||||
The default values are `MASK(0 1, 0 1, 1)` and you can omit unnecessary ones, that is, `MASK(0 0.5, 0.3)` is `MASK(0 0.5, 0.3 1, 1)`
|
||||
|
||||
Note that because the default values are percentages, `MASK(0 256, 64 512)` is valid, but `MASK(0 200)` will raise an error.
|
||||
|
||||
Masking does not affect LoRA scheduling unless you set unet weights to 0 for a LoRA.
|
||||
|
||||
### FEATHER: Mask operations
|
||||
|
||||
When you use `MASK` or `IMASK`, you can also call `FEATHER(left top right bottom)` to apply feathering using ComfyUI's `FeatherMask` node. The values are in pixels and default to `0`.
|
||||
|
||||
If multiple masks are used, `FEATHER` is applied *before compositing* in the order they appear in the prompt, and any leftovers are applied to the combined mask. If you want to skip feathering a mask while compositing, just use `FEATHER()` with no arguments.
|
||||
|
||||
For example:
|
||||
```
|
||||
MASK(1) MASK(2) MASK(3) FEATHER(1) FEATHER() FEATHER(3) weirdmask FEATHER(4)
|
||||
```
|
||||
|
||||
gives you a mask that is a combination of 1, 2 and 3, where 1 and 3 are feathered before compositing and then `FEATHER(4)` is applied to the composite.
|
||||
|
||||
The order of the `FEATHER` and `MASK` calls doesn't matter; you can have `FEATHER` before `MASK` or even interleave them.
|
||||
|
||||
## Cutoff
|
||||
|
||||
NOTE: Cutoff syntax might change at some point; it's pretty clunky.
|
||||
|
||||
`PCTextEncode` reimplements cutoff from [ComfyUI Cutoff](https://github.com/BlenderNeko/ComfyUI_Cutoff).
|
||||
|
||||
The syntax is
|
||||
```
|
||||
a group of animals, [CUT:white cat:white], [CUT:brown dog:brown:0.5:1.0:1.0:_]
|
||||
```
|
||||
You should read the prompt as `a group of animals, white cat, brown dog`, but CUT causes the tokens in `target_tokens` to be masked off from the base prompt in `region_text`, so that their effect can be isolated, and you're less likely to get brown cats or white dogs.
|
||||
|
||||
Target tokens are treated individually, separated by space, for example, `[CUT:green apple, red apple, green leaf:green apple]` will mask *both* greens and the apple, giving you `+ +, red +, + leaf`. To mask out just `green apple`, use `[CUT:green apple, red apple:green_apple]` which will result in a masked prompt of `+ +, red apple`. Escape `_` with a `\`.
|
||||
|
||||
the parameters in the `CUT` section are `region_text:target_tokens:weight;strict_mask:start_from_masked:padding_token` of which only the first two are required. The default values are `weight=1.0`, `strict_mask=1.0` `start_from_masked=1.0`, `padding_token=+`
|
||||
|
||||
If `strict_mask`, `start_from_masked` or `padding_token` are specified in more than one CUT, the *last* one becomes the default for any CUTs afterwards that do not explicitly set the parameters. For example, in:
|
||||
|
||||
`[CUT:white cat:white:0.5] and [CUT:black parrot, flying:black:1.0:0.5] and [CUT:green apple:green]`
|
||||
|
||||
`white cat` will a weight of 0.5, and 1.0 for all parameters, and `black parrot` and `green apple` will *both* have a `strict_mask` parameter of 0.5.
|
||||
|
||||
The parameters affect how the masked and unmasked prompts are combined to produce the final embedding. Just play around with them.
|
||||
|
||||
## Miscellaneous
|
||||
- `<emb:xyz>` is alternative syntax for `embedding:xyz` to work around a syntax conflict with `[embedding:xyz:0.5]` which is parsed as a schedule that switches from `embedding` to `xyz`.
|
||||
|
||||
# Experimental features
|
||||
|
||||
Experimental features are unstable and may disappear or change without warning.
|
||||
|
||||
## DEF: Lightweight prompt macros
|
||||
|
||||
You can define "prompt macros" by using `DEF`:
|
||||
```
|
||||
DEF(MYMACRO=this is a prompt)
|
||||
[(MYMACRO:0.6):(MYMACRO:1.1):0.5]
|
||||
```
|
||||
is equivalent to
|
||||
```
|
||||
[(this is a prompt:0.5):(this is a prompt:1.1):0.5]
|
||||
```
|
||||
|
||||
It's also possible to give parameters to a macro:
|
||||
```
|
||||
DEF(MYMACRO=[(prompt $1:$2):(prompt $1:$3):$4])
|
||||
MYMACRO(test; 1.1; 0.7; 0.2)
|
||||
```
|
||||
gives
|
||||
```
|
||||
[(prompt test:1.1):(prompt test:0.7):0.2]
|
||||
```
|
||||
in this form, the variables $N (where N is any number corresponding to a positional parameter) will be replaced with the given parameter. The parameters must be separated with a semicolon.
|
||||
|
||||
You can also optionally specify default values:
|
||||
|
||||
```
|
||||
DEF(MACRO(example; 0; 1)=[$1:$2,$3])
|
||||
MACRO MACRO(test; 0.2)
|
||||
```
|
||||
gives
|
||||
```
|
||||
[example:0,1] [test:0.2,1]
|
||||
```
|
||||
|
||||
Note that unspecified parameters will not be substituted:
|
||||
```
|
||||
DEF(mything=a $1 b $2)
|
||||
mything
|
||||
mything(A)
|
||||
```
|
||||
gives
|
||||
```
|
||||
a $1 b $2
|
||||
a A b $2
|
||||
```
|
||||
|
||||
Macros are expanded before any other parsing takes place. The expansion continues until no further changes occur. Recursion will raise an error.
|
||||
|
||||
## Attention Couple
|
||||
|
||||
Attention Couple is an attention-based implementation of regional prompting. it can often be faster and more flexible than latent-based masking.
|
||||
|
||||
The implementation is based on the one by [pamparamm](https://github.com/pamparamm/ComfyUI-ppm.git), but modified to use ComfyUI's hook system. This enables it to work with prompt scheduling.
|
||||
|
||||
The implementation produces slightly different results from Pamparamm's implementation because ComfyUI will only run the hook for conds that have it attached, unlike the ModelPatcher based implementation which has special logic to avoid messing up negative prompts with attention masks. It's also slightly slower because ComfyUI can't batch cond and uncond calculations while the hook is in use.
|
||||
|
||||
As a consequence of this, however, you can also use `ATTN()` in your negative prompt, and it will work correctly.
|
||||
|
||||
### ATTN: Trigger Attention Couple
|
||||
|
||||
Use `ATTN()` to mark a prompt to be used with Attention Couple. `ATTN()` needs to be combined with either `MASK()` or `IMASK()` to work correctly.
|
||||
|
||||
If no mask is specified, an implicit `MASK()` is assumed.
|
||||
|
||||
For attention masking to take effect, you need at least two prompt segments with the `ATTN()` marker (separated with `AND`). A single prompt with `ATTN()` will simply ignore the marker.
|
||||
|
||||
For the first prompt (and the first prompt only) you can also use `FILL()` to automatically mask all parts not masked by other prompt segments.
|
||||
|
||||
For example:
|
||||
```
|
||||
dog FILL() ATTN() AND cat MASK(0.5 1) ATTN()
|
||||
```
|
||||
|
||||
If typing `ATTN() MASK()` feels bothersome, try the following macro:
|
||||
```
|
||||
DEF(AM=ATTN() MASK($1))
|
||||
```
|
||||
and then use it like `MASK`: `AM(0 1, 0.5 1)`
|
||||
|
||||
## TE_WEIGHT
|
||||
|
||||
For models using multiple text encoders, you can set weights per TE using the syntax `TE_WEIGHT(clipname=weight, clipname2=weight2, ...)` where `clipname` is one of the encoder names printed by `TE(help)`. For example with SDXL, try `TE_WEIGHT(g=0.25, l=0.75)`.
|
||||
|
||||
The weights are applied as a multiplier to the TE output. You can also override pooled output multipliers using eg. `l_pooled`.
|
||||
|
||||
To set a default value for all encoders, use `TE_WEIGHT(all=weight)`
|
||||
+299
-163
@@ -1,7 +1,17 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from math import copysign
|
||||
import logging
|
||||
import itertools
|
||||
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
|
||||
def _norm_mag(w, n):
|
||||
d = w - 1
|
||||
return 1 + np.sign(d) * np.sqrt(np.abs(d) ** 2 / n)
|
||||
# return np.sign(w) * np.sqrt(np.abs(w)**2 / n)
|
||||
|
||||
|
||||
def _grouper(n, iterable):
|
||||
it = iter(iterable)
|
||||
@@ -12,29 +22,22 @@ def _grouper(n, iterable):
|
||||
yield chunk
|
||||
|
||||
|
||||
def _norm_mag(w, n):
|
||||
d = w - 1
|
||||
return 1 + np.sign(d) * np.sqrt(np.abs(d) ** 2 / n)
|
||||
# return np.sign(w) * np.sqrt(np.abs(w)**2 / n)
|
||||
def batched_clip_encode(tokens, length, encode_func, num_chunks):
|
||||
embs = []
|
||||
for e in _grouper(32, tokens):
|
||||
enc, pooled, *_ = encode_func(e)
|
||||
enc = enc.reshape((len(e), length, -1))
|
||||
embs.append(enc)
|
||||
|
||||
embs = torch.cat(embs)
|
||||
embs = embs.reshape((len(tokens) // num_chunks, length * num_chunks, -1))
|
||||
return embs
|
||||
|
||||
|
||||
def weights_like(weights, emb):
|
||||
return torch.tensor(weights, dtype=emb.dtype, device=emb.device).reshape(1, -1, 1).expand(emb.shape)
|
||||
|
||||
|
||||
def divide_length(word_ids, weights):
|
||||
sums = dict(zip(*np.unique(word_ids, return_counts=True)))
|
||||
sums[0] = 1
|
||||
weights = [[_norm_mag(w, sums[id]) if id != 0 else 1.0 for w, id in zip(x, y)] for x, y in zip(weights, word_ids)]
|
||||
return weights
|
||||
|
||||
|
||||
def shift_mean_weight(word_ids, weights):
|
||||
delta = 1 - np.mean([w for x, y in zip(weights, word_ids) for w, id in zip(x, y) if id != 0])
|
||||
weights = [[w if id == 0 else w + delta for w, id in zip(x, y)] for x, y in zip(weights, word_ids)]
|
||||
return weights
|
||||
|
||||
|
||||
def scale_to_norm(weights, word_ids, w_max):
|
||||
top = np.max(weights)
|
||||
w_max = min(top, w_max)
|
||||
@@ -48,61 +51,6 @@ def mask_word_id(tokens, word_ids, target_id, mask_token):
|
||||
return (new_tokens, mask)
|
||||
|
||||
|
||||
def batched_clip_encode(tokens, length, encode_func, num_chunks):
|
||||
embs = []
|
||||
for e in _grouper(32, tokens):
|
||||
enc, pooled = encode_func(e)
|
||||
enc = enc.reshape((len(e), length, -1))
|
||||
embs.append(enc)
|
||||
embs = torch.cat(embs)
|
||||
embs = embs.reshape((len(tokens) // num_chunks, length * num_chunks, -1))
|
||||
return embs
|
||||
|
||||
|
||||
def from_masked(tokens, weights, word_ids, base_emb, length, encode_func, m_token=266):
|
||||
pooled_base = base_emb[0, length - 1 : length, :]
|
||||
wids, inds = np.unique(np.array(word_ids).reshape(-1), return_index=True)
|
||||
weight_dict = dict((id, w) for id, w in zip(wids, np.array(weights).reshape(-1)[inds]) if w != 1.0)
|
||||
|
||||
if len(weight_dict) == 0:
|
||||
return torch.zeros_like(base_emb), base_emb[0, length - 1 : length, :]
|
||||
|
||||
weight_tensor = weights_like(weights, base_emb)
|
||||
|
||||
# m_token = (clip.tokenizer.end_token, 1.0) if clip.tokenizer.pad_with_end else (0,1.0)
|
||||
# TODO: find most suitable masking token here
|
||||
m_token = (m_token, 1.0)
|
||||
|
||||
ws = []
|
||||
masked_tokens = []
|
||||
masks = []
|
||||
|
||||
# create prompts
|
||||
for id, w in weight_dict.items():
|
||||
masked, m = mask_word_id(tokens, word_ids, id, m_token)
|
||||
masked_tokens.extend(masked)
|
||||
masks.append(weights_like(m, base_emb))
|
||||
|
||||
ws.append(w)
|
||||
|
||||
# batch process prompts
|
||||
embs = batched_clip_encode(masked_tokens, length, encode_func, len(tokens))
|
||||
masks = torch.cat(masks)
|
||||
|
||||
embs = base_emb.expand(embs.shape) - embs
|
||||
pooled = embs[0, length - 1 : length, :]
|
||||
|
||||
embs *= masks
|
||||
embs = embs.sum(axis=0, keepdim=True)
|
||||
|
||||
pooled_start = pooled_base.expand(len(ws), -1)
|
||||
ws = torch.tensor(ws).reshape(-1, 1).expand(pooled_start.shape)
|
||||
pooled = (pooled - pooled_start) * (ws - 1)
|
||||
pooled = pooled.mean(axis=0, keepdim=True)
|
||||
|
||||
return ((weight_tensor - 1) * embs), pooled_base + pooled
|
||||
|
||||
|
||||
def mask_inds(tokens, inds, mask_token):
|
||||
clip_len = len(tokens[0])
|
||||
inds_set = set(inds)
|
||||
@@ -112,34 +60,6 @@ def mask_inds(tokens, inds, mask_token):
|
||||
return new_tokens
|
||||
|
||||
|
||||
def down_weight(tokens, weights, word_ids, base_emb, length, encode_func, m_token=266):
|
||||
w, w_inv = np.unique(weights, return_inverse=True)
|
||||
|
||||
if np.sum(w < 1) == 0:
|
||||
return base_emb, tokens, base_emb[0, length - 1 : length, :]
|
||||
# m_token = (clip.tokenizer.end_token, 1.0) if clip.tokenizer.pad_with_end else (0,1.0)
|
||||
# using the comma token as a masking token seems to work better than aos tokens for SD 1.x
|
||||
m_token = (m_token, 1.0)
|
||||
|
||||
masked_tokens = []
|
||||
|
||||
masked_current = tokens
|
||||
for i in range(len(w)):
|
||||
if w[i] >= 1:
|
||||
continue
|
||||
masked_current = mask_inds(masked_current, np.where(w_inv == i)[0], m_token)
|
||||
masked_tokens.extend(masked_current)
|
||||
|
||||
embs = batched_clip_encode(masked_tokens, length, encode_func, len(tokens))
|
||||
embs = torch.cat([base_emb, embs])
|
||||
w = w[w <= 1.0]
|
||||
w_mix = np.diff([0] + w.tolist())
|
||||
w_mix = torch.tensor(w_mix, dtype=embs.dtype, device=embs.device).reshape((-1, 1, 1))
|
||||
|
||||
weighted_emb = (w_mix * embs).sum(axis=0, keepdim=True)
|
||||
return weighted_emb, masked_current, weighted_emb[0, length - 1 : length, :]
|
||||
|
||||
|
||||
def scale_emb_to_mag(base_emb, weighted_emb):
|
||||
norm_base = torch.linalg.norm(base_emb)
|
||||
norm_weighted = torch.linalg.norm(weighted_emb)
|
||||
@@ -147,12 +67,6 @@ def scale_emb_to_mag(base_emb, weighted_emb):
|
||||
return embeddings_final
|
||||
|
||||
|
||||
def recover_dist(base_emb, weighted_emb):
|
||||
fixed_std = (base_emb.std() / weighted_emb.std()) * (weighted_emb - weighted_emb.mean())
|
||||
embeddings_final = fixed_std + (base_emb.mean() - fixed_std.mean())
|
||||
return embeddings_final
|
||||
|
||||
|
||||
def perp_weight(weights, unweighted_embs, empty_embs):
|
||||
unweighted, unweighted_pooled = unweighted_embs
|
||||
zero, zero_pooled = empty_embs
|
||||
@@ -171,72 +85,294 @@ def perp_weight(weights, unweighted_embs, empty_embs):
|
||||
result[~over1] = (unweighted - (1 - weights) * perp)[~over1]
|
||||
result[weights == 0.0] = zero[weights == 0.0]
|
||||
|
||||
# Not sure if this is an implementation bug or if this just doesn't make sense with T5
|
||||
nans = result.isnan()
|
||||
if nans.any():
|
||||
log.warning("perp weight returned NaNs (known to happen with T5), replacing with 0")
|
||||
result[nans] = 0.0
|
||||
|
||||
return result, unweighted_pooled
|
||||
|
||||
|
||||
def style_comfy(encoder, tokens, **kwargs):
|
||||
tokens = encoder.without_word_ids(tokens)
|
||||
return encoder.encode_fn(tokens)
|
||||
|
||||
|
||||
def style_a1111(encoder, tokens, **kwargs):
|
||||
base_emb, pooled, *extra = encoder.base_emb(tokens)
|
||||
weighted_emb = base_emb * weights_like(encoder.weights(tokens), base_emb)
|
||||
weighted_emb = (base_emb.mean() / weighted_emb.mean()) * weighted_emb # renormalize
|
||||
return (weighted_emb, pooled) + tuple(extra)
|
||||
|
||||
|
||||
def style_compel(encoder, tokens, **kwargs):
|
||||
pos_tokens = encoder.weighted_with(tokens, lambda w: w if w > 1.0 else 1.0)
|
||||
weighted_emb, pooled, *extra = encoder.encode_fn(pos_tokens)
|
||||
weighted_emb, _, pooled = encoder.down_weight(
|
||||
pos_tokens, encoder.weights(tokens), encoder.word_ids(tokens), weighted_emb, pooled
|
||||
)
|
||||
return (weighted_emb, pooled) + tuple(extra)
|
||||
|
||||
|
||||
def style_comfypp(encoder, tokens, **kwargs):
|
||||
unweighted_tokens = encoder.unweighted(tokens)
|
||||
base_emb, pooled_base, *extra = encoder.base_emb(tokens)
|
||||
weighted_emb, tokens_down, _ = encoder.down_weight(
|
||||
unweighted_tokens, encoder.weights(tokens), encoder.word_ids(tokens), base_emb, pooled_base
|
||||
)
|
||||
weights = encoder.weights(encoder.weighted_with(tokens, lambda w: w if w > 1.0 else 1.0))
|
||||
embs, pooled = encoder.from_masked(
|
||||
unweighted_tokens,
|
||||
weights,
|
||||
encoder.word_ids(tokens),
|
||||
base_emb,
|
||||
pooled_base,
|
||||
)
|
||||
weighted_emb += embs
|
||||
|
||||
return (weighted_emb, pooled) + tuple(extra)
|
||||
|
||||
|
||||
def style_downweight(encoder, tokens, **kwargs):
|
||||
weights = scale_to_norm(encoder.weights(tokens), encoder.word_ids(tokens), encoder.w_max)
|
||||
base_emb, pooled_base, *extra = encoder.base_emb(tokens)
|
||||
weighted_emb, _, pooled = encoder.down_weight(
|
||||
encoder.unweighted(tokens), weights, encoder.word_ids(tokens), base_emb, pooled_base
|
||||
)
|
||||
|
||||
return (weighted_emb, pooled) + tuple(extra)
|
||||
|
||||
|
||||
def style_perp(encoder, tokens, **kwargs):
|
||||
zero_emb, zero_pooled, *_ = encoder.encode_fn(encoder.tokenizer.tokenize_with_weights(""))
|
||||
base_emb, pooled, *extra = encoder.base_emb(tokens)
|
||||
return perp_weight(encoder.weights(tokens), (base_emb, pooled), (zero_emb, zero_pooled)) + tuple(extra)
|
||||
|
||||
|
||||
def apply_negpip(encoder, emb, pooled, **kwargs):
|
||||
original_tokens = kwargs["original_tokens"]
|
||||
emb_negpip = torch.empty_like(emb).repeat(1, 2, 1)
|
||||
emb_negpip[:, 0::2, :] = emb
|
||||
emb_negpip[:, 1::2, :] = emb * weights_like(encoder.signs(original_tokens), emb)
|
||||
return emb_negpip, pooled
|
||||
|
||||
|
||||
def norm_length(encoder, tokens, **kwargs):
|
||||
word_ids = encoder.word_ids(tokens)
|
||||
sums = dict(zip(*np.unique(word_ids, return_counts=True)))
|
||||
sums[0] = 1
|
||||
tokens = [[(t, _norm_mag(w, sums[id]) if id != 0 else 1.0, id) for (t, w, id) in x] for x in tokens]
|
||||
return tokens
|
||||
|
||||
|
||||
def norm_mean(encoder, tokens, **kwargs):
|
||||
weights = encoder.weights(tokens)
|
||||
word_ids = encoder.word_ids(tokens)
|
||||
delta = 1 - np.mean([w for x, y in zip(weights, word_ids) for w, id in zip(x, y) if id != 0])
|
||||
tokens = [[(t, w if id == 0 else w + delta, id) for (t, w, id) in x] for x in tokens]
|
||||
return tokens
|
||||
|
||||
|
||||
def norm_none(encoder, tokens, **kwargs):
|
||||
return tokens
|
||||
|
||||
|
||||
class AdvancedEncoder:
|
||||
STYLES = {
|
||||
"A1111": style_a1111,
|
||||
"comfy": style_comfy,
|
||||
"comfy++": style_comfypp,
|
||||
"compel": style_compel,
|
||||
"down_weight": style_downweight,
|
||||
"perp": style_perp,
|
||||
}
|
||||
NORMALIZATION_OPS = {
|
||||
"none": norm_none,
|
||||
"length": norm_length,
|
||||
"mean": norm_mean,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def add_encoder(cls, name, fn):
|
||||
cls.STYLES[name] = fn
|
||||
|
||||
def add_normalization_op(cls, name, fn):
|
||||
cls.NORMALIZATION_OPS[name] = fn
|
||||
|
||||
@classmethod
|
||||
def weighted_with(cls, tokens, fn=id, word_ids=True):
|
||||
w = ([(t, fn(w), id) for t, w, id in x] for x in tokens)
|
||||
if not word_ids:
|
||||
w = cls.without_word_ids(w)
|
||||
return list(w)
|
||||
|
||||
@classmethod
|
||||
def unweighted(cls, tokens, word_ids=False):
|
||||
return cls.weighted_with(tokens, fn=lambda w: 1.0, word_ids=word_ids)
|
||||
|
||||
@classmethod
|
||||
def tokens_only(cls, tokens):
|
||||
return list([t[0] for t in x] for x in tokens)
|
||||
|
||||
@classmethod
|
||||
def weights(cls, tokens):
|
||||
return list([t[1] for t in x] for x in tokens)
|
||||
|
||||
@classmethod
|
||||
def word_ids(cls, tokens):
|
||||
return list([t[2] for t in x] for x in tokens)
|
||||
|
||||
@classmethod
|
||||
def signs(cls, tokens):
|
||||
return list([copysign(1, t[1]) for t in x] for x in tokens)
|
||||
|
||||
@classmethod
|
||||
def without_word_ids(cls, tokens):
|
||||
return list([(t, w) for t, w, _ in x] for x in tokens)
|
||||
|
||||
def __init__(self, encode_fn, style, normalization, tokenizer, m_token="+", w_max=1.0, **extra_args):
|
||||
self.encode_fn = encode_fn
|
||||
self.preprocessors = []
|
||||
self.postprocessors = []
|
||||
self.tokenizer = tokenizer
|
||||
self.extra_args = extra_args
|
||||
self.m_token = tokenizer.tokenize_with_weights(m_token)[0][tokenizer.tokens_start]
|
||||
self.max_length = tokenizer.max_length if tokenizer.pad_to_max_length else None
|
||||
self.w_max = w_max
|
||||
|
||||
if style == "comfy++" and not self.max_length:
|
||||
log.warning("comfy++ does not work with tokenizer %s, using default weighting", tokenizer)
|
||||
style = "comfy"
|
||||
|
||||
norms = normalization.split("+")
|
||||
assert style in self.STYLES, f"Invalid weight interpretation: {style}"
|
||||
self.weight_fn = self.STYLES[style]
|
||||
for n in norms:
|
||||
n = n.strip()
|
||||
assert n in self.NORMALIZATION_OPS, f"Invalid normalization: {normalization}"
|
||||
self.preprocessors.append(self.NORMALIZATION_OPS[n])
|
||||
|
||||
negpip = extra_args.get("has_negpip")
|
||||
if negpip:
|
||||
|
||||
def _encode(t):
|
||||
emb, pooled, *extra = encode_fn(t)
|
||||
return (emb[:, 0::2, :], pooled) + tuple(extra)
|
||||
|
||||
self.encode_fn = _encode
|
||||
self.preprocessors.insert(0, lambda encoder, tokens, **kwargs: encoder.weighted_with(tokens, abs))
|
||||
self.postprocessors.insert(0, apply_negpip)
|
||||
|
||||
def base_emb(self, tokens):
|
||||
unweighted = self.unweighted(tokens)
|
||||
return self.encode_fn(unweighted)
|
||||
|
||||
def down_weight(self, tokens, weights, word_ids, base_emb, pooled_base):
|
||||
w, w_inv = np.unique(weights, return_inverse=True)
|
||||
|
||||
if np.sum(w < 1) == 0:
|
||||
return (
|
||||
base_emb,
|
||||
tokens,
|
||||
(
|
||||
base_emb[0, self.max_length - 1 : self.max_length, :]
|
||||
if (pooled_base is not None and self.max_length)
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
masked_current = tokens
|
||||
emblist = [base_emb]
|
||||
for i in range(len(w)):
|
||||
if w[i] >= 1:
|
||||
continue
|
||||
masked_current = mask_inds(masked_current, np.where(w_inv == i)[0], self.m_token)
|
||||
masked, _, *extra = self.encode_fn(masked_current)
|
||||
emblist.append(masked)
|
||||
|
||||
embs = torch.cat(emblist)
|
||||
w = w[w <= 1.0]
|
||||
w_mix = np.diff([0] + w.tolist())
|
||||
w_mix = torch.tensor(w_mix, dtype=embs.dtype, device=embs.device).reshape((-1, 1, 1))
|
||||
|
||||
weighted_emb = (w_mix * embs).sum(axis=0, keepdim=True)
|
||||
pooled = pooled_base
|
||||
if pooled is not None and self.max_length:
|
||||
pooled = weighted_emb[0, self.max_length - 1 : self.max_length, :]
|
||||
return weighted_emb, masked_current, pooled
|
||||
|
||||
def from_masked(self, tokens, weights, word_ids, base_emb, pooled_base):
|
||||
wids, inds = np.unique(np.array(word_ids).reshape(-1), return_index=True)
|
||||
weight_dict = dict((id, w) for id, w in zip(wids, np.array(weights).reshape(-1)[inds]) if w != 1.0)
|
||||
|
||||
if len(weight_dict) == 0:
|
||||
return torch.zeros_like(base_emb), torch.zeros_like(pooled_base) if pooled_base is not None else None
|
||||
|
||||
weight_tensor = weights_like(weights, base_emb)
|
||||
|
||||
ws = []
|
||||
masked_tokens = []
|
||||
masks = []
|
||||
|
||||
# create prompts
|
||||
for id, w in weight_dict.items():
|
||||
masked, m = mask_word_id(tokens, word_ids, id, self.m_token)
|
||||
masks.append(weights_like(m, base_emb))
|
||||
masked_tokens.extend(masked)
|
||||
|
||||
ws.append(w)
|
||||
|
||||
# TODO: figure out how to get rid of this
|
||||
embs = batched_clip_encode(masked_tokens, self.max_length, self.encode_fn, len(tokens))
|
||||
masks = torch.cat(masks)
|
||||
|
||||
embs = base_emb.expand(embs.shape) - embs
|
||||
if pooled_base is not None and self.max_length:
|
||||
pooled = embs[0, self.max_length - 1 : self.max_length, :]
|
||||
pooled_start = pooled_base.expand(len(ws), -1)
|
||||
ws = torch.tensor(ws).reshape(-1, 1).expand(pooled_start.shape)
|
||||
pooled = (pooled - pooled_start) * (ws - 1)
|
||||
pooled = pooled.mean(axis=0, keepdim=True)
|
||||
pooled = pooled_base + pooled
|
||||
|
||||
if embs.shape[0] != masks.shape[0]:
|
||||
embs = embs.repeat(masks.shape[0], 1, 1)
|
||||
embs *= masks
|
||||
embs = embs.sum(axis=0, keepdim=True)
|
||||
|
||||
return ((weight_tensor - 1) * embs), pooled
|
||||
|
||||
def __call__(self, tokens, apply_to_pooled=False, return_pooled=False):
|
||||
normalized_tokens = tokens
|
||||
for op in self.preprocessors:
|
||||
normalized_tokens = op(self, normalized_tokens)
|
||||
|
||||
emb, pooled, *extra = self.weight_fn(self, normalized_tokens, original_tokens=tokens)
|
||||
|
||||
for fn in self.postprocessors:
|
||||
emb, pooled = fn(self, emb, pooled, tokens=tokens, original_tokens=tokens)
|
||||
|
||||
if not return_pooled:
|
||||
pooled = None
|
||||
elif not apply_to_pooled:
|
||||
_, pooled, *_ = self.base_emb(tokens)
|
||||
return (emb, pooled) + tuple(extra)
|
||||
|
||||
|
||||
def advanced_encode_from_tokens(
|
||||
tokenized,
|
||||
token_normalization,
|
||||
weight_interpretation,
|
||||
encode_func,
|
||||
m_token=266,
|
||||
length=77,
|
||||
m_token="+",
|
||||
w_max=1.0,
|
||||
return_pooled=False,
|
||||
apply_to_pooled=False,
|
||||
**extra_args
|
||||
tokenizer=None,
|
||||
**extra_args,
|
||||
):
|
||||
tokens = [[t for t, _, _ in x] for x in tokenized]
|
||||
weights = [[w for _, w, _ in x] for x in tokenized]
|
||||
word_ids = [[wid for _, _, wid in x] for x in tokenized]
|
||||
|
||||
for op in token_normalization.split("+"):
|
||||
op = op.strip()
|
||||
if op == "length":
|
||||
# distribute down/up weights over word lengths
|
||||
weights = divide_length(word_ids, weights)
|
||||
if op == "mean":
|
||||
weights = shift_mean_weight(word_ids, weights)
|
||||
|
||||
pooled = None
|
||||
|
||||
if weight_interpretation == "comfy":
|
||||
weighted_tokens = [[(t, w) for t, w in zip(x, y)] for x, y in zip(tokens, weights)]
|
||||
weighted_emb, pooled_base = encode_func(weighted_tokens)
|
||||
pooled = pooled_base
|
||||
else:
|
||||
unweighted_tokens = [[(t, 1.0) for t, _, _ in x] for x in tokenized]
|
||||
base_emb, pooled_base = encode_func(unweighted_tokens)
|
||||
|
||||
if weight_interpretation == "A1111":
|
||||
weighted_emb = base_emb * weights_like(weights, base_emb) # from_zero
|
||||
weighted_emb = (base_emb.mean() / weighted_emb.mean()) * weighted_emb # renormalize
|
||||
pooled = pooled_base
|
||||
|
||||
if weight_interpretation == "compel":
|
||||
pos_tokens = [[(t, w) if w >= 1.0 else (t, 1.0) for t, w in zip(x, y)] for x, y in zip(tokens, weights)]
|
||||
weighted_emb, _ = encode_func(pos_tokens)
|
||||
weighted_emb, _, pooled = down_weight(pos_tokens, weights, word_ids, weighted_emb, length, encode_func)
|
||||
|
||||
if weight_interpretation == "comfy++":
|
||||
weighted_emb, tokens_down, _ = down_weight(unweighted_tokens, weights, word_ids, base_emb, length, encode_func)
|
||||
weights = [[w if w > 1.0 else 1.0 for w in x] for x in weights]
|
||||
# unweighted_tokens = [[(t,1.0) for t, _,_ in x] for x in tokens_down]
|
||||
embs, pooled = from_masked(unweighted_tokens, weights, word_ids, base_emb, length, encode_func)
|
||||
weighted_emb += embs
|
||||
|
||||
if weight_interpretation == "down_weight":
|
||||
weights = scale_to_norm(weights, word_ids, w_max)
|
||||
weighted_emb, _, pooled = down_weight(unweighted_tokens, weights, word_ids, base_emb, length, encode_func)
|
||||
|
||||
if weight_interpretation == "perp":
|
||||
weighted_emb, pooled = perp_weight(
|
||||
weights, (base_emb, pooled_base), encode_func(extra_args["tokenizer"].tokenize_with_weights(""))
|
||||
)
|
||||
|
||||
if return_pooled:
|
||||
if apply_to_pooled:
|
||||
return weighted_emb, pooled
|
||||
else:
|
||||
return weighted_emb, pooled_base
|
||||
return weighted_emb, None
|
||||
enc = AdvancedEncoder(
|
||||
encode_func, weight_interpretation, token_normalization, tokenizer, m_token, w_max, **extra_args
|
||||
)
|
||||
return enc(tokenized, return_pooled=return_pooled, apply_to_pooled=apply_to_pooled)
|
||||
|
||||
@@ -2,31 +2,46 @@
|
||||
# Original implementation by laksjdjf, hako-mikan, Haoming02 licensed under GPL-3.0
|
||||
# https://github.com/laksjdjf/cgem156-ComfyUI/blob/1f5533f7f31345bafe4b833cbee15a3c4ad74167/scripts/attention_couple/node.py
|
||||
# https://github.com/Haoming02/sd-forge-couple/blob/e8e258e982a8d149ba59a4bc43b945467604311c/scripts/attention_couple.py
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from comfy.hooks import TransformerOptionsHook, HookGroup, EnumHookScope, set_hooks_for_conditioning
|
||||
|
||||
from comfy.hooks import EnumHookScope, HookGroup, TransformerOptionsHook, set_hooks_for_conditioning
|
||||
from comfy.model_patcher import ModelPatcher
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
|
||||
def set_cond_attnmask(base_cond, extra_conds, fill=False):
|
||||
hook = AttentionCoupleHook(base_cond[0], extra_conds, fill=fill)
|
||||
hook = AttentionCoupleHook()
|
||||
c = [base_cond[0][0], base_cond[0][1].copy()]
|
||||
# hook uses these, remove them to avoid doing latent masking
|
||||
c[1].pop("mask", None)
|
||||
c[1].pop("strength", None)
|
||||
c[1].pop("mask_strength", None)
|
||||
c = [c]
|
||||
c.extend(base_cond[1:])
|
||||
|
||||
hook.initialize_regions(base_cond[0], extra_conds, fill=fill)
|
||||
group = HookGroup()
|
||||
group.add(hook)
|
||||
return set_hooks_for_conditioning(base_cond, hooks=group)
|
||||
|
||||
return set_hooks_for_conditioning(c, hooks=group, append_hooks=True)
|
||||
|
||||
|
||||
def lcm_for_list(numbers):
|
||||
current_lcm = numbers[0]
|
||||
for number in numbers[1:]:
|
||||
current_lcm = math.lcm(current_lcm, number)
|
||||
return current_lcm
|
||||
def get_mask(mask, batch_size, num_tokens, extra_options):
|
||||
activations_shape = extra_options["activations_shape"]
|
||||
size = activations_shape[-2:]
|
||||
|
||||
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(batch_size, dim=0)
|
||||
|
||||
return mask_downsample_reshaped
|
||||
|
||||
|
||||
class Proxy:
|
||||
@@ -42,109 +57,192 @@ class Proxy:
|
||||
|
||||
|
||||
class AttentionCoupleHook(TransformerOptionsHook):
|
||||
def __init__(self, base_cond, conds, fill):
|
||||
COND_UNCOND_COUPLE_OPTION = "cond_or_uncond_hook_couple"
|
||||
COND = 0
|
||||
UNCOND = 1
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(hook_scope=EnumHookScope.HookedOnly)
|
||||
|
||||
self.transformers_dict = {
|
||||
"patches": {
|
||||
"attn2_output_patch": [Proxy(self.attn2_output_patch)],
|
||||
"attn2_patch": [Proxy(self.attn2_patch)],
|
||||
}
|
||||
}
|
||||
self.has_negpip = False
|
||||
|
||||
# calculate later. All clones must refer to the same kv dict
|
||||
self.kv = {"k": None, "v": None}
|
||||
|
||||
def initialize_regions(self, base_cond, conds, fill):
|
||||
self.num_conds = len(conds) + 1
|
||||
self.base_strength = base_cond[1].pop("strength", 1.0)
|
||||
self.base_strength = base_cond[1].get("strength", 1.0)
|
||||
self.strengths = [cond[1].get("strength", 1.0) for cond in conds]
|
||||
self.conds: list[torch.Tensor] = [base_cond[0]] + [cond[0] for cond in conds]
|
||||
base_mask = base_cond[1].pop("mask", None)
|
||||
masks = [cond[1].pop("mask") * cond[1].pop("mask_strength") 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]
|
||||
if len(masks) < 1:
|
||||
raise ValueError("Attention Couple hook makes no sense without masked conds")
|
||||
|
||||
if base_mask is None and not fill:
|
||||
raise ValueError("You must specify a base mask when fill=False")
|
||||
elif base_mask is None:
|
||||
if any(m is None for m in masks):
|
||||
raise ValueError("All conds given to Attention Couple must have masks")
|
||||
|
||||
if any(m.shape != masks[0].shape for m in masks) or (
|
||||
base_mask is not None and base_mask.shape != masks[0].shape
|
||||
):
|
||||
largest_shape = max(m.shape for m in masks)
|
||||
if base_mask is not None:
|
||||
largest_shape = max(largest_shape, base_mask.shape)
|
||||
log.warning("Attention Couple: Masks are irregularly shaped, resizing them all to match the largest")
|
||||
for i in range(len(masks)):
|
||||
masks[i] = F.interpolate(masks[i].unsqueeze(1), size=largest_shape[1:], mode="nearest-exact").squeeze(1)
|
||||
|
||||
if base_mask is not None:
|
||||
base_mask = F.interpolate(base_mask.unsqueeze(1), size=largest_shape[1:], mode="nearest-exact").squeeze(
|
||||
1
|
||||
)
|
||||
|
||||
if base_mask is None:
|
||||
if not fill:
|
||||
raise ValueError("You must specify a base mask when fill=False")
|
||||
sum = torch.stack(masks, dim=0).sum(dim=0)
|
||||
base_mask = torch.zeros_like(sum)
|
||||
base_mask[sum <= 0] = 1.0
|
||||
|
||||
mask = [base_mask] + masks
|
||||
mask = torch.stack(mask, dim=0)
|
||||
if mask.sum(dim=0).min() <= 0 and not fill:
|
||||
raise ValueError("Masks contain non-filled areas")
|
||||
|
||||
self.mask = mask / mask.sum(dim=0, keepdim=True)
|
||||
# calculate later
|
||||
self.conds_k_tensor = None
|
||||
self.conds_v_tensor = None
|
||||
|
||||
def on_apply_hooks(self, model: ModelPatcher, transformer_options: dict[str]):
|
||||
if self.conds_k_tensor is None:
|
||||
attn_patches = model.model_options["transformer_options"].get("patches", {}).get("attn2_patch", [])
|
||||
has_negpip = any("negpip_attn" in i.__name__ for i in attn_patches)
|
||||
log.debug("AttentionCouple has_negpip=%s", has_negpip)
|
||||
def on_apply_hooks(self, model: ModelPatcher, transformer_options: dict[str, Any]):
|
||||
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)
|
||||
|
||||
conds_kv = (
|
||||
[(cond[:, 0::2], cond[:, 1::2]) for cond in self.conds]
|
||||
if has_negpip
|
||||
else [(cond, cond) for cond in self.conds]
|
||||
)
|
||||
|
||||
num_tokens_k = [cond[0].shape[1] for cond in conds_kv]
|
||||
num_tokens_v = [cond[1].shape[1] for cond in conds_kv]
|
||||
|
||||
lcm_tokens_k = lcm_for_list(num_tokens_k)
|
||||
lcm_tokens_v = lcm_for_list(num_tokens_v)
|
||||
# Skip the base cond here, which is always first
|
||||
self.conds_k_tensor = torch.cat(
|
||||
[
|
||||
cond[0].repeat(1, lcm_tokens_k // num_tokens_k[i + 1], 1) * self.strengths[i]
|
||||
for i, cond in enumerate(conds_kv[1:])
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
if has_negpip:
|
||||
self.conds_v_tensor = torch.cat(
|
||||
[
|
||||
cond[1].repeat(1, lcm_tokens_v // num_tokens_v[i + 1], 1) * self.strengths[i]
|
||||
for i, cond in enumerate(conds_kv[1:])
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
if self.has_negpip:
|
||||
self.kv["k"] = [cond[:, 0::2] for cond in self.conds[1:]]
|
||||
self.kv["v"] = [cond[:, 1::2] for cond in self.conds[1:]]
|
||||
else:
|
||||
self.conds_v_tensor = self.conds_k_tensor
|
||||
self.kv["k"] = self.kv["v"] = self.conds[1:]
|
||||
|
||||
return super().on_apply_hooks(model, transformer_options)
|
||||
|
||||
def clone(self):
|
||||
c: AttentionCoupleHook = super().clone()
|
||||
c.mask = self.mask
|
||||
c.conds = self.conds
|
||||
c.kv = self.kv
|
||||
c.has_negpip = self.has_negpip
|
||||
c.base_strength = self.base_strength
|
||||
c.strengths = self.strengths
|
||||
c.num_conds = self.num_conds
|
||||
return c
|
||||
|
||||
def to(self, *args, **kwargs):
|
||||
self.conds = [c.to(*args, **kwargs) for c in self.conds]
|
||||
self.mask = self.mask.to(*args, **kwargs)
|
||||
if self.kv["k"] is not None:
|
||||
self.kv["k"] = [c.to(*args, **kwargs) for c in self.kv["k"]]
|
||||
self.kv["v"] = [c.to(*args, **kwargs) for c in self.kv["v"]]
|
||||
return self
|
||||
|
||||
def attn2_patch(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, extra_options):
|
||||
cond_or_uncond = extra_options["cond_or_uncond"]
|
||||
num_chunks = len(cond_or_uncond) # should always be 1
|
||||
cond_or_uncond_couple = extra_options[self.COND_UNCOND_COUPLE_OPTION] = list(cond_or_uncond)
|
||||
num_chunks = len(cond_or_uncond)
|
||||
|
||||
# Cloning messes up the device sometimes
|
||||
if self.kv["k"][0].device != k.device:
|
||||
self.to(k)
|
||||
|
||||
conds_k = self.kv["k"]
|
||||
conds_v = self.kv["v"]
|
||||
|
||||
lcm_tokens_k = math.lcm(k.shape[1], *(cond.shape[1] for cond in conds_k))
|
||||
lcm_tokens_v = math.lcm(v.shape[1], *(cond.shape[1] for cond in conds_v))
|
||||
q_chunks = q.chunk(num_chunks, dim=0)
|
||||
k_chunks = k.chunk(num_chunks, dim=0)
|
||||
v_chunks = v.chunk(num_chunks, dim=0)
|
||||
|
||||
bs = q.shape[0] // num_chunks
|
||||
|
||||
conds_k_tensor = self.conds_k_tensor.expand(bs, *self.conds_k_tensor.shape[1:])
|
||||
conds_v_tensor = self.conds_v_tensor.expand(bs, *self.conds_v_tensor.shape[1:])
|
||||
conds_k_tensor = conds_v_tensor = torch.cat(
|
||||
[cond.repeat(bs, lcm_tokens_k // cond.shape[1], 1) * self.strengths[i] for i, cond in enumerate(conds_k)],
|
||||
dim=0,
|
||||
)
|
||||
if self.has_negpip:
|
||||
conds_v_tensor = torch.cat(
|
||||
[
|
||||
cond.repeat(bs, lcm_tokens_v // cond.shape[1], 1) * self.strengths[i]
|
||||
for i, cond in enumerate(conds_v)
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
|
||||
q = q.repeat(self.num_conds, 1, 1)
|
||||
k = k.repeat(1, self.conds_k_tensor.shape[1] // k.shape[1], 1)
|
||||
v = v.repeat(1, self.conds_v_tensor.shape[1] // v.shape[1], 1)
|
||||
qs, ks, vs = [], [], []
|
||||
cond_or_uncond_couple.clear()
|
||||
|
||||
k = torch.cat([k * self.base_strength, conds_k_tensor], dim=0)
|
||||
v = torch.cat([v * self.base_strength, conds_v_tensor], dim=0)
|
||||
for i, cond_type in enumerate(cond_or_uncond):
|
||||
q_target = q_chunks[i]
|
||||
k_target = k_chunks[i].repeat(1, lcm_tokens_k // k.shape[1], 1)
|
||||
v_target = v_chunks[i].repeat(1, lcm_tokens_v // v.shape[1], 1)
|
||||
if cond_type == self.UNCOND:
|
||||
qs.append(q_target)
|
||||
ks.append(k_target)
|
||||
vs.append(v_target)
|
||||
cond_or_uncond_couple.append(self.UNCOND)
|
||||
else:
|
||||
qs.append(q_target.repeat(self.num_conds, 1, 1))
|
||||
ks.append(
|
||||
torch.cat(
|
||||
[
|
||||
k_target * self.base_strength,
|
||||
conds_k_tensor,
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
)
|
||||
vs.append(
|
||||
torch.cat(
|
||||
[
|
||||
v_target * self.base_strength,
|
||||
conds_v_tensor,
|
||||
],
|
||||
dim=0,
|
||||
)
|
||||
)
|
||||
cond_or_uncond_couple.extend(itertools.repeat(self.COND, self.num_conds))
|
||||
|
||||
q = torch.cat(qs, dim=0)
|
||||
k = torch.cat(ks, dim=0)
|
||||
v = torch.cat(vs, dim=0)
|
||||
|
||||
return q, k, v
|
||||
|
||||
def attn2_output_patch(self, out, extra_options):
|
||||
# out has been extended to shape [num_conds*batch_size, TOKENS, N]
|
||||
# out is [b1c1 b1c2 ... b1cN, b2c1 b2c2 ... b2cn, ...]
|
||||
num_conds = self.mask.shape[0]
|
||||
bs = out.shape[0] // num_conds
|
||||
num_tokens = out.shape[1]
|
||||
mask_size = extra_options["activations_shape"][-2:]
|
||||
mask_downsample = F.interpolate(self.mask, size=mask_size, mode="nearest")
|
||||
mask_downsample = mask_downsample.view(num_conds, num_tokens, 1).repeat_interleave(bs, dim=0)
|
||||
cond_or_uncond = extra_options[self.COND_UNCOND_COUPLE_OPTION]
|
||||
bs = out.shape[0] // len(cond_or_uncond)
|
||||
mask_downsample = get_mask(self.mask, bs, out.shape[1], extra_options)
|
||||
outputs = []
|
||||
cond_outputs = []
|
||||
i_cond = 0
|
||||
for i, cond_type in enumerate(cond_or_uncond):
|
||||
pos, next_pos = i * bs, (i + 1) * bs
|
||||
|
||||
# cond_outputs is [num_conds*bs, tokens, N], output needs to be [bs, tokens, N]
|
||||
cond_outputs = out * mask_downsample
|
||||
cond_output = cond_outputs.view(num_conds, bs, out.shape[1], out.shape[2]).sum(0)
|
||||
return cond_output
|
||||
if cond_type == self.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)
|
||||
|
||||
@@ -209,6 +209,9 @@ def encode_regions(clip_regions, encode, tokenizer):
|
||||
debug_tokens("region", region_prompt, tokenizer)
|
||||
region_emb, _ = encode(region_prompt)
|
||||
region_emb -= base_embedding_start
|
||||
# NegPiP support:
|
||||
if region_emb.shape[1] == 2 * region_masking.shape[1]:
|
||||
region_masking = torch.repeat_interleave(region_masking, 2, dim=1)
|
||||
region_emb *= region_masking
|
||||
|
||||
region_embeddings.append(region_emb)
|
||||
@@ -217,6 +220,10 @@ def encode_regions(clip_regions, encode, tokenizer):
|
||||
embeddings_final_mask = torch.tensor(
|
||||
global_region_mask, dtype=base_embedding_full.dtype, device=base_embedding_full.device
|
||||
).unsqueeze(-1)
|
||||
# NegPiP support:
|
||||
if region_embeddings.shape[1] == 2 * embeddings_final_mask.shape[1]:
|
||||
embeddings_final_mask = torch.repeat_interleave(embeddings_final_mask, 2, dim=1)
|
||||
|
||||
embeddings_final = base_embedding_start * embeddings_final_mask + base_embedding_outer * (1 - embeddings_final_mask)
|
||||
embeddings_final += region_embeddings
|
||||
return embeddings_final, pool
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import main
|
||||
import nodes
|
||||
import prompt_control.adv_encode
|
||||
|
||||
(l,) = nodes.CLIPLoader.load_clip(None, "clip_l.safetensors")
|
||||
(t5,) = nodes.CLIPLoader.load_clip(None, "t5base.safetensors")
|
||||
|
||||
id(main) # get rid of warning
|
||||
|
||||
|
||||
def adv(t, text, style="A1111", norm="none", new=True, **kwargs):
|
||||
c = t.tokenize(text, return_word_ids=True)
|
||||
if new:
|
||||
style = "new+" + style
|
||||
if t is t5:
|
||||
te = t.patcher.model.t5base.encode_token_weights
|
||||
token = t.tokenizer.clip_t5base
|
||||
tok = c["t5base"]
|
||||
else:
|
||||
te = t.patcher.model.clip_l.encode_token_weights
|
||||
token = t.tokenizer.clip_l
|
||||
tok = c["l"]
|
||||
return prompt_control.adv_encode.advanced_encode_from_tokens(tok, norm, style, te, tokenizer=token)
|
||||
|
||||
|
||||
def adv_all(t, text, styles=[], **kwargs):
|
||||
r = []
|
||||
for s in styles or prompt_control.adv_encode.AdvancedEncoder.STYLES:
|
||||
print("Testing", s, kwargs)
|
||||
r.append([s, adv(t, text, style=s, **kwargs)])
|
||||
return r
|
||||
|
||||
|
||||
def replacenan(t):
|
||||
t[t.isnan()] = 42.123321
|
||||
return t
|
||||
|
||||
|
||||
def adv_equal(t, text, **kwargs):
|
||||
old = adv_all(t, text, new=False, **kwargs)
|
||||
new = adv_all(t, text, new=True, **kwargs)
|
||||
r = {}
|
||||
for i, o in enumerate(old):
|
||||
n = new[i]
|
||||
r[n[0]] = (replacenan(n[1][0]) == replacenan(o[1][0])).all()
|
||||
return r
|
||||
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
import comfy.utils
|
||||
|
||||
import comfy.hooks
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
from .utils import consolidate_schedule
|
||||
from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict
|
||||
|
||||
from .attention_couple_ppm import AttentionCoupleHook
|
||||
from .parser import parse_prompt_schedules
|
||||
from .utils import consolidate_schedule
|
||||
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
@@ -79,10 +83,51 @@ def lora_hooks_from_schedule(schedules, non_scheduled):
|
||||
return hooks
|
||||
|
||||
|
||||
class PCAttentionCoupleBatchNegative(ComfyNodeABC):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
return {
|
||||
"required": {
|
||||
"positive": (IO.CONDITIONING, {}),
|
||||
"negative": (IO.CONDITIONING, {}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.CONDITIONING, IO.CONDITIONING)
|
||||
RETURN_NAMES = ("positive", "negative")
|
||||
CATEGORY = "promptcontrol/v2"
|
||||
FUNCTION = "batch"
|
||||
EXPERIMENTAL = True
|
||||
|
||||
# May cause side-effects?
|
||||
# TODO: Support scheduling in negative prompt
|
||||
def batch(self, positive, negative):
|
||||
if len(negative) != 1:
|
||||
log.warning("Batching scheduled negatives is not supported yet")
|
||||
return (positive, negative)
|
||||
|
||||
negative_batch = []
|
||||
for p in positive:
|
||||
n = [negative[0][0], negative[0][1].copy()]
|
||||
n_hook_group: comfy.hooks.HookGroup = n[1].get("hooks", comfy.hooks.HookGroup()).clone()
|
||||
p_hook_group: comfy.hooks.HookGroup = p[1].get("hooks", comfy.hooks.HookGroup())
|
||||
attn_couple = [hook for hook in p_hook_group.hooks if isinstance(hook, AttentionCoupleHook)]
|
||||
for hook in attn_couple:
|
||||
n_hook_group.add(hook)
|
||||
n[1]["hooks"] = p_hook_group if n_hook_group.hooks == p_hook_group.hooks else n_hook_group
|
||||
n[1]["start_percent"] = p[1].get("start_percent", 0.0)
|
||||
n[1]["end_percent"] = p[1].get("end_percent", 1.0)
|
||||
negative_batch.append(n)
|
||||
|
||||
return (positive, negative_batch)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PCLoraHooksFromText": PCLoraHooksFromText,
|
||||
"PCAttentionCoupleBatchNegative": PCAttentionCoupleBatchNegative,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PCLoraHooksFromText": "PC: LoRA Hooks From Text (non-lazy)",
|
||||
"PCAttentionCoupleBatchNegative": "PC: Attention Couple (batch negative)",
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ class PCLazyLoraLoaderAdvanced:
|
||||
self, unique_id, model=None, clip=None, text="", apply_hooks=True, tags="", start=0.0, end=1.0, num_steps=0
|
||||
):
|
||||
schedule = parse_prompt_schedules(text, filters=tags, start=start, end=end, num_steps=num_steps)
|
||||
graph = GraphBuilder(f"{unique_id}-")
|
||||
graph = GraphBuilder()
|
||||
r = build_lora_schedule(graph, schedule, model, clip, apply_hooks=apply_hooks)
|
||||
return r
|
||||
|
||||
@@ -214,8 +214,7 @@ def build_scheduled_prompts(graph, schedules, clip):
|
||||
classname = "PCTextEncode"
|
||||
paramname = "text"
|
||||
if classnames:
|
||||
classname = classnames[0][0]
|
||||
paramname = classnames[0][1]
|
||||
classname, paramname = classnames[0].args
|
||||
node = graph.node(classname)
|
||||
node.set_input("clip", clip)
|
||||
node.set_input(paramname, p)
|
||||
@@ -265,7 +264,7 @@ class PCLazyTextEncodeAdvanced:
|
||||
|
||||
def apply(self, clip, text, unique_id, tags="", start=0.0, end=1.0, num_steps=0):
|
||||
schedules = parse_prompt_schedules(text, filters=tags, start=start, end=end, num_steps=num_steps)
|
||||
graph = GraphBuilder(f"{unique_id}-")
|
||||
graph = GraphBuilder()
|
||||
return build_scheduled_prompts(graph, schedules, clip)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from .parser import parse_prompt_schedules
|
||||
from .parser import parse_prompt_schedules, expand_macros
|
||||
from .nodes_lazy import NODE_CLASS_MAPPINGS as LAZY_NODES
|
||||
from .utils import expand_graph
|
||||
import json
|
||||
import folder_paths
|
||||
from pathlib import Path
|
||||
from comfy_execution.graph_utils import is_link
|
||||
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
@@ -20,7 +20,7 @@ class PCSaveExpandedWorkflow:
|
||||
"any": ("*", {}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "DYNPROMPT",
|
||||
"prompt": "PROMPT",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class PCSaveExpandedWorkflow:
|
||||
OUTPUT_NODE = True
|
||||
RETURN_TYPES = ()
|
||||
CATEGORY = "promptcontrol/tools"
|
||||
DESCRIPTION = "Saves the current expanded dynamic prompt into a JSON file"
|
||||
DESCRIPTION = "Expands lazy prompt control nodes in the prompt and saves the expanded prompt into a JSON file"
|
||||
|
||||
FUNCTION = "apply"
|
||||
|
||||
@@ -39,29 +39,12 @@ class PCSaveExpandedWorkflow:
|
||||
full_output_folder, filename, counter, subfolder, prefix = folder_paths.get_save_image_path(
|
||||
"pc_workflow_debug", self.output_dir
|
||||
)
|
||||
p = {}
|
||||
input_replace_map = {}
|
||||
for node in prompt.all_node_ids():
|
||||
n = prompt.get_node(node)
|
||||
t = n["class_type"]
|
||||
if t in LAZY_NODES:
|
||||
expanded_prompt = LAZY_NODES[t]().apply(**n["inputs"], unique_id=node)
|
||||
for k in expanded_prompt["expand"]:
|
||||
p[k] = expanded_prompt["expand"][k]
|
||||
for i, _ in enumerate(expanded_prompt["result"]):
|
||||
input_replace_map[(node, i)] = [k, i]
|
||||
else:
|
||||
p[node] = n
|
||||
for k in p:
|
||||
for ik in p[k]["inputs"]:
|
||||
x = p[k]["inputs"][ik]
|
||||
if is_link(x) and tuple(x) in input_replace_map:
|
||||
p[k]["inputs"][ik] = input_replace_map[tuple(x)]
|
||||
expanded = expand_graph(LAZY_NODES, prompt)
|
||||
file = f"{filename}_{counter:05}_.json"
|
||||
full_path = Path(full_output_folder) / file
|
||||
with open(full_path, "w") as f:
|
||||
log.info(f"Saving workflow to {full_path}")
|
||||
json.dump(p, f)
|
||||
json.dump(expanded, f)
|
||||
|
||||
return ()
|
||||
|
||||
@@ -209,6 +192,24 @@ class PCExtractScheduledPrompt:
|
||||
return (prompt_text,)
|
||||
|
||||
|
||||
class PCMacroExpand:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"multiline": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
CATEGORY = "promptcontrol/tools"
|
||||
FUNCTION = "apply"
|
||||
DESCRIPTION = "Expands DEF macros in a string and returns the result"
|
||||
|
||||
def apply(self, text):
|
||||
return (expand_macros(text),)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PCSetPCTextEncodeSettings": PCSetPCTextEncodeSettings,
|
||||
"PCAddMaskToCLIP": PCAddMaskToCLIP,
|
||||
@@ -216,6 +217,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"PCSetLogLevel": PCSetLogLevel,
|
||||
"PCExtractScheduledPrompt": PCExtractScheduledPrompt,
|
||||
"PCSaveExpandedWorkflow": PCSaveExpandedWorkflow,
|
||||
"PCMacroExpand": PCMacroExpand,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -225,4 +227,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PCSetLogLevel": "PC: Configure Logging (for debug)",
|
||||
"PCExtractScheduledPrompt": "PC: Extract Scheduled Prompt",
|
||||
"PCSaveExpandedWorkflow": "PC: Save Expanded Workflow (for debug)",
|
||||
"PCMacroExpand": "PC: Expand Macros",
|
||||
}
|
||||
|
||||
+53
-21
@@ -24,10 +24,40 @@ if lark.__version__ == "0.12.0":
|
||||
raise ImportError(x)
|
||||
|
||||
|
||||
ESCAPES = [
|
||||
("XxPCBackslashESCAPExX", "\\"),
|
||||
("XxPCColonESCAPExX", ":"),
|
||||
("XxPCCommentESCAPExX", "#"),
|
||||
]
|
||||
|
||||
|
||||
def escape_specials(string):
|
||||
for ph, c in ESCAPES:
|
||||
string = string.replace(rf"\{c}", ph)
|
||||
return string
|
||||
|
||||
|
||||
def restore_escaped(string):
|
||||
for ph, c in ESCAPES:
|
||||
string = string.replace(ph, c)
|
||||
return string
|
||||
|
||||
|
||||
def remove_comments(string):
|
||||
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)+
|
||||
prompt: (emphasized | embedding | scheduled | alternate | sequence | loraspec | PLAIN | | /\\:/ | /</ | />/ | WHITESPACE)+
|
||||
!emphasized: "(" prompt? ")"
|
||||
| "(" prompt ":" prompt ")"
|
||||
| "[" prompt "]"
|
||||
@@ -241,7 +271,7 @@ def at_step(step, filters, tree):
|
||||
return {"prompt": p, "loras": loraspecs}
|
||||
|
||||
def PLAIN(self, args):
|
||||
return args.replace("\\:", ":")
|
||||
return restore_escaped(args)
|
||||
|
||||
def FILENAME(self, value):
|
||||
return str(value)
|
||||
@@ -284,7 +314,8 @@ class PromptSchedule(object):
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.num_steps = num_steps
|
||||
self.prompt = prompt.strip()
|
||||
# placeholder is restored on parse
|
||||
self.prompt = remove_comments(escape_specials(prompt.strip()))
|
||||
self.defaults = {}
|
||||
self.loaded_loras = {}
|
||||
|
||||
@@ -373,27 +404,32 @@ def parse_search(search):
|
||||
args = ""
|
||||
name = search.strip()
|
||||
if arg_start > 0:
|
||||
arg_end = find_closing_paren(search, arg_start)
|
||||
arg_end = find_closing_paren(search, arg_start + 1)
|
||||
if arg_end < 0:
|
||||
arg_end = len(search)
|
||||
name = search[:arg_start].strip()
|
||||
args = search[arg_start + 1 : arg_end - 1]
|
||||
args = search[arg_start + 1 : arg_end]
|
||||
|
||||
if not name:
|
||||
return None
|
||||
args = args.strip()
|
||||
if args:
|
||||
# If using the form DEF(F()=$1) then the default value of $1 is the empty string
|
||||
if arg_start > 0:
|
||||
args = [a.strip() for a in args.split(";")]
|
||||
else:
|
||||
args = []
|
||||
return name, args
|
||||
|
||||
|
||||
def replace_def(text):
|
||||
def expand_macros(text):
|
||||
text, defs = get_function(text, "DEF", defaults=None)
|
||||
res = text
|
||||
prevres = text
|
||||
replacements = []
|
||||
for d in defs:
|
||||
r = d.split("=", 1)
|
||||
if not d.args:
|
||||
continue
|
||||
r = d.args[0].split("=", 1)
|
||||
search = parse_search(r[0].strip())
|
||||
if not search or len(r) != 2:
|
||||
log.warning("Ignoring invalid DEF(%s)", d)
|
||||
@@ -407,7 +443,6 @@ def replace_def(text):
|
||||
return text
|
||||
for search, replace in replacements:
|
||||
res = substitute_defcall(res, search, replace)
|
||||
res = substitute_def(res, search, replace)
|
||||
if res == prevres:
|
||||
break
|
||||
prevres = res
|
||||
@@ -417,19 +452,16 @@ def replace_def(text):
|
||||
return res
|
||||
|
||||
|
||||
def substitute_def(text, search, replace):
|
||||
search, default_args = search
|
||||
for i, v in enumerate(default_args):
|
||||
replace = re.sub(rf"\${i+1}\b", v, replace)
|
||||
return re.sub(rf"\b{re.escape(search)}\b", replace, text)
|
||||
|
||||
|
||||
def substitute_defcall(text, search, replace):
|
||||
name, default_args = search
|
||||
text, defns = get_function(text, name, defaults=None, placeholder=f"DEFNCALL{search}")
|
||||
for i, defn in enumerate(defns):
|
||||
ph = f"\0DEFNCALL{search}{i}\0"
|
||||
paramvals = [x.strip() for x in defn.split(";")]
|
||||
text, defns = get_function(text, name, defaults=None, placeholder=f"DEFNCALL{name}", require_args=False)
|
||||
for i, d in enumerate(defns):
|
||||
ph = d.placeholder
|
||||
assert ph is not None, "This is a bug"
|
||||
parameters = d.args
|
||||
paramvals = []
|
||||
if parameters:
|
||||
paramvals = [x.strip() for x in parameters[0].split(";")]
|
||||
r = replace
|
||||
for i, v in enumerate(paramvals):
|
||||
r = re.sub(rf"\${i+1}\b", v, r)
|
||||
@@ -443,5 +475,5 @@ def substitute_defcall(text, search, replace):
|
||||
|
||||
@lru_cache
|
||||
def parse_prompt_schedules(prompt, **kwargs):
|
||||
prompt = replace_def(prompt)
|
||||
prompt = expand_macros(prompt)
|
||||
return PromptSchedule(prompt, **kwargs)
|
||||
|
||||
+255
-151
@@ -1,10 +1,23 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
import torch
|
||||
import math
|
||||
from functools import partial
|
||||
from comfy_extras.nodes_mask import FeatherMask, MaskComposite
|
||||
from nodes import ConditioningAverage
|
||||
|
||||
from .utils import safe_float, get_function, parse_floats, smarter_split
|
||||
from .utils import (
|
||||
safe_float,
|
||||
get_function,
|
||||
split_by_function,
|
||||
parse_floats,
|
||||
smarter_split,
|
||||
call_node,
|
||||
split_quotable,
|
||||
FunctionSpec,
|
||||
ComfyConditioning,
|
||||
)
|
||||
from .adv_encode import advanced_encode_from_tokens
|
||||
from .cutoff import process_cuts
|
||||
from .parser import parse_cuts
|
||||
@@ -24,7 +37,7 @@ def get_sdxl(text, defaults):
|
||||
text, sdxl = get_function(text, "SDXL", ["none", "none", "none"])
|
||||
if not sdxl:
|
||||
return text, {}
|
||||
args = sdxl[0]
|
||||
args = sdxl[0].args
|
||||
d = defaults
|
||||
w, h = parse_floats(args[0], [d.get("sdxl_width", 1024), d.get("sdxl_height", 1024)], split_re="\\s+")
|
||||
tw, th = parse_floats(args[1], [d.get("sdxl_twidth", 1024), d.get("sdxl_theight", 1024)], split_re="\\s+")
|
||||
@@ -45,7 +58,7 @@ def get_clipweights(text, existing_spec=None):
|
||||
text, spec = get_function(text, "TE_WEIGHT", defaults=None)
|
||||
if not spec:
|
||||
return existing_spec or {}, text
|
||||
args = spec[0].strip()
|
||||
args = spec[0].args[0].strip()
|
||||
res = {}
|
||||
for arg in args.split(","):
|
||||
try:
|
||||
@@ -61,22 +74,24 @@ def get_style(text, default_style="comfy", default_normalization="none"):
|
||||
text, styles = get_function(text, "STYLE", [default_style, default_normalization])
|
||||
if not styles:
|
||||
return default_style, default_normalization, text
|
||||
style, normalization = styles[0]
|
||||
style, normalization = styles[0].args
|
||||
style = style.strip()
|
||||
normalization = normalization.strip()
|
||||
if style not in AVAILABLE_STYLES:
|
||||
if style.replace("old+", "") not in AVAILABLE_STYLES:
|
||||
log.warning("Unrecognized prompt style: %s. Using %s", style, default_style)
|
||||
style = default_style
|
||||
|
||||
if normalization not in AVAILABLE_NORMALIZATIONS:
|
||||
log.warning("Unrecognized prompt normalization: %s. Using %s", normalization, default_normalization)
|
||||
normalization = default_normalization
|
||||
for part in normalization.split("+"):
|
||||
if part not in AVAILABLE_NORMALIZATIONS:
|
||||
log.warning("Unrecognized prompt normalization: %s. Using %s", normalization, default_normalization)
|
||||
normalization = default_normalization
|
||||
break
|
||||
|
||||
return style, normalization, text
|
||||
|
||||
|
||||
def shuffle_chunk(shuffle, c):
|
||||
func, shuffle = shuffle
|
||||
def shuffle_chunk(func_spec: FunctionSpec, c: str) -> str:
|
||||
func = func_spec.name
|
||||
shuffle = func_spec.args
|
||||
shuffle_count = int(safe_float(shuffle[0], 0))
|
||||
_, separator, joiner = shuffle
|
||||
if separator == "default":
|
||||
@@ -125,57 +140,51 @@ def fix_word_ids(tokens):
|
||||
return tokens
|
||||
|
||||
|
||||
def tokenize_chunks(clip, text, need_word_ids):
|
||||
chunks = re.split(r"\bBREAK\b", text)
|
||||
def tokenize_chunks(clip, text, need_word_ids, can_break):
|
||||
chunks = list(split_quotable(text, r"\bBREAK\b"))
|
||||
token_chunks = []
|
||||
shuffled_chunks = []
|
||||
for c in chunks:
|
||||
c, shuffles = get_function(c.strip(), "(SHIFT|SHUFFLE)", ["0", "default", "default"], return_func_name=True)
|
||||
c, shuffles = get_function(c.strip(), "(SHIFT|SHUFFLE)", ["0", "default", "default"])
|
||||
r = c
|
||||
for s in shuffles:
|
||||
r = shuffle_chunk(s, r)
|
||||
if r != c:
|
||||
log.info("Shuffled prompt chunk to %s", r)
|
||||
c = r
|
||||
shuffled_chunks.append(r)
|
||||
t = clip.tokenize(c, return_word_ids=need_word_ids)
|
||||
token_chunks.append(t)
|
||||
tokens = token_chunks[0]
|
||||
|
||||
for key in tokens:
|
||||
for c in token_chunks[1:]:
|
||||
tokens[key].extend(c[key])
|
||||
tokens = token_chunks[0]
|
||||
full_prompt = "".join(shuffled_chunks)
|
||||
full_tokenized = tokens
|
||||
if len(chunks) > 1:
|
||||
full_tokenized = clip.tokenize(full_prompt, return_word_ids=need_word_ids)
|
||||
for key in tokens:
|
||||
if not can_break.get(key):
|
||||
log.warning("BREAK does not make sense for %s, tokenizing as one chunk. Use CAT instead.", key)
|
||||
tokens[key] = full_tokenized[key]
|
||||
continue
|
||||
for c in token_chunks[1:]:
|
||||
tokens[key].extend(c[key])
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def encode_prompt_segment(
|
||||
clip,
|
||||
text,
|
||||
settings,
|
||||
default_style="comfy",
|
||||
default_normalization="none",
|
||||
clip_weights=None,
|
||||
) -> list[tuple[torch.Tensor, dict[str]]]:
|
||||
style, normalization, text = get_style(text, default_style, default_normalization)
|
||||
clip_weights, text = get_clipweights(text, clip_weights)
|
||||
text, cuts = parse_cuts(text)
|
||||
extra = {}
|
||||
if clip_weights:
|
||||
extra["clip_weights"] = clip_weights
|
||||
if cuts:
|
||||
extra["cuts"] = cuts
|
||||
|
||||
def tokenize(clip, text, can_break, empty_tokens):
|
||||
# defaults=None means there is no argument parsing at all
|
||||
text, l_prompts = get_function(text, "CLIP_L", defaults=None)
|
||||
text, te_prompts = get_function(text, "TE", defaults=None)
|
||||
need_word_ids = True
|
||||
tokens = tokenize_chunks(clip, text, need_word_ids)
|
||||
tokens = tokenize_chunks(clip, text, need_word_ids, can_break)
|
||||
|
||||
per_te_prompts = {}
|
||||
if l_prompts:
|
||||
log.warning("Note: CLIP_L is deprecated. Use TE(l=prompt) instead")
|
||||
per_te_prompts["l"] = l_prompts
|
||||
per_te_prompts["l"] = [x.args for x in l_prompts]
|
||||
|
||||
for prompt in te_prompts:
|
||||
prompt = prompt.args[0]
|
||||
if prompt.strip() == "help":
|
||||
log.info("Encoders available for TE: %s", ", ".join(tokens.keys()))
|
||||
continue
|
||||
@@ -196,29 +205,92 @@ def encode_prompt_segment(
|
||||
if per_te_prompts:
|
||||
for key in per_te_prompts:
|
||||
prompt = " ".join(per_te_prompts[key])
|
||||
tokens[key] = tokenize_chunks(clip, prompt, need_word_ids)[key]
|
||||
tokens[key] = tokenize_chunks(clip, prompt, need_word_ids, can_break)[key]
|
||||
log.info("Encoded prompt with TE '%s': %s", key, prompt)
|
||||
|
||||
maxlen = max(len(tokens[k]) for k in tokens)
|
||||
empty = None
|
||||
maxlen = max([0] + [len(tokens[k]) for k in tokens if can_break[k]])
|
||||
for k in tokens:
|
||||
if not can_break[k]:
|
||||
continue
|
||||
while len(tokens[k]) < maxlen:
|
||||
if empty is None:
|
||||
empty = clip.tokenize("", return_word_ids=need_word_ids)
|
||||
tokens[k] += empty[k]
|
||||
tokens[k] += empty_tokens[k]
|
||||
|
||||
tokens = fix_word_ids(tokens)
|
||||
return fix_word_ids(tokens)
|
||||
|
||||
tes = []
|
||||
for k in tokens:
|
||||
if k in ["g", "l"]:
|
||||
tes.append(f"clip_{k}")
|
||||
else:
|
||||
tes.append(k)
|
||||
|
||||
clip = hook_te(clip, tes, style, normalization, extra)
|
||||
def encode_prompt_segment(
|
||||
clip,
|
||||
text,
|
||||
settings,
|
||||
default_style="comfy",
|
||||
default_normalization="none",
|
||||
clip_weights=None,
|
||||
) -> list[ComfyConditioning]:
|
||||
style, normalization, text = get_style(text, default_style, default_normalization)
|
||||
clip_weights, text = get_clipweights(text, clip_weights)
|
||||
text, cuts = parse_cuts(text)
|
||||
extra = {}
|
||||
if clip_weights:
|
||||
extra["clip_weights"] = clip_weights
|
||||
if cuts:
|
||||
extra["cuts"] = cuts
|
||||
|
||||
return clip.encode_from_tokens_scheduled(tokens, add_dict=settings)
|
||||
empty = clip.tokenize("", return_word_ids=True)
|
||||
can_break = {}
|
||||
for k in empty:
|
||||
tokenizer = getattr(clip.tokenizer, f"clip_{k}", getattr(clip.tokenizer, k, None))
|
||||
can_break[k] = tokenizer and tokenizer.pad_to_max_length
|
||||
|
||||
clip = hook_te(clip, empty.keys(), style, normalization, extra)
|
||||
|
||||
# Chunks to ConditioningAverage:
|
||||
|
||||
text, averages = split_by_function(text, "AVG", ["0.5"], require_args=False)
|
||||
prompts_to_avg = []
|
||||
for chunk, avg in averages:
|
||||
w = safe_float(avg.args[0], 0.5)
|
||||
prompts_to_avg.append((text, w))
|
||||
text = chunk
|
||||
prompts_to_avg.append((text, 1.0))
|
||||
|
||||
conds_to_avg = []
|
||||
for prompt, weight in prompts_to_avg:
|
||||
conds_to_cat = []
|
||||
for c in split_quotable(prompt, r"\bCAT\b"):
|
||||
tokens = tokenize(clip, c, can_break, empty)
|
||||
conds_to_cat.append(clip.encode_from_tokens_scheduled(tokens, add_dict=settings))
|
||||
|
||||
base = conds_to_cat[0]
|
||||
for cond in conds_to_cat[1:]:
|
||||
assert len(cond) == len(base), "Conditioning length mismatch"
|
||||
# Pooled gets ignored
|
||||
for i in range(len(base)):
|
||||
c1 = base[i][0]
|
||||
c2 = cond[i][0]
|
||||
base[i][0] = torch.cat((c1, c2), 1)
|
||||
conds_to_avg.append((base, weight))
|
||||
|
||||
base, w = conds_to_avg[0]
|
||||
for cond, next_w in conds_to_avg[1:]:
|
||||
assert len(base) == len(cond), "Conditioning length mismatch"
|
||||
if w == 1.0:
|
||||
w = next_w
|
||||
continue
|
||||
for i in range(len(base)):
|
||||
(cond,) = call_node(ConditioningAverage, [base[i]], [cond[i]], w)
|
||||
base[i] = cond[0]
|
||||
w = next_w
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def calc_w(tensor, w):
|
||||
if math.isclose(w, 0):
|
||||
return torch.zeros_like(tensor)
|
||||
elif math.isclose(w, 1.0):
|
||||
return tensor
|
||||
else:
|
||||
return tensor * w
|
||||
|
||||
|
||||
def apply_weights(output, te_name, spec):
|
||||
@@ -232,7 +304,7 @@ def apply_weights(output, te_name, spec):
|
||||
default = spec.get("all", None)
|
||||
|
||||
if isinstance(output, tuple):
|
||||
out, pooled = output
|
||||
out, pooled, *extra = output
|
||||
pkey = te_name + "_pooled"
|
||||
if te_name in spec or pkey in spec or default is not None:
|
||||
w = spec.get(te_name, default)
|
||||
@@ -242,15 +314,16 @@ def apply_weights(output, te_name, spec):
|
||||
if pooled_w is None:
|
||||
pooled_w = 1.0
|
||||
log.info("Weighting %s output by %s, pooled by %s", te_name, w, pooled_w)
|
||||
out = out * w
|
||||
pooled = pooled * pooled_w
|
||||
out = calc_w(out, w)
|
||||
if pooled is not None:
|
||||
pooled = calc_w(pooled, pooled_w)
|
||||
|
||||
return out, pooled
|
||||
return (out, pooled) + tuple(extra)
|
||||
else:
|
||||
if te_name in spec or default is not None:
|
||||
w = spec.get(te_name, default)
|
||||
log.info("Weighting %s output by %s", te_name, w)
|
||||
output = output * w
|
||||
output = calc_w(output, w)
|
||||
return output
|
||||
|
||||
|
||||
@@ -271,15 +344,24 @@ def hook_te(clip, te_names, style, normalization, extra):
|
||||
return clip
|
||||
newclip = clip.clone()
|
||||
for te_name in te_names:
|
||||
if hasattr(clip.patcher.model, te_name):
|
||||
tokenizer = getattr(clip.tokenizer, f"clip_{te_name}", getattr(clip.tokenizer, te_name, None))
|
||||
if tokenizer:
|
||||
x = extra.copy()
|
||||
x["tokenizer"] = getattr(clip.tokenizer, te_name)
|
||||
log.debug("Hooked into %s with style=%s, normalization=%s", te_name, style, normalization)
|
||||
x["tokenizer"] = tokenizer
|
||||
if not hasattr(clip.patcher.model, te_name):
|
||||
te_name = "clip_" + te_name
|
||||
if not hasattr(clip.patcher.model, te_name):
|
||||
log.warning("TE model %s not found on model patcher. Skipping...", te_name)
|
||||
continue
|
||||
|
||||
log.debug("Hooked into te=%s with style=%s, normalization=%s", te_name, style, normalization)
|
||||
encode = clip.patcher.get_model_object(f"{te_name}.encode_token_weights")
|
||||
x["has_negpip"] = clip.patcher.model_options.get("ppm_negpip", False)
|
||||
newclip.patcher.add_object_patch(
|
||||
f"{te_name}.encode_token_weights",
|
||||
make_patch(
|
||||
te_name,
|
||||
clip.patcher.get_model_object(f"{te_name}.encode_token_weights"),
|
||||
encode,
|
||||
normalization,
|
||||
style,
|
||||
x,
|
||||
@@ -287,7 +369,7 @@ def hook_te(clip, te_names, style, normalization, extra):
|
||||
)
|
||||
# 'g' and 'l' exist in these are clip_g and clip_l
|
||||
else:
|
||||
log.debug("Tokens contain items with key %s but no TE found on object with that name.", te_name)
|
||||
log.warning("Tokens contain items with key %s but no tokenizer found on object with that name.", te_name)
|
||||
return newclip
|
||||
|
||||
|
||||
@@ -296,7 +378,7 @@ def get_area(text):
|
||||
if not areas:
|
||||
return text, None
|
||||
|
||||
args = areas[0]
|
||||
args = areas[0].args
|
||||
x, w = parse_floats(args[0], [0.0, 1.0], split_re="\\s+")
|
||||
y, h = parse_floats(args[1], [0.0, 1.0], split_re="\\s+")
|
||||
weight = safe_float(args[2], 1.0)
|
||||
@@ -323,7 +405,7 @@ def get_mask_size(text, defaults):
|
||||
text, sizes = get_function(text, "MASK_SIZE", ["512", "512"])
|
||||
if not sizes:
|
||||
return text, (defaults.get("mask_width", 512), defaults.get("mask_height", 512))
|
||||
w, h = sizes[0]
|
||||
w, h = sizes[0].args
|
||||
return text, (int(w), int(h))
|
||||
|
||||
|
||||
@@ -353,7 +435,7 @@ def make_mask(args, size, weight):
|
||||
mask = torch.full((h, w), 0, dtype=torch.float32, device="cpu")
|
||||
mask[ys[0] : ys[1], xs[0] : xs[1]] = weight
|
||||
mask = mask.unsqueeze(0)
|
||||
log.info("Mask xs=%s, ys=%s, shape=%s, weight=%s", xs, ys, mask.shape, weight)
|
||||
log.debug("Mask xs=%s, ys=%s, shape=%s, weight=%s", xs, ys, mask.shape, weight)
|
||||
return mask
|
||||
|
||||
|
||||
@@ -369,46 +451,53 @@ def get_mask(text, size, input_masks):
|
||||
|
||||
def feather(f, mask):
|
||||
l, t, r, b, *_ = [int(x) for x in parse_floats(f[0], [0, 0, 0, 0], split_re="\\s+")]
|
||||
mask = FeatherMask().feather(mask, l, t, r, b)[0]
|
||||
mask = call_node(FeatherMask, mask, l, t, r, b)[0]
|
||||
log.info("FeatherMask l=%s, t=%s, r=%s, b=%s", l, t, r, b)
|
||||
return mask
|
||||
|
||||
mask = None
|
||||
totalweight = 1.0
|
||||
if maskw:
|
||||
totalweight = safe_float(maskw[0][0], 1.0)
|
||||
totalweight = safe_float(maskw[0].args[0], 1.0)
|
||||
i = 0
|
||||
for m in masks:
|
||||
weight = safe_float(m[2], 1.0)
|
||||
op = m[3]
|
||||
nextmask = make_mask(m, size, weight)
|
||||
weight = safe_float(m.args[2], 1.0)
|
||||
op = m.args[3]
|
||||
nextmask = make_mask(m.args, size, weight)
|
||||
if i < len(feathers):
|
||||
nextmask = feather(feathers[i], nextmask)
|
||||
nextmask = feather(feathers[i].args, nextmask)
|
||||
i += 1
|
||||
if mask is not None:
|
||||
log.info("MaskComposite op=%s", op)
|
||||
mask = MaskComposite().combine(mask, nextmask, 0, 0, op)[0]
|
||||
mask = call_node(MaskComposite, mask, nextmask, 0, 0, op)[0]
|
||||
else:
|
||||
mask = nextmask
|
||||
|
||||
for idx, w, op in imasks:
|
||||
for im in imasks:
|
||||
idx, w, op = im.args
|
||||
idx = int(safe_float(idx, 0.0))
|
||||
w = safe_float(w, 1.0)
|
||||
if input_masks is None:
|
||||
log.warn(
|
||||
"IMASK requires you to attach custom masks to the CLIP object using PCAddMasksToClIP before using it"
|
||||
)
|
||||
input_masks = []
|
||||
|
||||
if len(input_masks) < idx + 1:
|
||||
log.warn("IMASK index %s not found, ignoring...", idx)
|
||||
continue
|
||||
nextmask = input_masks[idx] * w
|
||||
if i < len(feathers):
|
||||
nextmask = feather(feathers[i], nextmask)
|
||||
nextmask = feather(feathers[i].args, nextmask)
|
||||
i += 1
|
||||
if mask is not None:
|
||||
mask = MaskComposite().combine(mask, nextmask, 0, 0, op)[0]
|
||||
mask = call_node(MaskComposite, mask, nextmask, 0, 0, op)[0]
|
||||
else:
|
||||
mask = nextmask
|
||||
|
||||
# apply leftover FEATHER() specs to the whole
|
||||
for f in feathers[i:]:
|
||||
mask = feather(f, mask)
|
||||
mask = feather(f.args, mask)
|
||||
|
||||
return text, mask, totalweight
|
||||
|
||||
@@ -423,14 +512,15 @@ def get_noise(text):
|
||||
return text, None, None
|
||||
w = 0
|
||||
# Only take seed from first noise spec, for simplicity
|
||||
seed = safe_float(noises[0][1], "none")
|
||||
seed = noises[0].args[0].strip()
|
||||
if seed == "none":
|
||||
gen = None
|
||||
else:
|
||||
seed = safe_float(seed, 0)
|
||||
gen = torch.Generator()
|
||||
gen.manual_seed(int(seed))
|
||||
for n in noises:
|
||||
w += safe_float(n[0], 0.0)
|
||||
w += safe_float(n.args[0], 0.0)
|
||||
return text, max(min(w, 1.0), 0.0), gen
|
||||
|
||||
|
||||
@@ -443,21 +533,15 @@ def apply_noise(cond, weight, gen):
|
||||
return cond * (1 - weight) + n * weight
|
||||
|
||||
|
||||
def encode_prompt(clip, text, start_pct, end_pct, defaults, masks):
|
||||
# First style modifier applies to ANDed prompts too unless overridden
|
||||
style, normalization, text = get_style(text)
|
||||
text, mask_size = get_mask_size(text, defaults)
|
||||
|
||||
prompts = [p.strip() for p in re.split(r"\bAND\b", text)]
|
||||
|
||||
p, sdxl_opts = get_sdxl(prompts[0], defaults)
|
||||
prompts[0] = p
|
||||
def process_settings(prompt, defaults, masks, mask_size, sdxl_opts):
|
||||
if "ATTN()" in prompt:
|
||||
raise ValueError("ATTN() no longer works and has been replaced by COUPLE()")
|
||||
|
||||
def weight(t):
|
||||
opts = {}
|
||||
m = re.search(r":(-?\d\.?\d*)(![A-Za-z]+)?$", t)
|
||||
m = re.search(r":(-?\d\.?\d*)(![A-Za-z]+)?$", t.strip())
|
||||
if not m:
|
||||
return (1.0, opts, t)
|
||||
return (None, opts, t)
|
||||
w = float(m[1])
|
||||
tag = m[2]
|
||||
t = t[: m.span()[0]]
|
||||
@@ -466,56 +550,45 @@ def encode_prompt(clip, text, start_pct, end_pct, defaults, masks):
|
||||
|
||||
return w, opts, t
|
||||
|
||||
settings = {"prompt": prompt}
|
||||
|
||||
if "FILL()" in prompt:
|
||||
prompt = prompt.replace("FILL()", "")
|
||||
settings["x-promptcontrol.fill"] = True
|
||||
prompt, mask, mask_weight = get_mask(prompt, mask_size, masks)
|
||||
prompt, noise_w, generator = get_noise(prompt)
|
||||
prompt, area = get_area(prompt)
|
||||
prompt, local_sdxl_opts = get_sdxl(prompt, defaults)
|
||||
# Get weight last so other syntax doesn't interfere with it
|
||||
w, opts, prompt = weight(prompt)
|
||||
if w is not None:
|
||||
settings["strength"] = w
|
||||
settings.update(sdxl_opts)
|
||||
settings.update(local_sdxl_opts)
|
||||
if area:
|
||||
settings["area"] = area[0]
|
||||
settings["strength"] = area[1]
|
||||
settings["set_area_to_bounds"] = False
|
||||
if mask is not None:
|
||||
settings["mask"] = mask
|
||||
settings["mask_strength"] = mask_weight
|
||||
|
||||
return prompt, settings
|
||||
|
||||
|
||||
def encode_prompt(clip, text, start_pct, end_pct, defaults, masks):
|
||||
# First style modifier applies to ANDed prompts too unless overridden
|
||||
style, normalization, text = get_style(text)
|
||||
text, mask_size = get_mask_size(text, defaults)
|
||||
|
||||
prompts = list(split_quotable(text, r"\bAND\b"))
|
||||
|
||||
p, sdxl_opts = get_sdxl(prompts[0], defaults)
|
||||
prompts[0] = p
|
||||
|
||||
conds = []
|
||||
# TODO: is this still needed?
|
||||
# scale = sum(abs(weight(p)[0]) for p in prompts if not ("AREA(" in p or "MASK(" in p))
|
||||
attnmasked_prompts = []
|
||||
fill = False
|
||||
for prompt in prompts:
|
||||
attn_couple = False
|
||||
prompt_has_fill = False
|
||||
if "ATTN()" in prompt:
|
||||
prompt = prompt.replace("ATTN()", "")
|
||||
attn_couple = True
|
||||
if "FILL()" in prompt:
|
||||
prompt = prompt.replace("FILL()", "")
|
||||
prompt_has_fill = True
|
||||
prompt, mask, mask_weight = get_mask(prompt, mask_size, masks)
|
||||
text, noise_w, generator = get_noise(text)
|
||||
prompt, area = get_area(prompt)
|
||||
prompt, local_sdxl_opts = get_sdxl(prompt, defaults)
|
||||
# Get weight last so other syntax doesn't interfere with it
|
||||
w, opts, prompt = weight(prompt)
|
||||
if not w:
|
||||
continue
|
||||
settings = {"prompt": prompt}
|
||||
settings["strength"] = w
|
||||
settings.update(sdxl_opts)
|
||||
settings.update(local_sdxl_opts)
|
||||
if area:
|
||||
settings["area"] = area[0]
|
||||
settings["strength"] = area[1]
|
||||
settings["set_area_to_bounds"] = False
|
||||
if mask is not None:
|
||||
settings["mask"] = mask
|
||||
settings["mask_strength"] = mask_weight
|
||||
|
||||
settings["start_percent"] = start_pct
|
||||
settings["end_percent"] = end_pct
|
||||
|
||||
x = encode_prompt_segment(clip, prompt, settings, style, normalization)
|
||||
if attn_couple:
|
||||
if prompt_has_fill:
|
||||
if attnmasked_prompts:
|
||||
log.warning("FILL() can only be used for the first prompt, ignoring")
|
||||
elif mask is not None:
|
||||
log.warning("MASK() and FILL() can't be used together, ignoring FILL()")
|
||||
else:
|
||||
fill = True
|
||||
log.info("Using attention masking for prompt segment")
|
||||
attnmasked_prompts.extend(x)
|
||||
else:
|
||||
conds.extend(x)
|
||||
|
||||
def ensure_mask(c):
|
||||
if "mask" not in c[1]:
|
||||
@@ -524,20 +597,51 @@ def encode_prompt(clip, text, start_pct, end_pct, defaults, masks):
|
||||
c[1]["mask_strength"] = 1.0
|
||||
return c
|
||||
|
||||
if attnmasked_prompts:
|
||||
base_cond = attnmasked_prompts[0]
|
||||
if not fill:
|
||||
ensure_mask(base_cond)
|
||||
# else, set_cond_attnmask will have the base mask fill any unspecified areas
|
||||
base_cond = [base_cond]
|
||||
if len(attnmasked_prompts) > 1:
|
||||
base_cond = set_cond_attnmask(
|
||||
base_cond,
|
||||
[ensure_mask(c) for c in attnmasked_prompts[1:]],
|
||||
fill=fill,
|
||||
)
|
||||
else:
|
||||
log.warning("You must specify at least two prompt segments with ATTN() for attention couple to work")
|
||||
def couple_mask(args):
|
||||
assert len(args) <= 1, "Argument parsing failure. This is a bug in Prompt Control"
|
||||
if not args:
|
||||
return ""
|
||||
return f"MASK({args[0]})"
|
||||
|
||||
for prompt in prompts:
|
||||
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]
|
||||
encoded = []
|
||||
for p in prompts:
|
||||
p, settings = process_settings(p, defaults, masks, mask_size, sdxl_opts)
|
||||
if settings.get("strength") == 0: # weight is explicitly set to 0, skip
|
||||
continue
|
||||
settings["start_percent"] = start_pct
|
||||
settings["end_percent"] = end_pct
|
||||
x = encode_prompt_segment(clip, p, settings, style, normalization)
|
||||
encoded.append(x)
|
||||
|
||||
assert all(
|
||||
len(c) == len(encoded[0]) for c in encoded
|
||||
), "All encoded prompts didn't produce the same number of conds, I don't know what to do in this situation."
|
||||
|
||||
# each call to encode_prompt_segment can produce a number of conds based on any
|
||||
# scheduled LoRA hooks on the clip model. Zip them together with coupled prompts
|
||||
base_cond = []
|
||||
for base_cond, *attention_couple in zip(*encoded):
|
||||
s = base_cond[1]
|
||||
# If there are LoRAs on the CLIP, we need to fix start_percent and end_percent on the new conds for things to work properly.
|
||||
s["start_percent"] = s.get("clip_start_percent", s["start_percent"])
|
||||
s["end_percent"] = s.get("clip_end_percent", s["end_percent"])
|
||||
s.pop("clip_start_percent", None)
|
||||
s.pop("clip_end_percent", None)
|
||||
base_cond = [base_cond]
|
||||
if attention_couple:
|
||||
fill = base_cond[0][1].get("x-promptcontrol.fill")
|
||||
if not fill:
|
||||
ensure_mask(base_cond[0])
|
||||
# else, set_cond_attnmask will have the base mask fill any unspecified areas
|
||||
base_cond = set_cond_attnmask(
|
||||
base_cond,
|
||||
[ensure_mask(c) for c in attention_couple],
|
||||
fill=fill,
|
||||
)
|
||||
conds.extend(base_cond)
|
||||
|
||||
return conds
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
import numpy.testing as npt
|
||||
from os import environ
|
||||
import nodes
|
||||
import comfy_extras.nodes_mask
|
||||
from .nodes_base import PCTextEncode
|
||||
|
||||
clips = []
|
||||
|
||||
import logging
|
||||
|
||||
logging.basicConfig()
|
||||
|
||||
|
||||
def run(f, *args):
|
||||
if hasattr(f, "execute"):
|
||||
return f.execute(*args)
|
||||
else:
|
||||
return getattr(f, f.FUNCTION)(*args)
|
||||
|
||||
|
||||
def compare_hookgroup_mask(h1, h2):
|
||||
assert len(h1.hooks) == len(h2.hooks)
|
||||
for a, b in zip(h1.hooks, h2.hooks):
|
||||
assert (a.mask == b.mask).all()
|
||||
|
||||
|
||||
@mock.patch("torch.cuda.current_device", lambda: "cpu")
|
||||
class TestEncode(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
global clips
|
||||
print("Loading ComfyUI")
|
||||
from comfy.sd import load_clip
|
||||
from pathlib import Path
|
||||
|
||||
to_test = environ.get("TEST_TE", "clip_l").split()
|
||||
model_dir = environ.get("COMFYUI_TE_DIR", ".")
|
||||
|
||||
te_root = Path(model_dir).resolve()
|
||||
|
||||
if "clip_l" in to_test:
|
||||
clip_l = load_clip(
|
||||
ckpt_paths=[str(te_root / "clip_l.safetensors")], clip_type="stable_diffusion", model_options={}
|
||||
)
|
||||
clips.append(("clip_l", clip_l))
|
||||
|
||||
if "t5" in to_test:
|
||||
dual = load_clip(
|
||||
[str(te_root / "clip_l.safetensors"), str(te_root / "t5xxl_fp16.safetensors")],
|
||||
clip_type="flux",
|
||||
model_options={},
|
||||
)
|
||||
clips.append(("clip_l+t5", dual))
|
||||
|
||||
print("Starting tests")
|
||||
|
||||
def tensorsEqual(self, t1, t2):
|
||||
npt.assert_equal(t1.detach().numpy(), t2.detach().numpy())
|
||||
|
||||
def condEqual(self, c1, c2, key=None, key_assert=None):
|
||||
self.assertEqual(len(c1), len(c2))
|
||||
for i in range(len(c1)):
|
||||
a, b = c1[i], c2[i]
|
||||
if key:
|
||||
(key_assert or self.assertEqual)(a[1].get(key), b[1].get(key))
|
||||
else:
|
||||
self.tensorsEqual(a[0], b[0])
|
||||
|
||||
def test_basic_encode(self):
|
||||
pc = PCTextEncode()
|
||||
comfy = nodes.CLIPTextEncode()
|
||||
combine = nodes.ConditioningCombine()
|
||||
average = nodes.ConditioningAverage()
|
||||
concat = nodes.ConditioningConcat()
|
||||
zeroout = nodes.ConditioningZeroOut()
|
||||
for k, clip in clips:
|
||||
with self.subTest(k):
|
||||
with self.subTest("No exceptions"):
|
||||
run(
|
||||
pc,
|
||||
clip,
|
||||
"test AND test (test:1.2) BREAK test AND TE_WEIGHT(all=0) SDXL() AND AREA(,,) test CAT test",
|
||||
)
|
||||
with self.subTest("Basic"):
|
||||
(c1,) = run(pc, clip, "test")
|
||||
(c2,) = run(comfy, clip, "test")
|
||||
c = c2 # Used in later tests
|
||||
self.condEqual(c1, c2)
|
||||
|
||||
with self.subTest("Quotes"):
|
||||
(c1,) = run(pc, clip, 'Text saying "DOG MASK AND CAT COUPLE MASK(X)"')
|
||||
(c2,) = run(comfy, clip, 'Text saying "DOG MASK AND CAT COUPLE MASK(X)"')
|
||||
self.condEqual(c1, c2)
|
||||
|
||||
with self.subTest("Function cornercase"):
|
||||
(c1,) = run(pc, clip, "test SDXL function")
|
||||
(c2,) = run(comfy, clip, "test SDXL function")
|
||||
(c3,) = run(pc, clip, "test SDXL() function")
|
||||
self.condEqual(c1, c2)
|
||||
|
||||
with self.subTest("Weights"):
|
||||
(c1,) = run(pc, clip, "(test:1.2) (test:0.6)")
|
||||
(c2,) = run(comfy, clip, "(test:1.2) (test:0.6)")
|
||||
self.condEqual(c1, c2)
|
||||
|
||||
with self.subTest("Concat"):
|
||||
(c1,) = run(pc, clip, "test CAT test")
|
||||
(c2,) = run(concat, c, c)
|
||||
self.condEqual(c1, c2)
|
||||
|
||||
with self.subTest("Combine"):
|
||||
(c1,) = run(pc, clip, "test AND test")
|
||||
(c2,) = run(combine, c, c)
|
||||
self.condEqual(c1, c2)
|
||||
|
||||
with self.subTest("Zero out"):
|
||||
(c1,) = run(pc, clip, "test TE_WEIGHT(all=0)")
|
||||
(c2,) = run(zeroout, c)
|
||||
self.condEqual(c1, c2)
|
||||
|
||||
with self.subTest("Average"):
|
||||
(c1,) = run(comfy, clip, "test1")
|
||||
(c2,) = run(comfy, clip, "test2")
|
||||
(c3,) = run(pc, clip, "test1 AVG() test2")
|
||||
(c4,) = run(pc, clip, "test1 AVG test2")
|
||||
(avg,) = run(average, c1, c2, 0.5)
|
||||
self.condEqual(avg, c3)
|
||||
self.condEqual(avg, c4)
|
||||
|
||||
with self.subTest("Average multi"):
|
||||
(c1,) = run(comfy, clip, "test1")
|
||||
(c2,) = run(comfy, clip, "test2")
|
||||
(c3,) = run(comfy, clip, "test3")
|
||||
(c4,) = run(pc, clip, "test1 AVG() test2 AVG() test3")
|
||||
(c5,) = run(pc, clip, "test1 AVG test2 AVG test3")
|
||||
(avg1,) = run(average, c1, c2, 0.5)
|
||||
(avg,) = run(average, avg1, c3, 0.5)
|
||||
self.condEqual(avg, c4)
|
||||
self.condEqual(avg, c5)
|
||||
|
||||
@unittest.expectedFailure
|
||||
def test_failure(self):
|
||||
pc = PCTextEncode()
|
||||
comfy = nodes.CLIPTextEncode()
|
||||
for k, clip in clips:
|
||||
with self.subTest(k):
|
||||
(c1,) = run(comfy, clip, "test SDXL function")
|
||||
(c2,) = run(pc, clip, "test SDXL() function")
|
||||
self.condEqual(c1, c2)
|
||||
|
||||
def test_weight(self):
|
||||
pc = PCTextEncode()
|
||||
comfy = nodes.CLIPTextEncode()
|
||||
combine = nodes.ConditioningCombine()
|
||||
strength = nodes.ConditioningSetAreaStrength()
|
||||
for k, clip in clips:
|
||||
(c,) = run(comfy, clip, "test")
|
||||
(c2,) = run(strength, c, 0.5)
|
||||
with self.subTest(f"Testing {k}"):
|
||||
with self.subTest("Conditioning weights"):
|
||||
(a,) = run(pc, clip, "test :0.5 AND test :0.5")
|
||||
(b,) = run(combine, c2, c2)
|
||||
self.condEqual(a, b)
|
||||
self.condEqual(a, b, "strength")
|
||||
with self.subTest("Weight == 0"):
|
||||
(a,) = run(pc, clip, "test :0.5 AND test :0 AND test")
|
||||
(b,) = run(combine, c2, c)
|
||||
self.condEqual(a, b)
|
||||
self.condEqual(a, b, "strength")
|
||||
|
||||
def test_attn_couple(self):
|
||||
pc = PCTextEncode()
|
||||
for k, clip in clips:
|
||||
with self.subTest(f"Testing {k}"):
|
||||
(c,) = run(pc, clip, "test COUPLE prompt1 AND test2 COUPLE prompt2")
|
||||
(c2,) = run(pc, clip, "test COUPLE prompt1 COUPLE test2 COUPLE prompt2")
|
||||
self.assertTrue(len(c) == 2)
|
||||
self.assertTrue(len(c2) == 1)
|
||||
with self.subTest(f"Testing {k} mask shortcut"):
|
||||
(c,) = run(pc, clip, "test COUPLE() prompt1")
|
||||
(c2,) = run(pc, clip, "test COUPLE MASK() prompt1")
|
||||
self.condEqual(c, c2)
|
||||
self.condEqual(c, c2, "hooks", compare_hookgroup_mask)
|
||||
with self.subTest(f"Testing {k} mask shortcut 2"):
|
||||
(c,) = run(pc, clip, "test COUPLE(0 0.2, 0.5) prompt1")
|
||||
(c2,) = run(pc, clip, "test COUPLE MASK(0 0.2, 0.5) prompt1")
|
||||
self.condEqual(c, c2)
|
||||
self.condEqual(c, c2, "hooks", compare_hookgroup_mask)
|
||||
|
||||
def test_styles(self):
|
||||
pc = PCTextEncode()
|
||||
comfy = nodes.CLIPTextEncode()
|
||||
for k, clip in clips:
|
||||
(no_weights,) = run(comfy, clip, "this prompt has no weights")
|
||||
for style in ["comfy", "A1111", "comfy++", "compel", "down_weight", "perp"]:
|
||||
with self.subTest(f"TE {k} style {style} no weights equal comfy"):
|
||||
(c,) = run(pc, clip, "this prompt has no weights")
|
||||
self.condEqual(no_weights, c)
|
||||
with self.subTest(f"TE {k} style {style} does not fail when encoding weights"):
|
||||
for normalization in ["none", "mean", "length", "mean+length", "length+mean"]:
|
||||
with self.subTest(f"TE {k} style {style} normalization {normalization}"):
|
||||
(c,) = run(
|
||||
pc,
|
||||
clip,
|
||||
f"STYLE({style}, {normalization}) (this prompt) (has weights:0.9), (a:1.2) (b:1.2)",
|
||||
)
|
||||
|
||||
def test_masks(self):
|
||||
pc = PCTextEncode()
|
||||
comfy = nodes.CLIPTextEncode()
|
||||
solidmask = comfy_extras.nodes_mask.SolidMask()
|
||||
setMask = nodes.ConditioningSetMask()
|
||||
for k, clip in clips:
|
||||
(c1,) = run(pc, clip, "test MASK()")
|
||||
(c2,) = run(comfy, clip, "test")
|
||||
(c2,) = run(setMask, c2, run(solidmask, 1.0, 512, 512)[0], "default", 1.0)
|
||||
self.condEqual(c1, c2)
|
||||
self.condEqual(c1, c2, "mask", self.tensorsEqual)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+110
-81
@@ -5,100 +5,131 @@ import logging
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
|
||||
def reset_graphbuilder_state():
|
||||
from comfy_execution.graph_utils import GraphBuilder
|
||||
|
||||
GraphBuilder.set_default_prefix("UID", 0, 0)
|
||||
|
||||
|
||||
def find_file(name):
|
||||
names = {"test": "test.safetensors", "other": "some/other.safetensors"}
|
||||
return names.get(name)
|
||||
|
||||
|
||||
def apply(cls, text, **kwargs):
|
||||
def loraloader(text, adv=False, **kwargs):
|
||||
from .nodes_lazy import PCLazyLoraLoader, PCLazyLoraLoaderAdvanced
|
||||
|
||||
reset_graphbuilder_state()
|
||||
if adv:
|
||||
cls = PCLazyLoraLoader
|
||||
else:
|
||||
cls = PCLazyLoraLoaderAdvanced
|
||||
model = [0, 1]
|
||||
clip = [0, 0]
|
||||
return cls().apply(unique_id="UID", model=model, clip=clip, text=text, **kwargs)
|
||||
|
||||
|
||||
def te(text, adv=False, **kwargs):
|
||||
from .nodes_lazy import PCLazyTextEncode, PCLazyTextEncodeAdvanced
|
||||
|
||||
if adv:
|
||||
cls = PCLazyTextEncode
|
||||
else:
|
||||
cls = PCLazyTextEncodeAdvanced
|
||||
reset_graphbuilder_state()
|
||||
clip = [0, 0]
|
||||
return cls().apply(clip=clip, text=text, unique_id="UID", **kwargs)
|
||||
|
||||
|
||||
@mock.patch("prompt_control.utils.lora_name_to_file", find_file)
|
||||
@mock.patch("torch.cuda.current_device", lambda: "cpu")
|
||||
class GraphTests(unittest.TestCase):
|
||||
maxDiff = 4096
|
||||
|
||||
def test_textencode(self):
|
||||
clip = [0, 0]
|
||||
from .nodes_lazy import PCLazyTextEncode, PCLazyTextEncodeAdvanced
|
||||
|
||||
for p in ["test", "[test:0.2] test", "[test[test::0.5]]<lora:test:1>"]:
|
||||
r1 = PCLazyTextEncode().apply(clip, p, "UID")
|
||||
r2 = PCLazyTextEncodeAdvanced().apply(clip, p, "UID")
|
||||
self.assertEqual(r1, r2)
|
||||
r1 = te(p)
|
||||
r2 = te(p, adv=True)
|
||||
with self.subTest(f"Expansion: {p}"):
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
r = PCLazyTextEncode().apply(clip, "test<lora:test:1>", "UID")
|
||||
self.assertEqual(
|
||||
r,
|
||||
{
|
||||
"result": (["UID-2", 0],),
|
||||
"expand": {
|
||||
"UID-1": {"class_type": "PCTextEncode", "inputs": {"clip": [0, 0], "text": "test"}},
|
||||
"UID-2": {
|
||||
"class_type": "ConditioningSetTimestepRange",
|
||||
"inputs": {"conditioning": ["UID-1", 0], "start": 0.0, "end": 1.0},
|
||||
reset_graphbuilder_state()
|
||||
with self.subTest("Expansion: LoRA"):
|
||||
r = te("test<lora:test:1>")
|
||||
self.assertEqual(
|
||||
r,
|
||||
{
|
||||
"result": (["UID.0.0.2", 0],),
|
||||
"expand": {
|
||||
"UID.0.0.1": {"class_type": "PCTextEncode", "inputs": {"clip": [0, 0], "text": "test"}},
|
||||
"UID.0.0.2": {
|
||||
"class_type": "ConditioningSetTimestepRange",
|
||||
"inputs": {"conditioning": ["UID.0.0.1", 0], "start": 0.0, "end": 1.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
r = PCLazyTextEncode().apply(clip, "simple [test:0.1,0.5] prompt<lora:test:1>", "UID")
|
||||
self.assertEqual(
|
||||
r,
|
||||
{
|
||||
"result": (["UID-8", 0],),
|
||||
"expand": {
|
||||
"UID-1": {"class_type": "PCTextEncode", "inputs": {"clip": [0, 0], "text": "simple prompt"}},
|
||||
"UID-2": {
|
||||
"class_type": "ConditioningSetTimestepRange",
|
||||
"inputs": {"conditioning": ["UID-1", 0], "start": 0.0, "end": 0.1},
|
||||
},
|
||||
"UID-3": {"class_type": "PCTextEncode", "inputs": {"clip": [0, 0], "text": "simple test prompt"}},
|
||||
"UID-4": {
|
||||
"class_type": "ConditioningSetTimestepRange",
|
||||
"inputs": {"conditioning": ["UID-3", 0], "start": 0.1, "end": 0.5},
|
||||
},
|
||||
"UID-5": {"class_type": "PCTextEncode", "inputs": {"clip": [0, 0], "text": "simple prompt"}},
|
||||
"UID-6": {
|
||||
"class_type": "ConditioningSetTimestepRange",
|
||||
"inputs": {"conditioning": ["UID-5", 0], "start": 0.5, "end": 1.0},
|
||||
},
|
||||
"UID-7": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["UID-2", 0], "conditioning_2": ["UID-4", 0]},
|
||||
},
|
||||
"UID-8": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {"conditioning_1": ["UID-7", 0], "conditioning_2": ["UID-6", 0]},
|
||||
)
|
||||
with self.subTest("Expansion: LoRA with schedule"):
|
||||
r = te("simple [test:0.1,0.5] prompt<lora:test:1>")
|
||||
self.assertEqual(
|
||||
r,
|
||||
{
|
||||
"result": (["UID.0.0.8", 0],),
|
||||
"expand": {
|
||||
"UID.0.0.1": {
|
||||
"class_type": "PCTextEncode",
|
||||
"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": "PCTextEncode",
|
||||
"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": "PCTextEncode",
|
||||
"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]},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@mock.patch("prompt_control.utils.lora_name_to_file", find_file)
|
||||
def test_loraloader(self):
|
||||
from .nodes_lazy import PCLazyLoraLoader, PCLazyLoraLoaderAdvanced
|
||||
|
||||
model = [0, 1]
|
||||
clip = [0, 0]
|
||||
with self.assertLogs(log, level="WARNING") as cm:
|
||||
result = apply(PCLazyLoraLoader, "prompt here <lora:nonexistent:1.0:0.5>")["expand"]
|
||||
result_adv = apply(PCLazyLoraLoaderAdvanced, "prompt here <lora:nonexistent:1.0:0.5>")["expand"]
|
||||
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"]
|
||||
self.assertIn("LoRA 'nonexistent' not found", cm.output[0])
|
||||
self.assertEqual(result, {})
|
||||
self.assertEqual(result_adv, {})
|
||||
|
||||
result = apply(PCLazyLoraLoader, "<lora:test:1>")["expand"]
|
||||
result2 = apply(PCLazyLoraLoader, "prompt here <lora:test:1.0:0.5><lora:test:0:0.5>")["expand"]
|
||||
result3 = apply(PCLazyLoraLoaderAdvanced, "prompt here <lora:test:1.0:0.5><lora:test:0:0.5>")["expand"]
|
||||
result = loraloader("<lora:test:1>")["expand"]
|
||||
result2 = loraloader("prompt here <lora:test:1.0:0.5><lora:test:0:0.5>")["expand"]
|
||||
result3 = loraloader("prompt here <lora:test:1.0:0.5><lora:test:0:0.5>", adv=True)["expand"]
|
||||
self.assertEqual(result, result2)
|
||||
self.assertEqual(result2, result3)
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"UID-1": {
|
||||
"UID.0.0.1": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"model": [0, 1],
|
||||
@@ -110,11 +141,11 @@ class GraphTests(unittest.TestCase):
|
||||
}
|
||||
},
|
||||
)
|
||||
result = apply(PCLazyLoraLoader, "<lora:test:1><lora:other:0.5>")["expand"]
|
||||
result = loraloader("<lora:test:1><lora:other:0.5>")["expand"]
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"UID-1": {
|
||||
"UID.0.0.1": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"model": [0, 1],
|
||||
@@ -124,11 +155,11 @@ class GraphTests(unittest.TestCase):
|
||||
"lora_name": "test.safetensors",
|
||||
},
|
||||
},
|
||||
"UID-2": {
|
||||
"UID.0.0.2": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"model": ["UID-1", 0],
|
||||
"clip": ["UID-1", 1],
|
||||
"model": ["UID.0.0.1", 0],
|
||||
"clip": ["UID.0.0.1", 1],
|
||||
"strength_model": 0.5,
|
||||
"strength_clip": 0.5,
|
||||
"lora_name": "some/other.safetensors",
|
||||
@@ -137,11 +168,11 @@ class GraphTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
result = apply(PCLazyLoraLoader, "prompt here <lora:test:1.0:0.5>")["expand"]
|
||||
result = loraloader("prompt here <lora:test:1.0:0.5>")["expand"]
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"UID-1": {
|
||||
"UID.0.0.1": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"model": [0, 1],
|
||||
@@ -154,46 +185,46 @@ class GraphTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
result = apply(PCLazyLoraLoader, "prompt [<lora:test:0.5>:0.5]")["expand"]
|
||||
result2 = apply(PCLazyLoraLoaderAdvanced, "prompt [<lora:test:0.5>:0.5]")["expand"]
|
||||
result = loraloader("prompt [<lora:test:0.5>:0.5]")["expand"]
|
||||
result2 = loraloader("prompt [<lora:test:0.5>:0.5]", adv=True)["expand"]
|
||||
self.assertEqual(result, result2)
|
||||
expected = {
|
||||
"UID-1": {
|
||||
"UID.0.0.1": {
|
||||
"class_type": "CreateHookLora",
|
||||
"inputs": {"lora_name": "test.safetensors", "strength_model": 0.5, "strength_clip": 0.5},
|
||||
},
|
||||
"UID-2": {
|
||||
"UID.0.0.2": {
|
||||
"class_type": "CreateHookKeyframe",
|
||||
"inputs": {"strength_mult": 0.0, "start_percent": 0.0},
|
||||
},
|
||||
"UID-3": {
|
||||
"UID.0.0.3": {
|
||||
"class_type": "CreateHookKeyframe",
|
||||
"inputs": {
|
||||
"start_percent": 0.5,
|
||||
"prev_hook_kf": ["UID-2", 0],
|
||||
"prev_hook_kf": ["UID.0.0.2", 0],
|
||||
"strength_mult": 1.0,
|
||||
},
|
||||
},
|
||||
"UID-4": {
|
||||
"UID.0.0.4": {
|
||||
"class_type": "SetHookKeyframes",
|
||||
"inputs": {"hooks": ["UID-1", 0], "hook_kf": ["UID-3", 0]},
|
||||
"inputs": {"hooks": ["UID.0.0.1", 0], "hook_kf": ["UID.0.0.3", 0]},
|
||||
},
|
||||
"UID-5": {
|
||||
"UID.0.0.5": {
|
||||
"class_type": "SetClipHooks",
|
||||
"inputs": {
|
||||
"clip": [0, 0],
|
||||
"hooks": ["UID-4", 0],
|
||||
"hooks": ["UID.0.0.4", 0],
|
||||
"apply_to_conds": True,
|
||||
"schedule_clip": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertEqual(result, expected)
|
||||
result2 = apply(PCLazyLoraLoaderAdvanced, "prompt [<lora:test:0.5>:0.5]", start=0.6)["expand"]
|
||||
result2 = loraloader("prompt [<lora:test:0.5>:0.5]", adv=True, start=0.6)["expand"]
|
||||
self.assertEqual(
|
||||
result2,
|
||||
{
|
||||
"UID-1": {
|
||||
"UID.0.0.1": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"model": [0, 1],
|
||||
@@ -205,9 +236,7 @@ class GraphTests(unittest.TestCase):
|
||||
}
|
||||
},
|
||||
)
|
||||
result2 = PCLazyLoraLoaderAdvanced().apply(model, clip, "prompt [<lora:test:0.5>:0.5]", "UID", end=0.5)[
|
||||
"expand"
|
||||
]
|
||||
result2 = loraloader("prompt [<lora:test:0.5>:0.5]", end=0.5)["expand"]
|
||||
self.assertEqual(result2, {})
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import unittest
|
||||
from .parser import parse_prompt_schedules as parse
|
||||
from .parser import parse_prompt_schedules as parse, expand_macros
|
||||
|
||||
|
||||
def prompt(until, text, *loras):
|
||||
@@ -18,26 +18,25 @@ class TestParser(unittest.TestCase):
|
||||
self.assertEqual(p.at_step(0.5), expected)
|
||||
self.assertEqual(p.at_step(1), expected)
|
||||
|
||||
def test_quote(self):
|
||||
p = parse('This is a text with a "QUOTED DEF(X=Y)"')
|
||||
expected = prompt(1.0, 'This is a text with a "QUOTED DEF(X=Y)"')
|
||||
self.assertEqual(p.at_step(0), expected)
|
||||
self.assertEqual(p.at_step(0.5), expected)
|
||||
self.assertEqual(p.at_step(1), expected)
|
||||
|
||||
def test_equivalences(self):
|
||||
eqs = [parse(p) for p in ["[a:0.1]", "[:a:0.1]", "[:a:0,0.1]", "[:a::0.1,1.0]", "[:a::0.1]"]]
|
||||
for p in eqs[1:]:
|
||||
self.assertEqual(eqs[0].parsed_prompt, p.parsed_prompt)
|
||||
|
||||
eqs = [parse(p) for p in ["[before:during:after:0.1]", "[before:during:after:0.1,1.0]", "[before:during:0.1]"]]
|
||||
for p in eqs[1:]:
|
||||
self.assertEqual(eqs[0].parsed_prompt, p.parsed_prompt)
|
||||
|
||||
eqs = [parse(p) for p in ["[a:0.1,0.5]", "[[a:0.1]::0.5]", "[:a::0.1,0.5]", "[a::0.1,0.5]"]]
|
||||
for p in eqs[1:]:
|
||||
self.assertEqual(eqs[0].parsed_prompt, p.parsed_prompt)
|
||||
|
||||
eqs = [parse(p) for p in ["[a:b:0.5]", "[a::b:0.5,0.5]"]]
|
||||
for p in eqs[1:]:
|
||||
self.assertEqual(eqs[0].parsed_prompt, p.parsed_prompt)
|
||||
|
||||
eqs = [parse(p) for p in ["[a::0.5]", "[a:::0.5,0.5]"]]
|
||||
for p in eqs[1:]:
|
||||
self.assertEqual(eqs[0].parsed_prompt, p.parsed_prompt)
|
||||
eqs = [
|
||||
[parse(p) for p in ["[a:0.1]", "[:a:0.1]", "[:a:0,0.1]", "[:a::0.1,1.0]", "[:a::0.1]"]],
|
||||
[parse(p) for p in ["[before:during:after:0.1]", "[before:during:after:0.1,1.0]", "[before:during:0.1]"]],
|
||||
[parse(p) for p in ["[a:0.1,0.5]", "[[a:0.1]::0.5]", "[:a::0.1,0.5]", "[a::0.1,0.5]"]],
|
||||
[parse(p) for p in ["[a:b:0.5]", "[a::b:0.5,0.5]"]],
|
||||
[parse(p) for p in ["[a::0.5]", "[a:::0.5,0.5]"]],
|
||||
]
|
||||
for group in eqs:
|
||||
for p in group[1:]:
|
||||
with self.subTest(p):
|
||||
self.assertEqual(group[0].parsed_prompt, p.parsed_prompt)
|
||||
|
||||
def test_basic(self):
|
||||
p = parse(
|
||||
@@ -135,24 +134,68 @@ class TestParser(unittest.TestCase):
|
||||
|
||||
p = parse("DEF(X=[($1):($1:$2):$2])X(test;0.7)")
|
||||
p2 = parse("[(test):(test:0.7):0.7]")
|
||||
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
|
||||
with self.subTest("parameters"):
|
||||
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
|
||||
|
||||
p = parse("DEF(X=[($1):($1:$2):$2])DEF(Y=X(test;$1))Y(0.7) Y(0.5)")
|
||||
p2 = parse("[(test):(test:0.7):0.7] [(test):(test:0.5):0.5]")
|
||||
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
|
||||
with self.subTest("two functions"):
|
||||
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
|
||||
|
||||
p = parse("DEF(X(a;b)=$1 $2 $3 d)X(A) X(A;B;C)")
|
||||
p2 = parse("A b $3 d A B C d")
|
||||
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
|
||||
p = expand_macros("DEF(X(a;b)=$1 $2 $3 d)X(A) X(A;B;C)")
|
||||
with self.subTest("defaults"):
|
||||
self.assertEqual(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)")
|
||||
with self.subTest("Empty default for $1"):
|
||||
self.assertEqual(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)]")
|
||||
with self.subTest("defaults, DEF=X vs DEF=X()"):
|
||||
self.assertEqual(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]")
|
||||
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
|
||||
with self.subTest("defaults, nested parens"):
|
||||
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
|
||||
|
||||
with self.assertRaises(ValueError) as c:
|
||||
parse("DEF(X=recurse Y) DEF(Y=recurse X) X")
|
||||
expand_macros("DEF(X=recurse Y) DEF(Y=recurse X) X")
|
||||
self.assertTrue("Unable to resolve DEFs" in str(c.exception))
|
||||
|
||||
def test_escapes(self):
|
||||
p = parse(r"[a:\:a:0.5] :\[a:b:0.5]")
|
||||
self.assertPrompt(p, 0, 0.5, r"a :\[a:b:0.5]")
|
||||
self.assertPrompt(p, 0.55, 1, r":a :\[a:b:0.5]")
|
||||
|
||||
p = parse(r"[embedding\:a:embedding\:b:0.1,0.5]")
|
||||
self.assertPrompt(p, 0.15, 0.5, r"embedding:a")
|
||||
self.assertPrompt(p, 0.55, 1, r"embedding:b")
|
||||
|
||||
p = parse(r"[embedding\:a:embedding\:b:embedding\:c:0.1,0.5]")
|
||||
self.assertPrompt(p, 0.0, 0.1, r"embedding:a")
|
||||
self.assertPrompt(p, 0.15, 0.5, r"embedding:b")
|
||||
self.assertPrompt(p, 0.55, 1, r"embedding:c")
|
||||
|
||||
p = parse(r"[a\:b\\:c:0.5]")
|
||||
self.assertPrompt(p, 0.0, 0.5, "a:b\\")
|
||||
self.assertPrompt(p, 0.55, 1, r"c")
|
||||
|
||||
p = parse(r"[a:\#b:0.5]")
|
||||
self.assertPrompt(p, 0.0, 0.5, "a")
|
||||
self.assertPrompt(p, 0.55, 1, "#b")
|
||||
|
||||
def test_comments(self):
|
||||
p = parse("this is a # comment")
|
||||
self.assertPrompt(p, 0, 1.0, "this is a ")
|
||||
p = parse("this is a [comment#:scheduled:0.6]")
|
||||
self.assertPrompt(p, 0, 1.0, "this is a [comment")
|
||||
p = parse(r"this is a [comment\#:scheduled:0.6]")
|
||||
self.assertPrompt(p, 0, 0.6, "this is a comment#")
|
||||
self.assertPrompt(p, 0.65, 1.0, "this is a scheduled")
|
||||
p = parse("#this is a comment\nthis is a prompt")
|
||||
self.assertPrompt(p, 0, 1.0, "\nthis is a prompt")
|
||||
|
||||
def test_misc(self):
|
||||
p = parse("[[a:c:0.5]:0.7]")
|
||||
p2 = parse("[:[a:c:0.5]:0.7]")
|
||||
@@ -190,11 +233,13 @@ class TestParser(unittest.TestCase):
|
||||
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
|
||||
for i, x in enumerate(["cat", "wolf", "tiger", "cat", "dog", "tiger", "cat", "wolf", "tiger", "cat"]):
|
||||
step = round((i * 0.1) + 0.1, 2)
|
||||
self.assertPrompt(p3, step, step, x)
|
||||
with self.subTest(step):
|
||||
self.assertPrompt(p3, step, step, x)
|
||||
|
||||
for i, x in enumerate([["cat"], ["dog"], ["cat"], ["wolf", ("canine", 1.0, 1.0)], ["cat"]]):
|
||||
step = round((i * 0.2) + 0.2, 2)
|
||||
self.assertPrompt(p4, step, step, *x)
|
||||
with self.subTest(step):
|
||||
self.assertPrompt(p4, step, step, *x)
|
||||
self.assertPrompt(p4, 0.7, 0.8, "wolf", ("canine", 1.0, 1.0))
|
||||
|
||||
|
||||
|
||||
+172
-31
@@ -1,19 +1,48 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import re
|
||||
import logging
|
||||
import copy
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypeAlias, Iterator, TypeVar, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import torch # flakes8: noqa
|
||||
|
||||
FunctionArgs: TypeAlias = list[str]
|
||||
ComfyConditioning: TypeAlias = tuple["torch.Tensor", dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionSpec:
|
||||
name: str
|
||||
args: FunctionArgs
|
||||
position: int
|
||||
placeholder: str | None
|
||||
|
||||
|
||||
# Allow testing
|
||||
try:
|
||||
from folder_paths import get_filename_list
|
||||
except ImportError:
|
||||
|
||||
def get_filename_list(x):
|
||||
raise NotImplementedError("How did you get here?")
|
||||
def get_filename_list(folder_name) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
|
||||
def call_node(cls, *args, **kwargs):
|
||||
if hasattr(cls, "execute"):
|
||||
# v3 node
|
||||
return cls.execute(*args, **kwargs)
|
||||
else:
|
||||
func = getattr(cls(), cls.FUNCTION)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
def consolidate_schedule(prompt_schedule):
|
||||
prev_loras = {}
|
||||
not_found = []
|
||||
@@ -54,10 +83,11 @@ def find_nonscheduled_loras(consolidated_schedule):
|
||||
return {k: v for (k, v) in candidate_loras.items() if k not in to_remove}
|
||||
|
||||
|
||||
def smarter_split(separator, string):
|
||||
def smarter_split(separator: str, string: str) -> list[str]:
|
||||
"""Does not break () when splitting"""
|
||||
splits = []
|
||||
prev = 0
|
||||
idx = 0
|
||||
stack = 0
|
||||
escape = False
|
||||
for idx, x in enumerate(string):
|
||||
@@ -74,7 +104,7 @@ def smarter_split(separator, string):
|
||||
return splits
|
||||
|
||||
|
||||
def find_closing_paren(text, start):
|
||||
def find_closing_paren(text: str, start: int) -> int:
|
||||
stack = 1
|
||||
for i, char in enumerate(text[start:]):
|
||||
if char == ")":
|
||||
@@ -83,56 +113,126 @@ def find_closing_paren(text, start):
|
||||
stack += 1
|
||||
if stack == 0:
|
||||
return start + i
|
||||
# Implicit closing paren after end
|
||||
return len(text)
|
||||
return -1
|
||||
|
||||
|
||||
def get_function(text, func, defaults, return_func_name=False, placeholder=""):
|
||||
rex = re.compile(rf"\b{func}\(", re.MULTILINE)
|
||||
instances = []
|
||||
def find_function_spans(
|
||||
text: str, func: str, require_args: bool, defaults: FunctionArgs | None
|
||||
) -> Iterator[tuple[int, int, str, FunctionArgs]]:
|
||||
if require_args:
|
||||
rex = re.compile(rf"\b{func}\(", re.MULTILINE)
|
||||
else:
|
||||
rex = re.compile(rf"\b{func}\b", re.MULTILINE)
|
||||
|
||||
idx = 0
|
||||
match = rex.search(text)
|
||||
count = 0
|
||||
while match:
|
||||
# Match start, content start
|
||||
start, after_first_paren = match.span()
|
||||
funcname = text[start : after_first_paren - 1]
|
||||
end = find_closing_paren(text, after_first_paren)
|
||||
args = parse_strings(text[after_first_paren:end], defaults)
|
||||
if return_func_name:
|
||||
instances.append((funcname, args))
|
||||
start, at_paren = match.span()
|
||||
if require_args:
|
||||
at_paren = at_paren - 1
|
||||
funcname = text[start:at_paren]
|
||||
after_first_paren = at_paren + 1
|
||||
if text[at_paren:after_first_paren] == "(":
|
||||
end = find_closing_paren(text, after_first_paren)
|
||||
if end < 0:
|
||||
continue
|
||||
args = parse_strings(text[after_first_paren:end], defaults)
|
||||
end += 1
|
||||
else:
|
||||
instances.append(args)
|
||||
|
||||
if placeholder:
|
||||
text = text[:start] + f"\0{placeholder}{count}\0" + text[end + 1 :]
|
||||
else:
|
||||
text = text[:start] + text[end + 1 :]
|
||||
end = at_paren
|
||||
args = defaults or []
|
||||
yield idx + start, idx + end, funcname, args
|
||||
idx = idx + end
|
||||
text = text[end:]
|
||||
match = rex.search(text)
|
||||
|
||||
|
||||
def get_function(
|
||||
text: str, func: str, defaults: list[str] | None, placeholder: str = "", require_args: bool = True
|
||||
) -> tuple[str, list[FunctionSpec]]:
|
||||
spans = [x.span() for x in re.finditer(r'".+?"', text)]
|
||||
instances = []
|
||||
count = 0
|
||||
chunks = []
|
||||
current = 0
|
||||
skipped = 0
|
||||
for start, end, funcname, args in find_function_spans(text, func, require_args, defaults):
|
||||
ph = None
|
||||
if spans_include(spans, start, end):
|
||||
continue
|
||||
if placeholder:
|
||||
ph = f"\0{placeholder}{count}\0"
|
||||
instances.append(FunctionSpec(funcname, args, start - skipped, ph))
|
||||
skipped += end - start
|
||||
chunks.append(text[current:start] + (ph or ""))
|
||||
current = end
|
||||
count += 1
|
||||
chunks.append(text[current:])
|
||||
text = "".join(chunks)
|
||||
return text, instances
|
||||
|
||||
|
||||
def parse_args(strings, arg_spec, strip=True):
|
||||
def spans_include(spans: list[tuple[int, int]], s: int, e: int) -> bool:
|
||||
return any((s > a and e < b) for a, b in spans)
|
||||
|
||||
|
||||
def split_quotable(text: str, regexp: str) -> Iterator[str]:
|
||||
start_from = 0
|
||||
spans = [x.span() for x in re.finditer(r'".+?"', text)]
|
||||
for x in re.finditer(regexp, text):
|
||||
s, e = x.span()
|
||||
if not spans_include(spans, s, e):
|
||||
yield text[start_from:s].strip()
|
||||
start_from = e
|
||||
yield text[start_from:].strip()
|
||||
|
||||
|
||||
def split_by_function(
|
||||
text: str, func: str, defaults: list[str] | None = None, require_args: bool = True
|
||||
) -> tuple[str, list[tuple[str, FunctionSpec]]]:
|
||||
"""
|
||||
Splits a string by function calls, returning the leftover text along with a list of functions with their associated text chunk.
|
||||
"""
|
||||
text, functions = get_function(text, func, defaults, require_args=require_args)
|
||||
chunks = []
|
||||
prev = 0
|
||||
for f in functions:
|
||||
chunks.append(text[prev : f.position])
|
||||
prev = f.position
|
||||
chunks.append(text[prev:])
|
||||
r = []
|
||||
for i, f in enumerate(functions):
|
||||
r.append((chunks[i + 1], f))
|
||||
return chunks[0], r
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def parse_args(strings: list[str], arg_spec: list[tuple[Any, T]], strip: bool = True) -> list[T]:
|
||||
args = [s[1] for s in arg_spec]
|
||||
for i, spec in list(enumerate(arg_spec))[: len(strings)]:
|
||||
try:
|
||||
if strip:
|
||||
strings[i] = strings[i].strip()
|
||||
args[i] = spec[0](strings[i])
|
||||
f = spec[0]
|
||||
args[i] = f(strings[i])
|
||||
except ValueError:
|
||||
pass
|
||||
return args
|
||||
|
||||
|
||||
def parse_floats(string, defaults, split_re=","):
|
||||
def parse_floats(string: str, defaults: list[float], split_re: str = ",") -> list[float]:
|
||||
spec = [(float, d) for d in defaults]
|
||||
return parse_args(re.split(split_re, string.strip()), spec)
|
||||
|
||||
|
||||
def parse_strings(string, defaults, split_re=r"(?<!\\),", replace=(r"\,", ",")):
|
||||
def parse_strings(
|
||||
string: str, defaults: FunctionArgs | None, split_re: str = r"(?<!\\),", replace: tuple[str, str] = (r"\,", ",")
|
||||
) -> FunctionArgs:
|
||||
if defaults is None:
|
||||
return string
|
||||
spec = [(lambda x: x, d) for d in defaults]
|
||||
return [string]
|
||||
spec = [(str, d) for d in defaults]
|
||||
splits = re.split(split_re, string)
|
||||
if replace:
|
||||
f, t = replace
|
||||
@@ -140,7 +240,7 @@ def parse_strings(string, defaults, split_re=r"(?<!\\),", replace=(r"\,", ",")):
|
||||
return parse_args(splits, spec, strip=False)
|
||||
|
||||
|
||||
def safe_float(f, default):
|
||||
def safe_float(f: Any, default: float) -> float:
|
||||
if f is None:
|
||||
return default
|
||||
try:
|
||||
@@ -149,7 +249,7 @@ def safe_float(f, default):
|
||||
return default
|
||||
|
||||
|
||||
def lora_name_to_file(name):
|
||||
def lora_name_to_file(name: str) -> str | None:
|
||||
filenames = get_filename_list("loras")
|
||||
# Return exact matches as is
|
||||
if name in filenames:
|
||||
@@ -160,4 +260,45 @@ def lora_name_to_file(name):
|
||||
p = Path(f).with_suffix("")
|
||||
if p.name == n or str(p) == n:
|
||||
return f
|
||||
# Finally, try to find unique match from parts
|
||||
parts = name.split()
|
||||
search = [f for f in filenames if all(p in f for p in parts)]
|
||||
if len(search) == 1:
|
||||
return search[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def map_inputs(input_map, inputs):
|
||||
new_inputs = {}
|
||||
for k in inputs:
|
||||
key = inputs[k]
|
||||
new_inputs[k] = key
|
||||
if isinstance(key, list):
|
||||
key = tuple(key)
|
||||
x = input_map.get(key, inputs[k])
|
||||
new_inputs[k] = x
|
||||
return new_inputs
|
||||
|
||||
|
||||
def expand_graph(node_mappings, graph):
|
||||
input_map = {}
|
||||
new_graph = copy.deepcopy(graph)
|
||||
for k in graph:
|
||||
data = graph[k]
|
||||
if not isinstance(data, dict) or "class_type" not in data or data["class_type"] not in node_mappings:
|
||||
continue
|
||||
node = node_mappings[data["class_type"]]()
|
||||
inputs = map_inputs(input_map, data["inputs"].copy())
|
||||
inputs["unique_id"] = k
|
||||
fn = getattr(node, getattr(node, "FUNCTION"))
|
||||
expansion = fn(**inputs)
|
||||
for i, v in enumerate(expansion["result"]):
|
||||
input_map[(k, i)] = v
|
||||
del new_graph[k]
|
||||
new_graph.update(expansion["expand"])
|
||||
|
||||
for k in new_graph:
|
||||
data = new_graph[k]
|
||||
data["inputs"] = map_inputs(input_map, data["inputs"])
|
||||
return new_graph
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-prompt-control"
|
||||
description = "Nodes for convenient prompt editing, making many common operations prompt-controllable"
|
||||
version = "2.0.0-rc.3"
|
||||
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.3"
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
from prompt_control.utils import expand_graph
|
||||
from prompt_control.nodes_lazy import NODE_CLASS_MAPPINGS as LN
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
# Needs ComfyUI in Python path
|
||||
# Usage: PYTHONPATH=../..:. python tools/expand_graph < graph_in_api_format.json > out.json
|
||||
if __name__ == "__main__":
|
||||
graph = json.load(sys.stdin)
|
||||
new = expand_graph(LN, graph)
|
||||
print(json.dumps(new))
|
||||
@@ -0,0 +1,3 @@
|
||||
# PC: Attach Mask
|
||||
|
||||
Attaches custom masks to a CLIP object so that they can be referred to in prompts using `PCTextEncode` or `PC: Schedule prompt`.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
PCAddMaskToCLIP.md
|
||||
@@ -0,0 +1,7 @@
|
||||
# PC: Attention Couple (batch negative)
|
||||
|
||||
This node applies an optimization that re-enables negative cond batching when Attention Couple is in use.
|
||||
|
||||
It improves performance when negative prompts are not scheduled, but slightly affects outputs and is not required for Attention Couple to work.
|
||||
|
||||
Simply add it to your workflow and pass in your positive and negative prompts. It is always safe to use, as it will not do anything when it detects that the optimization can't be applied (eg. when negative prompts contain schedules)
|
||||
@@ -0,0 +1,7 @@
|
||||
# PC: Schedule LoRAs
|
||||
|
||||
This node is the core of Prompt Control. It evaluates a prompt schedule and dynamically expands into a scheduled workflow consisting of necessary calls to `LoRALoader` and `Create Hook LoRA` (for scheduled LoRAs).
|
||||
|
||||
You can use it in place or in addition to your usual `LoRA Loader` nodes; just pass in a text prompt containing your LoRA schedule (it can be shared with `PC: Schedule Prompt`). Then connect your MODEL output as usual and the CLIP output to your `PC: Schedule Prompt` nodes.
|
||||
|
||||
For documentation on syntax, for now see the [documentation on GitHub](https://github.com/asagi4/comfyui-prompt-control/blob/master/doc/schedules.md)
|
||||
+1
@@ -0,0 +1 @@
|
||||
PCLazyLoraLoader.md
|
||||
@@ -0,0 +1,7 @@
|
||||
# PC: Schedule Prompt
|
||||
|
||||
This node is the core of Prompt Control. It evaluates a prompt schedule and dynamically expands into a scheduled workflow consisting of calls to `PCTextEncode`, `SetConditioningTimesteps` and other necessary nodes.
|
||||
|
||||
To use it, simply replace your usual `CLIP Text Encode` nodes with `PC: Schedule Prompt` nodes. For LoRA Loading, you should use `PC: Schedule LoRAs` in place (or in addition to) of your usual LoRA Loader node.
|
||||
|
||||
For documentation on syntax, for now see the [documentation on GitHub](https://github.com/asagi4/comfyui-prompt-control/blob/master/doc/schedules.md)
|
||||
+1
@@ -0,0 +1 @@
|
||||
PCLazyTextEncode.md
|
||||
@@ -0,0 +1,5 @@
|
||||
# PC: LoRA Hooks from Text (non-lazy)
|
||||
|
||||
Creates cond hooks from a LoRA schedule, if you want to apply them manually for some reason.
|
||||
|
||||
You should not need to use this. Use `PC: Schedule LoRAs`.
|
||||
@@ -0,0 +1,5 @@
|
||||
# PC: Expand Macros
|
||||
|
||||
Expands [prompt macros](https://github.com/asagi4/comfyui-prompt-control/blob/master/doc/macros.md)
|
||||
|
||||
You should not need to use this directly. Use `PC: Schedule Prompt` instead.
|
||||
@@ -0,0 +1,7 @@
|
||||
# PC: Configure PCTextEncode
|
||||
|
||||
Configures a CLIP object with new default values used by `PCTextEncode`. Apply it before everything else.
|
||||
|
||||
This is needed if you want to do scheduling with steps instead of denoising percentages, but otherwise it's completely optional.
|
||||
|
||||
Note that steps are simply syntactic sugar for percentages and may not correspond to actual steps depending on the scheduler used.
|
||||
@@ -0,0 +1,5 @@
|
||||
# PC: Text Encode (no scheduling)
|
||||
|
||||
This node encodes text using some special syntax for advanced features. You should rarely need to use this node directly, and instead use `PC: Schedule Prompt` which uses this node under the hood.
|
||||
|
||||
For documentation on syntax, see the [documentation on GitHub](https://github.com/asagi4/comfyui-prompt-control/blob/master/doc/basic.md)
|
||||
Reference in New Issue
Block a user