From b325edc48e150b07618a15b790630c799c43a314 Mon Sep 17 00:00:00 2001 From: Antonio Cordero Balcazar Date: Sat, 23 May 2026 22:29:39 +0200 Subject: [PATCH] * Additional job info passed as input variable. * New results_file option to output the process information to a file. --- docs/CONFIG.md | 12 +-- docs/COOKBOOK.md | 35 +++++++++ ppp.py | 115 ++++++++++++++++++++++++++++- ppp_classes.py | 2 + ppp_comfyui.py | 34 +++++---- scripts/ppp_script.py | 53 +++++++++---- tests/base_tests.py | 3 +- web/docs/ACBPromptPostProcessor.md | 4 +- 8 files changed, 221 insertions(+), 37 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index f070784..6f10405 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -24,7 +24,7 @@ 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. +* **model**: Connect here the MODEL or a string with the model class name used by *ComfyUI*. Needed for the model kind system variables. Optional if you set modelname. * **modelname**: Filename of the model (with relative path). Needed for the detection of model variants. * **seed**: Set or connect here the seed used. By default it is -1 (random). The actual value used can be extracted from the output variables (`_input_seed`). * **pos_prompt**: Connect here the prompt text, or fill it as a widget. @@ -42,6 +42,7 @@ Inputs: * **stn_options**: Connection to a Send-To-Negative options node. * **cup_options**: Connection to a Cleanup options node. * **en_options**: Connection to a ExtraNetworkMapping options node. +* **results_file**: Filename to save processing results. Supports `%datetime%`, `%date%`, `%time%`, and `%host%` tokens. The file extension determines the format: `.yaml`/`.yml`, `.jsonl`, `.csv`, or plain text for any other extension. Relative paths are resolved against the extension's `logs` folder. Leave empty to disable. 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. @@ -49,13 +50,13 @@ The model and modelname are also optional, but if you don't set them you will no Setting only the modelname will try to detect its class from the file contents. If you don't want to set the path twice (and you don't use a loader node that outputs the name), you can set it here and then extract it from the output variables (`_modelfullname`) to send to the loader node. -You can also set them from the prompt and load the model afterwards. +You can instead set it from the prompt (with variable `_modelfullname`) and load the model afterwards. Outputs: -* **pos_prompt**: the resulting positive prompt -* **neg_prompt**: the resulting negative prompt -* **variables**: the dictionary of variables set or echoed. +* **pos_prompt**: Resulting positive prompt. +* **neg_prompt**: Resulting negative prompt. +* **variables**: Resulting output variables. The outputs are lists, and in combinatorial mode there will be multiple elements that *ComfyUI* will process sequentially. @@ -159,6 +160,7 @@ Options for extranetworks mapping, in case you want to change them from the defa * **Use strict operators**: Sets strict operations in comparisons. * **Apply in img2img**: Check if you want to do the processing in img2img processes. * **Add original prompts to metadata**: Adds original prompts to the metadata if they have changed. +* **Results file**: Filename to save processing results. Supports `%datetime%`, `%date%`, `%time%`, and `%host%` tokens. The file extension determines the format: `.yaml`/`.yml`, `.jsonl`, `.csv`, or plain text for any other extension. Relative paths are resolved against the extension's `logs` folder. Leave empty to disable. * **Extranetwork Mappings folders**: You can enter multiple folders separated by commas. ### Wildcard settings diff --git a/docs/COOKBOOK.md b/docs/COOKBOOK.md index 4e542d6..54268c1 100644 --- a/docs/COOKBOOK.md +++ b/docs/COOKBOOK.md @@ -534,3 +534,38 @@ This confirms the order and content of choices before relying on label filters. - A `%` choice in a YAML array must be quoted, otherwise YAML treats `%` as invalid syntax. - Variables set with `${var=value}` are lazy - they are not evaluated until echoed. Use `${var=!__wildcard__}` (with `!`) for immediate evaluation if the value should only be resolved once (as in, you want the same value to be echoed later multiple times). - Wildcards cannot be used inside extranetwork tags (because some LoRA names contain double underscores). Put the entire `` tag inside a wildcard choice instead, or use the `ext` command. + +## Recording results to a file + +When using wildcards and choices the actual prompt sent to the model can look very different from what you typed. Configuring a results file lets you capture every resolved prompt, along with its metadata, so you always know exactly what was generated. + +Common use cases: + +- **Reproducibility** - if an image turns out great but you didn't keep the inputs, you can look up the exact resolved prompt and all the inputs from the log. +- **Dataset building** - when running large batches with combinatorial mode or random wildcards, the file gives you a record of every prompt variant that was generated. +- **Prompt analysis** - review the log to spot patterns, see which wildcard choices come up most often, or evaluate the output of a new wildcard set before committing to a full run. +- **Debugging wildcard expansion** - compare the resolved prompts against what you expected to confirm that variables, conditions, and filters are all behaving correctly. + +### Setup + +Set the `results_file` option (in the extension settings for *A1111*, or the `results_file` input on the main node for *ComfyUI*) to a filename. The extension determines the output format from the file extension: + +| Extension | Format | +|------------------|----------------------------------------------------| +| `.yaml` / `.yml` | YAML list of records | +| `.jsonl` | JSON Lines, one JSON object per line | +| `.csv` | CSV with a header row (semicolon-delimited) | +| anything else | Plain text with labelled sections | + +Each record contains five sections: `options` (the PPP settings that were active), `inputs` (seed, prompts), `system` (system variables like `_modelclass`), `results` (the final positive and negative prompts), and `variables` (any user variables that were set). + +Use `%datetime%`, `%date%`, `%time%`, or `%host%` tokens in the filename to create a new file for each session or application: + +```text +results_%date%.yaml +``` + +Relative paths are resolved against the `logs` folder inside the extension directory. + +> [!TIP] +> The `.jsonl` format is the most convenient for programmatic processing. The `.yaml` format is the easiest to read manually. diff --git a/ppp.py b/ppp.py index cdca201..8087d14 100644 --- a/ppp.py +++ b/ppp.py @@ -1,8 +1,12 @@ +import csv import dataclasses +from datetime import datetime from enum import Enum +import json import logging from pathlib import Path import re +import textwrap import time from typing import Any, Callable, Optional import lark @@ -88,6 +92,8 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in DEFAULT_DO_COMBINATORIAL = defopt["do_combinatorial"] DEFAULT_COMBINATORIAL_SHUFFLE = defopt["combinatorial_shuffle"] DEFAULT_COMBINATORIAL_LIMIT = defopt["combinatorial_limit"] + DEFAULT_RESULTS_FILE = defopt["results_file"] + WILDCARD_WARNING = '(WARNING TEXT "INVALID WILDCARD" IN BRIGHT RED:1.5)\nBREAK ' WILDCARD_STOP = "INVALID WILDCARD! {0}\nBREAK " UNPROCESSED_STOP = "UNPROCESSED CONSTRUCTS!\nBREAK " @@ -992,6 +998,7 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in prompt: str, negative_prompt: str, seed: int, + jobinfo: Any = None, ) -> list[tuple[str, str, dict[str, Any]]]: """ Process the prompt and negative prompt. @@ -1010,6 +1017,7 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in self.state.inputs.seed = int(seed & ((1 << self.state.host_config.seed_bits) - 1)) self.state.inputs.pos_prompt = prompt self.state.inputs.neg_prompt = negative_prompt + self.state.inputs.jobinfo = jobinfo # Input related system variables for input_name in self.state.inputs.__dict__.keys(): @@ -1017,8 +1025,12 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in var_name = "_input_" + input_name if isinstance(input_value, (bool, str, int, float)): self.state.variables.set_system(var_name, input_value) + elif isinstance(input_value, (dict, list)): + self.state.variables.set_system(var_name, str(input_value)) elif isinstance(input_value, Enum): self.state.variables.set_system(var_name, str(input_value).split(".", 1)[-1]) + elif input_value is None: + self.state.variables.set_system(var_name, None) else: self.log( logging.WARNING, @@ -1077,11 +1089,110 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in self.log(logging.DEBUG, f"System variables: {filtered_sysvars}") self.log(logging.INFO, f"Combinatorial: {self.state.options.do_combinatorial}") + def _expand_filename(self) -> Path: + """Expand %...% tokens in a filename template and resolve relative paths against the extension logs folder.""" + now = datetime.now() + substitutions = { + r"%datetime%": now.strftime(r"%Y-%m-%d_%H-%M-%S"), + r"%date%": now.strftime(r"%Y-%m-%d"), + r"%time%": now.strftime(r"%H-%M-%S"), + r"%host%": str(self.state.env_info.get("app", "")), + } + result = str(self.state.options.results_file) + for token, value in substitutions.items(): + result = result.replace(token, value) + path = Path(result) + if not path.is_absolute(): + path = Path(__file__).resolve().parent / "logs" / path + return path + + def __save_results(self, results: list[tuple[str, str, dict[str, Any]]]) -> None: + """Append processing results to the configured results file.""" + if not self.state.options.results_file: + return + try: + filepath = self._expand_filename() + self.log(logging.INFO, f"Saving results to file: {filepath}") + ext = filepath.suffix.lower() + records = [] + for result_prompt, result_neg_prompt, all_variables in results: + records.append( + { + "options": { + k.removeprefix("_opt_"): v for k, v in all_variables.items() if k.startswith("_opt_") + }, + "system_variables": { + k: v + for k, v in all_variables.items() + if k.startswith("_") and not k.startswith("_opt_") and not k.startswith("_input_") + }, + "inputs": { + k.removeprefix("_input_"): v for k, v in all_variables.items() if k.startswith("_input_") + }, + "prompt_results": {"prompt": result_prompt, "negative_prompt": result_neg_prompt}, + "user_variables": {k: v for k, v in all_variables.items() if not k.startswith("_")}, + } + ) + filepath.parent.mkdir(parents=True, exist_ok=True) + file_exists = filepath.exists() + if ext in (".yaml", ".yml"): + with open(filepath, "a", encoding="utf-8-sig") as f: + if not file_exists: + f.write("records:\n") + for record in records: + y = yaml.dump(record, allow_unicode=True, default_flow_style=False) + f.write(f" - {textwrap.indent(y, ' ' * 4).strip()}\n") + elif ext == ".jsonl": + with open(filepath, "a", encoding="utf-8-sig") as f: + for record in records: + f.write(json.dumps(record, ensure_ascii=False, default=str) + "\n") + elif ext == ".csv": + # Build ordered column list from the current batch - header written only on new file. + # Columns from later calls that weren't in the original header will be silently dropped. + # The 'variables' section is serialized as a single JSON string column. + all_columns: list[str] = [] + for record in records: + for section, data in record.items(): + if section == "user_variables": + if "user_variables" not in all_columns: + all_columns.append("user_variables") + else: + for k in data: + col = f"{section}.{k}" + if col not in all_columns: + all_columns.append(col) + with open(filepath, "a", newline="", encoding="utf-8-sig") as f: + writer = csv.DictWriter(f, fieldnames=all_columns, delimiter=";", restval="", extrasaction="ignore") + if not file_exists: + writer.writeheader() + for record in records: + row: dict[str, Any] = {} + for section, data in record.items(): + if section == "user_variables": + row["user_variables"] = json.dumps(data, ensure_ascii=False, default=str) + else: + for k, v in data.items(): + row[f"{section}.{k}"] = ( + v if isinstance(v, (str, int, float, bool)) or v is None else str(v) + ) + writer.writerow(row) + else: # plain text + with open(filepath, "a", encoding="utf-8-sig") as f: + for record in records: + for section, data in record.items(): + f.write(f"[{section}]\n") + for k, v in data.items(): + f.write(f"{k}: {v}\n") + f.write(f"#{'-'*70}\n") + except Exception: # pylint: disable=broad-exception-caught + self.log(logging.WARNING, "Failed to save results to file", exc_info=True) + def process_prompt( self, original_prompt: str, original_negative_prompt: str, seed: int = -1, + jobinfo: Any = None, ) -> list[tuple[str, str, dict[str, Any]]]: """ Initializes the random number generator and processes the prompt and negative prompt. @@ -1090,6 +1201,7 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in original_prompt (str): The original prompt. original_negative_prompt (str): The original negative prompt. seed (int): The seed. + jobinfo (Any): Optional job information, available as `_input_jobinfo`. Returns: list[tuple[str, str, dict[str, Any]]]: A list of tuples containing the processed prompt, negative prompt and all the prompt variables. @@ -1104,10 +1216,11 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in if self.state.cyclical_state.last_prompt_pair != (original_prompt, original_negative_prompt): self.state.cyclical_state.reset() self.state.cyclical_state.last_prompt_pair = (original_prompt, original_negative_prompt) - results = self.__processprompts(prompt, negative_prompt, seed) + results = self.__processprompts(prompt, negative_prompt, seed, jobinfo) t2 = time.monotonic_ns() self.log(logging.INFO, f"Process prompt pair time: {(t2 - t1) / 1_000_000_000:.3f} seconds") # self.log(logging.DEBUG,f"Wildcards memory usage: {self.state.wildcards_obj.__sizeof__()}") + self.__save_results(results) return results except PPPInterrupt as e: self.log(logging.ERROR, e.message) diff --git a/ppp_classes.py b/ppp_classes.py index e6e2931..5577073 100644 --- a/ppp_classes.py +++ b/ppp_classes.py @@ -212,6 +212,7 @@ class PPPStateOptions: do_combinatorial: bool = False combinatorial_shuffle: bool = False combinatorial_limit: int = 100 # 0 = no limit + results_file: str = "" # empty = disabled; supports %datetime%, %date%, %time%, %host% tokens def __post_init__(self): if not self.cup_do_cleanup: @@ -237,6 +238,7 @@ class PPPStateInputs: seed: int = -1 pos_prompt: str = "" neg_prompt: str = "" + jobinfo: Any = None class CyclicalSamplerState: diff --git a/ppp_comfyui.py b/ppp_comfyui.py index 3375fe4..112f41d 100644 --- a/ppp_comfyui.py +++ b/ppp_comfyui.py @@ -1,6 +1,7 @@ if __name__ == "__main__": raise SystemExit("This script must be run from ComfyUI") +from datetime import datetime import logging import os from pathlib import Path @@ -242,6 +243,14 @@ class PromptPostProcessorComfyUINode: "tooltip": "ExtraNetworks mapping options", }, ), + "results_file": ( + "STRING", + { + "default": PromptPostProcessor.DEFAULT_RESULTS_FILE, + "tooltip": r"Filename to save processing results. Supports %datetime%, %date%, %time%, %host% tokens. Empty = disabled.", + "dynamicPrompts": False, + }, + ), }, } @@ -307,6 +316,7 @@ class PromptPostProcessorComfyUINode: cup_options=None, en_options=None, strict_operators=None, + results_file=None, ): modelclass = ( model.model.model_config.__class__.__name__ if model is not None and not isinstance(model, str) else model @@ -408,6 +418,7 @@ class PromptPostProcessorComfyUINode: do_combinatorial=do_combinatorial, combinatorial_shuffle=combinatorial_shuffle, combinatorial_limit=combinatorial_limit, + results_file=results_file or "", ) self.wildcards_obj.refresh_wildcards( options.debug_level, @@ -437,21 +448,14 @@ class PromptPostProcessorComfyUINode: self.extranetwork_mappings_obj, ) self.ppp.process_prompts_group_start() - results = self.ppp.process_prompt(pos_prompt, neg_prompt, seed if seed is not None else -1) + results = self.ppp.process_prompt( + pos_prompt, + neg_prompt, + seed if seed is not None else -1, + jobinfo={"job_timestamp": datetime.now().isoformat()}, + ) self.ppp.process_prompts_group_end() - # with open(Path(__file__).parent / "logs" / "last_prompts_comfyui.txt", "w", encoding="utf-8") as f: - # f.write(f"Seed: {seed if seed is not None else -1}\n") - # f.write(f"In Positive: {pos_prompt}\n") - # f.write(f"In Negative: {neg_prompt}\n") - # f.write("\n") - # for i, (posp, negp, var) in enumerate(results): - # f.write(f"Index: {i}\n") - # f.write(f"Out Positive: {posp}\n") - # f.write(f"Out Negative: {negp}\n") - # f.write(f"Out Variables: {var}\n") - # f.write("\n") - return tuple(zip(*results)) # unzip the list of tuples into tuple of lists def interrupt(self): @@ -879,7 +883,9 @@ class PromptPostProcessorSelectVariableComfyUINode: return (variables[name],) if if_not_found == ONWARNING_CHOICES.stop.value: raise ValueError(f"Variable '{name}' not found in the input variables") - self.logger.warning(f"Variable '{name}' not found in the input variables, using default value '{escape_single_quotes(default)}'") + self.logger.warning( + f"Variable '{name}' not found in the input variables, using default value '{escape_single_quotes(default)}'" + ) return (default,) raise ValueError("No variables provided to select from") diff --git a/scripts/ppp_script.py b/scripts/ppp_script.py index 6eb7223..273e3bf 100644 --- a/scripts/ppp_script.py +++ b/scripts/ppp_script.py @@ -255,6 +255,7 @@ class PromptPostProcessorA1111Script(scripts.Script): do_combinatorial=input_combinatorial, combinatorial_shuffle=input_combinatorial_shuffle, combinatorial_limit=max(num_seeds, int(input_combinatorial_limit)) if input_combinatorial else 0, + results_file=getattr(opts, "ppp_gen_resultsfile", PromptPostProcessor.DEFAULT_RESULTS_FILE), ) if self.ppp_logger is None: lf = PromptPostProcessorLogFactory() @@ -401,7 +402,16 @@ class PromptPostProcessorA1111Script(scripts.Script): hiresfix_changes = False if regular_exists: log(self.ppp_logger, self.ppp_debug_level, logging.INFO, "processing prompts combinatorially (regular)") - comb_results = ppp.process_prompt(rpr[0], rnr[0], seed_for_comb) + comb_results = ppp.process_prompt( + rpr[0], + rnr[0], + seed_for_comb, + jobinfo={ + "job_timestamp": shared.state.job_timestamp, + "job": shared.state.job, + "detail": "regular prompt combination", + }, + ) num_comb = len(comb_results) for i in range(len(rpr)): # pylint: disable=consider-using-enumerate posp, negp, _ = comb_results[i % num_comb] @@ -425,7 +435,16 @@ class PromptPostProcessorA1111Script(scripts.Script): logging.INFO, "processing prompts combinatorially (hiresfix)", ) - comb_results_hr = ppp.process_prompt(rph[0], rnh[0], seed_for_comb) + comb_results_hr = ppp.process_prompt( + rph[0], + rnh[0], + seed_for_comb, + jobinfo={ + "job_timestamp": shared.state.job_timestamp, + "job": shared.state.job, + "detail": "hiresfix prompt combination", + }, + ) num_comb_hr = len(comb_results_hr) for i in range(len(rph)): # pylint: disable=consider-using-enumerate posp, negp, _ = comb_results_hr[i % num_comb_hr] @@ -448,7 +467,16 @@ class PromptPostProcessorA1111Script(scripts.Script): cached = self.lru_cache.get(key) if cached is None: hsh, seed, prompt, negative_prompt = key - results = ppp.process_prompt(prompt, negative_prompt, seed) + results = ppp.process_prompt( + prompt, + negative_prompt, + seed, + jobinfo={ + "job_timestamp": shared.state.job_timestamp, + "job": shared.state.job, + "detail": f"{prompttype} prompt", + }, + ) posp, negp, _ = results[0] cached = (posp, negp) self.lru_cache.put(key, cached) @@ -459,16 +487,6 @@ class PromptPostProcessorA1111Script(scripts.Script): prompts_list[(prompttype, typeindex)] = cached ppp.process_prompts_group_end() - # with open(Path(__file__).parent.parent / "logs" / f"last_prompts_{app.value}.txt", "w", encoding="utf-8") as f: - # for (prompttype, typeindex), (posp, negp) in prompts_list.items(): - # f.write(f"Key: {prompttype}[{typeindex}]\n") - # f.write(f"Seed: {calculated_seeds[typeindex]}\n") - # f.write(f"In Positive: {rpr[typeindex] if prompttype == regular_type else rph[typeindex]}\n") - # f.write(f"In Negative: {rnr[typeindex] if prompttype == regular_type else rnh[typeindex]}\n") - # f.write(f"Out Positive: {posp}\n") - # f.write(f"Out Negative: {negp}\n") - # f.write("\n") - # updates the prompts regular_copy = (rpr.copy() if rpr else None, rnr.copy() if rnr else None) hiresfix_copy = (rph.copy() if rph else None, rnh.copy() if rnh else None) @@ -615,6 +633,15 @@ def on_ui_settings(): section=section, ), ) + shared.opts.add_option( + key="ppp_gen_resultsfile", + info=shared.OptionInfo( + PromptPostProcessor.DEFAULT_RESULTS_FILE, + label="Results file", + comment_after=r'(filename to save processing results; supports %datetime%, %date%, %time%, %host% tokens; extension determines format: .yaml, .jsonl, .csv, .txt; empty = disabled)', + section=section, + ), + ) shared.opts.add_option( key="ppp_en_mappingsfolders", diff --git a/tests/base_tests.py b/tests/base_tests.py index 25bfd52..4b78bfd 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -75,8 +75,9 @@ class TestPromptPostProcessorBase(unittest.TestCase): cup_merge_attention=True, cup_remove_extranetwork_tags=False, do_combinatorial=False, - combinatorial_limit=0, combinatorial_shuffle=False, + combinatorial_limit=0, + results_file=(Path(__file__).parent / "logs" / "output_%date%.txt") if enable_file_logging else "", ) self.def_env_info = { "app": "tests", diff --git a/web/docs/ACBPromptPostProcessor.md b/web/docs/ACBPromptPostProcessor.md index cf300b3..6f3c130 100644 --- a/web/docs/ACBPromptPostProcessor.md +++ b/web/docs/ACBPromptPostProcessor.md @@ -22,9 +22,7 @@ Main PPP node that processes prompts. * **stn_options**: Connection to a Send-To-Negative options node. * **cup_options**: Connection to a Cleanup options node. * **en_options**: Connection to a ExtraNetworkMapping options node. - -> [!NOTE] -> The node uses 32-bit seeds, to be compatible with all the UIs. This means that 64-bit input values will be cut at 32-bits. If you connect an external seed that is also connected directly to the ksampler, the values may differ. This doesn't really matter in practice, but if you want the same value use the output `_input_seed` variable to send to the ksampler. +* **results_file**: Filename to save processing results. Supports `%datetime%`, `%date%`, `%time%`, and `%host%` tokens. The file extension determines the format: `.yaml`/`.yml`, `.jsonl`, `.csv`, or plain text for any other extension. Relative paths are resolved against the extension's `logs` folder. Leave empty to disable. 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.