Files
ltdrdata-ComfyUI-Impact-Pack/modules/impact/wildcard_source.py
T

282 lines
11 KiB
Python

import os
import re
import random
import logging
import folder_paths
import nodes
from aiohttp import web
from server import PromptServer
from impact import config, wildcards
_LORA_RE = re.compile(r"<lora:([^>]+)>")
# Keep labels and strength targets from workflows saved by the incoming node.
_LEGACY_LORA_RE = re.compile(r"<lora:([^:>]+):([\d.]+)\s*>")
def achar_arquivo_wildcard(nome_arquivo):
if "/" in nome_arquivo or "\\" in nome_arquivo:
return None
names = [nome_arquivo]
stem, _ = os.path.splitext(nome_arquivo)
if stem in ("style", "styles", "estilo", "estilos_arte"):
names.extend(["styles.txt", "estilos_arte.txt"])
elif stem in ("pose", "poses"):
names.extend(["poses.txt", "pose.txt"])
root = os.path.realpath(config.get_config()["custom_wildcards"])
for name in dict.fromkeys(names):
path = os.path.realpath(os.path.join(root, name))
try:
contained = os.path.commonpath((root, path)) == root
except ValueError: # Windows links can resolve to a different drive/share.
continue
if contained and os.path.isfile(path):
return path
return None
def parse_linhas_wildcard(caminho):
if not caminho:
return []
existing, added = [], []
for line in wildcards.load_txt_wildcard(caminho):
legacy_match = _LEGACY_LORA_RE.search(line)
match = legacy_match or _LORA_RE.search(line)
if match is None:
continue
name = match.group(1).split(":", 1)[0].strip()
if not name:
continue
(existing if legacy_match else added).append((line.strip(), name))
entries, counts, used = [], {}, set()
# Allocate every old label first. Newly supported rows cannot take an old
# row's name, duplicate suffix, or position in the original option list.
for group in (existing, added):
for line, name in group:
count = counts.get(name, 0)
label = f"{name} ({count})" if count else name
while group is added and label in used:
count += 1
label = f"{name} ({count})"
counts[name] = count + 1
used.add(label)
entries.append((label, line, name))
return entries
def listar_categorias_wildcard():
root = config.get_config()["custom_wildcards"]
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):
match = _LEGACY_LORA_RE.search(entry) or _LORA_RE.search(entry)
if match is None:
return entry
name = match.group(1).split(":", 1)[0].strip()
return entry[:match.start()] + f"<lora:{name}:{strength}:{strength}>" + entry[match.end():]
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")
# 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."""
# The shared wildcard parser deduplicates tags and accepts different weights.
# Keep this node's original repeated-tag and float() semantics.
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
def nome_arquivo_limpo(nome, fallback="no_lora"):
"""Cleans and formats the LoRA filename for safe usage in image saving prefixes."""
n = nome.replace("\\", "/").split("/")[-1]
n = re.sub(r'[<>:"/\\|?*\[\],]', "", n)
n = re.sub(r"\s+", "_", n).strip("_")
return n or fallback
@PromptServer.instance.routes.get("/impact/get_items")
async def get_items_handler(request):
category = request.query.get("category", request.query.get("categoria", ""))
entries = parse_linhas_wildcard(achar_arquivo_wildcard(f"{category}.txt"))
return web.json_response([entry[0] for entry in entries])
class LoraWildcardSource:
@classmethod
def INPUT_TYPES(cls):
# Scan wildcard text files from custom_wildcards directory dynamically
categorias = listar_categorias_wildcard()
primeira_categoria = categorias[0] if categorias else "styles"
# Default fallback to first available category file list
caminho = achar_arquivo_wildcard(primeira_categoria + ".txt")
entradas = parse_linhas_wildcard(caminho)
labels_padrao = [e[0] for e in entradas] or [f"({primeira_categoria}.txt not found)"]
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"category": (categorias,),
"lora_wildcard": (labels_padrao,),
"mode": (["fixed", "random", "sequential", "batch"],),
"strength": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 2.0, "step": 0.05}),
"base_prompt": ("STRING", {
"multiline": True,
"default": "masterpiece, best quality, very aesthetic, absurdres, 1girl",
}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
},
}
RETURN_TYPES = ("MODEL", "CLIP", "CONDITIONING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("model", "clip", "conditioning", "text", "lora_name", "name")
OUTPUT_IS_LIST = (True, True, True, True, True, True)
FUNCTION = "build"
CATEGORY = "Impact Pack/Wildcard"
DESCRIPTION = ("Generic LoRA wildcard source: fixed / random / sequential / batch. "
"Select category (wildcard text file) and the corresponding LoRA wildcard. "
"The node automatically applies the LoRAs and encodes the prompt.")
@classmethod
def VALIDATE_INPUTS(cls, category, lora_wildcard):
# Dynamic choices may differ from INPUT_TYPES' initial directory scan.
# Leave mode and numeric constraints to ComfyUI's standard validation.
return True
def build(self, model, clip, category, lora_wildcard, mode, strength, base_prompt, seed):
entries = parse_linhas_wildcard(achar_arquivo_wildcard(f"{category}.txt"))
if mode in ("random", "sequential", "batch") or not any(entry[0] == lora_wildcard for entry in entries):
# Extended syntax requires an explicit fixed selection. Keep the
# original candidates for saved seeds, batch counts and empty selections.
entries = [entry for entry in entries if _LEGACY_LORA_RE.search(entry[1])]
if not entries:
loras, text = [], base_prompt
if _LORA_RE.search(base_prompt):
loras, text = extrair_e_limpar_loras(base_prompt)
result_model, result_clip, conditioning = aplicar_loras_e_codificar(model, clip, loras, text)
return ([result_model], [result_clip], [conditioning], [text], ["no_lora"], [f"no_{category}"])
if mode == "batch":
selected = entries
elif mode == "random":
selected = [random.Random(seed).choice(entries)]
elif mode == "sequential":
selected = [entries[seed % len(entries)]]
else:
entry = next((entry for entry in entries if entry[0] == lora_wildcard), None)
if entry is None:
raise ValueError(f"LoRA wildcard '{lora_wildcard}' not found in category '{category}'")
selected = [entry]
outputs = tuple([] for _ in self.RETURN_TYPES)
for label, entry, name in selected:
parts = []
if base_prompt and base_prompt.strip():
parts.append(base_prompt.strip().rstrip(","))
parts.append(entry_with_strength(entry, strength))
prompt = ", ".join(parts)
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