From 93f01028979310ff52fc1494226e787ee64e886d Mon Sep 17 00:00:00 2001 From: Antonio Cordero Balcazar Date: Sun, 6 Oct 2024 18:49:09 +0200 Subject: [PATCH] * Fix a bug with the variable echo command. * Fix a bug obtaining random seed. * Reading version number from pyproject file. * A1111: Support for forcing the same seed/variation seed to all images in a batch. * A1111: Support for unlinking the seed from the image seed and specifying one for the prompt. * ComfyUI: Warning on use of invalid constructs. Related settings kept but default to False. --- README.md | 11 +++++ ppp.py | 90 +++++++++++++++++++++++++++--------- ppp_comfyui.py | 4 +- pyproject.toml | 2 +- scripts/ppp_script.py | 105 +++++++++++++++++++++++++++++++++--------- tests/tests.py | 27 ++++++++++- 6 files changed, 190 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 8c7056a..6fe59e0 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Notes: In SD.Next that means only the *A1111* or *Full* parsers. It will warn you if you use the *Compel* parser. Does not recognize tokenizer separators like "TE2:" and "TE3:", so sending to negative prompt from those sections of the prompt will not add them in the corresponding section of the negative prompt. + + ComfyUI only supports the attention change using parenthesis, so the ones with the braces will be converted. The extra networks constructs are not natively supported but some custom nodes do. The other constructs are not supported and will print a warning in the console. 2. It recognizes wildcards in the *\_\_wildcard\_\_* and *{choice|choice}* formats (and almost everything that [Dynamic Prompts](https://github.com/adieyal/sd-dynamic-prompts) supports). 3. It does not create *AND/BREAK* constructs when moving content to the negative prompt. @@ -336,6 +338,13 @@ This should still work as intended, and the only negative point i see is the unn ## Configuration +### A1111 (and compatible UIs) UI options + +* **Force equal seeds**: Changes the image seeds and variation seeds to be equal to the first of the batch. This allows using the same values for all the images in a batch. +* **Unlink seed**: Uses the specified seed for the prompt generation instead of the one from the image. +* **Seed**: The seed to use for the prompt generation. If -1 a random one will be used for each image in the batch. This seed is only used for wildcards and choices. +* **Variable seed**: If the seed is not -1 you can use this to increase it for the other images in the batch. + ### ComfyUI specific inputs * **model**: Connect here the MODEL or a string with the model class name used by ComfyUI. Needed for the model kind system variables. @@ -380,6 +389,8 @@ This should still work as intended, and the only negative point i see is the unn * **Merge attention modifiers (weights) when possible**: it merges attention modifiers when possible (merges into one, multiplying their values). Only merges individually nested modifiers. * **Remove extra spaces**: removes other unnecessary spaces. +Please note that ComfyUI does not support the BREAK and AND constructs, but the related settings are kept in that UI. + ### Content removal settings * **Remove extra network tags**: removes all extra network tags. diff --git a/ppp.py b/ppp.py index 47ebb32..cdeb4b4 100644 --- a/ppp.py +++ b/ppp.py @@ -22,8 +22,28 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in The PromptPostProcessor class is responsible for processing and manipulating prompt strings. """ + @staticmethod + def get_version_from_pyproject() -> tuple: + """ + Reads the version from the pyproject.toml file. + + Returns: + tuple: A tuple containing the version numbers. + """ + version_str = "0.0.0" + try: + pyproject_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pyproject.toml") + with open(pyproject_path, "r", encoding="utf-8") as file: + for line in file: + if line.startswith("version = "): + version_str = line.split("=")[1].strip().strip('"') + break + except Exception as e: # pylint: disable=broad-exception-caught + logging.getLogger().exception(e) + return tuple(map(int, version_str.split("."))) + NAME = "Prompt Post-Processor" - VERSION = (2, 7, 0) + VERSION = get_version_from_pyproject() class IFWILDCARDS_CHOICES(Enum): ignore = "ignore" @@ -135,6 +155,15 @@ def formatOutput(self, text: str) -> str: """ return text.encode("unicode_escape").decode("utf-8") + def isComfyUI(self) -> bool: + """ + Checks if the current environment is ComfyUI. + + Returns: + bool: True if the environment is ComfyUI, False otherwise. + """ + return self.env_info.get("app", "") == "comfyui" + def __init_sysvars(self): self.system_variables = {} sdchecks = { @@ -417,7 +446,7 @@ def process_prompt( """ try: if seed == -1: - seed = np.random.randint(0, 2**32) + seed = np.random.randint(0, 2**32, dtype=np.int64) self.rng = np.random.default_rng(seed & 0xFFFFFFFF) prompt = original_prompt negative_prompt = original_negative_prompt @@ -488,8 +517,8 @@ def __init__(self, ppp: "PromptPostProcessor"): self.__ppp = ppp self.AccumulatedShell = namedtuple("AccumulatedShell", ["type", "data"]) self.NegTag = namedtuple("NegTag", ["start", "end", "content", "parameters", "shell"]) - self.__shell: list[self.AccumulatedShell] = [] - self.__negtags: list[self.NegTag] = [] + self.__shell: list[self.AccumulatedShell] = [] # type: ignore + self.__negtags: list[self.NegTag] = [] # type: ignore self.__already_processed: list[str] = [] self.__is_negative = False self.__wildcard_filters = {} @@ -572,19 +601,29 @@ def __get_original_node_content(self, node: lark.Tree | lark.Token, default=None else default ) - def __get_user_variable_value(self, name: str, default="", evaluate=True) -> str: - if evaluate: - v = self.__ppp.user_variables.get(name, default) + def __get_user_variable_value(self, name: str, evaluate=True, visit=False) -> str: + """ + Get the value of a user variable. + + Args: + name (str): The name of the user variable. + evaluate (bool): Whether to evaluate the variable. + visit (bool): Whether to also visit the variable (add to result). + + Returns: + str: The value of the user variable. + """ + v = self.__ppp.user_variables.get(name, None) + if v is not None: + visited = False if isinstance(v, lark.Tree): - v = self.__visit(v, True) - else: - v = ( - self.__ppp.user_variables[name] - if isinstance(self.__ppp.user_variables[name], str) - else self.__get_original_node_content( - self.__ppp.user_variables[name], default or "not evaluated yet" - ) - ) + if evaluate: + v = self.__visit(v, not visit) + visited = visit + else: + v = self.__get_original_node_content(v, "not evaluated yet") + if visit and not visited: + self.result += v return v def __set_user_variable_value(self, name: str, value: str): @@ -616,7 +655,7 @@ def __eval_condition(self, cond_var: str, cond_comp: str, cond_value: str | list Returns: bool: The result of the condition evaluation. """ - var_value = self.__ppp.system_variables.get(cond_var, self.__get_user_variable_value(cond_var, None)) + var_value = self.__ppp.system_variables.get(cond_var, self.__get_user_variable_value(cond_var)) if var_value is None: var_value = "" self.__ppp.logger.warning(f"Unknown variable {cond_var}") @@ -717,6 +756,8 @@ def promptcomp(self, tree: lark.Tree): """ Process a prompt composition construct in the tree. """ + if self.__ppp.isComfyUI(): + self.__ppp.logger.warning("Prompt composition is not supported in ComfyUI.") start_result = self.result t1 = time.time() self.__visit(tree.children[0]) @@ -744,6 +785,8 @@ def scheduled(self, tree: lark.Tree): """ Process a scheduling construct in the tree and add it to the accumulated shell. """ + if self.__ppp.isComfyUI(): + self.__ppp.logger.warning("Prompt scheduling is not supported in ComfyUI.") start_result = self.result t1 = time.time() before = tree.children[0] @@ -778,6 +821,8 @@ def alternate(self, tree: lark.Tree): """ Process an alternation construct in the tree and add it to the accumulated shell. """ + if self.__ppp.isComfyUI(): + self.__ppp.logger.warning("Prompt alternation is not supported in ComfyUI.") start_result = self.result t1 = time.time() # self.__shell.append(self.AccumulatedShell("al", len(tree.children))) @@ -832,7 +877,7 @@ def attention(self, tree: lark.Tree): weight = math.floor(weight * 100) / 100 # we round to 2 decimals weight_str = f"{weight:.2f}".rstrip("0").rstrip(".") self.__shell.append(self.AccumulatedShell("at", weight)) - if weight == 0.9: + if weight == 0.9 and not self.__ppp.isComfyUI(): starttag = "[" self.result += starttag self.__visit(current_tree) @@ -947,7 +992,7 @@ def __varset( else: info += " = " self.__set_user_variable_value(variable, newvalue) - currentvalue = self.__get_user_variable_value(variable, None, False) + currentvalue = self.__get_user_variable_value(variable, False) if currentvalue is None: info += "not evaluated yet" else: @@ -973,16 +1018,14 @@ def __varecho(self, command: str, variable: str, default: lark.Tree | None): """ t1 = time.time() start_result = self.result - value = self.__get_user_variable_value(variable, None) if default is not None: default_value = self.__visit(default, True) # for log + value = self.__get_user_variable_value(variable, True, True) if value is None: if default is not None: - value = self.__visit(default, False, True) + self.result += self.__visit(default, False, True) else: - value = "" self.__ppp.logger.warning(f"Unknown variable {variable}") - self.result += value t2 = time.time() info = variable if default is not None: @@ -1237,6 +1280,7 @@ def wildcard(self, tree: lark.Tree): f"Using a globbing wildcard '{wildcard_key}' with positional index filters is not recommended!" ) var_object = tree.children[3] + variablebackup = None if var_object is not None: variablename = var_object.children[0] # should be a token variablevalue = self.__visit(var_object.children[1], False, True) diff --git a/ppp_comfyui.py b/ppp_comfyui.py index 620a443..e38245d 100644 --- a/ppp_comfyui.py +++ b/ppp_comfyui.py @@ -222,7 +222,7 @@ def INPUT_TYPES(cls): "cleanup_breaks": ( "BOOLEAN", { - "default": True, + "default": False, "tooltip": "Cleanup around BREAKs", "label_on": "Yes", "label_off": "No", @@ -244,7 +244,7 @@ def INPUT_TYPES(cls): "cleanup_ands": ( "BOOLEAN", { - "default": True, + "default": False, "tooltip": "Cleanup around ANDs", "label_on": "Yes", "label_off": "No", diff --git a/pyproject.toml b/pyproject.toml index c4d0301..85830c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "sd-webui-prompt-postprocessor" description = "Stable Diffusion WebUI & ComfyUI extension to post-process the prompt, including sending content from the prompt to the negative prompt and wildcards." -version = "2.7.0" +version = "2.8.0" license = {file = "LICENSE.txt"} dependencies = ["lark"] diff --git a/scripts/ppp_script.py b/scripts/ppp_script.py index 8381aba..9d459b9 100644 --- a/scripts/ppp_script.py +++ b/scripts/ppp_script.py @@ -4,6 +4,7 @@ import sys import os import time +import numpy as np sys.path.append(os.path.join(sys.path[0], "..")) @@ -36,6 +37,8 @@ class PromptPostProcessorA1111Script(scripts.Script): __on_ui_settings(): Callback function for UI settings. """ + VERSION = PromptPostProcessor.VERSION + def __init__(self): """ Initializes the PromptPostProcessor object. @@ -79,7 +82,48 @@ def show(self, is_img2img): """ return scripts.AlwaysVisible - def process(self, p: StableDiffusionProcessing, *args, **kwargs): # pylint: disable=unused-argument + def ui(self, is_img2img): + with gr.Accordion(PromptPostProcessor.NAME, open=False): + force_equal_seeds = gr.Checkbox( + label="Force equal seeds", + info="Force all image seeds and variation seeds to be equal to the first one, disabling the default autoincrease.", + default=False, + # show_label=True, + elem_id="ppp_force_equal_seeds", + ) + gr.HTML( + """
Unlink the seed to use the specified one for the prompts instead of the image seed. + This seed will only change for each image in the batch if the value is -1 or 'variable seed' is checked.
+
Seeds are only used for the wildcards and choice constructs.
""" + ) + unlink_seed = gr.Checkbox( + label="Unlink seed", + default=False, + # show_label=True, + elem_id="ppp_unlink_seed", + ) + seed = gr.Number( + label="Seed", + default=-1, + precision=0, + # minimum=-1, + # maximum=2**32 - 1, + # step=1, + # show_label=True, + min_width=100, + elem_id="ppp_seed", + ) + variable_seed = gr.Checkbox( + label="Variable seed", + default=False, + # show_label=True, + elem_id="ppp_variable_seed", + ) + return [force_equal_seeds, unlink_seed, seed, variable_seed] + + def process( + self, p: StableDiffusionProcessing, input_force_equal_seeds, input_unlink_seed, input_seed, input_variable_seed + ): # pylint: disable=arguments-differ """ Processes the prompts and applies post-processing operations. @@ -138,7 +182,7 @@ def process(self, p: StableDiffusionProcessing, *args, **kwargs): # pylint: dis env_info["is_ssd"] = False # ? env_info["is_sd3"] = getattr(p.sd_model, "is_sd3", False) env_info["is_flux"] = p.sd_model.model_config.__class__.__name__ == "Flux" - env_info["is_auraflow"] = False # p.sd_model.model_config.__class__.__name__ == "AuraFlow" + env_info["is_auraflow"] = False # p.sd_model.model_config.__class__.__name__ == "AuraFlow" else: # assume A1111 compatible (p.sd_model.__class__.__name__=="DiffusionEngine") env_info["model_class"] = p.sd_model.__class__.__name__ env_info["is_sd1"] = getattr(p.sd_model, "is_sd1", False) @@ -185,19 +229,38 @@ def process(self, p: StableDiffusionProcessing, *args, **kwargs): # pylint: dis ) prompts_list = [] - seeds = getattr(p, "all_seeds", []) - subseeds = getattr(p, "all_subseeds", []) - subseed_strength = getattr(p, "subseed_strength", 0.0) - if subseed_strength > 0: - calculated_seeds = [ - int(subseed * subseed_strength + seed * (1 - subseed_strength)) - for seed, subseed in zip(seeds, subseeds) - ] + if input_force_equal_seeds: + if self.ppp_debug_level != DEBUG_LEVEL.none: + self.ppp_logger.info("Forcing equal seeds") + seeds = getattr(p, "all_seeds", []) + subseeds = getattr(p, "all_subseeds", []) + p.all_seeds = [seeds[0] for _ in seeds] + p.all_subseeds = [subseeds[0] for _ in subseeds] + + if input_unlink_seed: + if self.ppp_debug_level != DEBUG_LEVEL.none: + self.ppp_logger.info("Using unlinked seed") + num_seeds = len(getattr(p, "all_seeds", [])) + if input_seed == -1: + calculated_seeds = np.random.randint(0, 2**32, size=num_seeds, dtype=np.int64) + elif input_variable_seed: + calculated_seeds = [input_seed + i for i in range(num_seeds)] + else: + calculated_seeds = [input_seed for _ in range(num_seeds)] else: - calculated_seeds = seeds - if len(set(calculated_seeds)) < len(calculated_seeds): - self.ppp_logger.info("Adjusting seeds because some are equal.") - calculated_seeds = [seed + i for i, seed in enumerate(calculated_seeds)] + seeds = getattr(p, "all_seeds", []) + subseeds = getattr(p, "all_subseeds", []) + subseed_strength = getattr(p, "subseed_strength", 0.0) + if subseed_strength > 0: + calculated_seeds = [ + int(subseed * subseed_strength + seed * (1 - subseed_strength)) + for seed, subseed in zip(seeds, subseeds) + ] + # if len(set(calculated_seeds)) < len(calculated_seeds): + # self.ppp_logger.info("Adjusting seeds because some are equal.") + # calculated_seeds = [seed + i for i, seed in enumerate(calculated_seeds)] + else: + calculated_seeds = seeds # adds regular prompts rpr = getattr(p, "all_prompts", None) @@ -223,10 +286,10 @@ def process(self, p: StableDiffusionProcessing, *args, **kwargs): # pylint: dis if self.ppp_debug_level != DEBUG_LEVEL.none: self.ppp_logger.info(f"processing prompts[{i+1}] ({prompttype})") if self.lru_cache.get((seed, prompt, negative_prompt)) is None: - pp, np = ppp.process_prompt(prompt, negative_prompt, seed) - self.lru_cache.put((seed, prompt, negative_prompt), (pp, np)) + posp, negp = ppp.process_prompt(prompt, negative_prompt, seed) + self.lru_cache.put((seed, prompt, negative_prompt), (posp, negp)) # adds also the result so i2i doesn't process it unnecessarily - self.lru_cache.put((seed, pp, np), (pp, np)) + self.lru_cache.put((seed, posp, negp), (posp, negp)) elif self.ppp_debug_level != DEBUG_LEVEL.none: self.ppp_logger.info("result already in cache") @@ -336,7 +399,7 @@ def new_html_title(title): # wildcard settings shared.opts.add_option( key="ppp_wil_sep", - info=new_html_title('

Wildcard settings

'), + info=new_html_title("

Wildcard settings

"), ) shared.opts.add_option( key="ppp_wil_processwildcards", @@ -395,7 +458,7 @@ def new_html_title(title): # content removal settings shared.opts.add_option( key="ppp_rem_sep", - info=new_html_title('

Content removal settings

'), + info=new_html_title("

Content removal settings

"), ) shared.opts.add_option( key="ppp_rem_removeextranetworktags", @@ -409,7 +472,7 @@ def new_html_title(title): # send to negative settings shared.opts.add_option( key="ppp_stn_sep", - info=new_html_title('

Send to Negative settings

'), + info=new_html_title("

Send to Negative settings

"), ) shared.opts.add_option( key="ppp_stn_separator", @@ -430,7 +493,7 @@ def new_html_title(title): # clean-up settings shared.opts.add_option( key="ppp_cup_sep", - info=new_html_title('

Clean-up settings

'), + info=new_html_title("

Clean-up settings

"), ) shared.opts.add_option( key="ppp_cup_emptyconstructs", diff --git a/tests/tests.py b/tests/tests.py index 1fe842f..d2826ab 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -140,6 +140,11 @@ def __process( self.assertEqual(result_negative_prompt, eo.negative_prompt, "Incorrect negative prompt") seed += 1 + # Other tests + + def test_version(self): # reading ppp version + self.assertNotEqual(PromptPostProcessor.VERSION, (0, 0, 0), "Incorrect version") + # Send To Negative tests def test_stn_simple(self): # negtags with different parameters and separations @@ -575,8 +580,8 @@ def test_wc_stop(self): # wildcards with stop option self.__process( PromptPair("__bad_wildcard__", "{option1|option2}"), PromptPair( - PromptPostProcessor.WILDCARD_STOP + "__bad_wildcard__", - PromptPostProcessor.WILDCARD_STOP + "{option1|option2}", + PromptPostProcessor.WILDCARD_STOP.format("__bad_wildcard__") + "__bad_wildcard__", + "{option1|option2}", ), ppp=PromptPostProcessor( self.__ppp_logger, @@ -593,6 +598,24 @@ def test_wc_stop(self): # wildcards with stop option interrupted=True, ) + def test_wcinvar_warn(self): # wildcards in var with warn option + self.__process( + PromptPair("${v=__bad_wildcard__}${v}", ""), + PromptPair(PromptPostProcessor.WILDCARD_WARNING + "__bad_wildcard__", ""), + ppp=PromptPostProcessor( + self.__ppp_logger, + self.__interrupt, + self.__def_env_info, + { + **self.__defopts, + "process_wildcards": False, + "if_wildcards": PromptPostProcessor.IFWILDCARDS_CHOICES.warn.value, + }, + self.__grammar_content, + self.__wildcards_obj, + ), + ) + def test_wc_wildcard1a_text(self): # simple text wildcard self.__process( PromptPair("the choices are: __text/wildcard1__", ""),