diff --git a/README.md b/README.md index cfa8e86..40f33fc 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ See the [syntax documentation](docs/SYNTAX.md). See the [cookbook](docs/COOKBOOK.md) for interesting usages. +## Tools + +A tool `tools/convert_styles.py` exists to convert A1111 or SD.Next styles into a wildcards file. + ## Contributing To develop, I suggest doing so with the extension isolated from the UI (you can use a symlink to test it in the UI), and with its own virtual environment (venv or .venv), so the tests work and can be debugged properly. diff --git a/docs/SYNTAX.md b/docs/SYNTAX.md index 0fd58ff..fcf71d8 100644 --- a/docs/SYNTAX.md +++ b/docs/SYNTAX.md @@ -88,7 +88,7 @@ The parameters, the filter, and the setting of a variable are optional. The para ### Identifier * Allowed characters are letters, numbers, underscore (`_`), dash (`-`), dot (`.`), and the path separators (`/` and `\`). It cannot start with an underscore because it would be ambiguous whether it's part of the name or just precedes the wildcard. -* Can have a relative path and contain globbing formatting, to read multiple wildcards and merge their choices. Note that if there are no parameters specified, the globbing will use the ones from the first wildcard that matches and have parameters (sorted by keys), so if you don't want that you might want to specify them. Also note that, unlike with *Dynamic Prompts*, the wildcard name has to be specified with its full path (unless you use globbing). +* Can have a relative path and contain globbing formatting, to read multiple wildcards and merge their choices. Note that if there are no parameters specified, the globbing will use the ones from the first wildcard that matches and have parameters (sorted by keys), so if you don't want that you might want to specify them. Also note that, unlike with *Dynamic Prompts*, the wildcard name has to be specified with its full path (unless you use globbing). Globbing cannot be at the start (you can't do, for example, `*/colors`). * You can use variables, with the `${name}`, `${name:default}`, `` or `defaultecho>` formats, to build a dynamic identifier. ### Filter @@ -196,7 +196,7 @@ The format is: | `` | Sets a filter | | `` | Removes the filter | -The wildcard identifier supports globbing. The filter does not allow the `^` or `#` flags. +The wildcard identifier supports globbing (but not at the start). The filter does not allow the `^` or `#` flags. ## Variables diff --git a/ppp.code-workspace b/ppp.code-workspace index c35c31d..8b8348c 100644 --- a/ppp.code-workspace +++ b/ppp.code-workspace @@ -54,6 +54,13 @@ "console": "integratedTerminal", "justMyCode": false, "cwd": "${workspaceFolder}" + }, + { + "name": "Python Debugger (Current File)", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "args": "${command:pickArgs}" } ], "compounds": [] diff --git a/ppp.py b/ppp.py index 809af61..e86eaf4 100644 --- a/ppp.py +++ b/ppp.py @@ -328,6 +328,23 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in else: user_cfg = None if user_cfg is not None: + if not isinstance(user_config_file, dict): + default_hosts = set((self.config.hosts or {}).keys()) + user_hosts = set((user_cfg.hosts or {}).keys()) + missing_hosts = default_hosts - user_hosts + if missing_hosts: + self.log( + logging.WARNING, + f"User configuration is missing host(s) from the default: {', '.join(sorted(missing_hosts))}. Consider updating your configuration file.", + ) + default_models = set((self.config.models or {}).keys()) + user_models = set((user_cfg.models or {}).keys()) + missing_models = default_models - user_models + if missing_models: + self.log( + logging.WARNING, + f"User configuration is missing model(s) from the default: {', '.join(sorted(missing_models))}. Consider updating your configuration file.", + ) self.__merge_configuration(user_cfg) self.models_config: dict[str, ModelConfig | None] = self.config.models or {} diff --git a/ppp_classes.py b/ppp_classes.py index 557162d..3a66ff3 100644 --- a/ppp_classes.py +++ b/ppp_classes.py @@ -18,6 +18,7 @@ class SUPPORTED_APPS(Enum): comfyui = "comfyui" a1111 = "a1111" forge = "forge" + forgeneo = "forgeneo" reforge = "reforge" sdnext = "sdnext" tests = "tests" # for testing purposes only, not a real app @@ -26,7 +27,8 @@ class SUPPORTED_APPS(Enum): SUPPORTED_APPS_NAMES = { SUPPORTED_APPS.comfyui: "ComfyUI", SUPPORTED_APPS.sdnext: "SD.Next", - SUPPORTED_APPS.forge: "Forge", + SUPPORTED_APPS.forge: "Forge Classic", + SUPPORTED_APPS.forgeneo: "Forge Neo", SUPPORTED_APPS.reforge: "reForge", SUPPORTED_APPS.a1111: "A1111 (or compatible)", SUPPORTED_APPS.tests: "Tests", diff --git a/ppp_common.py b/ppp_common.py index e063ccc..e80603d 100644 --- a/ppp_common.py +++ b/ppp_common.py @@ -1,10 +1,13 @@ import ast +import csv +from functools import reduce import logging from pathlib import Path import re import textwrap import time import lark +import yaml from ppp_logging import log from ppp_classes import ONWARNING_CHOICES, PPPInterrupt, PPPState @@ -237,3 +240,67 @@ def get_model_class_from_filename(filename: str) -> str: return config.__class__.__name__ if config else "" except Exception: # pylint: disable=broad-except return "" + + +def sanitize_wc_name(name: str) -> str: + # Remove invalid characters + return re.sub(r"[^a-zA-Z0-9-_]+", "", re.sub(r"_{2,}", "_", name.replace(" ", "_"))) + + +def convert_a1111_styles_to_wildcard(inp: Path, out: Path): + """ + Converts styles from A1111 format to wildcard format in a YAML file. + Args: + inp (Path): The input CSV file path. + out (Path): The output YAML file path. + """ + wildcards = {} + with open(inp, "r", encoding="utf-8-sig") as f: + for reg in csv.reader(f): + name = reg[0].strip() + if name.lower() == "name": + continue + positive = reg[1].strip() + negative = reg[2].strip() + if negative: + positive = f"{positive}{negative}" + name = sanitize_wc_name(name) + wildcards[name] = positive + if not wildcards: + raise RuntimeError(f"No styles found in {inp} to convert to wildcards.") + with open(out, "w", encoding="utf-8-sig") as f: + f.write(f"# Original names may contain characters that are replaced in the output.\n# Converted from {inp}\n") + yaml.dump(wildcards, f, allow_unicode=True) + + +def convert_sdnext_styles_to_wildcard(inp: Path, out: Path): + """ + Converts styles from SD.Next format to wildcard format in a YAML file. + Args: + inp (Path): The input folder path containing style json files or a single json file. + out (Path): The output YAML file path. + """ + wildcards = {} + files = inp.glob("*.json") if inp.is_dir() else [inp] + for file in files: + with open(file, "r", encoding="utf-8-sig") as f: + data = yaml.safe_load(f) + if not isinstance(data, list): + continue + wildcards[file] = {} + for style in data: + name = style.get("name", "").strip() + positive = style.get("prompt", "").strip() + negative = style.get("negative", "").strip() + # extra = style.get("extra", "").strip() + if negative: + positive = f"{positive}{negative}" + name = sanitize_wc_name(name) + wildcards[file][name] = positive + if not reduce(lambda acc, d: acc or bool(d), wildcards.values(), False): + raise RuntimeError(f"No styles found in {inp} to convert to wildcards.") + with open(out, "w", encoding="utf-8-sig") as f: + f.write("# Original names may contain characters that are replaced in the output.\n") + for name, wcs in wildcards.items(): + f.write(f"# Converted from {name}\n") + yaml.dump(wcs, f, allow_unicode=True) diff --git a/ppp_config.yaml.defaults b/ppp_config.yaml.defaults index 113ee32..5f598a3 100644 --- a/ppp_config.yaml.defaults +++ b/ppp_config.yaml.defaults @@ -50,6 +50,12 @@ hosts: alternation: ok and: ok break: ok + forgeneo: + attention: ok + scheduling: ok + alternation: ok + and: ok + break: ok reforge: attention: ok scheduling: ok @@ -74,10 +80,11 @@ hosts: # Supported base models, variants, and options # Check supported models for each host in: # A1111: -# Forge: repositories\huggingface_guess\huggingface_guess\model_list.py -# reForge: -# SD.Next: -# ComfyUI: +# Forge Classic: repositories\huggingface_guess\huggingface_guess\model_list.py +# Forge Neo: modules_forge\packages\huggingface_guess\model_list.py +# reForge: ldm_patched\modules\supported_models.py +# SD.Next: modules\shared_items.py +# ComfyUI: ComfyUI\comfy\supported_models.py models: # We define supported models and how we detect them in each host application (by class or by a known boolean property, or null for not supported). # We can also define here variants and some options. @@ -86,6 +93,7 @@ models: detect: a1111: { property: "is_sd1" } forge: { property: "is_sd1" } + forgeneo: { property: "is_sd1" } reforge: { property: "is_sd1" } sdnext: { class: ["LatentDiffusion", "StableDiffusionPipeline"] } # LatentDiffusion is for the original backend, StableDiffusionPipeline is for the diffusers backend; cannot differentiate SD1 and SD2, we set both to True comfyui: { class: ["SD15", "SD15_instructpix2pix"] } @@ -93,6 +101,7 @@ models: detect: a1111: { property: "is_sd2" } forge: { property: "is_sd2" } + forgeneo: null reforge: { property: "is_sd2" } sdnext: { class: ["LatentDiffusion", "StableDiffusionPipeline"] } # cannot differentiate SD1 and SD2, we set both to True; LatentDiffusion is for the original backend, StableDiffusionPipeline is for the diffusers backend comfyui: { class: ["SD20", "SD21UnclipL", "SD21UnclipH", "LotusD"] } @@ -100,6 +109,7 @@ models: detect: a1111: { property: "is_ssd" } forge: null + forgeneo: null reforge: { property: "is_ssd" } sdnext: null comfyui: { class: ["SSD1B"]} @@ -107,6 +117,7 @@ models: detect: a1111: { property: "is_sdxl" } forge: { property: "is_sdxl" } + forgeneo: { property: "is_sdxl" } reforge: { property: "is_sdxl" } sdnext: { class: ["StableDiffusionXLPipeline"] } comfyui: { class: ["SDXL", "SDXLRefiner", "SDXL_instructpix2pix", "Segmind_Vega", "KOALA_700M", "KOALA_1B"] } @@ -118,10 +129,12 @@ models: # If specified as a list, it should be a list of dicts or strings pony: { find_in_filename: "(?:pony|pny|pdxl)" } illustrious: { find_in_filename: { regex: "(?:illustrious|illust|ilxl)", flags: ["IGNORECASE"] } } + noobai: { find_in_filename: "(?:noob|nai)" } sd3: # Stable Diffusion 3 detect: a1111: { property: "is_sd3" } forge: { property: "is_sd3", class: ["SD3"] } + forgeneo: null reforge: { property: "is_sd3" } sdnext: { class: ["StableDiffusion3Pipeline"] } comfyui: { class: ["SD3"] } @@ -129,13 +142,15 @@ models: detect: a1111: null forge: { class: ["Flux", "FluxSchnell"] } + forgeneo: { class: ["Flux", "FluxSchnell"] } reforge: { class: ["Flux", "FluxInpaint", "FluxSchnell"] } sdnext: { class: ["FluxPipeline"] } comfyui: { class: ["Flux", "FluxInpaint", "FluxSchnell"] } auraflow: # AuraFlow detect: a1111: null - forge: null + forge: { class: ["AuraFlow"] } + forgeneo: null reforge: { class: ["AuraFlow"] } sdnext: { class: ["AuraFlowPipeline"] } comfyui: { class: ["AuraFlow"] } @@ -143,20 +158,31 @@ models: detect: a1111: null forge: null + forgeneo: null reforge: { class: ["PixArtAlpha", "PixArtSigma"] } - sdnext: { class: ["PixArtAlphaPipeline"] } + sdnext: { class: ["PixArtAlphaPipeline", "PixArtSigmaPipeline"] } comfyui: { class: ["PixArtAlpha", "PixArtSigma"] } - lumina2: # Lumina2 & ZImage + lumina2: # Lumina2 detect: a1111: null forge: null - reforge: { class: ["Lumina2", "ZImage"] } - sdnext: null - comfyui: { class: ["Lumina2", "ZImage"] } + forgeneo: { class: ["Lumina2"] } + reforge: { class: ["Lumina2"] } + sdnext: { class: ["Lumina2Pipeline"] } + comfyui: { class: ["Lumina2"] } + zimage: # ZImage + detect: + a1111: null + forge: null + forgeneo: { class: ["ZImage"] } + reforge: { class: ["ZImage"] } + sdnext: { class: ["ZImagePipeline"] } + comfyui: { class: ["ZImage"] } ltxv: # LTXV detect: a1111: null forge: null + forgeneo: null reforge: null sdnext: null comfyui: { class: ["LTXV", "LTXAV"] } @@ -164,13 +190,15 @@ models: detect: a1111: null forge: null + forgeneo: null reforge: { class: ["CosmosT2V", "CosmosI2V"] } - sdnext: null + sdnext: { class: ["Cosmos2TextToImagePipeline"] } comfyui: { class: ["CosmosT2V", "CosmosI2V"] } cosmospredict2: # CosmosPredict2 detect: a1111: null forge: null + forgeneo: null reforge: { class: ["CosmosT2IPredict2", "CosmosI2VPredict2"] } sdnext: null comfyui: { class: ["CosmosT2IPredict2", "CosmosI2VPredict2"] } @@ -178,20 +206,23 @@ models: detect: a1111: null forge: null + forgeneo: null reforge: { class: ["GenmoMochi"] } sdnext: null comfyui: { class: ["GenmoMochi"] } hunyuan: # Hunyuan detect: a1111: null - forge: null + forge: { class: ["HunyuanDiT", "HunyuanDiT1"] } + forgeneo: null reforge: { class: ["HunyuanDiT", "HunyuanDiT1"] } - sdnext: null + sdnext: { class: ["HunyuanDiTPipeline", "HunyuanImagePipeline"] } comfyui: { class: ["HunyuanDiT", "HunyuanDiT1"] } hunyuanvideo: # HunyuanVideo detect: a1111: null forge: null + forgeneo: null reforge: { class: ["HunyuanVideo", "HunyuanVideoI2V", "HunyuanVideoSkyreelsI2V", "HunyuanImage21", "HunyuanImage21Refiner", "HunyuanVideo15", "HunyuanVideo15_SR_Distilled"] } sdnext: null comfyui: { class: ["HunyuanVideo", "HunyuanVideoI2V", "HunyuanVideoSkyreelsI2V", "HunyuanImage21", "HunyuanImage21Refiner", "HunyuanVideo15", "HunyuanVideo15_SR_Distilled"] } @@ -199,6 +230,7 @@ models: detect: a1111: null forge: null + forgeneo: null reforge: { class: ["Hunyuan3Dv2", "Hunyuan3Dv2_1", "Hunyuan3Dv2mini"] } sdnext: null comfyui: { class: ["Hunyuan3Dv2", "Hunyuan3Dv2_1", "Hunyuan3Dv2mini"] } @@ -206,55 +238,95 @@ models: detect: a1111: null forge: { class: ["WAN21_T2V", "WAN21_I2V"] } + forgeneo: { class: ["WAN21_T2V", "WAN21_I2V"] } reforge: { class: ["WAN21_T2V", "WAN21_I2V", "WAN21_FunControl2V", "WAN21_Camera", "WAN22_Camera", "WAN21_Vace", "WAN21_HuMo", "WAN22_S2V", "WAN22_Animate", "WAN22_T2V"] } - sdnext: null - comfyui: { class: ["WAN21_T2V", "WAN21_I2V", "WAN21_FunControl2V", "WAN21_Camera", "WAN22_Camera", "WAN21_Vace", "WAN21_HuMo", "WAN22_S2V", "WAN22_Animate", "WAN22_T2V"] } + sdnext: { class: ["WanPipeline"] } + comfyui: { class: ["WAN21_T2V", "WAN21_I2V", "WAN21_FunControl2V", "WAN21_Camera", "WAN22_Camera", "WAN21_Vace", "WAN21_HuMo", "WAN22_S2V", "WAN22_Animate", "WAN22_T2V", "WAN21_FlowRVS", "WAN21_SCAIL", "WAN22_WanDancer"] } hidream: # HiDream detect: a1111: null forge: null + forgeneo: null reforge: { class: ["HiDream"] } - sdnext: null - comfyui: { class: ["HiDream"] } + sdnext: { class: ["HiDreamImagePipeline"] } + comfyui: { class: ["HiDream", "HiDreamO1"] } qwenimage: # QwenImage & QwenImageEdit detect: a1111: null - forge: { class: ["QwenImage"] } + forge: null + forgeneo: { class: ["QwenImage"] } reforge: null - sdnext: null + sdnext: { class: ["QwenImagePipeline"] } comfyui: { class: ["QwenImage"] } chroma: # Chroma detect: a1111: null - forge: { class: ["Chroma"] } + forge: null + forgeneo: { class: ["Chroma"] } reforge: { class: ["Chroma", "ChromaRadiance"] } - sdnext: null + sdnext: { class: ["ChromaPipeline"] } comfyui: { class: ["Chroma", "ChromaRadiance"] } omnigen2: # Omnigen 2 detect: a1111: null forge: null + forgeneo: null reforge: { class: ["Omnigen2"] } - sdnext: null + sdnext: { class: ["OmniGen2Pipeline"] } comfyui: { class: ["Omnigen2"] } flux2: # Flux 2 detect: a1111: null forge: null + forgeneo: { class: ["Flux2K4B", "Flux2K9B"] } reforge: null - sdnext: null - comfyui: { class: ["Flux2"] } + sdnext: { class: ["Flux2Pipeline", "Flux2KleinPipeline"] } + comfyui: { class: ["Flux2", "Flux2K4B", "Flux2K9B"] } kandinsky5: # Kandinsky 5 detect: a1111: null forge: null + forgeneo: null reforge: null - sdnext: null + sdnext: { class: ["Kandinsky5T2IPipeline"] } comfyui: { class: ["Kandinsky5", "Kandinsky5Image"] } anima: # Anima detect: a1111: null forge: null + forgeneo: { class: ["Anima"] } + reforge: null + sdnext: { class: ["AnimaText2ImagePipeline"] } + comfyui: { class: ["Anima"] } + longcatimage: # LongCatImage + detect: + a1111: null + forge: null + forgeneo: null + reforge: null + sdnext: { class: ["LongCatImagePipeline"] } + comfyui: { class: ["LongCatImage"] } + ernieimage: # ErnieImage + detect: + a1111: null + forge: null + forgeneo: { class: ["ErnieImage"] } + reforge: null + sdnext: { class: ["ErnieImagePipeline"] } + comfyui: { class: ["ErnieImage"] } + mugen: # Mugen + detect: + a1111: null + forge: null + forgeneo: { class: ["Mugen"] } reforge: null sdnext: null - comfyui: { class: ["Anima"] } + comfyui: { class: ["Mugen"] } + cogvideox: # CogVideoX + detect: + a1111: null + forge: null + forgeneo: null + reforge: null + sdnext: null + comfyui: { class: ["CogVideoX_T2V", "CogVideoX_I2V", "CogVideoX_Inpaint"] } diff --git a/scripts/ppp_script.py b/scripts/ppp_script.py index afbf2a1..6eb7223 100644 --- a/scripts/ppp_script.py +++ b/scripts/ppp_script.py @@ -197,19 +197,16 @@ class PromptPostProcessorA1111Script(scripts.Script): Returns: None """ - app = ( - SUPPORTED_APPS.forge - if hasattr(p.sd_model, "model_config") - else ( - SUPPORTED_APPS.reforge - if hasattr(p.sd_model, "forge_objects") - else ( - SUPPORTED_APPS.sdnext - if hasattr(p.sd_model, "is_sdxl") and not hasattr(p.sd_model, "is_ssd") - else SUPPORTED_APPS.a1111 - ) - ) - ) + app = SUPPORTED_APPS.a1111 + if hasattr(p.sd_model, "model_config"): + app = SUPPORTED_APPS.forge + if not hasattr(p.sd_model, "is_sd2"): + app = SUPPORTED_APPS.forgeneo + elif hasattr(p.sd_model, "forge_objects"): + app = SUPPORTED_APPS.reforge + elif hasattr(p.sd_model, "is_sdxl") and not hasattr(p.sd_model, "is_ssd"): + app = SUPPORTED_APPS.sdnext + num_seeds = len(getattr(p, "all_seeds", [])) options = PPPStateOptions( debug_level=DEBUG_LEVEL(getattr(opts, "ppp_gen_debug_level", PromptPostProcessor.DEFAULT_DEBUG_LEVEL)), @@ -303,11 +300,13 @@ class PromptPostProcessorA1111Script(scripts.Script): "app": app.value, "models_path": models_path, "model_filename": getattr(p.sd_model.sd_checkpoint_info, "filename", ""), - "model_class": p.sd_model.__class__.__name__, + "model_class": ( + p.sd_model.model_config.__class__.__name__ + if app in (SUPPORTED_APPS.forge, SUPPORTED_APPS.forgeneo) + else p.sd_model.__class__.__name__ + ), "property_base": p.sd_model, } - if app == SUPPORTED_APPS.forge: - env_info["model_class"] = p.sd_model.model_config.__class__.__name__ wc_wildcards_folders = getattr(opts, "ppp_wil_wildcardsfolders", "") if wc_wildcards_folders == "": wc_wildcards_folders = os.getenv("WILDCARD_DIR", PPPWildcards.DEFAULT_WILDCARDS_FOLDER) diff --git a/tools/convert_styles.py b/tools/convert_styles.py new file mode 100644 index 0000000..94f22df --- /dev/null +++ b/tools/convert_styles.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +""" +Converts style files (A1111 CSV or SD.Next JSON) to a PPP-compatible YAML wildcard file. + +Usage: + python convert_styles.py [--format {a1111,sdnext}] + +Arguments: + input Path to the A1111 styles CSV file, a SD.Next JSON file, or a folder of SD.Next JSON files. + output Path to the output YAML wildcard file. + +Options: + --format Force the input format. If omitted, the format is inferred from the input: + - a1111 : input is a .csv file + - sdnext : input is a .json file or a directory +""" + +import argparse +import sys +from pathlib import Path + +# Allow importing from the parent package when running directly from this folder. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from ppp_common import convert_a1111_styles_to_wildcard, convert_sdnext_styles_to_wildcard + + +def detect_format(inp: Path) -> str: + if inp.is_dir(): + return "sdnext" + if inp.suffix.lower() == ".csv": + return "a1111" + if inp.suffix.lower() == ".json": + return "sdnext" + return "" + + +def main(): + parser = argparse.ArgumentParser( + description="Convert A1111 or SD.Next style files to a PPP YAML wildcard file.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("input", type=Path, help="Input CSV file (A1111) or JSON file/folder (SD.Next).") + parser.add_argument("output", type=Path, help="Output YAML wildcard file.") + parser.add_argument( + "--format", + choices=["a1111", "sdnext"], + default=None, + help="Force input format. Auto-detected from the input path when not specified.", + ) + args = parser.parse_args() + + inp: Path = args.input + out: Path = args.output + + if not inp.exists(): + print(f"Error: input path does not exist: {inp}", file=sys.stderr) + sys.exit(1) + + if out.suffix.lower() not in (".yaml", ".yml"): + print(f"Error: output path is not a YAML file: {out}", file=sys.stderr) + sys.exit(1) + + fmt = args.format or detect_format(inp) + if not fmt: + print( + f"Error: could not detect format from '{inp}'. Use --format to specify it explicitly.", + file=sys.stderr, + ) + sys.exit(1) + + out.parent.mkdir(parents=True, exist_ok=True) + + try: + if fmt == "a1111": + convert_a1111_styles_to_wildcard(inp, out) + else: + convert_sdnext_styles_to_wildcard(inp, out) + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f"Converted '{inp}' ({fmt}) -> '{out}'") + + +if __name__ == "__main__": + main()