Compare commits

...
15 changed files with 425 additions and 37 deletions
+6 -1
View File
@@ -8,14 +8,19 @@ on:
- "pyproject.toml"
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
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
+1 -5
View File
@@ -8,7 +8,7 @@ A `Basic Text to Image` template is included with the extension, and can be load
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
@@ -16,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.
+38 -1
View File
@@ -196,7 +196,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

+22 -11
View File
@@ -1,8 +1,9 @@
import logging
from .parser import parse_prompt_schedules
from comfy_execution.graph_utils import GraphBuilder, is_link
from comfy_execution.graph import ExecutionBlocker
from .prompts import get_function
from .utils import get_function
log = logging.getLogger("comfyui-prompt-control")
@@ -88,8 +89,12 @@ def create_hook_nodes_for_lora(graph, path, info, existing_node, start_pct, end_
def build_lora_schedule(graph, schedule, model, clip, apply_hooks=True, return_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
@@ -170,7 +175,7 @@ class PCLazyLoraLoaderAdvanced:
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}")
graph = GraphBuilder(f"{unique_id}-")
return build_lora_schedule(graph, schedule, model, clip, apply_hooks=apply_hooks, return_hooks=True)
@@ -180,10 +185,10 @@ class PCLazyLoraLoader:
@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"},
}
@@ -196,9 +201,14 @@ class PCLazyLoraLoader:
CATEGORY = "promptcontrol"
FUNCTION = "apply"
def apply(self, model, clip, text, unique_id):
graph = GraphBuilder(f"PCLazyLoraLoader-{unique_id}")
def apply(self, unique_id, model=None, clip=None, text=""):
graph = GraphBuilder(f"{unique_id}-")
schedule = parse_prompt_schedules(text)
if model is None and clip is None:
return (
ExecutionBlocker("No model input provided to PCLazyLoraLoader"),
ExecutionBlocker("No clip input provided to PCLazyLoraLoader"),
)
return build_lora_schedule(graph, schedule, model, clip, apply_hooks=True, return_hooks=False)
@@ -247,6 +257,7 @@ class PCLazyTextEncode:
def INPUT_TYPES(s):
return {
"required": {"clip": ("CLIP", {"rawLink": True}), "text": ("STRING", {"multiline": True})},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = ("CONDITIONING",)
@@ -254,9 +265,9 @@ class PCLazyTextEncode:
CATEGORY = "promptcontrol"
FUNCTION = "apply"
def apply(self, clip, text):
def apply(self, clip, text, unique_id):
schedules = parse_prompt_schedules(text)
graph = GraphBuilder()
graph = GraphBuilder(f"{unique_id}-")
return build_scheduled_prompts(graph, schedules, clip)
@@ -281,7 +292,7 @@ class PCLazyTextEncodeAdvanced:
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}")
graph = GraphBuilder(f"{unique_id}-")
return build_scheduled_prompts(graph, schedules, clip)
+64
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):
@@ -156,6 +218,7 @@ NODE_CLASS_MAPPINGS = {
"PCAddMaskToCLIPMany": PCAddMaskToCLIPMany,
"PCSetLogLevel": PCSetLogLevel,
"PCExtractScheduledPrompt": PCExtractScheduledPrompt,
"PCSaveExpandedWorkflow": PCSaveExpandedWorkflow,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -164,4 +227,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)",
}
+24 -5
View File
@@ -1,3 +1,4 @@
# vim: sw=4 ts=4
import lark
import logging
from math import ceil
@@ -344,7 +345,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 +360,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)
+214
View File
@@ -0,0 +1,214 @@
import unittest
import unittest.mock as mock
def find_file(name):
names = {"test": "test.safetensors", "other": "some/other.safetensors"}
return names.get(name)
class GraphTests(unittest.TestCase):
maxDiff = 4096
@mock.patch("prompt_control.utils.lora_name_to_file", find_file)
def test_textencode(self):
clip = [0, 0]
from .nodes_lazy import PCLazyTextEncode, PCLazyTextEncodeAdvanced
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("comfyui-prompt-control", level="WARNING") as cm:
result = PCLazyLoraLoader().apply(model, clip, "prompt here <lora:nonexistent:1.0:0.5>", "UID")["expand"]
result_adv = PCLazyLoraLoaderAdvanced().apply(model, clip, "prompt here <lora:nonexistent:1.0:0.5>", "UID")[
"expand"
]
self.assertIn("LoRA 'nonexistent' not found", cm.output[0])
self.assertIn("LoRA 'nonexistent' not found", cm.output[1])
self.assertEqual(result, {})
self.assertEqual(result_adv, {})
result = PCLazyLoraLoader().apply(model, clip, "<lora:test:1>", "UID")["expand"]
result2 = PCLazyLoraLoader().apply(model, clip, "prompt here <lora:test:1.0:0.5><lora:test:0:0.5>", "UID")[
"expand"
]
result3 = PCLazyLoraLoaderAdvanced().apply(
model, clip, "prompt here <lora:test:1.0:0.5><lora:test:0:0.5>", "UID"
)["expand"]
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 = PCLazyLoraLoader().apply(model, clip, "<lora:test:1><lora:other:0.5>", "UID")["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 = PCLazyLoraLoader().apply(model, clip, "prompt here <lora:test:1.0:0.5>", "UID")["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 = PCLazyLoraLoader().apply(model, clip, "prompt [<lora:test:0.5>:0.5]", "UID")["expand"]
result2 = PCLazyLoraLoaderAdvanced().apply(model, clip, "prompt [<lora:test:0.5>:0.5]", "UID")["expand"]
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 = PCLazyLoraLoaderAdvanced().apply(model, clip, "prompt [<lora:test:0.5>:0.5]", "UID", 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.8"
version = "2.0.0-beta.9"
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"]