Files
Jason Hoku 1552950d9b refactor(init): drop distribution_routes import + register main USCG alias
- Deletes the `from . import distribution_routes` import. The /distribution/*
  HTTP routes now register from the companion plugin (ComfyUI-USCG-Distributed)
  at /uscg-distribution/*.
- Adds `sys.modules["comfyui_uscg_main"] = sys.modules[__name__]` so the
  Distributed companion's worker can lazy-import main USCG modules via
  `from comfyui_uscg_main.<module> import ...` without depending on the
  hyphenated folder name (not a valid Python identifier).

This mirrors the Phase 1 pattern of registering 'comfyui_uscg_remote_vae'
in the Remote VAE companion's __init__.py.
2026-05-19 15:14:52 -07:00

1186 lines
49 KiB
Python

import re
import asyncio
import server
from aiohttp import web
import json
import os
import urllib.parse
import folder_paths
import shutil
from .sampler_node import SamplerGridTester
from .dashboard_node import SamplerConfigDashboardViewer
from .html_generator import get_html_template
from .config_builder_node import UltimateConfigBuilder
from .json_text_node import SmartJSONTextNode
from .metadata_packer import pack_metadata_into_image
from .directory_scanner import scan_directory_for_images
import sys
# Register a sys.modules alias for cross-companion imports.
# The Distributed companion (ComfyUI-USCG-Distributed) lazy-imports main USCG
# modules via `from comfyui_uscg_main.<module> import <func>`. The folder name
# 'ComfyUI-Ultimate-Auto-Sampler-Config-Grid-Testing-Suite' has hyphens (not
# valid identifiers), so we publish the underscored alias for Python imports.
sys.modules["comfyui_uscg_main"] = sys.modules[__name__]
# --- PATH SECURITY HELPERS ---
def _get_benchmarks_base():
"""Return the canonical benchmarks base directory."""
return os.path.realpath(os.path.join(folder_paths.get_output_directory(), "benchmarks"))
def _is_path_within(path, base):
"""Check that *path* is contained within *base* after resolving symlinks."""
real_path = os.path.realpath(path)
real_base = os.path.realpath(base)
return real_path == real_base or real_path.startswith(real_base + os.sep)
# --- CONFIG MANAGEMENT PATH ---
CONFIGS_DIR = os.path.join(folder_paths.get_output_directory(), "ultimate-configs")
os.makedirs(CONFIGS_DIR, exist_ok=True)
# --- CUSTOM RESOLUTIONS FILE ---
CUSTOM_RESOLUTIONS_FILE = os.path.join(folder_paths.get_output_directory(), "benchmarks", "USCG-custom-resolutions.json")
# --- UPSCALE PRESETS FILE ---
UPSCALE_PRESETS_FILE = os.path.join(folder_paths.get_output_directory(), "benchmarks", "USCG-upscale-presets.json")
# --- CONFIG SECTION PRESETS FILE ---
CONFIG_SECTION_PRESETS_FILE = os.path.join(folder_paths.get_output_directory(), "benchmarks", "USCG-config-section-presets.json")
# --- INDIVIDUAL SECTION PRESETS FILES ---
MODELS_PRESETS_FILE = os.path.join(folder_paths.get_output_directory(), "benchmarks", "USCG-models-presets.json")
LORAS_PRESETS_FILE = os.path.join(folder_paths.get_output_directory(), "benchmarks", "USCG-loras-presets.json")
PROMPTS_PRESETS_FILE = os.path.join(folder_paths.get_output_directory(), "benchmarks", "USCG-prompts-presets.json")
# =============================================================================
# API: CONFIG MANAGEMENT
# =============================================================================
@server.PromptServer.instance.routes.get("/configbuilder/list_configs")
async def list_configs(request):
try:
if not os.path.exists(CONFIGS_DIR):
os.makedirs(CONFIGS_DIR, exist_ok=True)
files = [f for f in os.listdir(CONFIGS_DIR) if f.endswith(".json")]
files.sort()
return web.json_response(files)
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/configbuilder/save_config")
async def save_config(request):
try:
data = await request.json()
filename = data.get("name")
config_data = data.get("data")
if not filename or not config_data:
return web.Response(status=400, text="Missing name or data")
if not filename.endswith(".json"):
filename += ".json"
# Sanitize: strip to basename to prevent directory traversal
filename = os.path.basename(filename)
filepath = os.path.join(CONFIGS_DIR, filename)
if not _is_path_within(filepath, CONFIGS_DIR):
return web.Response(status=403, text="Forbidden")
with open(filepath, "w") as f:
json.dump(config_data, f, indent=4)
return web.Response(status=200, text="Saved")
except Exception as e:
print(f"[ConfigBuilder] Error saving config: {e}")
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.get("/configbuilder/custom_resolutions")
async def get_custom_resolutions(request):
try:
if os.path.exists(CUSTOM_RESOLUTIONS_FILE):
with open(CUSTOM_RESOLUTIONS_FILE, "r") as f:
data = json.load(f)
return web.json_response(data)
return web.json_response({"categories": []})
except Exception as e:
return web.json_response({"categories": []})
@server.PromptServer.instance.routes.post("/configbuilder/custom_resolutions")
async def save_custom_resolutions(request):
try:
data = await request.json()
with open(CUSTOM_RESOLUTIONS_FILE, "w") as f:
json.dump(data, f, indent=2)
return web.Response(status=200, text="Saved")
except Exception as e:
print(f"[ConfigBuilder] Error saving custom resolutions: {e}")
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.get("/configbuilder/upscale_presets")
async def get_upscale_presets(request):
try:
if os.path.exists(UPSCALE_PRESETS_FILE):
with open(UPSCALE_PRESETS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return web.json_response(data)
return web.json_response({"presets": []})
except Exception as e:
return web.json_response({"presets": []})
@server.PromptServer.instance.routes.post("/configbuilder/upscale_presets")
async def save_upscale_presets(request):
try:
body = await request.text()
if not body or not body.strip():
return web.Response(status=400, text="Empty request body")
data = json.loads(body)
os.makedirs(os.path.dirname(UPSCALE_PRESETS_FILE), exist_ok=True)
with open(UPSCALE_PRESETS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
return web.Response(status=200, text="Saved")
except Exception as e:
print(f"[ConfigBuilder] Error saving upscale presets: {e}")
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.get("/configbuilder/config_section_presets")
async def get_config_section_presets(request):
try:
if os.path.exists(CONFIG_SECTION_PRESETS_FILE):
with open(CONFIG_SECTION_PRESETS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return web.json_response(data)
return web.json_response({"presets": []})
except Exception as e:
return web.json_response({"presets": []})
@server.PromptServer.instance.routes.post("/configbuilder/config_section_presets")
async def save_config_section_presets(request):
try:
body = await request.text()
if not body or not body.strip():
return web.Response(status=400, text="Empty request body")
data = json.loads(body)
os.makedirs(os.path.dirname(CONFIG_SECTION_PRESETS_FILE), exist_ok=True)
with open(CONFIG_SECTION_PRESETS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
return web.Response(status=200, text="Saved")
except Exception as e:
print(f"[ConfigBuilder] Error saving config section presets: {e}")
return web.Response(status=500, text=str(e))
# --- GENERIC SECTION PRESETS (models, loras, prompts) ---
_SECTION_PRESET_FILES = {
"models": MODELS_PRESETS_FILE,
"loras": LORAS_PRESETS_FILE,
"prompts": PROMPTS_PRESETS_FILE,
}
@server.PromptServer.instance.routes.get("/configbuilder/section_presets")
async def get_section_presets(request):
section = request.query.get("section", "")
filepath = _SECTION_PRESET_FILES.get(section)
if not filepath:
return web.Response(status=400, text=f"Unknown section: {section}")
try:
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return web.json_response(json.load(f))
return web.json_response({"presets": []})
except Exception:
return web.json_response({"presets": []})
@server.PromptServer.instance.routes.post("/configbuilder/section_presets")
async def save_section_presets(request):
section = request.query.get("section", "")
filepath = _SECTION_PRESET_FILES.get(section)
if not filepath:
return web.Response(status=400, text=f"Unknown section: {section}")
try:
body = await request.text()
if not body or not body.strip():
return web.Response(status=400, text="Empty request body")
data = json.loads(body)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
return web.Response(status=200, text="Saved")
except Exception as e:
print(f"[ConfigBuilder] Error saving {section} presets: {e}")
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/configbuilder/load_config")
async def load_config(request):
try:
data = await request.json()
filename = data.get("name")
if not filename:
return web.Response(status=400, text="Missing name")
if not filename.endswith(".json"):
filename += ".json"
filename = os.path.basename(filename)
filepath = os.path.join(CONFIGS_DIR, filename)
if not _is_path_within(filepath, CONFIGS_DIR):
return web.Response(status=403, text="Forbidden")
if not os.path.exists(filepath):
return web.Response(status=404, text="Config not found")
with open(filepath, "r") as f:
config_data = json.load(f)
return web.json_response(config_data)
except Exception as e:
print(f"[ConfigBuilder] Error loading config: {e}")
return web.Response(status=500, text=str(e))
# =============================================================================
# API: DELETE SESSION
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/delete_session")
async def delete_session(request):
try:
data = await request.json()
session_name = data.get("session_name")
# Sanitize
if session_name:
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name or session_name == "default_session":
return web.Response(status=400, text="Invalid session name")
# Path construction with containment check
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
if os.path.exists(base_dir):
shutil.rmtree(base_dir)
return web.Response(status=200, text="Deleted")
else:
return web.Response(status=404, text="Session not found")
except Exception as e:
return web.Response(status=500, text=str(e))
# =============================================================================
# API: LIST SESSIONS
# =============================================================================
@server.PromptServer.instance.routes.get("/config_tester/list_sessions")
async def list_sessions(request):
"""
List all available sessions with metadata for the session picker.
Returns JSON array sorted by modification time (newest first).
Each entry: {name, item_count, first_image, mtime}
"""
try:
benchmarks_dir = _get_benchmarks_base()
sessions = []
if os.path.exists(benchmarks_dir):
for item in os.listdir(benchmarks_dir):
item_path = os.path.join(benchmarks_dir, item)
manifest_path = os.path.join(item_path, "manifest.json")
if os.path.isdir(item_path) and os.path.exists(manifest_path):
try:
mtime = os.path.getmtime(item_path)
# Read manifest for item count and first image
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
items = manifest.get("items", [])
item_count = len(items)
# Find first image filename for thumbnail
first_image = None
if items:
first_item = items[0]
fname = first_item.get("filename")
if fname:
first_image = f"benchmarks/{item}/{fname}"
sessions.append({
"name": item,
"item_count": item_count,
"first_image": first_image,
"mtime": mtime
})
except Exception:
# Skip sessions with unreadable manifests
sessions.append({
"name": item,
"item_count": 0,
"first_image": None,
"mtime": os.path.getmtime(item_path)
})
# Sort by modification time, newest first
sessions.sort(key=lambda x: x["mtime"], reverse=True)
return web.json_response(sessions)
except Exception as e:
return web.json_response([], status=500)
# =============================================================================
# API: SAVE CHANGES (Optimized - Only Changed Items)
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/save_changes")
async def save_changes(request):
"""
OPTIMIZED: Save only changed items instead of full manifest.
This drastically reduces network payload and processing time:
- Before: ~10MB for 500 images
- After: ~10KB for 1-5 changed items
Receives:
- session_name: str
- changed_items: list of item objects with updates
Process:
1. Load current manifest from disk
2. Update only the changed items by ID
3. Save back to disk
"""
try:
data = await request.json()
session_name = data.get("session_name")
changed_items = data.get("changed_items", [])
# Sanitize
if session_name:
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name or not changed_items:
return web.Response(status=400, text="Missing session_name or changed_items")
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
manifest_path = os.path.join(base_dir, "manifest.json")
# Load current manifest
if not os.path.exists(manifest_path):
return web.Response(status=404, text=f"Session '{session_name}' not found")
with open(manifest_path, "r") as f:
manifest = json.load(f)
# Create lookup of changed items by ID
changed_by_id = {item.get("id"): item for item in changed_items if "id" in item}
# Update items in manifest
items_updated = 0
for i, item in enumerate(manifest.get("items", [])):
item_id = item.get("id")
if item_id in changed_by_id:
# Merge changes (preserve fields not sent by client)
updated_item = changed_by_id[item_id]
manifest["items"][i].update(updated_item)
items_updated += 1
# Save updated manifest
with open(manifest_path, "w") as f:
json.dump(manifest, f, indent=4)
print(f"[ConfigTester] ⚡ Updated {items_updated} items in {session_name}")
return web.Response(status=200, text=f"Updated {items_updated} items")
except Exception as e:
print(f"[ConfigTester] Error saving changes: {e}")
import traceback
traceback.print_exc()
return web.Response(status=500, text=str(e))
# =============================================================================
# API: SAVE MANIFEST (Legacy - Full Save)
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/save_manifest")
async def save_manifest(request):
"""
Save manifest from dashboard.
CRITICAL: This is called when users favorite/reject/note images in the dashboard.
We need to preserve any NEW images that generation added but dashboard doesn't know about yet.
"""
try:
data = await request.json()
session_name = data.get("session_name")
manifest_data = data.get("manifest") # This is from dashboard (may be stale)
# --- sanitize ---
if session_name:
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name or not manifest_data:
return web.Response(status=400, text="Missing session_name or manifest")
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
manifest_path = os.path.join(base_dir, "manifest.json")
# --- MERGE STRATEGY: Preserve server data ---
# 1. Load server manifest (has newest images)
server_manifest = None
if os.path.exists(manifest_path):
try:
with open(manifest_path, "r") as f:
server_manifest = json.load(f)
except:
pass
if server_manifest:
# Build lookup of items by ID from dashboard
dashboard_items = {item.get("id"): item for item in manifest_data.get("items", [])}
# Merge: Update existing items, keep new items
merged_items = []
for server_item in server_manifest.get("items", []):
item_id = server_item.get("id")
if item_id in dashboard_items:
# Item exists in dashboard: merge updates
dashboard_item = dashboard_items[item_id]
# Preserve server's metadata but update user actions
merged_item = server_item.copy()
merged_item["favorited"] = dashboard_item.get("favorited", False)
merged_item["rejected"] = dashboard_item.get("rejected", False)
merged_item["note"] = dashboard_item.get("note", "")
merged_items.append(merged_item)
else:
# NEW item from server (generation added it): keep as-is
merged_items.append(server_item)
# Update manifest
manifest_data["items"] = merged_items
# Preserve server's meta
if "meta" in server_manifest:
manifest_data["meta"] = server_manifest["meta"]
# Save merged manifest
os.makedirs(base_dir, exist_ok=True)
with open(manifest_path, "w") as f:
json.dump(manifest_data, f, indent=4)
return web.Response(status=200, text="Saved")
except Exception as e:
print(f"[ConfigTester] Error saving manifest: {e}")
import traceback
traceback.print_exc()
return web.Response(status=500, text=str(e))
# =============================================================================
# API: GET SESSION HTML
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/get_session_html")
async def get_session_html(request):
"""
Dynamically generate HTML for a session.
This allows the dashboard to load without triggering a workflow execution.
"""
try:
data = await request.json()
session_name = data.get("session_name")
node_id = data.get("node_id")
if session_name:
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name:
# No session name — return landing page with empty manifest
empty_manifest = {"items": [], "meta": {"model": "", "positive": "", "negative": ""}, "session_name": ""}
html = get_html_template("", empty_manifest, node_id)
return web.Response(status=200, text=html)
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
manifest_path = os.path.join(base_dir, "manifest.json")
if not os.path.exists(manifest_path):
# No session found — return full template with empty manifest
# so the landing page JS can show available sessions
empty_manifest = {"items": [], "meta": {"model": "", "positive": "", "negative": ""}, "session_name": session_name}
html = get_html_template(session_name, empty_manifest, node_id)
return web.Response(status=200, text=html)
with open(manifest_path, "r") as f:
manifest = json.load(f)
# Generate HTML on the fly
html = get_html_template(session_name, manifest, node_id)
return web.Response(status=200, text=html)
except Exception as e:
return web.Response(status=500, text=str(e))
# =============================================================================
# API: EXPORT FAVORITES
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/export_favorites")
async def export_favorites(request):
"""
Export favorited images to a 'benchmark_favorites/{session_name}' folder.
Optionally packs metadata into images for CivitAI uploads.
Optionally organizes into subfolders by unique prompts.
"""
try:
data = await request.json()
session_name = data.get("session_name")
pack_metadata = data.get("pack_metadata", False)
organize_by_prompt = data.get("organize_by_prompt", False)
organize_by_lora = data.get("organize_by_lora", False)
export_prompt_txt = data.get("export_prompt_txt", False)
copy_manifest = data.get("copy_manifest", True)
pack_workflow = data.get("pack_workflow", False)
pack_nodes_workflow = data.get("pack_nodes_workflow", False)
workflow_data = data.get("workflow_data", None)
nodes_workflows = data.get("nodes_workflows", None)
# Sanitize
if session_name:
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name:
return web.Response(status=400, text="Missing session_name")
# Paths
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
manifest_path = os.path.join(base_dir, "manifest.json")
images_dir = os.path.join(base_dir, "images")
# Load manifest
if not os.path.exists(manifest_path):
return web.Response(status=404, text=f"Session '{session_name}' not found")
with open(manifest_path, "r") as f:
manifest = json.load(f)
# Filter favorited items
favorited = [item for item in manifest.get("items", []) if item.get("favorited", False)]
if not favorited:
return web.Response(status=200, text="No favorited images to export")
# Build prompt mapping if organizing by prompt
prompt_to_folder = {}
if organize_by_prompt:
unique_prompts = []
for item in favorited:
prompt = item.get("positive") or manifest.get("meta", {}).get("positive", "")
if prompt and prompt not in unique_prompts:
unique_prompts.append(prompt)
# Create Prompt1, Prompt2, etc. mapping
for idx, prompt in enumerate(unique_prompts, 1):
prompt_to_folder[prompt] = f"Prompt{idx}"
print(f"[Export] Organizing into {len(unique_prompts)} prompt folders")
# Build lora mapping if organizing by lora
lora_to_folder = {}
if organize_by_lora:
unique_loras = []
for item in favorited:
lora = item.get("lora_expanded") or manifest.get("meta", {}).get("lora", "")
if lora and lora not in unique_loras:
unique_loras.append(lora)
# Create Lora1, Lora2, etc. mapping
for idx, lora in enumerate(unique_loras, 1):
lora_to_folder[lora] = f"Loras{idx}"
print(f"[Export] Organizing into {len(unique_loras)} lora folders")
# Create base export directory
export_base = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name, "favorites")
os.makedirs(export_base, exist_ok=True)
exported_count = 0
for item in favorited:
# Get source image path - handle both URL format and relative paths
file_path = item.get("file", "")
# Parse filename from URL format: /view?filename=img_123.webp&type=output&subfolder=benchmarks/Session/images
if file_path.startswith("/view?"):
# Extract filename and subfolder from URL
parsed_url = urllib.parse.urlparse(file_path)
url_params = urllib.parse.parse_qs(parsed_url.query)
filename = url_params.get("filename", [""])[0]
subfolder = url_params.get("subfolder", [""])[0]
if subfolder:
source_path = os.path.join(folder_paths.get_output_directory(), subfolder, filename)
else:
source_path = os.path.join(images_dir, filename)
elif file_path.startswith("./images/"):
# Relative path format
filename = file_path[9:] # Remove ./images/
source_path = os.path.join(images_dir, filename)
else:
# Just use basename
filename = os.path.basename(file_path)
source_path = os.path.join(images_dir, filename)
if not filename:
print(f"[Export] Warning: Could not parse filename from: {file_path}")
continue
if not os.path.exists(source_path):
print(f"[Export] Warning: Image not found: {source_path}")
continue
# Determine destination folder
if organize_by_prompt and organize_by_lora:
# Both options: create nested folders Prompt/Lora
prompt = item.get("positive") or manifest.get("meta", {}).get("positive", "")
lora = item.get("lora") or manifest.get("meta", {}).get("lora", "")
prompt_folder = prompt_to_folder.get(prompt, "Unknown")
lora_folder = lora_to_folder.get(lora, "Unknown")
dest_dir = os.path.join(export_base, prompt_folder, lora_folder)
os.makedirs(dest_dir, exist_ok=True)
elif organize_by_prompt:
prompt = item.get("positive") or manifest.get("meta", {}).get("positive", "")
folder_name = prompt_to_folder.get(prompt, "Unknown")
dest_dir = os.path.join(export_base, folder_name)
os.makedirs(dest_dir, exist_ok=True)
elif organize_by_lora:
lora = item.get("lora") or manifest.get("meta", {}).get("lora", "")
folder_name = lora_to_folder.get(lora, "Unknown")
dest_dir = os.path.join(export_base, folder_name)
os.makedirs(dest_dir, exist_ok=True)
else:
dest_dir = export_base
# Destination path
dest_filename = filename
if pack_metadata:
# Change extension to .png if packing metadata
dest_filename = os.path.splitext(filename)[0] + '.png'
dest_path = os.path.join(dest_dir, dest_filename)
# Copy or pack metadata
if pack_metadata:
try:
# Determine which workflow to embed
item_workflow = None
if pack_nodes_workflow and nodes_workflows:
# Per-image nodes workflow takes priority
item_workflow = nodes_workflows.get(item.get("file", ""))
elif pack_workflow and workflow_data:
# Full ComfyUI graph workflow
item_workflow = workflow_data
pack_metadata_into_image(source_path, dest_path, item, manifest.get("meta", {}), workflow_data=item_workflow)
exported_count += 1
except Exception as e:
print(f"[Export] Error packing metadata for {filename}: {e}")
# Fallback to simple copy
shutil.copy2(source_path, dest_path)
exported_count += 1
else:
shutil.copy2(source_path, dest_path)
exported_count += 1
# Export positive prompt as .txt file alongside the image
if export_prompt_txt:
try:
prompt_text = item.get("positive") or manifest.get("meta", {}).get("positive", "")
if prompt_text:
txt_filename = os.path.splitext(dest_filename)[0] + '.txt'
txt_path = os.path.join(dest_dir, txt_filename)
with open(txt_path, "w", encoding="utf-8") as txt_file:
txt_file.write(prompt_text)
except Exception as e:
print(f"[Export] Error writing prompt txt for {filename}: {e}")
# Copy cleaned favorites-only manifest if requested
if copy_manifest:
try:
cleaned_manifest = {
"items": favorited,
"meta": manifest.get("meta", {}),
"session_name": session_name
}
manifest_dest = os.path.join(export_base, "manifest.json")
with open(manifest_dest, "w", encoding="utf-8") as f:
json.dump(cleaned_manifest, f, indent=2, ensure_ascii=False)
print(f"[Export] Saved cleaned favorites manifest to {manifest_dest}")
except Exception as e:
print(f"[Export] Error saving cleaned manifest: {e}")
result_msg = f"Exported {exported_count} favorited images to 'benchmarks/{session_name}/favorites/'"
if organize_by_prompt and organize_by_lora:
result_msg += f" (organized into {len(prompt_to_folder)} prompt folders with {len(lora_to_folder)} lora subfolders)"
elif organize_by_prompt:
result_msg += f" (organized into {len(prompt_to_folder)} prompt folders)"
elif organize_by_lora:
result_msg += f" (organized into {len(lora_to_folder)} lora folders)"
if pack_metadata:
result_msg += " (with metadata packed)"
if export_prompt_txt:
result_msg += " (with prompt .txt files)"
if copy_manifest:
result_msg += " (with favorites manifest)"
if pack_workflow:
result_msg += " (with full workflow)"
if pack_nodes_workflow:
result_msg += " (with nodes workflows)"
print(f"[ConfigTester] ✅ {result_msg}")
return web.Response(status=200, text=result_msg)
except Exception as e:
print(f"[ConfigTester] Error exporting favorites: {e}")
import traceback
traceback.print_exc()
return web.Response(status=500, text=str(e))
# =============================================================================
# API: DELETE NON-FAVORITED ITEMS
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/delete_non_favorites")
async def delete_non_favorites(request):
"""
Delete all non-favorited images from a session and update the manifest.
"""
try:
data = await request.json()
session_name = data.get("session_name")
# Sanitize
if session_name:
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name:
return web.Response(status=400, text="Missing session_name")
# Paths
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
manifest_path = os.path.join(base_dir, "manifest.json")
images_dir = os.path.join(base_dir, "images")
# Load manifest
if not os.path.exists(manifest_path):
return web.Response(status=404, text=f"Session '{session_name}' not found")
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
items = manifest.get("items", [])
favorited = [item for item in items if item.get("favorited", False)]
non_favorited = [item for item in items if not item.get("favorited", False)]
if not non_favorited:
return web.Response(status=200, text="No non-favorited items to delete")
# Delete non-favorited image files
deleted_count = 0
for item in non_favorited:
file_path = item.get("file", "")
# Parse filename from various formats
if file_path.startswith("/view?"):
parsed_url = urllib.parse.urlparse(file_path)
url_params = urllib.parse.parse_qs(parsed_url.query)
filename = url_params.get("filename", [""])[0]
elif file_path.startswith("./images/"):
filename = file_path[9:]
elif "filename=" in file_path:
filename = file_path.split("filename=")[-1].split("&")[0]
else:
filename = os.path.basename(file_path)
if filename:
image_path = os.path.join(images_dir, filename)
if os.path.exists(image_path):
try:
os.remove(image_path)
deleted_count += 1
except Exception as e:
print(f"[Delete] Error deleting {filename}: {e}")
# Update manifest to only contain favorited items
manifest["items"] = favorited
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
result_msg = f"Deleted {deleted_count} non-favorited images. {len(favorited)} favorited items remain."
print(f"[ConfigTester] 🗑️ {result_msg}")
return web.Response(status=200, text=result_msg)
except Exception as e:
print(f"[ConfigTester] Error deleting non-favorites: {e}")
import traceback
traceback.print_exc()
return web.Response(status=500, text=str(e))
# =============================================================================
# API: DELETE REJECTED ITEMS
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/delete_rejected")
async def delete_rejected(request):
"""
Delete all rejected images from a session and update the manifest.
"""
try:
data = await request.json()
session_name = data.get("session_name")
# Sanitize
if session_name:
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name:
return web.Response(status=400, text="Missing session_name")
# Paths
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
manifest_path = os.path.join(base_dir, "manifest.json")
images_dir = os.path.join(base_dir, "images")
# Load manifest
if not os.path.exists(manifest_path):
return web.Response(status=404, text=f"Session '{session_name}' not found")
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
items = manifest.get("items", [])
rejected = [item for item in items if item.get("rejected", False)]
kept = [item for item in items if not item.get("rejected", False)]
if not rejected:
return web.Response(status=200, text="No rejected items to delete")
# Delete rejected image files
deleted_count = 0
for item in rejected:
file_path = item.get("file", "")
# Parse filename from various formats
if file_path.startswith("/view?"):
parsed_url = urllib.parse.urlparse(file_path)
url_params = urllib.parse.parse_qs(parsed_url.query)
filename = url_params.get("filename", [""])[0]
elif file_path.startswith("./images/"):
filename = file_path[9:]
elif "filename=" in file_path:
filename = file_path.split("filename=")[-1].split("&")[0]
else:
filename = os.path.basename(file_path)
if filename:
image_path = os.path.join(images_dir, filename)
if os.path.exists(image_path):
try:
os.remove(image_path)
deleted_count += 1
except Exception as e:
print(f"[Delete] Error deleting {filename}: {e}")
# Update manifest to remove rejected items
manifest["items"] = kept
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
result_msg = f"Deleted {deleted_count} rejected images. {len(kept)} items remain."
print(f"[ConfigTester] 🗑️ {result_msg}")
return web.Response(status=200, text=result_msg)
except Exception as e:
print(f"[ConfigTester] Error deleting rejected: {e}")
import traceback
traceback.print_exc()
return web.Response(status=500, text=str(e))
# =============================================================================
# API: SCAN EXTERNAL DIRECTORY
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/scan_directory")
async def scan_directory_route(request):
"""
Scan an external directory for images, extract PNG metadata,
and generate a manifest compatible with the dashboard.
Creates symlinks in the output directory so ComfyUI's built-in
/view endpoint can serve the images (no custom file serving needed).
"""
try:
data = await request.json()
directory_path = data.get("directory_path", "").strip()
session_name = data.get("session_name", "").strip()
if not directory_path:
return web.json_response({"error": "Missing directory_path"}, status=400)
# Normalize path
directory_path = os.path.normpath(directory_path)
if not os.path.isabs(directory_path):
return web.json_response({"error": "Path must be absolute"}, status=400)
if not os.path.isdir(directory_path):
return web.json_response({"error": f"Directory not found: {directory_path}"}, status=404)
# Auto-generate session name from directory if not provided
if not session_name:
dir_basename = os.path.basename(directory_path)
session_name = "scan-" + re.sub(r'[^\w\-]', '', dir_basename)
# Sanitize session name
session_name = re.sub(r'[^\w\-]', '', session_name)
if not session_name:
session_name = "scan-unnamed"
print(f"[DirScanner] Scanning directory: {directory_path}")
print(f"[DirScanner] Session name: {session_name}")
# Prepare the symlink directory within the output folder
# so ComfyUI's built-in /view endpoint can serve the images
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.json_response({"error": "Forbidden: path outside benchmarks directory"}, status=403)
link_dir = os.path.join(base_dir, "external_images")
os.makedirs(link_dir, exist_ok=True)
# The subfolder path for /view URLs (relative to output dir)
view_subfolder = f"benchmarks/{session_name}/external_images"
# Run the scan in a thread pool to avoid blocking the event loop
loop = asyncio.get_event_loop()
items, stats = await loop.run_in_executor(
None, scan_directory_for_images, directory_path, 5000, view_subfolder
)
from_manifest = stats.get("from_manifest", 0)
manifest_info = f", {from_manifest} from existing manifest" if from_manifest > 0 else ""
print(f"[DirScanner] Found {stats['total']} images ({stats['with_metadata']} with metadata, {stats['skipped']} skipped{manifest_info})")
# Create symlinks (or copy on Windows if symlinks not available)
# for each image so /view can serve them
_create_image_links(directory_path, link_dir, items)
# Build manifest
manifest = {
"session_name": session_name,
"items": items,
"meta": {
"source_directory": directory_path,
"scan_type": "directory",
"total_scanned": stats["total"],
"with_metadata": stats["with_metadata"],
}
}
# Save manifest to benchmarks session directory
manifest_path = os.path.join(base_dir, "manifest.json")
# If manifest already exists, preserve user tags (favorited/rejected/notes)
if os.path.exists(manifest_path):
try:
with open(manifest_path, "r") as f:
old_manifest = json.load(f)
# Build lookup of old items by source_file for tag preservation
old_by_source = {}
for old_item in old_manifest.get("items", []):
src = old_item.get("source_file")
if src:
old_by_source[src] = old_item
# Merge user tags into new items
for item in items:
src = item.get("source_file")
if src and src in old_by_source:
old = old_by_source[src]
item["favorited"] = old.get("favorited", False)
item["rejected"] = old.get("rejected", False)
if old.get("notes"):
item["notes"] = old["notes"]
except Exception as e:
print(f"[DirScanner] Warning: Could not merge old tags: {e}")
with open(manifest_path, "w") as f:
json.dump(manifest, f, indent=2)
print(f"[DirScanner] Created image links in: {link_dir}")
from_manifest = stats.get("from_manifest", 0)
return web.json_response({
"session_name": session_name,
"item_count": len(items),
"with_metadata": stats["with_metadata"],
"without_metadata": len(items) - stats["with_metadata"],
"skipped": stats["skipped"],
"from_manifest": from_manifest,
})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
print(f"[DirScanner] Error: {e}")
import traceback
traceback.print_exc()
return web.json_response({"error": str(e)}, status=500)
# =============================================================================
# API: DASHBOARD UPSCALE
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/upscale_images")
async def upscale_images(request):
"""Start an async upscale job from the dashboard."""
try:
data = await request.json()
session_name = data.get("session_name", "")
image_ids = data.get("image_ids", [])
upscale_config = data.get("upscale_config", {})
all_favorited = data.get("all_favorited", False)
if not session_name:
return web.json_response({"error": "Missing session_name"}, status=400)
if not upscale_config or not upscale_config.get("pipelines"):
return web.json_response({"error": "Missing upscale_config with pipelines"}, status=400)
from .upscale_runner import start_upscale_job
job_id, error = start_upscale_job(session_name, image_ids, upscale_config, all_favorited=all_favorited)
if error:
return web.json_response({"error": error}, status=400)
from .upscale_runner import get_upscale_status
status = get_upscale_status(job_id)
return web.json_response({"job_id": job_id, "total_images": status["total"]})
except Exception as e:
print(f"[ConfigTester] Error starting upscale job: {e}")
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.get("/config_tester/upscale_status")
async def upscale_status(request):
"""Check status of an async upscale job."""
job_id = request.query.get("job_id", "")
if not job_id:
return web.json_response({"error": "Missing job_id"}, status=400)
from .upscale_runner import get_upscale_status
return web.json_response(get_upscale_status(job_id))
@server.PromptServer.instance.routes.post("/config_tester/cancel_upscale")
async def cancel_upscale(request):
"""Cancel a running upscale job."""
try:
data = await request.json()
job_id = data.get("job_id", "")
from .upscale_runner import cancel_upscale_job
cancelled = cancel_upscale_job(job_id)
return web.json_response({"cancelled": cancelled})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
def _create_image_links(source_dir, link_dir, items):
"""
Create symlinks (or copies on Windows) from source images and videos into
the output directory so ComfyUI's built-in /view endpoint can serve them.
"""
# Use shared media extensions (images + videos) from the scanner module
from .directory_scanner import MEDIA_EXTENSIONS
# Collect all source files referenced by items
filenames = set()
for item in items:
src = item.get("source_file")
if src:
filenames.add(src)
# Scan source directory and subdirectories for matching files
scan_dirs = [source_dir]
images_subdir = os.path.join(source_dir, "images")
if os.path.isdir(images_subdir):
scan_dirs.append(images_subdir)
for scan_dir in scan_dirs:
try:
for entry in os.scandir(scan_dir):
if not entry.is_file():
continue
ext = os.path.splitext(entry.name)[1].lower()
if ext not in MEDIA_EXTENSIONS:
continue
if entry.name not in filenames:
continue
link_path = os.path.join(link_dir, entry.name)
if os.path.exists(link_path):
continue
try:
os.symlink(entry.path, link_path)
except (OSError, NotImplementedError):
# Windows may need admin privileges for symlinks; fall back to copy
shutil.copy2(entry.path, link_path)
except PermissionError:
print(f"[DirScanner] Warning: Permission denied scanning {scan_dir}")
# =============================================================================
# NODE MAPPINGS
# =============================================================================
NODE_CLASS_MAPPINGS = {
"UltimateSamplerGrid": SamplerGridTester,
"UltimateGridDashboard": SamplerConfigDashboardViewer,
"UltimateConfigBuilder": UltimateConfigBuilder,
"SmartJSONText": SmartJSONTextNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"UltimateSamplerGrid": "Ultimate Sampler Grid (Generator)",
"UltimateGridDashboard": "Ultimate Grid Dashboard (Viewer)",
"UltimateConfigBuilder": "Ultimate Config Builder",
"SmartJSONText": "Smart JSON Text",
}
WEB_DIRECTORY = "./web"