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
asagi4 d113d4ba78 v2.0.0-beta.9 2025-03-10 20:17:34 +02:00
asagi4 5bd1d04dcd Fix template image name, again... 2025-03-10 20:15:53 +02:00
asagi4 7c10770e07 Debug node for saving the expanded workflow 2025-03-10 20:09:57 +02:00
asagi4 69ea298174 Make the model input optional in PCLazyLoRALoader 2025-03-03 17:52:32 +02:00
asagi4 4ba4b28bb2 README tweaks 2025-03-03 14:49:52 +02:00
asagi4 f728866b90 Shuffle workflows around a bit 2025-03-03 14:25:56 +02:00
asagi4 306c02f57b Pin publish workflow version 2025-03-03 14:15:27 +02:00
asagi4 2c519310ac Fix workflow 2025-03-03 14:12:29 +02:00
asagi4 3f23d1b14a Enable manual execution for tests 2025-03-03 14:10:57 +02:00
asagi4 148776fe5d Try running graph tests via CI 2025-03-03 14:06:30 +02:00
asagi4 cef4a80440 Fix tests 2025-03-02 23:37:08 +02:00
asagi4 cd642b5d42 Use unique ID properly 2025-03-02 23:35:29 +02:00
asagi4 2b323da9a9 Make target for graph test 2025-03-02 23:28:54 +02:00
asagi4 79b3675c4f Rename test.py to parser_tests.py 2025-03-02 23:21:25 +02:00
asagi4 b8d5b7a7c4 Add some tests for the PCLazyTextEncode and PCLazyLoraLoader 2025-03-02 23:17:13 +02:00
asagi4 a5da586dc5 Consistently use UNIQUE_ID in all nodes 2025-03-02 23:15:43 +02:00
asagi4 01aa061bef Don't bother giving the class name to GraphBuilder 2025-03-02 22:41:08 +02:00
asagi4 2fab4be810 import get_function from the correct file 2025-03-02 22:36:53 +02:00
asagi4 127acb7018 Implement parameters for DEF 2025-03-02 18:51:07 +02:00
asagi4 b952b2f186 v2.0.0-beta.8
Add position data into the example template so that it's actually
visible when loaded.
2025-03-02 01:23:54 +02:00
asagi4 2732a795fb Fix title for negative prompt 2025-03-02 01:02:53 +02:00
asagi4 4f78fff892 Doc fix 2025-03-02 00:53:32 +02:00
asagi4 e290cb57ac Move the note about the template higher so it's easier to notice 2025-03-01 22:24:14 +02:00
asagi4 d577d439e7 v2.0.0-beta.7 2025-03-01 22:09:14 +02:00
asagi4 e59d46c8d1 Add a basic template 2025-03-01 22:05:04 +02:00
asagi4 bd1c69a517 Run tests with Python 3.11 2025-02-28 02:21:27 +02:00
asagi4 cee19aea67 Testing testing 2025-02-28 02:18:29 +02:00
asagi4 b93bb66aed See if tests run with CI 2025-02-28 02:16:17 +02:00
asagi4 c75d1a6651 Refactor tests a bit
Also test that DEF handles whitespace as intended
2025-02-28 02:06:43 +02:00
asagi4 a4c7f99cc1 Fixed duplicate prompts 2025-02-28 01:21:29 +02:00
asagi4 b7d544c05c Remove duplicates from parsed prompts 2025-02-28 00:04:56 +02:00
asagi4 04c4bd0846 Test alternating syntax 2025-02-27 23:35:13 +02:00
asagi4 5365679a60 Test single-element SEQ too 2025-02-27 23:10:03 +02:00
asagi4 fa77c158ac Run tests on make all 2025-02-27 20:27:33 +02:00
asagi4 525cb157ce Forgot to format 2025-02-27 20:26:38 +02:00
asagi4 e10950e4da Test some more weirdness 2025-02-27 20:26:07 +02:00
19 changed files with 1883 additions and 685 deletions
+10 -2
View File
@@ -7,15 +7,23 @@ on:
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
tests:
uses: ./.github/workflows/tests.yml
tests_with_comfy:
uses: ./.github/workflows/tests_with_comfy.yml
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
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
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 }}
+18
View File
@@ -0,0 +1,18 @@
name: Run parser tests
on:
- workflow_call
- workflow_dispatch
- push
jobs:
run-parser-tests:
name: Run parser tests
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: python -m prompt_control.test_parser
+28
View File
@@ -0,0 +1,28 @@
name: Run tests requiring ComfyUI
on:
workflow_call:
workflow_dispatch:
push:
paths:
- prompt_control/nodes_lazy.py
- prompt_control/utils.py
jobs:
run-graph-tests:
name: Run graph tests
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Check out ComfyUI
uses: actions/checkout@v4
with:
repository: comfyanonymous/ComfyUI
path: ComfyUI
- 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
+5 -2
View File
@@ -1,4 +1,4 @@
all: format check
all: format check test
@echo "Done"
check:
find . -name "*.py" | xargs pyflakes
@@ -6,6 +6,9 @@ format:
find . -name "*.py" | xargs black -l 120
test:
python -m prompt_control.test
python -m prompt_control.test_parser
test_graph:
PYTHONPATH=../../ python -m prompt_control.test_graph
.PHONY: check format all
+5 -11
View File
@@ -2,11 +2,13 @@
Control LoRA and prompt scheduling, advanced text encoding, regional prompting, and much more, through your text prompt. Generates dynamic graphs that are literally identical to handcrafted noodle soup.
A `Basic Text to Image` template is included with the extension, and can be loaded from ComfyUI's template library.
## 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
Prompt Control also comes with `PCTextEncode`, which provides advanced text encoding with many additional features compared to ComfyUI's base `CLIPTextEncode`.
Prompt Control comes with `PCTextEncode`, which provides advanced text encoding with many additional features compared to ComfyUI's base `CLIPTextEncode`.
### Removed features
@@ -14,10 +16,6 @@ Prompt Control also comes with `PCTextEncode`, which provides advanced text enco
- LoRA block weight integration; ditto, for now.
### Is it stable now?
Unless I run into bugs or significant annoyances that require changing the interface, it probably won't change too much, but until I tag 2.0, everything can change.
### 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.
@@ -30,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)
@@ -39,10 +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)
@@ -139,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.
+69 -12
View File
@@ -24,12 +24,20 @@ 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
**Note:** As a special case, `[cat:0.5]` is equivalent to `[:cat:0.5]` meaning it switches from empty to `cat` at 0.5.
**Note:** As a special case, `[cat:0.5]` is like `[:cat:0.5]` meaning it switches from empty to `cat` at 0.5. Currently, `[:cat:0.5]` doesn't actually parse correctly, so you **must** use the shortcut form
### 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]`
For convenience, `[during:0.1,0.4]` is equivalent to `[during::0.1,0.4]`
## Tag selection
@@ -79,7 +87,6 @@ Might be useful with Jinja templating (see https://github.com/asagi4/comfyui-uti
```
generates a LoRA schedule based on a sinewave
# Basic prompt syntax
This syntax is also available in outside scheduled prompts, where applicable.
@@ -98,7 +105,6 @@ cat :1 AND dog :2
```
The weight defaults to 1 and are normalized so that `a:2 AND b:2` is equal to `a AND b`. `AND` is processed after schedule parsing, so you can change the weight mid-prompt: `cat:[1:2:0.5] AND dog`
## Functions
There are some "functions" that can be included in a prompt to do various things.
@@ -114,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
@@ -157,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`
@@ -198,7 +214,44 @@ The order of the `FEATHER` and `MASK` calls doesn't matter; you can have `FEATHE
# Experimental features
Experimental features are unstable and may disappear or break without warning.
Experimental features are unstable and may disappear or change without warning.
## DEF
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.
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 masking
@@ -206,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)`
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+672
View File
@@ -0,0 +1,672 @@
{
"id": "e820c2fb-9502-45b7-a864-684757dddcdf",
"revision": 0,
"last_node_id": 18,
"last_link_id": 20,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [
-135,
-930
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
2
]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3
]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
18
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"NoobAI-XL-Vpred-v1.0.safetensors"
]
},
{
"id": 2,
"type": "PCLazyTextEncode",
"pos": [
555,
-720
],
"size": [
252,
78
],
"flags": {
"collapsed": true
},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
},
{
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 7
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
12
]
}
],
"title": "PC: Schedule Prompt (positive)",
"properties": {
"cnr_id": "comfyui-prompt-control",
"ver": "2.0.0-beta.7",
"Node name for S&R": "PCLazyTextEncode"
},
"widgets_values": [
"STYLE(A1111) 1girl, [painting \\(medium\\), realistic,::0.2] fennec fox girl, animal ear fluff, [[purple:white pupils, purple:0.2] eyes:sparkling eyes:0.85], cargo pants, long sleeves, cardigan, winter, snow, steaming cup, coffee mug, [thermos,:0.1] [long hair,:0.25] [BREAK:0.3]\n[(masterpiece, best quality, newest, very awa,):0.1], night sky, full moon, star \\(sky\\),"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 3,
"type": "PCLazyLoraLoader",
"pos": [
257.5,
-745
],
"size": [
210,
78
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "model",
"shape": 7,
"type": "MODEL",
"link": 2
},
{
"name": "clip",
"shape": 7,
"type": "CLIP",
"link": 3
},
{
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 6
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
17
]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
5,
9
]
}
],
"properties": {
"cnr_id": "comfyui-prompt-control",
"ver": "2.0.0-beta.7",
"Node name for S&R": "PCLazyLoraLoader"
},
"widgets_values": [
"STYLE(A1111) 1girl, [painting \\(medium\\), realistic,::0.2] fennec fox girl, animal ear fluff, [[purple:white pupils, purple:0.2] eyes:sparkling eyes:0.85], cargo pants, long sleeves, cardigan, winter, snow, steaming cup, coffee mug, [thermos,:0.1] [long hair,:0.25] [BREAK:0.3]\n[(masterpiece, best quality, newest, very awa,):0.1], night sky, full moon, star \\(sky\\),"
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 4,
"type": "KSampler",
"pos": [
930,
-780
],
"size": [
315,
474
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 17
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 12
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 13
},
{
"name": "latent_image",
"type": "LATENT",
"link": 14
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
15
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "KSampler"
},
"widgets_values": [
2,
"fixed",
25,
1.4000000000000001,
"euler_cfg_pp",
"simple",
1
]
},
{
"id": 5,
"type": "PrimitiveNode",
"pos": [
-270,
-780
],
"size": [
495,
225
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "text"
},
"links": [
6,
7
]
}
],
"title": "Positive prompt (with LoRAs)",
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
"STYLE(A1111) 1girl, [painting \\(medium\\), realistic,::0.2] fennec fox girl, animal ear fluff, [[purple:white pupils, purple:0.2] eyes:sparkling eyes:0.85], cargo pants, long sleeves, cardigan, winter, snow, steaming cup, coffee mug, [thermos,:0.1] [long hair,:0.25] [BREAK:0.3]\n[(masterpiece, best quality, newest, very awa,):0.1], night sky, full moon, star \\(sky\\),"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 6,
"type": "PrimitiveNode",
"pos": [
-270,
-510
],
"size": [
480,
225
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "text"
},
"slot_index": 0,
"links": [
8
]
}
],
"title": "Negative prompt",
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
"chibi, [bad hands,low quality, worst quality,:0.05], simple background, blurry, sketch, unfinished, [holding two cups,no pupils,:0.1]"
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 7,
"type": "PCLazyTextEncode",
"pos": [
555,
-675
],
"size": [
252,
78
],
"flags": {
"collapsed": true
},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 9
},
{
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 8
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
13
]
}
],
"title": "PC: Schedule Prompt (negative)",
"properties": {
"cnr_id": "comfyui-prompt-control",
"ver": "2.0.0-beta.7",
"Node name for S&R": "PCLazyTextEncode"
},
"widgets_values": [
"chibi, [bad hands,low quality, worst quality,:0.05], simple background, blurry, sketch, unfinished, [holding two cups,no pupils,:0.1]"
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 9,
"type": "EmptyLatentImage",
"pos": [
525,
-615
],
"size": [
315,
106
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
14
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
896,
1152,
1
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 10,
"type": "VAEDecode",
"pos": [
1290,
-780
],
"size": [
210,
46
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 15
},
{
"name": "vae",
"type": "VAE",
"link": 18
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
20
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 13,
"type": "MarkdownNote",
"pos": [
240,
-615
],
"size": [
240,
105
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"If you do not need LoRA scheduling, you can simply skip this node."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 15,
"type": "MarkdownNote",
"pos": [
240,
-450
],
"size": [
600,
210
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"`PC: Schedule prompt` will expand into instances of `PCTextEncode`. `PC: Schedule LoRAs` will expand into the required `LoRALoader`s and `CLIP` hooks required to schedule LoRAs in the prompt.\n\nYou can pass the same prompt to both nodes; `PC: Schedule Prompt` will simply ignore any `<lora:xyz:1>` elements, so they will not affect the prompt.\nSee the [full syntax available in the prompts](https://github.com/asagi4/comfyui-prompt-control/blob/master/doc/syntax.md) on GitHub"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 18,
"type": "SaveImage",
"pos": [
1290,
-690
],
"size": [
270,
405
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 20
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18"
},
"widgets_values": [
"PromptControl"
]
}
],
"links": [
[
2,
1,
0,
3,
0,
"MODEL"
],
[
3,
1,
1,
3,
1,
"CLIP"
],
[
5,
3,
1,
2,
0,
"CLIP"
],
[
6,
5,
0,
3,
2,
"STRING"
],
[
7,
5,
0,
2,
1,
"STRING"
],
[
8,
6,
0,
7,
1,
"STRING"
],
[
9,
3,
1,
7,
0,
"CLIP"
],
[
12,
2,
0,
4,
1,
"CONDITIONING"
],
[
13,
7,
0,
4,
2,
"CONDITIONING"
],
[
14,
9,
0,
4,
3,
"LATENT"
],
[
15,
4,
0,
10,
0,
"LATENT"
],
[
17,
3,
0,
4,
0,
"MODEL"
],
[
20,
10,
0,
18,
0,
"IMAGE"
],
[
18,
1,
2,
10,
1,
"VAE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8,
"offset": [
591.75,
1235
]
},
"linkExtensions": [
{
"id": 18,
"parentId": 1
}
],
"reroutes": [
{
"id": 1,
"pos": [
1273.75,
-879.5
],
"linkIds": [
18
]
}
]
},
"version": 0.4,
"models": [{
"name": "NoobAI-XL-Vpred-v1.0.safetensors",
"url": "https://huggingface.co/Laxhar/noobai-XL-Vpred-1.0/resolve/main/NoobAI-XL-Vpred-v1.0.safetensors",
"directory": "checkpoints"
}]
}
File diff suppressed because it is too large Load Diff
+48 -53
View File
@@ -2,7 +2,9 @@ import logging
from .parser import parse_prompt_schedules
from comfy_execution.graph_utils import GraphBuilder, is_link
from .prompts import get_function
from comfy_execution.graph import ExecutionBlocker
from .utils import get_function
log = logging.getLogger("comfyui-prompt-control")
@@ -85,11 +87,15 @@ 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)
non_scheduled = find_nonscheduled_loras(consolidated)
model, clip = create_lora_loader_nodes(graph, model, clip, non_scheduled)
if model is not None:
non_scheduled = find_nonscheduled_loras(consolidated)
model, clip = create_lora_loader_nodes(graph, model, clip, non_scheduled)
else:
non_scheduled = {}
model = ExecutionBlocker("No model provided to PCLazyLoRALoader or PCLazyLoRALoaderAdvanced")
hook_nodes = {}
start_pct = 0.0
@@ -124,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)
@@ -132,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}
@@ -149,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"},
}
@@ -168,22 +172,23 @@ 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)
graph = GraphBuilder(f"PCLazyLoraLoaderAdvanced-{unique_id}")
return build_lora_schedule(graph, schedule, model, clip, apply_hooks=apply_hooks, return_hooks=True)
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}-")
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 {
"required": {
"optional": {
"model": ("MODEL", {"rawLink": True}),
"clip": ("CLIP", {"rawLink": True}),
"text": ("STRING", {"multiline": True}),
"text": ("STRING", {"multiline": True, "default": ""}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
@@ -192,14 +197,12 @@ class PCLazyLoraLoader:
"MODEL",
"CLIP",
)
OUTPUT_TOOLTIPS = ("Returns a model and clip with LoRAs scheduled",)
CATEGORY = "promptcontrol"
FUNCTION = "apply"
def apply(self, model, clip, text, unique_id):
graph = GraphBuilder(f"PCLazyLoraLoader-{unique_id}")
schedule = parse_prompt_schedules(text)
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):
@@ -235,31 +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})},
}
RETURN_TYPES = ("CONDITIONING",)
OUTPUT_TOOLTIPS = ("A fully encoded and scheduled conditioning",)
CATEGORY = "promptcontrol"
FUNCTION = "apply"
def apply(self, clip, text):
schedules = parse_prompt_schedules(text)
graph = GraphBuilder()
return build_scheduled_prompts(graph, schedules, clip)
class PCLazyTextEncodeAdvanced:
CACHE_KEY = cache_key_prompt
@@ -271,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"},
}
@@ -279,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)
graph = GraphBuilder(f"PCLazyTextEncodeAdvanced-{unique_id}")
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,
+64 -3
View File
@@ -1,9 +1,71 @@
import logging
from .parser import parse_prompt_schedules
from .nodes_lazy import NODE_CLASS_MAPPINGS as LAZY_NODES
import json
import folder_paths
from pathlib import Path
from comfy_execution.graph_utils import is_link
log = logging.getLogger("comfyui-prompt-control")
class PCSaveExpandedWorkflow:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"any": ("*", {}),
},
"hidden": {
"prompt": "DYNPROMPT",
},
}
@classmethod
def VALIDATE_INPUTS(self, input_types):
return True
OUTPUT_NODE = True
RETURN_TYPES = ()
CATEGORY = "promptcontrol/tools"
DESCRIPTION = "Saves the current expanded dynamic prompt into a JSON file"
FUNCTION = "apply"
def apply(self, any, prompt):
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)]
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)
return ()
class PCSetLogLevel:
@classmethod
def INPUT_TYPES(s):
@@ -81,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}),
@@ -101,7 +162,6 @@ class PCSetPCTextEncodeSettings:
def apply(
self,
clip,
steps=0,
mask_width=512,
mask_height=512,
sdxl_width=1024,
@@ -112,7 +172,6 @@ class PCSetPCTextEncodeSettings:
sdxl_crop_h=0,
):
settings = {
"steps": steps,
"mask_width": mask_width,
"mask_height": mask_height,
"sdxl_width": sdxl_width,
@@ -156,6 +215,7 @@ NODE_CLASS_MAPPINGS = {
"PCAddMaskToCLIPMany": PCAddMaskToCLIPMany,
"PCSetLogLevel": PCSetLogLevel,
"PCExtractScheduledPrompt": PCExtractScheduledPrompt,
"PCSaveExpandedWorkflow": PCSaveExpandedWorkflow,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -164,4 +224,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"PCAddMaskToCLIPMany": "PC: Attach Mask (multi)",
"PCSetLogLevel": "PC: Configure Logging (for debug)",
"PCExtractScheduledPrompt": "PC: Extract Scheduled Prompt",
"PCSaveExpandedWorkflow": "PC: Save Expanded Workflow (for debug)",
}
+62 -21
View File
@@ -1,3 +1,4 @@
# vim: sw=4 ts=4
import lark
import logging
from math import ceil
@@ -102,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):
@@ -130,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)
@@ -258,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)
@@ -308,7 +322,15 @@ class PromptSchedule(object):
if len(res) == 0:
res = [[1.0, parsed[-1][1]]]
return res
final = [res[0]]
# Clean up duplicates
for p in res[1:]:
if p[1] != final[-1][1]:
final.append(p)
else:
final[-1][0] = p[0]
return final
def clone(self):
return self.with_filters()
@@ -322,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
@@ -336,7 +359,7 @@ class PromptSchedule(object):
return len(self.parsed_prompt) - 1, self.parsed_prompt[-1]
def replace_defs(text):
def replace_def(text):
text, defs = get_function(text, "DEF", defaults=None)
res = text
prevres = text
@@ -351,19 +374,37 @@ def replace_defs(text):
while True:
iterations += 1
if iterations > 10:
log.error("Unable to resolve DEFs, make sure there are no cycles!")
raise ValueError("Unable to resolve DEFs, make sure there are no cycles!")
return text
for search, replace in replacements:
res = re.sub(rf"\b{re.escape(search)}\b", replace, res)
res = substitute_defcall(res, search, replace)
res = substitute_def(res, search, replace)
if res == prevres:
break
prevres = res
if res != text:
if res.strip() != text.strip():
res = res.strip()
log.info("DEFs expanded to: %s", res)
return res
def substitute_def(text, search, replace):
return re.sub(rf"\b{re.escape(search)}\b", replace, text)
def substitute_defcall(text, search, replace):
text, defns = get_function(text, search, 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(";")]
r = replace
for i, v in enumerate(paramvals):
r = re.sub(rf"\${i+1}\b", v, r)
text = text.replace(ph, r)
return text
@lru_cache
def parse_prompt_schedules(prompt, **kwargs):
prompt = replace_defs(prompt)
prompt = replace_def(prompt)
return PromptSchedule(prompt, **kwargs)
+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
-136
View File
@@ -1,136 +0,0 @@
import unittest
from .parser import parse_prompt_schedules as parse
class TestParser(unittest.TestCase):
def test_no_scheduling(self):
p = parse("This is a (basic:0.6) (prompt) with [no scheduling] features")
expected = [1.0, {"prompt": "This is a (basic:0.6) (prompt) with [no scheduling] features", "loras": {}}]
self.assertEqual(p.at_step(0), expected)
self.assertEqual(p.at_step(0.5), expected)
self.assertEqual(p.at_step(1), expected)
def test_basic(self):
p = parse(
"This is a (basic:0.6) (prompt) with (very [[simple]:(basic:0.6):0.5]:1.1) [features::0.8][ and this is ignored:1]"
)
expected = [0.5, {"prompt": "This is a (basic:0.6) (prompt) with (very [simple]:1.1) features", "loras": {}}]
expected2 = [
0.8,
{"prompt": "This is a (basic:0.6) (prompt) with (very (basic:0.6):1.1) features", "loras": {}},
]
expected3 = [1.0, {"prompt": "This is a (basic:0.6) (prompt) with (very (basic:0.6):1.1) ", "loras": {}}]
self.assertEqual(p.at_step(0), expected)
self.assertEqual(p.at_step(0.5), expected)
self.assertEqual(p.at_step(0.7), expected2)
self.assertEqual(p.at_step(1), expected3)
def test_lora(self):
p = parse("This is a (lora:0.6) (prompt) with [no scheduling] features <lora:foo:0.5> <lora:bar:0.5:1.0>")
expected = [
1.0,
{
"prompt": "This is a (lora:0.6) (prompt) with [no scheduling] features ",
"loras": {"foo": {"weight": 0.5, "weight_clip": 0.5}, "bar": {"weight": 0.5, "weight_clip": 1.0}},
},
]
self.assertEqual(p.at_step(0), expected)
self.assertEqual(p.at_step(0.5), expected)
self.assertEqual(p.at_step(1), expected)
def test_scheduled_lora(self):
p = parse(
"This is a (lora:0.6) (prompt) with [scheduling] features [<lora:foo:0.5>:<lora:bar:0.5:0.2>:0.3] <lora:bar:0.5:1.0>"
)
expected = [
0.3,
{
"prompt": "This is a (lora:0.6) (prompt) with [scheduling] features ",
"loras": {"foo": {"weight": 0.5, "weight_clip": 0.5}, "bar": {"weight": 0.5, "weight_clip": 1.0}},
},
]
expected2 = [
1.0,
{
"prompt": "This is a (lora:0.6) (prompt) with [scheduling] features ",
"loras": {"bar": {"weight": 1.0, "weight_clip": 1.2}},
},
]
self.assertEqual(p.at_step(0.1), expected)
self.assertEqual(p.at_step(1), expected2)
def test_seq(self):
p = parse("This is a sequence of [SEQ:a:0.2::0.5:c:0.8]")
p2 = parse("This is a sequence of [[a:[c:0.5]:0.2]::0.8]")
prompts = {
0.2: "This is a sequence of a",
0.5: "This is a sequence of ",
0.8: "This is a sequence of c",
1.0: "This is a sequence of ",
}
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
for k in prompts:
self.assertEqual(p.at_step(k), [k, {"prompt": prompts[k], "loras": {}}])
def test_shortcuts_scheduling(self):
p = parse("A schedule [a:0.1,0.7] b")
p2 = parse("A schedule [[a:0.1]::0.7] b")
p3 = parse("A schedule [a:b:0.5,0.8]")
p4 = parse("A schedule [[a:0.5]:b:0.8]")
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
self.assertEqual(p3.parsed_prompt, p4.parsed_prompt)
def test_nested(self):
p = parse(
"This [prompt is [SEQ:[crazy:weird:0.2] stuff:0.5:<lora:cool:1>:0.7:nesting:1.0]:completely ignored with tags:HR]"
)
prompts = {
0.2: (0.2, "This prompt is crazy stuff"),
0.3: (0.5, "This prompt is weird stuff"),
0.5: (0.5, "This prompt is weird stuff"),
0.8: (1.0, "This prompt is nesting"),
}
for k in prompts:
self.assertEqual(p.at_step(k), [prompts[k][0], {"prompt": prompts[k][1], "loras": {}}])
self.assertEqual(
p.at_step(0.6), [0.7, {"prompt": "This prompt is ", "loras": {"cool": {"weight": 1.0, "weight_clip": 1.0}}}]
)
self.assertEqual(
p.at_step(0.7), [0.7, {"prompt": "This prompt is ", "loras": {"cool": {"weight": 1.0, "weight_clip": 1.0}}}]
)
p2 = p.with_filters(filters="hr, xyz")
# TODO: for some reason, this does not deduplicate
# self.assertEqual(p2.at_step(0), p2.at_step(1))
self.assertEqual(p2.at_step(0)[1]["prompt"], p2.at_step(1)[1]["prompt"])
def test_def(self):
p = parse("DEF(X=0.5) [a:b:X] DEF(test=[c:X]) test test")
prompts = {
0.2: (0.5, "a "),
0.6: (1.0, "b c c"),
}
for k in prompts:
self.assertEqual(p.at_step(k), [prompts[k][0], {"prompt": prompts[k][1], "loras": {}}])
def test_misc(self):
p = parse("[[a:c:0.5]:0.7]")
p2 = parse("[:[a:c:0.5]:0.7]")
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
p = parse("[:[<lora:test:1>:c:0.5]:0.3]")
self.assertEqual(p.at_step(0), [0.3, {"prompt": "", "loras": {}}])
self.assertEqual(p.at_step(0.4), [0.5, {"prompt": "", "loras": {"test": {"weight": 1.0, "weight_clip": 1.0}}}])
self.assertEqual(p.at_step(1.0), [1.0, {"prompt": "c", "loras": {}}])
p = parse("an [<emb:foo>:<emb:bar>:0.5]")
prompts = {
0.2: (0.5, "an embedding:foo"),
0.8: (1.0, "an embedding:bar"),
}
for k in prompts:
self.assertEqual(p.at_step(k), [prompts[k][0], {"prompt": prompts[k][1], "loras": {}}])
if __name__ == "__main__":
unittest.main()
+215
View File
@@ -0,0 +1,215 @@
import unittest
import unittest.mock as mock
import logging
log = logging.getLogger("comfyui-prompt-control")
def find_file(name):
names = {"test": "test.safetensors", "other": "some/other.safetensors"}
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
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)
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},
},
},
},
)
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]},
},
},
},
)
@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"]
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"]
self.assertEqual(result, result2)
self.assertEqual(result2, result3)
self.assertEqual(
result,
{
"UID-1": {
"class_type": "LoraLoader",
"inputs": {
"model": [0, 1],
"clip": [0, 0],
"strength_model": 1.0,
"strength_clip": 1.0,
"lora_name": "test.safetensors",
},
}
},
)
result = apply(PCLazyLoraLoader, "<lora:test:1><lora:other:0.5>")["expand"]
self.assertEqual(
result,
{
"UID-1": {
"class_type": "LoraLoader",
"inputs": {
"model": [0, 1],
"clip": [0, 0],
"strength_model": 1.0,
"strength_clip": 1.0,
"lora_name": "test.safetensors",
},
},
"UID-2": {
"class_type": "LoraLoader",
"inputs": {
"model": ["UID-1", 0],
"clip": ["UID-1", 1],
"strength_model": 0.5,
"strength_clip": 0.5,
"lora_name": "some/other.safetensors",
},
},
},
)
result = apply(PCLazyLoraLoader, "prompt here <lora:test:1.0:0.5>")["expand"]
self.assertEqual(
result,
{
"UID-1": {
"class_type": "LoraLoader",
"inputs": {
"model": [0, 1],
"clip": [0, 0],
"strength_model": 1.0,
"strength_clip": 0.5,
"lora_name": "test.safetensors",
},
}
},
)
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": {
"class_type": "CreateHookLora",
"inputs": {"lora_name": "test.safetensors", "strength_model": 0.5, "strength_clip": 0.5},
},
"UID-2": {
"class_type": "CreateHookKeyframe",
"inputs": {"strength_mult": 0.0, "start_percent": 0.0},
},
"UID-3": {
"class_type": "CreateHookKeyframe",
"inputs": {
"start_percent": 0.5,
"prev_hook_kf": ["UID-2", 0],
"strength_mult": 1.0,
},
},
"UID-4": {
"class_type": "SetHookKeyframes",
"inputs": {"hooks": ["UID-1", 0], "hook_kf": ["UID-3", 0]},
},
"UID-5": {
"class_type": "SetClipHooks",
"inputs": {
"clip": [0, 0],
"hooks": ["UID-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"]
self.assertEqual(
result2,
{
"UID-1": {
"class_type": "LoraLoader",
"inputs": {
"model": [0, 1],
"clip": [0, 0],
"strength_model": 0.5,
"strength_clip": 0.5,
"lora_name": "test.safetensors",
},
}
},
)
result2 = PCLazyLoraLoaderAdvanced().apply(model, clip, "prompt [<lora:test:0.5>:0.5]", "UID", end=0.5)[
"expand"
]
self.assertEqual(result2, {})
if __name__ == "__main__":
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
import unittest
from .parser import parse_prompt_schedules as parse
def prompt(until, text, *loras):
loras = {lora: {"weight": unet, "weight_clip": te} for lora, unet, te in loras}
return [until, {"prompt": text, "loras": loras}]
class TestParser(unittest.TestCase):
def assertPrompt(self, p, at, until, text, *loras):
self.assertEqual(p.at_step(at), prompt(until, text, *loras))
def test_no_scheduling(self):
p = parse("This is a (basic:0.6) (prompt) with [no scheduling] features")
expected = prompt(1.0, "This is a (basic:0.6) (prompt) with [no scheduling] features")
self.assertEqual(p.at_step(0), expected)
self.assertEqual(p.at_step(0.5), expected)
self.assertEqual(p.at_step(1), expected)
def test_basic(self):
p = parse(
"This is a (basic:0.6) (prompt) with (very [[simple]:(basic:0.6):0.5]:1.1) [features::0.8][ and this is ignored:1]"
)
self.assertPrompt(p, 0, 0.5, "This is a (basic:0.6) (prompt) with (very [simple]:1.1) features")
self.assertPrompt(p, 0.5, 0.5, "This is a (basic:0.6) (prompt) with (very [simple]:1.1) features")
self.assertPrompt(p, 0.7, 0.8, "This is a (basic:0.6) (prompt) with (very (basic:0.6):1.1) features")
self.assertPrompt(p, 1.0, 1.0, "This is a (basic:0.6) (prompt) with (very (basic:0.6):1.1) ")
def test_lora(self):
p = parse("This is a (lora:0.6) (prompt) with [no scheduling] features <lora:foo:0.5> <lora:bar:0.5:1.0>")
expected = prompt(
1.0, "This is a (lora:0.6) (prompt) with [no scheduling] features ", ("foo", 0.5, 0.5), ("bar", 0.5, 1.0)
)
self.assertEqual(p.at_step(0), expected)
self.assertEqual(p.at_step(0.5), expected)
self.assertEqual(p.at_step(1), expected)
def test_scheduled_lora(self):
p = parse(
"This is a (lora:0.6) (prompt) with [scheduling] features [<lora:foo:0.5>:<lora:bar:0.5:0.2>:0.3] <lora:bar:0.5:1.0>"
)
self.assertPrompt(
p,
0.1,
0.3,
"This is a (lora:0.6) (prompt) with [scheduling] features ",
("foo", 0.5, 0.5),
("bar", 0.5, 1.0),
)
self.assertPrompt(p, 0.5, 1.0, "This is a (lora:0.6) (prompt) with [scheduling] features ", ("bar", 1.0, 1.2))
def test_seq(self):
p = parse("This is a sequence of [SEQ:a:0.2::0.5:c:0.8][SEQ: and x:0.8]")
p2 = parse("This is a sequence of [[a:[c:0.5]:0.2]::0.8][ and x::0.8]")
prompts = {
0.2: "This is a sequence of a and x",
0.5: "This is a sequence of and x",
0.8: "This is a sequence of c and x",
1.0: "This is a sequence of ",
}
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
for k, v in prompts.items():
self.assertPrompt(p, k, k, v)
def test_shortcuts_scheduling(self):
p = parse("A schedule [a:0.1,0.7] b")
p2 = parse("A schedule [[a:0.1]::0.7] b")
p3 = parse("A schedule [a:b:0.5,0.8]")
p4 = parse("A schedule [[a:0.5]:b:0.8]")
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
self.assertEqual(p3.parsed_prompt, p4.parsed_prompt)
def test_nested(self):
p = parse(
"This [prompt is [SEQ:[crazy:weird:0.2] stuff:0.5:<lora:cool:1>:0.7:nesting:1.0]:completely ignored with tags:HR]"
)
prompts = {
0.2: (0.2, "This prompt is crazy stuff"),
0.3: (0.5, "This prompt is weird stuff"),
0.5: (0.5, "This prompt is weird stuff"),
0.8: (1.0, "This prompt is nesting"),
}
for k in prompts:
self.assertEqual(p.at_step(k), [prompts[k][0], {"prompt": prompts[k][1], "loras": {}}])
self.assertPrompt(p, 0.6, 0.7, "This prompt is ", ("cool", 1.0, 1.0))
self.assertPrompt(p, 0.7, 0.7, "This prompt is ", ("cool", 1.0, 1.0))
p2 = p.with_filters(filters="hr, xyz")
self.assertEqual(p2.at_step(0), p2.at_step(1))
def test_def(self):
p = parse("DEF(X=0.5) [a:b:X] DEF(test = [c:X]) test test")
prompts = {
0.2: (0.5, "a "),
0.6: (1.0, "b c c"),
}
for k, v in prompts.items():
self.assertPrompt(p, k, v[0], v[1])
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)
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.assertRaises(ValueError) as c:
parse("DEF(X=recurse Y) DEF(Y=recurse X) X")
self.assertTrue("Unable to resolve DEFs" in str(c.exception))
def test_misc(self):
p = parse("[[a:c:0.5]:0.7]")
p2 = parse("[:[a:c:0.5]:0.7]")
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
p = parse("test [[a:[b<lora:test:0.5>:0.6]:0.5]:HR]")
p2 = parse("test [:[a:[:b<lora:test:0.5>:0.6]:0.5]:HR]")
self.assertEqual(p.parsed_prompt, p2.parsed_prompt)
pf = p.with_filters(filters="hr")
self.assertEqual(pf.parsed_prompt, p2.with_filters(filters="hr").parsed_prompt)
self.assertPrompt(pf, 0, 0.5, "test a")
self.assertPrompt(pf, 0.55, 0.6, "test ")
self.assertPrompt(pf, 0.8, 1.0, "test b", ("test", 0.5, 0.5))
p = parse("[:[<lora:test:1>:c:0.5]:0.3]")
self.assertPrompt(p, 0, 0.3, "")
self.assertPrompt(p, 0.4, 0.5, "", ("test", 1.0, 1.0))
self.assertPrompt(p, 1.0, 1.0, "c")
p = parse("an [<emb:foo>:<emb:bar>:0.5]")
prompts = {
0.2: (0.5, "an embedding:foo"),
0.8: (1.0, "an embedding:bar"),
}
for k, v in prompts.items():
self.assertPrompt(p, k, v[0], v[1])
def test_alternating(self):
p = parse("[cat|dog|tiger]")
p2 = parse("[cat|dog|tiger:0.1]")
p3 = parse("[cat|[dog|wolf]|tiger]")
p4 = parse("[cat|[dog:wolf<lora:canine:1>:0.5]:0.2]")
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)
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)
self.assertPrompt(p4, 0.7, 0.8, "wolf", ("canine", 1.0, 1.0))
if __name__ == "__main__":
unittest.main()
+7 -2
View File
@@ -87,10 +87,11 @@ def find_closing_paren(text, start):
return len(text)
def get_function(text, func, defaults, return_func_name=False):
def get_function(text, func, defaults, return_func_name=False, placeholder=""):
rex = re.compile(rf"\b{func}\(", re.MULTILINE)
instances = []
match = rex.search(text)
count = 0
while match:
# Match start, content start
start, after_first_paren = match.span()
@@ -102,8 +103,12 @@ def get_function(text, func, defaults, return_func_name=False):
else:
instances.append(args)
text = text[:start] + text[end + 1 :]
if placeholder:
text = text[:start] + f"\0{placeholder}{count}\0" + text[end + 1 :]
else:
text = text[:start] + text[end + 1 :]
match = rex.search(text)
count += 1
return text, instances
+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.6"
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"