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

747 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Generation Orchestrator - Main Entry Point
Coordinates the entire grid generation workflow
"""
import os
import json
import time
import re
import gc
import torch
import hashlib
import folder_paths
import comfy.sd # Required for async workers
from .trigger_words import collect_unique_prompts_with_triggers, build_prompt_with_triggers
from .batch_encoding import batch_encode_prompts
from .manifest_utils import load_existing_manifest, save_manifest
from .model_loader import (
load_checkpoint, load_loras, cleanup_model_references,
get_latent_channels, load_loras_for_preencoding,
print_incompatible_loras_summary
)
from .lora_utils import expand_lora_folder
from .image_generation import (
generate_image, flush_batch_with_vae, flush_batch_with_remote_vae,
create_image_metadata, calculate_eta, print_generation_progress
)
from .config_utils import sanitize_session_name
from .html_generator import get_html_template
from .conditioning_cache import ConditioningCache
from .remote_vae import RemoteVAEDecodeWorker, HF_ENDPOINTS
try:
from server import PromptServer
except ImportError:
PromptServer = None
def setup_session_directories(session_name):
"""Create session directories and return paths."""
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
img_dir = os.path.join(base_dir, "images")
manifest_path = os.path.join(base_dir, "manifest.json")
os.makedirs(base_dir, exist_ok=True)
os.makedirs(img_dir, exist_ok=True)
return {
"base": base_dir,
"images": img_dir,
"manifest": manifest_path
}
def initialize_remote_vae(remote_vae_endpoint, img_dir, manifest_path, existing_data, session_name, unique_id):
"""Initialize remote VAE worker if enabled."""
if not remote_vae_endpoint or remote_vae_endpoint == "None":
return None
if remote_vae_endpoint in ["SD", "SDXL", "Flux", "HunyuanVideo"]:
actual_endpoint = HF_ENDPOINTS.get(remote_vae_endpoint)
print(f"[GridTester] 🌐 Using {remote_vae_endpoint} endpoint: {actual_endpoint}")
elif remote_vae_endpoint == "Auto (Experimental)":
print(f"[GridTester] 🌐 Auto mode selected - worker will initialize on first flush")
return None
else:
actual_endpoint = remote_vae_endpoint
print(f"[GridTester] 🌐 Using custom endpoint: {actual_endpoint}")
worker = RemoteVAEDecodeWorker(
endpoint=actual_endpoint,
img_dir=img_dir,
manifest_path=manifest_path,
existing_data=existing_data,
session_name=session_name,
unique_id=unique_id
)
print(f"[GridTester] 🌐 Remote VAE worker started")
return worker
def calculate_clip_hash(clip_model):
"""Calculate a hash of the CLIP model for cache validation."""
try:
if hasattr(clip_model, 'state_dict'):
state_dict = clip_model.state_dict()
model_signature = str([(k, tuple(v.shape)) for k, v in list(state_dict.items())[:10]])
elif hasattr(clip_model, 'cond_stage_model'):
model_signature = str(type(clip_model.cond_stage_model))
else:
model_signature = str(type(clip_model))
return hashlib.md5(model_signature.encode()).hexdigest()[:16]
except:
return "unknown"
def check_if_job_completed(existing_items, conf, seed, width, height, batch_idx, positive_prompt, negative_prompt):
"""Independent check to see if a specific generation job already exists."""
FLOAT_TOLERANCE = 0.0001
for idx, item in enumerate(existing_items):
if item.get("seed") != seed: continue
if item.get("width") != width: continue
if item.get("height") != height: continue
if item.get("batch_idx", 0) != batch_idx: continue
if item.get("model") != conf["model"]: continue
if item.get("sampler") != conf["sampler"]: continue
if item.get("scheduler") != conf["scheduler"]: continue
try:
if abs(float(item.get("steps")) - float(conf["steps"])) > FLOAT_TOLERANCE: continue
if abs(float(item.get("cfg")) - float(conf["cfg"])) > FLOAT_TOLERANCE: continue
if abs(float(item.get("denoise")) - float(conf["denoise"])) > FLOAT_TOLERANCE: continue
except (ValueError, TypeError):
continue
if item.get("positive", "").strip() != positive_prompt.strip(): continue
if item.get("negative", "").strip() != negative_prompt.strip(): continue
item_lora = item.get("lora", "None")
conf_lora = conf.get("lora_expanded", "None")
if item_lora != conf_lora: continue
return idx
return -1
def run_generation_loop(
self,
ckpt_name, positive_text, negative_text, seed, denoise, vae_batch_size,
overwrite_existing, flush_batch_every, configs_json, resolutions_json,
session_name, unique_id, add_random_seeds_to_gens, lora_triggerwords_mode,
remote_vae_endpoint, save_conditioning_cache_to_file, enable_model_cache,
optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, optional_latent
):
"""Main generation loop orchestrator."""
from .model_cache import ModelCache
# Initialize Model Cache (or disable completely if user requested)
if enable_model_cache:
model_cache = ModelCache(
max_models=1, # Adjust for your VRAM
max_lora_sets=2, # Adjust for your VRAM
max_lora_files=30, # Adjust for your VRAM
enable_preload=True, # Preloading enabled when cache is on
async_preload=True, # Always async when preloading
cache_device='cpu',
verbose=True
)
else:
# Completely disable entire cache system
print("[GridTester] ⚠️ Model cache DISABLED - all models will load from disk (slower but saves RAM/VRAM)")
model_cache = None
# ==== SETUP ====
session_name = sanitize_session_name(session_name)
paths = setup_session_directories(session_name)
existing_data = load_existing_manifest(paths["manifest"])
existing_data["session_name"] = session_name
from .config_utils import (
parse_json_with_error, parse_float_input, parse_prompt_input_nested,
expand_configs, prepare_input_jobs
)
raw_configs = parse_json_with_error(configs_json, "configs")
denoise_values = parse_float_input(denoise)
resolutions = parse_json_with_error(resolutions_json, "resolutions")
pos_prompts = parse_prompt_input_nested(positive_text)
neg_prompts = parse_prompt_input_nested(negative_text)
extra_seeds = []
if add_random_seeds_to_gens > 0:
import random
extra_seeds = [random.randint(0, 2**32 - 1) for _ in range(add_random_seeds_to_gens)]
expanded = expand_configs(raw_configs, pos_prompts, neg_prompts, denoise_values, seed, extra_seeds, ckpt_name)
expanded.sort(key=lambda x: (x['model'], x['lora'], x['positive'], x['negative']))
# ==== EXPAND LORA FOLDERS ====
print(f"[GridTester] 🎲 Expanding LoRA folders and random selections...")
for conf in expanded:
if conf["lora"] != "None":
lora_parts = conf["lora"].split(" + ")
expanded_parts = []
for part in lora_parts:
part = part.strip()
if "[" in part and "]" in part:
expanded_lora = expand_lora_folder(part, seed=conf.get("seed"))
if expanded_lora:
if isinstance(expanded_lora, list):
expanded_parts.extend(expanded_lora)
else:
expanded_parts.append(expanded_lora)
else:
expanded_parts.append(part)
conf["lora_expanded"] = " + ".join(expanded_parts) if expanded_parts else "None"
conf["lora"] = conf["lora_expanded"]
else:
conf["lora_expanded"] = "None"
print(f"[GridTester] ✅ LoRA expansion complete")
# ==== REGISTER SMART CACHE SCHEDULE ====
if model_cache:
model_cache.register_schedule(expanded)
input_jobs = prepare_input_jobs(optional_latent, resolutions)
total_jobs = len(expanded) * len(input_jobs)
print(f"{'='*80}")
print(f"[GridTester] 🚀 GENERATION START")
print(f"[GridTester] 📋 {len(expanded)} configs × {len(input_jobs)} resolutions = {total_jobs} total jobs")
print(f"{'='*80}")
# ==== OPTIONAL CONDITIONING SETUP ====
if optional_positive or optional_negative:
pos_hash = hashlib.md5(str(optional_positive).encode()).hexdigest()[:16] if optional_positive else None
neg_hash = hashlib.md5(str(optional_negative).encode()).hexdigest()[:16] if optional_negative else None
else:
pos_hash, neg_hash = None, None
# ==== REMOTE VAE SETUP ====
use_remote_vae = remote_vae_endpoint and remote_vae_endpoint != "None"
try:
if PromptServer is not None:
pbar = PromptServer.instance.progress_bar_pool.get_progress_bar(unique_id)
else:
pbar = None
except:
pbar = None
# ==== STATE VARIABLES ====
loaded_model, loaded_clip, loaded_vae = None, None, None
patched_model, patched_clip = None, None
cached_model_key = None
cached_lora_key = None
cached_lora_cache_key = None
conditioning_cache = {"positive": {}, "negative": {}}
incompatible_loras = {}
pending_batch = []
current_job = 0
total_generated = 0
skipped_count = 0
job_durations = []
eta_start_time = time.time()
# Initialize remote VAE worker
remote_vae_worker = None
if use_remote_vae and expanded:
remote_vae_worker = initialize_remote_vae(
remote_vae_endpoint,
paths["images"],
paths["manifest"],
existing_data,
session_name,
unique_id
)
# ==== PRE-ENCODING STAGE ====
unique_models = set(conf["model"] for conf in expanded)
if not (optional_positive and optional_negative) and expanded and len(unique_models) == 1:
first_conf = expanded[0]
target_model_name = first_conf["model"]
print(f"[GridTester] ✅ Single model detected ({target_model_name}) - enabling pre-encoding")
loaded_model, loaded_clip, loaded_vae = load_checkpoint(
target_model_name, ckpt_name, use_remote_vae,
optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, None, None, model_cache=model_cache
)
if first_conf["lora_expanded"] != "None":
patched_model, patched_clip = load_loras_for_preencoding(
loaded_model, loaded_clip, first_conf["lora_expanded"]
)
else:
patched_model, patched_clip = loaded_model, loaded_clip
clip_hash = calculate_clip_hash(patched_clip)
cond_cache = ConditioningCache(
cache_dir=paths["base"],
clip_hash=clip_hash,
enable_disk_cache=save_conditioning_cache_to_file)
print(f"[GridTester] 🧠 Collecting unique prompts...")
unique_positives, unique_negatives = collect_unique_prompts_with_triggers(
expanded, lora_triggerwords_mode
)
clip_skip = first_conf.get("clip_skip", 0)
if clip_skip != 0:
print(f"[GridTester] 🔧 Using clip_skip={clip_skip}")
conditioning_cache = batch_encode_prompts(
patched_clip, unique_positives, unique_negatives, cond_cache, clip_skip, enable_disk_cache=save_conditioning_cache_to_file
)
cached_model_key = target_model_name
cached_lora_key = first_conf["lora_expanded"]
if model_cache:
cached_lora_cache_key = model_cache._get_lora_cache_key(target_model_name, first_conf["lora_expanded"])
else:
cached_lora_cache_key = None
latent_channels = get_latent_channels(loaded_model, optional_latent)
elif len(unique_models) > 1:
print(f"[GridTester] ⚠️ Multiple models detected ({len(unique_models)} different models) - pre-encoding DISABLED")
cond_cache = None
latent_channels = 4
else:
print(f"[GridTester] ℹ️ Using optional conditioning, skipping pre-encoding")
cond_cache = None
latent_channels = 4
# ==== MAIN GENERATION LOOP ====
print(f"\n{'='*80}\n")
for job_idx, job in enumerate(input_jobs):
w, h = job["width"], job["height"]
batch_idx = job["batch_idx"]
for conf_idx, conf in enumerate(expanded):
# ==== CHECK FOR INTERRUPT ====
# ==== UPDATE CURRENT STEP ====
if model_cache:
model_cache.set_current_step(conf_idx)
try:
import comfy.model_management as mm
if mm.processing_interrupted():
print(f"\n[GridTester] 🛑 INTERRUPTED - Stopping all jobs")
if pending_batch:
if use_remote_vae:
flush_batch_with_remote_vae(pending_batch, remote_vae_worker, existing_data, session_name)
else:
flush_batch_with_vae(pending_batch, loaded_vae, paths["images"], existing_data, session_name, paths["manifest"], unique_id)
pending_batch = []
if remote_vae_worker:
remote_vae_worker.wait_completion()
remote_vae_worker.stop()
existing_data["meta"] = {
"positive": positive_text,
"negative": negative_text,
"model": ckpt_name,
"seed": seed,
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json
}
save_manifest(paths["manifest"], existing_data)
loaded_model, loaded_clip, loaded_vae = None, None, None
patched_model, patched_clip = None, None
conditioning_cache.clear()
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
html = get_html_template(session_name, existing_data, unique_id)
return (html,)
except:
pass
current_seed = conf["seed"]
if conf.get("seed_behavior") == "randomize":
import random
current_seed = random.randint(0, 2**63 - 1)
current_job += 1
if pbar:
try:
pbar.update_absolute(current_job, total_jobs)
except:
pass
progress_pct = int((current_job / total_jobs) * 100)
print(f"[GridTester] 📊 {current_job}/{total_jobs} ({progress_pct}%) | "
f"{conf['sampler']} @ {conf['steps']} steps | {w}x{h}")
actual_positive_prompt, lora_triggers = build_prompt_with_triggers(
conf, lora_triggerwords_mode
)
actual_negative_prompt = conf["negative"]
# ==== CHECK EXISTING MATCH ====
match_index = check_if_job_completed(
existing_data["items"],
conf,
current_seed,
w, h,
batch_idx,
actual_positive_prompt,
actual_negative_prompt
)
if match_index != -1:
if not overwrite_existing:
skipped_count += 1
continue
else:
old_item = existing_data["items"][match_index]
try:
old_fname_match = re.search(r'filename=([^&]+)', old_item["file"])
if old_fname_match:
old_file_path = os.path.join(paths["images"], old_fname_match.group(1))
if os.path.exists(old_file_path):
os.remove(old_file_path)
except Exception as e:
print(f"[GridTester] ⚠️ Warning: Could not delete old file: {e}")
existing_data["items"].pop(match_index)
# ==== MODEL SWITCHING ====
target_model_name = conf["model"]
if target_model_name != cached_model_key:
if cached_model_key is not None:
patched_model, patched_clip = cleanup_model_references(
patched_model, patched_clip, conditioning_cache
)
# CRITICAL FIX: Restore dict structure after clear
conditioning_cache["positive"] = {}
conditioning_cache["negative"] = {}
loaded_model, loaded_clip, loaded_vae = load_checkpoint(
target_model_name, ckpt_name, use_remote_vae,
optional_model, optional_clip, optional_vae,
optional_positive, optional_negative, loaded_clip, loaded_vae, model_cache=model_cache
)
cached_model_key = target_model_name
cached_lora_key = None
cached_lora_cache_key = None
latent_channels = get_latent_channels(loaded_model, optional_latent)
model_switched = True
else:
model_switched = False
# ==== LORA SWITCHING ====
current_lora_string = conf["lora_expanded"]
if model_cache:
current_cache_key = model_cache._get_lora_cache_key(target_model_name, current_lora_string)
need_to_load = (current_cache_key != cached_lora_cache_key) or patched_model is None
else:
need_to_load = (current_lora_string != cached_lora_key) or patched_model is None
if need_to_load:
patched_model, patched_clip, should_skip = load_loras(
loaded_model, loaded_clip, current_lora_string,
target_model_name, incompatible_loras, model_cache=model_cache
)
if should_skip:
skipped_count += 1
continue
cached_lora_key = current_lora_string
if model_cache:
cached_lora_cache_key = current_cache_key
# Batch encode logic for multi-model switching
# Because of short-circuit, if model_switched is True, conditioning_cache['positive'] isn't checked initially
# But inside the loop, we access it.
if model_switched or not conditioning_cache["positive"]:
model_unique_positives = set()
model_unique_negatives = set()
for future_idx in range(conf_idx, len(expanded)):
future_conf = expanded[future_idx]
if future_conf["model"] == target_model_name:
future_positive, _ = build_prompt_with_triggers(
future_conf, lora_triggerwords_mode
)
model_unique_positives.add(future_positive)
model_unique_negatives.add(future_conf["negative"])
if model_unique_positives:
print(f"[GridTester] 🧠 Batch encoding {len(model_unique_positives)} prompts for {target_model_name}")
import comfy.model_management as mm_batch
mm_batch.load_models_gpu([patched_clip.patcher], force_patch_weights=True)
clip_skip = conf.get("clip_skip", 0)
for prompt in model_unique_positives:
# print(prompt)
if prompt not in conditioning_cache["positive"]:
original_layer = None
if clip_skip != 0 and hasattr(patched_clip.cond_stage_model, 'clip_layer'):
original_layer = patched_clip.cond_stage_model.clip_layer
patched_clip.cond_stage_model.set_clip_options({"layer": clip_skip})
tokens = patched_clip.tokenize(prompt)
cond, pooled = patched_clip.encode_from_tokens(tokens, return_pooled=True)
conditioning_cache["positive"][prompt] = [[cond, {"pooled_output": pooled}]]
if original_layer is not None:
patched_clip.cond_stage_model.set_clip_options({"layer": original_layer})
for prompt in model_unique_negatives:
if prompt not in conditioning_cache["negative"]:
original_layer = None
if clip_skip != 0 and hasattr(patched_clip.cond_stage_model, 'clip_layer'):
original_layer = patched_clip.cond_stage_model.clip_layer
patched_clip.cond_stage_model.set_clip_options({"layer": clip_skip})
tokens = patched_clip.tokenize(prompt)
cond, pooled = patched_clip.encode_from_tokens(tokens, return_pooled=True)
conditioning_cache["negative"][prompt] = [[cond, {"pooled_output": pooled}]]
if original_layer is not None:
patched_clip.cond_stage_model.set_clip_options({"layer": original_layer})
print(f"[GridTester] ✅ Encoded {len(conditioning_cache['positive'])} positive, {len(conditioning_cache['negative'])} negative")
model_switched = False
if cond_cache:
cond_cache.set_lora_config(conf['lora_expanded'])
# Get conditioning
if optional_positive:
final_positive = optional_positive
else:
full_positive = actual_positive_prompt
final_positive = conditioning_cache["positive"].get(full_positive)
if final_positive is None:
raise RuntimeError(f"[GridTester] ❌ BUG: Encoding not found for: {full_positive[:50]}...")
if optional_negative:
final_negative = optional_negative
else:
final_negative = conditioning_cache["negative"].get(conf["negative"])
if final_negative is None:
raise RuntimeError(f"[GridTester] ❌ BUG: Encoding not found for: {conf['negative'][:50]}...")
# =========================================================
# ==== 🚀 ASYNC LOOK-AHEAD PRE-FETCHING (CORRECTED) ====
# =========================================================
if model_cache is not None and model_cache.async_preload:
current_overall_index = (job_idx * len(expanded)) + conf_idx
# Check if next job exists
if current_overall_index + 1 < total_jobs:
next_idx = current_overall_index + 1
next_job_idx = next_idx // len(expanded)
next_conf_idx = next_idx % len(expanded)
if next_job_idx < len(input_jobs):
next_conf = expanded[next_conf_idx]
# RESOLVE "Default" to actual ckpt name
next_model_raw = next_conf["model"]
next_model_resolved = ckpt_name if next_model_raw == "Default" else next_model_raw
next_lora = next_conf["lora_expanded"]
# CASE 1: Same Base, Different LoRA
if next_model_resolved == target_model_name and next_lora != current_lora_string:
def _preload_lora_worker():
return load_loras(
loaded_model, loaded_clip, next_lora,
next_model_resolved, {}, model_cache=None
)
model_cache.preload_lora_model(next_model_resolved, next_lora, _preload_lora_worker)
# CASE 2: Different Base Model
elif next_model_resolved != target_model_name:
print(f"[GridTester] 🔮 Pre-loading Base Model: {next_model_resolved}")
def _preload_base_worker(path_to_load=next_model_resolved):
try:
import comfy.sd # Re-import locally to be safe in thread
ckpt_path = folder_paths.get_full_path("checkpoints", path_to_load)
if use_remote_vae:
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path, output_vae=False, output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings")
)
return out[0], out[1], None
else:
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path, output_vae=True, output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings")
)
return out[0], out[1], out[2]
except Exception as e:
print(f"[GridTester] ❌ Async Worker Error: {e}")
return None, None, None
model_cache.preload_base_model(next_model_resolved, _preload_base_worker)
# =========================================================
# Generate image
if job["latent"] is not None:
latent_in = {"samples": job["latent"]["samples"].clone()}
else:
latent_in = {"samples": torch.zeros([1, latent_channels, h // 8, w // 8])}
result_latent = None
try:
result_latent, duration = generate_image(
patched_model, current_seed, conf["steps"], conf["cfg"],
conf["sampler"], conf["scheduler"], final_positive, final_negative,
latent_in, conf["denoise"]
)
job_durations.append(duration)
eta_info = calculate_eta(job_durations, current_job, total_jobs)
if eta_info:
print_generation_progress(current_job, total_jobs, conf, w, h, duration, eta_info)
meta = create_image_metadata(
conf, w, h, duration, current_seed, batch_idx,
actual_positive_prompt, actual_negative_prompt
)
if pos_hash or neg_hash:
meta["conditioning_pos_hash"] = pos_hash
meta["conditioning_neg_hash"] = neg_hash
pending_batch.append((result_latent["samples"].clone(), meta))
total_generated += 1
except Exception as e:
import comfy.model_management
if isinstance(e, comfy.model_management.InterruptProcessingException):
print(f"\n[GridTester] 🛑 INTERRUPTED during generation - Stopping all jobs")
if result_latent is not None:
del result_latent
result_latent = None
if pending_batch:
if use_remote_vae:
flush_batch_with_remote_vae(pending_batch, remote_vae_worker, existing_data, session_name)
else:
flush_batch_with_vae(pending_batch, loaded_vae, paths["images"], existing_data, session_name, paths["manifest"], unique_id)
pending_batch = []
if remote_vae_worker:
remote_vae_worker.wait_completion()
remote_vae_worker.stop()
existing_data["meta"] = {
"positive": positive_text,
"negative": negative_text,
"model": ckpt_name,
"seed": seed,
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json
}
save_manifest(paths["manifest"], existing_data)
loaded_model, loaded_clip, loaded_vae = None, None, None
patched_model, patched_clip = None, None
conditioning_cache.clear()
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
html = get_html_template(session_name, existing_data, unique_id)
return (html,)
else:
print(f"[GridTester] ❌ Generation failed: {e}")
if result_latent is not None:
del result_latent
continue
if result_latent is not None:
del result_latent
result_latent = None
del latent_in
latent_in = None
if current_job % 10 == 0:
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
threshold = vae_batch_size if flush_batch_every <= 0 else flush_batch_every
if len(pending_batch) >= threshold:
if use_remote_vae:
flush_batch_with_remote_vae(pending_batch, remote_vae_worker, existing_data, session_name)
else:
flush_batch_with_vae(pending_batch, loaded_vae, paths["images"], existing_data, session_name, paths["manifest"], unique_id)
pending_batch = []
# ==== FINALIZATION ====
if pending_batch:
if use_remote_vae:
flush_batch_with_remote_vae(pending_batch, remote_vae_worker, existing_data, session_name)
else:
flush_batch_with_vae(pending_batch, loaded_vae, paths["images"], existing_data, session_name, paths["manifest"], unique_id)
if remote_vae_worker:
print(f"[GridTester] 🌐 Waiting for remote VAE...")
remote_vae_worker.wait_completion()
remote_vae_worker.stop()
print_incompatible_loras_summary(incompatible_loras)
if skipped_count > 0:
print(f"[GridTester] ⏭️ Skipped {skipped_count} configs")
existing_data["meta"] = {
"positive": positive_text,
"negative": negative_text,
"model": ckpt_name,
"seed": seed,
"vae_batch_size": vae_batch_size,
"configs_json": configs_json,
"resolutions_json": resolutions_json
}
save_manifest(paths["manifest"], existing_data)
print(f"[GridTester] 🧹 Cleaning up...")
loaded_model, loaded_clip, loaded_vae = None, None, None
patched_model, patched_clip = None, None
conditioning_cache.clear()
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
html = get_html_template(session_name, existing_data, unique_id)
if job_durations:
total_elapsed = time.time() - eta_start_time
total_hours = int(total_elapsed // 3600)
total_minutes = int((total_elapsed % 3600) // 60)
total_seconds = int(total_elapsed % 60)
avg_per_job = sum(job_durations) / len(job_durations)
print(f"\n{'='*80}")
print(f"[GridTester] 🎉 COMPLETE!")
print(f"[GridTester] ✅ {total_generated} images generated")
print(f"[GridTester] ⏱️ {total_hours}h {total_minutes}m {total_seconds}s total")
print(f"[GridTester] 📊 {avg_per_job:.1f}s average per job")
print(f"{'='*80}\n")
return (html,)