Files
JasonHoku-ComfyUI-Ultimate-…/config_utils.py
T

391 lines
13 KiB
Python

import json
import re
import itertools
import folder_paths
import comfy.samplers
import itertools
def parse_prompt_input_nested(prompt_input: str):
"""
Parse prompt input that supports plain text, single-level, and nested arrays.
Creates Cartesian products from nested arrays.
Args:
prompt_input: Plain text string OR JSON string containing prompts
Returns:
List of final prompt strings
Examples:
Plain text: "my prompt" -> ["my prompt"]
Plain text with quotes: '"my prompt"' -> ["my prompt"]
Simple: '["a", "b", "c"]' -> ["a", "b", "c"]
Nested: '[["a", "b"], ["1", "2"]]' -> ["a, 1", "a, 2", "b, 1", "b, 2"]
"""
import json
import itertools
# Handle None or empty string input
if not prompt_input or prompt_input.strip() == "":
return [""]
# Strip whitespace
prompt_input = prompt_input.strip()
# Try to parse as JSON first
try:
parsed = json.loads(prompt_input)
except (json.JSONDecodeError, ValueError):
# Not valid JSON - treat as plain text
# Remove quotes if they exist (user might have typed "my prompt")
if (prompt_input.startswith('"') and prompt_input.endswith('"')) or \
(prompt_input.startswith("'") and prompt_input.endswith("'")):
prompt_input = prompt_input[1:-1]
return [prompt_input]
# Handle empty parsed result
if not parsed:
return [""]
# If it's a single string from JSON
if isinstance(parsed, str):
return [parsed]
# Check if this is a nested structure (has any lists inside)
has_nested = any(isinstance(item, list) for item in parsed)
if not has_nested:
# Simple list of strings: ["a", "b", "c"]
return [str(item) for item in parsed]
# Nested structure - expand to Cartesian product
normalized = []
for item in parsed:
if isinstance(item, list):
normalized.append([str(x) for x in item])
else:
normalized.append([str(item)])
# Generate Cartesian product
combinations = list(itertools.product(*normalized))
# Join each combination with ", "
result = [", ".join(combo) for combo in combinations]
return result
def normalize_str(s):
"""Normalize string paths by replacing backslashes with forward slashes"""
if isinstance(s, str):
return s.replace("\\", "/").strip()
return s
def parse_json_with_error(json_str, name):
"""Parse JSON string with helpful error messages"""
try:
return json.loads(json_str.strip())
except json.JSONDecodeError as e:
raise ValueError(f"JSON Error in {name}: {e}")
def parse_float_input(input_str):
"""Parse float input that could be JSON array, comma-separated, or single value"""
try:
val = json.loads(input_str)
if isinstance(val, list):
return [float(x) for x in val]
return [float(val)]
except:
try:
if "," in input_str:
return [float(x.strip()) for x in input_str.split(",")]
return [float(input_str)]
except:
return [1.0]
def parse_string_input(input_str):
"""Parse string input that could be JSON array or single value"""
try:
val = json.loads(input_str.strip())
if isinstance(val, list):
return [str(x) for x in val]
return [str(val)]
except:
return [input_str]
def get_files_from_folder(input_string, type_key):
"""
Get files from a folder path, or return the input if it's a single file.
Args:
input_string: Path to file or folder (folder ends with /)
type_key: Type of files to search for (e.g., "checkpoints", "loras")
Returns:
List of file paths
"""
input_norm = input_string.replace("\\", "/")
if ":" in input_norm:
input_norm = input_norm.split(":")[0]
if not input_norm.endswith("/"):
return [input_string]
target_folder = input_norm.rstrip("/")
all_files = folder_paths.get_filename_list(type_key)
found = []
for f in all_files:
f_norm = f.replace("\\", "/")
if f_norm.startswith(target_folder + "/"):
found.append(f)
if not found:
print(f"[GridTester] Warning: No files found in folder '{input_string}' for type '{type_key}'")
return []
return found
def parse_lora_definition(lora_string):
"""
Parse LoRA definition string into list of (name, model_str, clip_str) tuples.
Format: "lora1.safetensors:1.0:0.8 + lora2.safetensors:0.5:0.5"
If no strength is specified, defaults to 1.0 for both model and CLIP.
Args:
lora_string: String defining LoRAs with optional strengths
Returns:
List of (name, model_strength, clip_strength) tuples
"""
if lora_string == "None":
return []
definitions = []
parts = lora_string.split(" + ")
for part in parts:
part = part.strip()
if ":" in part:
segments = part.split(":")
name = segments[0].strip()
m_str = float(segments[1]) if len(segments) > 1 else 1.0
c_str = float(segments[2]) if len(segments) > 2 else m_str # Default CLIP to same as model
definitions.append((name, m_str, c_str))
else:
# No strength specified, default to 1.0 for both
definitions.append((part, 1.0, 1.0))
return definitions
def expand_lora_stack(lora_input):
"""
Expand LoRA stacks with folder support.
Handles formats like:
- "lora.safetensors"
- "lora.safetensors:0.8:0.6"
- "lora1:0.8:0.6 + lora2:1.0:1.0"
- "folder/ + lora.safetensors" (expands each LoRA individually)
- "folder/* + lora.safetensors" (stacks ALL LoRAs in folder together)
- "folder/:0.8:0.6 + lora.safetensors"
- "folder/*:0.8:0.6" (stacks ALL LoRAs with same strength)
Args:
lora_input: LoRA specification string or list
Returns:
List of expanded LoRA stack strings
"""
def to_list(x):
return x if isinstance(x, list) else [x]
raw_loras = to_list(lora_input)
expanded_loras = []
for l in raw_loras:
if l == "None":
expanded_loras.append("None")
continue
stack_parts = l.split(" + ")
expanded_parts = []
for part in stack_parts:
if ":" in part:
p_split = part.split(":", 1)
base_path = p_split[0].strip()
args = ":" + p_split[1].strip()
else:
base_path = part.strip()
args = ""
norm_path = base_path.replace("\\", "/")
# Check for folder/* syntax (stack all together)
if norm_path.endswith("/*") or (norm_path.endswith("*") and "/" in norm_path):
# Remove the /* or * suffix
folder_path = norm_path.rstrip("/*").rstrip("*").rstrip("/")
found_files = get_files_from_folder(folder_path + "/", "loras")
if found_files:
# Stack ALL files together as a single entry (not separate combinations)
stacked = " + ".join([f"{f}{args}" for f in found_files])
expanded_parts.append([stacked])
print(f"[GridTester] 📚 Folder/* syntax: Stacking {len(found_files)} LoRAs from '{folder_path}' together")
else:
print(f"[GridTester] ⚠️ No LoRAs found in folder: {folder_path}")
expanded_parts.append([])
# Check for regular folder/ syntax (expand individually)
elif norm_path.endswith("/"):
found_files = get_files_from_folder(base_path, "loras")
expanded_parts.append([f"{f}{args}" for f in found_files])
else:
expanded_parts.append([part])
# Skip if any part expanded to empty
if not all(expanded_parts):
continue
for combo in itertools.product(*expanded_parts):
expanded_loras.append(" + ".join(combo))
return expanded_loras
def expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, extra_seeds, ckpt_name=None):
"""
Expand raw config entries into full configuration combinations.
Args:
raw_configs: List of config dictionaries with potentially wildcarded values
pos_prompts: List of positive prompts
neg_prompts: List of negative prompts
denoise_values: List of denoise values
seed: Base seed value
extra_seeds: List of additional random seeds
ckpt_name: Checkpoint name from node input (used as default when model is "Default")
Returns:
List of fully expanded config dictionaries
"""
ALL_SCHEDULERS = comfy.samplers.KSampler.SCHEDULERS
ALL_SAMPLERS = comfy.samplers.KSampler.SAMPLERS
expanded = []
# Determine prompt pairing strategy
if len(pos_prompts) > 1 and len(neg_prompts) > 1 and len(pos_prompts) == len(neg_prompts):
print("[GridTester] Detected matching prompt lists. Using 1-to-1 Pairing.")
prompt_pairs = list(zip(pos_prompts, neg_prompts))
else:
prompt_pairs = list(itertools.product(pos_prompts, neg_prompts))
def to_list(x):
return x if isinstance(x, list) else [x]
for entry in raw_configs:
# Expand wildcards
samplers = ALL_SAMPLERS if entry.get("sampler") == "*" else to_list(entry.get("sampler", "euler"))
schedulers = ALL_SCHEDULERS if entry.get("scheduler") == "*" else to_list(entry.get("scheduler", "normal"))
steps_l = to_list(entry.get("steps", 20))
cfgs = to_list(entry.get("cfg", 7.0))
clip_skips = to_list(entry.get("clip_skip", 0))
# Expand model folders
raw_models = to_list(entry.get("model", "Default"))
expanded_models = []
for m in raw_models:
if m == "Default":
# Use the checkpoint name from node input instead of "Default"
if ckpt_name:
expanded_models.append(ckpt_name)
else:
expanded_models.append("Default")
else:
expanded_models.extend(get_files_from_folder(m, "checkpoints"))
# Expand LoRA stacks (strengths are now in the lora string itself)
expanded_loras = expand_lora_stack(entry.get("lora", "None"))
# Get LoRA trigger word omissions (if specified)
lora_omit_triggers = entry.get("lora_omit_triggers", [])
if not isinstance(lora_omit_triggers, list):
lora_omit_triggers = [lora_omit_triggers]
# Build all combinations
base_combos = []
for combo in itertools.product(samplers, schedulers, steps_l, cfgs, clip_skips, expanded_loras,
denoise_values, prompt_pairs, expanded_models):
base_combos.append({
"sampler": combo[0],
"scheduler": combo[1],
"steps": combo[2],
"cfg": combo[3],
"clip_skip": combo[4],
"lora": combo[5],
"denoise": combo[6],
"positive": combo[7][0],
"negative": combo[7][1],
"model": combo[8],
"seed": seed,
"lora_omit_triggers": lora_omit_triggers
})
# Apply base seed and extra seeds
for c in base_combos:
expanded.append(c)
for extra_seed in extra_seeds:
new_c = c.copy()
new_c["seed"] = extra_seed
expanded.append(new_c)
return expanded
def prepare_input_jobs(optional_latent, resolutions):
"""
Prepare input jobs from either optional latent or resolution list.
Args:
optional_latent: Optional latent tensor input
resolutions: List of [width, height] pairs
Returns:
List of job dictionaries with label, width, height, latent, batch_idx
"""
input_jobs = []
if optional_latent is not None:
batch_count = optional_latent["samples"].shape[0]
for i in range(batch_count):
single_sample = optional_latent["samples"][i].unsqueeze(0)
input_jobs.append({
"label": f"Input {i+1}",
"width": single_sample.shape[3] * 8,
"height": single_sample.shape[2] * 8,
"latent": {"samples": single_sample},
"batch_idx": i
})
else:
for res in resolutions:
input_jobs.append({
"label": f"{res[0]}x{res[1]}",
"width": res[0],
"height": res[1],
"latent": None,
"batch_idx": 0
})
return input_jobs
def sanitize_session_name(session_name):
"""Sanitize session name to be filesystem-safe"""
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name:
session_name = "default_session"
return session_name