Compare commits

...
Author SHA1 Message Date
asagi4 633b2f05e0 Release v2.0.0-rc.1 properly 2025-05-21 19:10:53 +03:00
asagi4 a15135ddc5 Remove misleading instruction that no longer applies 2025-05-21 19:09:08 +03:00
asagi4 0d7e2a4e60 No, I do not want CUDA 2025-05-20 21:46:25 +03:00
asagi4 c5495832c5 Use CPU torch 2025-05-20 21:35:32 +03:00
asagi4 63d2cb3e0c Tests are broken again... 2025-05-20 21:28:11 +03:00
asagi4 95832e801b Make TE_WEIGHT more convenient 2025-05-20 21:13:22 +03:00
asagi4 01bd5568d0 New function: TE
Fixes #106
2025-05-20 20:47:53 +03:00
asagi4 36b3638f4e refactor: tokenize_chunks 2025-05-20 19:45:16 +03:00
asagi4 d46000ef78 Allow TE_WEIGHT(all=1.1) 2025-05-20 19:21:01 +03:00
asagi4 aba246a33c Add a note about compositing to clarify docs, fixes #101 2025-05-05 15:33:25 +03:00
asagi4 42ae22db83 Rename the 2pass workflow for now; it needs review 2025-04-30 16:39:48 +03:00
asagi4 cb6de285cb Fix link in README 2025-04-03 20:51:59 +03:00
asagi4 49a073bb12 Update the comparison workflow 2025-04-03 20:45:15 +03:00
asagi4 e9afe779ae Refresh template workflow 2025-04-03 20:09:21 +03:00
asagi4 fa3b4f7da3 Fix brain typo 2025-04-02 23:25:38 +03:00
asagi4 7a76cc8c72 v2.0.0-beta.11 2025-04-02 23:17:55 +03:00
asagi4 6b1e2a5a8a Remove steps from PCSetPCTextEncodeSettings, it can't be used 2025-04-02 23:17:55 +03:00
asagi4 9aee531c09 Allow configuring a steps value via the Advanced nodes 2025-04-02 23:17:55 +03:00
asagi4 c08bf395a6 Test cleanup 2025-04-02 23:17:55 +03:00
asagi4 1964708997 Combine PCLazyTextEncode with PCLazyTextEncodeAdvanced 2025-04-02 23:17:55 +03:00
1c4b5ce0c4 chore(publish): update GitHub Actions workflow for node publishing (#99)
- Add permissions to allow issue writing
- Update action version to v1 for publish-node-action
- Add condition to run job only for 'asagi4' repository owner

Co-authored-by: snomiao <snomiao+comfy-pr@gmail.com>
Co-authored-by: asagi4 <130366179+asagi4@users.noreply.github.com>
2025-03-22 17:12:08 +02:00
asagi4 5eabbb419c Re-enable tests 2025-03-10 21:40:55 +02:00
asagi4 53400a029b v2.0.0-beta.10 2025-03-10 21:39:37 +02:00
asagi4 c39605eec4 Gah, tests broke.
I hate mocking
2025-03-10 21:38:03 +02:00
asagi4 dc62e638ed How on earth does this syntax work 2025-03-10 20:30:38 +02:00
asagi4 109cac16ef ... 2025-03-10 20:27:07 +02:00
asagi4 cf6c2b3e6a Fix github actions 2025-03-10 20:22:36 +02:00
13 changed files with 727 additions and 646 deletions
+4 -1
View File
@@ -7,6 +7,9 @@ on:
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
tests:
uses: ./.github/workflows/tests.yml
@@ -15,6 +18,7 @@ jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'asagi4' }}
needs: [tests, tests_with_comfy]
steps:
- name: Check out code
@@ -22,5 +26,4 @@ jobs:
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+8 -7
View File
@@ -1,11 +1,11 @@
name: Run tests requiring ComfyUI
on:
- workflow_call
- workflow_dispatch
- push:
paths:
- prompt_control/nodes_lazy.py
- prompt_control/utils.py
workflow_call:
workflow_dispatch:
push:
paths:
- prompt_control/nodes_lazy.py
- prompt_control/utils.py
jobs:
@@ -23,5 +23,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- 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
+2 -5
View File
@@ -28,6 +28,7 @@ See [features](#features) below. Things you can control via the prompt:
- Prompt editing and filtering without noodle soup
- LoRA loading and scheduling via ComfyUI's hook system
- Masking, composition and area control (regional prompting)
- Per-encoder prompts for models with multiple text encoders, such as SDXL and Flux
- Prompt operations like `BREAK` and `AND`
- Weight interpretation types (comfy, A1111, etc.)
- Prompt masking with [cutoff](#cutoff)
@@ -37,9 +38,7 @@ See the [syntax documentation](doc/syntax.md)
If you find prompt scheduling inconvenient for some reason, `PCTextEncode` can be used as a drop-in replacement for `CLIPTextEncode` to get everything else.
[This workflow](workflows/example-lazy.json?raw=1) shows LoRA scheduling and prompt editing and compares it with the same prompt implemented with built-in ComfyUI nodes.
[Here](workflows/example-2pass.json?raw=1) is a two-pass workflow illustrating more features, including custom masks and filtering.
[This workflow](example_workflows/Workflow%20Comparison.json?raw=1) shows LoRA scheduling and prompt editing and compares it with the same prompt implemented with built-in ComfyUI nodes. You can also find it in the template library.
The tools in this repository combine well with the macro and wildcard functionality in [comfyui-utility-nodes](https://github.com/asagi4/comfyui-utility-nodes)
@@ -136,8 +135,6 @@ a (red:1.1) cat with (brown:0.9) spots and a long tail AND STYLE(A1111) an (old:
```
Will interpret the first one using the default ComfyUI behaviour, the second prompt with A1111 and the last prompt with the default again
For things (ie. the code imports) to work, the nodes must be cloned in a directory named exactly `ComfyUI_ADV_CLIP_emb`.
## Cutoff
NOTE: Cutoff syntax might change at some point; it's pretty clunky.
+30 -8
View File
@@ -26,6 +26,14 @@ switches from `a red cat` to `a blue cat` at 0.5 and to `a cat` at 0.7
**Note:** As a special case, `[cat:0.5]` is equivalent to `[:cat:0.5]` meaning it switches from empty to `cat` at 0.5.
### 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.
### Range expressions
You can also use `a [during:after:0.3,0.7]` as a shortcut. The prompt be `a` until 0.3, `a during` until 0.7, and then `a after`. This form is equivalent to `[[during:after:0.7]:0.3]`
@@ -112,15 +120,23 @@ The nodes do not treat SDXL models specially, but there are some utilities that
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`.
To set the `clip_l` prompt, as with `CLIPTextEncodeSDXL`, use the function `CLIP_L(prompt text goes here)`.
### Multiple text encoders: TE
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:
- Multiple instances of `CLIP_L` are joined with a space. That is, `CLIP_L(foo)CLIP_L(bar)` is the same as `CLIP_L(foo bar)`
- Using `BREAK` isn't supported in it; it'll just parse as the plain word BREAK.
- similarly, `AND` inside `CLIP_L` does not do anything sensible; `CLIP_L(foo AND bar)` will parse as two prompts `CLIP_L(foo` and `bar)`
- `CLIP_L` and `SDXL` have no effect on SD 1.5.
- The rest of the prompt becomes the `clip_g` prompt.
- If there is no `CLIP_L` or `SDXL`, the prompts will work as with `CLIPTextEncode`.
- 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
@@ -155,6 +171,8 @@ The function `NOISE(weight, seed)` adds some random noise into the prompt. The s
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`
@@ -241,4 +259,8 @@ Use `ATTN()` in combination with `MASK()` or `IMASK()` to enable attention maski
## 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 `g`, `l`, or `t5xxl`. For example with SDXL, try `TE_WEIGHT(g=0.25, l=0.75`). The weights are applied as a multiplier to the TE output.
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)`
+76 -91
View File
@@ -1,4 +1,6 @@
{
"id": "e820c2fb-9502-45b7-a864-684757dddcdf",
"revision": 0,
"last_node_id": 18,
"last_link_id": 20,
"nodes": [
@@ -21,26 +23,26 @@
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
2
],
"slot_index": 0
]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3
],
"slot_index": 1
]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
16
],
"slot_index": 2
18
]
}
],
"properties": {
@@ -66,7 +68,7 @@
"flags": {
"collapsed": true
},
"order": 8,
"order": 7,
"mode": 0,
"inputs": [
{
@@ -87,10 +89,10 @@
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
12
],
"slot_index": 0
]
}
],
"title": "PC: Schedule Prompt (positive)",
@@ -109,24 +111,26 @@
"id": 3,
"type": "PCLazyLoraLoader",
"pos": [
255,
-765
257.5,
-745
],
"size": [
210,
98
78
],
"flags": {},
"order": 7,
"order": 6,
"mode": 0,
"inputs": [
{
"name": "model",
"shape": 7,
"type": "MODEL",
"link": 2
},
{
"name": "clip",
"shape": 7,
"type": "CLIP",
"link": 3
},
@@ -143,19 +147,19 @@
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
17
],
"slot_index": 0
]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
5,
9
],
"slot_index": 1
]
}
],
"properties": {
@@ -181,7 +185,7 @@
474
],
"flags": {},
"order": 10,
"order": 9,
"mode": 0,
"inputs": [
{
@@ -209,10 +213,10 @@
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
15
],
"slot_index": 0
]
}
],
"properties": {
@@ -242,13 +246,16 @@
225
],
"flags": {},
"order": 5,
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "text"
},
"links": [
6,
7
@@ -277,17 +284,20 @@
225
],
"flags": {},
"order": 4,
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "text"
},
"slot_index": 0,
"links": [
8
],
"slot_index": 0
]
}
],
"title": "Negative prompt",
@@ -314,7 +324,7 @@
"flags": {
"collapsed": true
},
"order": 9,
"order": 8,
"mode": 0,
"inputs": [
{
@@ -335,10 +345,10 @@
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
13
],
"slot_index": 0
]
}
],
"title": "PC: Schedule Prompt (negative)",
@@ -365,7 +375,7 @@
106
],
"flags": {},
"order": 1,
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
@@ -402,7 +412,7 @@
46
],
"flags": {},
"order": 11,
"order": 10,
"mode": 0,
"inputs": [
{
@@ -420,53 +430,18 @@
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
20
],
"slot_index": 0
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "VAEDecode"
}
},
{
"id": 11,
"type": "Reroute",
"pos": [
1155,
-900
],
"size": [
75,
26
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 16
}
],
"outputs": [
{
"name": "",
"type": "VAE",
"links": [
18
],
"slot_index": 0
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
"widgets_values": []
},
{
"id": 13,
@@ -480,7 +455,7 @@
105
],
"flags": {},
"order": 3,
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
@@ -503,7 +478,7 @@
210
],
"flags": {},
"order": 2,
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [],
@@ -526,7 +501,7 @@
405
],
"flags": {},
"order": 12,
"order": 11,
"mode": 0,
"inputs": [
{
@@ -634,14 +609,6 @@
0,
"LATENT"
],
[
16,
1,
2,
11,
0,
"*"
],
[
17,
3,
@@ -650,14 +617,6 @@
0,
"MODEL"
],
[
18,
11,
0,
10,
1,
"VAE"
],
[
20,
10,
@@ -665,6 +624,14 @@
18,
0,
"IMAGE"
],
[
18,
1,
2,
10,
1,
"VAE"
]
],
"groups": [],
@@ -673,10 +640,28 @@
"ds": {
"scale": 0.8,
"offset": [
588,
1260
591.75,
1235
]
}
},
"linkExtensions": [
{
"id": 18,
"parentId": 1
}
],
"reroutes": [
{
"id": 1,
"pos": [
1273.75,
-879.5
],
"linkIds": [
18
]
}
]
},
"version": 0.4,
"models": [{
File diff suppressed because it is too large Load Diff
+36 -52
View File
@@ -1,6 +1,7 @@
import logging
from .parser import parse_prompt_schedules
from comfy_execution.graph_utils import GraphBuilder, is_link
from comfy_execution.graph import ExecutionBlocker
from .utils import get_function
@@ -86,7 +87,7 @@ def create_hook_nodes_for_lora(graph, path, info, existing_node, start_pct, end_
return hook_node, next_keyframe
def build_lora_schedule(graph, schedule, model, clip, apply_hooks=True, return_hooks=True):
def build_lora_schedule(graph, schedule, model, clip, apply_hooks=True):
# This gets rid of non-existent LoRAs
consolidated = consolidate_schedule(schedule)
if model is not None:
@@ -129,7 +130,7 @@ def build_lora_schedule(graph, schedule, model, clip, apply_hooks=True, return_h
n.set_input("hooks_B", h.out(0))
res = n
res = res.out(0)
if apply_hooks:
if clip is not None and apply_hooks:
n = graph.node("SetClipHooks")
n.set_input("clip", clip)
n.set_input("hooks", res)
@@ -137,13 +138,12 @@ def build_lora_schedule(graph, schedule, model, clip, apply_hooks=True, return_h
n.set_input("schedule_clip", True)
clip = n.out(0)
if clip is None:
clip = ExecutionBlocker("No clip model provided to PCLazyLoRALoader or PCLazyLoRALoaderAdvanced")
r = graph.finalize()
log.debug("LazyLoraLoader built graph: %s", json.dumps(r))
if return_hooks:
ret = (model, clip, res)
else:
ret = (model, clip)
ret = (model, clip, res)
return {"result": ret, "expand": r}
@@ -154,16 +154,15 @@ class PCLazyLoraLoaderAdvanced:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"multiline": True}),
"optional": {
"model": ("MODEL", {"rawLink": True}),
"clip": ("CLIP", {"rawLink": True}),
},
"optional": {
"text": ("STRING", {"multiline": True, "default": ""}),
"apply_hooks": ("BOOLEAN", {"default": True}),
"tags": ("STRING", {"default": ""}),
"start": ("FLOAT", {"min": 0.0, "max": 1.0, "default": 0.0, "step": 0.01}),
"end": ("FLOAT", {"min": 0.0, "max": 1.0, "default": 1.0, "step": 0.01}),
"num_steps": ("INT", {"min": 0, "max": 10000, "default": 0, "step": 1}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
@@ -173,15 +172,16 @@ class PCLazyLoraLoaderAdvanced:
CATEGORY = "promptcontrol"
FUNCTION = "apply"
def apply(self, model, clip, text, unique_id, apply_hooks=True, tags="", start=0.0, end=1.0):
schedule = parse_prompt_schedules(text, filters=tags, start=start, end=end)
def apply(
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}-")
return build_lora_schedule(graph, schedule, model, clip, apply_hooks=apply_hooks, return_hooks=True)
r = build_lora_schedule(graph, schedule, model, clip, apply_hooks=apply_hooks)
return r
class PCLazyLoraLoader:
CACHE_KEY = cache_key_lora
class PCLazyLoraLoader(PCLazyLoraLoaderAdvanced):
@classmethod
def INPUT_TYPES(s):
return {
@@ -197,19 +197,12 @@ class PCLazyLoraLoader:
"MODEL",
"CLIP",
)
OUTPUT_TOOLTIPS = ("Returns a model and clip with LoRAs scheduled",)
CATEGORY = "promptcontrol"
FUNCTION = "apply"
def apply(self, unique_id, model=None, clip=None, text=""):
graph = GraphBuilder(f"{unique_id}-")
schedule = parse_prompt_schedules(text)
if model is None and clip is None:
return (
ExecutionBlocker("No model input provided to PCLazyLoraLoader"),
ExecutionBlocker("No clip input provided to PCLazyLoraLoader"),
)
return build_lora_schedule(graph, schedule, model, clip, apply_hooks=True, return_hooks=False)
def apply(self, *args, **kwargs):
r = super().apply(*args, **kwargs)
r["result"] = r["result"][:2]
return r
def build_scheduled_prompts(graph, schedules, clip):
@@ -245,32 +238,11 @@ def build_scheduled_prompts(graph, schedules, clip):
return {"result": (node.out(0),), "expand": g}
def cache_key_from_inputs(cachekey, text, tags="", start=0.0, end=1.0, **kwargs):
schedules = parse_prompt_schedules(text, filters=tags, start=start, end=end)
def cache_key_from_inputs(cachekey, text, tags="", start=0.0, end=1.0, num_steps=0, **kwargs):
schedules = parse_prompt_schedules(text, filters=tags, start=start, end=end, num_steps=num_steps)
return [(pct, s[cachekey]) for pct, s in schedules]
class PCLazyTextEncode:
CACHE_KEY = cache_key_prompt
@classmethod
def INPUT_TYPES(s):
return {
"required": {"clip": ("CLIP", {"rawLink": True}), "text": ("STRING", {"multiline": True})},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = ("CONDITIONING",)
OUTPUT_TOOLTIPS = ("A fully encoded and scheduled conditioning",)
CATEGORY = "promptcontrol"
FUNCTION = "apply"
def apply(self, clip, text, unique_id):
schedules = parse_prompt_schedules(text)
graph = GraphBuilder(f"{unique_id}-")
return build_scheduled_prompts(graph, schedules, clip)
class PCLazyTextEncodeAdvanced:
CACHE_KEY = cache_key_prompt
@@ -282,6 +254,7 @@ class PCLazyTextEncodeAdvanced:
"tags": ("STRING", {"default": ""}),
"start": ("FLOAT", {"min": 0.0, "max": 1.0, "default": 0.0, "step": 0.01}),
"end": ("FLOAT", {"min": 0.0, "max": 1.0, "default": 1.0, "step": 0.01}),
"num_steps": ("INT", {"min": 0, "max": 10000, "default": 0, "step": 1}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
@@ -290,12 +263,23 @@ class PCLazyTextEncodeAdvanced:
CATEGORY = "promptcontrol"
FUNCTION = "apply"
def apply(self, clip, text, unique_id, tags="", start=0.1, end=1.0):
schedules = parse_prompt_schedules(text, filters=tags, start=start, end=end)
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}-")
return build_scheduled_prompts(graph, schedules, clip)
class PCLazyTextEncode(PCLazyTextEncodeAdvanced):
@classmethod
def INPUT_TYPES(s):
return {
"required": {"clip": ("CLIP", {"rawLink": True}), "text": ("STRING", {"multiline": True})},
"hidden": {"unique_id": "UNIQUE_ID"},
}
CATEGORY = "promptcontrol"
NODE_CLASS_MAPPINGS = {
"PCLazyTextEncode": PCLazyTextEncode,
"PCLazyTextEncodeAdvanced": PCLazyTextEncodeAdvanced,
-3
View File
@@ -143,7 +143,6 @@ class PCSetPCTextEncodeSettings:
return {
"required": {"clip": ("CLIP",)},
"optional": {
"steps": ("INT", {"default": 0, "min": 0, "max": 10000}),
"mask_width": ("INT", {"default": 512, "min": 64, "max": 4096 * 4}),
"mask_height": ("INT", {"default": 512, "min": 64, "max": 4096 * 4}),
"sdxl_width": ("INT", {"default": 1024, "min": 0, "max": 4096 * 4}),
@@ -163,7 +162,6 @@ class PCSetPCTextEncodeSettings:
def apply(
self,
clip,
steps=0,
mask_width=512,
mask_height=512,
sdxl_width=1024,
@@ -174,7 +172,6 @@ class PCSetPCTextEncodeSettings:
sdxl_crop_h=0,
):
settings = {
"steps": steps,
"mask_width": mask_width,
"mask_height": mask_height,
"sdxl_width": sdxl_width,
+29 -15
View File
@@ -103,13 +103,25 @@ def clamp(a, b, c):
return min(max(a, b), c)
def get_steps(tree):
res = [100]
def get_steps(tree, num_steps):
res = [num_steps or 100]
def tostep(s):
w = float(s) * 100
w = int(clamp(0, w, 100))
return w
steps = num_steps or 100
if "." in str(s) or not num_steps:
w = float(s)
value = w * steps
else:
w = int(s)
value = w
if w > 1 and not num_steps:
log.warning(
"You haven't configured the number of steps for Prompt Control to use, %s will be clipped to 1.0", w
)
value = steps
return int(clamp(0, value, steps))
class CollectSteps(lark.Visitor):
def scheduled(self, tree):
@@ -131,15 +143,14 @@ def get_steps(tree):
def sequence(self, tree):
steps = tree.children[1::2]
for i, steps in enumerate(steps):
w = float(tree.children[i * 2 + 1]) * 100
tree.children[i * 2 + 1] = clamp(0, w, 100)
w = tostep(tree.children[i * 2 + 1])
tree.children[i * 2 + 1] = w
res.append(w)
def alternate(self, tree):
step_size = int(round(float(tree.children[-1] or 0.1), 2) * 100)
step_size = clamp(1, step_size, 100)
step_size = tostep(round(float(tree.children[-1] or 0.1), 2))
tree.children[-1] = step_size
res.extend([x for x in range(step_size, 100, step_size)])
res.extend([x for x in range(step_size, num_steps or 100, step_size)])
CollectSteps().visit(tree)
@@ -259,29 +270,31 @@ def at_step(step, filters, tree):
class PromptSchedule(object):
def __init__(self, prompt, filters="", start=0.0, end=1.0):
# 0 num_steps means unconfigured
def __init__(self, prompt, filters="", start=0.0, end=1.0, num_steps=0):
self.filters = filters
self.start = start
self.end = end
self.num_steps = num_steps
self.prompt = prompt.strip()
self.defaults = {}
self.loaded_loras = {}
self.parsed_prompt = self._parse()
self.parsed_prompt = self._parse(num_steps)
def __iter__(self):
# Filter out zero, it's only useful for interpolation
return (x for x in self.parsed_prompt if x[0] != 0)
def _parse(self):
def _parse(self, num_steps):
filters = [x.strip() for x in self.filters.upper().split(",")]
try:
parsed = []
tree = prompt_parser.parse(self.prompt)
steps = get_steps(tree)
steps = get_steps(tree, num_steps=num_steps)
def f(x):
return round(x / 100, 2)
return round(x / (num_steps or 100), 2)
for t in steps:
p = at_step(t, filters, tree)
@@ -331,6 +344,7 @@ class PromptSchedule(object):
filters=ifspecified(filters, self.filters),
start=ifspecified(start, self.start),
end=ifspecified(end, self.end),
num_steps=self.num_steps,
)
return p
+74 -36
View File
@@ -138,6 +138,28 @@ def fix_word_ids(tokens):
return tokens
def tokenize_chunks(clip, text, need_word_ids):
chunks = re.split(r"\bBREAK\b", text)
token_chunks = []
for c in chunks:
c, shuffles = get_function(c.strip(), "(SHIFT|SHUFFLE)", ["0", "default", "default"], return_func_name=True)
r = c
for s in shuffles:
r = shuffle_chunk(s, r)
if r != c:
log.info("Shuffled prompt chunk to %s", r)
c = 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])
return tokens
def encode_prompt_segment(
clip,
text,
@@ -157,37 +179,46 @@ def encode_prompt_segment(
# defaults=None means there is no argument parsing at all
text, l_prompts = get_function(text, "CLIP_L", defaults=None)
chunks = re.split(r"\bBREAK\b", text)
token_chunks = []
text, te_prompts = get_function(text, "TE", defaults=None)
need_word_ids = True
for c in chunks:
c, shuffles = get_function(c.strip(), "(SHIFT|SHUFFLE)", ["0", "default", "default"], return_func_name=True)
r = c
for s in shuffles:
r = shuffle_chunk(s, r)
if r != c:
log.info("Shuffled prompt chunk to %s", r)
c = r
t = clip.tokenize(c, return_word_ids=need_word_ids)
token_chunks.append(t)
tokens = token_chunks[0]
tokens = tokenize_chunks(clip, text, need_word_ids)
for key in tokens:
for c in token_chunks[1:]:
tokens[key].extend(c[key])
per_te_prompts = {}
if l_prompts:
log.warning("Note: CLIP_L is deprecated. Use TE(l=prompt) instead")
per_te_prompts["l"] = l_prompts
# Non-SDXL has only "l"
if "g" in tokens and l_prompts:
text_l = " ".join(l_prompts)
log.info("Encoded SDXL CLIP_L prompt: %s", text_l)
tokens["l"] = clip.tokenize(text_l, return_word_ids=need_word_ids)["l"]
for prompt in te_prompts:
if prompt.strip() == "help":
log.info("Encoders available for TE: %s", ", ".join(tokens.keys()))
continue
params = prompt.split("=", 1)
if len(params) != 2:
log.warning("Invalid TE call, ignoring: %s", prompt)
continue
te = params[0].strip()
prompt = params[1].strip()
if te not in tokens:
log.warning("Invalid TE call, no TE with key '%s', ignoring: %s", te)
log.info("Encoders available for TE: %s", ", ".join(tokens.keys()))
continue
l = per_te_prompts.get(te, [])
l.append(prompt)
per_te_prompts[te] = l
if "g" in tokens and "l" in tokens and len(tokens["l"]) != len(tokens["g"]):
empty = clip.tokenize("", return_word_ids=need_word_ids)
while len(tokens["l"]) < len(tokens["g"]):
tokens["l"] += empty["l"]
while len(tokens["l"]) > len(tokens["g"]):
tokens["g"] += empty["g"]
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]
log.info("Encoded prompt with TE '%s': %s", key, prompt)
maxlen = max(len(tokens[k]) for k in tokens)
empty = None
for k in tokens:
while len(tokens[k]) < maxlen:
if empty is None:
empty = clip.tokenize("", return_word_ids=need_word_ids)
tokens[k] += empty[k]
tokens = fix_word_ids(tokens)
@@ -211,21 +242,28 @@ def apply_weights(output, te_name, spec):
if te_name.startswith("clip_"):
te_name = te_name[5:]
default = spec.get("all", None)
if isinstance(output, tuple):
out, pooled = output
if te_name in spec:
log.info("Weighting %s output by %s", te_name, spec[te_name])
out = out * spec[te_name]
pkey = te_name + "_pooled"
if pkey in spec:
log.info("Weighting %s pooled output by %s", te_name, spec[pkey])
pooled = pooled * spec[pkey]
if te_name in spec or pkey in spec or default is not None:
w = spec.get(te_name, default)
pooled_w = spec.get(pkey, w)
if w is None:
w = 1.0
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
return out, pooled
else:
if te_name in spec:
log.info("Weighting %s output by %s", te_name, spec[te_name])
output = output * spec[te_name]
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
return output
+22 -21
View File
@@ -1,5 +1,8 @@
import unittest
import unittest.mock as mock
import logging
log = logging.getLogger("comfyui-prompt-control")
def find_file(name):
@@ -7,10 +10,17 @@ def find_file(name):
return names.get(name)
def apply(cls, text, **kwargs):
model = [0, 1]
clip = [0, 0]
return cls().apply(unique_id="UID", model=model, clip=clip, text=text, **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
@mock.patch("prompt_control.utils.lora_name_to_file", find_file)
def test_textencode(self):
clip = [0, 0]
from .nodes_lazy import PCLazyTextEncode, PCLazyTextEncodeAdvanced
@@ -73,23 +83,16 @@ class GraphTests(unittest.TestCase):
model = [0, 1]
clip = [0, 0]
with self.assertLogs("comfyui-prompt-control", level="WARNING") as cm:
result = PCLazyLoraLoader().apply(model, clip, "prompt here <lora:nonexistent:1.0:0.5>", "UID")["expand"]
result_adv = PCLazyLoraLoaderAdvanced().apply(model, clip, "prompt here <lora:nonexistent:1.0:0.5>", "UID")[
"expand"
]
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"]
self.assertIn("LoRA 'nonexistent' not found", cm.output[0])
self.assertIn("LoRA 'nonexistent' not found", cm.output[1])
self.assertEqual(result, {})
self.assertEqual(result_adv, {})
result = PCLazyLoraLoader().apply(model, clip, "<lora:test:1>", "UID")["expand"]
result2 = PCLazyLoraLoader().apply(model, clip, "prompt here <lora:test:1.0:0.5><lora:test:0:0.5>", "UID")[
"expand"
]
result3 = PCLazyLoraLoaderAdvanced().apply(
model, clip, "prompt here <lora:test:1.0:0.5><lora:test:0:0.5>", "UID"
)["expand"]
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"]
self.assertEqual(result, result2)
self.assertEqual(result2, result3)
self.assertEqual(
@@ -107,7 +110,7 @@ class GraphTests(unittest.TestCase):
}
},
)
result = PCLazyLoraLoader().apply(model, clip, "<lora:test:1><lora:other:0.5>", "UID")["expand"]
result = apply(PCLazyLoraLoader, "<lora:test:1><lora:other:0.5>")["expand"]
self.assertEqual(
result,
{
@@ -134,7 +137,7 @@ class GraphTests(unittest.TestCase):
},
)
result = PCLazyLoraLoader().apply(model, clip, "prompt here <lora:test:1.0:0.5>", "UID")["expand"]
result = apply(PCLazyLoraLoader, "prompt here <lora:test:1.0:0.5>")["expand"]
self.assertEqual(
result,
{
@@ -151,8 +154,8 @@ class GraphTests(unittest.TestCase):
},
)
result = PCLazyLoraLoader().apply(model, clip, "prompt [<lora:test:0.5>:0.5]", "UID")["expand"]
result2 = PCLazyLoraLoaderAdvanced().apply(model, clip, "prompt [<lora:test:0.5>:0.5]", "UID")["expand"]
result = apply(PCLazyLoraLoader, "prompt [<lora:test:0.5>:0.5]")["expand"]
result2 = apply(PCLazyLoraLoaderAdvanced, "prompt [<lora:test:0.5>:0.5]")["expand"]
self.assertEqual(result, result2)
expected = {
"UID-1": {
@@ -186,9 +189,7 @@ class GraphTests(unittest.TestCase):
},
}
self.assertEqual(result, expected)
result2 = PCLazyLoraLoaderAdvanced().apply(model, clip, "prompt [<lora:test:0.5>:0.5]", "UID", start=0.6)[
"expand"
]
result2 = apply(PCLazyLoraLoaderAdvanced, "prompt [<lora:test:0.5>:0.5]", start=0.6)["expand"]
self.assertEqual(
result2,
{
+1 -2
View File
@@ -1,14 +1,13 @@
[project]
name = "comfyui-prompt-control"
description = "Nodes for convenient prompt editing, making many common operations prompt-controllable"
version = "2.0.0-beta.9"
version = "2.0.0-rc.1"
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"]
[project.urls]
Repository = "https://github.com/asagi4/comfyui-prompt-control"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "asagi4"