3216 lines
131 KiB
Python
3216 lines
131 KiB
Python
import asyncio
|
|
import base64
|
|
import io
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import random
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
import folder_paths
|
|
import numpy as np
|
|
from aiohttp import web
|
|
from PIL import Image, ImageOps
|
|
from server import PromptServer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# v9k: DirectLink mode and persistent output-side delete script folder.
|
|
# v9i: ImageDirPreview left-click menu, hover frame, and preview-state source path fix.
|
|
# v9h: ImageDirPreview right-click event separation and blank-area suppression.
|
|
# v9g: ImageDirPreview right-click capture fix for ComfyUI/LiteGraph context menu.
|
|
# v9f: ImageDirPreview context menu foundation and Set as checkpoint thumbnail.
|
|
# v9e: add top-tier god checkpoint status.
|
|
# v9d: sticky/current-selection thumbnail popup refinement.
|
|
# v9c: ListSelector hover thumbnail popup.
|
|
# v9b: v8l baseline plus delete-script thumbnail sidecar candidates.
|
|
# v8g: restore-safe Cycler runtime controls, global shuffle deck, and UI regression fixes.
|
|
EXTENSION_PREFIX = "checkpoint_handpicker_suite"
|
|
PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview"
|
|
CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler"
|
|
TAGGER_EVENT = "ruminar.checkpoint_handpicker_suite.tagger"
|
|
STATUS_CHANGED_EVENT = "ruminar.checkpoint_handpicker_suite.status_changed"
|
|
TAG_TRANSFER_EVENT = "ruminar.checkpoint_handpicker_suite.tag_transfer"
|
|
|
|
STATUS_VALUES = ["god", "favorite", "nice", "keep", "delete", "none"]
|
|
STATUS_ICON = {"god": "👑", "favorite": "💛", "nice": "👍", "keep": "✔", "delete": "🗑", "none": "—"}
|
|
STATUS_LABEL = {"god": "god!", "favorite": "favorite", "nice": "nice", "keep": "keep", "delete": "delete", "none": "none"}
|
|
|
|
NODE_DIR = Path(__file__).resolve().parent
|
|
DATA_DIR = NODE_DIR / "data"
|
|
STATUS_DB_PATH = DATA_DIR / "checkpoint_statuses.json"
|
|
FAVORITES_COMPAT_PATH = DATA_DIR / "checkpoint_favorites.json"
|
|
try:
|
|
OUTPUT_DIR = Path(folder_paths.get_output_directory()).resolve()
|
|
except Exception:
|
|
OUTPUT_DIR = NODE_DIR / "output"
|
|
DELETE_SCRIPT_DIR = OUTPUT_DIR / "CheckpointHandpickerSuite" / "delete_scripts"
|
|
DELETE_QUEUE_PATH = DELETE_SCRIPT_DIR / "checkpoint_delete_queue.jsonl"
|
|
DELETE_SCRIPT_PATH = DELETE_SCRIPT_DIR / "delete_reserved_checkpoints.py"
|
|
DEFAULT_TAG_TRANSFER_DIR = OUTPUT_DIR / "CheckpointHandpickerSuite"
|
|
TAG_TRANSFER_FORMAT_VERSION = 1
|
|
TAG_EXPORT_FILE_RE = re.compile(r"^checkpoint_tags_(\d{8}_\d{6})(?:_(\d+))?\.json$")
|
|
TAG_TRANSFER_DETAIL_LIMIT = 20
|
|
|
|
JPEG_QUALITY = 80
|
|
JPEG_OPTIMIZE = False
|
|
GAP = 6
|
|
IMAGE_DIR_DEFAULT_MAX_IMAGES = 12
|
|
IMAGE_DIR_MAX_IMAGES = 80
|
|
IMAGE_DIR_SCAN_LIMIT = 3000
|
|
MAX_CONTENT_EDGE = 4096
|
|
ALLOWED_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
|
|
ALLOWED_THUMBNAIL_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
|
|
ALLOWED_CHECKPOINT_SUFFIX = ".safetensors"
|
|
|
|
_STATE_LOCK = threading.Lock()
|
|
_CYCLER_STATES: dict[str, dict] = {}
|
|
_TAGGER_STATES: dict[str, dict] = {}
|
|
_PREVIEW_STATES: dict[str, dict] = {}
|
|
_TAB_EXECUTION_STATES: dict[str, dict] = {}
|
|
_EXECUTION_REVISION = 0
|
|
_TAG_TRANSFER_REVISION = 0
|
|
_TAG_TRANSFER_OPERATION_LOCK = threading.Lock()
|
|
|
|
|
|
def _clean_tab_id(value) -> str:
|
|
value = str(value or "").strip()
|
|
if not value:
|
|
return "__legacy__"
|
|
value = re.sub(r"[^0-9A-Za-z_.:-]+", "_", value)[:128]
|
|
return value or "__legacy__"
|
|
|
|
|
|
def _state_key(tab_id, node_id) -> str:
|
|
return f"{_clean_tab_id(tab_id)}:{str(node_id or '__none__')}"
|
|
|
|
|
|
@dataclass
|
|
class SheetLayout:
|
|
cols: int
|
|
rows: int
|
|
tile_width: int
|
|
tile_height: int
|
|
canvas_width: int
|
|
canvas_height: int
|
|
count: int
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
|
|
|
|
def _ensure_data_dir():
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _normalize_relpath(value: str) -> str:
|
|
return str(PurePosixPath(str(value or "").replace("\\", "/")))
|
|
|
|
|
|
def _is_valid_checkpoint_relpath(value: str) -> bool:
|
|
if not value or not isinstance(value, str):
|
|
return False
|
|
value = value.replace("\\", "/")
|
|
path = PurePosixPath(value)
|
|
if path.is_absolute():
|
|
return False
|
|
if any(part in ("", ".", "..") for part in path.parts):
|
|
return False
|
|
return value.lower().endswith(ALLOWED_CHECKPOINT_SUFFIX)
|
|
|
|
|
|
def _ckpt_name_safe_from_relpath(value: str) -> str:
|
|
relpath = _normalize_relpath(value)
|
|
if relpath.lower().endswith(ALLOWED_CHECKPOINT_SUFFIX):
|
|
relpath = relpath[:-len(ALLOWED_CHECKPOINT_SUFFIX)]
|
|
return re.sub(r"[^0-9A-Za-z_-]+", "_", relpath).strip("_") or "checkpoint"
|
|
|
|
|
|
def _safe_json_write(path: Path, data: dict):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def _load_status_db() -> dict:
|
|
_ensure_data_dir()
|
|
if not STATUS_DB_PATH.exists():
|
|
return {"version": 1, "statuses": {}}
|
|
try:
|
|
data = json.loads(STATUS_DB_PATH.read_text(encoding="utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise ValueError("root must be object")
|
|
statuses = data.setdefault("statuses", {})
|
|
if not isinstance(statuses, dict):
|
|
data["statuses"] = {}
|
|
data.setdefault("version", 1)
|
|
return data
|
|
except Exception:
|
|
logger.exception("Failed to read checkpoint_statuses.json; using empty database")
|
|
return {"version": 1, "statuses": {}}
|
|
|
|
|
|
def _save_status_db(data: dict):
|
|
_safe_json_write(STATUS_DB_PATH, data)
|
|
favorites = {}
|
|
for relpath, entry in data.get("statuses", {}).items():
|
|
if entry.get("status") == "favorite":
|
|
favorites[relpath] = {
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"favorited_at": entry.get("updated_at", _now_iso()),
|
|
"last_seen_at": entry.get("updated_at", _now_iso()),
|
|
}
|
|
_safe_json_write(FAVORITES_COMPAT_PATH, {"version": 1, "favorites": favorites})
|
|
|
|
|
|
def _get_status(relpath: str) -> str:
|
|
relpath = _normalize_relpath(relpath)
|
|
db = _load_status_db()
|
|
entry = db.get("statuses", {}).get(relpath) or {}
|
|
status = entry.get("status", "none")
|
|
return status if status in STATUS_VALUES else "none"
|
|
|
|
|
|
def _set_status(relpath: str, status: str):
|
|
relpath = _normalize_relpath(relpath)
|
|
if not _is_valid_checkpoint_relpath(relpath):
|
|
raise ValueError("Invalid checkpoint path")
|
|
if status not in STATUS_VALUES:
|
|
raise ValueError(f"Invalid status: {status}")
|
|
db = _load_status_db()
|
|
statuses = db.setdefault("statuses", {})
|
|
if status == "none":
|
|
statuses.pop(relpath, None)
|
|
else:
|
|
statuses[relpath] = {
|
|
"status": status,
|
|
"updated_at": _now_iso(),
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
}
|
|
_save_status_db(db)
|
|
|
|
|
|
def _tag_export_filename_key(path: Path):
|
|
match = TAG_EXPORT_FILE_RE.fullmatch(path.name)
|
|
if not match:
|
|
return None
|
|
return match.group(1), int(match.group(2) or 0)
|
|
|
|
|
|
def _resolve_tag_transfer_directory(raw_value, *, create_default: bool) -> Path:
|
|
value = str(raw_value or "").strip()
|
|
if not value:
|
|
path = DEFAULT_TAG_TRANSFER_DIR
|
|
if create_default:
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
path = Path(value)
|
|
if not path.is_absolute():
|
|
raise ValueError("Tag Transfer Directory must be a full path.")
|
|
if not path.exists():
|
|
raise ValueError(f"Tag Transfer Directory does not exist.\n\n{path}")
|
|
if not path.is_dir():
|
|
raise ValueError(f"Tag Transfer Directory is not a directory.\n\n{path}")
|
|
return path
|
|
|
|
|
|
def _latest_tag_export_path(directory: Path) -> Path | None:
|
|
if not directory.exists() or not directory.is_dir():
|
|
return None
|
|
candidates = []
|
|
for path in directory.iterdir():
|
|
if not path.is_file():
|
|
continue
|
|
key = _tag_export_filename_key(path)
|
|
if key is not None:
|
|
candidates.append((key, path))
|
|
return max(candidates, key=lambda item: item[0])[1] if candidates else None
|
|
|
|
|
|
def _tag_export_candidate_path(directory: Path, stamp: str, suffix: int) -> Path:
|
|
filename = f"checkpoint_tags_{stamp}.json" if suffix == 0 else f"checkpoint_tags_{stamp}_{suffix}.json"
|
|
return directory / filename
|
|
|
|
|
|
def _write_tag_export(directory: Path, data: dict) -> Path:
|
|
if not directory.exists() or not directory.is_dir():
|
|
raise ValueError(f"Tag Transfer Directory does not exist.\n\n{directory}")
|
|
stamp = datetime.now().astimezone().strftime("%Y%m%d_%H%M%S")
|
|
suffix = 0
|
|
while True:
|
|
candidate = _tag_export_candidate_path(directory, stamp, suffix)
|
|
reservation = directory / f".{candidate.name}.lock"
|
|
if candidate.exists():
|
|
suffix = 2 if suffix == 0 else suffix + 1
|
|
continue
|
|
try:
|
|
reservation_handle = reservation.open("x", encoding="utf-8")
|
|
except FileExistsError:
|
|
suffix = 2 if suffix == 0 else suffix + 1
|
|
continue
|
|
temporary = directory / f".{candidate.name}.{uuid.uuid4().hex}.tmp"
|
|
try:
|
|
reservation_handle.write(f"pid={os.getpid()}\ncreated_at={_now_iso()}\n")
|
|
reservation_handle.close()
|
|
if candidate.exists():
|
|
suffix = 2 if suffix == 0 else suffix + 1
|
|
continue
|
|
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
|
|
json.dump(data, handle, ensure_ascii=False, indent=2)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, candidate)
|
|
return candidate
|
|
finally:
|
|
if not reservation_handle.closed:
|
|
reservation_handle.close()
|
|
try:
|
|
temporary.unlink(missing_ok=True)
|
|
except Exception:
|
|
logger.warning("Failed to remove temporary tag export file: %s", temporary)
|
|
try:
|
|
reservation.unlink(missing_ok=True)
|
|
except Exception:
|
|
logger.warning("Failed to remove tag export reservation: %s", reservation)
|
|
|
|
|
|
def _checkpoint_candidates_for_relpath(relpath: str) -> list[Path]:
|
|
candidates = []
|
|
seen = set()
|
|
for root in _checkpoint_roots():
|
|
path = (root / relpath).resolve()
|
|
try:
|
|
path.relative_to(root)
|
|
except Exception:
|
|
continue
|
|
key = os.path.normcase(str(path))
|
|
if key in seen or not path.exists() or not path.is_file():
|
|
continue
|
|
seen.add(key)
|
|
candidates.append(path)
|
|
return candidates
|
|
|
|
|
|
def _build_tag_export(status_db: dict) -> tuple[dict, dict]:
|
|
by_identity: dict[tuple[str, int], list[dict]] = {}
|
|
failed = []
|
|
ambiguous = []
|
|
raw_statuses = status_db.get("statuses", {})
|
|
statuses = raw_statuses if isinstance(raw_statuses, dict) else {}
|
|
|
|
for raw_relpath, raw_entry in sorted(statuses.items(), key=lambda item: str(item[0]).lower()):
|
|
relpath = _normalize_relpath(raw_relpath)
|
|
entry = raw_entry if isinstance(raw_entry, dict) else {}
|
|
tag = entry.get("status", "none")
|
|
if tag not in STATUS_VALUES or tag == "none" or not _is_valid_checkpoint_relpath(relpath):
|
|
continue
|
|
candidates = _checkpoint_candidates_for_relpath(relpath)
|
|
if not candidates:
|
|
failed.append({"file_name": PurePosixPath(relpath).name, "reason": "Checkpoint file not found."})
|
|
continue
|
|
identities: dict[tuple[str, int], list[Path]] = {}
|
|
try:
|
|
for path in candidates:
|
|
identity = (path.name, path.stat().st_size)
|
|
identities.setdefault(identity, []).append(path)
|
|
except OSError as exc:
|
|
failed.append({"file_name": PurePosixPath(relpath).name, "reason": str(exc)})
|
|
continue
|
|
if len(identities) != 1:
|
|
ambiguous.append({
|
|
"file_name": PurePosixPath(relpath).name,
|
|
"reason": "One evaluation resolves to multiple file sizes.",
|
|
})
|
|
continue
|
|
identity = next(iter(identities))
|
|
by_identity.setdefault(identity, []).append({"tag": tag, "relpath": relpath})
|
|
|
|
evaluations = []
|
|
for identity, sources in sorted(by_identity.items(), key=lambda item: (item[0][0].lower(), item[0][1])):
|
|
tags = {source["tag"] for source in sources}
|
|
if len(tags) != 1:
|
|
ambiguous.append({
|
|
"file_name": identity[0],
|
|
"file_size": identity[1],
|
|
"reason": "Multiple evaluations exist for the same identity.",
|
|
})
|
|
continue
|
|
evaluations.append({
|
|
"file_name": identity[0],
|
|
"file_size": identity[1],
|
|
"tag": next(iter(tags)),
|
|
})
|
|
|
|
payload = {
|
|
"format_version": TAG_TRANSFER_FORMAT_VERSION,
|
|
"exported_at": _now_iso(),
|
|
"evaluations": evaluations,
|
|
}
|
|
return payload, {"failed": failed, "ambiguous": ambiguous}
|
|
|
|
|
|
def _build_current_checkpoint_identity_index() -> dict[tuple[str, int], list[dict]]:
|
|
index: dict[tuple[str, int], list[dict]] = {}
|
|
seen = set()
|
|
extensions = {ALLOWED_CHECKPOINT_SUFFIX}
|
|
for root in _checkpoint_roots():
|
|
try:
|
|
if not root.exists() or not root.is_dir():
|
|
continue
|
|
for dirpath, _dirnames, filenames in os.walk(root):
|
|
parent = Path(dirpath)
|
|
for filename in filenames:
|
|
if Path(filename).suffix.lower() not in extensions:
|
|
continue
|
|
path = (parent / filename).resolve()
|
|
try:
|
|
relpath = path.relative_to(root).as_posix()
|
|
size = path.stat().st_size
|
|
except (OSError, ValueError):
|
|
continue
|
|
physical_key = os.path.normcase(str(path))
|
|
candidate_key = (physical_key, relpath)
|
|
if candidate_key in seen:
|
|
continue
|
|
seen.add(candidate_key)
|
|
identity = (path.name, size)
|
|
index.setdefault(identity, []).append({"relpath": _normalize_relpath(relpath), "path": str(path)})
|
|
except Exception:
|
|
logger.exception("Failed to build checkpoint identity index from: %s", root)
|
|
return index
|
|
|
|
|
|
def _validate_tag_import_document(document) -> tuple[list[dict], list[dict], list[dict]]:
|
|
if not isinstance(document, dict):
|
|
raise ValueError("JSON root must be an object.")
|
|
version = document.get("format_version")
|
|
if isinstance(version, bool) or not isinstance(version, int) or version != TAG_TRANSFER_FORMAT_VERSION:
|
|
raise ValueError(f"Unsupported format_version: {version!r}.")
|
|
raw_records = document.get("evaluations")
|
|
if not isinstance(raw_records, list):
|
|
raise ValueError("evaluations must be an array.")
|
|
|
|
failed = []
|
|
grouped: dict[tuple[str, int], list[dict]] = {}
|
|
valid_tags = {status for status in STATUS_VALUES if status != "none"}
|
|
for index, raw in enumerate(raw_records, start=1):
|
|
label = f"Record #{index}"
|
|
try:
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("record must be an object")
|
|
file_name = raw.get("file_name")
|
|
file_size = raw.get("file_size")
|
|
tag = raw.get("tag")
|
|
if not isinstance(file_name, str) or not file_name or "/" in file_name or "\\" in file_name:
|
|
raise ValueError("invalid file_name")
|
|
label = file_name
|
|
if not file_name.lower().endswith(ALLOWED_CHECKPOINT_SUFFIX):
|
|
raise ValueError("unsupported checkpoint extension")
|
|
if isinstance(file_size, bool) or not isinstance(file_size, int) or file_size < 0:
|
|
raise ValueError("file_size must be a non-negative integer")
|
|
if not isinstance(tag, str) or tag not in valid_tags:
|
|
raise ValueError("invalid tag")
|
|
record = {"file_name": file_name, "file_size": file_size, "tag": tag}
|
|
grouped.setdefault((file_name, file_size), []).append(record)
|
|
except ValueError as exc:
|
|
failed.append({"file_name": label, "reason": str(exc)})
|
|
|
|
records = []
|
|
ambiguous = []
|
|
for identity, duplicates in grouped.items():
|
|
tags = {record["tag"] for record in duplicates}
|
|
if len(tags) != 1:
|
|
ambiguous.append({
|
|
"file_name": identity[0],
|
|
"file_size": identity[1],
|
|
"reason": "Multiple evaluations exist for the same identity.",
|
|
})
|
|
continue
|
|
records.append(duplicates[0])
|
|
return records, failed, ambiguous
|
|
|
|
|
|
def _prepare_tag_import(path: Path) -> dict:
|
|
try:
|
|
document = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid JSON: {exc.msg}.") from exc
|
|
records, failed, ambiguous = _validate_tag_import_document(document)
|
|
identity_index = _build_current_checkpoint_identity_index()
|
|
matches = []
|
|
missing = []
|
|
for record in records:
|
|
identity = (record["file_name"], record["file_size"])
|
|
candidates = identity_index.get(identity, [])
|
|
if not candidates:
|
|
missing.append({"file_name": record["file_name"], "file_size": record["file_size"]})
|
|
continue
|
|
if len(candidates) != 1:
|
|
ambiguous.append({
|
|
"file_name": record["file_name"],
|
|
"file_size": record["file_size"],
|
|
"reason": "Multiple checkpoints match this identity.",
|
|
})
|
|
continue
|
|
matches.append({**record, "relpath": candidates[0]["relpath"]})
|
|
return {"matches": matches, "missing": missing, "ambiguous": ambiguous, "failed": failed}
|
|
|
|
|
|
def _commit_tag_import(prepared: dict) -> dict:
|
|
# This is deliberately one synchronous read/merge/write section. No await or
|
|
# worker hand-off may be inserted here: a Tagger request completed before this
|
|
# read must win by being observed as current state, while a later Tagger request
|
|
# naturally wins by writing after this commit.
|
|
db = _load_status_db()
|
|
statuses = db.setdefault("statuses", {})
|
|
imported = []
|
|
unchanged = []
|
|
conflicts = []
|
|
updated_at = _now_iso()
|
|
for record in prepared["matches"]:
|
|
relpath = record["relpath"]
|
|
raw_current = statuses.get(relpath)
|
|
current_entry = raw_current if isinstance(raw_current, dict) else {}
|
|
current = current_entry.get("status", "none")
|
|
if current not in STATUS_VALUES:
|
|
current = "none"
|
|
if current == "none":
|
|
statuses[relpath] = {
|
|
"status": record["tag"],
|
|
"updated_at": updated_at,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
}
|
|
imported.append(record)
|
|
elif current == record["tag"]:
|
|
unchanged.append(record)
|
|
else:
|
|
conflicts.append({**record, "current": current, "import": record["tag"]})
|
|
if imported:
|
|
_save_status_db(db)
|
|
return {
|
|
"imported_records": imported,
|
|
"unchanged_records": unchanged,
|
|
"conflict_records": conflicts,
|
|
"missing_records": prepared["missing"],
|
|
"ambiguous_records": prepared["ambiguous"],
|
|
"failed_records": prepared["failed"],
|
|
}
|
|
|
|
|
|
def _get_supported_checkpoint_extensions() -> set[str]:
|
|
default_extensions = {".ckpt", ".pt", ".bin", ".pth", ".safetensors"}
|
|
extensions = getattr(folder_paths, "supported_pt_extensions", None)
|
|
if not extensions:
|
|
return default_extensions
|
|
return {str(ext).lower() for ext in extensions}
|
|
|
|
|
|
def _clear_checkpoint_filename_cache():
|
|
cache = getattr(folder_paths, "filename_list_cache", None)
|
|
if isinstance(cache, dict):
|
|
cache.pop("checkpoints", None)
|
|
|
|
|
|
def _checkpoint_roots() -> list[Path]:
|
|
try:
|
|
roots = folder_paths.get_folder_paths("checkpoints")
|
|
except Exception:
|
|
roots = []
|
|
return [Path(p).resolve() for p in roots]
|
|
|
|
|
|
def _get_checkpoint_list() -> list[str]:
|
|
"""Return ComfyUI's normal checkpoint list order.
|
|
|
|
This order is intentionally not re-sorted here. Before Refresh All has patched
|
|
every checkpoint combo type, CheckpointNameCycler must expose the same combo
|
|
ordering as ComfyUI's standard CheckpointLoaderSimple, otherwise combo output
|
|
types can diverge and fail when the queue starts.
|
|
"""
|
|
try:
|
|
names = folder_paths.get_filename_list("checkpoints")
|
|
except Exception:
|
|
logger.exception("Failed to list checkpoints")
|
|
names = []
|
|
seen = set()
|
|
out = []
|
|
for name in names:
|
|
relpath = _normalize_relpath(name)
|
|
if relpath in seen or not _is_valid_checkpoint_relpath(relpath):
|
|
continue
|
|
seen.add(relpath)
|
|
out.append(relpath)
|
|
return out
|
|
|
|
|
|
def _scan_registered_checkpoint_folders() -> list[str]:
|
|
results = set()
|
|
extensions = _get_supported_checkpoint_extensions()
|
|
for root in _checkpoint_roots():
|
|
try:
|
|
if not root.exists() or not root.is_dir():
|
|
continue
|
|
for dirpath, _dirnames, filenames in os.walk(root):
|
|
dirpath = Path(dirpath)
|
|
for filename in filenames:
|
|
if Path(filename).suffix.lower() not in extensions:
|
|
continue
|
|
try:
|
|
relpath = Path(dirpath, filename).resolve().relative_to(root).as_posix()
|
|
except Exception:
|
|
continue
|
|
results.add(_normalize_relpath(relpath))
|
|
except Exception:
|
|
logger.exception("Failed to scan checkpoint root: %s", root)
|
|
return sorted(results, key=lambda value: value.lower())
|
|
|
|
|
|
def _get_fresh_checkpoint_values() -> list[str]:
|
|
results = set()
|
|
_clear_checkpoint_filename_cache()
|
|
try:
|
|
results.update(_normalize_relpath(name) for name in folder_paths.get_filename_list("checkpoints"))
|
|
except Exception:
|
|
logger.exception("Failed to refresh checkpoint list through folder_paths")
|
|
try:
|
|
results.update(_scan_registered_checkpoint_folders())
|
|
except Exception:
|
|
logger.exception("Failed to refresh checkpoint list by scanning folders")
|
|
return sorted((x for x in results if x), key=lambda value: value.lower())
|
|
|
|
|
|
def _checkpoint_items() -> list[dict]:
|
|
items = []
|
|
for relpath in _get_checkpoint_list():
|
|
status = _get_status(relpath)
|
|
icon = STATUS_ICON[status] if status != "none" else " "
|
|
label = f"{icon} {relpath}" if icon.strip() else f" {relpath}"
|
|
items.append({
|
|
"ckpt_name": relpath,
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"label": label,
|
|
})
|
|
return items
|
|
|
|
|
|
def _delete_status_summary() -> dict[str, int]:
|
|
checkpoints = _get_checkpoint_list()
|
|
counts = {s: 0 for s in STATUS_VALUES if s != "none"}
|
|
for relpath in checkpoints:
|
|
status = _get_status(relpath)
|
|
if status in counts:
|
|
counts[status] += 1
|
|
counts["none"] = len(checkpoints) - sum(counts.values())
|
|
counts["total"] = len(checkpoints)
|
|
return counts
|
|
|
|
|
|
def _status_summary_text(prefix: str) -> str:
|
|
summary = _delete_status_summary()
|
|
return (
|
|
f"{prefix}: {summary['total']} total "
|
|
f"(👑:{summary['god']}, 💛:{summary['favorite']}, 👍:{summary['nice']}, ✔:{summary['keep']}, 🗑:{summary['delete']}, —:{summary['none']})"
|
|
)
|
|
|
|
|
|
def _status_icons_for_filter(active_statuses: list[str]) -> str:
|
|
return "".join(STATUS_ICON[s] for s in ["god", "favorite", "nice", "keep", "delete", "none"] if s in active_statuses)
|
|
|
|
|
|
def _filter_display(active_statuses: list[str]) -> str:
|
|
icons = _status_icons_for_filter(active_statuses)
|
|
return icons or "all"
|
|
|
|
|
|
def _log_widget_refresh(updated_count: int):
|
|
logger.info("[CheckpointHandpickerSuite] Updated checkpoint widgets: %s", updated_count)
|
|
|
|
|
|
def _parse_saved_int(value, fallback: int = 0) -> int:
|
|
try:
|
|
return int(value)
|
|
except Exception:
|
|
return fallback
|
|
|
|
|
|
def _parse_saved_bool(value, fallback: bool = True) -> bool:
|
|
text = str(value or "").strip().lower()
|
|
if not text:
|
|
return fallback
|
|
if text in {"1", "true", "yes", "on"}:
|
|
return True
|
|
if text in {"0", "false", "no", "off"}:
|
|
return False
|
|
return fallback
|
|
|
|
|
|
def _normalize_status_list(value) -> list[str]:
|
|
raw = value
|
|
if isinstance(raw, str):
|
|
text = raw.strip()
|
|
if not text:
|
|
return []
|
|
try:
|
|
raw = json.loads(text)
|
|
except Exception:
|
|
raw = [x.strip() for x in text.split(",")]
|
|
if isinstance(raw, str):
|
|
raw = [raw]
|
|
if not isinstance(raw, list):
|
|
return []
|
|
seen = {str(x).strip() for x in raw if str(x).strip() in STATUS_VALUES}
|
|
return [s for s in STATUS_VALUES if s in seen]
|
|
|
|
|
|
def _valid_mode(value) -> str:
|
|
value = str(value or "increment")
|
|
return value if value in {"fixed", "increment", "randomize", "shuffle_once"} else "increment"
|
|
|
|
|
|
def _valid_change_every(value) -> int:
|
|
try:
|
|
n = int(value)
|
|
except Exception:
|
|
n = 1
|
|
return max(1, min(999999, n))
|
|
|
|
|
|
def _get_default_cycler_state() -> dict:
|
|
return {
|
|
# runtime_controls are the live controls used by the next execution.
|
|
# Python function arguments are only a fallback when the frontend state
|
|
# has not been initialized yet.
|
|
"runtime_controls": {
|
|
"mode": "increment",
|
|
"change_every": 1,
|
|
"start_checkpoint": "",
|
|
"active_filter": [],
|
|
"use_local_list": True,
|
|
"settings_revision": 0,
|
|
},
|
|
"runtime_controls_initialized": False,
|
|
# runtime_state: mutable execution inventory/decks/counters.
|
|
"local_list": [],
|
|
"local_index": 0,
|
|
"override_queue": [],
|
|
"current_index": 0,
|
|
"repeat_count": 0,
|
|
"cycle_count": 0,
|
|
"shuffle_deck": [],
|
|
# last_execution_snapshot is the original record of the last checkpoint
|
|
# that was actually returned downstream. Local List contents are NOT
|
|
# stored here; they are a separate runtime_state ledger.
|
|
"last_execution_snapshot": None,
|
|
# legacy mirrors kept for old JS/payload compatibility.
|
|
"use_local_list": True,
|
|
"accept_queue": True,
|
|
"active_filter": [],
|
|
"settings_revision": 0,
|
|
"last_ckpt_name": "",
|
|
"last_normal_ckpt_name": "",
|
|
"last_source": "cycle",
|
|
"last_title": "Checkpoint Name Cycler",
|
|
"last_status_text": "Current: (not executed yet)",
|
|
"last_hold_index": 0,
|
|
"last_change_every": 1,
|
|
"last_mode": "increment",
|
|
"last_start_checkpoint": "",
|
|
"execution_revision": 0,
|
|
}
|
|
|
|
|
|
def _get_cycler_state(key: str) -> dict:
|
|
with _STATE_LOCK:
|
|
state = _CYCLER_STATES.get(key)
|
|
if state is None:
|
|
state = _get_default_cycler_state()
|
|
_CYCLER_STATES[key] = state
|
|
return state
|
|
|
|
|
|
def _runtime_controls_from_state(state: dict) -> dict:
|
|
raw = state.get("runtime_controls")
|
|
if not isinstance(raw, dict):
|
|
raw = {}
|
|
controls = {
|
|
"mode": _valid_mode(raw.get("mode", state.get("last_mode", "increment"))),
|
|
"change_every": _valid_change_every(raw.get("change_every", state.get("last_change_every", 1))),
|
|
"start_checkpoint": _normalize_relpath(raw.get("start_checkpoint", state.get("last_start_checkpoint", ""))),
|
|
"active_filter": _normalize_status_list(raw.get("active_filter", state.get("active_filter", []))),
|
|
"use_local_list": bool(raw.get("use_local_list", state.get("use_local_list", True))),
|
|
"settings_revision": _parse_saved_int(raw.get("settings_revision", state.get("settings_revision", 0)), 0),
|
|
}
|
|
state["runtime_controls"] = controls
|
|
# Keep legacy mirrors in sync. These mirrors are runtime controls, not last
|
|
# execution facts.
|
|
state["last_mode"] = controls["mode"]
|
|
state["last_change_every"] = controls["change_every"]
|
|
state["last_start_checkpoint"] = controls["start_checkpoint"]
|
|
state["active_filter"] = list(controls["active_filter"])
|
|
state["use_local_list"] = controls["use_local_list"]
|
|
state["accept_queue"] = controls["use_local_list"]
|
|
state["settings_revision"] = controls["settings_revision"]
|
|
return controls
|
|
|
|
|
|
def _set_runtime_controls(state: dict, *, mode=None, change_every=None, start_checkpoint=None, active_filter=None, use_local_list=None, settings_revision=None, mark_initialized=True) -> dict:
|
|
controls = _runtime_controls_from_state(state)
|
|
if mode is not None:
|
|
controls["mode"] = _valid_mode(mode)
|
|
if change_every is not None:
|
|
controls["change_every"] = _valid_change_every(change_every)
|
|
if start_checkpoint is not None:
|
|
controls["start_checkpoint"] = _normalize_relpath(start_checkpoint)
|
|
if active_filter is not None:
|
|
controls["active_filter"] = _normalize_status_list(active_filter)
|
|
if use_local_list is not None:
|
|
controls["use_local_list"] = bool(use_local_list)
|
|
if settings_revision is not None:
|
|
controls["settings_revision"] = max(0, _parse_saved_int(settings_revision, controls.get("settings_revision", 0)))
|
|
state["runtime_controls"] = controls
|
|
if mark_initialized:
|
|
state["runtime_controls_initialized"] = True
|
|
return _runtime_controls_from_state(state)
|
|
|
|
|
|
def _last_execution_snapshot(state: dict) -> dict:
|
|
snap = state.get("last_execution_snapshot")
|
|
if isinstance(snap, dict) and snap.get("ckpt_name"):
|
|
return snap
|
|
# Migrate old last_* fields if present.
|
|
ckpt = state.get("last_ckpt_name", "") or ""
|
|
if not ckpt:
|
|
return {}
|
|
return {
|
|
"ckpt_name": ckpt,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt),
|
|
"source": state.get("last_source", "cycle"),
|
|
"mode_used": state.get("last_mode", "increment"),
|
|
"base_mode": state.get("last_mode", "increment"),
|
|
"change_every_used": _valid_change_every(state.get("last_change_every", 1)),
|
|
"hold_index": max(0, _parse_saved_int(state.get("last_hold_index"), 0)),
|
|
"filter_used": list(state.get("active_filter", [])),
|
|
"fallback_all": False,
|
|
}
|
|
|
|
|
|
def _should_accept_settings_update(state: dict, revision: int) -> tuple[bool, int]:
|
|
revision = max(0, _parse_saved_int(revision, 0))
|
|
current = max(0, _parse_saved_int(state.get("settings_revision"), 0))
|
|
if revision >= current:
|
|
return True, revision
|
|
return False, current
|
|
|
|
|
|
def _cycler_state_payload(state: dict) -> dict:
|
|
controls = _runtime_controls_from_state(state)
|
|
snap = _last_execution_snapshot(state)
|
|
ckpt = snap.get("ckpt_name", "") or ""
|
|
status = _get_status(ckpt) if ckpt else "none"
|
|
active_filter = list(controls.get("active_filter", []))
|
|
valid_local_items = _valid_local_list(state.get("local_list", []))
|
|
local_count = len(valid_local_items)
|
|
filter_matches = _filter_match_count(active_filter)
|
|
use_local = bool(controls.get("use_local_list", True))
|
|
|
|
if ckpt:
|
|
display_source = snap.get("source", "cycle")
|
|
hold_index = max(0, _parse_saved_int(snap.get("hold_index"), 0))
|
|
change_every = _valid_change_every(snap.get("change_every_used", controls.get("change_every", 1)))
|
|
mode_used = snap.get("mode_used", controls.get("mode", "increment"))
|
|
fallback_all = bool(snap.get("fallback_all", False))
|
|
title = _build_cycler_title(display_source, ckpt, hold_index, change_every, active_filter, fallback_all, local_count if use_local else 0)
|
|
status_text = _build_cycler_status_text(
|
|
display_source,
|
|
ckpt,
|
|
active_filter,
|
|
fallback_all,
|
|
[],
|
|
hold_index,
|
|
change_every,
|
|
filter_matches,
|
|
local_count,
|
|
mode_used,
|
|
local_items=valid_local_items,
|
|
use_local_list=use_local,
|
|
shuffle_deck_remaining=(
|
|
len(state.get("shuffle_deck", []))
|
|
if display_source != "local_list" and (snap.get("base_mode", mode_used) == "shuffle_once" or mode_used == "shuffle_once")
|
|
else None
|
|
),
|
|
)
|
|
else:
|
|
title = "Checkpoint Name Cycler"
|
|
status_text = _build_cycler_idle_status_text("", state)
|
|
|
|
state["last_title"] = title
|
|
state["last_status_text"] = status_text
|
|
return {
|
|
"ckpt_name_str": ckpt,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt) if ckpt else "",
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"title": title,
|
|
"status_text": status_text,
|
|
"use_local_list": bool(controls.get("use_local_list", True)),
|
|
"active_filter": active_filter,
|
|
"filter_matches": filter_matches,
|
|
"filter_total": _checkpoint_total_count(),
|
|
"settings_revision": _parse_saved_int(controls.get("settings_revision"), 0),
|
|
"local_list_count": len(valid_local_items),
|
|
"local_list_items": valid_local_items,
|
|
"runtime_controls": {
|
|
"mode": controls.get("mode", "increment"),
|
|
"change_every": controls.get("change_every", 1),
|
|
"start_checkpoint": controls.get("start_checkpoint", ""),
|
|
"active_filter": active_filter,
|
|
"use_local_list": bool(controls.get("use_local_list", True)),
|
|
"settings_revision": _parse_saved_int(controls.get("settings_revision"), 0),
|
|
},
|
|
"runtime_controls_initialized": bool(state.get("runtime_controls_initialized", False)),
|
|
"shuffle_deck_remaining": len(state.get("shuffle_deck", [])),
|
|
"mode": controls.get("mode", "increment"),
|
|
"change_every": controls.get("change_every", 1),
|
|
"start_checkpoint": controls.get("start_checkpoint", ""),
|
|
"last_execution_snapshot": snap,
|
|
"execution_revision": state.get("execution_revision", 0),
|
|
}
|
|
|
|
|
|
def _store_tagger_state(tab_id, node_id, ckpt_name_str, status):
|
|
key = _state_key(tab_id, node_id)
|
|
with _STATE_LOCK:
|
|
_TAGGER_STATES[key] = {
|
|
"ckpt_name_str": ckpt_name_str,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name_str) if ckpt_name_str else "",
|
|
"status": status,
|
|
"status_icon": STATUS_ICON.get(status, "—"),
|
|
"updated_at": _now_iso(),
|
|
}
|
|
|
|
|
|
def _store_preview_state(tab_id, node_id, ckpt_name_str, status, **extra):
|
|
key = _state_key(tab_id, node_id)
|
|
with _STATE_LOCK:
|
|
_PREVIEW_STATES[key] = {
|
|
"ckpt_name_str": ckpt_name_str,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name_str) if ckpt_name_str else "",
|
|
"status": status,
|
|
"status_icon": STATUS_ICON.get(status, "—"),
|
|
"updated_at": _now_iso(),
|
|
**extra,
|
|
}
|
|
|
|
|
|
def _store_tab_execution_state(tab_id, payload: dict):
|
|
global _EXECUTION_REVISION
|
|
with _STATE_LOCK:
|
|
_EXECUTION_REVISION += 1
|
|
payload = dict(payload)
|
|
payload["execution_revision"] = _EXECUTION_REVISION
|
|
payload["updated_at"] = _now_iso()
|
|
_TAB_EXECUTION_STATES[_clean_tab_id(tab_id)] = payload
|
|
return payload
|
|
|
|
|
|
def _get_tab_execution_state(tab_id) -> dict:
|
|
with _STATE_LOCK:
|
|
return dict(_TAB_EXECUTION_STATES.get(_clean_tab_id(tab_id), {}))
|
|
|
|
|
|
def _send_event(name: str, payload: dict, client_id=None):
|
|
try:
|
|
server = PromptServer.instance
|
|
client_id = client_id or getattr(server, "client_id", None)
|
|
if client_id:
|
|
server.send_sync(name, payload, client_id)
|
|
else:
|
|
server.send_sync(name, payload)
|
|
except Exception:
|
|
logger.exception("[CheckpointHandpickerSuite] failed to send event: %s", name)
|
|
|
|
|
|
def _broadcast_event(name: str, payload: dict):
|
|
try:
|
|
PromptServer.instance.send_sync(name, payload)
|
|
except Exception:
|
|
logger.exception("[CheckpointHandpickerSuite] failed to broadcast event: %s", name)
|
|
|
|
|
|
def _sync_tag_status_changes(statuses_by_relpath: dict[str, str]) -> int:
|
|
global _TAG_TRANSFER_REVISION
|
|
if not statuses_by_relpath:
|
|
return _TAG_TRANSFER_REVISION
|
|
changes = {
|
|
_normalize_relpath(relpath): {
|
|
"ckpt_name_str": _normalize_relpath(relpath),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
}
|
|
for relpath, status in statuses_by_relpath.items()
|
|
if status in STATUS_VALUES
|
|
}
|
|
if not changes:
|
|
return _TAG_TRANSFER_REVISION
|
|
cycler_updates = []
|
|
with _STATE_LOCK:
|
|
_TAG_TRANSFER_REVISION += 1
|
|
revision = _TAG_TRANSFER_REVISION
|
|
for state in _TAGGER_STATES.values():
|
|
change = changes.get(_normalize_relpath(state.get("ckpt_name_str", "")))
|
|
if change:
|
|
state.update({"status": change["status"], "status_icon": change["status_icon"], "updated_at": _now_iso()})
|
|
for state in _PREVIEW_STATES.values():
|
|
change = changes.get(_normalize_relpath(state.get("ckpt_name_str", "")))
|
|
if change:
|
|
state.update({"status": change["status"], "status_icon": change["status_icon"], "updated_at": _now_iso()})
|
|
for state in _TAB_EXECUTION_STATES.values():
|
|
change = changes.get(_normalize_relpath(state.get("ckpt_name_str", "")))
|
|
if change:
|
|
state.update({"status": change["status"], "status_icon": change["status_icon"], "updated_at": _now_iso()})
|
|
for key, state in _CYCLER_STATES.items():
|
|
tab_id, node_id = _split_state_key(key)
|
|
if node_id and node_id != "__none__":
|
|
cycler_updates.append((tab_id, node_id, state))
|
|
|
|
_broadcast_event(TAG_TRANSFER_EVENT, {
|
|
"scope": "global",
|
|
"revision": revision,
|
|
"changes": list(changes.values()),
|
|
})
|
|
for tab_id, node_id, state in cycler_updates:
|
|
_send_cycler_state_update(node_id, state, tab_id=tab_id)
|
|
return revision
|
|
|
|
|
|
def _sync_imported_tag_states(imported_records: list[dict]) -> int:
|
|
return _sync_tag_status_changes({record["relpath"]: record["tag"] for record in imported_records})
|
|
|
|
|
|
def _append_limited_detail(lines: list[str], title: str, records: list[dict], formatter):
|
|
if not records:
|
|
return
|
|
lines.extend(["", f"{title}:", ""])
|
|
for record in records[:TAG_TRANSFER_DETAIL_LIMIT]:
|
|
lines.extend(formatter(record))
|
|
remaining = len(records) - TAG_TRANSFER_DETAIL_LIMIT
|
|
if remaining > 0:
|
|
lines.append(f"... and {remaining} more.")
|
|
|
|
|
|
def _format_tag_export_message(path: Path, payload: dict, diagnostics: dict) -> str:
|
|
evaluations = payload["evaluations"]
|
|
failed = diagnostics["failed"]
|
|
ambiguous = diagnostics["ambiguous"]
|
|
lines = [
|
|
"Export completed.",
|
|
"",
|
|
f"Exported: {len(evaluations)}",
|
|
f"Ambiguous: {len(ambiguous)}",
|
|
f"Failed: {len(failed)}",
|
|
"",
|
|
"File:",
|
|
path.name,
|
|
"",
|
|
"Directory:",
|
|
str(path.parent),
|
|
]
|
|
_append_limited_detail(lines, "Ambiguous", ambiguous, lambda item: [
|
|
f"- {item.get('file_name', '(unknown)')}",
|
|
*([f" Size: {item['file_size']}"] if "file_size" in item else []),
|
|
f" {item.get('reason', '')}",
|
|
])
|
|
_append_limited_detail(lines, "Failed", failed, lambda item: [
|
|
f"- {item.get('file_name', '(unknown)')}",
|
|
f" {item.get('reason', '')}",
|
|
])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_tag_import_message(source: Path, result: dict) -> str:
|
|
imported = result["imported_records"]
|
|
unchanged = result["unchanged_records"]
|
|
conflicts = result["conflict_records"]
|
|
missing = result["missing_records"]
|
|
ambiguous = result["ambiguous_records"]
|
|
failed = result["failed_records"]
|
|
lines = [
|
|
"Import completed.",
|
|
"",
|
|
"Source:",
|
|
source.name,
|
|
"",
|
|
"Directory:",
|
|
str(source.parent),
|
|
"",
|
|
f"Imported: {len(imported)}",
|
|
f"Unchanged: {len(unchanged)}",
|
|
f"Conflicts: {len(conflicts)}",
|
|
f"Missing: {len(missing)}",
|
|
f"Ambiguous: {len(ambiguous)}",
|
|
f"Failed: {len(failed)}",
|
|
"",
|
|
"HandpickerSuite UI refreshed.",
|
|
]
|
|
_append_limited_detail(lines, "Conflicts", conflicts, lambda item: [
|
|
item["file_name"],
|
|
f"Current: {item['current']}",
|
|
f"Import: {item['import']}",
|
|
"",
|
|
])
|
|
_append_limited_detail(lines, "Missing", missing, lambda item: [f"- {item['file_name']}"])
|
|
_append_limited_detail(lines, "Ambiguous", ambiguous, lambda item: [
|
|
f"- {item.get('file_name', '(unknown)')}",
|
|
*([f" Size: {item['file_size']}"] if "file_size" in item else []),
|
|
f" {item.get('reason', '')}",
|
|
])
|
|
_append_limited_detail(lines, "Failed", failed, lambda item: [
|
|
f"- {item.get('file_name', '(unknown)')}",
|
|
f" {item.get('reason', '')}",
|
|
])
|
|
if any(record.get("tag") == "delete" for record in imported):
|
|
lines.extend([
|
|
"",
|
|
"Note:",
|
|
"Imported delete tags did not create deletion reservations.",
|
|
"Toggle delete OFF and ON to create a deletion reservation.",
|
|
])
|
|
return "\n".join(lines).rstrip()
|
|
|
|
|
|
def _send_status_changed(relpath: str, tab_id: str = "", node_id=None):
|
|
status = _get_status(relpath)
|
|
_send_event(STATUS_CHANGED_EVENT, {
|
|
"node": int(node_id) if str(node_id or "").isdigit() else None,
|
|
"tab_id": _clean_tab_id(tab_id),
|
|
"node_class": "CheckpointStatusTagger",
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
})
|
|
|
|
|
|
def _send_cycler_state_update(node_id, state: dict, tab_id: str = ""):
|
|
payload = _cycler_state_payload(state)
|
|
payload.update({
|
|
"node": int(node_id) if str(node_id or "").isdigit() else None,
|
|
"tab_id": _clean_tab_id(tab_id),
|
|
"node_class": "CheckpointNameCycler",
|
|
})
|
|
_send_event(CYCLER_EVENT, payload)
|
|
|
|
|
|
def _split_state_key(key: str) -> tuple[str, str]:
|
|
tab_id, sep, node_id = str(key or "").partition(":")
|
|
if not sep:
|
|
return "__legacy__", tab_id
|
|
return tab_id, node_id
|
|
|
|
|
|
def _refresh_cycler_states_for_status_change(relpath: str):
|
|
"""Resend affected Cycler state after a tag/status change.
|
|
|
|
This keeps the current title, status panel, filter match count, and Local
|
|
List item icons in sync without mutating the Cycler title from frontend
|
|
STATUS_CHANGED_EVENT handling. Events are sent after releasing _STATE_LOCK.
|
|
"""
|
|
relpath = _normalize_relpath(relpath)
|
|
updates: list[tuple[str, str, dict]] = []
|
|
with _STATE_LOCK:
|
|
for key, state in list(_CYCLER_STATES.items()):
|
|
snap = _last_execution_snapshot(state)
|
|
last_ckpt = _normalize_relpath(snap.get("ckpt_name", "") or state.get("last_ckpt_name", ""))
|
|
local_items = {_normalize_relpath(item) for item in state.get("local_list", [])}
|
|
controls = state.get("runtime_controls") if isinstance(state.get("runtime_controls"), dict) else {}
|
|
active_filter = _normalize_status_list(controls.get("active_filter", state.get("active_filter", [])))
|
|
if last_ckpt != relpath and relpath not in local_items and not active_filter:
|
|
continue
|
|
tab_id, node_id = _split_state_key(key)
|
|
if not node_id or node_id == "__none__":
|
|
continue
|
|
updates.append((tab_id, node_id, state))
|
|
for tab_id, node_id, state in updates:
|
|
_send_cycler_state_update(node_id, state, tab_id=tab_id)
|
|
|
|
|
|
def _send_cycler_update(node_id, title, status_text, tab_id="", **extra):
|
|
payload = {
|
|
"node": int(node_id) if str(node_id or "").isdigit() else None,
|
|
"tab_id": _clean_tab_id(tab_id),
|
|
"node_class": "CheckpointNameCycler",
|
|
"title": title,
|
|
"status_text": status_text,
|
|
**extra,
|
|
}
|
|
_send_event(CYCLER_EVENT, payload)
|
|
|
|
|
|
def _send_preview(node_id, title, image: Image.Image | None, extra: dict | None = None, tab_id: str = ""):
|
|
extra = dict(extra or {})
|
|
payload = {
|
|
"node": int(node_id) if str(node_id or "").isdigit() else None,
|
|
"tab_id": _clean_tab_id(tab_id),
|
|
"title": title,
|
|
**extra,
|
|
}
|
|
if image is None:
|
|
payload.update({"image": None, "format": "jpeg"})
|
|
else:
|
|
payload.update(_encode_preview_payload(image))
|
|
_send_event(PREVIEW_EVENT, payload)
|
|
|
|
|
|
def _active_delete_records() -> dict[str, dict]:
|
|
active = {}
|
|
if not DELETE_QUEUE_PATH.exists():
|
|
return active
|
|
try:
|
|
for line in DELETE_QUEUE_PATH.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
rec = json.loads(line)
|
|
typ = rec.get("type")
|
|
relpath = _normalize_relpath(rec.get("ckpt_name_str", ""))
|
|
rid = rec.get("id")
|
|
if typ == "reserve" and rid and relpath:
|
|
active[relpath] = rec
|
|
elif typ == "cancel":
|
|
if relpath:
|
|
active.pop(relpath, None)
|
|
elif rid:
|
|
for key, value in list(active.items()):
|
|
if value.get("id") == rid:
|
|
active.pop(key, None)
|
|
except Exception:
|
|
logger.exception("Failed to parse delete queue")
|
|
return active
|
|
|
|
|
|
def _append_delete_record(record: dict):
|
|
DELETE_QUEUE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with DELETE_QUEUE_PATH.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
|
|
|
|
def _resolve_checkpoint_unique(ckpt_name_str: str):
|
|
relpath = _normalize_relpath(ckpt_name_str)
|
|
if not _is_valid_checkpoint_relpath(relpath):
|
|
return None
|
|
candidates = []
|
|
for root in _checkpoint_roots():
|
|
path = (root / relpath).resolve()
|
|
try:
|
|
path.relative_to(root)
|
|
except Exception:
|
|
continue
|
|
if path.exists() and path.is_file():
|
|
candidates.append(path)
|
|
if len(candidates) != 1:
|
|
return None
|
|
path = candidates[0]
|
|
return {"path": str(path), "root": str(path.parent), "json_path": str(path.with_suffix(".json"))}
|
|
|
|
|
|
def _is_path_under_root(path: Path, root: Path) -> bool:
|
|
try:
|
|
path = Path(path).resolve()
|
|
root = Path(root).resolve()
|
|
return path == root or root in path.parents
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _checkpoint_thumbnail_candidates(safetensors_path: Path) -> list[Path]:
|
|
"""Return conservative sidecar thumbnail candidates for a checkpoint.
|
|
|
|
Only files in the same directory as the checkpoint are considered. This is
|
|
intentionally stricter than ImageDirPreview source-image scanning: delete
|
|
script targets must be direct sidecars, not arbitrary generated images.
|
|
"""
|
|
path = Path(safetensors_path).resolve()
|
|
parent = path.parent
|
|
stem = path.stem
|
|
name = path.name
|
|
patterns: list[str] = []
|
|
for ext in sorted(ALLOWED_THUMBNAIL_SUFFIXES):
|
|
patterns.extend([
|
|
f"{stem}{ext}",
|
|
f"{name}{ext}",
|
|
f"{stem}.thumbnail{ext}",
|
|
f"{stem}.thumb{ext}",
|
|
f"{stem}.preview{ext}",
|
|
f"{name}.thumbnail{ext}",
|
|
f"{name}.thumb{ext}",
|
|
f"{name}.preview{ext}",
|
|
])
|
|
candidates: list[Path] = []
|
|
seen: set[str] = set()
|
|
for pattern in patterns:
|
|
candidate = (parent / pattern).resolve()
|
|
if str(candidate) in seen:
|
|
continue
|
|
seen.add(str(candidate))
|
|
if candidate.exists() and candidate.is_file():
|
|
candidates.append(candidate)
|
|
return candidates
|
|
|
|
|
|
def _load_sidecar_thumbnail_preview(relpath: str) -> tuple[Image.Image | None, str]:
|
|
"""Load the first conservative sidecar thumbnail for a checkpoint.
|
|
|
|
This is the display counterpart of the v9b delete-script sidecar policy.
|
|
It intentionally does not scan output/review folders or ImageDirPreview
|
|
source directories: hover thumbnails are direct checkpoint sidecars only.
|
|
"""
|
|
resolved = _resolve_checkpoint_unique(relpath)
|
|
if not resolved:
|
|
return None, ""
|
|
safetensors_path = Path(resolved["path"]).resolve()
|
|
roots = _checkpoint_roots()
|
|
if roots and not any(_is_path_under_root(safetensors_path, root) for root in roots):
|
|
return None, ""
|
|
for thumb_path in _checkpoint_thumbnail_candidates(safetensors_path):
|
|
if roots and not any(_is_path_under_root(thumb_path, root) for root in roots):
|
|
continue
|
|
try:
|
|
with Image.open(thumb_path) as img:
|
|
img = ImageOps.exif_transpose(img)
|
|
img.thumbnail((512, 512), Image.Resampling.LANCZOS)
|
|
return img.convert("RGB"), str(thumb_path)
|
|
except Exception:
|
|
logger.exception("Failed to load ListSelector thumbnail: %s", thumb_path)
|
|
return None, ""
|
|
|
|
|
|
def _preferred_checkpoint_thumbnail_path(safetensors_path: Path) -> Path:
|
|
existing = _checkpoint_thumbnail_candidates(safetensors_path)
|
|
if existing:
|
|
return Path(existing[0]).resolve()
|
|
return Path(safetensors_path).resolve().with_suffix(".jpg")
|
|
|
|
|
|
def _get_preview_state_item(tab_id, node_id, item_index, preview_session_id=None):
|
|
key = _state_key(tab_id, node_id)
|
|
with _STATE_LOCK:
|
|
state = dict(_PREVIEW_STATES.get(key, {}))
|
|
if preview_session_id and state.get("preview_session_id") != str(preview_session_id):
|
|
return state, None, None, None
|
|
source_paths = state.get("source_paths")
|
|
if not isinstance(source_paths, list):
|
|
return None, None, None, None
|
|
try:
|
|
index = int(item_index)
|
|
except Exception:
|
|
return state, None, None, None
|
|
if index < 0 or index >= len(source_paths):
|
|
return state, None, None, None
|
|
raw_path = str(source_paths[index] or "").strip()
|
|
if not raw_path:
|
|
return state, None, None, None
|
|
try:
|
|
source_path = Path(raw_path).resolve()
|
|
except Exception:
|
|
return state, None, None, None
|
|
return state, index, source_path, _normalize_relpath(state.get("ckpt_name_str", ""))
|
|
|
|
|
|
def _preview_item_exists_for_state(state: dict | None, source_path: Path | None) -> bool:
|
|
if not state or source_path is None:
|
|
return False
|
|
try:
|
|
if not source_path.exists() or not source_path.is_file():
|
|
return False
|
|
except Exception:
|
|
return False
|
|
search_root = _safe_search_root(state.get("search_directory"))
|
|
if search_root is not None and not _is_path_under_root(source_path, search_root):
|
|
return False
|
|
if source_path.suffix.lower() not in ALLOWED_IMAGE_SUFFIXES:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _preview_deleted_indices(state: dict | None) -> list[int]:
|
|
if not state:
|
|
return []
|
|
values = state.get("deleted_indices")
|
|
if not isinstance(values, list):
|
|
return []
|
|
out = []
|
|
for value in values:
|
|
try:
|
|
idx = int(value)
|
|
except Exception:
|
|
continue
|
|
if idx >= 0:
|
|
out.append(idx)
|
|
return sorted(set(out))
|
|
|
|
|
|
def _preview_item_deleted_for_state(state: dict | None, index: int | None) -> bool:
|
|
if index is None:
|
|
return False
|
|
return index in set(_preview_deleted_indices(state))
|
|
|
|
|
|
def _mark_preview_item_deleted(tab_id, node_id, index: int) -> list[int]:
|
|
key = _state_key(tab_id, node_id)
|
|
with _STATE_LOCK:
|
|
state = _PREVIEW_STATES.get(key)
|
|
if not state:
|
|
return []
|
|
deleted = set(_preview_deleted_indices(state))
|
|
deleted.add(int(index))
|
|
merged = sorted(deleted)
|
|
state["deleted_indices"] = merged
|
|
_PREVIEW_STATES[key] = state
|
|
return list(merged)
|
|
|
|
|
|
def _delete_preview_source_image(state: dict, source_path: Path) -> None:
|
|
search_root = _safe_search_root(state.get("search_directory"))
|
|
if search_root is not None and not _is_path_under_root(source_path, search_root):
|
|
raise ValueError("Preview image is outside the allowed search directory.")
|
|
if source_path.suffix.lower() not in ALLOWED_IMAGE_SUFFIXES:
|
|
raise ValueError("Unsupported preview image format.")
|
|
if not source_path.exists() or not source_path.is_file():
|
|
raise FileNotFoundError(str(source_path))
|
|
source_path.unlink()
|
|
|
|
|
|
def _set_checkpoint_thumbnail_from_preview_item(relpath: str, source_path: Path) -> dict:
|
|
relpath = _normalize_relpath(relpath)
|
|
if not _is_valid_checkpoint_relpath(relpath):
|
|
raise ValueError("Invalid checkpoint path.")
|
|
resolved = _resolve_checkpoint_unique(relpath)
|
|
if not resolved:
|
|
raise ValueError("Checkpoint not found.")
|
|
ckpt_path = Path(resolved["path"]).resolve()
|
|
target_path = _preferred_checkpoint_thumbnail_path(ckpt_path)
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with Image.open(source_path) as img:
|
|
img = ImageOps.exif_transpose(img)
|
|
img = img.convert("RGB")
|
|
if max(img.width, img.height) > 1024:
|
|
img.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
|
|
img.save(target_path, format="JPEG", quality=JPEG_QUALITY, optimize=JPEG_OPTIMIZE)
|
|
return {
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"source_path": str(source_path),
|
|
"thumbnail_path": str(target_path),
|
|
}
|
|
|
|
|
|
def _delete_plan_targets() -> list[dict]:
|
|
roots = _checkpoint_roots()
|
|
targets = []
|
|
for relpath, rec in sorted(_active_delete_records().items(), key=lambda item: item[0].lower()):
|
|
raw_path = rec.get("resolved_path") or rec.get("safetensors_path") or ""
|
|
if raw_path:
|
|
safetensors_path = Path(raw_path).resolve()
|
|
else:
|
|
resolved = _resolve_checkpoint_unique(relpath)
|
|
if not resolved:
|
|
continue
|
|
safetensors_path = Path(resolved["path"]).resolve()
|
|
if safetensors_path.suffix.lower() != ALLOWED_CHECKPOINT_SUFFIX:
|
|
continue
|
|
if roots and not any(_is_path_under_root(safetensors_path, root) for root in roots):
|
|
continue
|
|
raw_json_path = rec.get("json_path") or ""
|
|
json_path = Path(raw_json_path).resolve() if raw_json_path else safetensors_path.with_suffix(".json")
|
|
thumbnail_paths = []
|
|
for thumb_path in _checkpoint_thumbnail_candidates(safetensors_path):
|
|
if roots and not any(_is_path_under_root(thumb_path, root) for root in roots):
|
|
continue
|
|
thumbnail_paths.append(str(thumb_path))
|
|
targets.append({
|
|
"ckpt_name_str": relpath,
|
|
"safetensors_path": str(safetensors_path),
|
|
"json_path": str(json_path),
|
|
"thumbnail_paths": thumbnail_paths,
|
|
"reserved_at": rec.get("reserved_at", ""),
|
|
})
|
|
return targets
|
|
|
|
|
|
def _write_delete_script():
|
|
DELETE_QUEUE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
script_path = DELETE_SCRIPT_PATH
|
|
plan_path = DELETE_SCRIPT_PATH.with_name("checkpoint_delete_plan.txt")
|
|
targets = _delete_plan_targets()
|
|
roots_json = json.dumps([str(root) for root in _checkpoint_roots()], ensure_ascii=False, indent=2)
|
|
script_lines = [
|
|
"#!/usr/bin/env python3",
|
|
"# Generated by ComfyUI-CheckpointHandpickerSuite.",
|
|
"# Review each prompt carefully. Default answer is No.",
|
|
"",
|
|
"import json",
|
|
"from pathlib import Path",
|
|
"",
|
|
"SCRIPT_DIR = Path(__file__).resolve().parent",
|
|
"QUEUE_PATH = SCRIPT_DIR / 'checkpoint_delete_queue.jsonl'",
|
|
f"ALLOWED_ROOTS = [Path(p).resolve() for p in {roots_json}]",
|
|
"ALLOWED_THUMBNAIL_SUFFIXES = {'.jpg', '.jpeg', '.png', '.webp'}",
|
|
"ALLOWED_SUFFIXES = {'.safetensors', '.json'} | ALLOWED_THUMBNAIL_SUFFIXES",
|
|
"",
|
|
"def is_under_root(path, root):",
|
|
" path = Path(path).resolve()",
|
|
" root = Path(root).resolve()",
|
|
" return path == root or root in path.parents",
|
|
"",
|
|
"def is_safe_target(path):",
|
|
" path = Path(path).resolve()",
|
|
" if path.suffix.lower() not in ALLOWED_SUFFIXES:",
|
|
" return False",
|
|
" return any(is_under_root(path, root) for root in ALLOWED_ROOTS)",
|
|
"",
|
|
"def thumbnail_candidates(safetensors_path):",
|
|
" path = Path(safetensors_path).resolve()",
|
|
" parent = path.parent",
|
|
" stem = path.stem",
|
|
" name = path.name",
|
|
" patterns = []",
|
|
" for ext in sorted(ALLOWED_THUMBNAIL_SUFFIXES):",
|
|
" patterns.extend([",
|
|
" f'{stem}{ext}', f'{name}{ext}',",
|
|
" f'{stem}.thumbnail{ext}', f'{stem}.thumb{ext}', f'{stem}.preview{ext}',",
|
|
" f'{name}.thumbnail{ext}', f'{name}.thumb{ext}', f'{name}.preview{ext}',",
|
|
" ])",
|
|
" found = []",
|
|
" seen = set()",
|
|
" for pattern in patterns:",
|
|
" candidate = (parent / pattern).resolve()",
|
|
" if str(candidate) in seen:",
|
|
" continue",
|
|
" seen.add(str(candidate))",
|
|
" if candidate.exists() and candidate.is_file() and is_safe_target(candidate):",
|
|
" found.append(candidate)",
|
|
" return found",
|
|
"",
|
|
"def read_records():",
|
|
" if not QUEUE_PATH.exists():",
|
|
" return []",
|
|
" records = []",
|
|
" for line in QUEUE_PATH.read_text(encoding='utf-8', errors='replace').splitlines():",
|
|
" if not line.strip():",
|
|
" continue",
|
|
" try:",
|
|
" rec = json.loads(line)",
|
|
" if isinstance(rec, dict): records.append(rec)",
|
|
" except Exception as exc:",
|
|
" print('Skipping invalid queue record:', exc)",
|
|
" return records",
|
|
"",
|
|
"def active_records():",
|
|
" active = {}",
|
|
" for rec in read_records():",
|
|
" rec_type = rec.get('type')",
|
|
" relpath = rec.get('ckpt_name_str') or ''",
|
|
" rid = rec.get('id')",
|
|
" if rec_type == 'reserve' and relpath:",
|
|
" active[relpath] = rec",
|
|
" elif rec_type == 'cancel':",
|
|
" if relpath: active.pop(relpath, None)",
|
|
" elif rid:",
|
|
" for key, value in list(active.items()):",
|
|
" if value.get('id') == rid: active.pop(key, None)",
|
|
" return active",
|
|
"",
|
|
"def delete_file(path):",
|
|
" path = Path(path).resolve()",
|
|
" if not is_safe_target(path):",
|
|
" print('Unsafe target, skipped:', path); return",
|
|
" if not path.exists():",
|
|
" print('Not found:', path); return",
|
|
" if not path.is_file():",
|
|
" print('Not a file, skipped:', path); return",
|
|
" path.unlink(); print('Deleted:', path)",
|
|
"",
|
|
"def main():",
|
|
" targets = []",
|
|
" for relpath, rec in sorted(active_records().items(), key=lambda item: item[0].lower()):",
|
|
" raw = rec.get('resolved_path') or rec.get('safetensors_path') or ''",
|
|
" if not raw:",
|
|
" print('Skipping target without resolved_path:', relpath); continue",
|
|
" safetensors_path = Path(raw).resolve()",
|
|
" json_path = Path(rec.get('json_path') or safetensors_path.with_suffix('.json')).resolve()",
|
|
" thumbnails = thumbnail_candidates(safetensors_path)",
|
|
" targets.append((relpath, safetensors_path, json_path, thumbnails, rec.get('reserved_at', '')))",
|
|
" print('Checkpoint delete script')",
|
|
" print('Queue file:', QUEUE_PATH)",
|
|
" print('Targets:', len(targets))",
|
|
" for idx, (relpath, safetensors_path, json_path, thumbnails, reserved_at) in enumerate(targets, start=1):",
|
|
" print()",
|
|
" print('[{}/{}] Delete checkpoint?'.format(idx, len(targets)))",
|
|
" print(' relpath:', relpath)",
|
|
" print(' safetensors:', safetensors_path)",
|
|
" print(' json:', json_path)",
|
|
" if thumbnails:",
|
|
" print(' thumbnails:')",
|
|
" for thumb in thumbnails:",
|
|
" print(' -', thumb)",
|
|
" else:",
|
|
" print(' thumbnails: (none)')",
|
|
" answer = input('Delete this checkpoint and listed sidecar thumbnails? (y/N): ').strip().lower()",
|
|
" if answer != 'y': print('Skipped.'); continue",
|
|
" delete_file(safetensors_path)",
|
|
" delete_file(json_path)",
|
|
" for thumb in thumbnails:",
|
|
" delete_file(thumb)",
|
|
" print()",
|
|
" print('Deletion completed. Please return to ComfyUI and click Refresh All.')",
|
|
"",
|
|
"if __name__ == '__main__': main()",
|
|
"",
|
|
]
|
|
script_path.write_text("\n".join(script_lines), encoding="utf-8", newline="\n")
|
|
plan_lines = [
|
|
"Checkpoint delete plan",
|
|
f"Generated at: {_now_iso()}",
|
|
f"Queue file: {DELETE_QUEUE_PATH}",
|
|
f"Active targets: {len(targets)}",
|
|
"",
|
|
]
|
|
for idx, item in enumerate(targets, start=1):
|
|
plan_lines.extend([
|
|
f"[{idx}] {item['ckpt_name_str']}",
|
|
f" safetensors: {item['safetensors_path']}",
|
|
f" json: {item['json_path']}",
|
|
" thumbnails:",
|
|
*([f" - {thumb}" for thumb in item.get("thumbnail_paths", [])] or [" (none)"]),
|
|
f" reserved_at: {item.get('reserved_at', '')}",
|
|
"",
|
|
])
|
|
plan_path.write_text("\n".join(plan_lines), encoding="utf-8", newline="\n")
|
|
return script_path, plan_path, len(targets)
|
|
|
|
|
|
def _prune_missing_delete_records_on_refresh(checkpoint_values: list[str] | None = None) -> dict:
|
|
active = _active_delete_records()
|
|
current_names = {_normalize_relpath(value) for value in (checkpoint_values if checkpoint_values is not None else _get_fresh_checkpoint_values())}
|
|
pruned_reservations = 0
|
|
for relpath, rec in list(active.items()):
|
|
relpath = _normalize_relpath(relpath)
|
|
rid = rec.get("id")
|
|
if not rid or relpath in current_names:
|
|
continue
|
|
raw_path = rec.get("resolved_path") or rec.get("safetensors_path") or ""
|
|
if raw_path:
|
|
try:
|
|
if Path(raw_path).expanduser().resolve().exists():
|
|
continue
|
|
except Exception:
|
|
pass
|
|
_append_delete_record({
|
|
"version": 1,
|
|
"type": "cancel",
|
|
"id": rid,
|
|
"ckpt_name_str": relpath,
|
|
"reason": "missing_on_refresh",
|
|
"cancelled_at": _now_iso(),
|
|
})
|
|
pruned_reservations += 1
|
|
|
|
# A delete tag can outlive its reservation (for example after importing a
|
|
# logical delete tag, deleting the queue manually, or deleting the checkpoint
|
|
# in Explorer). On Refresh All, a delete tag whose exact checkpoint relpath no
|
|
# longer resolves to a physical file is stale and should not remain as a
|
|
# permanent Failed export record. Other missing statuses are intentionally kept.
|
|
db = _load_status_db()
|
|
statuses = db.get("statuses", {}) if isinstance(db.get("statuses"), dict) else {}
|
|
cleared_relpaths = []
|
|
for raw_relpath, raw_entry in list(statuses.items()):
|
|
entry = raw_entry if isinstance(raw_entry, dict) else {}
|
|
if entry.get("status") != "delete":
|
|
continue
|
|
relpath = _normalize_relpath(raw_relpath)
|
|
if relpath in current_names or _checkpoint_candidates_for_relpath(relpath):
|
|
continue
|
|
statuses.pop(raw_relpath, None)
|
|
cleared_relpaths.append(relpath)
|
|
if cleared_relpaths:
|
|
_save_status_db(db)
|
|
|
|
if pruned_reservations:
|
|
try:
|
|
_write_delete_script()
|
|
except Exception:
|
|
logger.exception("Failed to rewrite delete script after pruning missing delete reservations")
|
|
return {
|
|
"pruned_delete_records": pruned_reservations,
|
|
"cleared_missing_delete_statuses": len(cleared_relpaths),
|
|
"cleared_relpaths": cleared_relpaths,
|
|
}
|
|
|
|
|
|
def _patch_backend_checkpoint_classes(checkpoint_values: list[str]) -> list[str]:
|
|
patched = []
|
|
values = list(checkpoint_values) or [""]
|
|
try:
|
|
import nodes as comfy_nodes
|
|
except Exception:
|
|
logger.exception("Failed to import ComfyUI nodes for checkpoint refresh patch")
|
|
return patched
|
|
mappings = getattr(comfy_nodes, "NODE_CLASS_MAPPINGS", {})
|
|
for class_name, node_class in list(mappings.items()):
|
|
name = str(class_name)
|
|
try:
|
|
if name == "CheckpointLoaderSimple":
|
|
def loader_input_types(cls, _values=values):
|
|
return {"required": {"ckpt_name": (list(_values),)}}
|
|
node_class.INPUT_TYPES = classmethod(loader_input_types)
|
|
patched.append(name)
|
|
continue
|
|
if "CheckpointNameSelector" in name:
|
|
def selector_input_types(cls, _values=values):
|
|
return {"required": {"ckpt_name": (list(_values),)}}
|
|
node_class.INPUT_TYPES = classmethod(selector_input_types)
|
|
node_class.RETURN_TYPES = (list(values), "STRING")
|
|
patched.append(name)
|
|
continue
|
|
if name == "CheckpointListSelector":
|
|
def list_selector_input_types(cls, _values=values):
|
|
return {"required": {"checkpoint": ("STRING", {"default": ""})}, "hidden": {"unique_id": "UNIQUE_ID"}}
|
|
node_class.INPUT_TYPES = classmethod(list_selector_input_types)
|
|
node_class.RETURN_TYPES = (list(values), "STRING", "STRING")
|
|
patched.append(name)
|
|
continue
|
|
if name == "CheckpointNameCycler":
|
|
def cycler_input_types(cls, _values=values):
|
|
return {
|
|
"required": {
|
|
"start_checkpoint": (list(_values),),
|
|
"mode": (["fixed", "increment", "randomize", "shuffle_once"], {"default": "increment"}),
|
|
"change_every": ("INT", {"default": 1, "min": 1, "max": 999999}),
|
|
},
|
|
"optional": {
|
|
"hps_tab_id": ("STRING", {"default": "", "hidden": True}),
|
|
"hps_filter_statuses": ("STRING", {"default": "", "hidden": True}),
|
|
"hps_use_local_list": ("STRING", {"default": "", "hidden": True}),
|
|
"hps_settings_revision": ("STRING", {"default": "0", "hidden": True}),
|
|
},
|
|
"hidden": {"unique_id": "UNIQUE_ID"},
|
|
}
|
|
node_class.INPUT_TYPES = classmethod(cycler_input_types)
|
|
node_class.RETURN_TYPES = (list(values), "STRING", "STRING")
|
|
patched.append(name)
|
|
continue
|
|
except Exception as exc:
|
|
logger.warning("[CheckpointHandpickerSuite] failed to patch %s: %s", name, exc)
|
|
return patched
|
|
|
|
|
|
def _pil_from_array(arr: np.ndarray) -> Image.Image:
|
|
arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
|
|
if arr.ndim == 2:
|
|
return Image.fromarray(arr, mode="L")
|
|
if arr.shape[-1] == 1:
|
|
return Image.fromarray(arr[..., 0], mode="L")
|
|
if arr.shape[-1] == 3:
|
|
return Image.fromarray(arr, mode="RGB")
|
|
if arr.shape[-1] == 4:
|
|
return Image.fromarray(arr, mode="RGBA")
|
|
raise ValueError(f"Unsupported channel count: {arr.shape[-1]}")
|
|
|
|
|
|
def _tensor_batch_to_pil(image_tensor) -> list[Image.Image]:
|
|
if image_tensor.ndim == 3:
|
|
image_tensor = image_tensor.unsqueeze(0)
|
|
batch = image_tensor.detach().cpu().numpy()
|
|
return [_pil_from_array(arr) for arr in batch]
|
|
|
|
|
|
def _clamp_image_dir_max_images(value) -> int:
|
|
try:
|
|
value = int(value)
|
|
except Exception:
|
|
value = IMAGE_DIR_DEFAULT_MAX_IMAGES
|
|
return max(1, min(IMAGE_DIR_MAX_IMAGES, value))
|
|
|
|
|
|
def _reference_image_size(images: list[Image.Image]) -> tuple[int, int]:
|
|
if not images:
|
|
return 512, 512
|
|
w, h = images[0].size
|
|
if w <= 0 or h <= 0:
|
|
return 512, 512
|
|
ratio = w / h
|
|
if ratio < 0.25 or ratio > 4.0:
|
|
return 512, 512
|
|
return int(w), int(h)
|
|
|
|
|
|
def _choose_layout_fit(count: int, ref_w: int, ref_h: int, allow_upscale: bool = False) -> SheetLayout:
|
|
count = max(1, int(count))
|
|
ref_w = max(1, int(ref_w))
|
|
ref_h = max(1, int(ref_h))
|
|
best = None
|
|
landscape = ref_w >= ref_h
|
|
for cols in range(1, count + 1):
|
|
rows = math.ceil(count / cols)
|
|
empty = cols * rows - count
|
|
scale = min(MAX_CONTENT_EDGE / max(1, cols * ref_w), MAX_CONTENT_EDGE / max(1, rows * ref_h))
|
|
if not allow_upscale:
|
|
scale = min(1.0, scale)
|
|
if scale <= 0:
|
|
continue
|
|
tile_w = max(1, int(ref_w * scale))
|
|
tile_h = max(1, int(ref_h * scale))
|
|
content_w = cols * tile_w
|
|
content_h = rows * tile_h
|
|
if content_w > MAX_CONTENT_EDGE or content_h > MAX_CONTENT_EDGE:
|
|
continue
|
|
canvas_w = content_w + GAP * max(0, cols - 1)
|
|
canvas_h = content_h + GAP * max(0, rows - 1)
|
|
aspect_score = max(content_w, content_h) / max(1, min(content_w, content_h))
|
|
orientation_bonus = 1 if ((cols >= rows) if landscape else (rows >= cols)) else 0
|
|
tie_score = (round(scale, 8), -empty, orientation_bonus, cols if landscape else rows)
|
|
layout = SheetLayout(cols, rows, tile_w, tile_h, canvas_w, canvas_h, count)
|
|
if best is None or aspect_score < best[0] - 1e-12 or (abs(aspect_score - best[0]) <= 1e-12 and tie_score > best[1]):
|
|
best = (aspect_score, tie_score, layout)
|
|
if best:
|
|
return best[2]
|
|
return SheetLayout(1, count, ref_w, ref_h, ref_w, count * ref_h + GAP * max(0, count - 1), count)
|
|
|
|
|
|
def _fit_image_to_tile(img: Image.Image, tile_w: int, tile_h: int, allow_upscale: bool = False) -> Image.Image:
|
|
resampling = getattr(Image, "Resampling", Image).LANCZOS
|
|
w, h = img.size
|
|
scale = min(tile_w / max(1, w), tile_h / max(1, h))
|
|
if not allow_upscale:
|
|
scale = min(1.0, scale)
|
|
new_w = max(1, int(round(w * scale)))
|
|
new_h = max(1, int(round(h * scale)))
|
|
if (new_w, new_h) == (w, h):
|
|
return img.copy()
|
|
return img.resize((new_w, new_h), resampling)
|
|
|
|
|
|
def _build_contact_sheet(images: list[Image.Image], progress_callback=None, max_images: int | None = None, allow_upscale: bool = False) -> tuple[Image.Image | None, dict]:
|
|
if not images:
|
|
return None, {"count": 0, "columns": 0, "rows": 0, "tile_width": 0, "tile_height": 0}
|
|
if max_images is None:
|
|
limit = len(images)
|
|
else:
|
|
limit = max(1, min(int(max_images), len(images)))
|
|
selected = images[:limit]
|
|
ref_w, ref_h = _reference_image_size(selected)
|
|
layout = _choose_layout_fit(len(selected), ref_w, ref_h, allow_upscale=allow_upscale)
|
|
canvas = Image.new("RGB", (layout.canvas_width, layout.canvas_height), (24, 24, 24))
|
|
normalized = []
|
|
total = layout.count
|
|
for idx, img in enumerate(selected[:layout.count], start=1):
|
|
if progress_callback:
|
|
progress_callback(idx - 1, total, f"Building preview sheet {idx}/{total}")
|
|
normalized.append(_fit_image_to_tile(img, layout.tile_width, layout.tile_height, allow_upscale=allow_upscale))
|
|
if progress_callback:
|
|
progress_callback(idx, total, f"Building preview sheet {idx}/{total}")
|
|
for idx, img in enumerate(normalized):
|
|
row = idx // layout.cols
|
|
col = idx % layout.cols
|
|
x = col * (layout.tile_width + GAP)
|
|
y = row * (layout.tile_height + GAP)
|
|
px = x + (layout.tile_width - img.width) // 2
|
|
py = y + (layout.tile_height - img.height) // 2
|
|
if img.mode == "RGBA":
|
|
canvas.paste(img, (px, py), img)
|
|
else:
|
|
canvas.paste(img.convert("RGB"), (px, py))
|
|
return canvas, {
|
|
"count": layout.count,
|
|
"columns": layout.cols,
|
|
"rows": layout.rows,
|
|
"tile_width": layout.tile_width,
|
|
"tile_height": layout.tile_height,
|
|
"gap": GAP,
|
|
"max_content_edge": MAX_CONTENT_EDGE,
|
|
"allow_upscale": allow_upscale,
|
|
"max_images": max_images if max_images is not None else len(images),
|
|
}
|
|
|
|
|
|
def _encode_preview_payload(image: Image.Image) -> dict:
|
|
if image.mode not in ("RGB", "L"):
|
|
image = image.convert("RGB")
|
|
buf = io.BytesIO()
|
|
image.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=JPEG_OPTIMIZE)
|
|
return {
|
|
"image": base64.b64encode(buf.getvalue()).decode("ascii"),
|
|
"format": "jpeg",
|
|
"width": image.width,
|
|
"height": image.height,
|
|
}
|
|
|
|
|
|
def _send_image_dir_progress(node_id, relpath, tab_id, message, value=0, total=0, max_preview_images=IMAGE_DIR_DEFAULT_MAX_IMAGES):
|
|
status = _get_status(relpath) if relpath else "none"
|
|
_send_preview(node_id, _image_dir_title(relpath), None, {
|
|
"node_class": "ImageDirPreview",
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath) if relpath else "",
|
|
"status": "loading",
|
|
"status_icon": STATUS_ICON[status],
|
|
"message": message,
|
|
"progress": True,
|
|
"progress_message": message,
|
|
"progress_value": value,
|
|
"progress_total": total,
|
|
"max_preview_images": max_preview_images,
|
|
}, tab_id=tab_id)
|
|
|
|
|
|
def _image_dir_title(relpath: str) -> str:
|
|
status = _get_status(relpath) if relpath else "none"
|
|
if relpath and status != "none":
|
|
return f"ImageDir : {STATUS_ICON[status]} {relpath}"
|
|
if relpath:
|
|
return f"ImageDir : {relpath}"
|
|
return "ImageDir Preview"
|
|
|
|
|
|
def _safe_search_root(value) -> Path | None:
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
try:
|
|
return Path(folder_paths.get_output_directory()).resolve()
|
|
except Exception:
|
|
return None
|
|
try:
|
|
path = Path(raw).expanduser().resolve()
|
|
return path if path.exists() and path.is_dir() else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _image_matches_checkpoint(path: Path, relpath: str) -> bool:
|
|
safe = _ckpt_name_safe_from_relpath(relpath).lower()
|
|
stem = Path(relpath).stem.lower()
|
|
hay = "/".join(part.lower() for part in path.parts)
|
|
return safe in hay or stem in hay
|
|
|
|
|
|
def _find_image_dir_candidates(relpath: str, search_directory=None, limit: int = IMAGE_DIR_SCAN_LIMIT) -> list[Path]:
|
|
root = _safe_search_root(search_directory)
|
|
if root is None:
|
|
return []
|
|
found = []
|
|
try:
|
|
for dirpath, _dirnames, filenames in os.walk(root):
|
|
for filename in filenames:
|
|
p = Path(dirpath) / filename
|
|
if p.suffix.lower() not in ALLOWED_IMAGE_SUFFIXES:
|
|
continue
|
|
if not _image_matches_checkpoint(p, relpath):
|
|
continue
|
|
try:
|
|
mtime = p.stat().st_mtime
|
|
except Exception:
|
|
mtime = 0.0
|
|
found.append((mtime, p))
|
|
if len(found) >= limit:
|
|
# Bound the scan as well as the returned candidate list.
|
|
# The sort below selects the newest images from this bounded
|
|
# candidate set.
|
|
break
|
|
if len(found) >= limit:
|
|
break
|
|
except Exception:
|
|
logger.exception("ImageDirPreview scan failed: %s", root)
|
|
found.sort(key=lambda item: item[0], reverse=True)
|
|
return [p for _mtime, p in found[:limit]]
|
|
|
|
|
|
def _load_image_file(path: Path) -> Image.Image | None:
|
|
try:
|
|
with Image.open(path) as img:
|
|
img = ImageOps.exif_transpose(img)
|
|
if img.mode not in ("RGB", "RGBA", "L"):
|
|
img = img.convert("RGB")
|
|
return img.copy()
|
|
except Exception:
|
|
logger.warning("[CheckpointHandpickerSuite] failed to read preview image: %s", path, exc_info=True)
|
|
return None
|
|
|
|
|
|
def _load_image_dir_preview(node_id, relpath, search_directory=None, tab_id="", send_progress=True, max_preview_images=IMAGE_DIR_DEFAULT_MAX_IMAGES):
|
|
max_preview_images = _clamp_image_dir_max_images(max_preview_images)
|
|
|
|
def progress(value: int, total: int, message: str):
|
|
if send_progress:
|
|
_send_image_dir_progress(node_id, relpath, tab_id, message, value, total, max_preview_images)
|
|
|
|
progress(0, max_preview_images, "Searching preview images...")
|
|
paths = _find_image_dir_candidates(relpath, search_directory)
|
|
selected_paths = paths[:max_preview_images]
|
|
progress(len(selected_paths), max_preview_images, f"Found preview images {len(selected_paths)}/{max_preview_images}")
|
|
|
|
images = []
|
|
loaded_paths = []
|
|
total = len(selected_paths)
|
|
for idx, path in enumerate(selected_paths, start=1):
|
|
progress(idx - 1, max(1, total), f"Loading preview image {idx}/{total}")
|
|
img = _load_image_file(path)
|
|
if img is not None:
|
|
images.append(img)
|
|
loaded_paths.append(path)
|
|
progress(idx, max(1, total), f"Loading preview image {idx}/{total}")
|
|
|
|
def cb(value, total, message):
|
|
progress(value, total, message)
|
|
|
|
sheet, extra = _build_contact_sheet(images, progress_callback=cb, max_images=max_preview_images)
|
|
if sheet is not None:
|
|
progress(max_preview_images, max_preview_images, "Encoding preview image...")
|
|
status = _get_status(relpath)
|
|
extra.update({
|
|
"node_class": "ImageDirPreview",
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"message": f"{len(images)} image(s) found" if images else "no preview images found",
|
|
"progress_message": "Preview ready." if sheet is not None else "no preview images found",
|
|
"progress_value": max_preview_images,
|
|
"progress_total": max_preview_images,
|
|
"search_directory": str(_safe_search_root(search_directory) or ""),
|
|
"max_preview_images": max_preview_images,
|
|
"source_paths": [str(p) for p in loaded_paths],
|
|
"preview_session_id": str(uuid.uuid4()),
|
|
"deleted_indices": [],
|
|
})
|
|
return sheet, extra
|
|
|
|
|
|
def _valid_local_list(local_list: list[str] | None) -> list[str]:
|
|
"""Return valid Local List entries while preserving order and duplicates.
|
|
|
|
Local List is a manual per-Cycler queue, not a set. The same checkpoint may
|
|
intentionally be pushed multiple times, and each entry must be consumed
|
|
independently.
|
|
"""
|
|
all_set = set(_get_checkpoint_list())
|
|
local = []
|
|
for item in local_list or []:
|
|
rel = _normalize_relpath(item)
|
|
if rel in all_set:
|
|
local.append(rel)
|
|
return local
|
|
|
|
|
|
def _filter_match_count(active_filter: list[str]) -> int:
|
|
all_checkpoints = _get_checkpoint_list()
|
|
if not active_filter:
|
|
return len(all_checkpoints)
|
|
return sum(1 for ckpt in all_checkpoints if _get_status(ckpt) in active_filter)
|
|
|
|
|
|
def _checkpoint_total_count() -> int:
|
|
return len(_get_checkpoint_list())
|
|
|
|
|
|
def _build_local_list_lines(local_items: list[str], max_items: int = 20) -> list[str]:
|
|
shown = []
|
|
for index, item in enumerate(list(local_items[:max_items]), start=1):
|
|
status = _get_status(item)
|
|
icon = STATUS_ICON[status] if status != "none" else " "
|
|
shown.append(f"{index:>2}. {icon} {item}")
|
|
if len(local_items) > max_items:
|
|
shown.append(f"... and {len(local_items) - max_items} more")
|
|
return shown
|
|
|
|
|
|
def _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filter, fallback_all, local_count) -> str:
|
|
status = _get_status(ckpt_name) if ckpt_name else "none"
|
|
icon = STATUS_ICON[status] if status != "none" else ""
|
|
prefix = "Cycler"
|
|
# Filter state is shown by the filter buttons and the status panel. Do not
|
|
# duplicate it in the title.
|
|
if fallback_all:
|
|
prefix += " [fallback all]"
|
|
if source == "local_list" and local_count > 0:
|
|
prefix += f" [local:{local_count}]"
|
|
body = f"{icon} {ckpt_name}".strip() if ckpt_name else "(none)"
|
|
if source == "local_list":
|
|
return f"{prefix} : {body}"
|
|
return f"{prefix} : {body} ({hold_index}/{change_every})"
|
|
|
|
|
|
def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, queue, hold_index, change_every, match_count, local_count, mode, local_items=None, use_local_list=True, shuffle_deck_remaining=None) -> str:
|
|
status = _get_status(ckpt_name) if ckpt_name else "none"
|
|
current = f"Current: {STATUS_ICON[status]} {ckpt_name}" if status != "none" and ckpt_name else (f"Current: {ckpt_name}" if ckpt_name else "Current: (none)")
|
|
display_mode = "local list" if source == "local_list" else mode
|
|
lines = [
|
|
current,
|
|
f"Mode: {display_mode}" if source == "local_list" else f"Mode: {mode} Hold: {hold_index}/{change_every}",
|
|
f"Filter: {_filter_display(active_filter)} Matches: {match_count} / {_checkpoint_total_count()}",
|
|
]
|
|
if fallback_all:
|
|
lines.append("Filter fallback: all checkpoints")
|
|
if shuffle_deck_remaining is not None:
|
|
lines.append(f"Shuffle Deck Remaining: {shuffle_deck_remaining}")
|
|
if use_local_list and local_count > 0:
|
|
items = list(local_items or [])
|
|
lines.append(f"Local List Remaining: {local_count} item(s)")
|
|
lines.extend(_build_local_list_lines(items))
|
|
if queue:
|
|
lines.append(f"Local List Remaining: {len(queue)} item(s)")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _build_cycler_idle_status_text(action: str, state: dict, detail: str = "") -> str:
|
|
"""Build a status panel for UI-only operations such as Push/Clear Local List.
|
|
|
|
This is a synthesized display: last_execution_snapshot provides the current
|
|
job, runtime_controls provide the live filter/mode settings, and Local List
|
|
comes from runtime_state.
|
|
"""
|
|
controls = _runtime_controls_from_state(state)
|
|
snap = _last_execution_snapshot(state)
|
|
ckpt_name = snap.get("ckpt_name", "") or ""
|
|
status = _get_status(ckpt_name) if ckpt_name else "none"
|
|
active_filter = list(controls.get("active_filter", []))
|
|
local_items = _valid_local_list(state.get("local_list", []))
|
|
local_count = len(local_items)
|
|
mode = controls.get("mode", "increment")
|
|
current = f"Current: {STATUS_ICON[status]} {ckpt_name}" if status != "none" and ckpt_name else (f"Current: {ckpt_name}" if ckpt_name else "Current: (not executed yet)")
|
|
use_local = bool(controls.get("use_local_list", True))
|
|
local_job_active = snap.get("source") == "local_list"
|
|
if local_job_active:
|
|
mode_line = "Mode: local list"
|
|
else:
|
|
hold_index = max(0, _parse_saved_int(snap.get("hold_index"), 0))
|
|
change_every = _valid_change_every(snap.get("change_every_used", controls.get("change_every", 1)))
|
|
mode_line = f"Mode: {snap.get('mode_used', mode)} Hold: {hold_index}/{change_every}" if ckpt_name else f"Mode: {mode}"
|
|
lines = [
|
|
current,
|
|
mode_line,
|
|
f"Filter: {_filter_display(active_filter)} Matches: {_filter_match_count(active_filter)} / {_checkpoint_total_count()}",
|
|
]
|
|
if (not local_job_active) and (controls.get("mode") == "shuffle_once" or snap.get("base_mode") == "shuffle_once" or snap.get("mode_used") == "shuffle_once"):
|
|
lines.append(f"Shuffle Deck Remaining: {len(state.get('shuffle_deck', []))}")
|
|
if use_local and local_count > 0:
|
|
lines.append(f"Local List Remaining: {local_count} item(s)")
|
|
lines.extend(_build_local_list_lines(local_items))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _candidate_checkpoints(active_filter: list[str]) -> tuple[list[str], bool, int]:
|
|
"""Return global candidates, fallback flag, and global filter match count.
|
|
|
|
Local List is intentionally handled outside this function. Filter metadata is
|
|
always about the entire checkpoint set, not about the Local List.
|
|
"""
|
|
all_checkpoints = _get_checkpoint_list()
|
|
if not active_filter:
|
|
return list(all_checkpoints), False, len(all_checkpoints)
|
|
filtered = [ckpt for ckpt in all_checkpoints if _get_status(ckpt) in active_filter]
|
|
if filtered:
|
|
return filtered, False, len(filtered)
|
|
return list(all_checkpoints), bool(all_checkpoints), 0
|
|
|
|
|
|
class CheckpointListSelector:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {"checkpoint": ("STRING", {"default": ""})},
|
|
"hidden": {"unique_id": "UNIQUE_ID"},
|
|
}
|
|
|
|
RETURN_TYPES = (_get_checkpoint_list() or [""], "STRING", "STRING")
|
|
RETURN_NAMES = ("ckpt_name", "ckpt_name_str", "ckpt_name_safe")
|
|
FUNCTION = "select"
|
|
CATEGORY = "HandpickerSuite"
|
|
|
|
def select(self, checkpoint="", unique_id=None):
|
|
rel = _normalize_relpath(checkpoint)
|
|
if rel not in _get_checkpoint_list():
|
|
items = _get_checkpoint_list()
|
|
rel = items[0] if items else ""
|
|
return (rel, rel, _ckpt_name_safe_from_relpath(rel) if rel else "checkpoint")
|
|
|
|
|
|
class CheckpointNameCycler:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
values = _get_checkpoint_list() or [""]
|
|
return {
|
|
"required": {
|
|
"start_checkpoint": (values,),
|
|
"mode": (["fixed", "increment", "randomize", "shuffle_once"], {"default": "increment"}),
|
|
"change_every": ("INT", {"default": 1, "min": 1, "max": 999999}),
|
|
},
|
|
"optional": {
|
|
"hps_tab_id": ("STRING", {"default": "", "hidden": True}),
|
|
"hps_filter_statuses": ("STRING", {"default": "", "hidden": True}),
|
|
"hps_use_local_list": ("STRING", {"default": "", "hidden": True}),
|
|
"hps_settings_revision": ("STRING", {"default": "0", "hidden": True}),
|
|
},
|
|
"hidden": {"unique_id": "UNIQUE_ID"},
|
|
}
|
|
|
|
RETURN_TYPES = (_get_checkpoint_list() or [""], "STRING", "STRING")
|
|
RETURN_NAMES = ("ckpt_name", "ckpt_name_str", "ckpt_name_safe")
|
|
FUNCTION = "cycle"
|
|
CATEGORY = "HandpickerSuite"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, start_checkpoint, mode="increment", change_every=1, hps_tab_id="", hps_filter_statuses="", hps_use_local_list="", hps_settings_revision="0", unique_id=None):
|
|
return float("nan")
|
|
|
|
def cycle(self, start_checkpoint, mode="increment", change_every=1, hps_tab_id="", hps_filter_statuses="", hps_use_local_list="", hps_settings_revision="0", unique_id=None):
|
|
tab_id = _clean_tab_id(hps_tab_id)
|
|
node_id = str(unique_id or "")
|
|
key = _state_key(tab_id, node_id)
|
|
state = _get_cycler_state(key)
|
|
|
|
prompt_mode = _valid_mode(mode)
|
|
prompt_change_every = _valid_change_every(change_every)
|
|
prompt_filter = _normalize_status_list(hps_filter_statuses)
|
|
prompt_use_local_list = _parse_saved_bool(hps_use_local_list, bool(_runtime_controls_from_state(state).get("use_local_list", True)))
|
|
prompt_start_checkpoint = _normalize_relpath(start_checkpoint)
|
|
prompt_revision = _parse_saved_int(hps_settings_revision, _runtime_controls_from_state(state).get("settings_revision", 0))
|
|
|
|
controls = _runtime_controls_from_state(state)
|
|
current_revision = _parse_saved_int(controls.get("settings_revision"), 0)
|
|
# First execution can seed runtime_controls from prompt arguments. After
|
|
# that, prompt JSON is treated as a stale fallback unless its revision is
|
|
# newer than the backend runtime state.
|
|
if not state.get("runtime_controls_initialized") or prompt_revision > current_revision:
|
|
controls = _set_runtime_controls(
|
|
state,
|
|
mode=prompt_mode,
|
|
change_every=prompt_change_every,
|
|
start_checkpoint=prompt_start_checkpoint,
|
|
active_filter=prompt_filter,
|
|
use_local_list=prompt_use_local_list,
|
|
settings_revision=max(prompt_revision, current_revision),
|
|
mark_initialized=True,
|
|
)
|
|
else:
|
|
controls = _runtime_controls_from_state(state)
|
|
|
|
mode = _valid_mode(controls.get("mode", prompt_mode))
|
|
change_every = _valid_change_every(controls.get("change_every", prompt_change_every))
|
|
active_filter = _normalize_status_list(controls.get("active_filter", prompt_filter))
|
|
use_local_list = bool(controls.get("use_local_list", prompt_use_local_list))
|
|
start_checkpoint_used = _normalize_relpath(controls.get("start_checkpoint") or prompt_start_checkpoint)
|
|
|
|
all_checkpoints = _get_checkpoint_list()
|
|
if not all_checkpoints:
|
|
title = "Cycler : no checkpoints"
|
|
status_text = "Current: (no checkpoints)"
|
|
state["last_title"] = title
|
|
state["last_status_text"] = status_text
|
|
_send_cycler_update(node_id, title, status_text, tab_id=tab_id)
|
|
return ("", "", "checkpoint")
|
|
|
|
if start_checkpoint_used not in all_checkpoints:
|
|
# Backend fallback only. The frontend must still keep the visible
|
|
# combo value valid so ComfyUI's queue-time validation does not fail.
|
|
start_checkpoint_used = all_checkpoints[0]
|
|
controls = _set_runtime_controls(state, start_checkpoint=start_checkpoint_used, mark_initialized=bool(state.get("runtime_controls_initialized", False)))
|
|
|
|
global_candidates, fallback_all, global_match_count = _candidate_checkpoints(active_filter)
|
|
local_items = _valid_local_list(state.get("local_list", []))
|
|
use_local_source = use_local_list and bool(local_items)
|
|
# fixed mode is a normal checkpoint selector and deliberately ignores
|
|
# status filters. Local List remains the only source allowed to override
|
|
# its selected start checkpoint.
|
|
if use_local_source:
|
|
candidates = local_items
|
|
elif mode == "fixed":
|
|
candidates = all_checkpoints
|
|
else:
|
|
candidates = global_candidates
|
|
if not candidates:
|
|
candidates = all_checkpoints
|
|
fallback_all = True
|
|
candidate_set = set(candidates)
|
|
|
|
def start_index() -> int:
|
|
requested = _normalize_relpath(start_checkpoint_used)
|
|
if requested in all_checkpoints:
|
|
return all_checkpoints.index(requested)
|
|
return 0
|
|
|
|
source = "local_list" if use_local_source else "cycle"
|
|
hold_index = 1
|
|
selected_index = 0
|
|
|
|
if use_local_source:
|
|
# Manual Local List queue. It is a runtime_state ledger, not part of
|
|
# last_execution_snapshot. Consume exactly one item per execution.
|
|
ckpt_name = local_items[0]
|
|
remaining_local_items = local_items[1:]
|
|
state["local_list"] = remaining_local_items
|
|
state["override_queue"] = remaining_local_items
|
|
state["local_index"] = 0
|
|
selected_index = all_checkpoints.index(ckpt_name)
|
|
title = _build_cycler_title("local_list", ckpt_name, 1, change_every, active_filter, False, len(remaining_local_items))
|
|
status_text = _build_cycler_status_text(
|
|
"local_list",
|
|
ckpt_name,
|
|
active_filter,
|
|
False,
|
|
[],
|
|
1,
|
|
change_every,
|
|
global_match_count,
|
|
len(remaining_local_items),
|
|
"local list",
|
|
local_items=remaining_local_items,
|
|
use_local_list=True,
|
|
shuffle_deck_remaining=None,
|
|
)
|
|
local_items = remaining_local_items
|
|
else:
|
|
repeat_count = max(0, int(state.get("repeat_count", 0)))
|
|
last_ckpt = _normalize_relpath(state.get("last_normal_ckpt_name") or state.get("last_ckpt_name", ""))
|
|
can_hold_last = last_ckpt in candidate_set and repeat_count > 0 and repeat_count < change_every
|
|
|
|
def find_next_increment(current_index: int):
|
|
if not candidates:
|
|
return all_checkpoints[0], 0
|
|
n = len(all_checkpoints)
|
|
start = max(0, min(n - 1, current_index))
|
|
for offset in range(n):
|
|
idx = (start + offset) % n
|
|
ckpt = all_checkpoints[idx]
|
|
if ckpt in candidate_set:
|
|
return ckpt, idx
|
|
ckpt = candidates[0]
|
|
return ckpt, all_checkpoints.index(ckpt)
|
|
|
|
def draw_from_shuffle_deck():
|
|
# v8g: shuffle_once is a global shuffled increment.
|
|
# The deck is built from all checkpoints, while the active filter
|
|
# is applied when scanning the deck. Non-matching checkpoints are
|
|
# skipped AND consumed, matching increment's "cursor moved on"
|
|
# semantics without exposing an extra cursor UI.
|
|
all_set = set(all_checkpoints)
|
|
deck = [x for x in state.get("shuffle_deck", []) if x in all_set]
|
|
|
|
def refill_deck():
|
|
fresh = list(all_checkpoints)
|
|
random.shuffle(fresh)
|
|
return fresh
|
|
|
|
if not deck:
|
|
deck = refill_deck()
|
|
|
|
for _pass in range(2):
|
|
while deck:
|
|
ckpt = deck.pop(0)
|
|
if ckpt in candidate_set:
|
|
state["shuffle_deck"] = deck
|
|
return ckpt
|
|
# The remaining global deck had no item for the current
|
|
# filter. Start a new global deck and scan once more.
|
|
deck = refill_deck()
|
|
|
|
# Defensive fallback. This should be unreachable when candidates
|
|
# is non-empty, but keeps the node alive if checkpoint state
|
|
# changes under us.
|
|
state["shuffle_deck"] = deck
|
|
return candidates[0]
|
|
|
|
if can_hold_last:
|
|
ckpt_name = last_ckpt
|
|
selected_index = all_checkpoints.index(ckpt_name)
|
|
else:
|
|
if mode == "fixed":
|
|
requested = _normalize_relpath(start_checkpoint_used)
|
|
ckpt_name = requested if requested in candidate_set else candidates[0]
|
|
selected_index = all_checkpoints.index(ckpt_name)
|
|
elif mode == "randomize":
|
|
ckpt_name = random.choice(candidates)
|
|
selected_index = all_checkpoints.index(ckpt_name)
|
|
elif mode == "shuffle_once":
|
|
ckpt_name = draw_from_shuffle_deck()
|
|
selected_index = all_checkpoints.index(ckpt_name)
|
|
else:
|
|
ckpt_name, selected_index = find_next_increment(int(state.get("current_index", start_index())))
|
|
|
|
hold_index = repeat_count + 1 if can_hold_last else 1
|
|
if hold_index >= change_every:
|
|
state["repeat_count"] = 0
|
|
if mode == "increment":
|
|
next_index = selected_index + 1
|
|
if next_index >= len(all_checkpoints):
|
|
next_index = 0
|
|
state["cycle_count"] = int(state.get("cycle_count", 0)) + 1
|
|
state["current_index"] = next_index
|
|
else:
|
|
state["current_index"] = selected_index
|
|
else:
|
|
state["repeat_count"] = hold_index
|
|
state["current_index"] = selected_index
|
|
|
|
state["last_normal_ckpt_name"] = ckpt_name
|
|
title = _build_cycler_title("cycle", ckpt_name, hold_index, change_every, active_filter, fallback_all and mode != "fixed", len(local_items) if use_local_list else 0)
|
|
status_text = _build_cycler_status_text(
|
|
"cycle",
|
|
ckpt_name,
|
|
active_filter,
|
|
fallback_all and mode != "fixed",
|
|
[],
|
|
hold_index,
|
|
change_every,
|
|
global_match_count,
|
|
len(local_items),
|
|
mode,
|
|
local_items=local_items,
|
|
use_local_list=use_local_list,
|
|
shuffle_deck_remaining=len(state.get("shuffle_deck", [])) if mode == "shuffle_once" else None,
|
|
)
|
|
|
|
status = _get_status(ckpt_name)
|
|
snapshot = {
|
|
"ckpt_name": ckpt_name,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name),
|
|
"source": source,
|
|
"mode_used": "local_list" if use_local_source else mode,
|
|
"base_mode": mode,
|
|
"change_every_used": change_every,
|
|
"filter_used": list(active_filter),
|
|
"use_local_list_used": use_local_list,
|
|
"fallback_all": False if use_local_source else (fallback_all and mode != "fixed"),
|
|
"matches_at_resolve": global_match_count,
|
|
"local_list_count_after": len(local_items),
|
|
"hold_index": hold_index,
|
|
"hold_total": change_every,
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"title": title,
|
|
"status_text": status_text,
|
|
}
|
|
state["last_execution_snapshot"] = snapshot
|
|
state["last_ckpt_name"] = ckpt_name
|
|
state["last_source"] = source
|
|
state["last_title"] = title
|
|
state["last_status_text"] = status_text
|
|
state["last_hold_index"] = hold_index
|
|
# Mirrors of runtime controls, not queue-prompt arguments.
|
|
state["last_change_every"] = change_every
|
|
state["last_mode"] = mode
|
|
state["last_start_checkpoint"] = start_checkpoint_used
|
|
|
|
exec_state = _store_tab_execution_state(tab_id, {
|
|
"node": int(unique_id) if unique_id is not None else None,
|
|
"node_class": "CheckpointNameCycler",
|
|
"ckpt_name_str": ckpt_name,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"source": source,
|
|
"mode": "local_list" if use_local_source else mode,
|
|
"mode_used": "local_list" if use_local_source else mode,
|
|
"base_mode": mode,
|
|
"change_every": change_every,
|
|
"change_every_used": change_every,
|
|
"filter_used": list(active_filter),
|
|
"fallback_all": False if use_local_source else (fallback_all and mode != "fixed"),
|
|
"matches_at_resolve": global_match_count,
|
|
"local_list_count": len(local_items),
|
|
"hold_index": hold_index,
|
|
"title": title,
|
|
"status_text": status_text,
|
|
"last_execution_snapshot": snapshot,
|
|
})
|
|
state["execution_revision"] = exec_state.get("execution_revision", 0)
|
|
snapshot["execution_revision"] = state["execution_revision"]
|
|
state["last_execution_snapshot"] = snapshot
|
|
_send_cycler_update(
|
|
node_id,
|
|
title,
|
|
status_text,
|
|
tab_id=tab_id,
|
|
ckpt_name_str=ckpt_name,
|
|
ckpt_name_safe=_ckpt_name_safe_from_relpath(ckpt_name),
|
|
status=status,
|
|
status_icon=STATUS_ICON[status],
|
|
source=source,
|
|
mode="local_list" if use_local_source else mode,
|
|
base_mode=mode,
|
|
hold_index=hold_index,
|
|
change_every=change_every,
|
|
local_list_count=len(local_items),
|
|
local_list_items=list(local_items),
|
|
filter_matches=global_match_count,
|
|
filter_total=_checkpoint_total_count(),
|
|
active_filter=list(active_filter),
|
|
use_local_list=use_local_list,
|
|
runtime_controls=controls,
|
|
last_execution_snapshot=snapshot,
|
|
execution_revision=state["execution_revision"],
|
|
)
|
|
return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name))
|
|
|
|
|
|
class CheckpointStatusTagger:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {"ckpt_name_str": ("STRING", {"forceInput": True})},
|
|
"optional": {"hps_tab_id": ("STRING", {"default": "", "hidden": True})},
|
|
"hidden": {"unique_id": "UNIQUE_ID"},
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
FUNCTION = "tag"
|
|
CATEGORY = "HandpickerSuite"
|
|
OUTPUT_NODE = True
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, ckpt_name_str, hps_tab_id="", unique_id=None):
|
|
return float("nan")
|
|
|
|
def tag(self, ckpt_name_str, hps_tab_id="", unique_id=None):
|
|
relpath = _normalize_relpath(ckpt_name_str)
|
|
status = _get_status(relpath)
|
|
title = f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}"
|
|
_store_tagger_state(hps_tab_id, unique_id, relpath, status)
|
|
_send_event(TAGGER_EVENT, {
|
|
"node": int(unique_id) if unique_id is not None else None,
|
|
"tab_id": _clean_tab_id(hps_tab_id),
|
|
"node_class": "CheckpointStatusTagger",
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"title": title,
|
|
})
|
|
return ()
|
|
|
|
|
|
class CheckpointTagExportImport:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"tag_transfer_directory": ("STRING", {"default": "", "multiline": False}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
FUNCTION = "noop"
|
|
CATEGORY = "HandpickerSuite"
|
|
OUTPUT_NODE = False
|
|
|
|
def noop(self, tag_transfer_directory=""):
|
|
return ()
|
|
|
|
|
|
class EphemeralPreview:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {"image": ("IMAGE",)},
|
|
"optional": {"hps_tab_id": ("STRING", {"default": "", "hidden": True})},
|
|
"hidden": {"unique_id": "UNIQUE_ID"},
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
FUNCTION = "preview"
|
|
CATEGORY = "HandpickerSuite/Preview"
|
|
OUTPUT_NODE = True
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, image, hps_tab_id="", unique_id=None):
|
|
return float("nan")
|
|
|
|
def preview(self, image, hps_tab_id="", unique_id=None):
|
|
try:
|
|
pil_images = _tensor_batch_to_pil(image)
|
|
sheet, meta = _build_contact_sheet(pil_images)
|
|
meta["node_class"] = "EphemeralPreview"
|
|
exec_state = _get_tab_execution_state(hps_tab_id)
|
|
ckpt_name = exec_state.get("ckpt_name_str", "")
|
|
status = exec_state.get("status", _get_status(ckpt_name) if ckpt_name else "none")
|
|
if ckpt_name:
|
|
meta.update({
|
|
"ckpt_name_str": ckpt_name,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON.get(status, "—"),
|
|
"execution_revision": exec_state.get("execution_revision", 0),
|
|
})
|
|
title = f"Preview : {STATUS_ICON[status]} {ckpt_name}" if status != "none" else f"Preview : {ckpt_name}"
|
|
_store_preview_state(hps_tab_id, unique_id, ckpt_name, status, execution_revision=exec_state.get("execution_revision", 0))
|
|
else:
|
|
title = "Ephemeral Preview"
|
|
_send_preview(unique_id, title, sheet, meta, tab_id=hps_tab_id)
|
|
except Exception:
|
|
logger.exception("EphemeralPreview failed")
|
|
return ()
|
|
|
|
|
|
class ImageDirPreview:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {"ckpt_name_str": ("STRING", {"forceInput": True})},
|
|
"optional": {
|
|
"search_directory": ("STRING", {"forceInput": True}),
|
|
"max_preview_images": ("INT", {"default": IMAGE_DIR_DEFAULT_MAX_IMAGES, "min": 1, "max": IMAGE_DIR_MAX_IMAGES}),
|
|
"hps_tab_id": ("STRING", {"default": "", "hidden": True}),
|
|
},
|
|
"hidden": {"unique_id": "UNIQUE_ID"},
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
FUNCTION = "preview"
|
|
CATEGORY = "HandpickerSuite/Preview"
|
|
OUTPUT_NODE = True
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, ckpt_name_str, search_directory=None, max_preview_images=IMAGE_DIR_DEFAULT_MAX_IMAGES, hps_tab_id="", unique_id=None):
|
|
return float("nan")
|
|
|
|
def preview(self, ckpt_name_str, search_directory=None, max_preview_images=IMAGE_DIR_DEFAULT_MAX_IMAGES, hps_tab_id="", unique_id=None):
|
|
relpath = _normalize_relpath(ckpt_name_str)
|
|
sheet, extra = _load_image_dir_preview(unique_id, relpath, search_directory, tab_id=hps_tab_id, send_progress=True, max_preview_images=max_preview_images)
|
|
status = _get_status(relpath)
|
|
state_extra = {k: v for k, v in extra.items() if k not in ("ckpt_name_str", "status")}
|
|
_store_preview_state(hps_tab_id, unique_id, relpath, status, **state_extra)
|
|
_send_preview(unique_id, _image_dir_title(relpath), sheet, extra, tab_id=hps_tab_id)
|
|
return ()
|
|
|
|
|
|
routes = PromptServer.instance.routes
|
|
|
|
|
|
@routes.get(f"/{EXTENSION_PREFIX}/node_state")
|
|
async def node_state(request):
|
|
node_id = str(request.query.get("node_id", ""))
|
|
tab_id = _clean_tab_id(request.query.get("tab_id", ""))
|
|
node_class = str(request.query.get("node_class", ""))
|
|
key = _state_key(tab_id, node_id)
|
|
if node_class == "CheckpointNameCycler":
|
|
state = _get_cycler_state(key)
|
|
payload = _cycler_state_payload(state)
|
|
payload.update({"ok": True, "node_id": node_id, "node_class": node_class})
|
|
return web.json_response(payload)
|
|
if node_class == "CheckpointStatusTagger":
|
|
state = dict(_TAGGER_STATES.get(key, {}))
|
|
state.update({"ok": True, "node_id": node_id, "node_class": node_class})
|
|
return web.json_response(state)
|
|
if node_class == "EphemeralPreview":
|
|
state = dict(_PREVIEW_STATES.get(key, {}))
|
|
if not state:
|
|
exec_state = _get_tab_execution_state(tab_id)
|
|
if exec_state.get("ckpt_name_str"):
|
|
state = {
|
|
"ckpt_name_str": exec_state.get("ckpt_name_str", ""),
|
|
"ckpt_name_safe": exec_state.get("ckpt_name_safe", ""),
|
|
"status": exec_state.get("status", "none"),
|
|
"status_icon": exec_state.get("status_icon", "—"),
|
|
"execution_revision": exec_state.get("execution_revision", 0),
|
|
}
|
|
state.update({"ok": True, "node_id": node_id, "node_class": node_class})
|
|
return web.json_response(state)
|
|
return web.json_response({"ok": False, "error": "Unsupported node_class."}, status=400)
|
|
|
|
|
|
@routes.get(f"/{EXTENSION_PREFIX}/selector/thumbnail")
|
|
async def selector_thumbnail(request):
|
|
relpath = _normalize_relpath(request.query.get("ckpt_name_str", ""))
|
|
if not relpath:
|
|
return web.json_response({"ok": False, "error": "ckpt_name_str is required."}, status=400)
|
|
if not _is_valid_checkpoint_relpath(relpath):
|
|
return web.json_response({"ok": False, "error": "Invalid checkpoint path."}, status=400)
|
|
image, path = _load_sidecar_thumbnail_preview(relpath)
|
|
if image is None:
|
|
return web.json_response({"ok": True, "found": False, "ckpt_name_str": relpath})
|
|
payload = _encode_preview_payload(image)
|
|
payload.update({
|
|
"ok": True,
|
|
"found": True,
|
|
"ckpt_name_str": relpath,
|
|
"thumbnail_path": path,
|
|
})
|
|
return web.json_response(payload)
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/image_dir_preview/context_menu")
|
|
async def image_dir_preview_context_menu(request):
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id", ""))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"), data.get("preview_session_id"))
|
|
if not state or index is None or source_path is None or not relpath:
|
|
return web.json_response({"ok": False, "error": "Preview item not found."}, status=404)
|
|
if _preview_item_deleted_for_state(state, index):
|
|
return web.json_response({
|
|
"ok": True,
|
|
"node_id": node_id,
|
|
"item_index": index,
|
|
"exists": False,
|
|
"deleted": True,
|
|
"ckpt_name_str": relpath,
|
|
"source_path": str(source_path),
|
|
"items": [],
|
|
})
|
|
exists = _preview_item_exists_for_state(state, source_path)
|
|
if not exists:
|
|
return web.json_response({
|
|
"ok": True,
|
|
"node_id": node_id,
|
|
"item_index": index,
|
|
"exists": False,
|
|
"deleted": False,
|
|
"ckpt_name_str": relpath,
|
|
"source_path": str(source_path),
|
|
"items": [],
|
|
})
|
|
return web.json_response({
|
|
"ok": True,
|
|
"node_id": node_id,
|
|
"item_index": index,
|
|
"exists": True,
|
|
"deleted": False,
|
|
"ckpt_name_str": relpath,
|
|
"source_path": str(source_path),
|
|
"items": [
|
|
{"id": "delete_image", "label": "Delete image", "enabled": True},
|
|
{"id": "set_thumbnail", "label": "Set as checkpoint thumbnail", "enabled": True},
|
|
],
|
|
})
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/image_dir_preview/delete_image")
|
|
async def image_dir_preview_delete_image(request):
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id", ""))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"), data.get("preview_session_id"))
|
|
if not state or index is None or source_path is None or not relpath:
|
|
return web.json_response({"ok": False, "error": "Preview item not found."}, status=404)
|
|
if _preview_item_deleted_for_state(state, index):
|
|
return web.json_response({
|
|
"ok": True,
|
|
"deleted": True,
|
|
"exists": False,
|
|
"message": "Preview image already deleted.",
|
|
"deleted_indices": _preview_deleted_indices(state),
|
|
"ckpt_name_str": relpath,
|
|
"source_path": str(source_path),
|
|
})
|
|
if not _preview_item_exists_for_state(state, source_path):
|
|
return web.json_response({
|
|
"ok": True,
|
|
"deleted": False,
|
|
"exists": False,
|
|
"message": "Preview image not found.",
|
|
"deleted_indices": _preview_deleted_indices(state),
|
|
"ckpt_name_str": relpath,
|
|
"source_path": str(source_path),
|
|
})
|
|
try:
|
|
_delete_preview_source_image(state, source_path)
|
|
deleted_indices = _mark_preview_item_deleted(tab_id, node_id, index)
|
|
except ValueError as exc:
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"ok": True, "deleted": False, "exists": False, "message": "Preview image not found.", "deleted_indices": _preview_deleted_indices(state), "ckpt_name_str": relpath, "source_path": str(source_path)})
|
|
except Exception:
|
|
logger.exception("[CheckpointHandpickerSuite] failed to delete preview image")
|
|
return web.json_response({"ok": False, "error": "Failed to delete preview image."}, status=500)
|
|
return web.json_response({
|
|
"ok": True,
|
|
"deleted": True,
|
|
"exists": False,
|
|
"message": "Preview image deleted.",
|
|
"deleted_indices": deleted_indices,
|
|
"ckpt_name_str": relpath,
|
|
"source_path": str(source_path),
|
|
})
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/image_dir_preview/set_thumbnail")
|
|
async def image_dir_preview_set_thumbnail(request):
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id", ""))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"), data.get("preview_session_id"))
|
|
if not state or index is None or source_path is None or not relpath:
|
|
return web.json_response({"ok": False, "error": "Preview item not found."}, status=404)
|
|
if _preview_item_deleted_for_state(state, index) or not _preview_item_exists_for_state(state, source_path):
|
|
return web.json_response({
|
|
"ok": True,
|
|
"exists": False,
|
|
"message": "Image deleted",
|
|
"ckpt_name_str": relpath,
|
|
"source_path": str(source_path),
|
|
})
|
|
try:
|
|
payload = _set_checkpoint_thumbnail_from_preview_item(relpath, source_path)
|
|
except ValueError as exc:
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
|
except Exception:
|
|
logger.exception("[CheckpointHandpickerSuite] failed to set checkpoint thumbnail")
|
|
return web.json_response({"ok": False, "error": "Failed to set checkpoint thumbnail."}, status=500)
|
|
payload.update({
|
|
"ok": True,
|
|
"exists": True,
|
|
"message": "Checkpoint thumbnail updated.",
|
|
})
|
|
return web.json_response(payload)
|
|
|
|
|
|
@routes.get(f"/{EXTENSION_PREFIX}/list_checkpoints")
|
|
async def list_checkpoints(_request):
|
|
items = _checkpoint_items()
|
|
return web.json_response({
|
|
"ok": True,
|
|
"items": items,
|
|
"summary": _delete_status_summary(),
|
|
"status_text": _status_summary_text("List only"),
|
|
})
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/tag_transfer/export")
|
|
async def tag_transfer_export(request):
|
|
if not _TAG_TRANSFER_OPERATION_LOCK.acquire(blocking=False):
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "BUSY",
|
|
"message": "Another tag Export / Import operation is already running.",
|
|
}, status=409)
|
|
try:
|
|
try:
|
|
request_data = await request.json()
|
|
except Exception:
|
|
request_data = {}
|
|
try:
|
|
directory = _resolve_tag_transfer_directory(request_data.get("directory", ""), create_default=True)
|
|
except ValueError as exc:
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": str(exc),
|
|
"message": f"Export failed.\n\n{exc}",
|
|
}, status=400)
|
|
# Snapshot the evaluation DB at operation start. Later Tagger changes are
|
|
# intentionally not included in this export.
|
|
snapshot = _load_status_db()
|
|
payload, diagnostics = await asyncio.to_thread(_build_tag_export, snapshot)
|
|
path = await asyncio.to_thread(_write_tag_export, directory, payload)
|
|
message = _format_tag_export_message(path, payload, diagnostics)
|
|
logger.info(
|
|
"[CheckpointHandpickerSuite] Tag export completed: exported=%s ambiguous=%s failed=%s file=%s",
|
|
len(payload["evaluations"]),
|
|
len(diagnostics["ambiguous"]),
|
|
len(diagnostics["failed"]),
|
|
path,
|
|
)
|
|
return web.json_response({
|
|
"ok": True,
|
|
"message": message,
|
|
"file_name": path.name,
|
|
"directory": str(directory),
|
|
"exported": len(payload["evaluations"]),
|
|
"ambiguous": len(diagnostics["ambiguous"]),
|
|
"failed": len(diagnostics["failed"]),
|
|
})
|
|
except Exception as exc:
|
|
logger.exception("[CheckpointHandpickerSuite] Tag export failed")
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "Export failed.",
|
|
"message": f"Export failed.\n\n{exc}",
|
|
}, status=500)
|
|
finally:
|
|
_TAG_TRANSFER_OPERATION_LOCK.release()
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/tag_transfer/import")
|
|
async def tag_transfer_import(request):
|
|
if not _TAG_TRANSFER_OPERATION_LOCK.acquire(blocking=False):
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "BUSY",
|
|
"message": "Another tag Export / Import operation is already running.",
|
|
}, status=409)
|
|
try:
|
|
try:
|
|
request_data = await request.json()
|
|
except Exception:
|
|
request_data = {}
|
|
try:
|
|
directory = _resolve_tag_transfer_directory(request_data.get("directory", ""), create_default=False)
|
|
except ValueError as exc:
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": str(exc),
|
|
"message": f"Import failed.\n\n{exc}",
|
|
}, status=400)
|
|
source = _latest_tag_export_path(directory)
|
|
if source is None:
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "No tag export file was found.",
|
|
"directory": str(directory),
|
|
"message": f"Import failed.\n\nNo tag export file was found.\n\nDirectory:\n{directory}",
|
|
}, status=404)
|
|
try:
|
|
prepared = await asyncio.to_thread(_prepare_tag_import, source)
|
|
except ValueError as exc:
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": str(exc),
|
|
"file_name": source.name,
|
|
"message": f"Latest export:\n{source.name}\n\nERROR:\n{exc}\n\nImport cancelled.",
|
|
}, status=400)
|
|
|
|
# Read the latest DB and commit synchronously only after all slow work is
|
|
# complete, so Tagger changes made before this point are never overwritten.
|
|
result = _commit_tag_import(prepared)
|
|
revision = _sync_imported_tag_states(result["imported_records"])
|
|
message = _format_tag_import_message(source, result)
|
|
response = {
|
|
"ok": True,
|
|
"message": message,
|
|
"file_name": source.name,
|
|
"directory": str(directory),
|
|
"changes": [
|
|
{
|
|
"ckpt_name_str": record["relpath"],
|
|
"status": record["tag"],
|
|
"status_icon": STATUS_ICON[record["tag"]],
|
|
}
|
|
for record in result["imported_records"]
|
|
],
|
|
"imported": len(result["imported_records"]),
|
|
"unchanged": len(result["unchanged_records"]),
|
|
"conflicts": len(result["conflict_records"]),
|
|
"missing": len(result["missing_records"]),
|
|
"ambiguous": len(result["ambiguous_records"]),
|
|
"failed": len(result["failed_records"]),
|
|
"revision": revision,
|
|
}
|
|
logger.info(
|
|
"[CheckpointHandpickerSuite] Tag import completed: imported=%s unchanged=%s conflicts=%s missing=%s ambiguous=%s failed=%s file=%s",
|
|
response["imported"], response["unchanged"], response["conflicts"],
|
|
response["missing"], response["ambiguous"], response["failed"], source,
|
|
)
|
|
return web.json_response(response)
|
|
except Exception as exc:
|
|
logger.exception("[CheckpointHandpickerSuite] Tag import failed")
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "Import failed.",
|
|
"message": f"Import failed.\n\n{exc}",
|
|
}, status=500)
|
|
finally:
|
|
_TAG_TRANSFER_OPERATION_LOCK.release()
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/refresh_all")
|
|
async def refresh_all(_request):
|
|
checkpoint_values = _get_fresh_checkpoint_values()
|
|
delete_cleanup = _prune_missing_delete_records_on_refresh(checkpoint_values)
|
|
pruned_delete_records = delete_cleanup["pruned_delete_records"]
|
|
cleared_missing_delete_statuses = delete_cleanup["cleared_missing_delete_statuses"]
|
|
if delete_cleanup["cleared_relpaths"]:
|
|
_sync_tag_status_changes({relpath: "none" for relpath in delete_cleanup["cleared_relpaths"]})
|
|
patched_classes = _patch_backend_checkpoint_classes(checkpoint_values)
|
|
items = _checkpoint_items()
|
|
summary = _delete_status_summary()
|
|
updated = len(checkpoint_values)
|
|
logger.info("[CheckpointHandpickerSuite] Refresh All completed.")
|
|
logger.info("[CheckpointHandpickerSuite] Checkpoints: %s total (favorite=%s, nice=%s, keep=%s, delete=%s, none=%s)", summary["total"], summary["favorite"], summary["nice"], summary["keep"], summary["delete"], summary["none"])
|
|
logger.info("[CheckpointHandpickerSuite] Backend checkpoint classes patched: %s", patched_classes)
|
|
if pruned_delete_records:
|
|
logger.info("[CheckpointHandpickerSuite] Pruned missing delete reservation(s): %s", pruned_delete_records)
|
|
if cleared_missing_delete_statuses:
|
|
logger.info("[CheckpointHandpickerSuite] Cleared stale missing delete status(es): %s", cleared_missing_delete_statuses)
|
|
_log_widget_refresh(updated)
|
|
return web.json_response({
|
|
"ok": True,
|
|
"items": items,
|
|
"summary": summary,
|
|
"status_text": _status_summary_text("Refresh All"),
|
|
"checkpoint_values": checkpoint_values,
|
|
"patched_classes": patched_classes,
|
|
"pruned_delete_records": pruned_delete_records,
|
|
"cleared_missing_delete_statuses": cleared_missing_delete_statuses,
|
|
})
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/tagger/set_status")
|
|
async def tagger_set_status(request):
|
|
data = await request.json()
|
|
relpath = _normalize_relpath(data.get("ckpt_name_str", ""))
|
|
requested = str(data.get("status", "none"))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
node_id = data.get("node_id") or data.get("node")
|
|
if not _is_valid_checkpoint_relpath(relpath):
|
|
return web.json_response({"ok": False, "error": "Invalid checkpoint path."}, status=400)
|
|
if requested not in STATUS_VALUES:
|
|
return web.json_response({"ok": False, "error": "Invalid status."}, status=400)
|
|
current = _get_status(relpath)
|
|
if requested == "none":
|
|
status = "none"
|
|
elif requested == current:
|
|
status = "none"
|
|
elif requested == "delete" and current not in ("none", "delete"):
|
|
return web.json_response({"ok": False, "error": "Delete is available only from none."}, status=400)
|
|
else:
|
|
status = requested
|
|
_set_status(relpath, status)
|
|
if status == "delete":
|
|
resolved = _resolve_checkpoint_unique(relpath)
|
|
if resolved:
|
|
_append_delete_record({
|
|
"version": 1,
|
|
"type": "reserve",
|
|
"id": f"{int(time.time())}_{uuid.uuid4().hex}",
|
|
"ckpt_name_str": relpath,
|
|
"resolved_path": resolved["path"],
|
|
"json_path": resolved["json_path"],
|
|
"reserved_at": _now_iso(),
|
|
})
|
|
_write_delete_script()
|
|
else:
|
|
# Cancel active delete reservation for this checkpoint if it existed.
|
|
active = _active_delete_records().get(relpath)
|
|
if active and active.get("id"):
|
|
_append_delete_record({
|
|
"version": 1,
|
|
"type": "cancel",
|
|
"id": active.get("id"),
|
|
"ckpt_name_str": relpath,
|
|
"cancelled_at": _now_iso(),
|
|
"reason": f"status_changed_to_{status}",
|
|
})
|
|
_write_delete_script()
|
|
_store_tagger_state(tab_id, node_id, relpath, status)
|
|
_send_status_changed(relpath, tab_id=tab_id, node_id=node_id)
|
|
_refresh_cycler_states_for_status_change(relpath)
|
|
return web.json_response({
|
|
"ok": True,
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"summary": _delete_status_summary(),
|
|
})
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/cycler/set_runtime_controls")
|
|
async def cycler_set_runtime_controls(request):
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id", ""))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
state = _get_cycler_state(_state_key(tab_id, node_id))
|
|
revision = _parse_saved_int(data.get("settings_revision"), _runtime_controls_from_state(state).get("settings_revision", 0))
|
|
accepted, revision = _should_accept_settings_update(state, revision)
|
|
if accepted:
|
|
_set_runtime_controls(
|
|
state,
|
|
mode=data.get("mode") if "mode" in data else None,
|
|
change_every=data.get("change_every") if "change_every" in data else None,
|
|
start_checkpoint=data.get("start_checkpoint", "") if "start_checkpoint" in data else None,
|
|
active_filter=data.get("active_filter") if "active_filter" in data else None,
|
|
use_local_list=bool(data.get("use_local_list")) if "use_local_list" in data else None,
|
|
settings_revision=revision,
|
|
mark_initialized=True,
|
|
)
|
|
_send_cycler_state_update(node_id, state, tab_id=tab_id)
|
|
payload = _cycler_state_payload(state)
|
|
payload.update({"ok": True, "accepted": accepted, "node_id": node_id})
|
|
return web.json_response(payload)
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/cycler/set_flags")
|
|
async def cycler_set_flags(request):
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id", ""))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
state = _get_cycler_state(_state_key(tab_id, node_id))
|
|
revision = _parse_saved_int(data.get("settings_revision"), _parse_saved_int(state.get("settings_revision"), 0))
|
|
accepted, revision = _should_accept_settings_update(state, revision)
|
|
if accepted:
|
|
_set_runtime_controls(
|
|
state,
|
|
mode=data.get("mode") if "mode" in data else None,
|
|
change_every=data.get("change_every") if "change_every" in data else None,
|
|
start_checkpoint=data.get("start_checkpoint", "") if "start_checkpoint" in data else None,
|
|
use_local_list=bool(data.get("use_local_list")) if "use_local_list" in data else None,
|
|
settings_revision=revision,
|
|
mark_initialized=True,
|
|
)
|
|
_send_cycler_state_update(node_id, state, tab_id=tab_id)
|
|
payload = _cycler_state_payload(state)
|
|
payload.update({"ok": True, "accepted": accepted, "use_local_list": state["use_local_list"]})
|
|
return web.json_response(payload)
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/cycler/set_filter")
|
|
async def cycler_set_filter(request):
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id", ""))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
statuses = _normalize_status_list(data.get("statuses", []))
|
|
state = _get_cycler_state(_state_key(tab_id, node_id))
|
|
revision = _parse_saved_int(data.get("settings_revision"), _parse_saved_int(state.get("settings_revision"), 0))
|
|
accepted, revision = _should_accept_settings_update(state, revision)
|
|
if accepted:
|
|
_set_runtime_controls(
|
|
state,
|
|
mode=data.get("mode") if "mode" in data else None,
|
|
change_every=data.get("change_every") if "change_every" in data else None,
|
|
start_checkpoint=data.get("start_checkpoint", "") if "start_checkpoint" in data else None,
|
|
active_filter=statuses.copy(),
|
|
settings_revision=revision,
|
|
mark_initialized=True,
|
|
)
|
|
_send_cycler_state_update(node_id, state, tab_id=tab_id)
|
|
logger.info("[CheckpointHandpickerSuite] Updated cycler filter for tab %s node %s: %s accepted=%s", tab_id, node_id, statuses or ["All"], accepted)
|
|
payload = _cycler_state_payload(state)
|
|
payload.update({"ok": True, "accepted": accepted, "node_id": node_id, "statuses": _runtime_controls_from_state(state).get("active_filter", [])})
|
|
return web.json_response(payload)
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/cycler/local_list_append")
|
|
async def cycler_local_list_append(request):
|
|
data = await request.json()
|
|
relpath = _normalize_relpath(data.get("ckpt_name_str", ""))
|
|
if not _is_valid_checkpoint_relpath(relpath):
|
|
return web.json_response({"ok": False, "error": "Invalid checkpoint path."}, status=400)
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
target_node_ids = [str(x) for x in data.get("target_node_ids", []) if str(x).isdigit()]
|
|
updated = 0
|
|
states = []
|
|
for node_id in target_node_ids:
|
|
state = _get_cycler_state(_state_key(tab_id, node_id))
|
|
if state.get("use_local_list", True):
|
|
local_list = state.setdefault("local_list", [])
|
|
local_list.append(relpath)
|
|
state["override_queue"] = local_list
|
|
state["last_status_text"] = _build_cycler_idle_status_text("", state)
|
|
updated += 1
|
|
_send_cycler_state_update(node_id, state, tab_id=tab_id)
|
|
payload = _cycler_state_payload(state)
|
|
payload.update({"node_id": node_id})
|
|
states.append(payload)
|
|
logger.info("[CheckpointHandpickerSuite] Pushed checkpoint to %s Local List(s) in tab %s: %s", updated, tab_id, relpath)
|
|
return web.json_response({"ok": True, "updated": updated, "states": states})
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/cycler/clear_local_list")
|
|
async def cycler_clear_local_list(request):
|
|
data = await request.json()
|
|
node_id = str(data.get("node_id", ""))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
state = _get_cycler_state(_state_key(tab_id, node_id))
|
|
cleared = len(state.get("local_list", []))
|
|
state["local_list"] = []
|
|
state["override_queue"] = []
|
|
state["last_status_text"] = _build_cycler_idle_status_text("", state)
|
|
_send_cycler_state_update(node_id, state, tab_id=tab_id)
|
|
logger.info("[CheckpointHandpickerSuite] Cleared Local List for tab %s node %s: %s item(s)", tab_id, node_id, cleared)
|
|
payload = _cycler_state_payload(state)
|
|
payload.update({"node_id": node_id})
|
|
return web.json_response({"ok": True, "cleared": cleared, "state": payload})
|
|
|
|
|
|
@routes.post(f"/{EXTENSION_PREFIX}/review/sync_checkpoint")
|
|
async def review_sync_checkpoint(request):
|
|
data = await request.json()
|
|
relpath = _normalize_relpath(data.get("ckpt_name_str", ""))
|
|
tab_id = _clean_tab_id(data.get("tab_id", ""))
|
|
if not _is_valid_checkpoint_relpath(relpath):
|
|
return web.json_response({"ok": False, "error": "Invalid checkpoint path."}, status=400)
|
|
status = _get_status(relpath)
|
|
title = f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}"
|
|
tagger_node_ids = [str(x) for x in data.get("tagger_node_ids", []) if str(x).isdigit()]
|
|
for node_id in tagger_node_ids:
|
|
_store_tagger_state(tab_id, node_id, relpath, status)
|
|
_send_event(TAGGER_EVENT, {
|
|
"node": int(node_id),
|
|
"tab_id": tab_id,
|
|
"node_class": "CheckpointStatusTagger",
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"title": title,
|
|
})
|
|
|
|
preview_targets = data.get("preview_targets")
|
|
if not isinstance(preview_targets, list):
|
|
preview_targets = []
|
|
for node_id in data.get("preview_node_ids", []) or []:
|
|
preview_targets.append({"node_id": node_id})
|
|
|
|
preview_count = 0
|
|
for target in preview_targets:
|
|
try:
|
|
node_id = str(target.get("node_id", ""))
|
|
if not node_id.isdigit():
|
|
continue
|
|
search_directory = target.get("search_directory")
|
|
max_preview_images = _clamp_image_dir_max_images(target.get("max_preview_images", IMAGE_DIR_DEFAULT_MAX_IMAGES))
|
|
sheet, extra = await asyncio.to_thread(
|
|
_load_image_dir_preview,
|
|
node_id,
|
|
relpath,
|
|
search_directory,
|
|
tab_id,
|
|
True,
|
|
max_preview_images,
|
|
)
|
|
state_extra = {k: v for k, v in extra.items() if k not in ("ckpt_name_str", "status")}
|
|
_store_preview_state(tab_id, node_id, relpath, status, **state_extra)
|
|
_send_preview(node_id, _image_dir_title(relpath), sheet, extra, tab_id=tab_id)
|
|
preview_count += 1
|
|
except Exception:
|
|
logger.exception("[CheckpointHandpickerSuite] failed to sync ImageDirPreview")
|
|
|
|
return web.json_response({
|
|
"ok": True,
|
|
"ckpt_name_str": relpath,
|
|
"ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath),
|
|
"status": status,
|
|
"status_icon": STATUS_ICON[status],
|
|
"taggers": len(tagger_node_ids),
|
|
"previews": preview_count,
|
|
})
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"CheckpointListSelector": CheckpointListSelector,
|
|
"CheckpointNameCycler": CheckpointNameCycler,
|
|
"CheckpointStatusTagger": CheckpointStatusTagger,
|
|
"CheckpointTagExportImport": CheckpointTagExportImport,
|
|
"EphemeralPreview": EphemeralPreview,
|
|
"ImageDirPreview": ImageDirPreview,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"CheckpointListSelector": "Checkpoint List Selector",
|
|
"CheckpointNameCycler": "Checkpoint Name Cycler",
|
|
"CheckpointStatusTagger": "Checkpoint Status Tagger",
|
|
"CheckpointTagExportImport": "Checkpoint Tag Export / Import",
|
|
"EphemeralPreview": "Ephemeral Preview",
|
|
"ImageDirPreview": "ImageDir Preview",
|
|
}
|