From 12d5d404401f4bb9445b6e2e59846d5a288e4bc1 Mon Sep 17 00:00:00 2001 From: Antonio Cordero Balcazar Date: Mon, 6 Apr 2026 13:32:35 +0200 Subject: [PATCH] * Added Wildcard Concat node for selecting and concatenating wildcards. * Refactor functions to resolve wildcard and extra-network mapping folder paths. --- __init__.py | 3 + docs/CONFIG.md | 50 ++++++++- ppp_comfyui.py | 208 +++++++++++++++++++++++++++++++------ web/ppp_wildcard_concat.js | 59 +++++++++++ 4 files changed, 281 insertions(+), 39 deletions(-) create mode 100644 web/ppp_wildcard_concat.js diff --git a/__init__.py b/__init__.py index 9cd1b5e..8e7ec7c 100644 --- a/__init__.py +++ b/__init__.py @@ -17,6 +17,7 @@ from .ppp_comfyui import ( PromptPostProcessorSTNOptionsComfyUINode, PromptPostProcessorCleanupOptionsComfyUINode, PromptPostProcessorSelectVariableComfyUINode, + PromptPostProcessorWildcardConcatComfyUINode, ) NODE_CLASS_MAPPINGS = { @@ -26,6 +27,7 @@ NODE_CLASS_MAPPINGS = { "ACBPPPSendToNegativeOptions": PromptPostProcessorSTNOptionsComfyUINode, "ACBPPPCleanupOptions": PromptPostProcessorCleanupOptionsComfyUINode, "ACBPPPSelectVariable": PromptPostProcessorSelectVariableComfyUINode, + "ACBPPPWildcardConcat": PromptPostProcessorWildcardConcatComfyUINode, } NODE_DISPLAY_NAME_MAPPINGS = { "ACBPromptPostProcessor": "ACB Prompt Post Processor", @@ -34,6 +36,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "ACBPPPSendToNegativeOptions": "ACB PPP Send-To-Negative Options", "ACBPPPCleanupOptions": "ACB PPP Cleanup Options", "ACBPPPSelectVariable": "ACB PPP Select Variable", + "ACBPPPWildcardConcat": "ACB PPP Wildcard Concat", } WEB_DIRECTORY = "./web" diff --git a/docs/CONFIG.md b/docs/CONFIG.md index a77fbe0..80ebd72 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -12,7 +12,11 @@ The model variants now support regular expressions instead of a list of strings ## ComfyUI -### ACB Prompt Post Processor node inputs +### ACB Prompt Post Processor node + +The main node that processes the prompt. + +Inputs: * **model**: Connect here the MODEL or a string with the model class name used by *ComfyUI*. Needed for the model kind system variables. * **modelname**: Name of the model. Needed for the detection of model variants. @@ -31,16 +35,46 @@ The model variants now support regular expressions instead of a list of strings The options nodes are optional. If you don't need to change any of the default values then you don't need to use them. -### ACB Prompt Post Processor node outputs +Outputs: -The outputs are the final positive and negative prompt and a variables dictionary. +* **pos_prompt**: the resulting positive prompt +* **neg_prompt**: the resulting negative prompt +* **variables**: the dictionary of variables set or echoed. -You can use the "**ACB PPP Select Variable**" node to choose one and output its value. You can use this to send only part of the prompt to, for example, a detailer node. For example: +### ACB PPP Select Variable node -With this prompt: `__quality__, 1girl, ${head:__eyes__, __hair__, __expression__}, __body__, __clothes__, __background__, __style__` then you extract the `head` variable and use `${head}` as prompt for the head/face detailer. +Lets you extract the variables used from the output (or just one of them). You can use this to send only part of the prompt to, for example, a detailer node. For example: + +With this prompt: `__quality__, 1girl, ${head:__eyes__, __hair__, __expression__}, __body__, __clothes__, __background__, __style__` then you extract the `head` variable and use it as prompt for the head/face detailer. + +Inputs: + +* **variables**: the variables dictionary from the main node output. +* **name**: optional name of a variable. + +Output: + +* **value**: the resulting content, either all the variables (one per line, in "name: value" format) or just the content of the chosen one. + +### ACB PPP Wildcards Concat node + +This node lets you select up to 10 wildcards that will be concatenated with a chosen separator. + +Inputs: + +* **previous_prompt**: An optional text that will be prepended to the wildcards. Lets you chain multiple nodes or other string nodes. +* **filter**: a string to filter the identifiers of the wildcards. It matches the start of the identifiers. +* **separator**: a separator string. +* **wildcard_**: the wildcards to concatenate. + +Output: + +* **prompt**: concatenated result. ### ACB PPP Wildcard Options node +Options for wildcard processing, in case you want to change them from the defaults. + * **folders**: You can enter multiple folders separated by commas. You can leave it empty (the default) and add a `ppp_wildcards` or `wildcards` entry in the **extra_model_paths.yaml** file (recommended). * **definitions**: Wildcards definitions (in yaml or json format). Direct input added to the ones found in the wildcards folders. Allows wildcards to be included in the workflow. * **if_wildcards**: Select what do you want to do with any found wildcards/choices (when process wildcards is off or after the processing). @@ -53,11 +87,15 @@ With this prompt: `__quality__, 1girl, ${head:__eyes__, __hair__, __expression__ ### ACB PPP Send-To-Negative Options node +Options for sent to negative commands, in case you want to change them from the defaults. + * **separator**: You can specify the separator used when adding to the negative prompt (by default it's ", "). * **ignore_repeats**: It ignores repeated content to avoid repetitions in the negative prompt. ### ACB PPP Cleanup Options node +Options for cleanup processing, in case you want to change them from the defaults. + * **extra_spaces**: Removes other unnecessary spaces. * **empty_constructs**: Removes attention/scheduling/alternation constructs when they are invalid. * **extra_separators**: Removes unnecessary separators. This applies to the configured separator and regular commas. @@ -75,6 +113,8 @@ Please note that *ComfyUI* does not natively support the `BREAK` and `AND` const ### ACB PPP ExtraNetwork Mapping Options node +Options for extranetworks mapping, in case you want to change them from the defaults. + * **folders**: You can enter multiple folders separated by commas. You can leave it empty (the default) and add a `ppp_extranetworkmappings` entry in the **extra_model_paths.yaml** file (recommended). * **definitions**: Extranetwork Mappings definitions (in yaml format). Direct input added to the ones found in the extranetwork mappings folders. Allows the mappings to be included in the workflow. diff --git a/ppp_comfyui.py b/ppp_comfyui.py index e1b4447..1d1fda7 100644 --- a/ppp_comfyui.py +++ b/ppp_comfyui.py @@ -14,6 +14,48 @@ if __name__ == "__main__": raise SystemExit("This script must be run from ComfyUI") +def _resolve_wildcards_folders(override: str = "") -> list[str]: + """Return the resolved list of wildcard folder paths.""" + folders_str = override + if folders_str == "": + try: + fp1 = folder_paths.get_folder_paths("ppp_wildcards") + except Exception: # pylint: disable=W0718 + fp1 = None + try: + fp2 = folder_paths.get_folder_paths("wildcards") + except Exception: # pylint: disable=W0718 + fp2 = None + folders_str = ",".join(fp1 or fp2 or []) + if folders_str == "": + folders_str = os.getenv("WILDCARD_DIR", PPPWildcards.DEFAULT_WILDCARDS_FOLDER) + return [ + (f if os.path.isabs(f) else os.path.abspath(os.path.join(folder_paths.models_dir, f))) + for f in folders_str.split(",") + if f.strip() != "" + ] + + +def _resolve_enmappings_folders(override: str = "") -> list[str]: + """Return the resolved list of extra-network mapping folder paths.""" + folders_str = override + if folders_str == "": + try: + fp3 = folder_paths.get_folder_paths("ppp_extranetworkmappings") + except Exception: # pylint: disable=W0718 + fp3 = None + folders_str = ",".join(fp3 or []) + if folders_str == "": + folders_str = os.getenv( + "EXTRANETWORKMAPPINGS_DIR", PPPExtraNetworkMappings.DEFAULT_ENMAPPINGS_FOLDER + ) + return [ + (f if os.path.isabs(f) else os.path.abspath(os.path.join(folder_paths.models_dir, f))) + for f in folders_str.split(",") + if f.strip() != "" + ] + + class PromptPostProcessorComfyUINode: """ Node for processing prompts. @@ -220,40 +262,8 @@ class PromptPostProcessorComfyUINode: "model_class": modelclass, "property_base": None, } - wc_wildcards_folders = wc_options["wc_wildcards_folders"] if wc_options else "" - if wc_wildcards_folders == "": - try: - fp1 = folder_paths.get_folder_paths("ppp_wildcards") - except Exception: # pylint: disable=W0718 - fp1 = None - try: - fp2 = folder_paths.get_folder_paths("wildcards") - except Exception: # pylint: disable=W0718 - fp2 = None - wc_wildcards_folders = ",".join(fp1 or fp2 or []) - if wc_wildcards_folders == "": - wc_wildcards_folders = os.getenv("WILDCARD_DIR", PPPWildcards.DEFAULT_WILDCARDS_FOLDER) - wildcards_folders = [ - (f if os.path.isabs(f) else os.path.abspath(os.path.join(folder_paths.models_dir, f))) - for f in wc_wildcards_folders.split(",") - if f.strip() != "" - ] - en_mappings_folders = en_options["en_mappings_folders"] if en_options else "" - if en_mappings_folders == "": - try: - fp3 = folder_paths.get_folder_paths("ppp_extranetworkmappings") - except Exception: # pylint: disable=W0718 - fp3 = None - en_mappings_folders = ",".join(fp3 or []) - if en_mappings_folders == "": - en_mappings_folders = os.getenv( - "EXTRANETWORKMAPPINGS_DIR", PPPExtraNetworkMappings.DEFAULT_ENMAPPINGS_FOLDER - ) - enmappings_folders = [ - (f if os.path.isabs(f) else os.path.abspath(os.path.join(folder_paths.models_dir, f))) - for f in en_mappings_folders.split(",") - if f.strip() != "" - ] + wildcards_folders = _resolve_wildcards_folders(wc_options["wc_wildcards_folders"] if wc_options else "") + enmappings_folders = _resolve_enmappings_folders(en_options["en_mappings_folders"] if en_options else "") options = { "debug_level": debug_level, @@ -743,3 +753,133 @@ class PromptPostProcessorSelectVariableComfyUINode: elif name in variables: value = variables[name] return (value,) + + +class PromptPostProcessorWildcardConcatComfyUINode: + """ + Node for selecting and concatenating wildcards as a prompt. + """ + + NONE_OPTION = "(none)" + _wildcards_obj = None + _wildcards_log = None + + @classmethod + def _ensure_wildcards(cls): + if cls._wildcards_obj is None: + lf = PromptPostProcessorLogFactory(SUPPORTED_APPS.comfyui) + cls._wildcards_log = lf.log + cls._wildcards_obj = PPPWildcards(cls._wildcards_log) + cls._wildcards_obj.refresh_wildcards(DEBUG_LEVEL.minimal, _resolve_wildcards_folders()) + return cls._wildcards_obj + + @classmethod + def get_wildcard_keys(cls, filter_prefix=""): + wc_obj = cls._ensure_wildcards() + keys = sorted(wc_obj.wildcards.keys()) + if filter_prefix: + keys = [k for k in keys if k.startswith(filter_prefix)] + return [cls.NONE_OPTION] + keys + + @classmethod + def INPUT_TYPES(cls): + wc_list = cls.get_wildcard_keys() + return { + "required": { + "filter": ( + "STRING", + { + "default": "build", + "multiline": False, + "dynamicPrompts": False, + "tooltip": "Filter wildcards by prefix (changing this refreshes the dropdowns)", + }, + ), + "separator": ( + "STRING", + { + "default": ", ", + "multiline": False, + "dynamicPrompts": False, + "tooltip": "Separator used to concatenate the selected wildcard references", + }, + ), + "wildcard_1": (wc_list, {"tooltip": "Wildcard 1"}), + "wildcard_2": (wc_list, {"tooltip": "Wildcard 2"}), + "wildcard_3": (wc_list, {"tooltip": "Wildcard 3"}), + "wildcard_4": (wc_list, {"tooltip": "Wildcard 4"}), + "wildcard_5": (wc_list, {"tooltip": "Wildcard 5"}), + "wildcard_6": (wc_list, {"tooltip": "Wildcard 6"}), + "wildcard_7": (wc_list, {"tooltip": "Wildcard 7"}), + "wildcard_8": (wc_list, {"tooltip": "Wildcard 8"}), + "wildcard_9": (wc_list, {"tooltip": "Wildcard 9"}), + "wildcard_10": (wc_list, {"tooltip": "Wildcard 10"}), + }, + "optional": { + "previous_prompt": ( + "STRING", + { + "forceInput": True, + "tooltip": "Connect to the prompt output of another Wildcard Concat node to chain more than 10 wildcards", + }, + ), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("prompt",) + + FUNCTION = "process" + + CATEGORY = "ACB" + + def process( + self, + filter, # noqa: A002 # pylint: disable=redefined-builtin, unused-argument + separator, + wildcard_1, + wildcard_2, + wildcard_3, + wildcard_4, + wildcard_5, + wildcard_6, + wildcard_7, + wildcard_8, + wildcard_9, + wildcard_10, + previous_prompt=None, + ): + parts = [ + f"__{w}__" + for w in [ + wildcard_1, + wildcard_2, + wildcard_3, + wildcard_4, + wildcard_5, + wildcard_6, + wildcard_7, + wildcard_8, + wildcard_9, + wildcard_10, + ] + if w != self.NONE_OPTION + ] + result = separator.join(parts) + if previous_prompt: + result = previous_prompt + separator + result if result else previous_prompt + return (result,) + + +try: + from server import PromptServer # type: ignore # pylint: disable=import-error + from aiohttp import web as _aiohttp_web # type: ignore # pylint: disable=import-error + + @PromptServer.instance.routes.get("/acb_ppp/wildcards") + async def _acb_ppp_get_wildcards(request): + filter_prefix = request.rel_url.query.get("filter", "") + wc_list = PromptPostProcessorWildcardConcatComfyUINode.get_wildcard_keys(filter_prefix) + return _aiohttp_web.json_response({"wildcards": wc_list}) + +except Exception: # pylint: disable=broad-except + pass diff --git a/web/ppp_wildcard_concat.js b/web/ppp_wildcard_concat.js new file mode 100644 index 0000000..d5934cd --- /dev/null +++ b/web/ppp_wildcard_concat.js @@ -0,0 +1,59 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "ACB.PPP.WildcardConcat", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "ACBPPPWildcardConcat") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + + const self = this; + + const refreshWildcards = async () => { + const filterWidget = self.widgets?.find((w) => w.name === "filter"); + if (!filterWidget) return; + + try { + const resp = await api.fetchApi( + `/acb_ppp/wildcards?filter=${encodeURIComponent(filterWidget.value ?? "")}` + ); + const data = await resp.json(); + const wildcards = data.wildcards ?? []; + + for (let i = 1; i <= 10; i++) { + const widget = self.widgets?.find((w) => w.name === `wildcard_${i}`); + if (widget) { + const current = widget.value; + widget.options.values = wildcards; + widget.value = wildcards.includes(current) + ? current + : wildcards[0] ?? "(none)"; + } + } + + app.graph.setDirtyCanvas(true, false); + } catch (err) { + console.error("[ACB PPP] Failed to refresh wildcard list:", err); + } + }; + + // Hook the filter widget so that committing a new value (Enter / blur) + // immediately refreshes all 10 wildcard dropdowns without running the workflow. + const filterWidget = this.widgets?.find((w) => w.name === "filter"); + if (filterWidget) { + const origCallback = filterWidget.callback; + filterWidget.callback = async function (...args) { + if (origCallback) origCallback.apply(this, args); + await refreshWildcards(); + }; + } + + return result; + }; + }, +});