391 lines
13 KiB
Python
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 |