Compare commits
39
Commits
prep_v2
...
v2.0.0-beta.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fbae90478 | ||
|
|
2f12069821 | ||
|
|
3baeabb8ee | ||
|
|
c85134b31e | ||
|
|
26f7e1ff24 | ||
|
|
e9e8b75d7f | ||
|
|
01526e3923 | ||
|
|
e888238625 | ||
|
|
53a6d48cb1 | ||
|
|
a0df992741 | ||
|
|
be51e0dfc4 | ||
|
|
c023956b4c | ||
|
|
b33f24e0cb | ||
|
|
a637321356 | ||
|
|
3a2d08fcf7 | ||
|
|
99d966d74a | ||
|
|
724488d20b | ||
|
|
bec28affbe | ||
|
|
08844019cc | ||
|
|
f7d78e54d5 | ||
|
|
25990cb17e | ||
|
|
de1ad8512a | ||
|
|
09925976d4 | ||
|
|
00061e18f6 | ||
|
|
06f1291727 | ||
|
|
ee914b2920 | ||
|
|
b8d002facc | ||
|
|
2f5d62b46b | ||
|
|
48c0286f09 | ||
|
|
e64a71fc6a | ||
|
|
dbd5a0e6d6 | ||
|
|
f3a4b12bc0 | ||
|
|
be921a9eea | ||
|
|
5aa6110206 | ||
|
|
48b727f4f6 | ||
|
|
5ff6d21e43 | ||
|
|
d8b850cafd | ||
|
|
24a9dd32f7 | ||
|
|
7b358e5127 |
@@ -20,5 +20,7 @@ A clear and concise description of what the bug is.
|
||||
Information needed to trigger the problem.
|
||||
If possible, attach a workflow to reproduce the problem
|
||||
|
||||
If a workflow works, but isn't producing the correct output, please enable debug logging with the `PCSetLogLevel` node (from `promptcontrol/tools`) and run your workflow with debug logging enabled, and copy the outputs here.
|
||||
|
||||
**Expected behavior**
|
||||
A description of what you expected to happen.
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# ComfyUI prompt control
|
||||
|
||||
Nodes for LoRA and prompt scheduling that make basic operations in ComfyUI completely prompt-controllable.
|
||||
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.
|
||||
|
||||
## 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. This has some advantages:
|
||||
|
||||
- ComfyUI will not re-run unchanged parts of generated graphs. This is especially useful for two-pass workflows where previously you'd be forced to re-run the first sampling pass even with filtering. That is no longer the case and it does the right thing.
|
||||
- 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 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`.
|
||||
|
||||
@@ -19,11 +16,11 @@ Prompt Control also comes with `PCTextEncode`, which provides advanced text enco
|
||||
|
||||
### Is it stable now?
|
||||
|
||||
Unless I run into bugs or significant annoyances that require changing the interface, probably.
|
||||
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, check out the [legacy branch](https://github.com/asagi4/comfyui-prompt-control/tree/legacy). However, I will not fix bugs in that branch, and I strongly recommend just migrating your workflows to the new nodes.
|
||||
If you really need them, you can install the [legacy nodes](https://github.com/asagi4/comfyui-prompt-control-legacy). However, I will not fix bugs in those nodes, and I strongly recommend just migrating your workflows to the new nodes.
|
||||
|
||||
You can have both installed at the same time; none of the nodes conflict.
|
||||
|
||||
@@ -43,9 +40,9 @@ 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 example 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.
|
||||
[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.
|
||||
|
||||
[This example workflow](workflows/example.json?raw=1) implements a two-pass workflow illustrating more features, including custom masks and filtering.
|
||||
[Here](workflows/example-2pass.json?raw=1) is a two-pass workflow illustrating more features, including custom masks and filtering.
|
||||
|
||||
The tools in this repository combine well with the macro and wildcard functionality in [comfyui-utility-nodes](https://github.com/asagi4/comfyui-utility-nodes)
|
||||
|
||||
@@ -65,23 +62,25 @@ Then restart ComfyUI afterwards.
|
||||
|
||||
# Core nodes
|
||||
|
||||
## PCLazyTextEncode and PCLazyTextEncodeAvanced
|
||||
**Note**: The documentation refers to the nodes with their internal names for consistency. The display name may change, but ComfyUI's search will always find the nodes with the internal name. `PCLazyTextEncode` and `PCLazyLoraLoader` are the main ones you'll want to use, also known as `PC: Schedule Prompt` and `PC: Schedule LoRas`.
|
||||
|
||||
## PCLazyTextEncode and PCLazyTextEncodeAdvanced
|
||||
|
||||
`PCLazyTextEncode` uses ComfyUI's lazy graph execution mechanism to generate a graph of `PCTextEncode` and `SetConditioningTimestepRange` nodes from a prompt with schedules. This has the advantage that if a part of the schedule doesn't change, ComfyUI's caching mechanism allows you to avoid re-encoding the non-changed part.
|
||||
|
||||
for example, if you first encode `[cat:dog:0.1]` and later change that to `[cat:dog:0.5]`, no re-encoding takes place.
|
||||
|
||||
for added fun, put `NODE(NodeClassName, paramname)` in a prompt to generate a graph using **any other node** that's compatible. The node can't have required parameters besides a single CLIP parameter (which must be named `clip`) and the text prompt, and it must return a `CONDITIONING` as its first return value.
|
||||
for added fun, put `NODE(NodeClassName, textinputname)` in a prompt to generate a graph using **any other node** that's compatible. The node can't have required parameters besides a single CLIP parameter (which must be named `clip`) and the text prompt, and it must return a `CONDITIONING` as its first return value. The "default" values are `PCTextEncode` and `text`.
|
||||
|
||||
For example, if you for some reason do not want the advanced features of `PCTextEncode`, use `NODE(CLIPTextEncode)` in the prompt and you'll still get scheduling with ComfyUI's regular TE node.
|
||||
|
||||
The advanced node enables filtering the prompt for multi-pass workflows.
|
||||
|
||||
## PCLazyLoraLoader and PCLazyLoraLoaderAdvanced
|
||||
|
||||
This node reads LoRA expressions from the scheduled prompt and constructs a graph of `LoraLoader`s and `CreateHookLora`s as necessary to provide the necessary LoRA scheduling.
|
||||
This node reads LoRA expressions from the scheduled prompt and constructs a graph of `LoraLoader`s and `CreateHookLora`s as necessary to provide the necessary LoRA scheduling. Just use it in place of a `LoRALoader` and use the output normally.
|
||||
|
||||
If you have `apply_hooks` set to true, you **do not** need to apply the `HOOKS` output to a CLIP model separately; it's provided in case you want to use it elsewhere.
|
||||
|
||||
The advanced node enables filtering the prompt for multi-pass workflows.
|
||||
The Advanced node gives you access to the generated hooks. If you have `apply_hooks` set to true, you **do not** need to apply the `HOOKS` output to a CLIP model separately; it's provided in case you want to use it elsewhere. The advanced node also enables filtering the prompt for multi-pass workflows.
|
||||
|
||||
## PCTextEncode
|
||||
|
||||
@@ -168,4 +167,8 @@ The parameters affect how the masked and unmasked prompts are combined to produc
|
||||
|
||||
# Known issues
|
||||
|
||||
- None at the moment
|
||||
- ComfyUI's caching mechanism has an issue that makes it unnecessarily invalidate caches for certain inputs; you'll still get some benefit from the lazy nodes, but changing inputs that shouldn't affect downstream nodes (especially if using filtering) will still cause them to be recomputed because ComfyUI doesn't realize the inputs haven't changed.
|
||||
|
||||
If you want to enable a hack to fix this, set `PROMPTCONTROL_ENABLE_CACHE_HACK=1` in your environment. Unset it to disable.
|
||||
|
||||
It's a purely optional performance optimization that allows Prompt Control nodes to override their cache keys in a way that should not interfere with other nodes. Note that the optimization only works if the text input to the lazy nodes is a constant (so either directly on the node or from a primitive); outputs from other nodes can't be optimized.
|
||||
|
||||
+10
-1
@@ -1,3 +1,10 @@
|
||||
"""
|
||||
@author: asagi4
|
||||
@title: ComfyUI Prompt Control
|
||||
@nickname: ComfyUI Prompt Control
|
||||
@description: 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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
@@ -8,7 +15,7 @@ log = logging.getLogger("comfyui-prompt-control")
|
||||
log.propagate = False
|
||||
if not log.handlers:
|
||||
h = logging.StreamHandler(sys.stdout)
|
||||
h.setFormatter(logging.Formatter("[%(levelname)s] PromptControl: %(message)s"))
|
||||
h.setFormatter(logging.Formatter("[PromptControl] %(levelname)s: %(message)s"))
|
||||
log.addHandler(h)
|
||||
|
||||
if os.environ.get("PROMPTCONTROL_DEBUG"):
|
||||
@@ -16,6 +23,8 @@ if os.environ.get("PROMPTCONTROL_DEBUG"):
|
||||
else:
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
cache_hack = importlib.import_module(".prompt_control.cache_hack", package=__name__)
|
||||
cache_hack.init()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ These functions are applied to each prompt chunk **after** `BREAK`, `AND` etc. h
|
||||
|
||||
Multiple instances of these functions are applied in the order they appear in the prompt.
|
||||
|
||||
**NOTE:** These functions are *not* smart about syntax and will break emphasis if the separator occurs inside parentheses. I might fix this at some point, but for now, keep this in mind.
|
||||
**NOTE** To avoid breaking emphasis syntax, the functions ignore any separators inside parentheses
|
||||
|
||||
For example:
|
||||
- `SHIFT(1) cat, dog, tiger, mouse` does a shift and results in `dog, tiger, mouse, cat`. (whitespace may vary)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import comfy_execution.caching
|
||||
from comfy_execution.graph_utils import is_link
|
||||
import nodes
|
||||
from os import environ
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
|
||||
include_unique_id_in_input = comfy_execution.caching.include_unique_id_in_input
|
||||
|
||||
|
||||
def promptcontrol_get_immediate_node_signature(self, dynprompt, node_id, ancestor_order_mapping):
|
||||
if not dynprompt.has_node(node_id):
|
||||
# This node doesn't exist -- we can't cache it.
|
||||
return [float("NaN")]
|
||||
node = dynprompt.get_node(node_id)
|
||||
class_type = node["class_type"]
|
||||
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
|
||||
inputs = node["inputs"]
|
||||
if hasattr(class_def, "CACHE_KEY"):
|
||||
inputs = getattr(class_def, "CACHE_KEY")(inputs)
|
||||
signature = [class_type, self.is_changed_cache.get(node_id)]
|
||||
if (
|
||||
self.include_node_id_in_input()
|
||||
or (hasattr(class_def, "NOT_IDEMPOTENT") and class_def.NOT_IDEMPOTENT)
|
||||
or include_unique_id_in_input(class_type)
|
||||
):
|
||||
signature.append(node_id)
|
||||
for key in sorted(inputs.keys()):
|
||||
if is_link(inputs[key]):
|
||||
(ancestor_id, ancestor_socket) = inputs[key]
|
||||
ancestor_index = ancestor_order_mapping[ancestor_id]
|
||||
signature.append((key, ("ANCESTOR", ancestor_index, ancestor_socket)))
|
||||
else:
|
||||
signature.append((key, inputs[key]))
|
||||
return signature
|
||||
|
||||
|
||||
def init():
|
||||
if environ.get("PROMPTCONTROL_ENABLE_CACHE_HACK") != "1":
|
||||
return
|
||||
log.warning("Enabling Prompt Control cache hack")
|
||||
comfy_execution.caching.CacheKeySetInputSignature.get_immediate_node_signature = (
|
||||
promptcontrol_get_immediate_node_signature
|
||||
)
|
||||
@@ -4,6 +4,29 @@ from .prompts import encode_prompt
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
|
||||
class PCTextEncodeWithRange:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {"clip": ("CLIP",), "text": ("STRING", {"multiline": True})},
|
||||
"optional": {
|
||||
"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}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING",)
|
||||
CATEGORY = "promptcontrol/tools"
|
||||
FUNCTION = "apply"
|
||||
DESCRIPTION = "Like PCTextEncode, but if you know the range you need for a prompt, can be slightly more efficient when you have LoRAs scheduled on a CLIP model"
|
||||
|
||||
def apply(self, clip, text, start=0.0, end=1.0):
|
||||
log.debug("PCTextEncode: Encoding '%s'", text)
|
||||
defaults = clip.patcher.model_options.get("x-promptcontrol.defaults", {})
|
||||
masks = clip.patcher.model_options.get("x-promptcontrol.masks", None)
|
||||
return (encode_prompt(clip, text, start, end, defaults, masks),)
|
||||
|
||||
|
||||
class PCTextEncode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
@@ -14,17 +37,15 @@ class PCTextEncode:
|
||||
RETURN_TYPES = ("CONDITIONING",)
|
||||
CATEGORY = "promptcontrol"
|
||||
FUNCTION = "apply"
|
||||
DESCRIPTION = "Encodes a prompt with extra goodies from Prompt Control. This node does *not* support scheduling"
|
||||
|
||||
def apply(self, clip, text):
|
||||
defaults = clip.patcher.model_options.get("x-promptcontrol.defaults", {})
|
||||
masks = clip.patcher.model_options.get("x-promptcontrol.masks", None)
|
||||
return (encode_prompt(clip, text, 0, 1.0, defaults, masks),)
|
||||
return PCTextEncodeWithRange.apply(self, clip, text, 0.0, 1.0)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PCTextEncode": PCTextEncode,
|
||||
}
|
||||
NODE_CLASS_MAPPINGS = {"PCTextEncode": PCTextEncode, "PCTextEncodeWithRange": PCTextEncodeWithRange}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PCTextEncode": "PC Text Encode (no scheduling)",
|
||||
"PCTextEncode": "PC: Text Encode (no scheduling)",
|
||||
"PCTextEncodeWithRange": "PC: Text Encode with Range (no scheduling)",
|
||||
}
|
||||
|
||||
@@ -84,5 +84,5 @@ NODE_CLASS_MAPPINGS = {
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PCLoraHooksFromText": "PC LoRA Hooks From Text (non-lazy)",
|
||||
"PCLoraHooksFromText": "PC: LoRA Hooks From Text (non-lazy)",
|
||||
}
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import logging
|
||||
from .parser import parse_prompt_schedules
|
||||
from comfy_execution.graph_utils import GraphBuilder
|
||||
from comfy_execution.graph_utils import GraphBuilder, is_link
|
||||
|
||||
from .prompts import get_function
|
||||
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
from .utils import consolidate_schedule, find_nonscheduled_loras
|
||||
import json
|
||||
|
||||
|
||||
def _cache_key(cachekey, inputs):
|
||||
out = inputs.copy()
|
||||
if not is_link(inputs["text"]):
|
||||
out["text"] = cache_key_from_inputs(cachekey, **inputs)
|
||||
return out
|
||||
|
||||
|
||||
def cache_key_prompt(inputs):
|
||||
return _cache_key("prompt", inputs)
|
||||
|
||||
|
||||
def cache_key_lora(inputs):
|
||||
return _cache_key("loras", inputs)
|
||||
|
||||
|
||||
def create_lora_loader_nodes(graph, model, clip, loras):
|
||||
@@ -24,9 +40,10 @@ def create_lora_loader_nodes(graph, model, clip, loras):
|
||||
|
||||
|
||||
def create_hook_nodes_for_lora(graph, path, info, existing_node, start_pct, end_pct):
|
||||
prev_keyframe = None
|
||||
next_keyframe = None
|
||||
if not existing_node:
|
||||
log.debug("Creating hook for %s", path)
|
||||
log.debug("Creating hook for %s, weight=%s, weight_clip=%s", path, info["weight"], info["weight_clip"])
|
||||
hook_node = graph.node("CreateHookLora")
|
||||
hook_node.set_input("lora_name", path)
|
||||
hook_node.set_input("strength_model", info["weight"])
|
||||
@@ -59,7 +76,7 @@ def create_hook_nodes_for_lora(graph, path, info, existing_node, start_pct, end_
|
||||
next_keyframe.set_input("strength_mult", 1.0)
|
||||
prev_hook_kf = next_keyframe.out(0)
|
||||
if end_pct < 1.0:
|
||||
log.debug("Creating end keyframe for %s, start=%s", path, start_pct)
|
||||
log.debug("Creating end keyframe for %s, start=%s", path, end_pct)
|
||||
next_keyframe = graph.node("CreateHookKeyframe")
|
||||
next_keyframe.set_input("strength_mult", 0.0)
|
||||
next_keyframe.set_input("start_percent", end_pct)
|
||||
@@ -67,7 +84,7 @@ def create_hook_nodes_for_lora(graph, path, info, existing_node, start_pct, end_
|
||||
return hook_node, next_keyframe
|
||||
|
||||
|
||||
def build_lora_schedule(graph, schedule, model, clip, apply_hooks):
|
||||
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)
|
||||
@@ -100,26 +117,34 @@ def build_lora_schedule(graph, schedule, model, clip, apply_hooks):
|
||||
# Finally, combine all hooks and optionally apply
|
||||
if len(hooks) > 0:
|
||||
res = hooks[0]
|
||||
for h in hooks[:1]:
|
||||
for h in hooks[1:]:
|
||||
n = graph.node("CombineHooks2")
|
||||
n.set_input("hooks_A", res.out(0))
|
||||
n.set_input("hooks_B", h.out(0))
|
||||
res = n
|
||||
res = res.out(0)
|
||||
if apply_hooks:
|
||||
n = graph.node("SetClipHooks")
|
||||
n.set_input("clip", clip)
|
||||
n.set_input("hooks", res)
|
||||
n.set_input("apply_to_conds", True)
|
||||
n.set_input("schedule_clip", True)
|
||||
clip = n.out(0)
|
||||
if apply_hooks:
|
||||
n = graph.node("SetClipHooks")
|
||||
n.set_input("clip", clip)
|
||||
n.set_input("hooks", res)
|
||||
n.set_input("apply_to_conds", True)
|
||||
n.set_input("schedule_clip", True)
|
||||
clip = n.out(0)
|
||||
|
||||
r = graph.finalize()
|
||||
log.debug("LazyLoraLoader built graph: %s", json.dumps(r))
|
||||
|
||||
return {"result": (model, clip, res), "expand": r}
|
||||
if return_hooks:
|
||||
ret = (model, clip, res)
|
||||
else:
|
||||
ret = (model, clip)
|
||||
|
||||
return {"result": ret, "expand": r}
|
||||
|
||||
|
||||
class PCLazyLoraLoaderAdvanced:
|
||||
CACHE_KEY = cache_key_lora
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
@@ -127,9 +152,9 @@ class PCLazyLoraLoaderAdvanced:
|
||||
"text": ("STRING", {"multiline": True}),
|
||||
"model": ("MODEL", {"rawLink": True}),
|
||||
"clip": ("CLIP", {"rawLink": True}),
|
||||
"apply_hooks": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"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}),
|
||||
@@ -142,40 +167,43 @@ class PCLazyLoraLoaderAdvanced:
|
||||
CATEGORY = "promptcontrol"
|
||||
FUNCTION = "apply"
|
||||
|
||||
def apply(self, model, clip, text, apply_hooks, unique_id, tags="", start=0.0, end=1.0):
|
||||
schedule = parse_prompt_schedules(text).with_filters(filters=tags, start=start, end=end)
|
||||
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)
|
||||
return build_lora_schedule(graph, schedule, model, clip, apply_hooks=apply_hooks, return_hooks=True)
|
||||
|
||||
|
||||
class PCLazyLoraLoader:
|
||||
CACHE_KEY = cache_key_lora
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"multiline": True}),
|
||||
"model": ("MODEL", {"rawLink": True}),
|
||||
"clip": ("CLIP", {"rawLink": True}),
|
||||
"apply_hooks": ("BOOLEAN", {"default": True}),
|
||||
"text": ("STRING", {"multiline": True}),
|
||||
},
|
||||
"hidden": {"unique_id": "UNIQUE_ID"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "HOOKS")
|
||||
RETURN_TYPES = (
|
||||
"MODEL",
|
||||
"CLIP",
|
||||
)
|
||||
OUTPUT_TOOLTIPS = ("Returns a model and clip with LoRAs scheduled",)
|
||||
CATEGORY = "promptcontrol"
|
||||
FUNCTION = "apply"
|
||||
|
||||
def apply(self, model, clip, text, apply_hooks, unique_id):
|
||||
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)
|
||||
return build_lora_schedule(graph, schedule, model, clip, apply_hooks=True, return_hooks=False)
|
||||
|
||||
|
||||
def build_scheduled_prompts(graph, schedules, clip):
|
||||
nodes = []
|
||||
start_pct = 0.0
|
||||
prompt_cache = {}
|
||||
for end_pct, c in schedules:
|
||||
p = c["prompt"]
|
||||
p, classnames = get_function(p, "NODE", ["PCTextEncode", "text"])
|
||||
@@ -184,12 +212,9 @@ def build_scheduled_prompts(graph, schedules, clip):
|
||||
if classnames:
|
||||
classname = classnames[0][0]
|
||||
paramname = classnames[0][1]
|
||||
node = prompt_cache.get((p, classname, paramname))
|
||||
if not node:
|
||||
node = graph.node(classname)
|
||||
node.set_input("clip", clip)
|
||||
node.set_input(paramname, p)
|
||||
prompt_cache[(p, classname, paramname)] = node
|
||||
node = graph.node(classname)
|
||||
node.set_input("clip", clip)
|
||||
node.set_input(paramname, p)
|
||||
timestep = graph.node("ConditioningSetTimestepRange")
|
||||
timestep.set_input("conditioning", node.out(0))
|
||||
timestep.set_input("start", start_pct)
|
||||
@@ -203,16 +228,24 @@ def build_scheduled_prompts(graph, schedules, clip):
|
||||
combiner.set_input("conditioning_2", othernode.out(0))
|
||||
node = combiner
|
||||
|
||||
return {"result": (node.out(0),), "expand": graph.finalize()}
|
||||
g = graph.finalize()
|
||||
log.debug("Built graph: %s", json.dumps(g))
|
||||
|
||||
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)
|
||||
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})},
|
||||
# "optional": {"defaults": ("SCHEDULE_DEFAULTS",)},
|
||||
"hidden": {"unique_id": "UNIQUE_ID"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING",)
|
||||
@@ -220,13 +253,15 @@ class PCLazyTextEncode:
|
||||
CATEGORY = "promptcontrol"
|
||||
FUNCTION = "apply"
|
||||
|
||||
def apply(self, clip, text, unique_id):
|
||||
def apply(self, clip, text):
|
||||
schedules = parse_prompt_schedules(text)
|
||||
graph = GraphBuilder(f"PCEncodeLazy-{unique_id}")
|
||||
graph = GraphBuilder()
|
||||
return build_scheduled_prompts(graph, schedules, clip)
|
||||
|
||||
|
||||
class PCLazyTextEncodeAdvanced:
|
||||
CACHE_KEY = cache_key_prompt
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
@@ -244,7 +279,7 @@ class PCLazyTextEncodeAdvanced:
|
||||
FUNCTION = "apply"
|
||||
|
||||
def apply(self, clip, text, unique_id, tags="", start=0.1, end=1.0):
|
||||
schedules = parse_prompt_schedules(text).with_filters(start=start, end=end, filters=tags)
|
||||
schedules = parse_prompt_schedules(text, filters=tags, start=start, end=end)
|
||||
graph = GraphBuilder(f"PCLazyTextEncodeAdvanced-{unique_id}")
|
||||
return build_scheduled_prompts(graph, schedules, clip)
|
||||
|
||||
|
||||
@@ -3,6 +3,32 @@ import logging
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
|
||||
class PCSetLogLevel:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"clip": ("CLIP",),
|
||||
},
|
||||
"optional": {
|
||||
"level": (["INFO", "DEBUG", "WARNING", "ERROR"], {"default": "INFO"}),
|
||||
},
|
||||
}
|
||||
|
||||
def apply(self, clip, level="INFO"):
|
||||
log.setLevel(getattr(logging, level))
|
||||
log.info("Set logging level to %s", level)
|
||||
return (clip,)
|
||||
|
||||
RETURN_TYPES = ("CLIP",)
|
||||
CATEGORY = "promptcontrol/tools"
|
||||
DESCRIPTION = (
|
||||
"A debug node to configure Prompt Control logging level. Pass a CLIP through it before you run any PC nodes"
|
||||
)
|
||||
|
||||
FUNCTION = "apply"
|
||||
|
||||
|
||||
class PCAddMaskToCLIP:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
@@ -14,7 +40,7 @@ class PCAddMaskToCLIP:
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CLIP",)
|
||||
CATEGORY = "promptcontrol/v2"
|
||||
CATEGORY = "promptcontrol/tools"
|
||||
FUNCTION = "apply"
|
||||
|
||||
def apply(self, clip, mask=None):
|
||||
@@ -35,7 +61,7 @@ class PCAddMaskToCLIPMany:
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CLIP",)
|
||||
CATEGORY = "promptcontrol/v2"
|
||||
CATEGORY = "promptcontrol/tools"
|
||||
FUNCTION = "apply"
|
||||
|
||||
def apply(self, clip, mask1=None, mask2=None, mask3=None, mask4=None):
|
||||
@@ -65,7 +91,7 @@ class PCSetPCTextEncodeSettings:
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CLIP",)
|
||||
CATEGORY = "promptcontrol/v2"
|
||||
CATEGORY = "promptcontrol/tools"
|
||||
FUNCTION = "apply"
|
||||
|
||||
def apply(
|
||||
@@ -101,10 +127,12 @@ NODE_CLASS_MAPPINGS = {
|
||||
"PCSetPCTextEncodeSettings": PCSetPCTextEncodeSettings,
|
||||
"PCAddMaskToCLIP": PCAddMaskToCLIP,
|
||||
"PCAddMaskToCLIPMany": PCAddMaskToCLIPMany,
|
||||
"PCSetLogLevel": PCSetLogLevel,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PCSetTextEncodeSettings": "PC: Configure PCTextEncode",
|
||||
"PCSetPCTextEncodeSettings": "PC: Configure PCTextEncode",
|
||||
"PCAddMaskToCLIP": "PC: Attach Mask",
|
||||
"PCAddMaskToCLIPMany": "PC: Attach Mask (multi)",
|
||||
"PCSetLogLevel": "PC: Configure Logging (for debug)",
|
||||
}
|
||||
|
||||
+20
-82
@@ -5,8 +5,18 @@ from math import ceil
|
||||
logging.basicConfig()
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
if lark.__version__ == "0.12.0":
|
||||
x = "Your lark package reports an ancient version (0.12.0) and will not work. If you have the 'lark-parser' package in your Python environment, remove that and *reinstall* lark!"
|
||||
from sys import executable
|
||||
|
||||
x = "\n".join(
|
||||
[
|
||||
"Your lark package reports an ancient version (0.12.0) and will not work. If you have the 'lark-parser' package in your Python environment, remove that and *reinstall* lark!",
|
||||
f"{executable} -m pip uninstall lark-parser lark",
|
||||
f"{executable} -m pip install lark",
|
||||
]
|
||||
)
|
||||
log.error(x)
|
||||
raise ImportError(x)
|
||||
|
||||
@@ -14,16 +24,13 @@ if lark.__version__ == "0.12.0":
|
||||
prompt_parser = lark.Lark(
|
||||
r"""
|
||||
!start: (prompt | /[][():|]/+)*
|
||||
prompt: (emphasized | embedding | scheduled | alternate | sequence | interpolate | loraspec | PLAIN | /</ | />/ | WHITESPACE)+
|
||||
prompt: (emphasized | embedding | scheduled | alternate | sequence | loraspec | PLAIN | /</ | />/ | WHITESPACE)+
|
||||
!emphasized: "(" prompt? ")"
|
||||
| "(" prompt ":" prompt ")"
|
||||
| "[" prompt "]"
|
||||
scheduled: "[" [prompt ":"] [prompt] ":" _WS? NUMBER ["," NUMBER] "]"
|
||||
| "[" [prompt ":"] [prompt] ":" _WS? TAG "]"
|
||||
sequence: "[SEQ" ":" [prompt] ":" NUMBER (":" [prompt] ":" NUMBER)+ "]"
|
||||
interpolate.100: "[INT" ":" interp_prompts ":" interp_steps "]"
|
||||
interp_prompts: prompt (":" [prompt])+
|
||||
interp_steps: NUMBER ("," NUMBER)+ [":" NUMBER]
|
||||
alternate: "[" [prompt] ("|" [prompt])+ [":" NUMBER] "]"
|
||||
loraspec.99: "<lora:" FILENAME lora_weights [lora_block_weights] ">"
|
||||
lora_weights.1: (":" _WS? NUMBER)~1..2
|
||||
@@ -94,7 +101,6 @@ def clamp(a, b, c):
|
||||
|
||||
def get_steps(tree):
|
||||
res = [100]
|
||||
interpolation_steps = []
|
||||
|
||||
def tostep(s):
|
||||
w = float(s) * 100
|
||||
@@ -116,7 +122,6 @@ def get_steps(tree):
|
||||
for i, _ in enumerate(tree.children[:-1]):
|
||||
tree.children[i] = tostep(tree.children[i])
|
||||
|
||||
interpolation_steps.append((tuple(tree.children[:-1]), tree.children[-1]))
|
||||
res.extend(tree.children[:-1])
|
||||
|
||||
def sequence(self, tree):
|
||||
@@ -134,7 +139,7 @@ def get_steps(tree):
|
||||
|
||||
CollectSteps().visit(tree)
|
||||
|
||||
return sorted(set(interpolation_steps)), sorted(set(res))
|
||||
return sorted(set(res))
|
||||
|
||||
|
||||
def at_step(step, filters, tree):
|
||||
@@ -180,24 +185,6 @@ def at_step(step, filters, tree):
|
||||
previous_step = s
|
||||
return ""
|
||||
|
||||
def interpolate(self, args):
|
||||
prompts, starts = args
|
||||
starts = starts[:-1]
|
||||
prev_prompt = None
|
||||
if step < starts[0]:
|
||||
return prompts[0]
|
||||
for i, x in enumerate(starts):
|
||||
prev_prompt = prompts[i]
|
||||
if x >= step:
|
||||
break
|
||||
return prev_prompt
|
||||
|
||||
def interp_steps(self, args):
|
||||
return list(args)
|
||||
|
||||
def interp_prompts(self, args):
|
||||
return ["".join(flatten(a or [])) for a in args]
|
||||
|
||||
def alternate(self, args):
|
||||
step_size = args[-1]
|
||||
idx = ceil(step / step_size)
|
||||
@@ -268,22 +255,15 @@ def at_step(step, filters, tree):
|
||||
|
||||
|
||||
class PromptSchedule(object):
|
||||
def __init__(self, prompt, filters="", start=0.0, end=1.0, defaults=None, masks=None):
|
||||
def __init__(self, prompt, filters="", start=0.0, end=1.0):
|
||||
self.filters = filters
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.prompt = prompt.strip()
|
||||
self.defaults = {}
|
||||
if defaults:
|
||||
self.defaults = defaults
|
||||
self.loaded_loras = {}
|
||||
|
||||
self.interpolations = None
|
||||
self.parsed_prompt = None
|
||||
self.interpolations, self.parsed_prompt = self._parse()
|
||||
self.masks = masks
|
||||
if masks is None:
|
||||
self.masks = []
|
||||
self.parsed_prompt = self._parse()
|
||||
|
||||
def __iter__(self):
|
||||
# Filter out zero, it's only useful for interpolation
|
||||
@@ -293,27 +273,14 @@ class PromptSchedule(object):
|
||||
filters = [x.strip() for x in self.filters.upper().split(",")]
|
||||
try:
|
||||
parsed = []
|
||||
interpolations = set()
|
||||
tree = prompt_parser.parse(self.prompt)
|
||||
interpolation_steps, steps = get_steps(tree)
|
||||
log.debug("Interpolation steps: %s", interpolation_steps)
|
||||
steps = get_steps(tree)
|
||||
|
||||
def f(x):
|
||||
return round(x / 100, 2)
|
||||
|
||||
for t in steps:
|
||||
p = at_step(t, filters, tree)
|
||||
for control_points, step in interpolation_steps:
|
||||
interp_start = None
|
||||
interp_end = None
|
||||
if t == control_points[-1]:
|
||||
interp_start = max(control_points[0], int(self.start * 100))
|
||||
interp_end = min(control_points[-1], int(self.end * 100))
|
||||
control_points = tuple(
|
||||
sorted(set(f(c) for c in control_points if c >= interp_start or c <= interp_end))
|
||||
)
|
||||
if interp_start is not None and interp_end is not None and interp_end > interp_start:
|
||||
interpolations.add((control_points, f(step)))
|
||||
parsed.append([f(t), p])
|
||||
|
||||
except lark.exceptions.LarkError as e:
|
||||
@@ -322,14 +289,9 @@ class PromptSchedule(object):
|
||||
|
||||
# Tag filtering may return redundant prompts, so filter them out here
|
||||
res = []
|
||||
prev_p = None
|
||||
prev_end = -1
|
||||
|
||||
for end_at, p in parsed:
|
||||
# Preserve prompt if it ends at the start of an interpolation, otherwise bump its end time
|
||||
if p == prev_p and res[-1][0] not in [x[0][0] for x in interpolations]:
|
||||
res[-1][0] = end_at
|
||||
continue
|
||||
if end_at < self.start:
|
||||
continue
|
||||
elif end_at <= self.end:
|
||||
@@ -338,18 +300,12 @@ class PromptSchedule(object):
|
||||
elif end_at > self.end and prev_end < self.end:
|
||||
res.append([end_at, p])
|
||||
break
|
||||
prev_p = p
|
||||
|
||||
# Always use the last prompt if everything was filtered
|
||||
if len(res) == 0:
|
||||
res = [[1.0, parsed[-1][1]]]
|
||||
|
||||
return interpolations, res
|
||||
|
||||
def add_masks(self, *masks):
|
||||
for mask in masks:
|
||||
if mask is not None:
|
||||
self.masks.append(mask)
|
||||
return res
|
||||
|
||||
def clone(self):
|
||||
return self.with_filters()
|
||||
@@ -363,8 +319,6 @@ class PromptSchedule(object):
|
||||
filters=ifspecified(filters, self.filters),
|
||||
start=ifspecified(start, self.start),
|
||||
end=ifspecified(end, self.end),
|
||||
defaults=ifspecified(defaults, self.defaults),
|
||||
masks=self.masks[:],
|
||||
)
|
||||
return p
|
||||
|
||||
@@ -378,23 +332,7 @@ class PromptSchedule(object):
|
||||
return i, x
|
||||
return len(self.parsed_prompt) - 1, self.parsed_prompt[-1]
|
||||
|
||||
def interpolation_at(self, step, total_steps=1):
|
||||
i, x = self.at_step_idx(step, total_steps)
|
||||
for y in self.parsed_prompt[i:]:
|
||||
step = min(y[0], 1.0)
|
||||
if x[1]["prompt"] != y[1]["prompt"]:
|
||||
return step, y
|
||||
return 1.0, self.parsed_prompt[-1]
|
||||
|
||||
def load_loras(self, lora_cache=None):
|
||||
from .utils import Timer, load_loras_from_schedule
|
||||
|
||||
if lora_cache is not None:
|
||||
self.loaded_loras = lora_cache
|
||||
with Timer("PromptSchedule.load_loras()"):
|
||||
self.loaded_loras = load_loras_from_schedule(self.parsed_prompt, self.loaded_loras)
|
||||
return self.loaded_loras
|
||||
|
||||
|
||||
def parse_prompt_schedules(prompt):
|
||||
return PromptSchedule(prompt)
|
||||
@lru_cache
|
||||
def parse_prompt_schedules(prompt, **kwargs):
|
||||
return PromptSchedule(prompt, **kwargs)
|
||||
|
||||
@@ -4,7 +4,7 @@ import torch
|
||||
from functools import partial
|
||||
from comfy_extras.nodes_mask import FeatherMask, MaskComposite
|
||||
|
||||
from .utils import safe_float, get_function, parse_floats
|
||||
from .utils import safe_float, get_function, parse_floats, smarter_split
|
||||
from .adv_encode import advanced_encode_from_tokens
|
||||
from .cutoff import process_cuts
|
||||
from .parser import parse_cuts
|
||||
@@ -88,8 +88,9 @@ def shuffle_chunk(shuffle, c):
|
||||
"separator": separator,
|
||||
}.get(joiner, joiner)
|
||||
|
||||
log.info("%s arg=%s sep=%s join=%s", func, shuffle_count, separator, joiner)
|
||||
separated = c.split(separator)
|
||||
log.debug("%s arg=%s sep=%s join=%s", func, shuffle_count, separator, joiner)
|
||||
separated = smarter_split(separator, c)
|
||||
log.debug("Prompt split into %s", separated)
|
||||
if func == "SHIFT":
|
||||
shuffle_count = shuffle_count % len(separated)
|
||||
permutation = separated[shuffle_count:] + separated[:shuffle_count]
|
||||
|
||||
+23
-2
@@ -2,7 +2,9 @@ from pathlib import Path
|
||||
import re
|
||||
import logging
|
||||
|
||||
import folder_paths
|
||||
# Allow testing
|
||||
if __name__ != "__main__":
|
||||
import folder_paths
|
||||
|
||||
log = logging.getLogger("comfyui-prompt-control")
|
||||
|
||||
@@ -35,7 +37,6 @@ def find_nonscheduled_loras(consolidated_schedule):
|
||||
if not consolidated_schedule:
|
||||
return {}
|
||||
last_end, candidate_loras = consolidated_schedule[0]
|
||||
print(candidate_loras)
|
||||
to_remove = set()
|
||||
for candidate, weights in candidate_loras.items():
|
||||
for end, loras in consolidated_schedule[1:]:
|
||||
@@ -48,6 +49,26 @@ def find_nonscheduled_loras(consolidated_schedule):
|
||||
return {k: v for (k, v) in candidate_loras.items() if k not in to_remove}
|
||||
|
||||
|
||||
def smarter_split(separator, string):
|
||||
"""Does not break () when splitting"""
|
||||
splits = []
|
||||
prev = 0
|
||||
stack = 0
|
||||
escape = False
|
||||
for idx, x in enumerate(string):
|
||||
if x == "(" and not escape:
|
||||
stack += 1
|
||||
elif x == ")" and not escape:
|
||||
stack = max(0, stack - 1)
|
||||
elif x == separator and stack == 0:
|
||||
splits.append(string[prev:idx])
|
||||
prev = idx + 1
|
||||
escape = x == "\\"
|
||||
|
||||
splits.append(string[prev : idx + 1])
|
||||
return splits
|
||||
|
||||
|
||||
def find_closing_paren(text, start):
|
||||
stack = 1
|
||||
for i, char in enumerate(text[start:]):
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-prompt-control"
|
||||
description = "Nodes for convenient prompt editing, making many common operations prompt-controllable"
|
||||
version = "1.2.1"
|
||||
version = "2.0.0-beta.4"
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user