Compare commits

...
Author SHA1 Message Date
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
16 changed files with 506 additions and 105 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 }}
-7
View File
@@ -1,7 +0,0 @@
name: Execute tests
on:
push:
jobs:
tests:
uses: ./.github/workflows/tests.yml
+5 -3
View File
@@ -1,9 +1,11 @@
name: Run tests
name: Run parser tests
on:
- workflow_call
- workflow_dispatch
- push
jobs:
run-tests:
run-parser-tests:
name: Run parser tests
runs-on: ubuntu-latest
steps:
@@ -13,4 +15,4 @@ jobs:
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: python -m prompt_control.test
- run: python -m prompt_control.test_parser
+27
View File
@@ -0,0 +1,27 @@
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 -r requirements.txt
- run: PYTHONPATH=ComfyUI python -m prompt_control.test_graph
+4 -1
View File
@@ -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
+3 -7
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.
@@ -39,8 +37,6 @@ 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.
A `Basic Text to Image` template is included with the extension, and can be loaded from ComfyUI's template library.
[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.
+47 -4
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 lazy LoRA loaders, 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.
@@ -198,7 +204,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

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

+10 -2
View File
@@ -290,7 +290,7 @@
"slot_index": 0
}
],
"title": "Positive prompt (with LoRAs)",
"title": "Negative prompt",
"properties": {
"Run widget replace on values": false
},
@@ -669,7 +669,15 @@
],
"groups": [],
"config": {},
"extra": {},
"extra": {
"ds": {
"scale": 0.8,
"offset": [
588,
1260
]
}
},
"version": 0.4,
"models": [{
"name": "NoobAI-XL-Vpred-v1.0.safetensors",
+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)",
}
+53 -20
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)
@@ -330,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
@@ -344,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
@@ -359,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)
+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.dict("sys.modules", nodes=mock.MagicMock())
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()
@@ -99,6 +99,18 @@ class TestParser(unittest.TestCase):
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]")
+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 -1
View File
@@ -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-beta.7"
version = "2.0.0-beta.11"
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"]