Preserve original LoRA behavior and verify real inference
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 `<lora:...>` 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
|
||||
|
||||
|
||||
@@ -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 "<lora:" + ":".join([name, str(strength), str(strength), *parts]) + ">"
|
||||
name = match.group(1).split(":", 1)[0].strip()
|
||||
return f"<lora:{name}:{strength}:{strength}>"
|
||||
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 <lora:NAME:weight> or <lora:NAME:model_w:clip_w> tags,
|
||||
returning the list of LoRAs to load and cleaning the tags from the text."""
|
||||
if not prompt:
|
||||
return [], ""
|
||||
|
||||
pattern = r"<lora:([^>]+)>"
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__, <lora: SD1.5/etc/add_detail : +1e-1 : 2e-1> 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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
petals, <lora:SD1.5/etc/add_detail:0.7>, dew, <lora:SD1.5/etc/detailmaker:-0.15:0.2>
|
||||
leaves, <lora:SD1.5/etc/detailmaker:0.7>, mist
|
||||
@@ -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, <lora:A:0.7>, __color__, <lora:B:-0.3:0.4> BREAK landscape")
|
||||
result = build(module, base_prompt="base, <lora:Base:0.2:0.3>")
|
||||
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 "<lora:" not in result[3][0]
|
||||
assert len(result[2][0]) == 2
|
||||
|
||||
@@ -138,7 +139,7 @@ def test_empty_category_still_processes_base_prompt_loras(source, model_calls):
|
||||
module, _ = source
|
||||
result = build(module, base_prompt="__color__, <lora:Base:0.2:0.3>")
|
||||
assert model_calls == [("Base.safetensors", 0.2, 0.3)]
|
||||
assert "blue" in result[3][0] and "<lora:" not in result[3][0]
|
||||
assert result[3] == ["__color__"]
|
||||
|
||||
|
||||
def test_pose_category_uses_the_same_file_in_ui_and_execution(source, model_calls):
|
||||
@@ -199,3 +200,58 @@ def test_input_names_and_order_remain_compatible(source):
|
||||
assert list(module.LoraWildcardSource.INPUT_TYPES()["required"]) == [
|
||||
"model", "clip", "category", "lora_wildcard", "mode", "strength", "base_prompt", "seed",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag,expected", [
|
||||
("<lora:A:0.2>", ("A.safetensors", 0.2, 0.2)),
|
||||
("<lora: Base : 0.2 : 0.3>", ("Base.safetensors", 0.2, 0.3)),
|
||||
("<lora:Base:1e-2:2e-2>", ("Base.safetensors", 0.01, 0.02)),
|
||||
("<lora:Base:+0.2:+0.3>", ("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, <lora:A:0.7>\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, <lora:A:0.7>\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, <lora:A:0.7>\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, <lora:A:0.7>\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)"]
|
||||
|
||||
Reference in New Issue
Block a user