From 395499ced8562bf18480fae734cd6d4185e3c176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Sun, 7 Jun 2026 00:54:27 +0900 Subject: [PATCH] =?UTF-8?q?=E7=A0=B4=E6=90=8D=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=82=92=E4=BF=AE=E5=BE=A9=E3=81=97=E3=81=A6=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 5 +- suite_nodes.py | 2615 ++++++++++++++-------------- web/checkpoint_handpicker_suite.js | 801 ++++++--- 3 files changed, 1925 insertions(+), 1496 deletions(-) diff --git a/__init__.py b/__init__.py index 9232db6..d59ee3e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,7 @@ -from .suite_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +try: + from .suite_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +except Exception: + from suite_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS WEB_DIRECTORY = "./web" diff --git a/suite_nodes.py b/suite_nodes.py index 6e1b5e3..e5cfeef 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -13,16 +13,16 @@ import uuid from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path, PurePosixPath -from typing import Iterable import folder_paths import numpy as np from aiohttp import web -from PIL import Image +from PIL import Image, ImageOps from server import PromptServer logger = logging.getLogger(__name__) +# 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" @@ -30,40 +30,36 @@ TAGGER_EVENT = "ruminar.checkpoint_handpicker_suite.tagger" STATUS_CHANGED_EVENT = "ruminar.checkpoint_handpicker_suite.status_changed" STATUS_VALUES = ["favorite", "nice", "keep", "delete", "none"] -STATUS_ICON = { - "favorite": "๐Ÿ’›", - "nice": "๐Ÿ‘", - "keep": "โœ”", - "delete": "๐Ÿ—‘", - "none": "โ€”", -} -STATUS_LABEL = { - "favorite": "favorite", - "nice": "nice", - "keep": "keep", - "delete": "delete", - "none": "none", -} +STATUS_ICON = {"favorite": "๐Ÿ’›", "nice": "๐Ÿ‘", "keep": "โœ”", "delete": "๐Ÿ—‘", "none": "โ€”"} +STATUS_LABEL = {"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" -DELETE_QUEUE_PATH = Path(folder_paths.get_temp_directory()).resolve() / "checkpoint_delete_queue.jsonl" -DELETE_SCRIPT_PATH = Path(folder_paths.get_temp_directory()).resolve() / "delete_reserved_checkpoints.py" +try: + TEMP_DIR = Path(folder_paths.get_temp_directory()).resolve() +except Exception: + TEMP_DIR = NODE_DIR / "temp" +DELETE_QUEUE_PATH = TEMP_DIR / "checkpoint_delete_queue.jsonl" +DELETE_SCRIPT_PATH = TEMP_DIR / "delete_reserved_checkpoints.py" JPEG_QUALITY = 80 JPEG_OPTIMIZE = False GAP = 6 IMAGE_DIR_DEFAULT_MAX_IMAGES = 12 IMAGE_DIR_MAX_IMAGES = 80 -MAX_LONG_EDGE = 512 +IMAGE_DIR_SCAN_LIMIT = 3000 MAX_CONTENT_EDGE = 4096 ALLOWED_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".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 def _clean_tab_id(value) -> str: @@ -147,7 +143,6 @@ def _load_status_db() -> dict: def _save_status_db(data: dict): _safe_json_write(STATUS_DB_PATH, data) - # compatibility file for older tools that only know favorites favorites = {} for relpath, entry in data.get("statuses", {}).items(): if entry.get("status") == "favorite": @@ -165,9 +160,7 @@ def _get_status(relpath: str) -> str: db = _load_status_db() entry = db.get("statuses", {}).get(relpath) or {} status = entry.get("status", "none") - if status not in STATUS_VALUES: - status = "none" - return status + return status if status in STATUS_VALUES else "none" def _set_status(relpath: str, status: str): @@ -189,19 +182,36 @@ def _set_status(relpath: str, status: str): _save_status_db(db) -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 _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: @@ -218,6 +228,42 @@ def _get_checkpoint_list() -> list[str]: 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(): @@ -235,17 +281,16 @@ def _checkpoint_items() -> list[dict]: return items -def _select_checkpoint_value(value: str | None) -> tuple[str, str]: - relpath = _normalize_relpath(value or "") - items = _checkpoint_items() - if _is_valid_checkpoint_relpath(relpath): - for item in items: - if item["ckpt_name_str"] == relpath: - return item["ckpt_name_str"], item["ckpt_name_safe"] - if items: - first = items[0] - return first["ckpt_name_str"], first["ckpt_name_safe"] - return "", "checkpoint" +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: @@ -257,23 +302,408 @@ def _status_summary_text(prefix: str) -> str: def _status_icons_for_filter(active_statuses: list[str]) -> str: - icons = [] - for s in ["favorite", "nice", "keep", "delete", "none"]: - if s in active_statuses: - icons.append(STATUS_ICON[s]) - return "".join(icons) + return "".join(STATUS_ICON[s] for s in ["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, + ) + if display_source != "local_list" and (snap.get("base_mode", mode_used) == "shuffle_once" or mode_used == "shuffle_once"): + status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" + 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 _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").splitlines(): + for line in DELETE_QUEUE_PATH.read_text(encoding="utf-8", errors="replace").splitlines(): if not line.strip(): continue rec = json.loads(line) @@ -282,10 +712,13 @@ def _active_delete_records() -> dict[str, dict]: rid = rec.get("id") if typ == "reserve" and rid and relpath: active[relpath] = rec - elif typ == "cancel" and rid: - for key, value in list(active.items()): - if value.get("id") == rid: - active.pop(key, None) + 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 @@ -297,83 +730,6 @@ def _append_delete_record(record: dict): f.write(json.dumps(record, ensure_ascii=False) + "\n") - -def _prune_missing_delete_records_on_refresh(checkpoint_values: list[str] | None = None) -> int: - """Prune active delete reservations whose checkpoint file is already gone. - - The delete script removes files outside ComfyUI, while the queue is an - append-only JSONL log. During Refresh All, cancel active delete reservations - that clearly point to checkpoints no longer present on disk, and clear their - delete status so they disappear from the active delete plan. - - To avoid removing a reservation during temporary path/root glitches, keep - the reservation if the checkpoint relpath still appears in the freshly - scanned checkpoint list. - """ - active = _active_delete_records() - if not active: - return 0 - - current_names = { - _normalize_relpath(value) - for value in (checkpoint_values if checkpoint_values is not None else _get_fresh_checkpoint_values()) - } - pruned = 0 - - for relpath, rec in list(active.items()): - relpath = _normalize_relpath(relpath) - rid = rec.get("id") - if not rid: - continue - - # If ComfyUI can still see the checkpoint by relpath, do not prune the - # reservation just because the old resolved_path is missing. This avoids - # dropping a delete reservation after a root/path change or duplicate - # model-folder situation. - if 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: - # If the path cannot be resolved and the checkpoint is no longer - # listed, treat the reservation as stale and cancel it below. - pass - - _append_delete_record({ - "version": 1, - "type": "cancel", - "id": rid, - "ckpt_name_str": relpath, - "reason": "missing_on_refresh", - "cancelled_at": _now_iso(), - }) - - if _get_status(relpath) == "delete": - _set_status(relpath, "none") - - pruned += 1 - - if pruned: - try: - _write_delete_script() - except Exception: - logger.exception("Failed to rewrite delete script after pruning missing delete reservations") - - return pruned - - -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 _resolve_checkpoint_unique(ckpt_name_str: str): relpath = _normalize_relpath(ckpt_name_str) if not _is_valid_checkpoint_relpath(relpath): @@ -393,155 +749,6 @@ def _resolve_checkpoint_unique(ckpt_name_str: str): return {"path": str(path), "root": str(path.parent), "json_path": str(path.with_suffix(".json"))} - -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 _scan_registered_checkpoint_folders() -> list[str]: - results = set() - extensions = _get_supported_checkpoint_extensions() - for root in _checkpoint_roots(): - try: - root = Path(root).resolve() - 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]: - """Return a fresh checkpoint list for ComfyUI combo widgets. - - This intentionally includes every supported checkpoint extension, while the - Suite's review/tag workflow still filters to .safetensors via - _is_valid_checkpoint_relpath(). - """ - 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 _patch_backend_checkpoint_classes(checkpoint_values: list[str]) -> list[str]: - """Patch common checkpoint combo INPUT_TYPES / RETURN_TYPES after refresh. - - This mirrors the important parts of the old ComfyUI-CheckpointWidgetRefresh - behavior. Updating browser widgets is not enough: ComfyUI also validates - links and combo values against backend class metadata. In particular, - CheckpointNameSelector-style nodes compare their input combo list against - the upstream ckpt_name output type, so both sides must be refreshed. - """ - 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) - # art-venture style CheckpointNameSelector returns a combo-typed - # ckpt_name output. If this remains stale while the input side is - # refreshed, ComfyUI can reject links with a type mismatch. - 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) - # Critical: refresh the combo-typed ckpt_name output as well as - # start_checkpoint. Otherwise downstream selector nodes may see - # different list types after checkpoint deletion. - 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 _is_path_under_root(path: Path, root: Path) -> bool: try: path = Path(path).resolve() @@ -563,12 +770,10 @@ def _delete_plan_targets() -> list[dict]: 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") targets.append({ @@ -585,13 +790,10 @@ def _write_delete_script(): 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.", - "# This script reads checkpoint_delete_queue.jsonl from the same directory at execution time.", "# Review each prompt carefully. Default answer is No.", "", "import json", @@ -611,10 +813,7 @@ def _write_delete_script(): " path = Path(path).resolve()", " if path.suffix.lower() not in ALLOWED_SUFFIXES:", " return False", - " for root in ALLOWED_ROOTS:", - " if is_under_root(path, root):", - " return True", - " return False", + " return any(is_under_root(path, root) for root in ALLOWED_ROOTS)", "", "def read_records():", " if not QUEUE_PATH.exists():", @@ -625,8 +824,7 @@ def _write_delete_script(): " continue", " try:", " rec = json.loads(line)", - " if isinstance(rec, dict):", - " records.append(rec)", + " if isinstance(rec, dict): records.append(rec)", " except Exception as exc:", " print('Skipping invalid queue record:', exc)", " return records", @@ -635,79 +833,56 @@ def _write_delete_script(): " active = {}", " for rec in read_records():", " rec_type = rec.get('type')", - " relpath = rec.get('ckpt_name_str') or rec.get('ckpt_name_relpath') or ''", + " 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)", + " if relpath: active.pop(relpath, None)", " elif rid:", " for key, value in list(active.items()):", - " if value.get('id') == rid:", - " active.pop(key, None)", + " if value.get('id') == rid: active.pop(key, None)", " return active", "", - "def active_targets():", - " targets = []", - " for relpath, rec in sorted(active_records().items(), key=lambda item: item[0].lower()):", - " raw_ckpt_path = rec.get('resolved_path') or rec.get('safetensors_path') or ''", - " if not raw_ckpt_path:", - " print('Skipping target without resolved_path:', relpath)", - " continue", - " safetensors_path = Path(raw_ckpt_path).resolve()", - " 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')", - " targets.append({", - " 'ckpt_name_str': relpath,", - " 'safetensors_path': str(safetensors_path),", - " 'json_path': str(json_path),", - " 'reserved_at': rec.get('reserved_at', ''),", - " })", - " return targets", - "", "def delete_file(path):", " path = Path(path).resolve()", " if not is_safe_target(path):", - " print('Unsafe target, skipped:', path)", - " return", + " print('Unsafe target, skipped:', path); return", " if not path.exists():", - " print('Not found:', path)", - " return", + " print('Not found:', path); return", " if not path.is_file():", - " print('Not a file, skipped:', path)", - " return", - " path.unlink()", - " print('Deleted:', path)", + " print('Not a file, skipped:', path); return", + " path.unlink(); print('Deleted:', path)", "", "def main():", - " targets = active_targets()", + " 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()", + " targets.append((relpath, safetensors_path, json_path, rec.get('reserved_at', '')))", " print('Checkpoint delete script')", " print('Queue file:', QUEUE_PATH)", " print('Targets:', len(targets))", - " for idx, item in enumerate(targets, start=1):", + " for idx, (relpath, safetensors_path, json_path, reserved_at) in enumerate(targets, start=1):", " print()", " print('[{}/{}] Delete checkpoint?'.format(idx, len(targets)))", - " print(' relpath:', item['ckpt_name_str'])", - " print(' safetensors:', item['safetensors_path'])", - " print(' json:', item['json_path'])", + " print(' relpath:', relpath)", + " print(' safetensors:', safetensors_path)", + " print(' json:', json_path)", " answer = input('Delete this checkpoint? (y/N): ').strip().lower()", - " if answer != 'y':", - " print('Skipped.')", - " continue", - " delete_file(item['safetensors_path'])", - " delete_file(item['json_path'])", + " if answer != 'y': print('Skipped.'); continue", + " delete_file(safetensors_path)", + " delete_file(json_path)", " print()", - " print('Deletion completed.')", - " print('Please return to ComfyUI and click:')", - " print('Checkpoint List Selector -> Refresh All')", + " print('Deletion completed. Please return to ComfyUI and click Refresh All.')", "", - "if __name__ == '__main__':", - " main()", + "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()}", @@ -727,13 +902,98 @@ def _write_delete_script(): return script_path, plan_path, len(targets) -def _send_event(name: str, payload: dict, client_id=None): - 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) +def _prune_missing_delete_records_on_refresh(checkpoint_values: list[str] | None = None) -> int: + active = _active_delete_records() + if not active: + return 0 + current_names = {_normalize_relpath(value) for value in (checkpoint_values if checkpoint_values is not None else _get_fresh_checkpoint_values())} + pruned = 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(), + }) + if _get_status(relpath) == "delete": + _set_status(relpath, "none") + pruned += 1 + if pruned: + try: + _write_delete_script() + except Exception: + logger.exception("Failed to rewrite delete script after pruning missing delete reservations") + return pruned + + +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: @@ -753,8 +1013,6 @@ 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() - # EphemeralPreview is a faithful branch-end preview. Do not silently drop - # batch items here; the contact sheet builder will shrink only when needed. return [_pil_from_array(arr) for arr in batch] @@ -766,6 +1024,51 @@ def _clamp_image_dir_max_images(value) -> int: 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 @@ -779,128 +1082,7 @@ def _fit_image_to_tile(img: Image.Image, tile_w: int, tile_h: int, allow_upscale return img.resize((new_w, new_h), resampling) -def _reference_image_size(images: list[Image.Image]) -> tuple[int, int]: - if not images: - return 512, 512 - ref = images[0] - w, h = ref.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 _layout_for_grid(count: int, ref_w: int, ref_h: int, cols: int, rows: int, allow_upscale: bool = False) -> SheetLayout | None: - 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: - return None - 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: - return None - return SheetLayout( - cols, - rows, - tile_w, - tile_h, - content_w + GAP * max(0, cols - 1), - content_h + GAP * max(0, rows - 1), - count, - ) - - -def _choose_layout_fit(count: int, ref_w: int, ref_h: int, allow_upscale: bool = False) -> SheetLayout: - """Choose a contact-sheet layout using MAX_CONTENT_EDGE as the image-content limit. - - GAP is intentionally ignored for the fit calculation and added only to the - final canvas size. This keeps overview sheets readable, with the small gap - overflow accepted by design. - - The grid is chosen by scoring every possible column count and selecting the - layout whose final content area is closest to square. This avoids hard-coded - per-count tables while naturally choosing layouts such as: - - - 5 portrait images -> 3x2 - - 10 portrait images -> 4x3 - - 12 portrait images -> 4x3 - - 80 landscape images -> 8x10 - - Tie-breakers prefer larger tile scale, fewer empty cells, and then a layout - that follows the source image orientation. - """ - count = max(1, int(count)) - ref_w = max(1, int(ref_w)) - ref_h = max(1, int(ref_h)) - - eps = 1e-12 - landscape = ref_w >= ref_h - best = None - - for cols in range(1, count + 1): - rows = math.ceil(count / cols) - cells = cols * rows - empty = cells - count - - raw_w = cols * ref_w - raw_h = rows * ref_h - scale = min(MAX_CONTENT_EDGE / max(1, raw_w), MAX_CONTENT_EDGE / max(1, raw_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 <= 0 or content_h <= 0: - continue - 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) - - # Gemini-style grid choice: make the whole sheet as close to square as - # possible. Use EPS so near-identical float results do not flap between - # layouts. - 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: - best = (aspect_score, tie_score, layout) - continue - - best_aspect, best_tie, _ = best - if aspect_score < best_aspect - eps: - best = (aspect_score, tie_score, layout) - elif abs(aspect_score - best_aspect) <= eps and tie_score > best_tie: - best = (aspect_score, tie_score, layout) - - if best is not None: - return best[2] - return SheetLayout(1, count, ref_w, ref_h, ref_w, count * ref_h + GAP * max(0, count - 1), count) - - -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]: +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: @@ -929,7 +1111,7 @@ def _build_contact_sheet( if img.mode == "RGBA": canvas.paste(img, (px, py), img) else: - canvas.paste(img, (px, py)) + canvas.paste(img.convert("RGB"), (px, py)) return canvas, { "count": layout.count, "columns": layout.cols, @@ -942,583 +1124,305 @@ def _build_contact_sheet( "max_images": max_images if max_images is not None else len(images), } -def _encode_jpeg(image: Image.Image) -> str: + +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 base64.b64encode(buf.getvalue()).decode("ascii") - - -def _send_preview(node_id, title: str, image: Image.Image | None, extra: dict | None = None, tab_id: str | None = None): - payload = { - "node": int(node_id) if node_id is not None and str(node_id).isdigit() else None, - "title": title, + return { + "image": base64.b64encode(buf.getvalue()).decode("ascii"), "format": "jpeg", - "image": None, - "width": 0, - "height": 0, + "width": image.width, + "height": image.height, } - if tab_id is not None: - payload["tab_id"] = _clean_tab_id(tab_id) - if image is not None: - payload["image"] = _encode_jpeg(image) - payload["width"] = image.width - payload["height"] = image.height - if extra: - payload.update(extra) - _send_event(PREVIEW_EVENT, payload) + + +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) - return f"ImageDir : {STATUS_ICON[status]} {relpath}" if status != "none" else f"ImageDir : {relpath}" + 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 _send_image_dir_progress(node_id, relpath: str, message: str, value: int = 0, total: int = 0, tab_id: str | None = None, root: str = ""): - # Keep both the old CheckpointCleanupReview field names and the newer - # ImageDirPreview caption field. Some frontend builds draw one or the other. - _send_preview( - node_id, - _image_dir_title(relpath), - None, - { - "node_class": "ImageDirPreview", - "ckpt_name_str": relpath, - "search_directory": root, - "status": "loading", - "message": message, - "progress_message": message, - "progress_value": value, - "progress_total": total, - "progress": True, - }, - tab_id=tab_id, - ) - - -def _resolve_search_root(search_directory: str | None) -> tuple[Path, str]: - if search_directory is None: - return Path(folder_paths.get_output_directory()).resolve(), "default_output" - value = str(search_directory).strip() - if not value: - return Path(folder_paths.get_output_directory()).resolve(), "default_output" +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: - candidate = Path(value).expanduser().resolve() - if candidate.exists() and candidate.is_dir(): - return candidate, "custom" - return Path(folder_paths.get_output_directory()).resolve(), "invalid_fallback_output" + path = Path(raw).expanduser().resolve() + return path if path.exists() and path.is_dir() else None except Exception: - return Path(folder_paths.get_output_directory()).resolve(), "invalid_fallback_output" + return None -def _iter_dirs_newest_first(root: Path): - root = root.resolve() - queue = [root] - while queue: - current = queue.pop(0) - yield current - try: - children = [p for p in current.iterdir() if p.is_dir()] - children.sort(key=lambda p: p.stat().st_mtime, reverse=True) - queue[0:0] = children - except Exception: - continue +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_preview_images(ckpt_name_str: str, search_directory: str | None = None, max_images: int = IMAGE_DIR_DEFAULT_MAX_IMAGES, progress_callback=None) -> tuple[list[Path], str, str]: - relpath = _normalize_relpath(ckpt_name_str) - safe = _ckpt_name_safe_from_relpath(relpath) - root, mode = _resolve_search_root(search_directory) - found: list[Path] = [] - if not root.exists() or not root.is_dir(): - return found, str(root), "missing_search_root" - - safe_lower = safe.lower() - for directory in _iter_dirs_newest_first(root): - try: - files = [ - p for p in directory.iterdir() - if p.is_file() - and p.suffix.lower() in ALLOWED_IMAGE_SUFFIXES - and safe_lower in p.stem.lower() - ] - files.sort(key=lambda p: p.stat().st_mtime, reverse=True) - for file in files: - resolved = file.resolve() - # A symlink should not escape the selected preview root. - try: - if resolved != root and root not in resolved.parents: - continue - except Exception: +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 - found.append(resolved) - if progress_callback: - progress_callback(len(found), max_images, f"Found preview images {len(found)}/{max_images}") - if len(found) >= max_images: - return found, str(root), mode - except Exception: - continue - return found, str(root), mode + 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: + # Keep scanning bounded. Sort latest first below. + pass + 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_pil_images(paths: list[Path], max_images: int | None = None, progress_callback=None) -> list[Image.Image]: - images: list[Image.Image] = [] - limit = len(paths) if max_images is None else max(1, min(int(max_images), len(paths))) - total = limit - for index, path in enumerate(paths[:limit], start=1): - if progress_callback: - progress_callback(index - 1, total, f"Loading preview images {index}/{total}") - try: - with Image.open(path) as img: - images.append(img.convert("RGB")) - except Exception: - logger.exception("Failed to open preview image: %s", path) - if progress_callback: - progress_callback(index, total, f"Loading preview images {index}/{total}") - return images +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, - ckpt_name_str: str, - search_directory: str | None = None, - tab_id: str | None = None, - send_progress: bool = True, - max_preview_images: int = IMAGE_DIR_DEFAULT_MAX_IMAGES, -) -> tuple[Image.Image | None, dict]: - relpath = _normalize_relpath(ckpt_name_str) +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, root: str = ""): + def progress(value: int, total: int, message: str): if send_progress: - _send_image_dir_progress(node_id, relpath, message, value=value, total=total, tab_id=tab_id, root=root) + _send_image_dir_progress(node_id, relpath, tab_id, message, value, total, max_preview_images) progress(0, max_preview_images, "Searching preview images...") - paths, root, mode = _find_preview_images( - relpath, - search_directory, - max_images=max_preview_images, - progress_callback=lambda v, t, m: progress(v, t, m, root=""), - ) - progress(len(paths), max_preview_images, f"Found preview images {len(paths)}/{max_preview_images}", root=root) + 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}") - pil_images = _load_pil_images( - paths, - max_images=max_preview_images, - progress_callback=lambda v, t, m: progress(v, t, m, root=root), - ) if paths else [] - if pil_images: - sheet, meta = _build_contact_sheet( - pil_images, - progress_callback=lambda v, t, m: progress(v, t, m, root=root), - max_images=max_preview_images, - allow_upscale=False, - ) - progress(max_preview_images, max_preview_images, "Encoding preview image...", root=root) - else: - sheet, meta = None, {"count": 0, "columns": 0, "rows": 0, "tile_width": 0, "tile_height": 0} + images = [] + 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) + progress(idx, max(1, total), f"Loading preview image {idx}/{total}") - extra = { + 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, - "search_directory": root, - "search_mode": mode, - "preview_found": bool(paths), - "preview_count": len(paths), - "max_preview_images": max_preview_images, - "status": "ready" if sheet is not None else "no_preview", - "message": "Preview ready." if sheet is not None else f"No preview images found: {relpath}", - "progress_message": "Preview ready." if sheet is not None else f"No preview images found: {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, - } - extra.update(meta) + "search_directory": str(_safe_search_root(search_directory) or ""), + "max_preview_images": max_preview_images, + "source_paths": [str(p) for p in selected_paths], + }) return sheet, extra -def _send_cycler_update( - node_id: str | None, - title: str, - status_text: str, - tab_id: str | None = None, - ckpt_name_str: str | None = None, - source: str | None = None, - mode: str | None = None, - hold_index: int | None = None, - change_every: int | None = None, -): - payload = { - "node": int(node_id) if node_id is not None and str(node_id).isdigit() else None, - "title": title, - "status_text": status_text, - } - if tab_id is not None: - payload["tab_id"] = _clean_tab_id(tab_id) - if ckpt_name_str: - relpath = _normalize_relpath(ckpt_name_str) - status = _get_status(relpath) - payload.update({ - "ckpt_name_str": relpath, - "ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath), - "status": status, - "status_icon": STATUS_ICON[status], - }) - if source is not None: - payload["source"] = source - if mode is not None: - payload["mode"] = mode - if hold_index is not None: - payload["hold_index"] = hold_index - if change_every is not None: - payload["change_every"] = change_every - _send_event(CYCLER_EVENT, payload) +def _valid_local_list(local_list: list[str] | None) -> list[str]: + """Return valid Local List entries while preserving order and duplicates. -def _get_cycler_state(node_key: str) -> dict: - with _STATE_LOCK: - state = _CYCLER_STATES.get(node_key) - if state is None: - state = { - "use_local_list": True, - "local_list": [], - "active_filter": [], # empty means All - # Monotonic frontend settings revision for persisted Cycler - # settings. Queued prompts may contain older hidden widget values; - # compare revisions so stale queue snapshots cannot rewind filters. - "settings_revision": -1, - "repeat_count": 0, - "current_index": 0, - "cycle_count": 0, - "last_checkpoint_hash": "", - "last_ckpt_name": "", - "last_normal_ckpt_name": "", - "last_title": "", - "last_hold_index": 0, - "last_change_every": 1, - "last_mode": "increment", - "last_source": "", - "last_all_checkpoint_hash": "", - "shuffle_deck": [], - # compatibility with older draft state/routes - "accept_queue": True, - "override_queue": [], - "accept_filter": True, - } - _CYCLER_STATES[node_key] = state - # migrate older draft keys in-memory - if "use_local_list" not in state: - state["use_local_list"] = bool(state.get("accept_queue", True)) - if "local_list" not in state: - state["local_list"] = list(state.get("override_queue", [])) - if "active_filter" not in state: - state["active_filter"] = [] - if "settings_revision" not in state: - state["settings_revision"] = -1 - if "shuffle_deck" not in state: - state["shuffle_deck"] = [] - if "last_mode" not in state: - state["last_mode"] = "increment" - if "last_source" not in state: - state["last_source"] = "" - if "last_all_checkpoint_hash" not in state: - state["last_all_checkpoint_hash"] = "" - if "last_normal_ckpt_name" not in state: - state["last_normal_ckpt_name"] = state.get("last_ckpt_name", "") - return state - -def _checkpoint_hash(names: Iterable[str]) -> str: - return "\n".join(names) - - -def _filter_checkpoints(checkpoints: list[str], statuses: list[str]) -> tuple[list[str], bool]: - if not statuses: - return checkpoints, False - active = [s for s in statuses if s in STATUS_VALUES] - if not active: - return checkpoints, False - filtered = [ckpt for ckpt in checkpoints if _get_status(ckpt) in active] - if not filtered: - return checkpoints, True - return filtered, False - - -def _active_filter_values(statuses: list[str] | None) -> list[str]: - return [s for s in (statuses or []) if s in STATUS_VALUES] - - -def _parse_saved_filter_statuses(value) -> list[str] | None: - """Parse persisted Cycler filter settings. - - None means "no persisted value was supplied" and keeps the current - in-memory state. An empty list means an explicit All filter. + 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. """ - if value is None: - return None - text = str(value).strip() - if text == "": - return None - try: - parsed = json.loads(text) - except Exception: - parsed = [part.strip() for part in text.split(",")] - if isinstance(parsed, str): - parsed = [parsed] - if not isinstance(parsed, list): - return [] - selected = [] - for item in parsed: - status = str(item).strip() - if status in STATUS_VALUES and status not in selected: - selected.append(status) - return selected + 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 _parse_saved_bool(value) -> bool | None: - if value is None: - return None - text = str(value).strip().lower() - if text == "": - return None - if text in ("1", "true", "yes", "on"): - return True - if text in ("0", "false", "no", "off"): - return False - return None - - -def _parse_saved_int(value) -> int | None: - if value is None: - return None - text = str(value).strip() - if text == "": - return None - try: - return int(text) - except Exception: - return None - - -def _filter_match_count(checkpoints: list[str], statuses: list[str] | None) -> int: - active = _active_filter_values(statuses) - if not active: - return len(checkpoints) - return sum(1 for ckpt in checkpoints if _get_status(ckpt) in active) - - -def _matches_filter(ckpt_name: str, statuses: list[str] | None, fallback_all: bool = False) -> bool: - active = _active_filter_values(statuses) - if fallback_all or not active: - return True - return _get_status(ckpt_name) in active - - -def _find_start_index(checkpoints: list[str], start_checkpoint: str) -> int: - rel = _normalize_relpath(start_checkpoint) - for i, ckpt in enumerate(checkpoints): - if _normalize_relpath(ckpt) == rel: - return i - return 0 - - -def _build_cycler_title( - source: str, - ckpt_name: str, - hold_index: int, - change_every: int, - active_filter: list[str], - fallback_all: bool, - local_list_remaining_count: int = 0, -) -> str: - status = _get_status(ckpt_name) - icon = STATUS_ICON[status] if status != "none" else "" - display = f"{icon} {ckpt_name}".strip() if ckpt_name else "(not executed yet)" - local_label = f" [local:{local_list_remaining_count}]" if local_list_remaining_count > 0 else "" - if fallback_all: - return f"Cycler{local_label} : โš  {ckpt_name} ({hold_index}/{change_every} Filter:0โ†’All)" - return f"Cycler{local_label} : {display}" - - -def _filter_label(active_filter: list[str]) -> str: +def _filter_match_count(active_filter: list[str]) -> int: + all_checkpoints = _get_checkpoint_list() if not active_filter: - return "All" - icons = _status_icons_for_filter(active_filter) - return icons or "All" + return len(all_checkpoints) + return sum(1 for ckpt in all_checkpoints if _get_status(ckpt) in active_filter) -def _source_label(source: str, active_filter: list[str], mode: str) -> str: +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 item in list(local_items[:max_items]): + status = _get_status(item) + icon = STATUS_ICON[status] if status != "none" else " " + shown.append(f"{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 "Local List" - if mode == "fixed": - return "Fixed" - return "Filtered Checkpoints" if active_filter else "Normal Cycle" + return f"{prefix} : {body}" + return f"{prefix} : {body} ({hold_index}/{change_every})" -def _append_local_list_lines(lines: list[str], local_list_remaining: list[str]): - if not local_list_remaining: - return - lines.append(f"Local List Remaining: {len(local_list_remaining)} item(s)") - for item in local_list_remaining[:8]: - s = _get_status(item) - prefix = STATUS_ICON[s] if s != "none" else "-" - lines.append(f"{prefix} {item}") - if len(local_list_remaining) > 8: - lines.append(f"+{len(local_list_remaining) - 8} more") - - -def _build_cycler_status_text( - source: str, - ckpt_name: str, - active_filter: list[str], - fallback_all: bool, - local_list_remaining: list[str], - hold_index: int, - change_every: int, - total_matches: int, - total_checkpoints: int | None = None, - mode: str = "", - shuffle_deck_remaining: int | None = None, -) -> str: - lines = [] - mode = mode or "increment" - if ckpt_name: - status = _get_status(ckpt_name) - lines.append(f"Current: {STATUS_ICON[status]} {ckpt_name}" if status != "none" else f"Current: {ckpt_name}") - else: - lines.append("Current: (not executed yet)") - - lines.append(f"Mode: {mode}") - lines.append(f"Source: {_source_label(source, active_filter, mode)}") - - total = total_checkpoints if total_checkpoints is not None else len(_get_checkpoint_list()) - if mode == "fixed": - if active_filter: - lines.append(f"Filter: {_filter_label(active_filter)} Matches: {total_matches} / {total}") - lines.append("Note: Filter is ignored in fixed mode.") - else: - lines.append(f"Filter: {_filter_label(active_filter)} Matches: {total_matches} / {total}") - if fallback_all: - lines.append("โš  Filter matched 0 checkpoints. Using all checkpoints.") - - if source != "local_list": - lines.append(f"Hold: {hold_index} / {change_every}") - if mode == "shuffle_once" and shuffle_deck_remaining is not None: - lines.append(f"Shuffle Deck Remaining: {shuffle_deck_remaining}") - - _append_local_list_lines(lines, local_list_remaining) +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) -> 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 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_pending_title(state: dict) -> str: - ckpt_name = state.get("last_ckpt_name", "") - if not ckpt_name: - return "" - source = state.get("last_source", "") or "cycle" - hold = int(state.get("last_hold_index") or 1) - change_every = int(state.get("last_change_every") or 1) - active_filter = _active_filter_values(state.get("active_filter", []) or []) +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 use_local and local_count > 0: + lines.append(f"Local List Remaining: {local_count} item(s)") + lines.extend(_build_local_list_lines(local_items)) + 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', []))}") + 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() - match_count = _filter_match_count(all_checkpoints, active_filter) - mode = state.get("last_mode", "increment") - fallback_all = bool(active_filter) and match_count == 0 and mode != "fixed" - local_list = list(state.get("local_list", [])) if state.get("use_local_list", True) else [] - return _build_cycler_title(source, ckpt_name, hold, change_every, active_filter, fallback_all, len(local_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 -def _build_cycler_pending_status_text(state: dict) -> str: - all_checkpoints = _get_checkpoint_list() - active_filter = _active_filter_values(state.get("active_filter", []) or []) - match_count = _filter_match_count(all_checkpoints, active_filter) - fallback_all = bool(active_filter) and match_count == 0 - local_list = list(state.get("local_list", [])) if state.get("use_local_list", True) else [] - last_ckpt = state.get("last_ckpt_name", "") - hold = int(state.get("last_hold_index") or 0) - change_every = int(state.get("last_change_every") or 1) - mode = state.get("last_mode", "increment") - source = state.get("last_source", "") - if not source: - source = "local_list" if local_list and not last_ckpt else "cycle" - shuffle_remaining = len(state.get("shuffle_deck", [])) if mode == "shuffle_once" and source != "local_list" else None +class CheckpointListSelector: + @classmethod + def INPUT_TYPES(cls): + return { + "required": {"checkpoint": ("STRING", {"default": ""})}, + "hidden": {"unique_id": "UNIQUE_ID"}, + } - if not last_ckpt: - lines = ["Current: (not executed yet)", f"Mode: {mode}"] - lines.append(f"Source: {_source_label(source, active_filter, mode)}") - if mode == "fixed": - if active_filter: - lines.append(f"Filter: {_filter_label(active_filter)} Matches: {match_count} / {len(all_checkpoints)}") - lines.append("Note: Filter is ignored in fixed mode.") - else: - lines.append(f"Filter: {_filter_label(active_filter)} Matches: {match_count} / {len(all_checkpoints)}") - if fallback_all: - lines.append("โš  Filter matched 0 checkpoints. Using all checkpoints.") - lines.append("Note: Filter will apply on next Cycler execution.") - _append_local_list_lines(lines, local_list) - return "\n".join(lines) + RETURN_TYPES = (_get_checkpoint_list() or [""], "STRING", "STRING") + RETURN_NAMES = ("ckpt_name", "ckpt_name_str", "ckpt_name_safe") + FUNCTION = "select" + CATEGORY = "checkpoint/handpicker" - return _build_cycler_status_text( - source, - last_ckpt, - active_filter, - fallback_all and mode != "fixed", - local_list, - hold if hold else 1, - change_every, - match_count, - len(all_checkpoints), - mode, - shuffle_remaining, - ) - - -def _send_cycler_state_update(node_id: str, state: dict, tab_id: str | None = None): - _send_cycler_update( - node_id, - _build_cycler_pending_title(state), - _build_cycler_pending_status_text(state), - tab_id=tab_id, - ckpt_name_str=state.get("last_ckpt_name") or None, - source=state.get("last_source") or None, - mode=state.get("last_mode") or None, - hold_index=int(state.get("last_hold_index") or 0) or None, - change_every=int(state.get("last_change_every") or 0) or None, - ) - - -def _split_cycler_state_key(node_key: str) -> tuple[str, str]: - tab_id, sep, node_id = str(node_key).partition(":") - return (tab_id, node_id) if sep else ("", tab_id) - - -def _refresh_cycler_states_for_status_change(relpath: str): - relpath = _normalize_relpath(relpath) - updates: list[tuple[str, str, dict]] = [] - with _STATE_LOCK: - for node_key, state in list(_CYCLER_STATES.items()): - active_filter = _active_filter_values(state.get("active_filter", []) or []) - local_list = list(state.get("local_list", [])) - last_ckpt = _normalize_relpath(state.get("last_ckpt_name", "")) - local_set = {_normalize_relpath(item) for item in local_list} - should_refresh = ( - last_ckpt == relpath - or relpath in local_set - or bool(active_filter) - ) - if not should_refresh: - continue - tab_id, node_id = _split_cycler_state_key(node_key) - if not node_id or node_id == "__none__": - continue - updates.append((tab_id, node_id, dict(state))) - for tab_id, node_id, state in updates: - _send_cycler_state_update(node_id, state, tab_id=tab_id) + 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): - checkpoints = _get_checkpoint_list() + values = _get_checkpoint_list() or [""] return { "required": { - "start_checkpoint": (checkpoints if checkpoints else [""],), + "start_checkpoint": (values,), "mode": (["fixed", "increment", "randomize", "shuffle_once"], {"default": "increment"}), "change_every": ("INT", {"default": 1, "min": 1, "max": 999999}), }, @@ -1528,227 +1432,296 @@ class CheckpointNameCycler: "hps_use_local_list": ("STRING", {"default": "", "hidden": True}), "hps_settings_revision": ("STRING", {"default": "0", "hidden": True}), }, - "hidden": { - "unique_id": "UNIQUE_ID", - }, + "hidden": {"unique_id": "UNIQUE_ID"}, } - RETURN_TYPES = (_get_checkpoint_list() if _get_checkpoint_list() else [""], "STRING", "STRING") + RETURN_TYPES = (_get_checkpoint_list() or [""], "STRING", "STRING") RETURN_NAMES = ("ckpt_name", "ckpt_name_str", "ckpt_name_safe") FUNCTION = "cycle" CATEGORY = "checkpoint/handpicker" @classmethod - def IS_CHANGED(cls, **kwargs): - return time.time_ns() + 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, change_every, hps_tab_id="", hps_filter_statuses="", hps_use_local_list="", hps_settings_revision="0", unique_id=None): - node_id = str(unique_id) if unique_id is not None else "__default__" + 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_key = _state_key(tab_id, node_id) - state = _get_cycler_state(node_key) + node_id = str(unique_id or "") + key = _state_key(tab_id, node_id) + state = _get_cycler_state(key) - hidden_revision = _parse_saved_int(hps_settings_revision) - state_revision = int(state.get("settings_revision", -1) or -1) - # Hidden widget values are persisted workflow settings. They are useful - # when loading a workflow, and as a fallback if a prompt executes before - # the frontend API call reaches the backend. But older queued prompts can - # contain stale hidden values. Only apply hidden settings when their - # revision is at least as new as the backend live state. - if hidden_revision is not None and hidden_revision >= state_revision: - saved_filter = _parse_saved_filter_statuses(hps_filter_statuses) - if saved_filter is not None: - state["active_filter"] = saved_filter - saved_use_local_list = _parse_saved_bool(hps_use_local_list) - if saved_use_local_list is not None: - state["use_local_list"] = saved_use_local_list - state["accept_queue"] = saved_use_local_list - state["settings_revision"] = hidden_revision + 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") - mode = mode if mode in ("fixed", "increment", "randomize", "shuffle_once") else "increment" - change_every = max(1, int(change_every or 1)) - active_filter = _active_filter_values(state.get("active_filter", []) or []) - match_count = _filter_match_count(all_checkpoints, active_filter) - fallback_all = bool(active_filter) and match_count == 0 - all_set = set(all_checkpoints) + 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))) - all_hash = _checkpoint_hash(all_checkpoints) - if state.get("last_all_checkpoint_hash") != all_hash: - state["last_all_checkpoint_hash"] = all_hash - state["current_index"] = max(0, min(int(state.get("current_index", 0)), len(all_checkpoints) - 1)) - state["shuffle_deck"] = [ckpt for ckpt in state.get("shuffle_deck", []) if ckpt in all_set] - if state.get("last_normal_ckpt_name") not in all_set: - state["last_normal_ckpt_name"] = "" - state["repeat_count"] = 0 + 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) + candidates = local_items if use_local_source else global_candidates + if not candidates: + candidates = all_checkpoints + fallback_all = True + candidate_set = set(candidates) - # Local List is an isolated manual lane: - # one entry per execution, no filter, no change_every, no normal index/deck progress. - local_list = list(state.get("local_list", [])) if state.get("use_local_list", True) else [] - selected_from_local = None - skipped_local = 0 - while local_list: - candidate = _normalize_relpath(local_list.pop(0)) - if candidate in all_set: - selected_from_local = candidate - break - skipped_local += 1 - if selected_from_local: - state["local_list"] = local_list - state["override_queue"] = local_list - ckpt_name = selected_from_local - hold_index = 1 - local_total = len(local_list) + 1 - title = _build_cycler_title("local_list", ckpt_name, hold_index, 1, active_filter, False, len(local_list)) + 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, - local_list, - hold_index, + [], 1, - match_count, - len(all_checkpoints), - mode, - None, + change_every, + global_match_count, + len(remaining_local_items), + "local list", + local_items=remaining_local_items, + use_local_list=True, ) - if skipped_local: - status_text += f"\nSkipped missing Local List item(s): {skipped_local}" - state["last_ckpt_name"] = ckpt_name - state["last_title"] = title - state["last_hold_index"] = hold_index - state["last_change_every"] = change_every - state["last_mode"] = mode - state["last_source"] = "local_list" - _send_cycler_update( - node_id, - title, - status_text, - tab_id=tab_id, - ckpt_name_str=ckpt_name, - source="local_list", - mode=mode, - hold_index=hold_index, - change_every=1, - ) - return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name)) - elif skipped_local: - state["local_list"] = [] - state["override_queue"] = [] + 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 is_match(ckpt: str) -> bool: - return _matches_filter(ckpt, active_filter, fallback_all) + 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 find_next_increment(start_idx: int) -> tuple[str, int]: - start_idx = max(0, min(int(start_idx), len(all_checkpoints) - 1)) - for offset in range(len(all_checkpoints)): - idx = (start_idx + offset) % len(all_checkpoints) - ckpt = all_checkpoints[idx] - if is_match(ckpt): - return ckpt, idx - return all_checkpoints[start_idx], start_idx + 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 filtered_candidates() -> list[str]: - if fallback_all or not active_filter: - return all_checkpoints - return [ckpt for ckpt in all_checkpoints if _get_status(ckpt) in active_filter] + def refill_deck(): + fresh = list(all_checkpoints) + random.shuffle(fresh) + return fresh - def draw_from_shuffle_deck() -> str: - deck = [ckpt for ckpt in state.get("shuffle_deck", []) if ckpt in all_set] - while True: if not deck: - deck = list(all_checkpoints) - random.shuffle(deck) - candidate = deck.pop(0) - # In shuffle_once, skipped non-matching cards are consumed too. - if is_match(candidate): - state["shuffle_deck"] = deck - return candidate + deck = refill_deck() - last_ckpt = state.get("last_normal_ckpt_name", "") - repeat_count = int(state.get("repeat_count", 0)) - can_hold_last = ( - repeat_count > 0 - and repeat_count < change_every - and last_ckpt in all_set - and (mode == "fixed" or is_match(last_ckpt)) - ) + 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() - new_selection = False - if can_hold_last: - ckpt_name = last_ckpt - try: - selected_index = all_checkpoints.index(ckpt_name) - except ValueError: - selected_index = 0 - else: - new_selection = True - if mode == "fixed": - requested = _normalize_relpath(start_checkpoint) - ckpt_name = requested if requested in all_set else all_checkpoints[0] - selected_index = all_checkpoints.index(ckpt_name) - elif mode == "randomize": - candidates = filtered_candidates() - ckpt_name = random.choice(candidates if candidates else all_checkpoints) - 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: # increment - ckpt_name, selected_index = find_next_increment(int(state.get("current_index", 0))) + # 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] - 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 + if can_hold_last: + ckpt_name = last_ckpt + selected_index = all_checkpoints.index(ckpt_name) else: - state["current_index"] = selected_index - else: - state["repeat_count"] = hold_index - state["current_index"] = selected_index + 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()))) - source = "cycle" - local_list_remaining = list(state.get("local_list", [])) if state.get("use_local_list", True) else [] - title = _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filter, fallback_all and mode != "fixed", len(local_list_remaining)) - status_text = _build_cycler_status_text( - source, - ckpt_name, - active_filter, - fallback_all and mode != "fixed", - local_list_remaining, - hold_index, - change_every, - match_count, - len(all_checkpoints), - mode, - len(state.get("shuffle_deck", [])) if mode == "shuffle_once" else None, - ) + 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, + ) + if mode == "shuffle_once": + status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" + + 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_normal_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_source"] = source + 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=mode, + 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)) @@ -1757,15 +1730,9 @@ 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", - }, + "required": {"ckpt_name_str": ("STRING", {"forceInput": True})}, + "optional": {"hps_tab_id": ("STRING", {"default": "", "hidden": True})}, + "hidden": {"unique_id": "UNIQUE_ID"}, } RETURN_TYPES = () @@ -1781,12 +1748,15 @@ class CheckpointStatusTagger: 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 () @@ -1815,7 +1785,22 @@ class EphemeralPreview: pil_images = _tensor_batch_to_pil(image) sheet, meta = _build_contact_sheet(pil_images) meta["node_class"] = "EphemeralPreview" - _send_preview(unique_id, "Ephemeral Preview", sheet, meta, tab_id=hps_tab_id) + 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 () @@ -1825,17 +1810,13 @@ class ImageDirPreview: @classmethod def INPUT_TYPES(cls): return { - "required": { - "ckpt_name_str": ("STRING", {"forceInput": True}), - }, + "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", - }, + "hidden": {"unique_id": "UNIQUE_ID"}, } RETURN_TYPES = () @@ -1850,6 +1831,8 @@ class ImageDirPreview: 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) + _store_preview_state(hps_tab_id, unique_id, relpath, status, node_class="ImageDirPreview") _send_preview(unique_id, _image_dir_title(relpath), sheet, extra, tab_id=hps_tab_id) return () @@ -1857,6 +1840,38 @@ class ImageDirPreview: 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}/list_checkpoints") async def list_checkpoints(_request): items = _checkpoint_items() @@ -1877,10 +1892,7 @@ async def refresh_all(_request): 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] 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) @@ -1892,7 +1904,6 @@ async def refresh_all(_request): "status_text": _status_summary_text("Refresh All"), "checkpoint_values": checkpoint_values, "patched_classes": patched_classes, - "patched_class_count": len(patched_classes), "pruned_delete_records": pruned_delete_records, }) @@ -1902,21 +1913,21 @@ 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" # toggle off + 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) @@ -1924,117 +1935,86 @@ async def tagger_set_status(request): _append_delete_record({ "version": 1, "type": "reserve", - "id": f"{int(time.time())}_{uuid.uuid4().hex[:12]}", + "id": f"{int(time.time())}_{uuid.uuid4().hex}", "ckpt_name_str": relpath, - "ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath), "resolved_path": resolved["path"], + "json_path": resolved["json_path"], "reserved_at": _now_iso(), }) _write_delete_script() - elif current == "delete" and status != "delete": + else: + # Cancel active delete reservation for this checkpoint if it existed. active = _active_delete_records().get(relpath) - if active: + 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() - payload = { - "scope": "global", - "ok": True, - "ckpt_name_str": relpath, - "status": _get_status(relpath), - "status_icon": STATUS_ICON[_get_status(relpath)], - "summary": _delete_status_summary(), - } - _send_event(STATUS_CHANGED_EVENT, payload) + _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(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", "")) - 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", "")) - tagger_ids = [str(x) for x in data.get("tagger_node_ids", []) if str(x).isdigit()] - preview_targets = [] - raw_preview_targets = data.get("preview_targets") - if isinstance(raw_preview_targets, list): - for item in raw_preview_targets: - if isinstance(item, dict): - node_id = str(item.get("node_id", "")) - if node_id.isdigit(): - preview_targets.append({ - "node_id": node_id, - "search_directory": str(item.get("search_directory") or ""), - "max_preview_images": _clamp_image_dir_max_images(item.get("max_preview_images", IMAGE_DIR_DEFAULT_MAX_IMAGES)), - }) - if not preview_targets: - preview_targets = [ - {"node_id": str(x), "search_directory": "", "max_preview_images": IMAGE_DIR_DEFAULT_MAX_IMAGES} - for x in data.get("preview_node_ids", []) - if str(x).isdigit() - ] - status = _get_status(relpath) - title = f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}" - for node_id in tagger_ids: - _send_event(TAGGER_EVENT, { - "node": int(node_id), - "tab_id": tab_id, - "node_class": "CheckpointStatusTagger", - "ckpt_name_str": relpath, - "status": status, - "title": title, - }) - - preview_count = 0 - for target in preview_targets: - node_id = target["node_id"] - search_directory = target.get("search_directory") or None - 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, - ) - _send_preview(node_id, _image_dir_title(relpath), sheet, extra, tab_id=tab_id) - preview_count += 1 - return web.json_response({ "ok": True, "ckpt_name_str": relpath, - "taggers": len(tagger_ids), - "previews": preview_count, + "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")) - if revision is None: - revision = int(state.get("settings_revision", -1) or -1) + 1 - if "use_local_list" in data: - state["use_local_list"] = bool(data.get("use_local_list")) - state["accept_queue"] = state["use_local_list"] - state["settings_revision"] = revision + 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) - return web.json_response({"ok": True, "use_local_list": state["use_local_list"]}) + 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") @@ -2042,16 +2022,25 @@ 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 = [s for s in data.get("statuses", []) if s in STATUS_VALUES] + 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")) - if revision is None: - revision = int(state.get("settings_revision", -1) or -1) + 1 - state["active_filter"] = statuses.copy() - state["settings_revision"] = revision + 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", tab_id, node_id, statuses or ["All"]) - return web.json_response({"ok": True, "node_id": node_id, "statuses": statuses}) + 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") @@ -2063,15 +2052,21 @@ async def cycler_local_list_append(request): 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): - state.setdefault("local_list", []).append(relpath) - state["override_queue"] = state["local_list"] + 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}) + return web.json_response({"ok": True, "updated": updated, "states": states}) @routes.post(f"/{EXTENSION_PREFIX}/cycler/clear_local_list") @@ -2083,9 +2078,75 @@ async def cycler_clear_local_list(request): 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) - return web.json_response({"ok": True, "cleared": 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, + ) + _store_preview_state(tab_id, node_id, relpath, status, node_class="ImageDirPreview") + _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 = { diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 6f11115..21fae55 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -1,6 +1,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; +// v8g: restore-safe Cycler runtime controls, global shuffle deck, and UI regression fixes. const EXT = "ruminar.checkpoint_handpicker_suite"; const PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview"; const CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler"; @@ -57,8 +58,10 @@ function patchCheckpointTitle(node, prefix, ckptName, status = "none") { } const STATUS_ORDER = ["favorite", "nice", "keep", "delete", "none"]; +const TAGGER_STATUS_ORDER = ["favorite", "nice", "keep", "delete"]; const STATUS_ICON = { favorite: "๐Ÿ’›", nice: "๐Ÿ‘", keep: "โœ”", delete: "๐Ÿ—‘", none: "โ€”" }; const STATUS_LABEL = { favorite: "favorite", nice: "nice", keep: "keep", delete: "delete", none: "none" }; +const STATUS_CURRENT_LABEL = { favorite: "favorite", nice: "nice", keep: "keep", delete: "Marked for deletion", none: "none" }; function getWidget(node, name) { return node.widgets?.find((w) => w.name === name); @@ -158,9 +161,6 @@ function patchCheckpointSlotTypes(node, checkpoints) { for (const output of node?.outputs ?? []) { if (!isCheckpointSlotName(output?.name)) continue; - // For combo-like checkpoint outputs, the type itself is the allowed value - // list. This is the part that prevents type mismatch with downstream - // CheckpointNameSelector-style nodes after deletion + Refresh All. output.type = [...values]; const links = Array.isArray(output.links) ? output.links : []; @@ -182,7 +182,6 @@ function patchCheckpointSlotTypes(node, checkpoints) { } } - function updateCheckpointComboWidget(node, widget, checkpoints) { if (!widget || !Array.isArray(checkpoints) || checkpoints.length === 0) { return { changed: false, valueChanged: false, oldValue: widget?.value, newValue: widget?.value }; @@ -335,7 +334,6 @@ function installCheckpointRefreshFuturePatch(nodeType, nodeData) { }; } - function isNodeClass(node, className) { return node && (node.type === className || node.comfyClass === className); } @@ -368,9 +366,6 @@ function ensureHiddenWidgetValue(node, name, value) { if (!w) return false; if (value !== undefined) w.value = value; - // Different ComfyUI/LiteGraph builds hide widgets through different flags. - // Set all harmless hints, and do it repeatedly from lifecycle hooks so the - // value is still serialized but the row does not occupy visible node space. w.type = "hidden"; w.hidden = true; w.disabled = true; @@ -381,6 +376,8 @@ function ensureHiddenWidgetValue(node, name, value) { return true; } +const CYCLER_FILTER_STATUSES = ["favorite", "nice", "keep", "delete", "none"]; + function normalizeCyclerFilterStatuses(value) { let raw = value; if (Array.isArray(raw)) { @@ -404,6 +401,90 @@ function serializeCyclerFilterStatuses(statuses) { return JSON.stringify(normalizeCyclerFilterStatuses(statuses)); } +function statusIconDisplay(statuses) { + const normalized = normalizeCyclerFilterStatuses(statuses); + return normalized.length ? normalized.map((status) => STATUS_ICON[status] || status).join("") : "all"; +} + +function selectorStatusIcon(status) { + return status && status !== "none" ? (STATUS_ICON[status] || "") : " "; +} + +function taggerCurrentMessage(status) { + const s = CYCLER_FILTER_STATUSES.includes(status) ? status : "none"; + return `Current: ${STATUS_ICON[s] || "โ€”"} ${STATUS_CURRENT_LABEL[s] || STATUS_LABEL[s] || s}`; +} + +function checkpointValuesFromWidget(widget) { + const values = widget?.options?.values; + return Array.isArray(values) ? values.filter((value) => typeof value === "string" && value) : []; +} + +function ensureStartCheckpointWidgetValid(node, checkpointValues = null) { + const widget = getWidget(node, "start_checkpoint"); + if (!widget) return false; + const values = Array.isArray(checkpointValues) && checkpointValues.length ? checkpointValues : checkpointValuesFromWidget(widget); + if (!values.length) return false; + if (values.includes(widget.value)) return false; + widget.value = values[0]; + node?.setDirtyCanvas?.(true, true); + return true; +} + +function applyCyclerStatePayload(node, detail) { + if (!node || !detail) return; + const controls = detail.runtime_controls || {}; + const runtimeControlsInitialized = detail.runtime_controls_initialized !== false; + + // On ComfyUI restart the backend state is empty and may return default + // runtime_controls. Do not let those defaults overwrite workflow-saved widget + // values. First seed the backend from the widgets, then subsequent initialized + // payloads can become authoritative. + if (runtimeControlsInitialized) { + if (detail.active_filter !== undefined || controls.active_filter !== undefined) { + node.__hpsFilterStatuses = normalizeCyclerFilterStatuses(detail.active_filter ?? controls.active_filter); + } + if (detail.use_local_list !== undefined || controls.use_local_list !== undefined) { + node.__hpsUseLocalList = !!(detail.use_local_list ?? controls.use_local_list); + } + if (detail.settings_revision !== undefined || controls.settings_revision !== undefined) { + node.__hpsSettingsRevision = parseSavedInt(detail.settings_revision ?? controls.settings_revision, node.__hpsSettingsRevision ?? 0); + } + if (detail.runtime_controls) { + node.__hpsApplyingRuntimePayload = true; + try { + if (controls.mode !== undefined) setWidgetValueIfDifferent(node, "mode", controls.mode); + if (controls.change_every !== undefined) setWidgetValueIfDifferent(node, "change_every", controls.change_every); + if (controls.start_checkpoint !== undefined && controls.start_checkpoint !== "") setWidgetValueIfDifferent(node, "start_checkpoint", controls.start_checkpoint); + ensureStartCheckpointWidgetValid(node); + } finally { + node.__hpsApplyingRuntimePayload = false; + } + } + } + if (detail.ckpt_name_str) { + node.__hpsCyclerCkptName = detail.ckpt_name_str; + node.__hpsCyclerStatusValue = detail.status || "none"; + setExecutionState(detail); + } + if (detail.filter_matches !== undefined) { + node.__hpsFilterMatches = Number(detail.filter_matches) || 0; + } + if (Array.isArray(detail.local_list_items)) { + node.__hpsLocalListItems = [...detail.local_list_items]; + } + if (detail.local_list_count !== undefined) { + node.__hpsLocalListCount = Number(detail.local_list_count) || 0; + } + if (detail.status_text !== undefined) { + node.__hpsCyclerStatus = detail.status_text; + } + if (detail.title) { + node.title = detail.title; + } + syncCyclerSettingsWidgets(node); +} + function parseSavedBool(value, fallback) { const text = String(value ?? "").trim().toLowerCase(); if (!text) return fallback; @@ -422,6 +503,32 @@ function bumpCyclerSettingsRevision(node) { return node.__hpsSettingsRevision; } +function validCyclerMode(value) { + return ["fixed", "increment", "randomize", "shuffle_once"].includes(String(value || "")) ? String(value) : "increment"; +} + +function validChangeEvery(value) { + const n = Number.parseInt(String(value ?? "1"), 10); + return Number.isFinite(n) && n > 0 ? n : 1; +} + +function getCyclerRuntimeControls(node) { + ensureStartCheckpointWidgetValid(node); + return { + mode: validCyclerMode(getWidget(node, "mode")?.value), + change_every: validChangeEvery(getWidget(node, "change_every")?.value), + start_checkpoint: String(getWidget(node, "start_checkpoint")?.value ?? ""), + }; +} + +function setWidgetValueIfDifferent(node, name, value) { + const widget = getWidget(node, name); + if (!widget) return false; + if (String(widget.value ?? "") === String(value ?? "")) return false; + widget.value = value; + return true; +} + function ensureHiddenTabIdWidget(node) { return ensureHiddenWidgetValue(node, "hps_tab_id", HPS_TAB_ID); } @@ -444,6 +551,7 @@ function restoreCyclerSettingsFromWidgets(node) { ensureHiddenWidgetValue(node, "hps_filter_statuses", serializeCyclerFilterStatuses(node.__hpsFilterStatuses)); ensureHiddenWidgetValue(node, "hps_use_local_list", node.__hpsUseLocalList ? "true" : "false"); ensureHiddenWidgetValue(node, "hps_settings_revision", String(node.__hpsSettingsRevision ?? 0)); + ensureStartCheckpointWidgetValid(node); } function syncCyclerSettingsWidgets(node) { @@ -550,9 +658,6 @@ function localPos(node, pos) { function candidatePositions(node, pos) { if (!pos) return []; - // LiteGraph/ComfyUI versions differ here: some callbacks pass node-local - // coordinates, others pass graph/canvas coordinates. Test both so custom - // drawn controls keep working across frontend versions. const graphToLocal = localPos(node, pos); return [pos, graphToLocal]; } @@ -602,6 +707,47 @@ function installCursorCapture() { canvasEl.addEventListener("mouseleave", () => setCanvasCursor("")); } +// ---------- Backend state restore ---------- +async function restoreNodeStateFromBackend(node, nodeClass) { + if (!node || !nodeClass) return; + try { + const query = new URLSearchParams({ node_id: String(node.id), node_class: nodeClass, tab_id: HPS_TAB_ID }); + const response = await api.fetchApi(`/${EXTENSION_PREFIX}/node_state?${query.toString()}`, { cache: "no-store" }); + if (!response.ok) return; + const result = await response.json(); + if (!result?.ok) return; + + if (nodeClass === "EphemeralPreview") { + if (result.ckpt_name_str) { + node.__hpsPreviewCkptName = result.ckpt_name_str; + node.__hpsPreviewStatus = result.status || "none"; + setPreviewTitleFromCheckpoint(node, result.ckpt_name_str, result.status || "none"); + } + } else if (nodeClass === TAGGER_CLASS) { + if (result.ckpt_name_str) { + node.__hpsTaggerPath = result.ckpt_name_str; + node.__hpsTaggerStatus = result.status || "none"; + node.__hpsTaggerMessage = taggerCurrentMessage(node.__hpsTaggerStatus); + node.title = node.__hpsTaggerStatus === "none" + ? `Tagger : ${result.ckpt_name_str}` + : `Tagger : ${STATUS_ICON[node.__hpsTaggerStatus]} ${result.ckpt_name_str}`; + } + } else if (nodeClass === CYCLER_CLASS) { + if (result.runtime_controls_initialized === false) { + restoreCyclerSettingsFromWidgets(node); + ensureStartCheckpointWidgetValid(node); + syncCyclerSettingsWidgets(node); + schedulePushCyclerRuntimeControls(node); + } else { + applyCyclerStatePayload(node, result); + } + } + app.graph?.setDirtyCanvas?.(true, true); + } catch (error) { + // Older Python side versions did not expose node_state. Ignore quietly. + } +} + // ---------- Preview ---------- function setupPreviewNode(nodeType) { installMinSize(nodeType, 340, 300); @@ -611,6 +757,13 @@ function setupPreviewNode(nodeType) { nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; ensureSize(this, 340, 300); + if (isNodeClass(this, "EphemeralPreview")) setTimeout(() => restoreNodeStateFromBackend(this, "EphemeralPreview"), 0); + return r; + }; + const origConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const r = origConfigure ? origConfigure.apply(this, arguments) : undefined; + if (isNodeClass(this, "EphemeralPreview")) setTimeout(() => restoreNodeStateFromBackend(this, "EphemeralPreview"), 0); return r; }; @@ -640,6 +793,19 @@ function setupPreviewNode(nodeType) { ctx.font = "12px sans-serif"; ctx.fillText(this.__hpsPreviewCaption, messageX, captionY, messageW); } + if (isImageDir && (this.__hpsPreviewState?.progress || this.__hpsPreviewState?.progress_total)) { + const st = this.__hpsPreviewState || {}; + const total = Math.max(1, Number(st.progress_total || st.max_preview_images || 1)); + const value = Math.max(0, Math.min(total, Number(st.progress_value || 0))); + const barX = margin; + const barY = 52; + const barW = Math.max(1, this.size[0] - margin * 2); + const barH = 6; + ctx.fillStyle = "rgba(255,255,255,0.14)"; + ctx.fillRect(barX, barY, barW, barH); + ctx.fillStyle = "rgba(255,255,255,0.58)"; + ctx.fillRect(barX, barY, barW * (value / total), barH); + } if (img) { let dw = w; let dh = dw * (img.height / img.width); @@ -677,11 +843,13 @@ api.addEventListener(PREVIEW_EVENT, ({ detail }) => { const isEphemeral = isNodeClass(node, "EphemeralPreview"); if (isEphemeral && detail.image) { const execution = getExecutionState(); - if (execution?.ckpt_name_str) { - node.__hpsPreviewCkptName = execution.ckpt_name_str; - node.__hpsPreviewStatus = execution.status || "none"; - node.__hpsPreviewStatusIcon = execution.status_icon || STATUS_ICON[node.__hpsPreviewStatus] || ""; - setPreviewTitleFromCheckpoint(node, node.__hpsPreviewCkptName, node.__hpsPreviewStatus); + const ckptName = detail.ckpt_name_str || execution?.ckpt_name_str || ""; + if (ckptName) { + node.__hpsPreviewCkptName = ckptName; + node.__hpsPreviewStatus = detail.status || execution?.status || "none"; + node.__hpsPreviewStatusIcon = detail.status_icon || execution?.status_icon || STATUS_ICON[node.__hpsPreviewStatus] || ""; + if (detail.title) node.title = detail.title; + else setPreviewTitleFromCheckpoint(node, node.__hpsPreviewCkptName, node.__hpsPreviewStatus); } else { node.title = "Ephemeral Preview"; } @@ -693,7 +861,6 @@ api.addEventListener(PREVIEW_EVENT, ({ detail }) => { const caption = detail.progress_message || detail.message || `${detail.count ?? 0} img ยท ${detail.columns ?? 0}ร—${detail.rows ?? 0} ยท ${detail.width ?? 0}ร—${detail.height ?? 0}`; node.__hpsPreviewCaption = caption; if (!detail.image) { - // Progress messages should not blank a previously loaded sheet. if (!detail.progress) node.__hpsPreview = null; app.graph.setDirtyCanvas(true, true); return; @@ -723,21 +890,44 @@ function hideSelectorWidget(node) { } function selectorRects(node) { const margin = 8; + const gap = 6; + const buttonY = 8; + const buttonH = 24; + const arrowW = 32; + const refreshW = 102; + const listOnlyW = 92; + const pushW = selectorActionMode(node) === "sync" ? 150 : 132; + const pushX = margin + refreshW + gap + listOnlyW + gap; + // Keep scroll buttons close to the toolbar action buttons instead of anchoring + // them to the right edge. The right edge belongs to ComfyUI output pins. + const upX = pushX + pushW + gap; + const downX = upX + arrowW + gap; return { - refreshAll: { x: margin, y: 8, w: 100, h: 24 }, - listOnly: { x: 114, y: 8, w: 80, h: 24 }, - pushLocalList: { x: 200, y: 8, w: 148, h: 24 }, - up: { x: 360, y: 8, w: 34, h: 24 }, - down: { x: 400, y: 8, w: 34, h: 24 }, + refreshAll: { x: margin, y: buttonY, w: refreshW, h: buttonH }, + listOnly: { x: margin + refreshW + gap, y: buttonY, w: listOnlyW, h: buttonH }, + pushLocalList: { x: pushX, y: buttonY, w: pushW, h: buttonH }, + up: { x: upX, y: buttonY, w: arrowW, h: buttonH }, + down: { x: downX, y: buttonY, w: arrowW, h: buttonH }, list: { x: margin, y: 104, w: node.size[0] - 16, h: ROW_H * SELECTOR_VISIBLE_ROWS }, }; } function selectorItems(node) { return node.__hpsItems || []; } function selectorSelected(node) { return selectorWidget(node)?.value || node.__hpsSelected || ""; } +function selectorStatusFor(node, ckptName) { + const item = selectorItems(node).find((entry) => entry.ckpt_name_str === ckptName); + return item?.status || "none"; +} + +function selectorTitleText(node, ckptName) { + if (!ckptName) return "Checkpoint List Selector"; + const status = selectorStatusFor(node, ckptName); + return `Selector : ${titleDisplayForCheckpoint(ckptName, status)}`; +} + function setSelectorSelected(node, value) { const w = selectorWidget(node); if (w) w.value = value; node.__hpsSelected = value; - node.title = value ? `Selector : ${value}` : "Checkpoint List Selector"; + node.title = selectorTitleText(node, value); } function selectorStatusText(result, prefix = "") { const s = result?.summary || {}; @@ -808,7 +998,6 @@ function firstWidgetValue(node, preferredSlot = null, seen = new Set()) { if (s) return s; } - // Reroute-like nodes usually forward their first input. Follow one hop chain. const type = String(node.type || node.comfyClass || "").toLowerCase(); if (type.includes("reroute") || type.includes("relay")) { return linkedInputValue(node, node.inputs?.[0]?.name || "", seen); @@ -874,7 +1063,7 @@ function selectorActionMode(node) { } function selectorActionLabel(node) { - return selectorActionMode(node) === "sync" ? "๐ŸŽฏ Sync Checkpoint" : "๐Ÿน Push to Local List"; + return selectorActionMode(node) === "sync" ? "๐ŸŽฏ Sync Checkpoint" : "๐Ÿน Push Local"; } async function loadSelector(node, mode = "list") { @@ -901,6 +1090,8 @@ async function refreshSelector(node, all = false) { const selected = selectorSelected(node); if (selected && !node.__hpsItems.find((x) => x.ckpt_name_str === selected) && node.__hpsItems.length) { setSelectorSelected(node, node.__hpsItems[0].ckpt_name_str); + } else if (selectorSelected(node)) { + node.title = selectorTitleText(node, selectorSelected(node)); } if (all) { @@ -934,6 +1125,13 @@ async function pushSelectedToLocalList(node) { })), }); const result = await response.json(); + if (result.ok && Array.isArray(result.states)) { + for (const state of result.states) { + const cycler = app.graph?.getNodeById?.(Number(state.node_id)); + if (cycler && isNodeClass(cycler, CYCLER_CLASS)) applyCyclerStatePayload(cycler, state); + } + } + console.info(`[INFO] [CheckpointHandpickerSuite] Push Local List: ${selected} -> ${result.updated ?? 0} Cycler(s)`); node.__hpsStatus = result.ok ? `pushed : ${selected} (${result.updated} Cycler)` : (result.error || "Push failed"); @@ -958,31 +1156,20 @@ async function syncSelectedCheckpoint(node) { ckpt_name_str: selected, tagger_node_ids: targets.taggers.map((n) => n.id), preview_node_ids: targets.previews.map((n) => n.id), - preview_targets: targets.previews.map((n) => { - const searchDirectory = imageDirSearchDirectory(n); - console.debug("[CheckpointHandpickerSuite] ImageDirPreview search_directory", n.id, searchDirectory); - return { - node_id: n.id, - search_directory: searchDirectory, - max_preview_images: imageDirMaxPreviewImages(n), - }; - }), + preview_targets: targets.previews.map((n) => ({ + node_id: n.id, + search_directory: imageDirSearchDirectory(n), + max_preview_images: imageDirMaxPreviewImages(n), + })), })), }); const result = await response.json(); - if (result.ok) { - node.__hpsStatus = `synced : ${selected}`; - } else { - node.__hpsStatus = result.error || "Sync failed"; - } + node.__hpsStatus = result.ok + ? `synced : ${selected} (${targets.taggers.length} tagger, ${targets.previews.length} preview)` + : (result.error || "Sync failed"); app.graph.setDirtyCanvas(true, true); } -function runSelectorAction(node) { - if (selectorActionMode(node) === "sync") return syncSelectedCheckpoint(node); - return pushSelectedToLocalList(node); -} - function maxSelectorScroll(node) { return Math.max(0, selectorItems(node).length - SELECTOR_VISIBLE_ROWS); } @@ -1001,8 +1188,12 @@ function selectorScrollbar(node) { const range = Math.max(1, maxSelectorScroll(node)); const y = r.y + (r.h - thumbH) * (scroll / range); return { + list: r, track: { x: r.x + r.w - 16, y: r.y, w: 16, h: r.h }, thumb: { x: r.x + r.w - 12, y, w: 8, h: thumbH }, + thumbH, + maxScroll: maxSelectorScroll(node), + scroll, }; } @@ -1012,7 +1203,7 @@ function setSelectorScrollFromScrollbarY(node, localY, thumbH, dragOffset = thum const usable = Math.max(1, r.h - thumbH); const y = Math.max(r.y, Math.min(r.y + usable, localY - dragOffset)); node.__hpsScroll = Math.round(((y - r.y) / usable) * maxScroll); - app.graph.setDirtyCanvas(true, true); + app.graph?.setDirtyCanvas?.(true, true); } function selectorLocalFromEventOrPos(node, event, pos) { @@ -1027,10 +1218,13 @@ function installSelectorWheelCapture() { selectorWheelCaptureInstalled = true; const canvasEl = app.canvas?.canvas; if (!canvasEl) return; + + // Capture before LiteGraph consumes the wheel event for canvas zoom/pan. canvasEl.addEventListener("wheel", (event) => { const canvas = app.canvas; const graph = app.graph; if (!canvas || !graph) return; + let graphPos = null; try { graphPos = canvas.convertEventToCanvasOffset?.(event); @@ -1038,12 +1232,18 @@ function installSelectorWheelCapture() { graphPos = null; } if (!graphPos) return; + const nodes = [...(graph._nodes || [])].reverse(); for (const node of nodes) { if (!(node.type === SELECTOR_CLASS || node.comfyClass === SELECTOR_CLASS)) continue; if (node.flags?.collapsed) continue; - const local = [graphPos[0] - (node.pos?.[0] || 0), graphPos[1] - (node.pos?.[1] || 0)]; + + const local = [ + graphPos[0] - (node.pos?.[0] || 0), + graphPos[1] - (node.pos?.[1] || 0), + ]; if (!hit(local, selectorRects(node).list)) continue; + event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation?.(); @@ -1056,178 +1256,274 @@ function installSelectorWheelCapture() { function selectorCursorAt(node, local) { if (!local) return ""; const r = selectorRects(node); - if (hit(local, r.refreshAll) || hit(local, r.listOnly) || hit(local, r.pushLocalList)) return node.__hpsLoading ? "wait" : "pointer"; - if ((selectorItems(node).length > SELECTOR_VISIBLE_ROWS) && (hit(local, r.up) || hit(local, r.down))) return "pointer"; + if (hit(local, r.refreshAll) || hit(local, r.listOnly) || hit(local, r.pushLocalList) || hit(local, r.up) || hit(local, r.down)) return "pointer"; const sb = selectorScrollbar(node); - if (node.__hpsScrollbarDragging) return "grabbing"; - if (sb && hit(local, sb.thumb)) return "grab"; if (sb && hit(local, sb.track)) return "pointer"; - if (hit(local, r.list)) { - const row = Math.floor((local[1] - r.list.y) / ROW_H); - const idx = (node.__hpsScroll || 0) + row; - return selectorItems(node)[idx] ? "pointer" : ""; + const list = selectorItems(node); + const base = node.__hpsScroll || 0; + for (let i = 0; i < Math.min(SELECTOR_VISIBLE_ROWS, list.length - base); i++) { + const row = { x: r.list.x, y: r.list.y + i * ROW_H, w: r.list.w, h: ROW_H }; + if (hit(local, row)) return "pointer"; } return ""; } +function drawSelectorRows(ctx, node) { + const r = selectorRects(node); + const items = selectorItems(node); + const selected = selectorSelected(node); + const base = node.__hpsScroll || 0; + ctx.save(); + ctx.font = "12px sans-serif"; + ctx.textBaseline = "middle"; + ctx.strokeStyle = "rgba(255,255,255,0.18)"; + ctx.strokeRect(r.list.x, r.list.y, r.list.w, r.list.h); + for (let i = 0; i < SELECTOR_VISIBLE_ROWS; i++) { + const item = items[base + i]; + const y = r.list.y + i * ROW_H; + if (!item) continue; + const ckpt = item.ckpt_name_str || ""; + const status = item.status || "none"; + const active = ckpt === selected; + ctx.fillStyle = active ? "rgba(75,125,190,0.58)" : (i % 2 ? "rgba(255,255,255,0.035)" : "rgba(0,0,0,0.06)"); + ctx.fillRect(r.list.x + 1, y + 1, r.list.w - 2, ROW_H - 2); + ctx.fillStyle = active ? "#fff" : "#ddd"; + const icon = selectorStatusIcon(status); + // Keep the checkpoint-name column aligned even when status is none. + ctx.textAlign = "center"; + ctx.fillText(icon, r.list.x + 14, y + ROW_H / 2, 18); + ctx.textAlign = "left"; + ctx.fillText(ckpt, r.list.x + 28, y + ROW_H / 2, r.list.w - 42); + } + const sb = selectorScrollbar(node); + if (sb) { + ctx.fillStyle = "rgba(220,220,220,0.18)"; + ctx.fillRect(sb.track.x + 6, sb.track.y, 4, sb.track.h); + ctx.fillStyle = node.__hpsScrollbarDragging ? "rgba(255,255,255,0.70)" : "rgba(230,230,230,0.55)"; + ctx.fillRect(sb.thumb.x, sb.thumb.y, sb.thumb.w, sb.thumb.h); + } + ctx.restore(); +} + function setupSelectorNode(nodeType) { - installMinSize(nodeType, 560, 520); + installMinSize(nodeType, 520, 540); + installTabIdSupport(nodeType); installCursorCapture(); + installSelectorWheelCapture(); + const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; - ensureSize(this, 560, 520); - installSelectorWheelCapture(); + ensureSize(this, 520, 540); hideSelectorWidget(this); - this.__hpsItems = []; - this.__hpsScroll = 0; setTimeout(() => refreshSelector(this, false), 0); return r; }; + const origConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const r = origConfigure ? origConfigure.apply(this, arguments) : undefined; + hideSelectorWidget(this); + return r; + }; const origDraw = nodeType.prototype.onDrawBackground; nodeType.prototype.onDrawBackground = function (ctx) { if (origDraw) origDraw.apply(this, arguments); + ensureHiddenTabIdWidget(this); hideSelectorWidget(this); - if (hpsNodeCollapsed(this)) { - this.__hpsScrollbarDragging = false; - this.__hpsScrollbarDragOffset = 0; - return; - } + if (hpsNodeCollapsed(this)) return; const r = selectorRects(this); drawButton(ctx, r.refreshAll, "๐Ÿ”„ Refresh All", !this.__hpsLoading); drawButton(ctx, r.listOnly, "๐Ÿ“‹ List Only", !this.__hpsLoading); drawButton(ctx, r.pushLocalList, selectorActionLabel(this), !this.__hpsLoading); - drawButton(ctx, r.up, "โ–ฒ", selectorItems(this).length > SELECTOR_VISIBLE_ROWS); - drawButton(ctx, r.down, "โ–ผ", selectorItems(this).length > SELECTOR_VISIBLE_ROWS); - ctx.fillStyle = "#ddd"; + drawButton(ctx, r.up, "โ–ฒ", true); + drawButton(ctx, r.down, "โ–ผ", true); + + ctx.save(); + ctx.fillStyle = "#ccc"; ctx.font = "12px sans-serif"; - const statusLines = String(this.__hpsStatus || "").split("\n").slice(0, 4); - statusLines.forEach((line, i) => ctx.fillText(line, 8, 50 + i * 14, this.size[0] - 16)); - ctx.fillStyle = "rgba(0,0,0,0.22)"; - ctx.fillRect(r.list.x, r.list.y, r.list.w, r.list.h); - ctx.strokeStyle = "rgba(180,220,255,0.35)"; - ctx.strokeRect(r.list.x, r.list.y, r.list.w, r.list.h); - const items = selectorItems(this); - const scroll = Math.max(0, Math.min(this.__hpsScroll || 0, Math.max(0, items.length - SELECTOR_VISIBLE_ROWS))); - this.__hpsScroll = scroll; - const selected = selectorSelected(this); - for (let row = 0; row < SELECTOR_VISIBLE_ROWS; row++) { - const idx = scroll + row; - const item = items[idx]; - if (!item) continue; - const y = r.list.y + row * ROW_H; - if (item.ckpt_name_str === selected) { - ctx.fillStyle = "rgba(80,120,180,0.65)"; - ctx.fillRect(r.list.x + 1, y + 1, r.list.w - 2, ROW_H - 2); - } - ctx.fillStyle = "#e6e6e6"; - ctx.font = "12px monospace"; - ctx.fillText(item.label || item.ckpt_name_str, r.list.x + 8, y + 14, r.list.w - 24); + const status = this.__hpsStatus || "not loaded"; + status.split("\n").slice(0, 3).forEach((line, i) => ctx.fillText(line, 8, 50 + i * 14)); + ctx.restore(); + + drawSelectorRows(ctx, this); + }; + + const origMouseDown = nodeType.prototype.onMouseDown; + nodeType.prototype.onMouseDown = function (event, pos, canvas) { + if (hpsNodeCollapsed(this)) return origMouseDown ? origMouseDown.apply(this, arguments) : false; + + const r = selectorRects(this); + if (hitAny(this, pos, r.refreshAll)) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + refreshSelector(this, true); + return true; } + if (hitAny(this, pos, r.listOnly)) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + refreshSelector(this, false); + return true; + } + if (hitAny(this, pos, r.pushLocalList)) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + if (selectorActionMode(this) === "sync") syncSelectedCheckpoint(this); + else pushSelectedToLocalList(this); + return true; + } + if (hitAny(this, pos, r.up)) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + scrollSelector(this, -SELECTOR_VISIBLE_ROWS); + return true; + } + if (hitAny(this, pos, r.down)) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + scrollSelector(this, SELECTOR_VISIBLE_ROWS); + return true; + } + const sb = selectorScrollbar(this); if (sb) { - ctx.fillStyle = "rgba(220,220,220,0.18)"; - ctx.fillRect(sb.track.x + 6, sb.track.y, 4, sb.track.h); - ctx.fillStyle = "rgba(230,230,230,0.55)"; - ctx.fillRect(sb.thumb.x, sb.thumb.y, sb.thumb.w, sb.thumb.h); - } - }; - const origMouseDown = nodeType.prototype.onMouseDown; - nodeType.prototype.onMouseDown = function (e, pos) { - if (hpsNodeCollapsed(this)) return origMouseDown ? origMouseDown.apply(this, arguments) : false; - const r = selectorRects(this); - if (hitAny(this, pos, r.refreshAll)) { refreshSelector(this, true); return true; } - if (hitAny(this, pos, r.listOnly)) { refreshSelector(this, false); return true; } - if (hitAny(this, pos, r.pushLocalList)) { runSelectorAction(this); return true; } - if (hitAny(this, pos, r.up)) { scrollSelector(this, -SELECTOR_VISIBLE_ROWS); return true; } - if (hitAny(this, pos, r.down)) { scrollSelector(this, SELECTOR_VISIBLE_ROWS); return true; } - const sb = selectorScrollbar(this); - const sbHitPos = sb ? candidatePositions(this, pos).find((p) => hit(p, sb.track)) : null; - if (sb && sbHitPos) { - if (hit(sbHitPos, sb.thumb)) { + const hitPositions = candidatePositions(this, pos); + const thumbHitPos = hitPositions.find((p) => hit(p, sb.thumb)); + const trackHitPos = hitPositions.find((p) => hit(p, sb.track)); + + if (thumbHitPos) { + event?.preventDefault?.(); + event?.stopPropagation?.(); this.__hpsScrollbarDragging = true; - this.__hpsScrollbarDragOffset = sbHitPos[1] - sb.thumb.y; - } else { - setSelectorScrollFromScrollbarY(this, sbHitPos[1], sb.thumb.h); + this.__hpsScrollbarDragOffset = thumbHitPos[1] - sb.thumb.y; + this.__hpsScrollbarStartY = thumbHitPos[1]; + this.__hpsScrollbarStartScroll = sb.scroll; + app.graph?.setDirtyCanvas?.(true, true); + return true; + } + + if (trackHitPos) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + if (trackHitPos[1] < sb.thumb.y) { + scrollSelector(this, -SELECTOR_VISIBLE_ROWS); + } else { + scrollSelector(this, SELECTOR_VISIBLE_ROWS); + } + return true; } - return true; } - const listHitPos = candidatePositions(this, pos).find((p) => hit(p, r.list)); - if (listHitPos) { - const row = Math.floor((listHitPos[1] - r.list.y) / ROW_H); - const idx = (this.__hpsScroll || 0) + row; - const item = selectorItems(this)[idx]; + + const positions = candidatePositions(this, pos); + for (const p of positions) { + if (!hit(p, r.list)) continue; + const row = Math.floor((p[1] - r.list.y) / ROW_H); + const item = selectorItems(this)[(this.__hpsScroll || 0) + row]; if (item) { + event?.preventDefault?.(); + event?.stopPropagation?.(); setSelectorSelected(this, item.ckpt_name_str); app.graph.setDirtyCanvas(true, true); + return true; } - return true; } return origMouseDown ? origMouseDown.apply(this, arguments) : false; }; + const origMouseMove = nodeType.prototype.onMouseMove; - nodeType.prototype.onMouseMove = function (e, pos) { - if (hpsNodeCollapsed(this)) { + nodeType.prototype.onMouseMove = function (event, pos, canvas) { + if (this.__hpsScrollbarDragging && event?.buttons === 0) { this.__hpsScrollbarDragging = false; - this.__hpsScrollbarDragOffset = 0; - return origMouseMove ? origMouseMove.apply(this, arguments) : false; - } - if (this.__hpsScrollbarDragging) { - const sb = selectorScrollbar(this); - const local = selectorLocalFromEventOrPos(this, e, pos); - if (sb && local) setSelectorScrollFromScrollbarY(this, local[1], sb.thumb.h, this.__hpsScrollbarDragOffset || sb.thumb.h / 2); + app.graph?.setDirtyCanvas?.(true, true); return true; } + + if (this.__hpsScrollbarDragging) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + + const sb = selectorScrollbar(this); + if (!sb) { + this.__hpsScrollbarDragging = false; + app.graph?.setDirtyCanvas?.(true, true); + return true; + } + + const local = selectorLocalFromEventOrPos(this, event, pos); + if (!local) return true; + + setSelectorScrollFromScrollbarY(this, local[1], sb.thumbH, this.__hpsScrollbarDragOffset ?? sb.thumbH / 2); + return true; + } + return origMouseMove ? origMouseMove.apply(this, arguments) : false; }; + const origMouseUp = nodeType.prototype.onMouseUp; - nodeType.prototype.onMouseUp = function () { + nodeType.prototype.onMouseUp = function (event, pos, canvas) { if (this.__hpsScrollbarDragging) { + event?.preventDefault?.(); + event?.stopPropagation?.(); this.__hpsScrollbarDragging = false; - this.__hpsScrollbarDragOffset = 0; - app.graph.setDirtyCanvas(true, true); + app.graph?.setDirtyCanvas?.(true, true); return true; } + return origMouseUp ? origMouseUp.apply(this, arguments) : false; }; - const origConnectionsChange = nodeType.prototype.onConnectionsChange; - nodeType.prototype.onConnectionsChange = function () { - app.graph.setDirtyCanvas(true, true); - return origConnectionsChange ? origConnectionsChange.apply(this, arguments) : undefined; - }; - const origWheel = nodeType.prototype.onMouseWheel; - nodeType.prototype.onMouseWheel = function (e, pos) { - if (hpsNodeCollapsed(this)) return origWheel ? origWheel.apply(this, arguments) : false; - const r = selectorRects(this).list; - if (hitAny(this, pos, r)) { - e?.preventDefault?.(); e?.stopPropagation?.(); - scrollSelector(this, e.deltaY > 0 ? 3 : -3); + + const origMouseWheel = nodeType.prototype.onMouseWheel; + nodeType.prototype.onMouseWheel = function (event, pos, canvas) { + const r = selectorRects(this); + if (candidatePositions(this, pos).some((p) => hit(p, r.list))) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + event?.stopImmediatePropagation?.(); + scrollSelector(this, event.deltaY > 0 ? 3 : -3); return true; } - return origWheel ? origWheel.apply(this, arguments) : false; + return origMouseWheel ? origMouseWheel.apply(this, arguments) : false; }; } // ---------- Tagger ---------- -const TAGGER_STATUSES = ["favorite", "nice", "keep", "delete"]; -function currentTaggerPath(node) { - return node.__hpsTaggerPath || getWidget(node, "ckpt_name_str")?.value || ""; -} -function taggerButtons(node) { - const buttonW = 72; - const gap = 6; - const rightMargin = 12; - const totalW = TAGGER_STATUSES.length * buttonW + (TAGGER_STATUSES.length - 1) * gap; - const startX = Math.max(120, (node.size?.[0] || 450) - rightMargin - totalW); - return TAGGER_STATUSES.map((status, i) => ({ - status, - x: startX + i * (buttonW + gap), - y: 5, - w: buttonW, - h: 24, - })); +function linkedCheckpointInputValue(node, inputName) { + const index = node.inputs?.findIndex((input) => input.name === inputName) ?? -1; + if (index < 0) return ""; + const linkId = node.inputs?.[index]?.link; + if (linkId == null) return ""; + const link = app.graph?.links?.[linkId]; + const source = link ? app.graph?.getNodeById?.(link.origin_id) : null; + if (!source) return ""; + if (isNodeClass(source, CYCLER_CLASS)) return source.__hpsCyclerCkptName || ""; + if (isNodeClass(source, SELECTOR_CLASS)) return selectorSelected(source) || ""; + const outputName = String(source.outputs?.[link.origin_slot]?.name || "").toLowerCase(); + if (outputName === "ckpt_name_str" || outputName === "ckpt_name") { + const ckptWidget = findCheckpointWidget(source) || findStartCheckpointWidget(source); + const value = String(ckptWidget?.value || ""); + return value.endsWith(".safetensors") ? value : ""; + } + return ""; } +function currentTaggerPath(node) { + return node.__hpsTaggerPath || linkedCheckpointInputValue(node, "ckpt_name_str") || ""; +} +function taggerButtons(node) { + // Keep controls right-aligned and away from LiteGraph input pins/labels. + const buttonW = 76; + const gap = 6; + const totalW = TAGGER_STATUS_ORDER.length * buttonW + (TAGGER_STATUS_ORDER.length - 1) * gap; + const right = Math.max(442, (node.size?.[0] || 450) - 8); + const startX = right - totalW; + return TAGGER_STATUS_ORDER.map((status, i) => ({ + status, + x: startX + i * (buttonW + gap), + y: 3, + w: buttonW, + h: 26, + })); +} function taggerDeleteEnabled(node) { const current = node.__hpsTaggerStatus || "none"; return current === "none" || current === "delete"; @@ -1244,7 +1540,7 @@ async function setTaggerStatus(node, status) { const result = await response.json(); if (result.ok) { node.__hpsTaggerStatus = result.status; - node.__hpsTaggerMessage = result.status === "none" ? "Current: โ€” none" : `Current: ${STATUS_ICON[result.status]} ${STATUS_LABEL[result.status]}`; + node.__hpsTaggerMessage = taggerCurrentMessage(result.status); node.title = result.status === "none" ? `Tagger : ${ckpt}` : `Tagger : ${STATUS_ICON[result.status]} ${ckpt}`; } else { node.__hpsTaggerMessage = result.error || "Failed"; @@ -1262,13 +1558,20 @@ function taggerCursorAt(node, local) { } function setupTaggerNode(nodeType) { - installMinSize(nodeType, 450, 100); + installMinSize(nodeType, 450, 104); installTabIdSupport(nodeType); installCursorCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; - ensureSize(this, 450, 100); + ensureSize(this, 450, 104); + setTimeout(() => restoreNodeStateFromBackend(this, TAGGER_CLASS), 0); + return r; + }; + const origConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const r = origConfigure ? origConfigure.apply(this, arguments) : undefined; + setTimeout(() => restoreNodeStateFromBackend(this, TAGGER_CLASS), 0); return r; }; const origDraw = nodeType.prototype.onDrawBackground; @@ -1294,11 +1597,11 @@ function setupTaggerNode(nodeType) { const p = currentTaggerPath(this); ctx.fillStyle = "#ddd"; ctx.font = "12px sans-serif"; - ctx.fillText(p ? p : "Execute once to bind current checkpoint.", 8, 54); - const msg = this.__hpsTaggerMessage || (current === "none" ? "Current: โ€” none" : `Current: ${STATUS_ICON[current]} ${STATUS_LABEL[current]}`); + ctx.fillText(p ? p : "Execute once to bind current checkpoint.", 8, 50); + const msg = this.__hpsTaggerMessage || taggerCurrentMessage(current); ctx.fillStyle = current === "none" ? "#ccc" : "#ddd"; - ctx.fillText(msg, 8, 72); - if (this.size[1] >= 110 && current !== "none" && current !== "delete") { + ctx.fillText(msg, 8, 70); + if (current !== "none" && current !== "delete") { ctx.fillStyle = "#aaa"; ctx.fillText("Delete is available only from none.", 8, 90); } @@ -1322,10 +1625,11 @@ api.addEventListener(TAGGER_EVENT, ({ detail }) => { if (!node) return; node.__hpsTaggerPath = detail.ckpt_name_str; node.__hpsTaggerStatus = detail.status; - node.__hpsTaggerMessage = detail.status === "none" ? "Current: โ€” none" : `Current: ${STATUS_ICON[detail.status]} ${STATUS_LABEL[detail.status]}`; + node.__hpsTaggerMessage = taggerCurrentMessage(detail.status || "none"); if (detail.title) node.title = detail.title; app.graph.setDirtyCanvas(true, true); }); + let selectorGlobalRefreshTimer = null; function scheduleSelectorGlobalRefresh() { clearTimeout(selectorGlobalRefreshTimer); @@ -1337,47 +1641,40 @@ function scheduleSelectorGlobalRefresh() { } api.addEventListener(STATUS_CHANGED_EVENT, ({ detail }) => { - if (detail?.scope !== "global") return; - scheduleSelectorGlobalRefresh(); - - const execution = getExecutionState(); - if (execution?.ckpt_name_str === detail.ckpt_name_str) { - execution.status = detail.status || "none"; - execution.status_icon = detail.status_icon || STATUS_ICON[execution.status] || ""; - execution.updated_at = Date.now(); - } - + if (!isForThisTab(detail)) return; for (const node of app.graph?._nodes || []) { - if (isNodeClass(node, TAGGER_CLASS) && node.__hpsTaggerPath === detail.ckpt_name_str) { - node.__hpsTaggerStatus = detail.status; - node.__hpsTaggerMessage = detail.status === "none" ? "Current: โ€” none" : `Current: ${STATUS_ICON[detail.status]} ${STATUS_LABEL[detail.status]}`; - node.title = detail.status === "none" ? `Tagger : ${detail.ckpt_name_str}` : `Tagger : ${STATUS_ICON[detail.status]} ${detail.ckpt_name_str}`; - app.graph.setDirtyCanvas(true, true); + if (isNodeClass(node, SELECTOR_CLASS)) { + for (const item of selectorItems(node)) { + if (item.ckpt_name_str === detail.ckpt_name_str) item.status = detail.status || "none"; + } + if (selectorSelected(node) === detail.ckpt_name_str) { + node.title = selectorTitleText(node, detail.ckpt_name_str); + } } - if (isNodeClass(node, "EphemeralPreview") && node.__hpsPreviewCkptName === detail.ckpt_name_str) { + if (PREVIEW_CLASSES.has(node.type || node.comfyClass) && node.__hpsPreviewCkptName === detail.ckpt_name_str) { node.__hpsPreviewStatus = detail.status || "none"; - node.__hpsPreviewStatusIcon = detail.status_icon || STATUS_ICON[node.__hpsPreviewStatus] || ""; - setPreviewTitleFromCheckpoint(node, detail.ckpt_name_str, node.__hpsPreviewStatus); - app.graph.setDirtyCanvas(true, true); + setPreviewTitleFromCheckpoint(node, detail.ckpt_name_str, detail.status || "none"); } - if (isNodeClass(node, "ImageDirPreview") && node.__hpsPreviewState?.ckpt_name_str === detail.ckpt_name_str) { - patchCheckpointTitle(node, "ImageDir", detail.ckpt_name_str, detail.status || "none"); - app.graph.setDirtyCanvas(true, true); + if (isNodeClass(node, TAGGER_CLASS) && currentTaggerPath(node) === detail.ckpt_name_str) { + node.__hpsTaggerStatus = detail.status || "none"; + node.__hpsTaggerMessage = taggerCurrentMessage(node.__hpsTaggerStatus); + patchCheckpointTitle(node, "Tagger", detail.ckpt_name_str, node.__hpsTaggerStatus); } // Cycler title/status are rebuilt from backend state via CYCLER_EVENT. - // Do not patch the existing Cycler title here; stale icons can accumulate. + // Do not patch the existing Cycler title here; stale status icons can accumulate. } + scheduleSelectorGlobalRefresh(); + app.graph.setDirtyCanvas(true, true); }); // ---------- Cycler ---------- -const CYCLER_FILTER_STATUSES = ["favorite", "nice", "keep", "delete", "none"]; function cyclerRects(node) { const filterY = 35; const filter = [{ status: "all", x: 8, y: filterY, w: 54, h: 24 }]; CYCLER_FILTER_STATUSES.forEach((status, i) => filter.push({ status, x: 68 + i * 48, y: filterY, w: 42, h: 24 })); return { - localListToggle: { x: 8, y: 4, w: 122, h: 24 }, - clearLocalList: { x: 136, y: 4, w: 122, h: 24 }, + localListToggle: { x: 8, y: 4, w: 150, h: 24 }, + clearLocalList: { x: 164, y: 4, w: 122, h: 24 }, filter, statusBox: { x: 8, y: 140, w: node.size[0] - 16, h: Math.max(80, node.size[1] - 150) }, }; @@ -1387,26 +1684,89 @@ function cyclerActiveFilter(node) { } async function pushCyclerFlags(node) { syncCyclerSettingsWidgets(node); - await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/set_flags`, { + const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/set_flags`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(tabPayload({ node_id: node.id, use_local_list: !!node.__hpsUseLocalList, settings_revision: node.__hpsSettingsRevision ?? 0 })), + body: JSON.stringify(tabPayload({ + node_id: node.id, + use_local_list: !!node.__hpsUseLocalList, + settings_revision: node.__hpsSettingsRevision ?? 0, + ...getCyclerRuntimeControls(node), + })), }); + const result = await response.json(); + if (result?.ok) applyCyclerStatePayload(node, result); } async function pushCyclerFilter(node) { syncCyclerSettingsWidgets(node); - await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/set_filter`, { + const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/set_filter`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(tabPayload({ node_id: node.id, statuses: cyclerActiveFilter(node), settings_revision: node.__hpsSettingsRevision ?? 0 })), + body: JSON.stringify(tabPayload({ + node_id: node.id, + statuses: cyclerActiveFilter(node), + settings_revision: node.__hpsSettingsRevision ?? 0, + ...getCyclerRuntimeControls(node), + })), }); + const result = await response.json(); + if (result?.ok) applyCyclerStatePayload(node, result); } +function schedulePushCyclerRuntimeControls(node) { + if (!node || node.__hpsApplyingRuntimePayload) return; + clearTimeout(node.__hpsRuntimePushTimer); + node.__hpsRuntimePushTimer = setTimeout(() => pushCyclerRuntimeControls(node), 120); +} + +async function pushCyclerRuntimeControls(node) { + if (!node || node.__hpsApplyingRuntimePayload) return; + syncCyclerSettingsWidgets(node); + const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/set_runtime_controls`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(tabPayload({ + node_id: node.id, + settings_revision: node.__hpsSettingsRevision ?? 0, + active_filter: cyclerActiveFilter(node), + use_local_list: node.__hpsUseLocalList !== false, + ...getCyclerRuntimeControls(node), + })), + }); + const result = await response.json(); + if (result?.ok) applyCyclerStatePayload(node, result); +} + +function installCyclerRuntimeControlHooks(node) { + if (!node) return; + let installedAny = false; + for (const name of ["start_checkpoint", "mode", "change_every"]) { + const widget = getWidget(node, name); + if (!widget || widget.__hpsRuntimeHookInstalled) continue; + widget.__hpsRuntimeHookInstalled = true; + installedAny = true; + const originalCallback = widget.callback; + widget.callback = function (...args) { + const r = originalCallback ? originalCallback.apply(this, args) : undefined; + if (!node.__hpsApplyingRuntimePayload) { + bumpCyclerSettingsRevision(node); + schedulePushCyclerRuntimeControls(node); + } + return r; + }; + } + if (installedAny) node.__hpsRuntimeHooksInstalled = true; +} + async function clearLocalList(node) { - await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/clear_local_list`, { + const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/clear_local_list`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(tabPayload({ node_id: node.id })), }); + const result = await response.json(); + if (result?.ok && result.state) applyCyclerStatePayload(node, result.state); + console.info(`[INFO] [CheckpointHandpickerSuite] Clear Local List: ${result?.cleared ?? 0} item(s)`); + app.graph?.setDirtyCanvas?.(true, true); } function cyclerCursorAt(node, local) { if (!local) return ""; @@ -1426,37 +1786,48 @@ function setupCyclerNode(nodeType) { nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; ensureSize(this, 560, 260); - this.__hpsUseLocalList = true; - this.__hpsFilterStatuses = []; - this.__hpsSettingsRevision = 0; restoreCyclerSettingsFromWidgets(this); - setTimeout(() => { restoreCyclerSettingsFromWidgets(this); pushCyclerFlags(this); pushCyclerFilter(this); }, 0); + installCyclerRuntimeControlHooks(this); + setTimeout(() => restoreNodeStateFromBackend(this, CYCLER_CLASS), 0); return r; }; const origConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const r = origConfigure ? origConfigure.apply(this, arguments) : undefined; restoreCyclerSettingsFromWidgets(this); - setTimeout(() => { restoreCyclerSettingsFromWidgets(this); pushCyclerFlags(this); pushCyclerFilter(this); }, 0); + installCyclerRuntimeControlHooks(this); + setTimeout(() => restoreNodeStateFromBackend(this, CYCLER_CLASS), 0); return r; }; const origDraw = nodeType.prototype.onDrawBackground; nodeType.prototype.onDrawBackground = function (ctx) { if (origDraw) origDraw.apply(this, arguments); ensureHiddenTabIdWidget(this); - syncCyclerSettingsWidgets(this); + restoreCyclerSettingsFromWidgets(this); + installCyclerRuntimeControlHooks(this); + const runtimeKey = JSON.stringify(getCyclerRuntimeControls(this)); + if (!this.__hpsApplyingRuntimePayload && this.__hpsLastRuntimeControlsKey && this.__hpsLastRuntimeControlsKey !== runtimeKey) { + bumpCyclerSettingsRevision(this); + schedulePushCyclerRuntimeControls(this); + } + this.__hpsLastRuntimeControlsKey = runtimeKey; if (hpsNodeCollapsed(this)) return; const r = cyclerRects(this); - drawButton(ctx, r.localListToggle, this.__hpsUseLocalList ? "โ˜‘ Use Local List" : "โ˜ Use Local List", true, this.__hpsUseLocalList); - drawButton(ctx, r.clearLocalList, "Clear Local List", true, false); const active = cyclerActiveFilter(this); - for (const b of r.filter) { - if (b.status === "all") drawButton(ctx, b, "All", true, active.length === 0); - else drawButton(ctx, b, STATUS_ICON[b.status], true, active.includes(b.status)); - } ctx.save(); + + drawButton(ctx, r.localListToggle, this.__hpsUseLocalList === false ? "โ˜ Use Local List" : "โ˜‘ Use Local List", true, this.__hpsUseLocalList !== false); + drawButton(ctx, r.clearLocalList, "Clear Local List", true, false, "rgba(105,90,90,0.65)"); + for (const b of r.filter) { + const isAll = b.status === "all"; + const on = isAll ? active.length === 0 : active.includes(b.status); + drawButton(ctx, b, isAll ? "all" : STATUS_ICON[b.status], true, on); + } + ctx.fillStyle = "rgba(0,0,0,0.18)"; ctx.fillRect(r.statusBox.x, r.statusBox.y, r.statusBox.w, r.statusBox.h); + ctx.strokeStyle = "rgba(255,255,255,0.18)"; + ctx.strokeRect(r.statusBox.x, r.statusBox.y, r.statusBox.w, r.statusBox.h); ctx.fillStyle = "#ddd"; ctx.font = "12px monospace"; const lines = (this.__hpsCyclerStatus || "Current: (not executed yet)").split("\n"); @@ -1500,13 +1871,7 @@ function setupCyclerNode(nodeType) { api.addEventListener(CYCLER_EVENT, ({ detail }) => { const node = nodeFromEvent(detail, CYCLER_CLASS); if (!node) return; - if (detail.ckpt_name_str) { - node.__hpsCyclerCkptName = detail.ckpt_name_str; - node.__hpsCyclerStatusValue = detail.status || "none"; - setExecutionState(detail); - } - if (detail.title) node.title = detail.title; - node.__hpsCyclerStatus = detail.status_text; + applyCyclerStatePayload(node, detail); app.graph.setDirtyCanvas(true, true); });