From 215a71bcbbe5df2a384201535a842e69ee5a626e Mon Sep 17 00:00:00 2001 From: "Dr.Lt.Data" Date: Sun, 1 Nov 2026 01:26:32 +0900 Subject: [PATCH] Preserve original LoRA behavior and verify real inference --- .gitignore | 1 + docs/E2E_TEST_STRATEGY.md | 33 +++++ docs/LORA_WILDCARD_SOURCE.md | 6 +- modules/impact/wildcard_source.py | 127 ++++++++++++++--- modules/impact/wildcards.py | 2 + tests/README.md | 14 ++ tests/e2e_lora_wildcard_inference.py | 139 +++++++++++++++++++ tests/integration_lora_wildcard_tensors.py | 70 ++++++++++ tests/wildcards/model_samples/models.txt | 2 + tests/wildcards/test_lora_wildcard_source.py | 64 ++++++++- 10 files changed, 432 insertions(+), 26 deletions(-) create mode 100644 tests/e2e_lora_wildcard_inference.py create mode 100644 tests/integration_lora_wildcard_tensors.py create mode 100644 tests/wildcards/model_samples/models.txt diff --git a/.gitignore b/.gitignore index 17226bce..1bebce0c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ impact_subpack !tests/wildcards/samples/test_encoding_emoji.txt !tests/wildcards/samples/test_encoding_special.txt !tests/wildcards/lora_samples/*.txt +!tests/wildcards/model_samples/*.txt !example_workflows/wildcards/styles.txt diff --git a/docs/E2E_TEST_STRATEGY.md b/docs/E2E_TEST_STRATEGY.md index bf7e4ad8..f5cb281a 100644 --- a/docs/E2E_TEST_STRATEGY.md +++ b/docs/E2E_TEST_STRATEGY.md @@ -16,6 +16,7 @@ that setup does not extend the supported import scope. | `tests/test_e2e_switches.py` | Create and wire nodes; import prepared graphs and UI-created JSON, reload Chrome, delete connected nodes, reconnect and click Run | Slot identities and typed links, list order, non-default selection and routing, fresh output after reconnecting, output preservation when a Reroute disconnects during loading | | `tests/test_e2e_widget_catalog.py` | Load the UI-created catalog and pan to each node at 100% zoom | Every registered node exactly once; live widget names, order, and values match the fixed baseline under positional and named restoration | | `tests/test_e2e_lora_wildcard_source.py` | Search for the node, select non-default values, native Export and file import | Delayed creation responses cannot overwrite saved widget names/order/values or category options in either restoration mode | +| `tests/e2e_lora_wildcard_inference.py` | Import a prepared graph, choose modes, Run, native Export and reload | Real SD1.5 LoRA loading and BREAK conditioning match core-node reference images pixel-for-pixel; four modes preserve output count/order/text and native saved widgets | | `tests/e2e_tutorials.py` | Import the original Switch tutorial PNGs, change selection widgets, save and reimport JSON | Every selected branch and final output matches direct core-node image results; both selection modes retain the final preview | | `tests/e2e_regional.py` | Import the regional img2img tutorial and prepared variants, click Run | Exact color masks, output differs from a VAE roundtrip, each regional prompt affects its own region more than distant pixels, browser previews match server images | | `tests/e2e_conditionings.py` | Import conditioning and regional-prompt graphs, click Run, save and reimport through the file input | Combine/Concat results match core ComfyUI nodes; regional prompts retain both items in order; dynamic slots and links survive loading | @@ -84,6 +85,38 @@ assertions; keep the tests and trusted fixtures unchanged. Restore the fix before final catalog verification. Catalog recording uses the ordinary Full Cache setup below, not the LoRA sample directory. +For real LoRA inference, configure `custom_wildcards` as +`tests/wildcards/model_samples` and restart the isolated server. Reuse local +models with `--models-directory`: the script requires +`v1-5-pruned-emaonly-fp16.safetensors` under checkpoints and +`SD1.5/etc/add_detail.safetensors` plus `SD1.5/etc/detailmaker.safetensors` under +loras. It downloads no assets. The reference uses core LoRA loaders, two CLIP +encodings joined by ConditioningConcat, and identical sampler settings. Cases +include a repeated LoRA, signed/scientific weights, literal prompt syntax, +trailing text, an extra LoRA, all four selection modes, and native reload. +Unpatched and distinct-LoRA images must differ so parity cannot pass with +silently ignored patches or duplicated batch entries. +For exact pixel comparisons, start the server with `--deterministic +--force-fp32 --fp32-vae --disable-dynamic-vram --use-pytorch-cross-attention`. +Default FP16/dynamic-VRAM output is not a bitwise pixel oracle for this test. +Reference text enters CLIP through a linked string to avoid the frontend's +dynamic-prompt widget changing literal braces before submission. + +```bash +python tests/e2e_lora_wildcard_inference.py --base-url http://127.0.0.1:18188 \ + --artifacts /tmp/impact-lora-inference +``` + +The separate model integration check compares every conditioning tensor and +all MODEL/CLIP patch tensors and strengths with core nodes, and verifies that +the original inputs remain unpatched. It uses default ComfyUI model settings +and the same local assets, without a running server. Only unused HTTP route +registration is substituted. It performs no sampling or browser actions: + +```bash +python tests/integration_lora_wildcard_tensors.py --comfy-root /path/to/ComfyUI +``` + ## Server setup Use an isolated checkout/copy and a separate ComfyUI user/output directory. diff --git a/docs/LORA_WILDCARD_SOURCE.md b/docs/LORA_WILDCARD_SOURCE.md index aa1745ba..62b4bb41 100644 --- a/docs/LORA_WILDCARD_SOURCE.md +++ b/docs/LORA_WILDCARD_SOURCE.md @@ -10,7 +10,7 @@ The optional sample is [example_workflows/wildcards/styles.txt](../example_workf - **Dynamic Category Mapping**: Automatically populates the `category` dropdown by scanning all `.txt` files in the `custom_wildcards/` directory. - **Dynamic Options Update**: Changing the `category` in the UI dynamically fetches and updates the available LoRAs in the `lora_wildcard` dropdown. -- **LoRA Loading & Encoding**: Uses Impact Pack's existing wildcard/LoRA processor, including wildcard expansion, multiple LoRAs, block weights and `BREAK`. Text after a LoRA tag is preserved. +- **LoRA Loading & Encoding**: Applies LoRA tags in order using ComfyUI's LoRA loader, then encodes the cleaned prompt with `BREAK` support. Text and additional LoRAs after the first tag are preserved. Repeated LoRAs are applied separately. Prompt text such as `__color__` or `{red|blue}` remains literal, matching the original node. - **Cleanup**: Strips `` tags from the final text prompt before encoding. - **Outputs for File Saving**: Provides `lora_name` (clean filename) and `name` (label) outputs, which can be connected directly to `SaveImage` as a filename prefix. @@ -28,9 +28,9 @@ The optional sample is [example_workflows/wildcards/styles.txt](../example_workf - **category** (COMBO): Dropdown containing the text file names (categories) from the `custom_wildcards/` directory. - **lora_wildcard** (COMBO): The LoRA entries inside the selected category file. - **mode** (COMBO): Select between `fixed`, `random`, `sequential`, or `batch`. -- **strength** (FLOAT): Overrides the model and CLIP weights of the entry's first LoRA (Default: `0.8`). Other LoRAs in the entry or base prompt retain their declared weights. The first LoRA's block-weight/loader options are preserved. +- **strength** (FLOAT): Overrides the model and CLIP weights of the entry's first LoRA (Default: `0.8`). Other LoRAs in the entry or base prompt retain their declared numeric weights. Block-weight options are not supported by this node. - **base_prompt** (STRING): Multiline text input for the base prompt. -- **seed** (INT): Seed used for random selection, sequential index and wildcard expansion. Image sampling uses the sampler's own seed. +- **seed** (INT): Seed used for random selection and sequential index. Image sampling uses the sampler's own seed. ## Outputs diff --git a/modules/impact/wildcard_source.py b/modules/impact/wildcard_source.py index 3fabc666..832fdc97 100644 --- a/modules/impact/wildcard_source.py +++ b/modules/impact/wildcard_source.py @@ -1,6 +1,8 @@ import os import re import random +import logging +import folder_paths import nodes from aiohttp import web from server import PromptServer @@ -26,7 +28,7 @@ def parse_linhas_wildcard(caminho): entries = [] counts = {} for line in wildcards.load_txt_wildcard(caminho): - loras = wildcards.extract_lora_values(line) + loras, _ = extrair_e_limpar_loras(line) if not loras or not loras[0][0]: continue name = loras[0][0].strip() @@ -39,19 +41,19 @@ def parse_linhas_wildcard(caminho): def listar_categorias_wildcard(): root = config.get_config()["custom_wildcards"] - categories = sorted(os.path.splitext(name)[0] for name in os.listdir(root) - if name.endswith(".txt") and achar_arquivo_wildcard(name)) + try: + categories = sorted(os.path.splitext(name)[0] for name in os.listdir(root) + if name.endswith(".txt") and achar_arquivo_wildcard(name)) + except OSError as error: + logging.warning(f"[Impact Wildcard] Error listing custom_wildcards: {error}") + categories = [] return categories or ["styles", "poses"] def entry_with_strength(entry, strength): def replace(match): - parts = match.group(1).split(":") - name = parts.pop(0).strip() - for _ in range(2): - if parts and wildcards.is_numeric_string(parts[0]): - parts.pop(0) - return "" + name = match.group(1).split(":", 1)[0].strip() + return f"" return _LORA_RE.sub(replace, entry, count=1) @@ -61,14 +63,102 @@ def nome_arquivo_limpo(nome, fallback="no_lora"): return re.sub(r"\s+", "_", name).strip("_") or fallback -def clean_prompt(text): - text = re.sub(r",\s*,", ",", text) - return re.sub(r"\s+", " ", text).strip().strip(",").strip() +def resolver_caminho_lora(nome): + """Locates the .safetensors (or other supported formats) in the ComfyUI cache, resolving subfolders.""" + if not nome.endswith((".safetensors", ".ckpt", ".pt")): + nome_com_ext = nome + ".safetensors" + else: + nome_com_ext = nome + todos_loras = folder_paths.get_filename_list("loras") -class _PromptEncoder: - def encode(self, clip, text): - return nodes.CLIPTextEncode().encode(clip, clean_prompt(text)) + # Attempt 1: Exact match or relative path + for lora in todos_loras: + lora_norm = lora.replace("\\", "/") + nome_norm = nome_com_ext.replace("\\", "/") + if lora_norm == nome_norm or lora_norm.endswith("/" + nome_norm): + return lora + + # Attempt 2: Filename only (ignores subfolders) + nome_basico = os.path.basename(nome_com_ext) + for lora in todos_loras: + if os.path.basename(lora) == nome_basico: + return lora + + return None + +def extrair_e_limpar_loras(prompt): + """Scans the prompt for or tags, + returning the list of LoRAs to load and cleaning the tags from the text.""" + if not prompt: + return [], "" + + pattern = r"]+)>" + matches = re.findall(pattern, prompt) + + loras_extraidos = [] + for match in matches: + parts = match.split(":") + if len(parts) >= 1: + nome_lora = parts[0].strip() + weight_model = 1.0 + weight_clip = 1.0 + + if len(parts) >= 2: + try: + weight_model = float(parts[1].strip()) + weight_clip = weight_model + except ValueError: + pass + if len(parts) >= 3: + try: + weight_clip = float(parts[2].strip()) + except ValueError: + pass + + loras_extraidos.append((nome_lora, weight_model, weight_clip)) + + # Clean the tags from the prompt + prompt_limpo = re.sub(pattern, "", prompt) + + # Remove duplicate/trailing commas and extra spaces + prompt_limpo = re.sub(r",\s*,", ",", prompt_limpo) + prompt_limpo = re.sub(r"\s+", " ", prompt_limpo).strip() + prompt_limpo = prompt_limpo.strip(",").strip() + + return loras_extraidos, prompt_limpo + +def aplicar_loras_e_codificar(model, clip, loras_a_aplicar, prompt_limpo): + """Loads LoRAs sequentially and encodes the prompt using CLIPTextEncode (supporting BREAK).""" + loader = nodes.LoraLoader() + + # 1. Load LoRAs onto the model and CLIP + for nome_lora, w_model, w_clip in loras_a_aplicar: + caminho_resolvido = resolver_caminho_lora(nome_lora) + if caminho_resolvido: + logging.info(f"[Impact Wildcard] Applying LoRA: {caminho_resolvido} (model={w_model}, clip={w_clip})") + model, clip = loader.load_lora(model, clip, caminho_resolvido, w_model, w_clip) + else: + logging.warning(f"[Impact Wildcard] WARNING: LoRA '{nome_lora}' could not be found in ComfyUI.") + + # 2. Split by BREAK and encode (for composite conditioning / ImpactWildcardEncode compatibility) + secoes = [x.strip() for x in prompt_limpo.split("BREAK")] + secoes = [x for x in secoes if x != ""] + if not secoes: + secoes = [""] + + encoder = nodes.CLIPTextEncode() + result_conditioning = None + + for secao in secoes: + logging.info(f"[Impact Wildcard] Encoding prompt: '{secao}'") + cond = encoder.encode(clip, secao)[0] + if result_conditioning is not None: + result_conditioning = nodes.ConditioningConcat().concat(result_conditioning, cond)[0] + else: + result_conditioning = cond + + return model, clip, result_conditioning @PromptServer.instance.routes.get("/impact/get_items") @@ -139,10 +229,9 @@ class LoraWildcardSource: for label, entry, name in selected: prompt = ", ".join(part.strip().strip(",") for part in (base_prompt, entry_with_strength(entry, strength)) if part.strip()) - processed = [] - result_model, result_clip, conditioning = wildcards.process_with_loras( - prompt, model, clip, clip_encoder=_PromptEncoder(), seed=seed, processed=processed) - values = (result_model, result_clip, conditioning, clean_prompt(processed[1]), nome_arquivo_limpo(name), label) + loras, text = extrair_e_limpar_loras(prompt) + result_model, result_clip, conditioning = aplicar_loras_e_codificar(model, clip, loras, text) + values = (result_model, result_clip, conditioning, text, nome_arquivo_limpo(name), label) for output, value in zip(outputs, values): output.append(value) return outputs diff --git a/modules/impact/wildcards.py b/modules/impact/wildcards.py index 03b41da6..6142d010 100644 --- a/modules/impact/wildcards.py +++ b/modules/impact/wildcards.py @@ -514,6 +514,8 @@ def process_comment_out(text): def process(text, seed=None): text = process_comment_out(text) + if seed is not None: + random.seed(seed) random_gen = np.random.default_rng(seed) local_wildcard_dict = get_wildcard_dict() diff --git a/tests/README.md b/tests/README.md index 544674aa..91528a69 100644 --- a/tests/README.md +++ b/tests/README.md @@ -68,6 +68,20 @@ contract through Chrome search, editing, native Export and file import in both restoration modes. Configure its isolated server with `tests/wildcards/lora_samples`; see the E2E guide. +`python3 tests/e2e_lora_wildcard_inference.py --artifacts /tmp/impact-lora-inference` +checks all four modes and native workflow reload against core LoRA/CLIP/sampler +nodes using real SD1.5 models. Configure the isolated server with +`tests/wildcards/model_samples` and the models listed in the script docstring. +It compares every output pixel, output text, list length and entry order; +browser previews must also match server images. This is separate from the +model-free catalog and delayed-response tests. +Use the deterministic FP32 server options in the E2E guide for pixel equality. +`python3 tests/integration_lora_wildcard_tensors.py --comfy-root /path/to/ComfyUI` +separately compares every conditioning tensor and MODEL/CLIP LoRA patch tensor +and weight with core nodes under ComfyUI's default model-loading settings. +It also verifies that the input models retain no patches. Only unused HTTP +route registration is substituted; model operations are real. + ## Test Suites ### test_encoding.sh (15 tests) diff --git a/tests/e2e_lora_wildcard_inference.py b/tests/e2e_lora_wildcard_inference.py new file mode 100644 index 00000000..25f6807f --- /dev/null +++ b/tests/e2e_lora_wildcard_inference.py @@ -0,0 +1,139 @@ +"""Compare LoraWildcardSource with core LoRA/CLIP/sampler nodes in Chrome. + +Requires v1-5-pruned-emaonly-fp16.safetensors and SD1.5/etc/{add_detail, +detailmaker}.safetensors. Configure custom_wildcards as wildcards/model_samples. +Uses real models and HTTP execution, with no model or route substitutions. +For exact pixels, start the server with --deterministic --force-fp32 --fp32-vae +--disable-dynamic-vram --use-pytorch-cross-attention. +""" +import hashlib +import json + +from e2e_ui import browser_session, options +from test_e2e_widget_catalog import export_workflow, full_zoom, show_node, widget_state + + +LORAS = ["SD1.5/etc/add_detail", "SD1.5/etc/detailmaker"] +BASE = "a {red|blue} flower, __color__, BREAK garden" +FIRST_SECTION = "a {red|blue} flower, __color__," +LAST_SECTIONS = ["garden, petals, dew", "garden, leaves, mist"] + + +def workflow(): + graph = {} + + def add(kind, **inputs): + node_id = str(len(graph) + 1) + graph[node_id] = {"class_type": kind, "inputs": inputs} + return node_id + + checkpoint = add("CheckpointLoaderSimple", ckpt_name="v1-5-pruned-emaonly-fp16.safetensors") + negative = add("CLIPTextEncode", clip=[checkpoint, 1], text="") + latent = add("EmptyLatentImage", width=128, height=128, batch_size=1) + # A linked string bypasses CLIPTextEncode's frontend dynamic-prompt widget. + # The reference must receive the same literal text as the source node. + literal = add("PrimitiveString", value=FIRST_SECTION) + + def preview(model, conditioning): + sampled = add("KSampler", model=model, positive=conditioning, negative=[negative, 0], + latent_image=[latent, 0], seed=12345, steps=4, cfg=7.0, + sampler_name="euler", scheduler="normal", denoise=1.0) + decoded = add("VAEDecode", samples=[sampled, 0], vae=[checkpoint, 2]) + return add("PreviewImage", images=[decoded, 0]) + + def encode(clip, last): + first = add("CLIPTextEncode", clip=clip, text=[literal, 0]) + second = add("CLIPTextEncode", clip=clip, text=last) + return add("ConditioningConcat", conditioning_to=[first, 0], conditioning_from=[second, 0]) + + source = add("LoraWildcardSource", model=[checkpoint, 0], clip=[checkpoint, 1], category="models", + lora_wildcard=LORAS[0], mode="batch", strength=0.6, base_prompt=BASE, seed=1) + actual = preview([source, 0], [source, 2]) + text = add("PreviewAny", source=[source, 3]) + names = add("PreviewAny", source=[source, 5]) + references = [] + for index, lora in enumerate(LORAS): + base = add("LoraLoader", model=[checkpoint, 0], clip=[checkpoint, 1], + lora_name=LORAS[0] + ".safetensors", strength_model=0.1, strength_clip=0.2) + selected = add("LoraLoader", model=[base, 0], clip=[base, 1], + lora_name=lora + ".safetensors", strength_model=0.6, strength_clip=0.6) + if index == 0: + selected = add("LoraLoader", model=[selected, 0], clip=[selected, 1], + lora_name=LORAS[1] + ".safetensors", strength_model=-0.15, strength_clip=0.2) + conditioning = encode([selected, 1], LAST_SECTIONS[index]) + references.append(preview([selected, 0], [conditioning, 0])) + plain = encode([checkpoint, 1], LAST_SECTIONS[0]) + unpatched = preview([checkpoint, 0], [plain, 0]) + return graph, source, actual, text, names, references, unpatched + + +def main(): + args = options(__doc__) + assert args.artifacts, "Pass --artifacts to retain the native workflow and results" + graph, source, actual, text, names, references, unpatched = workflow() + results = [] + with browser_session(args) as ui: + ui.page.keyboard.press("Escape") + ui.new_workflow() + ui.load_workflow({"name": "lora-inference-api.json", "mimeType": "application/json", + "buffer": json.dumps(graph).encode()}, len(graph)) + full_zoom(ui) + for node_id, node in graph.items(): + if node["class_type"] != "KSampler": + continue + show_node(ui, node_id) + point = ui.app.evaluate("""(app, id) => { + const node = app.graph.getNodeById(id); + const widget = node.widgets.find(w => w.name === 'control_after_generate'); + return app.canvasPosToClientPos([node.pos[0] + node.size[0] / 2, + node.pos[1] + widget.last_y + 10]); + }""", node_id) + ui.page.mouse.click(*point) + ui.page.get_by_role("menuitem", name="fixed", exact=True).click() + show_node(ui, source) + ui.choose("LoraWildcardSource", "control_after_generate", "fixed") + before = widget_state(ui, source) + saved = args.artifacts / "inference-workflow.json" + export_workflow(ui, saved) + + def check(label, expected): + entry = ui.run() + # Offscreen previews load lazily; fit all outputs into the view. + ui.page.get_by_role("button", name="Fit View (.)", exact=True).click() + images = ui.images(entry) + assert len(images[actual]) == len(expected) + for image, index in zip(images[actual], expected): + reference = images[references[index]][0] + assert image.size == reference.size == (128, 128) + assert image.tobytes() == reference.tobytes(), f"{label}: item {index} differs from core nodes" + assert images[references[0]][0].tobytes() != images[references[1]][0].tobytes() + assert images[references[0]][0].tobytes() != images[unpatched][0].tobytes() + assert entry["outputs"][text]["text"] == [FIRST_SECTION + " BREAK " + LAST_SECTIONS[i] for i in expected] + assert entry["outputs"][names]["text"] == [LORAS[i] for i in expected] + hashes = [hashlib.sha256(image.tobytes()).hexdigest() for image in images[actual]] + for i, image in enumerate(images[actual]): + image.save(args.artifacts / f"{label}-{i}.png") + results.append({"case": label, "expected_entries": expected, "pixel_sha256": hashes}) + print(f"PASS: {label}, {len(expected)} output(s), exact core-node pixel parity", flush=True) + full_zoom(ui) + show_node(ui, source) + + check("batch", [0, 1]) + ui.new_workflow() + ui.load_workflow(saved, len(graph)) + full_zoom(ui) + show_node(ui, source) + assert widget_state(ui, source) == before + check("native-reload", [0, 1]) + ui.choose("LoraWildcardSource", "mode", "fixed") + ui.choose("LoraWildcardSource", "lora_wildcard", LORAS[1]) + check("fixed", [1]) + ui.choose("LoraWildcardSource", "mode", "sequential") + check("sequential", [1]) + ui.choose("LoraWildcardSource", "mode", "random") + check("random", [0]) + (args.artifacts / "results.json").write_text(json.dumps(results, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tests/integration_lora_wildcard_tensors.py b/tests/integration_lora_wildcard_tensors.py new file mode 100644 index 00000000..3afa34ec --- /dev/null +++ b/tests/integration_lora_wildcard_tensors.py @@ -0,0 +1,70 @@ +"""Compare real LoRA patches and conditioning tensors with core ComfyUI nodes. + +Run separately from pytest's stubbed wildcard tests. Requires the same models +as e2e_lora_wildcard_inference.py. Only unused HTTP route registration is +substituted; checkpoint loading, patching and CLIP encoding use real ComfyUI. +""" +import argparse +import gc +from pathlib import Path +import sys +from types import SimpleNamespace + + +def same_values(first, second, torch): + if isinstance(first, torch.Tensor): + return isinstance(second, torch.Tensor) and torch.equal(first, second) + if isinstance(first, (list, tuple)): + return type(first) is type(second) and len(first) == len(second) and all( + same_values(a, b, torch) for a, b in zip(first, second)) + if isinstance(first, dict): + return first.keys() == second.keys() and all(same_values(first[k], second[k], torch) for k in first) + if hasattr(first, "weights"): + return type(first) is type(second) and same_values(first.weights, second.weights, torch) + return first == second + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--comfy-root", type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path[:0] = [str(args.comfy_root.resolve()), str(root / "modules")] + import torch + import nodes + import comfy.model_management + from aiohttp import web + from server import PromptServer + PromptServer.instance = SimpleNamespace(routes=web.RouteTableDef()) + from impact import config, wildcard_source + from e2e_lora_wildcard_inference import BASE, FIRST_SECTION, LAST_SECTIONS, LORAS + + config.cached_config = {"custom_wildcards": str(root / "tests/wildcards/model_samples")} + with torch.inference_mode(): + model, clip, vae = nodes.CheckpointLoaderSimple().load_checkpoint("v1-5-pruned-emaonly-fp16.safetensors") + result = wildcard_source.LoraWildcardSource().build(model, clip, "models", LORAS[0], "batch", 0.6, BASE, 1) + assert [len(output) for output in result] == [2] * 6 + for index, last in enumerate(LAST_SECTIONS): + reference_model, reference_clip = nodes.LoraLoader().load_lora( + model, clip, LORAS[0] + ".safetensors", 0.1, 0.2) + reference_model, reference_clip = nodes.LoraLoader().load_lora( + reference_model, reference_clip, LORAS[index] + ".safetensors", 0.6, 0.6) + if index == 0: + reference_model, reference_clip = nodes.LoraLoader().load_lora( + reference_model, reference_clip, LORAS[1] + ".safetensors", -0.15, 0.2) + first = nodes.CLIPTextEncode().encode(reference_clip, FIRST_SECTION)[0] + second = nodes.CLIPTextEncode().encode(reference_clip, last)[0] + conditioning = nodes.ConditioningConcat().concat(first, second)[0] + assert same_values(result[2][index], conditioning, torch), f"Conditioning differs: {index}" + assert same_values(result[0][index].patches, reference_model.patches, torch), f"MODEL patches differ: {index}" + assert same_values(result[1][index].patcher.patches, reference_clip.patcher.patches, torch), f"CLIP patches differ: {index}" + assert result[3][index] == FIRST_SECTION + " BREAK " + last + print(f"PASS: entry {index}, exact conditioning and all MODEL/CLIP patch tensors and strengths", flush=True) + assert model.patches == {} and clip.patcher.patches == {}, "Input models were mutated" + comfy.model_management.unload_all_models() + del result, reference_model, reference_clip, model, clip, vae + gc.collect() + + +if __name__ == "__main__": + main() diff --git a/tests/wildcards/model_samples/models.txt b/tests/wildcards/model_samples/models.txt new file mode 100644 index 00000000..e8f9633c --- /dev/null +++ b/tests/wildcards/model_samples/models.txt @@ -0,0 +1,2 @@ +petals, , dew, +leaves, , mist diff --git a/tests/wildcards/test_lora_wildcard_source.py b/tests/wildcards/test_lora_wildcard_source.py index 1c490284..aa24ae6c 100644 --- a/tests/wildcards/test_lora_wildcard_source.py +++ b/tests/wildcards/test_lora_wildcard_source.py @@ -17,14 +17,15 @@ from impact import config, wildcards ROOT = Path(__file__).resolve().parents[2] -def test_shipped_examples_preserve_existing_wildcard_results(tmp_path, monkeypatch): +@pytest.mark.parametrize("cache_limit", [0, 50], ids=["on_demand", "full_cache"]) +def test_shipped_examples_preserve_existing_wildcard_results(tmp_path, monkeypatch, cache_limit): bundled = tmp_path / "wildcards" bundled.mkdir() (bundled / "styles.txt").write_text("watercolor\n", encoding="utf-8") custom = tmp_path / "custom_wildcards" custom.mkdir() monkeypatch.setattr(config, "cached_config", { - "custom_wildcards": str(custom), "wildcard_cache_limit_mb": 50, + "custom_wildcards": str(custom), "wildcard_cache_limit_mb": cache_limit, }) monkeypatch.setattr(wildcards, "wildcards_path", str(bundled)) for name in ("wildcard_dict", "available_wildcards", "loaded_wildcards"): @@ -121,7 +122,7 @@ def test_full_entry_and_extra_loras_survive_encoding(source, model_calls): (custom / "styles.txt").write_text("portrait, , __color__, BREAK landscape") result = build(module, base_prompt="base, ") assert model_calls == [("Base.safetensors", 0.2, 0.3), ("A.safetensors", 0.8, 0.8), ("B.safetensors", -0.3, 0.4)] - assert all(word in result[3][0] for word in ("portrait", "blue", "landscape")) + assert all(word in result[3][0] for word in ("portrait", "__color__", "landscape")) assert "", ("A.safetensors", 0.2, 0.2)), + ("", ("Base.safetensors", 0.2, 0.3)), + ("", ("Base.safetensors", 0.01, 0.02)), + ("", ("Base.safetensors", 0.2, 0.3)), +]) +def test_base_loras_keep_original_weights_and_repeated_applications(source, model_calls, tag, expected): + module, custom = source + (custom / "styles.txt").write_text("portrait, \n") + result = build(module, base_prompt="base, " + tag) + assert model_calls == [expected, ("A.safetensors", 0.8, 0.8)] + assert result[0] == [(expected[0], "A.safetensors")] + assert result[1] == result[0] + + +def test_prompt_syntax_remains_literal(source, model_calls): + module, custom = source + (custom / "styles.txt").write_text("portrait, \n") + prompt = "a {red|blue} vase, __color__" + result = build(module, base_prompt=prompt) + assert result[3] == [prompt + ", portrait"] + assert result[2] == [[prompt + ", portrait"]] + + +def test_model_resolution_requires_filename_boundary(source, model_calls, monkeypatch): + import folder_paths + module, custom = source + (custom / "styles.txt").write_text("portrait, \n") + monkeypatch.setattr(folder_paths, "get_filename_list", lambda kind: ["aaa/NotA.safetensors", "zzz/A.safetensors"]) + build(module) + assert model_calls == [("zzz/A.safetensors", 0.8, 0.8)] + + +def test_break_keeps_original_section_punctuation(source, model_calls): + module, custom = source + (custom / "styles.txt").write_text("portrait, \n") + result = build(module, base_prompt="flowers, BREAK sky") + assert result[3] == ["flowers, BREAK sky, portrait"] + assert result[2] == [["flowers,", "sky, portrait"]] + + +@pytest.mark.parametrize("unavailable", ["missing", "permission"]) +def test_unavailable_directory_does_not_break_node_schema(source, monkeypatch, unavailable): + module, custom = source + if unavailable == "missing": + custom.rmdir() + else: + def denied(path): + raise PermissionError(path) + monkeypatch.setattr(module.os, "listdir", denied) + schema = module.LoraWildcardSource.INPUT_TYPES()["required"] + assert schema["category"][0] == ["styles", "poses"] + assert schema["lora_wildcard"][0] == ["(styles.txt not found)"]