From 9f6f5273ffd0604f04b8de5af9b8fb1d45cb3ed6 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: Sat, 6 Jun 2026 09:52:56 +0900 Subject: [PATCH 01/16] =?UTF-8?q?Cycler=E3=81=AE=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=82=92=E4=BF=9D=E6=8C=81=E3=80=82=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E4=B8=AD=E3=83=BB=E5=86=8D=E8=A1=A8=E7=A4=BA=E6=99=82=E3=83=BB?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E3=82=BF=E3=83=96=E6=99=82=E3=81=AE=E7=8A=B6?= =?UTF-8?q?=E6=85=8B=E3=82=BA=E3=83=AC=E3=82=92=E8=A7=A3=E6=B6=88=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 541 ++++++++++++++++++++++++----- web/checkpoint_handpicker_suite.js | 495 +++++++++++++------------- 2 files changed, 698 insertions(+), 338 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index cbd30bf..857bf6c 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -64,6 +64,11 @@ 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: @@ -522,6 +527,7 @@ def _patch_backend_checkpoint_classes(checkpoint_values: list[str]) -> list[str] "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", @@ -1181,6 +1187,10 @@ def _get_cycler_state(node_key: str) -> dict: "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, @@ -1191,6 +1201,10 @@ def _get_cycler_state(node_key: str) -> dict: "last_hold_index": 0, "last_change_every": 1, "last_mode": "increment", + "last_execution": {}, + "runtime_mode": "increment", + "runtime_change_every": 1, + "runtime_start_checkpoint": "", "last_all_checkpoint_hash": "", "shuffle_deck": [], # compatibility with older draft state/routes @@ -1206,10 +1220,22 @@ def _get_cycler_state(node_key: str) -> dict: 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_execution" not in state: + state["last_execution"] = {} + if "runtime_mode" not in state: + state["runtime_mode"] = state.get("last_mode", "increment") + state["runtime_mode"] = _valid_cycler_mode(state.get("runtime_mode", "increment")) + if "runtime_change_every" not in state: + state["runtime_change_every"] = state.get("last_change_every", 1) + state["runtime_change_every"] = _valid_change_every(state.get("runtime_change_every", 1)) + if "runtime_start_checkpoint" not in state: + state["runtime_start_checkpoint"] = "" if "last_all_checkpoint_hash" not in state: state["last_all_checkpoint_hash"] = "" if "last_normal_ckpt_name" not in state: @@ -1276,6 +1302,185 @@ def _parse_saved_bool(value) -> bool | None: 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 _valid_cycler_mode(value) -> str: + value = str(value or "").strip() + return value if value in ("fixed", "increment", "randomize", "shuffle_once") else "increment" + + +def _valid_change_every(value) -> int: + try: + return max(1, int(value or 1)) + except Exception: + return 1 + + +def _next_execution_revision() -> int: + global _EXECUTION_REVISION + with _STATE_LOCK: + _EXECUTION_REVISION += 1 + return _EXECUTION_REVISION + + +def _cycler_snapshot_title(snapshot: dict) -> str: + return _build_cycler_title( + str(snapshot.get("source") or "cycle"), + str(snapshot.get("ckpt_name") or ""), + int(snapshot.get("hold_index") or 1), + int(snapshot.get("change_every_used") or 1), + list(snapshot.get("filter_used") or []), + bool(snapshot.get("fallback_all") and snapshot.get("mode_used") != "fixed"), + int(snapshot.get("local_list_total") or 0), + ) + + +def _cycler_snapshot_status_text(snapshot: dict, state: dict | None = None) -> str: + state = state or {} + local_list = list(state.get("local_list", [])) + lines = [_build_cycler_status_text( + str(snapshot.get("source") or "cycle"), + str(snapshot.get("ckpt_name") or ""), + list(snapshot.get("filter_used") or []), + bool(snapshot.get("fallback_all") and snapshot.get("mode_used") != "fixed"), + local_list, + int(snapshot.get("hold_index") or 1), + int(snapshot.get("change_every_used") or 1), + int(snapshot.get("matches_at_resolve") or 0), + int(snapshot.get("local_list_total") or len(local_list)), + str(snapshot.get("mode_used") or "increment"), + )] + # The snapshot is immutable execution history. Local List and live controls + # are mutable runtime state, so append them as a separate section. + runtime_lines = [] + current_mode = str(state.get("runtime_mode") or snapshot.get("mode_used") or "increment") + current_change_every = _valid_change_every(state.get("runtime_change_every") or snapshot.get("change_every_used") or 1) + current_filter = _active_filter_values(state.get("active_filter", []) or []) + current_use_local = bool(state.get("use_local_list", True)) + if local_list or current_filter != list(snapshot.get("filter_used") or []) or current_mode != str(snapshot.get("mode_used") or "increment") or current_change_every != int(snapshot.get("change_every_used") or 1) or current_use_local != bool(snapshot.get("use_local_list_used", True)): + runtime_lines.append("Runtime controls:") + runtime_lines.append(f" Mode: {current_mode}") + runtime_lines.append(f" Change Every: {current_change_every}") + runtime_lines.append(f" Filter: {_filter_label(current_filter)}") + runtime_lines.append(f" Use Local List: {'on' if current_use_local else 'off'}") + if local_list: + runtime_lines.append(f" Local List Pending: {len(local_list)}") + if runtime_lines: + lines.append("\n".join(runtime_lines)) + return "\n".join(lines) + + +def _make_cycler_snapshot(ckpt_name: str, source: str, mode: str, change_every: int, active_filter: list[str], fallback_all: bool, match_count: int, hold_index: int, use_local_list: bool, local_list_total: int = 0) -> dict: + status = _get_status(ckpt_name) + snapshot = { + "ckpt_name": ckpt_name, + "ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name), + "status": status, + "status_icon": STATUS_ICON[status], + "source": source, + "mode_used": _valid_cycler_mode(mode), + "change_every_used": _valid_change_every(change_every), + "filter_used": _active_filter_values(active_filter), + "fallback_all": bool(fallback_all), + "matches_at_resolve": int(match_count or 0), + "hold_index": int(hold_index or 1), + "use_local_list_used": bool(use_local_list), + "local_list_total": int(local_list_total or 0), + "execution_revision": _next_execution_revision(), + "executed_at": _now_iso(), + } + snapshot["title"] = _cycler_snapshot_title(snapshot) + return snapshot + + +def _store_cycler_execution_snapshot(state: dict, snapshot: dict, tab_id: str | None = None): + state["last_execution"] = dict(snapshot) + state["last_ckpt_name"] = snapshot.get("ckpt_name", "") + state["last_title"] = snapshot.get("title", "") + state["last_hold_index"] = int(snapshot.get("hold_index") or 1) + state["last_change_every"] = int(snapshot.get("change_every_used") or 1) + state["last_mode"] = str(snapshot.get("mode_used") or "increment") + if tab_id: + _TAB_EXECUTION_STATES[_clean_tab_id(tab_id)] = dict(snapshot) + + +def _current_execution_for_tab(tab_id: str | None) -> dict: + return dict(_TAB_EXECUTION_STATES.get(_clean_tab_id(tab_id), {})) + + +def _tagger_title(relpath: str, status: str | None = None) -> str: + status = status or _get_status(relpath) + return f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}" + + +def _preview_title(relpath: str, status: str | None = None) -> str: + status = status or _get_status(relpath) + icon = STATUS_ICON[status] if status != "none" else "" + display = f"{icon} {relpath}".strip() + return f"Preview : {display}" + + +def _store_tagger_state(tab_id: str | None, node_id, relpath: str): + if node_id is None: + return + status = _get_status(relpath) + key = _state_key(tab_id, node_id) + _TAGGER_STATES[key] = { + "node_class": "CheckpointStatusTagger", + "ckpt_name_str": relpath, + "status": status, + "status_icon": STATUS_ICON[status], + "title": _tagger_title(relpath, status), + "updated_at": _now_iso(), + } + + +def _store_preview_state(tab_id: str | None, node_id, node_class: str, relpath: str): + if node_id is None or not relpath: + return + status = _get_status(relpath) + key = _state_key(tab_id, node_id) + _PREVIEW_STATES[key] = { + "node_class": node_class, + "ckpt_name_str": relpath, + "status": status, + "status_icon": STATUS_ICON[status], + "title": _preview_title(relpath, status), + "updated_at": _now_iso(), + } + + +def _update_known_status_titles(relpath: str): + status = _get_status(relpath) + for state in _CYCLER_STATES.values(): + snapshot = state.get("last_execution") + if isinstance(snapshot, dict) and snapshot.get("ckpt_name") == relpath: + snapshot["status"] = status + snapshot["status_icon"] = STATUS_ICON[status] + snapshot["title"] = _cycler_snapshot_title(snapshot) + state["last_title"] = snapshot["title"] + for state in _TAGGER_STATES.values(): + if state.get("ckpt_name_str") == relpath: + state["status"] = status + state["status_icon"] = STATUS_ICON[status] + state["title"] = _tagger_title(relpath, status) + for state in _PREVIEW_STATES.values(): + if state.get("ckpt_name_str") == relpath: + state["status"] = status + state["status_icon"] = STATUS_ICON[status] + state["title"] = _preview_title(relpath, status) + + def _filter_match_count(checkpoints: list[str], statuses: list[str] | None) -> int: active = _active_filter_values(statuses) if not active: @@ -1360,61 +1565,96 @@ def _build_cycler_status_text(source: str, ckpt_name: str, active_filter: list[s def _build_cycler_pending_status_text(state: dict) -> str: all_checkpoints = _get_checkpoint_list() - active_filter = state.get("active_filter", []) or [] + 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", [])) - 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 = "local_list" if local_list else "cycle" - if not last_ckpt: - lines = ["Current: (not executed yet)"] - if local_list: - lines.append("Source: Local List") - lines.append(f"Local List Total: {len(local_list)}") - lines.append(f"Local List Remaining: {len(local_list)}") - lines.append("Pending:") - for i, item in enumerate(local_list[:8], start=1): - s = _get_status(item) - prefix = STATUS_ICON[s] if s != "none" else "-" - lines.append(f"{i}. {prefix} {item}") - if len(local_list) > 8: - lines.append(f"+{len(local_list) - 8} more") + snapshot = state.get("last_execution") if isinstance(state.get("last_execution"), dict) else {} + + if snapshot and snapshot.get("ckpt_name"): + return _cycler_snapshot_status_text(snapshot, state) + + mode = _valid_cycler_mode(state.get("runtime_mode", "increment")) + change_every = _valid_change_every(state.get("runtime_change_every", 1)) + lines = ["Current: (not executed yet)"] + if local_list: + lines.append("Source: Local List") + lines.append(f"Local List Total: {len(local_list)}") + lines.append(f"Local List Remaining: {len(local_list)}") + lines.append("Pending:") + for i, item in enumerate(local_list[:8], start=1): + s = _get_status(item) + prefix = STATUS_ICON[s] if s != "none" else "-" + lines.append(f"{i}. {prefix} {item}") + if len(local_list) > 8: + lines.append(f"+{len(local_list) - 8} more") + if active_filter: + lines.append(f"Filter: {_filter_label(active_filter)}") + lines.append("Note: Local List has priority over Filter.") + else: + if mode == "fixed": + lines.append("Source: Fixed") if active_filter: lines.append(f"Filter: {_filter_label(active_filter)}") - lines.append("Note: Local List has priority over Filter.") + lines.append("Note: Filter is ignored in fixed mode.") else: - if mode == "fixed": - lines.append("Source: Fixed") - if active_filter: - lines.append(f"Filter: {_filter_label(active_filter)}") - lines.append("Note: Filter is ignored in fixed mode.") - else: - lines.append("Source: Normal Cycle" if not active_filter else "Source: Status Filter") - lines.append(f"Filter: {_filter_label(active_filter)}") - if fallback_all: - lines.append("⚠ Filter matched 0 checkpoints. Using all checkpoints.") - lines.append(f"Matches: {match_count}") - lines.append("Note: Filter will apply on next Cycler execution.") - return "\n".join(lines) - return _build_cycler_status_text( - source, - last_ckpt, - active_filter, - fallback_all, - local_list, - hold if hold else 1, - change_every, - match_count, - len(local_list), - mode, - ) + lines.append("Source: Normal Cycle" if not active_filter else "Source: Status Filter") + lines.append(f"Filter: {_filter_label(active_filter)}") + if fallback_all: + lines.append("⚠ Filter matched 0 checkpoints. Using all checkpoints.") + lines.append(f"Matches: {match_count}") + lines.append(f"Mode: {mode}") + lines.append(f"Hold: 0 / {change_every}") + lines.append("Note: Settings will apply on next Cycler execution.") + return "\n".join(lines) def _send_cycler_state_update(node_id: str, state: dict, tab_id: str | None = None): - _send_cycler_update(node_id, "", _build_cycler_pending_status_text(state), tab_id=tab_id) + snapshot = state.get("last_execution") if isinstance(state.get("last_execution"), dict) else {} + title = snapshot.get("title", "") if snapshot else "" + ckpt_name = snapshot.get("ckpt_name") if snapshot else None + source = snapshot.get("source") if snapshot else None + mode = snapshot.get("mode_used") if snapshot else None + hold_index = snapshot.get("hold_index") if snapshot else None + change_every = snapshot.get("change_every_used") if snapshot else None + _send_cycler_update( + node_id, + title, + _build_cycler_pending_status_text(state), + tab_id=tab_id, + ckpt_name_str=ckpt_name, + source=source, + mode=mode, + hold_index=hold_index, + change_every=change_every, + ) + + +def _cycler_state_payload(state: dict) -> dict: + snapshot = state.get("last_execution") if isinstance(state.get("last_execution"), dict) else {} + return { + "runtime_controls": { + "mode": _valid_cycler_mode(state.get("runtime_mode", "increment")), + "change_every": _valid_change_every(state.get("runtime_change_every", 1)), + "start_checkpoint": _normalize_relpath(state.get("runtime_start_checkpoint", "")), + "filter_statuses": _active_filter_values(state.get("active_filter", []) or []), + "use_local_list": bool(state.get("use_local_list", True)), + "settings_revision": (_parse_saved_int(state.get("settings_revision")) if _parse_saved_int(state.get("settings_revision")) is not None else -1), + }, + "runtime_state": { + "local_list_count": len(state.get("local_list", []) or []), + }, + "last_execution": dict(snapshot) if snapshot else {}, + "title": snapshot.get("title", "") if snapshot else "", + "status_text": _build_cycler_pending_status_text(state), + } + + +def _should_accept_settings_update(state: dict, revision: int | None) -> tuple[bool, int]: + state_revision = (_parse_saved_int(state.get("settings_revision")) if _parse_saved_int(state.get("settings_revision")) is not None else -1) + if revision is None: + revision = state_revision + 1 + return revision >= state_revision, revision class CheckpointListSelector: @@ -1457,6 +1697,7 @@ class CheckpointNameCycler: "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", @@ -1472,27 +1713,55 @@ class CheckpointNameCycler: def IS_CHANGED(cls, **kwargs): return time.time_ns() - def cycle(self, start_checkpoint, mode, change_every, hps_tab_id="", hps_filter_statuses="", hps_use_local_list="", unique_id=None): + 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__" tab_id = _clean_tab_id(hps_tab_id) node_key = _state_key(tab_id, node_id) state = _get_cycler_state(node_key) - 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 + hidden_revision = _parse_saved_int(hps_settings_revision) + state_revision = (_parse_saved_int(state.get("settings_revision")) if _parse_saved_int(state.get("settings_revision")) is not None else -1) + queued_mode = _valid_cycler_mode(mode) + queued_change_every = _valid_change_every(change_every) + queued_start_checkpoint = _normalize_relpath(start_checkpoint) + + # 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. Older queued prompts can + # contain stale hidden/widget values, so only apply them 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["runtime_mode"] = queued_mode + state["runtime_change_every"] = queued_change_every + state["runtime_start_checkpoint"] = queued_start_checkpoint + state["settings_revision"] = hidden_revision + else: + # First execution in a legacy/no-frontend situation: seed runtime + # controls from the prompt once. After that, the live backend state + # owns these values. + if not state.get("runtime_initialized"): + state["runtime_mode"] = queued_mode + state["runtime_change_every"] = queued_change_every + state["runtime_start_checkpoint"] = queued_start_checkpoint + + state["runtime_initialized"] = True all_checkpoints = _get_checkpoint_list() if not all_checkpoints: return ("", "", "checkpoint") - mode = mode if mode in ("fixed", "increment", "randomize", "shuffle_once") else "increment" - change_every = max(1, int(change_every or 1)) + mode = _valid_cycler_mode(state.get("runtime_mode", queued_mode)) + change_every = _valid_change_every(state.get("runtime_change_every", queued_change_every)) + start_checkpoint = _normalize_relpath(state.get("runtime_start_checkpoint") or queued_start_checkpoint) active_filter = _active_filter_values(state.get("active_filter", []) or []) + use_local_list = bool(state.get("use_local_list", True)) match_count = _filter_match_count(all_checkpoints, active_filter) fallback_all = bool(active_filter) and match_count == 0 all_set = set(all_checkpoints) @@ -1506,9 +1775,10 @@ class CheckpointNameCycler: state["last_normal_ckpt_name"] = "" state["repeat_count"] = 0 - # 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 [] + # Local List is a separate mutable runtime lane. Its contents are not + # part of the immutable execution snapshot. If it selects a checkpoint, + # only the selected result and source are recorded in the snapshot. + local_list = list(state.get("local_list", [])) if use_local_list else [] selected_from_local = None skipped_local = 0 while local_list: @@ -1523,18 +1793,25 @@ class CheckpointNameCycler: 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, local_total) - status_text = _build_cycler_status_text("local_list", ckpt_name, active_filter, False, local_list, hold_index, 1, match_count, local_total, mode) + snapshot = _make_cycler_snapshot( + ckpt_name, + "local_list", + mode, + 1, + active_filter, + False, + match_count, + hold_index, + use_local_list, + local_total, + ) + _store_cycler_execution_snapshot(state, snapshot, tab_id) + status_text = _cycler_snapshot_status_text(snapshot, state) 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 _send_cycler_update( node_id, - title, + snapshot["title"], status_text, tab_id=tab_id, ckpt_name_str=ckpt_name, @@ -1543,7 +1820,7 @@ class CheckpointNameCycler: hold_index=hold_index, change_every=1, ) - return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name)) + return (ckpt_name, ckpt_name, snapshot["ckpt_name_safe"]) elif skipped_local: state["local_list"] = [] state["override_queue"] = [] @@ -1586,7 +1863,6 @@ class CheckpointNameCycler: and (mode == "fixed" or is_match(last_ckpt)) ) - new_selection = False if can_hold_last: ckpt_name = last_ckpt try: @@ -1594,7 +1870,6 @@ class CheckpointNameCycler: 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] @@ -1625,19 +1900,26 @@ class CheckpointNameCycler: state["current_index"] = selected_index source = "cycle" - title = _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filter, fallback_all and mode != "fixed", 0) - status_text = _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all and mode != "fixed", [], hold_index, change_every, match_count, 0, mode) + snapshot = _make_cycler_snapshot( + ckpt_name, + source, + mode, + change_every, + active_filter, + fallback_all and mode != "fixed", + match_count, + hold_index, + use_local_list, + 0, + ) + _store_cycler_execution_snapshot(state, snapshot, tab_id) + state["last_normal_ckpt_name"] = ckpt_name + status_text = _cycler_snapshot_status_text(snapshot, state) if mode == "shuffle_once": status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" - state["last_ckpt_name"] = ckpt_name - state["last_normal_ckpt_name"] = ckpt_name - state["last_title"] = title - state["last_hold_index"] = hold_index - state["last_change_every"] = change_every - state["last_mode"] = mode _send_cycler_update( node_id, - title, + snapshot["title"], status_text, tab_id=tab_id, ckpt_name_str=ckpt_name, @@ -1646,7 +1928,7 @@ class CheckpointNameCycler: hold_index=hold_index, change_every=change_every, ) - return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name)) + return (ckpt_name, ckpt_name, snapshot["ckpt_name_safe"]) class CheckpointStatusTagger: @@ -1676,7 +1958,8 @@ class CheckpointStatusTagger: def tag(self, ckpt_name_str, hps_tab_id="", unique_id=None): relpath = _normalize_relpath(ckpt_name_str) status = _get_status(relpath) - title = f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}" + title = _tagger_title(relpath, status) + _store_tagger_state(hps_tab_id, unique_id, relpath) _send_event(TAGGER_EVENT, { "node": int(unique_id) if unique_id is not None else None, "tab_id": _clean_tab_id(hps_tab_id), @@ -1711,7 +1994,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) + execution = _current_execution_for_tab(hps_tab_id) + title = "Ephemeral Preview" + if execution.get("ckpt_name"): + relpath = _normalize_relpath(execution.get("ckpt_name", "")) + status = _get_status(relpath) + meta.update({ + "ckpt_name_str": relpath, + "ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath), + "status": status, + "status_icon": STATUS_ICON[status], + "source": execution.get("source", ""), + "execution_revision": execution.get("execution_revision"), + }) + title = _preview_title(relpath, status) + _store_preview_state(hps_tab_id, unique_id, "EphemeralPreview", relpath) + _send_preview(unique_id, title, sheet, meta, tab_id=hps_tab_id) except Exception: logger.exception("EphemeralPreview failed") return () @@ -1753,6 +2051,26 @@ 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, {})) + return web.json_response({"ok": True, "node_id": node_id, "node_class": node_class, "state": state}) + if node_class == "EphemeralPreview": + state = dict(_PREVIEW_STATES.get(key, {})) + return web.json_response({"ok": True, "node_id": node_id, "node_class": node_class, "state": 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() @@ -1838,6 +2156,8 @@ async def tagger_set_status(request): "cancelled_at": _now_iso(), }) _write_delete_script() + _update_known_status_titles(relpath) + payload = { "scope": "global", "ok": True, @@ -1878,8 +2198,9 @@ async def review_sync_checkpoint(request): if str(x).isdigit() ] status = _get_status(relpath) - title = f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}" + title = _tagger_title(relpath, status) for node_id in tagger_ids: + _store_tagger_state(tab_id, node_id, relpath) _send_event(TAGGER_EVENT, { "node": int(node_id), "tab_id": tab_id, @@ -1915,17 +2236,55 @@ async def review_sync_checkpoint(request): }) +@routes.get(f"/{EXTENSION_PREFIX}/cycler/state") +async def cycler_get_state(request): + node_id = str(request.query.get("node_id", "")) + tab_id = _clean_tab_id(request.query.get("tab_id", "")) + state = _get_cycler_state(_state_key(tab_id, node_id)) + payload = _cycler_state_payload(state) + payload.update({"ok": True, "node_id": node_id}) + return web.json_response(payload) + + +@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")) + accepted, revision = _should_accept_settings_update(state, revision) + if accepted: + if "mode" in data: + state["runtime_mode"] = _valid_cycler_mode(data.get("mode")) + if "change_every" in data: + state["runtime_change_every"] = _valid_change_every(data.get("change_every")) + if "start_checkpoint" in data: + state["runtime_start_checkpoint"] = _normalize_relpath(data.get("start_checkpoint", "")) + state["runtime_initialized"] = True + state["settings_revision"] = revision + _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)) - if "use_local_list" in data: + revision = _parse_saved_int(data.get("settings_revision")) + accepted, revision = _should_accept_settings_update(state, revision) + if accepted and "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 _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") @@ -1935,10 +2294,16 @@ async def cycler_set_filter(request): tab_id = _clean_tab_id(data.get("tab_id", "")) statuses = [s for s in data.get("statuses", []) if s in STATUS_VALUES] state = _get_cycler_state(_state_key(tab_id, node_id)) - state["active_filter"] = statuses.copy() + revision = _parse_saved_int(data.get("settings_revision")) + accepted, revision = _should_accept_settings_update(state, revision) + if accepted: + state["active_filter"] = statuses.copy() + state["settings_revision"] = revision _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": state.get("active_filter", [])}) + return web.json_response(payload) @routes.post(f"/{EXTENSION_PREFIX}/cycler/local_list_append") diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 58fef54..3f8b572 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -158,9 +158,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 +179,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 +331,6 @@ function installCheckpointRefreshFuturePatch(nodeType, nodeData) { }; } - function isNodeClass(node, className) { return node && (node.type === className || node.comfyClass === className); } @@ -368,9 +363,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 +373,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)) { @@ -412,6 +406,41 @@ function parseSavedBool(value, fallback) { return fallback; } +function parseSavedInt(value, fallback = 0) { + const n = Number.parseInt(String(value ?? "").trim(), 10); + return Number.isFinite(n) ? n : fallback; +} + +function bumpCyclerSettingsRevision(node) { + node.__hpsSettingsRevision = Math.max(0, parseSavedInt(node.__hpsSettingsRevision, 0)) + 1; + 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) { + 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); } @@ -428,13 +457,18 @@ function restoreCyclerSettingsFromWidgets(node) { const useLocalWidget = getWidget(node, "hps_use_local_list"); node.__hpsUseLocalList = parseSavedBool(useLocalWidget?.value, node.__hpsUseLocalList ?? true); + const revisionWidget = getWidget(node, "hps_settings_revision"); + node.__hpsSettingsRevision = parseSavedInt(revisionWidget?.value, node.__hpsSettingsRevision ?? 0); + 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)); } function syncCyclerSettingsWidgets(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)); } function scheduleHideTabIdWidget(node) { @@ -535,9 +569,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]; } @@ -587,6 +618,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 = node.__hpsTaggerStatus === "none" + ? "Current: — none" + : `Current: ${STATUS_ICON[node.__hpsTaggerStatus]} ${STATUS_LABEL[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.ckpt_name_str) { + node.__hpsCyclerCkptName = result.ckpt_name_str; + node.__hpsCyclerStatusValue = result.status || "none"; + } + if (result.status_text) node.__hpsCyclerStatus = result.status_text; + if (result.title) node.title = result.title; + } + 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); @@ -596,6 +668,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; }; @@ -662,11 +741,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"; } @@ -678,7 +759,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; @@ -793,7 +873,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); @@ -943,31 +1022,17 @@ 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), - }; - }), + search_directories: Object.fromEntries(targets.previews.map((n) => [String(n.id), imageDirSearchDirectory(n)])), + max_preview_images: Object.fromEntries(targets.previews.map((n) => [String(n.id), 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); } @@ -981,238 +1046,151 @@ function selectorScrollbar(node) { const items = selectorItems(node); if (items.length <= SELECTOR_VISIBLE_ROWS) return null; const r = selectorRects(node).list; - const scroll = Math.max(0, Math.min(node.__hpsScroll || 0, maxSelectorScroll(node))); - const thumbH = Math.max(24, r.h * (SELECTOR_VISIBLE_ROWS / items.length)); - const range = Math.max(1, maxSelectorScroll(node)); - const y = r.y + (r.h - thumbH) * (scroll / range); - return { - 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 }, - }; -} - -function setSelectorScrollFromScrollbarY(node, localY, thumbH, dragOffset = thumbH / 2) { - const r = selectorRects(node).list; + const scroll = node.__hpsScroll || 0; + const ratio = SELECTOR_VISIBLE_ROWS / items.length; + const h = Math.max(20, r.h * ratio); const maxScroll = maxSelectorScroll(node); - 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); -} - -function selectorLocalFromEventOrPos(node, event, pos) { - const fromEvent = event ? graphEventToLocal(node, event) : null; - if (fromEvent) return fromEvent; - return candidatePositions(node, pos)?.[0] || null; -} - -let selectorWheelCaptureInstalled = false; -function installSelectorWheelCapture() { - if (selectorWheelCaptureInstalled) return; - selectorWheelCaptureInstalled = true; - const canvasEl = app.canvas?.canvas; - if (!canvasEl) return; - canvasEl.addEventListener("wheel", (event) => { - const canvas = app.canvas; - const graph = app.graph; - if (!canvas || !graph) return; - let graphPos = null; - try { - graphPos = canvas.convertEventToCanvasOffset?.(event); - } catch { - 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)]; - if (!hit(local, selectorRects(node).list)) continue; - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation?.(); - scrollSelector(node, event.deltaY > 0 ? 3 : -3); - return; - } - }, { passive: false, capture: true }); + const y = r.y + (maxScroll ? ((r.h - h) * scroll / maxScroll) : 0); + return { x: r.x + r.w - 8, y, w: 6, h }; } 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"; - 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" : ""; + if (hit(local, r.refreshAll) || hit(local, r.listOnly) || hit(local, r.pushLocalList) || hit(local, r.up) || hit(local, r.down)) return "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"; + ctx.fillText(`${STATUS_ICON[status] || "—"} ${ckpt}`, r.list.x + 6, y + ROW_H / 2, r.list.w - 20); + } + const sb = selectorScrollbar(node); + if (sb) { + ctx.fillStyle = "rgba(255,255,255,0.35)"; + ctx.fillRect(sb.x, sb.y, sb.w, sb.h); + } + ctx.restore(); +} + function setupSelectorNode(nodeType) { - installMinSize(nodeType, 560, 520); + installMinSize(nodeType, 520, 540); + installTabIdSupport(nodeType); installCursorCapture(); + 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.refreshAll, "Refresh All", !this.__hpsLoading); + drawButton(ctx, r.listOnly, "List", !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 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 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 (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)) { - this.__hpsScrollbarDragging = true; - this.__hpsScrollbarDragOffset = sbHitPos[1] - sb.thumb.y; - } else { - setSelectorScrollFromScrollbarY(this, sbHitPos[1], sb.thumb.h); - } + if (hitAny(this, pos, r.pushLocalList)) { + if (selectorActionMode(this) === "sync") syncSelectedCheckpoint(this); + else pushSelectedToLocalList(this); 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]; + if (hitAny(this, pos, r.up)) { scrollSelector(this, -1); return true; } + if (hitAny(this, pos, r.down)) { scrollSelector(this, 1); return true; } + + 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) { 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)) { - 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); - return true; - } - return origMouseMove ? origMouseMove.apply(this, arguments) : false; - }; - const origMouseUp = nodeType.prototype.onMouseUp; - nodeType.prototype.onMouseUp = function () { - if (this.__hpsScrollbarDragging) { - this.__hpsScrollbarDragging = false; - this.__hpsScrollbarDragOffset = 0; - 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; + + const origMouseWheel = 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?.(); + const r = selectorRects(this); + if (candidatePositions(this, pos).some((p) => hit(p, r.list))) { scrollSelector(this, e.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 || ""; + return node.__hpsTaggerPath || linkedInputValue(node, "ckpt_name_str") || ""; } 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) => ({ + return STATUS_ORDER.map((status, i) => ({ status, - x: startX + i * (buttonW + gap), - y: 5, - w: buttonW, - h: 24, + x: 8 + i * 84, + y: 8, + w: 78, + h: 26, })); } - function taggerDeleteEnabled(node) { const current = node.__hpsTaggerStatus || "none"; return current === "none" || current === "delete"; @@ -1254,6 +1232,13 @@ function setupTaggerNode(nodeType) { nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; ensureSize(this, 450, 100); + 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; @@ -1311,6 +1296,7 @@ api.addEventListener(TAGGER_EVENT, ({ detail }) => { if (detail.title) node.title = detail.title; app.graph.setDirtyCanvas(true, true); }); + let selectorGlobalRefreshTimer = null; function scheduleSelectorGlobalRefresh() { clearTimeout(selectorGlobalRefreshTimer); @@ -1322,42 +1308,31 @@ 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 (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 = node.__hpsTaggerStatus === "none" ? "Current: — none" : `Current: ${STATUS_ICON[node.__hpsTaggerStatus]} ${STATUS_LABEL[node.__hpsTaggerStatus]}`; + patchCheckpointTitle(node, "Tagger", detail.ckpt_name_str, node.__hpsTaggerStatus); } if (isNodeClass(node, CYCLER_CLASS) && node.__hpsCyclerCkptName === detail.ckpt_name_str) { patchCheckpointTitle(node, "Cycler", detail.ckpt_name_str, detail.status || "none"); - app.graph.setDirtyCanvas(true, true); } } + 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 }]; @@ -1377,7 +1352,12 @@ async function pushCyclerFlags(node) { 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 })), + body: JSON.stringify(tabPayload({ + node_id: node.id, + use_local_list: !!node.__hpsUseLocalList, + settings_revision: node.__hpsSettingsRevision ?? 0, + ...getCyclerRuntimeControls(node), + })), }); } async function pushCyclerFilter(node) { @@ -1385,7 +1365,12 @@ async function pushCyclerFilter(node) { 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) })), + body: JSON.stringify(tabPayload({ + node_id: node.id, + statuses: cyclerActiveFilter(node), + settings_revision: node.__hpsSettingsRevision ?? 0, + ...getCyclerRuntimeControls(node), + })), }); } async function clearLocalList(node) { @@ -1413,36 +1398,44 @@ function setupCyclerNode(nodeType) { nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; ensureSize(this, 560, 260); - this.__hpsUseLocalList = true; - this.__hpsFilterStatuses = []; restoreCyclerSettingsFromWidgets(this); - setTimeout(() => { restoreCyclerSettingsFromWidgets(this); pushCyclerFlags(this); pushCyclerFilter(this); }, 0); + 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); + 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); 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 ? "Local List OFF" : "Local List ON", 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 = "#aaa"; + ctx.font = "12px sans-serif"; + ctx.fillText(`filter: ${active.length ? active.join(", ") : "all"}`, 8, 78); + ctx.fillText(`settings revision: ${this.__hpsSettingsRevision ?? 0}`, 8, 96); + 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"); @@ -1455,6 +1448,7 @@ function setupCyclerNode(nodeType) { const r = cyclerRects(this); if (hitAny(this, pos, r.localListToggle)) { this.__hpsUseLocalList = !this.__hpsUseLocalList; + bumpCyclerSettingsRevision(this); syncCyclerSettingsWidgets(this); pushCyclerFlags(this); app.graph.setDirtyCanvas(true, true); @@ -1473,6 +1467,7 @@ function setupCyclerNode(nodeType) { if (set.has(b.status)) set.delete(b.status); else set.add(b.status); this.__hpsFilterStatuses = CYCLER_FILTER_STATUSES.filter((x) => set.has(x)); } + bumpCyclerSettingsRevision(this); syncCyclerSettingsWidgets(this); pushCyclerFilter(this); app.graph.setDirtyCanvas(true, true); From b0b99d470510e83955dbd27ed7d1221dbf9b83bc 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: Sat, 6 Jun 2026 10:44:51 +0900 Subject: [PATCH 02/16] =?UTF-8?q?Selector=E3=81=AE=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=AB=E3=83=90=E3=83=BC=E3=81=8C=E5=8B=95?= =?UTF-8?q?=E4=BD=9C=E4=B8=8D=E8=89=AF=E3=82=92=E8=B5=B7=E3=81=93=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=81=9F=E3=81=AE=E3=81=A7=E6=88=BB=E3=81=97?= =?UTF-8?q?=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 541 +++++------------------------ web/checkpoint_handpicker_suite.js | 202 ++++++++++- 2 files changed, 274 insertions(+), 469 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index 857bf6c..cbd30bf 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -64,11 +64,6 @@ 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: @@ -527,7 +522,6 @@ def _patch_backend_checkpoint_classes(checkpoint_values: list[str]) -> list[str] "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", @@ -1187,10 +1181,6 @@ def _get_cycler_state(node_key: str) -> dict: "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, @@ -1201,10 +1191,6 @@ def _get_cycler_state(node_key: str) -> dict: "last_hold_index": 0, "last_change_every": 1, "last_mode": "increment", - "last_execution": {}, - "runtime_mode": "increment", - "runtime_change_every": 1, - "runtime_start_checkpoint": "", "last_all_checkpoint_hash": "", "shuffle_deck": [], # compatibility with older draft state/routes @@ -1220,22 +1206,10 @@ def _get_cycler_state(node_key: str) -> dict: 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_execution" not in state: - state["last_execution"] = {} - if "runtime_mode" not in state: - state["runtime_mode"] = state.get("last_mode", "increment") - state["runtime_mode"] = _valid_cycler_mode(state.get("runtime_mode", "increment")) - if "runtime_change_every" not in state: - state["runtime_change_every"] = state.get("last_change_every", 1) - state["runtime_change_every"] = _valid_change_every(state.get("runtime_change_every", 1)) - if "runtime_start_checkpoint" not in state: - state["runtime_start_checkpoint"] = "" if "last_all_checkpoint_hash" not in state: state["last_all_checkpoint_hash"] = "" if "last_normal_ckpt_name" not in state: @@ -1302,185 +1276,6 @@ def _parse_saved_bool(value) -> bool | None: 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 _valid_cycler_mode(value) -> str: - value = str(value or "").strip() - return value if value in ("fixed", "increment", "randomize", "shuffle_once") else "increment" - - -def _valid_change_every(value) -> int: - try: - return max(1, int(value or 1)) - except Exception: - return 1 - - -def _next_execution_revision() -> int: - global _EXECUTION_REVISION - with _STATE_LOCK: - _EXECUTION_REVISION += 1 - return _EXECUTION_REVISION - - -def _cycler_snapshot_title(snapshot: dict) -> str: - return _build_cycler_title( - str(snapshot.get("source") or "cycle"), - str(snapshot.get("ckpt_name") or ""), - int(snapshot.get("hold_index") or 1), - int(snapshot.get("change_every_used") or 1), - list(snapshot.get("filter_used") or []), - bool(snapshot.get("fallback_all") and snapshot.get("mode_used") != "fixed"), - int(snapshot.get("local_list_total") or 0), - ) - - -def _cycler_snapshot_status_text(snapshot: dict, state: dict | None = None) -> str: - state = state or {} - local_list = list(state.get("local_list", [])) - lines = [_build_cycler_status_text( - str(snapshot.get("source") or "cycle"), - str(snapshot.get("ckpt_name") or ""), - list(snapshot.get("filter_used") or []), - bool(snapshot.get("fallback_all") and snapshot.get("mode_used") != "fixed"), - local_list, - int(snapshot.get("hold_index") or 1), - int(snapshot.get("change_every_used") or 1), - int(snapshot.get("matches_at_resolve") or 0), - int(snapshot.get("local_list_total") or len(local_list)), - str(snapshot.get("mode_used") or "increment"), - )] - # The snapshot is immutable execution history. Local List and live controls - # are mutable runtime state, so append them as a separate section. - runtime_lines = [] - current_mode = str(state.get("runtime_mode") or snapshot.get("mode_used") or "increment") - current_change_every = _valid_change_every(state.get("runtime_change_every") or snapshot.get("change_every_used") or 1) - current_filter = _active_filter_values(state.get("active_filter", []) or []) - current_use_local = bool(state.get("use_local_list", True)) - if local_list or current_filter != list(snapshot.get("filter_used") or []) or current_mode != str(snapshot.get("mode_used") or "increment") or current_change_every != int(snapshot.get("change_every_used") or 1) or current_use_local != bool(snapshot.get("use_local_list_used", True)): - runtime_lines.append("Runtime controls:") - runtime_lines.append(f" Mode: {current_mode}") - runtime_lines.append(f" Change Every: {current_change_every}") - runtime_lines.append(f" Filter: {_filter_label(current_filter)}") - runtime_lines.append(f" Use Local List: {'on' if current_use_local else 'off'}") - if local_list: - runtime_lines.append(f" Local List Pending: {len(local_list)}") - if runtime_lines: - lines.append("\n".join(runtime_lines)) - return "\n".join(lines) - - -def _make_cycler_snapshot(ckpt_name: str, source: str, mode: str, change_every: int, active_filter: list[str], fallback_all: bool, match_count: int, hold_index: int, use_local_list: bool, local_list_total: int = 0) -> dict: - status = _get_status(ckpt_name) - snapshot = { - "ckpt_name": ckpt_name, - "ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name), - "status": status, - "status_icon": STATUS_ICON[status], - "source": source, - "mode_used": _valid_cycler_mode(mode), - "change_every_used": _valid_change_every(change_every), - "filter_used": _active_filter_values(active_filter), - "fallback_all": bool(fallback_all), - "matches_at_resolve": int(match_count or 0), - "hold_index": int(hold_index or 1), - "use_local_list_used": bool(use_local_list), - "local_list_total": int(local_list_total or 0), - "execution_revision": _next_execution_revision(), - "executed_at": _now_iso(), - } - snapshot["title"] = _cycler_snapshot_title(snapshot) - return snapshot - - -def _store_cycler_execution_snapshot(state: dict, snapshot: dict, tab_id: str | None = None): - state["last_execution"] = dict(snapshot) - state["last_ckpt_name"] = snapshot.get("ckpt_name", "") - state["last_title"] = snapshot.get("title", "") - state["last_hold_index"] = int(snapshot.get("hold_index") or 1) - state["last_change_every"] = int(snapshot.get("change_every_used") or 1) - state["last_mode"] = str(snapshot.get("mode_used") or "increment") - if tab_id: - _TAB_EXECUTION_STATES[_clean_tab_id(tab_id)] = dict(snapshot) - - -def _current_execution_for_tab(tab_id: str | None) -> dict: - return dict(_TAB_EXECUTION_STATES.get(_clean_tab_id(tab_id), {})) - - -def _tagger_title(relpath: str, status: str | None = None) -> str: - status = status or _get_status(relpath) - return f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}" - - -def _preview_title(relpath: str, status: str | None = None) -> str: - status = status or _get_status(relpath) - icon = STATUS_ICON[status] if status != "none" else "" - display = f"{icon} {relpath}".strip() - return f"Preview : {display}" - - -def _store_tagger_state(tab_id: str | None, node_id, relpath: str): - if node_id is None: - return - status = _get_status(relpath) - key = _state_key(tab_id, node_id) - _TAGGER_STATES[key] = { - "node_class": "CheckpointStatusTagger", - "ckpt_name_str": relpath, - "status": status, - "status_icon": STATUS_ICON[status], - "title": _tagger_title(relpath, status), - "updated_at": _now_iso(), - } - - -def _store_preview_state(tab_id: str | None, node_id, node_class: str, relpath: str): - if node_id is None or not relpath: - return - status = _get_status(relpath) - key = _state_key(tab_id, node_id) - _PREVIEW_STATES[key] = { - "node_class": node_class, - "ckpt_name_str": relpath, - "status": status, - "status_icon": STATUS_ICON[status], - "title": _preview_title(relpath, status), - "updated_at": _now_iso(), - } - - -def _update_known_status_titles(relpath: str): - status = _get_status(relpath) - for state in _CYCLER_STATES.values(): - snapshot = state.get("last_execution") - if isinstance(snapshot, dict) and snapshot.get("ckpt_name") == relpath: - snapshot["status"] = status - snapshot["status_icon"] = STATUS_ICON[status] - snapshot["title"] = _cycler_snapshot_title(snapshot) - state["last_title"] = snapshot["title"] - for state in _TAGGER_STATES.values(): - if state.get("ckpt_name_str") == relpath: - state["status"] = status - state["status_icon"] = STATUS_ICON[status] - state["title"] = _tagger_title(relpath, status) - for state in _PREVIEW_STATES.values(): - if state.get("ckpt_name_str") == relpath: - state["status"] = status - state["status_icon"] = STATUS_ICON[status] - state["title"] = _preview_title(relpath, status) - - def _filter_match_count(checkpoints: list[str], statuses: list[str] | None) -> int: active = _active_filter_values(statuses) if not active: @@ -1565,96 +1360,61 @@ def _build_cycler_status_text(source: str, ckpt_name: str, active_filter: list[s def _build_cycler_pending_status_text(state: dict) -> str: all_checkpoints = _get_checkpoint_list() - active_filter = _active_filter_values(state.get("active_filter", []) or []) + active_filter = 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", [])) - snapshot = state.get("last_execution") if isinstance(state.get("last_execution"), dict) else {} - - if snapshot and snapshot.get("ckpt_name"): - return _cycler_snapshot_status_text(snapshot, state) - - mode = _valid_cycler_mode(state.get("runtime_mode", "increment")) - change_every = _valid_change_every(state.get("runtime_change_every", 1)) - lines = ["Current: (not executed yet)"] - if local_list: - lines.append("Source: Local List") - lines.append(f"Local List Total: {len(local_list)}") - lines.append(f"Local List Remaining: {len(local_list)}") - lines.append("Pending:") - for i, item in enumerate(local_list[:8], start=1): - s = _get_status(item) - prefix = STATUS_ICON[s] if s != "none" else "-" - lines.append(f"{i}. {prefix} {item}") - if len(local_list) > 8: - lines.append(f"+{len(local_list) - 8} more") - if active_filter: - lines.append(f"Filter: {_filter_label(active_filter)}") - lines.append("Note: Local List has priority over Filter.") - else: - if mode == "fixed": - lines.append("Source: Fixed") + 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 = "local_list" if local_list else "cycle" + if not last_ckpt: + lines = ["Current: (not executed yet)"] + if local_list: + lines.append("Source: Local List") + lines.append(f"Local List Total: {len(local_list)}") + lines.append(f"Local List Remaining: {len(local_list)}") + lines.append("Pending:") + for i, item in enumerate(local_list[:8], start=1): + s = _get_status(item) + prefix = STATUS_ICON[s] if s != "none" else "-" + lines.append(f"{i}. {prefix} {item}") + if len(local_list) > 8: + lines.append(f"+{len(local_list) - 8} more") if active_filter: lines.append(f"Filter: {_filter_label(active_filter)}") - lines.append("Note: Filter is ignored in fixed mode.") + lines.append("Note: Local List has priority over Filter.") else: - lines.append("Source: Normal Cycle" if not active_filter else "Source: Status Filter") - lines.append(f"Filter: {_filter_label(active_filter)}") - if fallback_all: - lines.append("⚠ Filter matched 0 checkpoints. Using all checkpoints.") - lines.append(f"Matches: {match_count}") - lines.append(f"Mode: {mode}") - lines.append(f"Hold: 0 / {change_every}") - lines.append("Note: Settings will apply on next Cycler execution.") - return "\n".join(lines) - - -def _send_cycler_state_update(node_id: str, state: dict, tab_id: str | None = None): - snapshot = state.get("last_execution") if isinstance(state.get("last_execution"), dict) else {} - title = snapshot.get("title", "") if snapshot else "" - ckpt_name = snapshot.get("ckpt_name") if snapshot else None - source = snapshot.get("source") if snapshot else None - mode = snapshot.get("mode_used") if snapshot else None - hold_index = snapshot.get("hold_index") if snapshot else None - change_every = snapshot.get("change_every_used") if snapshot else None - _send_cycler_update( - node_id, - title, - _build_cycler_pending_status_text(state), - tab_id=tab_id, - ckpt_name_str=ckpt_name, - source=source, - mode=mode, - hold_index=hold_index, - change_every=change_every, + if mode == "fixed": + lines.append("Source: Fixed") + if active_filter: + lines.append(f"Filter: {_filter_label(active_filter)}") + lines.append("Note: Filter is ignored in fixed mode.") + else: + lines.append("Source: Normal Cycle" if not active_filter else "Source: Status Filter") + lines.append(f"Filter: {_filter_label(active_filter)}") + if fallback_all: + lines.append("⚠ Filter matched 0 checkpoints. Using all checkpoints.") + lines.append(f"Matches: {match_count}") + lines.append("Note: Filter will apply on next Cycler execution.") + return "\n".join(lines) + return _build_cycler_status_text( + source, + last_ckpt, + active_filter, + fallback_all, + local_list, + hold if hold else 1, + change_every, + match_count, + len(local_list), + mode, ) -def _cycler_state_payload(state: dict) -> dict: - snapshot = state.get("last_execution") if isinstance(state.get("last_execution"), dict) else {} - return { - "runtime_controls": { - "mode": _valid_cycler_mode(state.get("runtime_mode", "increment")), - "change_every": _valid_change_every(state.get("runtime_change_every", 1)), - "start_checkpoint": _normalize_relpath(state.get("runtime_start_checkpoint", "")), - "filter_statuses": _active_filter_values(state.get("active_filter", []) or []), - "use_local_list": bool(state.get("use_local_list", True)), - "settings_revision": (_parse_saved_int(state.get("settings_revision")) if _parse_saved_int(state.get("settings_revision")) is not None else -1), - }, - "runtime_state": { - "local_list_count": len(state.get("local_list", []) or []), - }, - "last_execution": dict(snapshot) if snapshot else {}, - "title": snapshot.get("title", "") if snapshot else "", - "status_text": _build_cycler_pending_status_text(state), - } - - -def _should_accept_settings_update(state: dict, revision: int | None) -> tuple[bool, int]: - state_revision = (_parse_saved_int(state.get("settings_revision")) if _parse_saved_int(state.get("settings_revision")) is not None else -1) - if revision is None: - revision = state_revision + 1 - return revision >= state_revision, revision +def _send_cycler_state_update(node_id: str, state: dict, tab_id: str | None = None): + _send_cycler_update(node_id, "", _build_cycler_pending_status_text(state), tab_id=tab_id) class CheckpointListSelector: @@ -1697,7 +1457,6 @@ class CheckpointNameCycler: "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", @@ -1713,55 +1472,27 @@ class CheckpointNameCycler: def IS_CHANGED(cls, **kwargs): return time.time_ns() - 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): + def cycle(self, start_checkpoint, mode, change_every, hps_tab_id="", hps_filter_statuses="", hps_use_local_list="", unique_id=None): node_id = str(unique_id) if unique_id is not None else "__default__" tab_id = _clean_tab_id(hps_tab_id) node_key = _state_key(tab_id, node_id) state = _get_cycler_state(node_key) - hidden_revision = _parse_saved_int(hps_settings_revision) - state_revision = (_parse_saved_int(state.get("settings_revision")) if _parse_saved_int(state.get("settings_revision")) is not None else -1) - queued_mode = _valid_cycler_mode(mode) - queued_change_every = _valid_change_every(change_every) - queued_start_checkpoint = _normalize_relpath(start_checkpoint) - - # 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. Older queued prompts can - # contain stale hidden/widget values, so only apply them 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["runtime_mode"] = queued_mode - state["runtime_change_every"] = queued_change_every - state["runtime_start_checkpoint"] = queued_start_checkpoint - state["settings_revision"] = hidden_revision - else: - # First execution in a legacy/no-frontend situation: seed runtime - # controls from the prompt once. After that, the live backend state - # owns these values. - if not state.get("runtime_initialized"): - state["runtime_mode"] = queued_mode - state["runtime_change_every"] = queued_change_every - state["runtime_start_checkpoint"] = queued_start_checkpoint - - state["runtime_initialized"] = True + 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 all_checkpoints = _get_checkpoint_list() if not all_checkpoints: return ("", "", "checkpoint") - mode = _valid_cycler_mode(state.get("runtime_mode", queued_mode)) - change_every = _valid_change_every(state.get("runtime_change_every", queued_change_every)) - start_checkpoint = _normalize_relpath(state.get("runtime_start_checkpoint") or queued_start_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 []) - use_local_list = bool(state.get("use_local_list", True)) match_count = _filter_match_count(all_checkpoints, active_filter) fallback_all = bool(active_filter) and match_count == 0 all_set = set(all_checkpoints) @@ -1775,10 +1506,9 @@ class CheckpointNameCycler: state["last_normal_ckpt_name"] = "" state["repeat_count"] = 0 - # Local List is a separate mutable runtime lane. Its contents are not - # part of the immutable execution snapshot. If it selects a checkpoint, - # only the selected result and source are recorded in the snapshot. - local_list = list(state.get("local_list", [])) if use_local_list else [] + # 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: @@ -1793,25 +1523,18 @@ class CheckpointNameCycler: ckpt_name = selected_from_local hold_index = 1 local_total = len(local_list) + 1 - snapshot = _make_cycler_snapshot( - ckpt_name, - "local_list", - mode, - 1, - active_filter, - False, - match_count, - hold_index, - use_local_list, - local_total, - ) - _store_cycler_execution_snapshot(state, snapshot, tab_id) - status_text = _cycler_snapshot_status_text(snapshot, state) + title = _build_cycler_title("local_list", ckpt_name, hold_index, 1, active_filter, False, local_total) + status_text = _build_cycler_status_text("local_list", ckpt_name, active_filter, False, local_list, hold_index, 1, match_count, local_total, mode) 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 _send_cycler_update( node_id, - snapshot["title"], + title, status_text, tab_id=tab_id, ckpt_name_str=ckpt_name, @@ -1820,7 +1543,7 @@ class CheckpointNameCycler: hold_index=hold_index, change_every=1, ) - return (ckpt_name, ckpt_name, snapshot["ckpt_name_safe"]) + return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name)) elif skipped_local: state["local_list"] = [] state["override_queue"] = [] @@ -1863,6 +1586,7 @@ class CheckpointNameCycler: and (mode == "fixed" or is_match(last_ckpt)) ) + new_selection = False if can_hold_last: ckpt_name = last_ckpt try: @@ -1870,6 +1594,7 @@ class CheckpointNameCycler: 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] @@ -1900,26 +1625,19 @@ class CheckpointNameCycler: state["current_index"] = selected_index source = "cycle" - snapshot = _make_cycler_snapshot( - ckpt_name, - source, - mode, - change_every, - active_filter, - fallback_all and mode != "fixed", - match_count, - hold_index, - use_local_list, - 0, - ) - _store_cycler_execution_snapshot(state, snapshot, tab_id) - state["last_normal_ckpt_name"] = ckpt_name - status_text = _cycler_snapshot_status_text(snapshot, state) + title = _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filter, fallback_all and mode != "fixed", 0) + status_text = _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all and mode != "fixed", [], hold_index, change_every, match_count, 0, mode) if mode == "shuffle_once": status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" + state["last_ckpt_name"] = ckpt_name + state["last_normal_ckpt_name"] = ckpt_name + state["last_title"] = title + state["last_hold_index"] = hold_index + state["last_change_every"] = change_every + state["last_mode"] = mode _send_cycler_update( node_id, - snapshot["title"], + title, status_text, tab_id=tab_id, ckpt_name_str=ckpt_name, @@ -1928,7 +1646,7 @@ class CheckpointNameCycler: hold_index=hold_index, change_every=change_every, ) - return (ckpt_name, ckpt_name, snapshot["ckpt_name_safe"]) + return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name)) class CheckpointStatusTagger: @@ -1958,8 +1676,7 @@ class CheckpointStatusTagger: def tag(self, ckpt_name_str, hps_tab_id="", unique_id=None): relpath = _normalize_relpath(ckpt_name_str) status = _get_status(relpath) - title = _tagger_title(relpath, status) - _store_tagger_state(hps_tab_id, unique_id, relpath) + title = f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}" _send_event(TAGGER_EVENT, { "node": int(unique_id) if unique_id is not None else None, "tab_id": _clean_tab_id(hps_tab_id), @@ -1994,22 +1711,7 @@ class EphemeralPreview: pil_images = _tensor_batch_to_pil(image) sheet, meta = _build_contact_sheet(pil_images) meta["node_class"] = "EphemeralPreview" - execution = _current_execution_for_tab(hps_tab_id) - title = "Ephemeral Preview" - if execution.get("ckpt_name"): - relpath = _normalize_relpath(execution.get("ckpt_name", "")) - status = _get_status(relpath) - meta.update({ - "ckpt_name_str": relpath, - "ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath), - "status": status, - "status_icon": STATUS_ICON[status], - "source": execution.get("source", ""), - "execution_revision": execution.get("execution_revision"), - }) - title = _preview_title(relpath, status) - _store_preview_state(hps_tab_id, unique_id, "EphemeralPreview", relpath) - _send_preview(unique_id, title, sheet, meta, tab_id=hps_tab_id) + _send_preview(unique_id, "Ephemeral Preview", sheet, meta, tab_id=hps_tab_id) except Exception: logger.exception("EphemeralPreview failed") return () @@ -2051,26 +1753,6 @@ 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, {})) - return web.json_response({"ok": True, "node_id": node_id, "node_class": node_class, "state": state}) - if node_class == "EphemeralPreview": - state = dict(_PREVIEW_STATES.get(key, {})) - return web.json_response({"ok": True, "node_id": node_id, "node_class": node_class, "state": 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() @@ -2156,8 +1838,6 @@ async def tagger_set_status(request): "cancelled_at": _now_iso(), }) _write_delete_script() - _update_known_status_titles(relpath) - payload = { "scope": "global", "ok": True, @@ -2198,9 +1878,8 @@ async def review_sync_checkpoint(request): if str(x).isdigit() ] status = _get_status(relpath) - title = _tagger_title(relpath, status) + title = f"Tagger : {STATUS_ICON[status]} {relpath}" if status != "none" else f"Tagger : {relpath}" for node_id in tagger_ids: - _store_tagger_state(tab_id, node_id, relpath) _send_event(TAGGER_EVENT, { "node": int(node_id), "tab_id": tab_id, @@ -2236,55 +1915,17 @@ async def review_sync_checkpoint(request): }) -@routes.get(f"/{EXTENSION_PREFIX}/cycler/state") -async def cycler_get_state(request): - node_id = str(request.query.get("node_id", "")) - tab_id = _clean_tab_id(request.query.get("tab_id", "")) - state = _get_cycler_state(_state_key(tab_id, node_id)) - payload = _cycler_state_payload(state) - payload.update({"ok": True, "node_id": node_id}) - return web.json_response(payload) - - -@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")) - accepted, revision = _should_accept_settings_update(state, revision) - if accepted: - if "mode" in data: - state["runtime_mode"] = _valid_cycler_mode(data.get("mode")) - if "change_every" in data: - state["runtime_change_every"] = _valid_change_every(data.get("change_every")) - if "start_checkpoint" in data: - state["runtime_start_checkpoint"] = _normalize_relpath(data.get("start_checkpoint", "")) - state["runtime_initialized"] = True - state["settings_revision"] = revision - _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")) - accepted, revision = _should_accept_settings_update(state, revision) - if accepted and "use_local_list" in data: + 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 _send_cycler_state_update(node_id, state, tab_id=tab_id) - payload = _cycler_state_payload(state) - payload.update({"ok": True, "accepted": accepted, "use_local_list": state["use_local_list"]}) - return web.json_response(payload) + return web.json_response({"ok": True, "use_local_list": state["use_local_list"]}) @routes.post(f"/{EXTENSION_PREFIX}/cycler/set_filter") @@ -2294,16 +1935,10 @@ async def cycler_set_filter(request): tab_id = _clean_tab_id(data.get("tab_id", "")) statuses = [s for s in data.get("statuses", []) if s in STATUS_VALUES] state = _get_cycler_state(_state_key(tab_id, node_id)) - revision = _parse_saved_int(data.get("settings_revision")) - accepted, revision = _should_accept_settings_update(state, revision) - if accepted: - state["active_filter"] = statuses.copy() - state["settings_revision"] = revision + state["active_filter"] = statuses.copy() _send_cycler_state_update(node_id, state, tab_id=tab_id) - logger.info("[CheckpointHandpickerSuite] Updated cycler filter for tab %s node %s: %s accepted=%s", tab_id, node_id, statuses or ["All"], accepted) - payload = _cycler_state_payload(state) - payload.update({"ok": True, "accepted": accepted, "node_id": node_id, "statuses": state.get("active_filter", [])}) - return web.json_response(payload) + 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}) @routes.post(f"/{EXTENSION_PREFIX}/cycler/local_list_append") diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 3f8b572..56d4877 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"; +// v8a: v7-based stable UI rebuild. Restores ListSelector page scroll, wheel capture, and scrollbar drag. const EXT = "ruminar.checkpoint_handpicker_suite"; const PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview"; const CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler"; @@ -1022,8 +1023,11 @@ 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), - search_directories: Object.fromEntries(targets.previews.map((n) => [String(n.id), imageDirSearchDirectory(n)])), - max_preview_images: Object.fromEntries(targets.previews.map((n) => [String(n.id), 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(); @@ -1046,18 +1050,82 @@ function selectorScrollbar(node) { const items = selectorItems(node); if (items.length <= SELECTOR_VISIBLE_ROWS) return null; const r = selectorRects(node).list; - const scroll = node.__hpsScroll || 0; - const ratio = SELECTOR_VISIBLE_ROWS / items.length; - const h = Math.max(20, r.h * ratio); + const scroll = Math.max(0, Math.min(node.__hpsScroll || 0, maxSelectorScroll(node))); + const thumbH = Math.max(24, r.h * (SELECTOR_VISIBLE_ROWS / items.length)); + 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, + }; +} + +function setSelectorScrollFromScrollbarY(node, localY, thumbH, dragOffset = thumbH / 2) { + const r = selectorRects(node).list; const maxScroll = maxSelectorScroll(node); - const y = r.y + (maxScroll ? ((r.h - h) * scroll / maxScroll) : 0); - return { x: r.x + r.w - 8, y, w: 6, h }; + 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); +} + +function selectorLocalFromEventOrPos(node, event, pos) { + const fromEvent = event ? graphEventToLocal(node, event) : null; + if (fromEvent) return fromEvent; + return candidatePositions(node, pos)?.[0] || null; +} + +let selectorWheelCaptureInstalled = false; +function installSelectorWheelCapture() { + if (selectorWheelCaptureInstalled) return; + 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); + } catch { + 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), + ]; + if (!hit(local, selectorRects(node).list)) continue; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + scrollSelector(node, event.deltaY > 0 ? 3 : -3); + return; + } + }, { passive: false, capture: true }); } function selectorCursorAt(node, local) { if (!local) return ""; const r = selectorRects(node); 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 (sb && hit(local, sb.track)) return "pointer"; const list = selectorItems(node); const base = node.__hpsScroll || 0; for (let i = 0; i < Math.min(SELECTOR_VISIBLE_ROWS, list.length - base); i++) { @@ -1091,8 +1159,10 @@ function drawSelectorRows(ctx, node) { } const sb = selectorScrollbar(node); if (sb) { - ctx.fillStyle = "rgba(255,255,255,0.35)"; - ctx.fillRect(sb.x, sb.y, sb.w, sb.h); + 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(); } @@ -1101,6 +1171,7 @@ function setupSelectorNode(nodeType) { installMinSize(nodeType, 520, 540); installTabIdSupport(nodeType); installCursorCapture(); + installSelectorWheelCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { @@ -1140,18 +1211,70 @@ function setupSelectorNode(nodeType) { }; const origMouseDown = nodeType.prototype.onMouseDown; - nodeType.prototype.onMouseDown = function (e, pos) { + 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)) { refreshSelector(this, true); return true; } - if (hitAny(this, pos, r.listOnly)) { refreshSelector(this, false); return true; } + 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)) { scrollSelector(this, -1); return true; } - if (hitAny(this, pos, r.down)) { scrollSelector(this, 1); 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) { + 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 = 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; + } + } const positions = candidatePositions(this, pos); for (const p of positions) { @@ -1159,6 +1282,8 @@ function setupSelectorNode(nodeType) { 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; @@ -1167,11 +1292,56 @@ function setupSelectorNode(nodeType) { return origMouseDown ? origMouseDown.apply(this, arguments) : false; }; + const origMouseMove = nodeType.prototype.onMouseMove; + nodeType.prototype.onMouseMove = function (event, pos, canvas) { + if (this.__hpsScrollbarDragging && event?.buttons === 0) { + this.__hpsScrollbarDragging = false; + 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 (event, pos, canvas) { + if (this.__hpsScrollbarDragging) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + this.__hpsScrollbarDragging = false; + app.graph?.setDirtyCanvas?.(true, true); + return true; + } + + return origMouseUp ? origMouseUp.apply(this, arguments) : false; + }; + const origMouseWheel = nodeType.prototype.onMouseWheel; - nodeType.prototype.onMouseWheel = function (e, pos) { + nodeType.prototype.onMouseWheel = function (event, pos, canvas) { const r = selectorRects(this); if (candidatePositions(this, pos).some((p) => hit(p, r.list))) { - scrollSelector(this, e.deltaY > 0 ? 3 : -3); + event?.preventDefault?.(); + event?.stopPropagation?.(); + event?.stopImmediatePropagation?.(); + scrollSelector(this, event.deltaY > 0 ? 3 : -3); return true; } return origMouseWheel ? origMouseWheel.apply(this, arguments) : false; From 55058c0a90a13582964ef2416abdcd304cbf8b5a 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: Sat, 6 Jun 2026 10:59:43 +0900 Subject: [PATCH 03/16] =?UTF-8?q?Cycler=E3=81=AE=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=82=92=E4=BF=9D=E6=8C=81=E3=80=82=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E4=B8=AD=E3=83=BB=E5=86=8D=E8=A1=A8=E7=A4=BA=E6=99=82=E3=83=BB?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E3=82=BF=E3=83=96=E6=99=82=E3=81=AE=E7=8A=B6?= =?UTF-8?q?=E6=85=8B=E3=82=BA=E3=83=AC=E3=82=92=E8=A7=A3=E6=B6=88=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 2024 +++++++++++++++++++++--------------------------- 1 file changed, 875 insertions(+), 1149 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index cbd30bf..03b785a 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -1,4 +1,3 @@ -import asyncio import base64 import io import json @@ -13,12 +12,11 @@ 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__) @@ -30,40 +28,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 +141,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 +158,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,16 +180,26 @@ 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]: @@ -215,7 +216,43 @@ def _get_checkpoint_list() -> list[str]: continue seen.add(relpath) out.append(relpath) - return out + return sorted(out, key=lambda x: x.lower()) + + +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]: @@ -235,17 +272,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 +293,233 @@ 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 _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 { + "use_local_list": True, + "accept_queue": True, + "local_list": [], + "override_queue": [], + "active_filter": [], + "settings_revision": 0, + "current_index": 0, + "repeat_count": 0, + "cycle_count": 0, + "shuffle_deck": [], + "last_ckpt_name": "", + "last_normal_ckpt_name": "", + "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 _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: + ckpt = state.get("last_ckpt_name", "") or "" + status = _get_status(ckpt) if ckpt else "none" + 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": state.get("last_title") or "Checkpoint Name Cycler", + "status_text": state.get("last_status_text") or "Current: (not executed yet)", + "use_local_list": bool(state.get("use_local_list", True)), + "active_filter": list(state.get("active_filter", [])), + "settings_revision": _parse_saved_int(state.get("settings_revision"), 0), + "local_list_count": len(state.get("local_list", [])), + "mode": state.get("last_mode", "increment"), + "change_every": state.get("last_change_every", 1), + "start_checkpoint": state.get("last_start_checkpoint", ""), + "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 _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 +528,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 +546,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,154 +565,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}), - }, - "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() @@ -562,12 +586,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({ @@ -584,13 +606,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", @@ -610,10 +629,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():", @@ -624,8 +640,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", @@ -634,79 +649,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()}", @@ -726,13 +718,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: @@ -752,8 +829,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] @@ -765,6 +840,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 @@ -778,128 +898,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: @@ -928,7 +927,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, @@ -941,515 +940,216 @@ 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 = ""): + if send_progress: + _send_image_dir_progress(node_id, relpath, tab_id, "Searching preview images...", 0, max_preview_images, max_preview_images) + paths = _find_image_dir_candidates(relpath, search_directory) + selected_paths = paths[:max_preview_images] + images = [] + for idx, path in enumerate(selected_paths, start=1): if send_progress: - _send_image_dir_progress(node_id, relpath, message, value=value, total=total, tab_id=tab_id, root=root) - - 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) - - 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} - - extra = { + _send_image_dir_progress(node_id, relpath, tab_id, f"Loading preview image {idx}/{len(selected_paths)}", idx - 1, len(selected_paths), max_preview_images) + img = _load_image_file(path) + if img is not None: + images.append(img) + def cb(value, total, message): + if send_progress: + _send_image_dir_progress(node_id, relpath, tab_id, message, value, total, max_preview_images) + sheet, extra = _build_contact_sheet(images, progress_callback=cb, max_images=max_preview_images) + 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), + "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", + "search_directory": str(_safe_search_root(search_directory) or ""), "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}", - "progress_value": max_preview_images, - "progress_total": max_preview_images, - } - extra.update(meta) + "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 _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 - "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_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 "shuffle_deck" not in state: - state["shuffle_deck"] = [] - if "last_mode" not in state: - state["last_mode"] = "increment" - 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. - """ - 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 - - -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 _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_total: int) -> str: - status = _get_status(ckpt_name) +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 "" - display = f"{icon} {ckpt_name}".strip() - if source == "local_list": - return f"Cycler : {display} (Local List)" + prefix = "Cycler" + if active_filter: + prefix += f" [{_status_icons_for_filter(active_filter)}]" if fallback_all: - return f"Cycler : ⚠ {ckpt_name} ({hold_index}/{change_every} Filter:0→All)" - return f"Cycler : {display} ({hold_index}/{change_every})" + prefix += " [fallback all]" + if local_count: + prefix += f" [local:{local_count}]" + body = f"{icon} {ckpt_name}".strip() if ckpt_name else "(none)" + return f"{prefix} : {body} ({hold_index}/{change_every})" -def _filter_label(active_filter: list[str]) -> str: - if not active_filter: - return "All" - icons = _status_icons_for_filter(active_filter) - return icons or "All" - - -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, local_list_total: int | None = None, mode: str = "") -> str: - lines = [] - 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)") - if source == "local_list": - lines.append("Source: Local List") - total = local_list_total if local_list_total is not None else len(local_list_remaining) - lines.append(f"Local List Total: {total}") - lines.append(f"Local List Remaining: {len(local_list_remaining)}") - if local_list_remaining: - lines.append("Remaining:") - for i, item in enumerate(local_list_remaining[:8], start=1): - s = _get_status(item) - prefix = STATUS_ICON[s] if s != "none" else "-" - lines.append(f"{i}. {prefix} {item}") - if len(local_list_remaining) > 8: - lines.append(f"+{len(local_list_remaining) - 8} more") - if active_filter: - lines.append(f"Filter: {_filter_label(active_filter)}") - lines.append("Note: Local List has priority over Filter.") - else: - if mode == "fixed": - lines.append("Source: Fixed") - if active_filter: - lines.append(f"Filter: {_filter_label(active_filter)}") - lines.append("Note: Filter is ignored in fixed mode.") - else: - lines.append("Source: Normal Cycle" if not active_filter else "Source: Status Filter") - lines.append(f"Filter: {_filter_label(active_filter)}") - if fallback_all: - lines.append("⚠ Filter matched 0 checkpoints. Using all checkpoints.") - lines.append(f"Matches: {total_matches}") - lines.append(f"Mode: {mode or 'increment'}") - lines.append(f"Hold: {hold_index} / {change_every}") - if local_list_remaining: - lines.append(f"Local List Pending: {len(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) -> str: + status = _get_status(ckpt_name) if ckpt_name else "none" + lines = [ + f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (none)", + f"Mode: {mode} Hold: {hold_index}/{change_every}", + f"Filter: {', '.join(active_filter) if active_filter else 'all'} Matches: {match_count}", + ] + if fallback_all: + lines.append("Filter fallback: all checkpoints") + if local_count: + lines.append(f"Local List: {local_count} item(s)") + if queue: + lines.append(f"Queue: {len(queue)} item(s)") return "\n".join(lines) -def _build_cycler_pending_status_text(state: dict) -> str: +def _candidate_checkpoints(active_filter: list[str], local_list: list[str] | None = None, use_local_list: bool = True) -> tuple[list[str], bool, int]: all_checkpoints = _get_checkpoint_list() - active_filter = 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", [])) - 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 = "local_list" if local_list else "cycle" - if not last_ckpt: - lines = ["Current: (not executed yet)"] - if local_list: - lines.append("Source: Local List") - lines.append(f"Local List Total: {len(local_list)}") - lines.append(f"Local List Remaining: {len(local_list)}") - lines.append("Pending:") - for i, item in enumerate(local_list[:8], start=1): - s = _get_status(item) - prefix = STATUS_ICON[s] if s != "none" else "-" - lines.append(f"{i}. {prefix} {item}") - if len(local_list) > 8: - lines.append(f"+{len(local_list) - 8} more") - if active_filter: - lines.append(f"Filter: {_filter_label(active_filter)}") - lines.append("Note: Local List has priority over Filter.") - else: - if mode == "fixed": - lines.append("Source: Fixed") - if active_filter: - lines.append(f"Filter: {_filter_label(active_filter)}") - lines.append("Note: Filter is ignored in fixed mode.") - else: - lines.append("Source: Normal Cycle" if not active_filter else "Source: Status Filter") - lines.append(f"Filter: {_filter_label(active_filter)}") - if fallback_all: - lines.append("⚠ Filter matched 0 checkpoints. Using all checkpoints.") - lines.append(f"Matches: {match_count}") - lines.append("Note: Filter will apply on next Cycler execution.") - return "\n".join(lines) - return _build_cycler_status_text( - source, - last_ckpt, - active_filter, - fallback_all, - local_list, - hold if hold else 1, - change_every, - match_count, - len(local_list), - mode, - ) - - -def _send_cycler_state_update(node_id: str, state: dict, tab_id: str | None = None): - _send_cycler_update(node_id, "", _build_cycler_pending_status_text(state), tab_id=tab_id) + all_set = set(all_checkpoints) + local = [] + if use_local_list and local_list: + seen = set() + for item in local_list: + rel = _normalize_relpath(item) + if rel in all_set and rel not in seen: + seen.add(rel) + local.append(rel) + base = local if local else all_checkpoints + if not active_filter: + return list(base), False, len(local) + filtered = [ckpt for ckpt in base if _get_status(ckpt) in active_filter] + if filtered: + return filtered, False, len(local) + return list(base), bool(base), len(local) class CheckpointListSelector: @classmethod def INPUT_TYPES(cls): return { - "required": { - "checkpoint": ("STRING", {"default": ""}), - }, - "hidden": { - "unique_id": "UNIQUE_ID", - }, + "required": {"checkpoint": ("STRING", {"default": ""})}, + "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 = "select" CATEGORY = "checkpoint/handpicker" - @classmethod - def IS_CHANGED(cls, checkpoint="", unique_id=None): - return float("nan") - def select(self, checkpoint="", unique_id=None): - ckpt_name_str, ckpt_name_safe = _select_checkpoint_value(checkpoint) - return (ckpt_name_str, ckpt_name_str, ckpt_name_safe) + 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_fresh_checkpoint_values() 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}), }, @@ -1457,157 +1157,102 @@ class CheckpointNameCycler: "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", - }, + "hidden": {"unique_id": "UNIQUE_ID"}, } - RETURN_TYPES = (_get_checkpoint_list() if _get_checkpoint_list() else [""], "STRING", "STRING") + RETURN_TYPES = (_get_fresh_checkpoint_values() 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="", 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) + mode = _valid_mode(mode) + change_every = _valid_change_every(change_every) + active_filter = _normalize_status_list(hps_filter_statuses) + use_local_list = _parse_saved_bool(hps_use_local_list, bool(state.get("use_local_list", True))) + revision = _parse_saved_int(hps_settings_revision, _parse_saved_int(state.get("settings_revision"), 0)) - 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 + accepted, revision = _should_accept_settings_update(state, revision) + if accepted: + state["settings_revision"] = revision + state["active_filter"] = active_filter + state["use_local_list"] = use_local_list + state["accept_queue"] = use_local_list 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) + candidates, fallback_all, local_count = _candidate_checkpoints(state.get("active_filter", []), state.get("local_list", []), bool(state.get("use_local_list", True))) + if not candidates: + candidates = all_checkpoints + fallback_all = True + candidate_set = set(candidates) + match_count = len(candidates) - 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 + repeat_count = max(0, int(state.get("repeat_count", 0))) + last_ckpt = _normalize_relpath(state.get("last_ckpt_name", "")) + can_hold_last = last_ckpt in candidate_set and repeat_count > 0 and repeat_count < change_every - # 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, local_total) - status_text = _build_cycler_status_text("local_list", ckpt_name, active_filter, False, local_list, hold_index, 1, match_count, local_total, mode) - 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 - _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"] = [] + def start_index() -> int: + requested = _normalize_relpath(start_checkpoint) + if requested in all_checkpoints: + return all_checkpoints.index(requested) + return 0 - def is_match(ckpt: str) -> bool: - return _matches_filter(ckpt, active_filter, fallback_all) - - 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) + 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 is_match(ckpt): + if ckpt in candidate_set: return ckpt, idx - return all_checkpoints[start_idx], start_idx + ckpt = candidates[0] + return ckpt, all_checkpoints.index(ckpt) - 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 draw_from_shuffle_deck(): + deck = [x for x in state.get("shuffle_deck", []) if x in candidate_set] + if not deck: + deck = candidates.copy() + random.shuffle(deck) + ckpt = deck.pop(0) + state["shuffle_deck"] = deck + return ckpt - 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 - - 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)) - ) - - new_selection = False if can_hold_last: ckpt_name = last_ckpt - try: - selected_index = all_checkpoints.index(ckpt_name) - except ValueError: - selected_index = 0 + selected_index = all_checkpoints.index(ckpt_name) else: - new_selection = True if mode == "fixed": requested = _normalize_relpath(start_checkpoint) - ckpt_name = requested if requested in all_set else all_checkpoints[0] + ckpt_name = requested if requested in candidate_set else candidates[0] selected_index = all_checkpoints.index(ckpt_name) elif mode == "randomize": - candidates = filtered_candidates() - ckpt_name = random.choice(candidates if candidates else all_checkpoints) + 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: # increment - ckpt_name, selected_index = find_next_increment(int(state.get("current_index", 0))) + else: + ckpt_name, selected_index = find_next_increment(int(state.get("current_index", start_index()))) hold_index = repeat_count + 1 if can_hold_last else 1 if hold_index >= change_every: @@ -1624,27 +1269,54 @@ class CheckpointNameCycler: state["repeat_count"] = hold_index state["current_index"] = selected_index - source = "cycle" - title = _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filter, fallback_all and mode != "fixed", 0) - status_text = _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all and mode != "fixed", [], hold_index, change_every, match_count, 0, mode) + title = _build_cycler_title("cycle", ckpt_name, hold_index, change_every, state.get("active_filter", []), fallback_all and mode != "fixed", local_count) + status_text = _build_cycler_status_text("cycle", ckpt_name, state.get("active_filter", []), fallback_all and mode != "fixed", [], hold_index, change_every, match_count, local_count, mode) if mode == "shuffle_once": status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" + state["last_ckpt_name"] = ckpt_name state["last_normal_ckpt_name"] = ckpt_name state["last_title"] = title + state["last_status_text"] = status_text state["last_hold_index"] = hold_index state["last_change_every"] = change_every state["last_mode"] = mode + state["last_start_checkpoint"] = _normalize_relpath(start_checkpoint) + status = _get_status(ckpt_name) + 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": "cycle", + "mode": mode, + "mode_used": mode, + "change_every": change_every, + "change_every_used": change_every, + "filter_used": list(state.get("active_filter", [])), + "fallback_all": fallback_all and mode != "fixed", + "matches_at_resolve": match_count, + "hold_index": hold_index, + "title": title, + "status_text": status_text, + }) + state["execution_revision"] = exec_state.get("execution_revision", 0) _send_cycler_update( node_id, title, status_text, tab_id=tab_id, ckpt_name_str=ckpt_name, - source=source, + ckpt_name_safe=_ckpt_name_safe_from_relpath(ckpt_name), + status=status, + status_icon=STATUS_ICON[status], + source="cycle", mode=mode, hold_index=hold_index, change_every=change_every, + execution_revision=state["execution_revision"], ) return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name)) @@ -1653,15 +1325,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 = () @@ -1677,12 +1343,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 () @@ -1711,7 +1380,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 () @@ -1721,17 +1405,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 = () @@ -1746,6 +1426,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 () @@ -1753,6 +1435,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() @@ -1773,10 +1487,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) @@ -1788,7 +1499,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, }) @@ -1798,21 +1508,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) @@ -1820,98 +1530,35 @@ 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) - 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 - + _store_tagger_state(tab_id, node_id, relpath, status) + _send_status_changed(relpath, tab_id=tab_id, node_id=node_id) 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(), }) @@ -1921,11 +1568,23 @@ async def cycler_set_flags(request): 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)) - if "use_local_list" in data: - state["use_local_list"] = bool(data.get("use_local_list")) - state["accept_queue"] = state["use_local_list"] + 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: + if "use_local_list" in data: + state["use_local_list"] = bool(data.get("use_local_list")) + state["accept_queue"] = state["use_local_list"] + if "mode" in data: + state["last_mode"] = _valid_mode(data.get("mode")) + if "change_every" in data: + state["last_change_every"] = _valid_change_every(data.get("change_every")) + if "start_checkpoint" in data: + state["last_start_checkpoint"] = _normalize_relpath(data.get("start_checkpoint", "")) + state["settings_revision"] = revision _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") @@ -1933,12 +1592,24 @@ 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)) - state["active_filter"] = statuses.copy() + 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: + state["active_filter"] = statuses.copy() + if "mode" in data: + state["last_mode"] = _valid_mode(data.get("mode")) + if "change_every" in data: + state["last_change_every"] = _valid_change_every(data.get("change_every")) + if "start_checkpoint" in data: + state["last_start_checkpoint"] = _normalize_relpath(data.get("start_checkpoint", "")) + state["settings_revision"] = revision _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": state.get("active_filter", [])}) + return web.json_response(payload) @routes.post(f"/{EXTENSION_PREFIX}/cycler/local_list_append") @@ -1975,6 +1646,61 @@ async def cycler_clear_local_list(request): return web.json_response({"ok": True, "cleared": cleared}) +@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 = _load_image_dir_preview(node_id, relpath, search_directory, tab_id=tab_id, send_progress=True, max_preview_images=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 = { "CheckpointListSelector": CheckpointListSelector, "CheckpointNameCycler": CheckpointNameCycler, From c9b6d62f2565bc99ee237c051cdae0b95c0b9edd 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: Sat, 6 Jun 2026 12:03:55 +0900 Subject: [PATCH 04/16] =?UTF-8?q?=E5=88=9D=E6=9C=9F=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=89=E6=99=82=E3=81=AB=E3=80=81CheckpointLoaderSimple?= =?UTF-8?q?=E3=81=A7=E3=82=AF=E3=83=A9=E3=83=83=E3=82=B7=E3=83=A5=E3=81=97?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 2 +- suite_nodes.py | 61 +++++++++++++++++--- web/checkpoint_handpicker_suite.js | 89 +++++++++++++++++++++--------- 3 files changed, 116 insertions(+), 36 deletions(-) diff --git a/__init__.py b/__init__.py index 93f323c..f4f7fe8 100644 --- a/__init__.py +++ b/__init__.py @@ -2,4 +2,4 @@ WEB_DIRECTORY = "./web" from .suite_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/suite_nodes.py b/suite_nodes.py index 03b785a..5d7b728 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -21,6 +21,7 @@ from server import PromptServer logger = logging.getLogger(__name__) +# v8c: conservative rebuild from v8b/v8a with Cycler/ListSelector UI-state fixes. EXTENSION_PREFIX = "checkpoint_handpicker_suite" PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview" CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler" @@ -203,6 +204,13 @@ def _checkpoint_roots() -> list[Path]: 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: @@ -216,7 +224,7 @@ def _get_checkpoint_list() -> list[str]: continue seen.add(relpath) out.append(relpath) - return sorted(out, key=lambda x: x.lower()) + return out def _scan_registered_checkpoint_folders() -> list[str]: @@ -296,6 +304,11 @@ def _status_icons_for_filter(active_statuses: list[str]) -> str: 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) @@ -1091,7 +1104,7 @@ def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, qu lines = [ f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (none)", f"Mode: {mode} Hold: {hold_index}/{change_every}", - f"Filter: {', '.join(active_filter) if active_filter else 'all'} Matches: {match_count}", + f"Filter: {_filter_display(active_filter)} Matches: {match_count}", ] if fallback_all: lines.append("Filter fallback: all checkpoints") @@ -1102,6 +1115,29 @@ def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, qu return "\n".join(lines) +def _build_cycler_idle_status_text(action: str, state: dict, detail: str = "") -> str: + """Build a status panel for UI-only operations such as Push/Clear Local List.""" + ckpt_name = state.get("last_ckpt_name", "") or "" + status = _get_status(ckpt_name) if ckpt_name else "none" + active_filter = list(state.get("active_filter", [])) + local_count = len(state.get("local_list", [])) + mode = state.get("last_mode", "increment") + hold_index = max(0, _parse_saved_int(state.get("last_hold_index"), 0)) + change_every = _valid_change_every(state.get("last_change_every", 1)) + current = f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (not executed yet)" + lines = [ + current, + f"Mode: {mode} Hold: {hold_index}/{change_every}", + f"Filter: {_filter_display(active_filter)}", + f"Local List: {local_count} item(s)", + ] + if action: + lines.append(action) + if detail: + lines.append(detail) + return "\n".join(lines) + + def _candidate_checkpoints(active_filter: list[str], local_list: list[str] | None = None, use_local_list: bool = True) -> tuple[list[str], bool, int]: all_checkpoints = _get_checkpoint_list() all_set = set(all_checkpoints) @@ -1146,7 +1182,7 @@ class CheckpointListSelector: class CheckpointNameCycler: @classmethod def INPUT_TYPES(cls): - values = _get_fresh_checkpoint_values() or [""] + values = _get_checkpoint_list() or [""] return { "required": { "start_checkpoint": (values,), @@ -1162,7 +1198,7 @@ class CheckpointNameCycler: "hidden": {"unique_id": "UNIQUE_ID"}, } - RETURN_TYPES = (_get_fresh_checkpoint_values() or [""], "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" @@ -1621,15 +1657,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("Pushed to Local List", state, relpath) 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") @@ -1641,9 +1683,12 @@ 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("Cleared Local List", state, f"Cleared: {cleared} item(s)") _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") diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 56d4877..861fa54 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -1,7 +1,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -// v8a: v7-based stable UI rebuild. Restores ListSelector page scroll, wheel capture, and scrollbar drag. +// v8c: v8b/v8a conservative rebuild with ListSelector/Cycler/Tagger UI-state fixes. const EXT = "ruminar.checkpoint_handpicker_suite"; const PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview"; const CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler"; @@ -399,6 +399,36 @@ 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 applyCyclerStatePayload(node, detail) { + if (!node || !detail) return; + if (detail.active_filter !== undefined) { + node.__hpsFilterStatuses = normalizeCyclerFilterStatuses(detail.active_filter); + } + if (detail.use_local_list !== undefined) { + node.__hpsUseLocalList = !!detail.use_local_list; + } + if (detail.settings_revision !== undefined) { + node.__hpsSettingsRevision = parseSavedInt(detail.settings_revision, node.__hpsSettingsRevision ?? 0); + } + if (detail.ckpt_name_str) { + node.__hpsCyclerCkptName = detail.ckpt_name_str; + node.__hpsCyclerStatusValue = detail.status || "none"; + setExecutionState(detail); + } + 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; @@ -647,12 +677,7 @@ async function restoreNodeStateFromBackend(node, nodeClass) { : `Tagger : ${STATUS_ICON[node.__hpsTaggerStatus]} ${result.ckpt_name_str}`; } } else if (nodeClass === CYCLER_CLASS) { - if (result.ckpt_name_str) { - node.__hpsCyclerCkptName = result.ckpt_name_str; - node.__hpsCyclerStatusValue = result.status || "none"; - } - if (result.status_text) node.__hpsCyclerStatus = result.status_text; - if (result.title) node.title = result.title; + applyCyclerStatePayload(node, result); } app.graph?.setDirtyCanvas?.(true, true); } catch (error) { @@ -994,11 +1019,18 @@ async function pushSelectedToLocalList(node) { body: JSON.stringify(tabPayload({ ckpt_name_str: selected, target_node_ids: (app.graph?._nodes || []) - .filter((n) => isNodeClass(n, CYCLER_CLASS) && n.__hpsUseLocalList !== false) + .filter((n) => isNodeClass(n, CYCLER_CLASS)) .map((n) => n.id), })), }); 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"); @@ -1194,7 +1226,7 @@ function setupSelectorNode(nodeType) { hideSelectorWidget(this); if (hpsNodeCollapsed(this)) return; const r = selectorRects(this); - drawButton(ctx, r.refreshAll, "Refresh All", !this.__hpsLoading); + drawButton(ctx, r.refreshAll, "🔄 Refresh All", !this.__hpsLoading); drawButton(ctx, r.listOnly, "List", !this.__hpsLoading); drawButton(ctx, r.pushLocalList, selectorActionLabel(this), !this.__hpsLoading); drawButton(ctx, r.up, "▲", true); @@ -1353,9 +1385,10 @@ function currentTaggerPath(node) { return node.__hpsTaggerPath || linkedInputValue(node, "ckpt_name_str") || ""; } function taggerButtons(node) { + // Keep the controls below LiteGraph input pins/labels. return STATUS_ORDER.map((status, i) => ({ status, - x: 8 + i * 84, + x: 128 + i * 84, y: 8, w: 78, h: 26, @@ -1395,13 +1428,13 @@ function taggerCursorAt(node, local) { } function setupTaggerNode(nodeType) { - installMinSize(nodeType, 450, 100); + installMinSize(nodeType, 450, 130); 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, 130); setTimeout(() => restoreNodeStateFromBackend(this, TAGGER_CLASS), 0); return r; }; @@ -1434,13 +1467,13 @@ 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); + ctx.fillText(p ? p : "Execute once to bind current checkpoint.", 8, 78); const msg = this.__hpsTaggerMessage || (current === "none" ? "Current: — none" : `Current: ${STATUS_ICON[current]} ${STATUS_LABEL[current]}`); ctx.fillStyle = current === "none" ? "#888" : "#ddd"; - ctx.fillText(msg, 8, 72); - if (this.size[1] >= 110 && current !== "none" && current !== "delete") { + ctx.fillText(msg, 8, 96); + if (this.size[1] >= 124 && current !== "none" && current !== "delete") { ctx.fillStyle = "#aaa"; - ctx.fillText("Delete is available only from none.", 8, 90); + ctx.fillText("Delete is available only from none.", 8, 114); } ctx.restore(); }; @@ -1519,7 +1552,7 @@ 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({ @@ -1529,10 +1562,12 @@ async function pushCyclerFlags(node) { ...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({ @@ -1542,13 +1577,19 @@ async function pushCyclerFilter(node) { ...getCyclerRuntimeControls(node), })), }); + const result = await response.json(); + if (result?.ok) applyCyclerStatePayload(node, result); } 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 ""; @@ -1599,7 +1640,7 @@ function setupCyclerNode(nodeType) { ctx.fillStyle = "#aaa"; ctx.font = "12px sans-serif"; - ctx.fillText(`filter: ${active.length ? active.join(", ") : "all"}`, 8, 78); + ctx.fillText(`filter: ${statusIconDisplay(active)}`, 8, 78); ctx.fillText(`settings revision: ${this.__hpsSettingsRevision ?? 0}`, 8, 96); ctx.fillStyle = "rgba(0,0,0,0.18)"; @@ -1649,13 +1690,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); }); From 3bf4afabd639b253c1fcfed775c3aca482154c59 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: Sat, 6 Jun 2026 13:40:55 +0900 Subject: [PATCH 05/16] =?UTF-8?q?Cycler=E3=81=AE=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=83=BC=E3=82=BF=E3=82=B9=E6=AC=84=E6=94=B9=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- suite_nodes.py | 299 +++++++++++++++++++---------- web/checkpoint_handpicker_suite.js | 36 ++-- 3 files changed, 225 insertions(+), 113 deletions(-) diff --git a/.gitignore b/.gitignore index b49d0b7..7f30d4e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ data/checkpoint_statuses.json data/checkpoint_favorites.json __pycache__/ *.pyc -.vscode/settings.json +.vscode/ +.spec/ diff --git a/suite_nodes.py b/suite_nodes.py index 5d7b728..2394764 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -21,7 +21,7 @@ from server import PromptServer logger = logging.getLogger(__name__) -# v8c: conservative rebuild from v8b/v8a with Cycler/ListSelector UI-state fixes. +# v8d: Local List execution/status separation, Tagger cleanup, and regression guardrails. EXTENSION_PREFIX = "checkpoint_handpicker_suite" PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview" CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler" @@ -367,6 +367,7 @@ def _get_default_cycler_state() -> dict: "use_local_list": True, "accept_queue": True, "local_list": [], + "local_index": 0, "override_queue": [], "active_filter": [], "settings_revision": 0, @@ -376,6 +377,7 @@ def _get_default_cycler_state() -> dict: "shuffle_deck": [], "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, @@ -406,17 +408,33 @@ def _should_accept_settings_update(state: dict, revision: int) -> tuple[bool, in def _cycler_state_payload(state: dict) -> dict: ckpt = state.get("last_ckpt_name", "") or "" status = _get_status(ckpt) if ckpt else "none" + active_filter = list(state.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(state.get("use_local_list", True)) + display_source = "local_list" if use_local and state.get("last_source") == "local_list" and local_count else "cycle" + hold_index = max(0, _parse_saved_int(state.get("last_hold_index"), 0)) + change_every = _valid_change_every(state.get("last_change_every", 1)) + title = _build_cycler_title(display_source, ckpt, hold_index, change_every, active_filter, False, local_count if use_local else 0) if ckpt else "Checkpoint Name Cycler" + # Keep status_text derived from the current UI state so Local List ON/OFF + # immediately shows/hides the list without waiting for the next queue run. + 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": state.get("last_title") or "Checkpoint Name Cycler", - "status_text": state.get("last_status_text") or "Current: (not executed yet)", + "title": title, + "status_text": status_text, "use_local_list": bool(state.get("use_local_list", True)), - "active_filter": list(state.get("active_filter", [])), + "active_filter": active_filter, + "filter_matches": filter_matches, "settings_revision": _parse_saved_int(state.get("settings_revision"), 0), - "local_list_count": len(state.get("local_list", [])), + "local_list_count": len(valid_local_items), + "local_list_items": valid_local_items, "mode": state.get("last_mode", "increment"), "change_every": state.get("last_change_every", 1), "start_checkpoint": state.get("last_start_checkpoint", ""), @@ -1085,6 +1103,33 @@ def _load_image_dir_preview(node_id, relpath, search_directory=None, tab_id="", return sheet, extra +def _valid_local_list(local_list: list[str] | None) -> list[str]: + all_checkpoints = _get_checkpoint_list() + all_set = set(all_checkpoints) + local = [] + seen = set() + for item in local_list or []: + rel = _normalize_relpath(item) + if rel in all_set and rel not in seen: + seen.add(rel) + local.append(rel) + return local + + +def _filter_match_count(active_filter: list[str]) -> int: + all_checkpoints = _get_checkpoint_list() + if not active_filter: + return len(all_checkpoints) + return sum(1 for ckpt in all_checkpoints if _get_status(ckpt) in active_filter) + + +def _build_local_list_lines(local_items: list[str], max_items: int = 20) -> list[str]: + shown = list(local_items[:max_items]) + 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 "" @@ -1093,23 +1138,28 @@ def _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filt prefix += f" [{_status_icons_for_filter(active_filter)}]" if fallback_all: prefix += " [fallback all]" - if local_count: + if source == "local_list": prefix += f" [local:{local_count}]" body = f"{icon} {ckpt_name}".strip() if ckpt_name else "(none)" + if source == "local_list": + return f"{prefix} : {body}" return f"{prefix} : {body} ({hold_index}/{change_every})" -def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, queue, hold_index, change_every, match_count, local_count, mode) -> str: +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" + display_mode = "local list" if source == "local_list" else mode lines = [ f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (none)", - f"Mode: {mode} Hold: {hold_index}/{change_every}", + 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}", ] if fallback_all: lines.append("Filter fallback: all checkpoints") - if local_count: + if use_local_list: + items = list(local_items or []) lines.append(f"Local List: {local_count} item(s)") + lines.extend(_build_local_list_lines(items)) if queue: lines.append(f"Queue: {len(queue)} item(s)") return "\n".join(lines) @@ -1120,42 +1170,39 @@ def _build_cycler_idle_status_text(action: str, state: dict, detail: str = "") - ckpt_name = state.get("last_ckpt_name", "") or "" status = _get_status(ckpt_name) if ckpt_name else "none" active_filter = list(state.get("active_filter", [])) - local_count = len(state.get("local_list", [])) + local_items = _valid_local_list(state.get("local_list", [])) + local_count = len(local_items) mode = state.get("last_mode", "increment") hold_index = max(0, _parse_saved_int(state.get("last_hold_index"), 0)) change_every = _valid_change_every(state.get("last_change_every", 1)) current = f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (not executed yet)" + use_local = bool(state.get("use_local_list", True)) + local_job_active = use_local and state.get("last_source") == "local_list" and bool(local_items) + mode_line = "Mode: local list" if local_job_active else f"Mode: {mode} Hold: {hold_index}/{change_every}" lines = [ current, - f"Mode: {mode} Hold: {hold_index}/{change_every}", - f"Filter: {_filter_display(active_filter)}", - f"Local List: {local_count} item(s)", + mode_line, + f"Filter: {_filter_display(active_filter)} Matches: {_filter_match_count(active_filter)}", ] - if action: - lines.append(action) - if detail: - lines.append(detail) + if use_local: + lines.append(f"Local List: {local_count} item(s)") + lines.extend(_build_local_list_lines(local_items)) return "\n".join(lines) -def _candidate_checkpoints(active_filter: list[str], local_list: list[str] | None = None, use_local_list: bool = True) -> tuple[list[str], bool, int]: +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() - all_set = set(all_checkpoints) - local = [] - if use_local_list and local_list: - seen = set() - for item in local_list: - rel = _normalize_relpath(item) - if rel in all_set and rel not in seen: - seen.add(rel) - local.append(rel) - base = local if local else all_checkpoints if not active_filter: - return list(base), False, len(local) - filtered = [ckpt for ckpt in base if _get_status(ckpt) in 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(local) - return list(base), bool(base), len(local) + return filtered, False, len(filtered) + return list(all_checkpoints), bool(all_checkpoints), 0 class CheckpointListSelector: @@ -1234,16 +1281,14 @@ class CheckpointNameCycler: _send_cycler_update(node_id, title, status_text, tab_id=tab_id) return ("", "", "checkpoint") - candidates, fallback_all, local_count = _candidate_checkpoints(state.get("active_filter", []), state.get("local_list", []), bool(state.get("use_local_list", True))) + global_candidates, fallback_all, global_match_count = _candidate_checkpoints(state.get("active_filter", [])) + local_items = _valid_local_list(state.get("local_list", [])) + use_local_source = bool(state.get("use_local_list", True)) 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) - match_count = len(candidates) - - repeat_count = max(0, int(state.get("repeat_count", 0))) - last_ckpt = _normalize_relpath(state.get("last_ckpt_name", "")) - can_hold_last = last_ckpt in candidate_set and repeat_count > 0 and repeat_count < change_every def start_index() -> int: requested = _normalize_relpath(start_checkpoint) @@ -1251,67 +1296,113 @@ class CheckpointNameCycler: return all_checkpoints.index(requested) return 0 - 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) + source = "local_list" if use_local_source else "cycle" + hold_index = 1 + selected_index = 0 - def draw_from_shuffle_deck(): - deck = [x for x in state.get("shuffle_deck", []) if x in candidate_set] - if not deck: - deck = candidates.copy() - random.shuffle(deck) - ckpt = deck.pop(0) - state["shuffle_deck"] = deck - return ckpt - - if can_hold_last: - ckpt_name = last_ckpt + if use_local_source: + # Local List is a manual per-Cycler queue. It must not advance the + # normal change_every/repeat/shuffle state. + local_index = max(0, _parse_saved_int(state.get("local_index"), 0)) + local_pos = local_index % len(local_items) + ckpt_name = local_items[local_pos] + state["local_index"] = (local_pos + 1) % len(local_items) selected_index = all_checkpoints.index(ckpt_name) + title = _build_cycler_title("local_list", ckpt_name, 1, change_every, state.get("active_filter", []), False, len(local_items)) + status_text = _build_cycler_status_text( + "local_list", + ckpt_name, + state.get("active_filter", []), + False, + [], + 1, + change_every, + global_match_count, + len(local_items), + mode, + local_items=local_items, + use_local_list=True, + ) else: - if mode == "fixed": - requested = _normalize_relpath(start_checkpoint) - 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()))) + 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 - 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 + def find_next_increment(current_index: int): + if not candidates: + return all_checkpoints[0], 0 + n = len(all_checkpoints) + start = max(0, min(n - 1, current_index)) + for offset in range(n): + idx = (start + offset) % n + ckpt = all_checkpoints[idx] + if ckpt in candidate_set: + return ckpt, idx + ckpt = candidates[0] + return ckpt, all_checkpoints.index(ckpt) + + def draw_from_shuffle_deck(): + deck = [x for x in state.get("shuffle_deck", []) if x in candidate_set] + if not deck: + deck = candidates.copy() + random.shuffle(deck) + ckpt = deck.pop(0) + state["shuffle_deck"] = deck + return ckpt + + if can_hold_last: + ckpt_name = last_ckpt + selected_index = all_checkpoints.index(ckpt_name) else: + if mode == "fixed": + requested = _normalize_relpath(start_checkpoint) + ckpt_name = requested if requested in candidate_set else candidates[0] + selected_index = all_checkpoints.index(ckpt_name) + elif mode == "randomize": + ckpt_name = random.choice(candidates) + selected_index = all_checkpoints.index(ckpt_name) + elif mode == "shuffle_once": + ckpt_name = draw_from_shuffle_deck() + selected_index = all_checkpoints.index(ckpt_name) + else: + ckpt_name, selected_index = find_next_increment(int(state.get("current_index", start_index()))) + + hold_index = repeat_count + 1 if can_hold_last else 1 + if hold_index >= change_every: + state["repeat_count"] = 0 + if mode == "increment": + next_index = selected_index + 1 + if next_index >= len(all_checkpoints): + next_index = 0 + state["cycle_count"] = int(state.get("cycle_count", 0)) + 1 + state["current_index"] = next_index + else: + state["current_index"] = selected_index + else: + state["repeat_count"] = hold_index state["current_index"] = selected_index - else: - state["repeat_count"] = hold_index - state["current_index"] = selected_index - title = _build_cycler_title("cycle", ckpt_name, hold_index, change_every, state.get("active_filter", []), fallback_all and mode != "fixed", local_count) - status_text = _build_cycler_status_text("cycle", ckpt_name, state.get("active_filter", []), fallback_all and mode != "fixed", [], hold_index, change_every, match_count, local_count, mode) - if mode == "shuffle_once": - status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" + state["last_normal_ckpt_name"] = ckpt_name + title = _build_cycler_title("cycle", ckpt_name, hold_index, change_every, state.get("active_filter", []), fallback_all and mode != "fixed", len(local_items) if state.get("use_local_list", True) else 0) + status_text = _build_cycler_status_text( + "cycle", + ckpt_name, + state.get("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=bool(state.get("use_local_list", True)), + ) + if mode == "shuffle_once": + status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" 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 @@ -1326,14 +1417,16 @@ class CheckpointNameCycler: "ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name), "status": status, "status_icon": STATUS_ICON[status], - "source": "cycle", - "mode": mode, - "mode_used": mode, + "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, + "change_every_used": 1 if use_local_source else change_every, "filter_used": list(state.get("active_filter", [])), - "fallback_all": fallback_all and mode != "fixed", - "matches_at_resolve": match_count, + "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, @@ -1348,10 +1441,13 @@ class CheckpointNameCycler: ckpt_name_safe=_ckpt_name_safe_from_relpath(ckpt_name), status=status, status_icon=STATUS_ICON[status], - source="cycle", - mode=mode, + source=source, + mode="local_list" if use_local_source else mode, + base_mode=mode, hold_index=hold_index, change_every=change_every, + local_list_count=len(local_items), + filter_matches=global_match_count, execution_revision=state["execution_revision"], ) return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name)) @@ -1662,9 +1758,10 @@ async def cycler_local_list_append(request): state = _get_cycler_state(_state_key(tab_id, node_id)) if state.get("use_local_list", True): local_list = state.setdefault("local_list", []) - local_list.append(relpath) + if relpath not in local_list: + local_list.append(relpath) state["override_queue"] = local_list - state["last_status_text"] = _build_cycler_idle_status_text("Pushed to Local List", state, relpath) + 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) @@ -1683,7 +1780,7 @@ 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("Cleared Local List", state, f"Cleared: {cleared} item(s)") + state["last_status_text"] = _build_cycler_idle_status_text("", state) _send_cycler_state_update(node_id, state, tab_id=tab_id) logger.info("[CheckpointHandpickerSuite] Cleared Local List for tab %s node %s: %s item(s)", tab_id, node_id, cleared) payload = _cycler_state_payload(state) diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 861fa54..99ebf68 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -58,6 +58,7 @@ 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" }; @@ -404,6 +405,10 @@ function statusIconDisplay(statuses) { return normalized.length ? normalized.map((status) => STATUS_ICON[status] || status).join("") : "all"; } +function selectorStatusIcon(status) { + return status && status !== "none" ? (STATUS_ICON[status] || "") : " "; +} + function applyCyclerStatePayload(node, detail) { if (!node || !detail) return; if (detail.active_filter !== undefined) { @@ -420,6 +425,15 @@ function applyCyclerStatePayload(node, detail) { 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; } @@ -1019,7 +1033,7 @@ async function pushSelectedToLocalList(node) { body: JSON.stringify(tabPayload({ ckpt_name_str: selected, target_node_ids: (app.graph?._nodes || []) - .filter((n) => isNodeClass(n, CYCLER_CLASS)) + .filter((n) => isNodeClass(n, CYCLER_CLASS) && n.__hpsUseLocalList !== false) .map((n) => n.id), })), }); @@ -1187,7 +1201,7 @@ function drawSelectorRows(ctx, node) { 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"; - ctx.fillText(`${STATUS_ICON[status] || "—"} ${ckpt}`, r.list.x + 6, y + ROW_H / 2, r.list.w - 20); + ctx.fillText(`${selectorStatusIcon(status)} ${ckpt}`, r.list.x + 6, y + ROW_H / 2, r.list.w - 20); } const sb = selectorScrollbar(node); if (sb) { @@ -1385,12 +1399,12 @@ function currentTaggerPath(node) { return node.__hpsTaggerPath || linkedInputValue(node, "ckpt_name_str") || ""; } function taggerButtons(node) { - // Keep the controls below LiteGraph input pins/labels. - return STATUS_ORDER.map((status, i) => ({ + // Keep the controls away from LiteGraph input pins/labels. + return TAGGER_STATUS_ORDER.map((status, i) => ({ status, - x: 128 + i * 84, - y: 8, - w: 78, + x: 120 + i * 82, + y: 3, + w: 76, h: 26, })); } @@ -1428,13 +1442,13 @@ function taggerCursorAt(node, local) { } function setupTaggerNode(nodeType) { - installMinSize(nodeType, 450, 130); + installMinSize(nodeType, 450, 90); installTabIdSupport(nodeType); installCursorCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; - ensureSize(this, 450, 130); + ensureSize(this, 450, 90); setTimeout(() => restoreNodeStateFromBackend(this, TAGGER_CLASS), 0); return r; }; @@ -1467,10 +1481,10 @@ 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, 78); + ctx.fillText(p ? p : "Execute once to bind current checkpoint.", 8, 50); const msg = this.__hpsTaggerMessage || (current === "none" ? "Current: — none" : `Current: ${STATUS_ICON[current]} ${STATUS_LABEL[current]}`); ctx.fillStyle = current === "none" ? "#888" : "#ddd"; - ctx.fillText(msg, 8, 96); + ctx.fillText(msg, 8, 70); if (this.size[1] >= 124 && current !== "none" && current !== "delete") { ctx.fillStyle = "#aaa"; ctx.fillText("Delete is available only from none.", 8, 114); From a5d8287f9a88a936637df79cf35ea7fdb68b7a4c 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: Sat, 6 Jun 2026 14:51:36 +0900 Subject: [PATCH 06/16] =?UTF-8?q?LocalList=E3=81=B8=E3=81=AE=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E9=A0=85=E7=9B=AE=E8=A4=87=E6=95=B0=E5=9B=9E=E7=99=BB?= =?UTF-8?q?=E9=8C=B2=E3=82=92=E8=A8=B1=E5=AE=B9=E3=81=97=E3=80=81=E3=82=B8?= =?UTF-8?q?=E3=83=A7=E3=83=96=E5=AE=9F=E8=A1=8C=E6=99=82=E3=81=AE=E9=99=A4?= =?UTF-8?q?=E5=8E=BB=E5=87=A6=E7=90=86=E3=81=AE=E4=B8=8D=E5=85=B7=E5=90=88?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 58 +++++++++++++++++++----------- web/checkpoint_handpicker_suite.js | 56 ++++++++++++++++------------- 2 files changed, 68 insertions(+), 46 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index 2394764..76c6b46 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -21,7 +21,7 @@ from server import PromptServer logger = logging.getLogger(__name__) -# v8d: Local List execution/status separation, Tagger cleanup, and regression guardrails. +# v8e: Local List queue consumption, UI restoration, and regression guardrails. EXTENSION_PREFIX = "checkpoint_handpicker_suite" PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview" CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler" @@ -413,7 +413,10 @@ def _cycler_state_payload(state: dict) -> dict: local_count = len(valid_local_items) filter_matches = _filter_match_count(active_filter) use_local = bool(state.get("use_local_list", True)) - display_source = "local_list" if use_local and state.get("last_source") == "local_list" and local_count else "cycle" + # Current job title/source is a snapshot of the last resolved job. Local List + # ON/OFF and Clear must not rewrite it; they only affect future selection and + # the pending Local List display. + display_source = "local_list" if state.get("last_source") == "local_list" else "cycle" hold_index = max(0, _parse_saved_int(state.get("last_hold_index"), 0)) change_every = _valid_change_every(state.get("last_change_every", 1)) title = _build_cycler_title(display_source, ckpt, hold_index, change_every, active_filter, False, local_count if use_local else 0) if ckpt else "Checkpoint Name Cycler" @@ -432,6 +435,7 @@ def _cycler_state_payload(state: dict) -> dict: "use_local_list": bool(state.get("use_local_list", True)), "active_filter": active_filter, "filter_matches": filter_matches, + "filter_total": _checkpoint_total_count(), "settings_revision": _parse_saved_int(state.get("settings_revision"), 0), "local_list_count": len(valid_local_items), "local_list_items": valid_local_items, @@ -1104,14 +1108,17 @@ def _load_image_dir_preview(node_id, relpath, search_directory=None, tab_id="", def _valid_local_list(local_list: list[str] | None) -> list[str]: - all_checkpoints = _get_checkpoint_list() - all_set = set(all_checkpoints) + """Return valid Local List entries while preserving order and duplicates. + + Local List is a manual per-Cycler queue, not a set. The same checkpoint may + intentionally be pushed multiple times, and each entry must be consumed + independently. + """ + all_set = set(_get_checkpoint_list()) local = [] - seen = set() for item in local_list or []: rel = _normalize_relpath(item) - if rel in all_set and rel not in seen: - seen.add(rel) + if rel in all_set: local.append(rel) return local @@ -1123,6 +1130,10 @@ def _filter_match_count(active_filter: list[str]) -> int: return sum(1 for ckpt in all_checkpoints if _get_status(ckpt) in active_filter) +def _checkpoint_total_count() -> int: + return len(_get_checkpoint_list()) + + def _build_local_list_lines(local_items: list[str], max_items: int = 20) -> list[str]: shown = list(local_items[:max_items]) if len(local_items) > max_items: @@ -1138,7 +1149,7 @@ def _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filt prefix += f" [{_status_icons_for_filter(active_filter)}]" if fallback_all: prefix += " [fallback all]" - if source == "local_list": + 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": @@ -1152,7 +1163,7 @@ def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, qu lines = [ f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (none)", 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}", + f"Filter: {_filter_display(active_filter)} Matches: {match_count} / {_checkpoint_total_count()}", ] if fallback_all: lines.append("Filter fallback: all checkpoints") @@ -1177,12 +1188,12 @@ def _build_cycler_idle_status_text(action: str, state: dict, detail: str = "") - change_every = _valid_change_every(state.get("last_change_every", 1)) current = f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (not executed yet)" use_local = bool(state.get("use_local_list", True)) - local_job_active = use_local and state.get("last_source") == "local_list" and bool(local_items) + local_job_active = state.get("last_source") == "local_list" mode_line = "Mode: local list" if local_job_active else f"Mode: {mode} Hold: {hold_index}/{change_every}" lines = [ current, mode_line, - f"Filter: {_filter_display(active_filter)} Matches: {_filter_match_count(active_filter)}", + f"Filter: {_filter_display(active_filter)} Matches: {_filter_match_count(active_filter)} / {_checkpoint_total_count()}", ] if use_local: lines.append(f"Local List: {local_count} item(s)") @@ -1302,13 +1313,16 @@ class CheckpointNameCycler: if use_local_source: # Local List is a manual per-Cycler queue. It must not advance the - # normal change_every/repeat/shuffle state. - local_index = max(0, _parse_saved_int(state.get("local_index"), 0)) - local_pos = local_index % len(local_items) - ckpt_name = local_items[local_pos] - state["local_index"] = (local_pos + 1) % len(local_items) + # normal change_every/repeat/shuffle state, and it must consume one + # queued entry per execution. Duplicates are allowed and consumed + # independently. + 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, state.get("active_filter", []), False, len(local_items)) + title = _build_cycler_title("local_list", ckpt_name, 1, change_every, state.get("active_filter", []), False, len(remaining_local_items)) status_text = _build_cycler_status_text( "local_list", ckpt_name, @@ -1318,11 +1332,12 @@ class CheckpointNameCycler: 1, change_every, global_match_count, - len(local_items), + len(remaining_local_items), mode, - local_items=local_items, + local_items=remaining_local_items, use_local_list=True, ) + 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", "")) @@ -1447,7 +1462,9 @@ class CheckpointNameCycler: 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(), execution_revision=state["execution_revision"], ) return (ckpt_name, ckpt_name, _ckpt_name_safe_from_relpath(ckpt_name)) @@ -1758,8 +1775,7 @@ async def cycler_local_list_append(request): state = _get_cycler_state(_state_key(tab_id, node_id)) if state.get("use_local_list", True): local_list = state.setdefault("local_list", []) - if relpath not in local_list: - local_list.append(relpath) + local_list.append(relpath) state["override_queue"] = local_list state["last_status_text"] = _build_cycler_idle_status_text("", state) updated += 1 diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 99ebf68..e2d67c2 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -1,7 +1,7 @@ -import { app } from "../../scripts/app.js"; -import { api } from "../../scripts/api.js"; +import { app } from "../scripts/app.js"; +import { api } from "../scripts/api.js"; -// v8c: v8b/v8a conservative rebuild with ListSelector/Cycler/Tagger UI-state fixes. +// v8e: Local List queue consumption, UI restoration, and regression guardrails. const EXT = "ruminar.checkpoint_handpicker_suite"; const PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview"; const CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler"; @@ -61,6 +61,7 @@ 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); @@ -684,8 +685,8 @@ async function restoreNodeStateFromBackend(node, nodeClass) { node.__hpsTaggerPath = result.ckpt_name_str; node.__hpsTaggerStatus = result.status || "none"; node.__hpsTaggerMessage = node.__hpsTaggerStatus === "none" - ? "Current: — none" - : `Current: ${STATUS_ICON[node.__hpsTaggerStatus]} ${STATUS_LABEL[node.__hpsTaggerStatus]}`; + ? "Current: none" + : `Current: ${STATUS_ICON[node.__hpsTaggerStatus]} ${STATUS_CURRENT_LABEL[node.__hpsTaggerStatus] || STATUS_LABEL[node.__hpsTaggerStatus]}`; node.title = node.__hpsTaggerStatus === "none" ? `Tagger : ${result.ckpt_name_str}` : `Tagger : ${STATUS_ICON[node.__hpsTaggerStatus]} ${result.ckpt_name_str}`; @@ -829,9 +830,9 @@ function hideSelectorWidget(node) { function selectorRects(node) { const margin = 8; 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 }, + refreshAll: { x: margin, y: 8, w: 110, h: 24 }, + listOnly: { x: 124, y: 8, w: 104, h: 24 }, + pushLocalList: { x: 236, y: 8, w: 154, h: 24 }, up: { x: 360, y: 8, w: 34, h: 24 }, down: { x: 400, y: 8, w: 34, h: 24 }, list: { x: margin, y: 104, w: node.size[0] - 16, h: ROW_H * SELECTOR_VISIBLE_ROWS }, @@ -1201,7 +1202,12 @@ function drawSelectorRows(ctx, node) { 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"; - ctx.fillText(`${selectorStatusIcon(status)} ${ckpt}`, r.list.x + 6, y + ROW_H / 2, r.list.w - 20); + 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) { @@ -1241,7 +1247,7 @@ function setupSelectorNode(nodeType) { if (hpsNodeCollapsed(this)) return; const r = selectorRects(this); drawButton(ctx, r.refreshAll, "🔄 Refresh All", !this.__hpsLoading); - drawButton(ctx, r.listOnly, "List", !this.__hpsLoading); + drawButton(ctx, r.listOnly, "📋 List Only", !this.__hpsLoading); drawButton(ctx, r.pushLocalList, selectorActionLabel(this), !this.__hpsLoading); drawButton(ctx, r.up, "▲", true); drawButton(ctx, r.down, "▼", true); @@ -1399,12 +1405,17 @@ function currentTaggerPath(node) { return node.__hpsTaggerPath || linkedInputValue(node, "ckpt_name_str") || ""; } function taggerButtons(node) { - // Keep the controls away from LiteGraph input pins/labels. + // 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: 120 + i * 82, + x: startX + i * (buttonW + gap), y: 3, - w: 76, + w: buttonW, h: 26, })); } @@ -1424,7 +1435,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 = result.status === "none" ? "Current: none" : `Current: ${STATUS_ICON[result.status]} ${STATUS_CURRENT_LABEL[result.status] || STATUS_LABEL[result.status]}`; node.title = result.status === "none" ? `Tagger : ${ckpt}` : `Tagger : ${STATUS_ICON[result.status]} ${ckpt}`; } else { node.__hpsTaggerMessage = result.error || "Failed"; @@ -1482,7 +1493,7 @@ function setupTaggerNode(nodeType) { ctx.fillStyle = "#ddd"; ctx.font = "12px sans-serif"; ctx.fillText(p ? p : "Execute once to bind current checkpoint.", 8, 50); - const msg = this.__hpsTaggerMessage || (current === "none" ? "Current: — none" : `Current: ${STATUS_ICON[current]} ${STATUS_LABEL[current]}`); + const msg = this.__hpsTaggerMessage || (current === "none" ? "Current: none" : `Current: ${STATUS_ICON[current]} ${STATUS_CURRENT_LABEL[current] || STATUS_LABEL[current]}`); ctx.fillStyle = current === "none" ? "#888" : "#ddd"; ctx.fillText(msg, 8, 70); if (this.size[1] >= 124 && current !== "none" && current !== "delete") { @@ -1509,7 +1520,7 @@ 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 = detail.status === "none" ? "Current: none" : `Current: ${STATUS_ICON[detail.status]} ${STATUS_CURRENT_LABEL[detail.status] || STATUS_LABEL[detail.status]}`; if (detail.title) node.title = detail.title; app.graph.setDirtyCanvas(true, true); }); @@ -1538,7 +1549,7 @@ api.addEventListener(STATUS_CHANGED_EVENT, ({ detail }) => { } if (isNodeClass(node, TAGGER_CLASS) && currentTaggerPath(node) === detail.ckpt_name_str) { node.__hpsTaggerStatus = detail.status || "none"; - node.__hpsTaggerMessage = node.__hpsTaggerStatus === "none" ? "Current: — none" : `Current: ${STATUS_ICON[node.__hpsTaggerStatus]} ${STATUS_LABEL[node.__hpsTaggerStatus]}`; + node.__hpsTaggerMessage = node.__hpsTaggerStatus === "none" ? "Current: none" : `Current: ${STATUS_ICON[node.__hpsTaggerStatus]} ${STATUS_CURRENT_LABEL[node.__hpsTaggerStatus] || STATUS_LABEL[node.__hpsTaggerStatus]}`; patchCheckpointTitle(node, "Tagger", detail.ckpt_name_str, node.__hpsTaggerStatus); } if (isNodeClass(node, CYCLER_CLASS) && node.__hpsCyclerCkptName === detail.ckpt_name_str) { @@ -1555,8 +1566,8 @@ function cyclerRects(node) { 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) }, }; @@ -1644,7 +1655,7 @@ function setupCyclerNode(nodeType) { const active = cyclerActiveFilter(this); ctx.save(); - drawButton(ctx, r.localListToggle, this.__hpsUseLocalList === false ? "Local List OFF" : "Local List ON", true, this.__hpsUseLocalList !== false); + 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"; @@ -1652,11 +1663,6 @@ function setupCyclerNode(nodeType) { drawButton(ctx, b, isAll ? "all" : STATUS_ICON[b.status], true, on); } - ctx.fillStyle = "#aaa"; - ctx.font = "12px sans-serif"; - ctx.fillText(`filter: ${statusIconDisplay(active)}`, 8, 78); - ctx.fillText(`settings revision: ${this.__hpsSettingsRevision ?? 0}`, 8, 96); - 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)"; From b383e87a70291c1e0d82f234b05d46219ca27dcd 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: Sat, 6 Jun 2026 15:04:46 +0900 Subject: [PATCH 07/16] =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=83=91=E3=82=B9=E3=82=92=E4=BF=AE=E6=AD=A3=E3=81=97?= =?UTF-8?q?=E3=80=81=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=81=AE?= =?UTF-8?q?=E5=8F=82=E7=85=A7=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/checkpoint_handpicker_suite.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index e2d67c2..0f30db6 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -1,5 +1,5 @@ -import { app } from "../scripts/app.js"; -import { api } from "../scripts/api.js"; +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; // v8e: Local List queue consumption, UI restoration, and regression guardrails. const EXT = "ruminar.checkpoint_handpicker_suite"; From c057da7fda04df602b0e8128c971761c4e31d3f8 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: Sat, 6 Jun 2026 15:48:39 +0900 Subject: [PATCH 08/16] =?UTF-8?q?Cycler=E3=81=AE=E3=83=A9=E3=83=B3?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A0=E5=88=B6=E5=BE=A1=E3=81=A8=E5=AE=9F?= =?UTF-8?q?=E8=A1=8C=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=81?= =?UTF-8?q?=E9=96=A2=E9=80=A3=E3=81=99=E3=82=8BUI=E3=82=A6=E3=82=A3?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=83=83=E3=83=88=E3=81=AE=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 7 +- suite_nodes.py | 368 ++++++++++++++++++++++------- web/checkpoint_handpicker_suite.js | 125 ++++++++-- 3 files changed, 399 insertions(+), 101 deletions(-) diff --git a/__init__.py b/__init__.py index f4f7fe8..d59ee3e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,8 @@ +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" -from .suite_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS - __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/suite_nodes.py b/suite_nodes.py index 76c6b46..f2e3f99 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -21,7 +21,7 @@ from server import PromptServer logger = logging.getLogger(__name__) -# v8e: Local List queue consumption, UI restoration, and regression guardrails. +# v8f: live Cycler runtime controls and execution snapshots. EXTENSION_PREFIX = "checkpoint_handpicker_suite" PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview" CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler" @@ -364,17 +364,35 @@ def _valid_change_every(value) -> int: def _get_default_cycler_state() -> dict: return { - "use_local_list": True, - "accept_queue": True, + # 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": [], - "active_filter": [], - "settings_revision": 0, "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", @@ -397,6 +415,72 @@ def _get_cycler_state(key: str) -> dict: 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)) @@ -406,23 +490,41 @@ def _should_accept_settings_update(state: dict, revision: int) -> tuple[bool, in def _cycler_state_payload(state: dict) -> dict: - ckpt = state.get("last_ckpt_name", "") or "" + 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(state.get("active_filter", [])) + 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(state.get("use_local_list", True)) - # Current job title/source is a snapshot of the last resolved job. Local List - # ON/OFF and Clear must not rewrite it; they only affect future selection and - # the pending Local List display. - display_source = "local_list" if state.get("last_source") == "local_list" else "cycle" - hold_index = max(0, _parse_saved_int(state.get("last_hold_index"), 0)) - change_every = _valid_change_every(state.get("last_change_every", 1)) - title = _build_cycler_title(display_source, ckpt, hold_index, change_every, active_filter, False, local_count if use_local else 0) if ckpt else "Checkpoint Name Cycler" - # Keep status_text derived from the current UI state so Local List ON/OFF - # immediately shows/hides the list without waiting for the next queue run. - status_text = _build_cycler_idle_status_text("", state) + 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, + ) + else: + title = "Checkpoint Name Cycler" + status_text = _build_cycler_idle_status_text("", state) + state["last_title"] = title state["last_status_text"] = status_text return { @@ -432,16 +534,25 @@ def _cycler_state_payload(state: dict) -> dict: "status_icon": STATUS_ICON[status], "title": title, "status_text": status_text, - "use_local_list": bool(state.get("use_local_list", True)), + "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(state.get("settings_revision"), 0), + "settings_revision": _parse_saved_int(controls.get("settings_revision"), 0), "local_list_count": len(valid_local_items), "local_list_items": valid_local_items, - "mode": state.get("last_mode", "increment"), - "change_every": state.get("last_change_every", 1), - "start_checkpoint": state.get("last_start_checkpoint", ""), + "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), + }, + "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), } @@ -1135,7 +1246,11 @@ def _checkpoint_total_count() -> int: def _build_local_list_lines(local_items: list[str], max_items: int = 20) -> list[str]: - shown = list(local_items[:max_items]) + 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 @@ -1145,8 +1260,8 @@ def _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filt status = _get_status(ckpt_name) if ckpt_name else "none" icon = STATUS_ICON[status] if status != "none" else "" prefix = "Cycler" - if active_filter: - prefix += f" [{_status_icons_for_filter(active_filter)}]" + # 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: @@ -1159,9 +1274,10 @@ def _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filt 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 = [ - f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (none)", + 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()}", ] @@ -1177,19 +1293,29 @@ def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, qu 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.""" - ckpt_name = state.get("last_ckpt_name", "") or "" + """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(state.get("active_filter", [])) + active_filter = list(controls.get("active_filter", [])) local_items = _valid_local_list(state.get("local_list", [])) local_count = len(local_items) - mode = state.get("last_mode", "increment") - hold_index = max(0, _parse_saved_int(state.get("last_hold_index"), 0)) - change_every = _valid_change_every(state.get("last_change_every", 1)) - current = f"Current: {STATUS_ICON[status]} {ckpt_name}" if ckpt_name else "Current: (not executed yet)" - use_local = bool(state.get("use_local_list", True)) - local_job_active = state.get("last_source") == "local_list" - mode_line = "Mode: local list" if local_job_active else f"Mode: {mode} Hold: {hold_index}/{change_every}" + 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, @@ -1270,18 +1396,38 @@ class CheckpointNameCycler: node_id = str(unique_id or "") key = _state_key(tab_id, node_id) state = _get_cycler_state(key) - mode = _valid_mode(mode) - change_every = _valid_change_every(change_every) - active_filter = _normalize_status_list(hps_filter_statuses) - use_local_list = _parse_saved_bool(hps_use_local_list, bool(state.get("use_local_list", True))) - revision = _parse_saved_int(hps_settings_revision, _parse_saved_int(state.get("settings_revision"), 0)) - accepted, revision = _should_accept_settings_update(state, revision) - if accepted: - state["settings_revision"] = revision - state["active_filter"] = active_filter - state["use_local_list"] = use_local_list - state["accept_queue"] = use_local_list + 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: @@ -1292,9 +1438,9 @@ class CheckpointNameCycler: _send_cycler_update(node_id, title, status_text, tab_id=tab_id) return ("", "", "checkpoint") - global_candidates, fallback_all, global_match_count = _candidate_checkpoints(state.get("active_filter", [])) + global_candidates, fallback_all, global_match_count = _candidate_checkpoints(active_filter) local_items = _valid_local_list(state.get("local_list", [])) - use_local_source = bool(state.get("use_local_list", True)) and bool(local_items) + 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 @@ -1302,7 +1448,7 @@ class CheckpointNameCycler: candidate_set = set(candidates) def start_index() -> int: - requested = _normalize_relpath(start_checkpoint) + requested = _normalize_relpath(start_checkpoint_used) if requested in all_checkpoints: return all_checkpoints.index(requested) return 0 @@ -1312,28 +1458,26 @@ class CheckpointNameCycler: selected_index = 0 if use_local_source: - # Local List is a manual per-Cycler queue. It must not advance the - # normal change_every/repeat/shuffle state, and it must consume one - # queued entry per execution. Duplicates are allowed and consumed - # independently. + # 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, state.get("active_filter", []), False, len(remaining_local_items)) + 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, - state.get("active_filter", []), + active_filter, False, [], 1, change_every, global_match_count, len(remaining_local_items), - mode, + "local list", local_items=remaining_local_items, use_local_list=True, ) @@ -1370,7 +1514,7 @@ class CheckpointNameCycler: selected_index = all_checkpoints.index(ckpt_name) else: if mode == "fixed": - requested = _normalize_relpath(start_checkpoint) + 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": @@ -1398,11 +1542,11 @@ class CheckpointNameCycler: state["current_index"] = selected_index state["last_normal_ckpt_name"] = ckpt_name - title = _build_cycler_title("cycle", ckpt_name, hold_index, change_every, state.get("active_filter", []), fallback_all and mode != "fixed", len(local_items) if state.get("use_local_list", True) else 0) + 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, - state.get("active_filter", []), + active_filter, fallback_all and mode != "fixed", [], hold_index, @@ -1411,20 +1555,42 @@ class CheckpointNameCycler: len(local_items), mode, local_items=local_items, - use_local_list=bool(state.get("use_local_list", True)), + 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_source"] = source state["last_title"] = title state["last_status_text"] = status_text state["last_hold_index"] = hold_index + # Mirrors of runtime controls, not queue-prompt arguments. state["last_change_every"] = change_every state["last_mode"] = mode - state["last_start_checkpoint"] = _normalize_relpath(start_checkpoint) - status = _get_status(ckpt_name) + 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", @@ -1437,16 +1603,19 @@ class CheckpointNameCycler: "mode_used": "local_list" if use_local_source else mode, "base_mode": mode, "change_every": change_every, - "change_every_used": 1 if use_local_source else change_every, - "filter_used": list(state.get("active_filter", [])), + "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, @@ -1465,6 +1634,10 @@ class CheckpointNameCycler: 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)) @@ -1711,6 +1884,31 @@ async def tagger_set_status(request): }) +@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() @@ -1720,16 +1918,15 @@ async def cycler_set_flags(request): 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: - if "use_local_list" in data: - state["use_local_list"] = bool(data.get("use_local_list")) - state["accept_queue"] = state["use_local_list"] - if "mode" in data: - state["last_mode"] = _valid_mode(data.get("mode")) - if "change_every" in data: - state["last_change_every"] = _valid_change_every(data.get("change_every")) - if "start_checkpoint" in data: - state["last_start_checkpoint"] = _normalize_relpath(data.get("start_checkpoint", "")) - state["settings_revision"] = revision + _set_runtime_controls( + state, + mode=data.get("mode") if "mode" in data else None, + change_every=data.get("change_every") if "change_every" in data else None, + start_checkpoint=data.get("start_checkpoint", "") if "start_checkpoint" in data else None, + use_local_list=bool(data.get("use_local_list")) if "use_local_list" in data else None, + settings_revision=revision, + mark_initialized=True, + ) _send_cycler_state_update(node_id, state, tab_id=tab_id) payload = _cycler_state_payload(state) payload.update({"ok": True, "accepted": accepted, "use_local_list": state["use_local_list"]}) @@ -1746,18 +1943,19 @@ async def cycler_set_filter(request): 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: - state["active_filter"] = statuses.copy() - if "mode" in data: - state["last_mode"] = _valid_mode(data.get("mode")) - if "change_every" in data: - state["last_change_every"] = _valid_change_every(data.get("change_every")) - if "start_checkpoint" in data: - state["last_start_checkpoint"] = _normalize_relpath(data.get("start_checkpoint", "")) - state["settings_revision"] = revision + _set_runtime_controls( + state, + mode=data.get("mode") if "mode" in data else None, + change_every=data.get("change_every") if "change_every" in data else None, + start_checkpoint=data.get("start_checkpoint", "") if "start_checkpoint" in data else None, + active_filter=statuses.copy(), + settings_revision=revision, + mark_initialized=True, + ) _send_cycler_state_update(node_id, state, tab_id=tab_id) logger.info("[CheckpointHandpickerSuite] Updated cycler filter for tab %s node %s: %s accepted=%s", tab_id, node_id, statuses or ["All"], accepted) payload = _cycler_state_payload(state) - payload.update({"ok": True, "accepted": accepted, "node_id": node_id, "statuses": state.get("active_filter", [])}) + payload.update({"ok": True, "accepted": accepted, "node_id": node_id, "statuses": _runtime_controls_from_state(state).get("active_filter", [])}) return web.json_response(payload) diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 0f30db6..a9c0408 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -1,7 +1,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -// v8e: Local List queue consumption, UI restoration, and regression guardrails. +// v8f: live Cycler runtime controls and execution snapshots. const EXT = "ruminar.checkpoint_handpicker_suite"; const PREVIEW_EVENT = "ruminar.checkpoint_handpicker_suite.preview"; const CYCLER_EVENT = "ruminar.checkpoint_handpicker_suite.cycler"; @@ -412,14 +412,28 @@ function selectorStatusIcon(status) { function applyCyclerStatePayload(node, detail) { if (!node || !detail) return; - if (detail.active_filter !== undefined) { - node.__hpsFilterStatuses = normalizeCyclerFilterStatuses(detail.active_filter); + const controls = detail.runtime_controls || {}; + if (detail.active_filter !== undefined || controls.active_filter !== undefined) { + node.__hpsFilterStatuses = normalizeCyclerFilterStatuses(detail.active_filter ?? controls.active_filter); } - if (detail.use_local_list !== undefined) { - node.__hpsUseLocalList = !!detail.use_local_list; + 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) { - node.__hpsSettingsRevision = parseSavedInt(detail.settings_revision, node.__hpsSettingsRevision ?? 0); + if (detail.settings_revision !== undefined || controls.settings_revision !== undefined) { + node.__hpsSettingsRevision = parseSavedInt(detail.settings_revision ?? controls.settings_revision, node.__hpsSettingsRevision ?? 0); + } + // Only apply visible runtime widgets when the backend explicitly returns them + // as runtime_controls. This keeps restore coherent while avoiding prompt-time + // stale values becoming authoritative. + 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); + } finally { + node.__hpsApplyingRuntimePayload = false; + } } if (detail.ckpt_name_str) { node.__hpsCyclerCkptName = detail.ckpt_name_str; @@ -829,21 +843,42 @@ function hideSelectorWidget(node) { } function selectorRects(node) { const margin = 8; + const gap = 6; + const buttonY = 8; + const buttonH = 24; + const arrowW = 34; + const downX = Math.max(430, (node.size?.[0] || 520) - margin - arrowW); + const upX = downX - gap - arrowW; + const refreshW = 108; + const listOnlyW = 96; + const pushX = margin + refreshW + gap + listOnlyW + gap; + const pushW = Math.max(118, upX - gap - pushX); return { - refreshAll: { x: margin, y: 8, w: 110, h: 24 }, - listOnly: { x: 124, y: 8, w: 104, h: 24 }, - pushLocalList: { x: 236, y: 8, w: 154, 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 || {}; @@ -1006,6 +1041,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) { @@ -1542,6 +1579,9 @@ api.addEventListener(STATUS_CHANGED_EVENT, ({ detail }) => { 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 (PREVIEW_CLASSES.has(node.type || node.comfyClass) && node.__hpsPreviewCkptName === detail.ckpt_name_str) { node.__hpsPreviewStatus = detail.status || "none"; @@ -1553,7 +1593,12 @@ api.addEventListener(STATUS_CHANGED_EVENT, ({ detail }) => { patchCheckpointTitle(node, "Tagger", detail.ckpt_name_str, node.__hpsTaggerStatus); } if (isNodeClass(node, CYCLER_CLASS) && node.__hpsCyclerCkptName === detail.ckpt_name_str) { - patchCheckpointTitle(node, "Cycler", detail.ckpt_name_str, detail.status || "none"); + node.__hpsCyclerStatusValue = detail.status || "none"; + const oldTitle = String(node.title || ""); + const idx = oldTitle.indexOf(detail.ckpt_name_str); + if (idx >= 0) { + node.title = oldTitle.slice(0, idx) + titleDisplayForCheckpoint(detail.ckpt_name_str, detail.status || "none") + oldTitle.slice(idx + detail.ckpt_name_str.length); + } } } scheduleSelectorGlobalRefresh(); @@ -1605,6 +1650,49 @@ async function pushCyclerFilter(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, + ...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) { const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/clear_local_list`, { method: "POST", @@ -1635,6 +1723,7 @@ function setupCyclerNode(nodeType) { const r = origCreated ? origCreated.apply(this, arguments) : undefined; ensureSize(this, 560, 260); restoreCyclerSettingsFromWidgets(this); + installCyclerRuntimeControlHooks(this); setTimeout(() => restoreNodeStateFromBackend(this, CYCLER_CLASS), 0); return r; }; @@ -1642,6 +1731,7 @@ function setupCyclerNode(nodeType) { nodeType.prototype.onConfigure = function () { const r = origConfigure ? origConfigure.apply(this, arguments) : undefined; restoreCyclerSettingsFromWidgets(this); + installCyclerRuntimeControlHooks(this); setTimeout(() => restoreNodeStateFromBackend(this, CYCLER_CLASS), 0); return r; }; @@ -1650,6 +1740,13 @@ function setupCyclerNode(nodeType) { if (origDraw) origDraw.apply(this, arguments); ensureHiddenTabIdWidget(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); const active = cyclerActiveFilter(this); From 63b24f37c285f512b089f310fe5191d3ef6d675c 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: Sat, 6 Jun 2026 17:50:56 +0900 Subject: [PATCH 09/16] =?UTF-8?q?Cycler=E3=81=AE=E3=83=A9=E3=83=B3?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A0=E5=88=B6=E5=BE=A1=E3=82=92=E5=BE=A9?= =?UTF-8?q?=E5=85=83=E3=81=97=E3=80=81=E3=82=B0=E3=83=AD=E3=83=BC=E3=83=90?= =?UTF-8?q?=E3=83=AB=E3=82=B7=E3=83=A3=E3=83=83=E3=83=95=E3=83=AB=E3=83=87?= =?UTF-8?q?=E3=83=83=E3=82=AD=E3=81=A8UI=E3=81=AE=E5=9B=9E=E5=B8=B0?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 73 +++++++++++--- web/checkpoint_handpicker_suite.js | 147 +++++++++++++++++++++-------- 2 files changed, 169 insertions(+), 51 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index f2e3f99..0b4de9a 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -21,7 +21,7 @@ from server import PromptServer logger = logging.getLogger(__name__) -# v8f: live Cycler runtime controls and execution snapshots. +# 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" @@ -521,6 +521,8 @@ def _cycler_state_payload(state: dict) -> dict: local_items=valid_local_items, use_local_list=use_local, ) + if 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) @@ -549,6 +551,8 @@ def _cycler_state_payload(state: dict) -> dict: "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", ""), @@ -1188,21 +1192,31 @@ def _load_image_file(path: Path) -> Image.Image | None: def _load_image_dir_preview(node_id, relpath, search_directory=None, tab_id="", send_progress=True, max_preview_images=IMAGE_DIR_DEFAULT_MAX_IMAGES): max_preview_images = _clamp_image_dir_max_images(max_preview_images) - if send_progress: - _send_image_dir_progress(node_id, relpath, tab_id, "Searching preview images...", 0, max_preview_images, max_preview_images) + + def progress(value: int, total: int, message: str): + if send_progress: + _send_image_dir_progress(node_id, relpath, tab_id, message, value, total, max_preview_images) + + progress(0, max_preview_images, "Searching preview images...") paths = _find_image_dir_candidates(relpath, search_directory) selected_paths = paths[:max_preview_images] + progress(len(selected_paths), max_preview_images, f"Found preview images {len(selected_paths)}/{max_preview_images}") + images = [] + total = len(selected_paths) for idx, path in enumerate(selected_paths, start=1): - if send_progress: - _send_image_dir_progress(node_id, relpath, tab_id, f"Loading preview image {idx}/{len(selected_paths)}", idx - 1, len(selected_paths), max_preview_images) + 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}") + def cb(value, total, message): - if send_progress: - _send_image_dir_progress(node_id, relpath, tab_id, message, value, total, max_preview_images) + 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", @@ -1211,6 +1225,9 @@ def _load_image_dir_preview(node_id, relpath, search_directory=None, tab_id="", "status": status, "status_icon": STATUS_ICON[status], "message": f"{len(images)} image(s) found" if images else "no preview images found", + "progress_message": "Preview ready." if sheet is not None else "no preview images found", + "progress_value": max_preview_images, + "progress_total": max_preview_images, "search_directory": str(_safe_search_root(search_directory) or ""), "max_preview_images": max_preview_images, "source_paths": [str(p) for p in selected_paths], @@ -1324,6 +1341,8 @@ def _build_cycler_idle_status_text(action: str, state: dict, detail: str = "") - if use_local: lines.append(f"Local List: {local_count} item(s)") lines.extend(_build_local_list_lines(local_items)) + if 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) @@ -1438,6 +1457,12 @@ class CheckpointNameCycler: _send_cycler_update(node_id, title, status_text, tab_id=tab_id) return ("", "", "checkpoint") + if start_checkpoint_used not in all_checkpoints: + # Backend fallback only. The frontend must still keep the visible + # combo value valid so ComfyUI's queue-time validation does not fail. + start_checkpoint_used = all_checkpoints[0] + controls = _set_runtime_controls(state, start_checkpoint=start_checkpoint_used, mark_initialized=bool(state.get("runtime_controls_initialized", False))) + global_candidates, fallback_all, global_match_count = _candidate_checkpoints(active_filter) local_items = _valid_local_list(state.get("local_list", [])) use_local_source = use_local_list and bool(local_items) @@ -1501,13 +1526,37 @@ class CheckpointNameCycler: return ckpt, all_checkpoints.index(ckpt) def draw_from_shuffle_deck(): - deck = [x for x in state.get("shuffle_deck", []) if x in candidate_set] + # v8g: shuffle_once is a global shuffled increment. + # The deck is built from all checkpoints, while the active filter + # is applied when scanning the deck. Non-matching checkpoints are + # skipped AND consumed, matching increment's "cursor moved on" + # semantics without exposing an extra cursor UI. + all_set = set(all_checkpoints) + deck = [x for x in state.get("shuffle_deck", []) if x in all_set] + + def refill_deck(): + fresh = list(all_checkpoints) + random.shuffle(fresh) + return fresh + if not deck: - deck = candidates.copy() - random.shuffle(deck) - ckpt = deck.pop(0) + deck = refill_deck() + + for _pass in range(2): + while deck: + ckpt = deck.pop(0) + if ckpt in candidate_set: + state["shuffle_deck"] = deck + return ckpt + # The remaining global deck had no item for the current + # filter. Start a new global deck and scan once more. + deck = refill_deck() + + # Defensive fallback. This should be unreachable when candidates + # is non-empty, but keeps the node alive if checkpoint state + # changes under us. state["shuffle_deck"] = deck - return ckpt + return candidates[0] if can_hold_last: ckpt_name = last_ckpt diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index a9c0408..bb66051 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -1,7 +1,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -// v8f: live Cycler runtime controls and execution snapshots. +// 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"; @@ -410,29 +410,56 @@ 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 || {}; - 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); - } - // Only apply visible runtime widgets when the backend explicitly returns them - // as runtime_controls. This keeps restore coherent while avoiding prompt-time - // stale values becoming authoritative. - 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); - } finally { - node.__hpsApplyingRuntimePayload = false; + 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) { @@ -486,6 +513,7 @@ function validChangeEvery(value) { } function getCyclerRuntimeControls(node) { + ensureStartCheckpointWidgetValid(node); return { mode: validCyclerMode(getWidget(node, "mode")?.value), change_every: validChangeEvery(getWidget(node, "change_every")?.value), @@ -523,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) { @@ -698,15 +727,20 @@ async function restoreNodeStateFromBackend(node, nodeClass) { if (result.ckpt_name_str) { node.__hpsTaggerPath = result.ckpt_name_str; node.__hpsTaggerStatus = result.status || "none"; - node.__hpsTaggerMessage = node.__hpsTaggerStatus === "none" - ? "Current: none" - : `Current: ${STATUS_ICON[node.__hpsTaggerStatus]} ${STATUS_CURRENT_LABEL[node.__hpsTaggerStatus] || STATUS_LABEL[node.__hpsTaggerStatus]}`; + 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) { - applyCyclerStatePayload(node, result); + 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) { @@ -759,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); @@ -846,13 +893,14 @@ function selectorRects(node) { const gap = 6; const buttonY = 8; const buttonH = 24; - const arrowW = 34; + const arrowW = 32; const downX = Math.max(430, (node.size?.[0] || 520) - margin - arrowW); const upX = downX - gap - arrowW; - const refreshW = 108; - const listOnlyW = 96; + const refreshW = 102; + const listOnlyW = 92; const pushX = margin + refreshW + gap + listOnlyW + gap; - const pushW = Math.max(118, upX - gap - pushX); + const availablePushW = Math.max(140, upX - gap - pushX); + const pushW = Math.min(190, availablePushW); return { refreshAll: { x: margin, y: buttonY, w: refreshW, h: buttonH }, listOnly: { x: margin + refreshW + gap, y: buttonY, w: listOnlyW, h: buttonH }, @@ -1438,8 +1486,27 @@ function setupSelectorNode(nodeType) { } // ---------- Tagger ---------- +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 || linkedInputValue(node, "ckpt_name_str") || ""; + return node.__hpsTaggerPath || linkedCheckpointInputValue(node, "ckpt_name_str") || ""; } function taggerButtons(node) { // Keep controls right-aligned and away from LiteGraph input pins/labels. @@ -1472,7 +1539,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_CURRENT_LABEL[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"; @@ -1490,13 +1557,13 @@ function taggerCursorAt(node, local) { } function setupTaggerNode(nodeType) { - installMinSize(nodeType, 450, 90); + 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, 90); + ensureSize(this, 450, 104); setTimeout(() => restoreNodeStateFromBackend(this, TAGGER_CLASS), 0); return r; }; @@ -1530,12 +1597,12 @@ function setupTaggerNode(nodeType) { ctx.fillStyle = "#ddd"; ctx.font = "12px sans-serif"; ctx.fillText(p ? p : "Execute once to bind current checkpoint.", 8, 50); - const msg = this.__hpsTaggerMessage || (current === "none" ? "Current: none" : `Current: ${STATUS_ICON[current]} ${STATUS_CURRENT_LABEL[current] || STATUS_LABEL[current]}`); - ctx.fillStyle = current === "none" ? "#888" : "#ddd"; + const msg = this.__hpsTaggerMessage || taggerCurrentMessage(current); + ctx.fillStyle = current === "none" ? "#aaa" : "#ddd"; ctx.fillText(msg, 8, 70); - if (this.size[1] >= 124 && current !== "none" && current !== "delete") { + if (current !== "none" && current !== "delete") { ctx.fillStyle = "#aaa"; - ctx.fillText("Delete is available only from none.", 8, 114); + ctx.fillText("Delete is available only from none.", 8, 90); } ctx.restore(); }; @@ -1557,7 +1624,7 @@ 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_CURRENT_LABEL[detail.status] || STATUS_LABEL[detail.status]}`; + node.__hpsTaggerMessage = taggerCurrentMessage(detail.status || "none"); if (detail.title) node.title = detail.title; app.graph.setDirtyCanvas(true, true); }); @@ -1589,7 +1656,7 @@ api.addEventListener(STATUS_CHANGED_EVENT, ({ detail }) => { } if (isNodeClass(node, TAGGER_CLASS) && currentTaggerPath(node) === detail.ckpt_name_str) { node.__hpsTaggerStatus = detail.status || "none"; - node.__hpsTaggerMessage = node.__hpsTaggerStatus === "none" ? "Current: none" : `Current: ${STATUS_ICON[node.__hpsTaggerStatus]} ${STATUS_CURRENT_LABEL[node.__hpsTaggerStatus] || STATUS_LABEL[node.__hpsTaggerStatus]}`; + node.__hpsTaggerMessage = taggerCurrentMessage(node.__hpsTaggerStatus); patchCheckpointTitle(node, "Tagger", detail.ckpt_name_str, node.__hpsTaggerStatus); } if (isNodeClass(node, CYCLER_CLASS) && node.__hpsCyclerCkptName === detail.ckpt_name_str) { @@ -1665,6 +1732,8 @@ async function pushCyclerRuntimeControls(node) { 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), })), }); From 28c8d3ba0b7e4b69e689ea8e66809bacc78bd4d3 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: Sat, 6 Jun 2026 22:39:50 +0900 Subject: [PATCH 10/16] =?UTF-8?q?async=E9=96=A2=E6=95=B0=E3=82=92=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E3=81=97=E3=81=A6ImageDir=E3=81=AE=E9=80=B2=E6=8D=97?= =?UTF-8?q?=E3=81=8C=E8=A1=A8=E7=A4=BA=E3=81=95=E3=82=8C=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 11 ++++++++++- web/checkpoint_handpicker_suite.js | 13 +++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index 0b4de9a..c140bd7 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -1,3 +1,4 @@ +import asyncio import base64 import io import json @@ -2088,7 +2089,15 @@ async def review_sync_checkpoint(request): 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 = _load_image_dir_preview(node_id, relpath, search_directory, tab_id=tab_id, send_progress=True, max_preview_images=max_preview_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 diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index bb66051..5313533 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -798,7 +798,7 @@ function setupPreviewNode(nodeType) { 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 barY = 0; const barW = Math.max(1, this.size[0] - margin * 2); const barH = 6; ctx.fillStyle = "rgba(255,255,255,0.14)"; @@ -894,13 +894,14 @@ function selectorRects(node) { const buttonY = 8; const buttonH = 24; const arrowW = 32; - const downX = Math.max(430, (node.size?.[0] || 520) - margin - arrowW); - const upX = downX - gap - arrowW; const refreshW = 102; const listOnlyW = 92; + const pushW = selectorActionMode(node) === "sync" ? 150 : 132; const pushX = margin + refreshW + gap + listOnlyW + gap; - const availablePushW = Math.max(140, upX - gap - pushX); - const pushW = Math.min(190, availablePushW); + // 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: buttonY, w: refreshW, h: buttonH }, listOnly: { x: margin + refreshW + gap, y: buttonY, w: listOnlyW, h: buttonH }, @@ -1062,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") { From 884d5260d299a5e6588e9f551b6443f838e66242 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:10:05 +0900 Subject: [PATCH 11/16] =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AA=E3=81=A9?= =?UTF-8?q?=E7=B4=B0=E3=81=8B=E3=81=84=E9=83=A8=E5=88=86=E3=82=92=E6=9C=80?= =?UTF-8?q?=E9=81=A9=E5=8C=96=EF=BC=88=E3=81=9F=E3=81=A0=E3=81=97=E5=B7=AE?= =?UTF-8?q?=E5=88=86=E3=81=AF=E5=A4=A7=E3=81=8D=E3=81=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 5 +- suite_nodes.py | 2597 ++++++++++++++-------------- web/checkpoint_handpicker_suite.js | 813 +++------ 3 files changed, 1507 insertions(+), 1908 deletions(-) diff --git a/__init__.py b/__init__.py index d59ee3e..9232db6 100644 --- a/__init__.py +++ b/__init__.py @@ -1,7 +1,4 @@ -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 +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 c140bd7..6e1b5e3 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, ImageOps +from PIL import Image 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,36 +30,40 @@ 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" -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" +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" JPEG_QUALITY = 80 JPEG_OPTIMIZE = False GAP = 6 IMAGE_DIR_DEFAULT_MAX_IMAGES = 12 IMAGE_DIR_MAX_IMAGES = 80 -IMAGE_DIR_SCAN_LIMIT = 3000 +MAX_LONG_EDGE = 512 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: @@ -143,6 +147,7 @@ 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": @@ -160,7 +165,9 @@ def _get_status(relpath: str) -> str: db = _load_status_db() entry = db.get("statuses", {}).get(relpath) or {} status = entry.get("status", "none") - return status if status in STATUS_VALUES else "none" + if status not in STATUS_VALUES: + status = "none" + return status def _set_status(relpath: str, status: str): @@ -182,36 +189,19 @@ def _set_status(relpath: str, status: str): _save_status_db(db) -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 _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_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: @@ -228,42 +218,6 @@ 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(): @@ -281,16 +235,17 @@ def _checkpoint_items() -> list[dict]: return items -def _delete_status_summary() -> dict[str, int]: - checkpoints = _get_checkpoint_list() - counts = {s: 0 for s in STATUS_VALUES if s != "none"} - for relpath in checkpoints: - status = _get_status(relpath) - if status in counts: - counts[status] += 1 - counts["none"] = len(checkpoints) - sum(counts.values()) - counts["total"] = len(checkpoints) - return counts +def _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 _status_summary_text(prefix: str) -> str: @@ -302,375 +257,23 @@ def _status_summary_text(prefix: str) -> str: def _status_icons_for_filter(active_statuses: list[str]) -> str: - 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" + icons = [] + for s in ["favorite", "nice", "keep", "delete", "none"]: + if s in active_statuses: + icons.append(STATUS_ICON[s]) + return "".join(icons) 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 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 _send_cycler_update(node_id, title, status_text, tab_id="", **extra): - payload = { - "node": int(node_id) if str(node_id or "").isdigit() else None, - "tab_id": _clean_tab_id(tab_id), - "node_class": "CheckpointNameCycler", - "title": title, - "status_text": status_text, - **extra, - } - _send_event(CYCLER_EVENT, payload) - - -def _send_preview(node_id, title, image: Image.Image | None, extra: dict | None = None, tab_id: str = ""): - extra = dict(extra or {}) - payload = { - "node": int(node_id) if str(node_id or "").isdigit() else None, - "tab_id": _clean_tab_id(tab_id), - "title": title, - **extra, - } - if image is None: - payload.update({"image": None, "format": "jpeg"}) - else: - payload.update(_encode_preview_payload(image)) - _send_event(PREVIEW_EVENT, payload) - - def _active_delete_records() -> dict[str, dict]: active = {} if not DELETE_QUEUE_PATH.exists(): return active try: - for line in DELETE_QUEUE_PATH.read_text(encoding="utf-8", errors="replace").splitlines(): + for line in DELETE_QUEUE_PATH.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue rec = json.loads(line) @@ -679,13 +282,10 @@ def _active_delete_records() -> dict[str, dict]: rid = rec.get("id") if typ == "reserve" and rid and relpath: active[relpath] = rec - elif typ == "cancel": - if relpath: - active.pop(relpath, None) - elif rid: - for key, value in list(active.items()): - if value.get("id") == rid: - active.pop(key, None) + elif typ == "cancel" and 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 @@ -697,6 +297,83 @@ 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): @@ -716,6 +393,155 @@ 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() @@ -737,10 +563,12 @@ 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({ @@ -757,10 +585,13 @@ 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", @@ -780,7 +611,10 @@ def _write_delete_script(): " path = Path(path).resolve()", " if path.suffix.lower() not in ALLOWED_SUFFIXES:", " return False", - " return any(is_under_root(path, root) for root in ALLOWED_ROOTS)", + " for root in ALLOWED_ROOTS:", + " if is_under_root(path, root):", + " return True", + " return False", "", "def read_records():", " if not QUEUE_PATH.exists():", @@ -791,7 +625,8 @@ 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", @@ -800,56 +635,79 @@ def _write_delete_script(): " active = {}", " for rec in read_records():", " rec_type = rec.get('type')", - " relpath = rec.get('ckpt_name_str') or ''", + " relpath = rec.get('ckpt_name_str') or rec.get('ckpt_name_relpath') 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 = []", - " 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', '')))", + " targets = active_targets()", " print('Checkpoint delete script')", " print('Queue file:', QUEUE_PATH)", " print('Targets:', len(targets))", - " for idx, (relpath, safetensors_path, json_path, reserved_at) in enumerate(targets, start=1):", + " for idx, item in enumerate(targets, start=1):", " print()", " print('[{}/{}] Delete checkpoint?'.format(idx, len(targets)))", - " print(' relpath:', relpath)", - " print(' safetensors:', safetensors_path)", - " print(' json:', json_path)", + " print(' relpath:', item['ckpt_name_str'])", + " print(' safetensors:', item['safetensors_path'])", + " print(' json:', item['json_path'])", " answer = input('Delete this checkpoint? (y/N): ').strip().lower()", - " if answer != 'y': print('Skipped.'); continue", - " delete_file(safetensors_path)", - " delete_file(json_path)", + " if answer != 'y':", + " print('Skipped.')", + " continue", + " delete_file(item['safetensors_path'])", + " delete_file(item['json_path'])", " print()", - " print('Deletion completed. Please return to ComfyUI and click Refresh All.')", + " print('Deletion completed.')", + " print('Please return to ComfyUI and click:')", + " print('Checkpoint List Selector -> 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()}", @@ -869,98 +727,13 @@ def _write_delete_script(): return script_path, plan_path, len(targets) -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 _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 _pil_from_array(arr: np.ndarray) -> Image.Image: @@ -980,6 +753,8 @@ 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] @@ -991,51 +766,6 @@ 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 @@ -1049,7 +779,128 @@ 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 _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 _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]: if not images: return None, {"count": 0, "columns": 0, "rows": 0, "tile_width": 0, "tile_height": 0} if max_images is None: @@ -1078,7 +929,7 @@ def _build_contact_sheet(images: list[Image.Image], progress_callback=None, max_ if img.mode == "RGBA": canvas.paste(img, (px, py), img) else: - canvas.paste(img.convert("RGB"), (px, py)) + canvas.paste(img, (px, py)) return canvas, { "count": layout.count, "columns": layout.cols, @@ -1091,305 +942,583 @@ def _build_contact_sheet(images: list[Image.Image], progress_callback=None, max_ "max_images": max_images if max_images is not None else len(images), } - -def _encode_preview_payload(image: Image.Image) -> dict: +def _encode_jpeg(image: Image.Image) -> str: if image.mode not in ("RGB", "L"): image = image.convert("RGB") buf = io.BytesIO() image.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=JPEG_OPTIMIZE) - return { - "image": base64.b64encode(buf.getvalue()).decode("ascii"), + 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, "format": "jpeg", - "width": image.width, - "height": image.height, + "image": None, + "width": 0, + "height": 0, } - - -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) + 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 _image_dir_title(relpath: str) -> str: - status = _get_status(relpath) if relpath else "none" - if relpath and status != "none": - return f"ImageDir : {STATUS_ICON[status]} {relpath}" - if relpath: - return f"ImageDir : {relpath}" - return "ImageDir Preview" + status = _get_status(relpath) + return f"ImageDir : {STATUS_ICON[status]} {relpath}" if status != "none" else f"ImageDir : {relpath}" -def _safe_search_root(value) -> Path | None: - raw = str(value or "").strip() - if not raw: +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" + 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" + except Exception: + return Path(folder_paths.get_output_directory()).resolve(), "invalid_fallback_output" + + +def _iter_dirs_newest_first(root: Path): + root = root.resolve() + queue = [root] + while queue: + current = queue.pop(0) + yield current try: - return Path(folder_paths.get_output_directory()).resolve() + 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: - return None - try: - path = Path(raw).expanduser().resolve() - return path if path.exists() and path.is_dir() else None - except Exception: - return None + 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" - -def _find_image_dir_candidates(relpath: str, search_directory=None, limit: int = IMAGE_DIR_SCAN_LIMIT) -> list[Path]: - root = _safe_search_root(search_directory) - if root is None: - return [] - found = [] - try: - for dirpath, _dirnames, filenames in os.walk(root): - for filename in filenames: - p = Path(dirpath) / filename - if p.suffix.lower() not in ALLOWED_IMAGE_SUFFIXES: - continue - if not _image_matches_checkpoint(p, relpath): - continue + 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: - mtime = p.stat().st_mtime + if resolved != root and root not in resolved.parents: + continue 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]] + 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 -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_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_dir_preview(node_id, relpath, search_directory=None, tab_id="", send_progress=True, max_preview_images=IMAGE_DIR_DEFAULT_MAX_IMAGES): +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) max_preview_images = _clamp_image_dir_max_images(max_preview_images) - def progress(value: int, total: int, message: str): + def progress(value: int, total: int, message: str, root: str = ""): if send_progress: - _send_image_dir_progress(node_id, relpath, tab_id, message, value, total, max_preview_images) + _send_image_dir_progress(node_id, relpath, message, value=value, total=total, tab_id=tab_id, root=root) progress(0, max_preview_images, "Searching preview images...") - paths = _find_image_dir_candidates(relpath, search_directory) - selected_paths = paths[:max_preview_images] - progress(len(selected_paths), max_preview_images, f"Found preview images {len(selected_paths)}/{max_preview_images}") + 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) - 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}") + 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} - 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({ + extra = { "node_class": "ImageDirPreview", "ckpt_name_str": relpath, - "ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath), - "status": status, - "status_icon": STATUS_ICON[status], - "message": f"{len(images)} image(s) found" if images else "no preview images found", - "progress_message": "Preview ready." if sheet is not None else "no preview images found", + "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}", "progress_value": max_preview_images, "progress_total": max_preview_images, - "search_directory": str(_safe_search_root(search_directory) or ""), - "max_preview_images": max_preview_images, - "source_paths": [str(p) for p in selected_paths], - }) + } + extra.update(meta) 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. - 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. +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. """ - 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 + 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 -def _filter_match_count(active_filter: list[str]) -> int: - all_checkpoints = _get_checkpoint_list() - if not active_filter: - return len(all_checkpoints) - return sum(1 for ckpt in all_checkpoints if _get_status(ckpt) in active_filter) +def _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 _checkpoint_total_count() -> int: - return len(_get_checkpoint_list()) +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 _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 _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 _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" +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 "" - prefix = "Cycler" - # Filter state is shown by the filter buttons and the status panel. Do not - # duplicate it in the title. + 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: - prefix += " [fallback all]" - if source == "local_list" and local_count > 0: - prefix += f" [local:{local_count}]" - body = f"{icon} {ckpt_name}".strip() if ckpt_name else "(none)" - if source == "local_list": - return f"{prefix} : {body}" - return f"{prefix} : {body} ({hold_index}/{change_every})" + return f"Cycler{local_label} : ⚠ {ckpt_name} ({hold_index}/{change_every} Filter:0→All)" + return f"Cycler{local_label} : {display}" -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: - items = list(local_items or []) - lines.append(f"Local List: {local_count} item(s)") - lines.extend(_build_local_list_lines(items)) - if queue: - lines.append(f"Queue: {len(queue)} item(s)") - return "\n".join(lines) - - -def _build_cycler_idle_status_text(action: str, state: dict, detail: str = "") -> str: - """Build a status panel for UI-only operations such as Push/Clear Local List. - - This is a synthesized display: last_execution_snapshot provides the current - job, runtime_controls provide the live filter/mode settings, and Local List - comes from runtime_state. - """ - controls = _runtime_controls_from_state(state) - snap = _last_execution_snapshot(state) - ckpt_name = snap.get("ckpt_name", "") or "" - status = _get_status(ckpt_name) if ckpt_name else "none" - active_filter = list(controls.get("active_filter", [])) - local_items = _valid_local_list(state.get("local_list", [])) - local_count = len(local_items) - mode = controls.get("mode", "increment") - current = f"Current: {STATUS_ICON[status]} {ckpt_name}" if status != "none" and ckpt_name else (f"Current: {ckpt_name}" if ckpt_name else "Current: (not executed yet)") - use_local = bool(controls.get("use_local_list", True)) - local_job_active = snap.get("source") == "local_list" - if local_job_active: - mode_line = "Mode: local list" - else: - hold_index = max(0, _parse_saved_int(snap.get("hold_index"), 0)) - change_every = _valid_change_every(snap.get("change_every_used", controls.get("change_every", 1))) - mode_line = f"Mode: {snap.get('mode_used', mode)} Hold: {hold_index}/{change_every}" if ckpt_name else f"Mode: {mode}" - lines = [ - current, - mode_line, - f"Filter: {_filter_display(active_filter)} Matches: {_filter_match_count(active_filter)} / {_checkpoint_total_count()}", - ] - if use_local: - lines.append(f"Local List: {local_count} item(s)") - lines.extend(_build_local_list_lines(local_items)) - if 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() +def _filter_label(active_filter: list[str]) -> str: 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 + return "All" + icons = _status_icons_for_filter(active_filter) + return icons or "All" -class CheckpointListSelector: - @classmethod - def INPUT_TYPES(cls): - return { - "required": {"checkpoint": ("STRING", {"default": ""})}, - "hidden": {"unique_id": "UNIQUE_ID"}, - } +def _source_label(source: str, active_filter: list[str], mode: str) -> str: + if source == "local_list": + return "Local List" + if mode == "fixed": + return "Fixed" + return "Filtered Checkpoints" if active_filter else "Normal Cycle" - RETURN_TYPES = (_get_checkpoint_list() or [""], "STRING", "STRING") - RETURN_NAMES = ("ckpt_name", "ckpt_name_str", "ckpt_name_safe") - FUNCTION = "select" - CATEGORY = "checkpoint/handpicker" - 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") +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) + 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 []) + 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)) + + +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 + + 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 _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) class CheckpointNameCycler: @classmethod def INPUT_TYPES(cls): - values = _get_checkpoint_list() or [""] + checkpoints = _get_checkpoint_list() return { "required": { - "start_checkpoint": (values,), + "start_checkpoint": (checkpoints if checkpoints else [""],), "mode": (["fixed", "increment", "randomize", "shuffle_once"], {"default": "increment"}), "change_every": ("INT", {"default": 1, "min": 1, "max": 999999}), }, @@ -1399,296 +1528,227 @@ 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() or [""], "STRING", "STRING") + RETURN_TYPES = (_get_checkpoint_list() if _get_checkpoint_list() else [""], "STRING", "STRING") RETURN_NAMES = ("ckpt_name", "ckpt_name_str", "ckpt_name_safe") FUNCTION = "cycle" CATEGORY = "checkpoint/handpicker" @classmethod - def IS_CHANGED(cls, start_checkpoint, mode="increment", change_every=1, hps_tab_id="", hps_filter_statuses="", hps_use_local_list="", hps_settings_revision="0", unique_id=None): - return float("nan") + def IS_CHANGED(cls, **kwargs): + return time.time_ns() - 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): + 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__" tab_id = _clean_tab_id(hps_tab_id) - node_id = str(unique_id or "") - key = _state_key(tab_id, node_id) - state = _get_cycler_state(key) + node_key = _state_key(tab_id, node_id) + state = _get_cycler_state(node_key) - prompt_mode = _valid_mode(mode) - prompt_change_every = _valid_change_every(change_every) - prompt_filter = _normalize_status_list(hps_filter_statuses) - prompt_use_local_list = _parse_saved_bool(hps_use_local_list, bool(_runtime_controls_from_state(state).get("use_local_list", True))) - prompt_start_checkpoint = _normalize_relpath(start_checkpoint) - prompt_revision = _parse_saved_int(hps_settings_revision, _runtime_controls_from_state(state).get("settings_revision", 0)) - - controls = _runtime_controls_from_state(state) - current_revision = _parse_saved_int(controls.get("settings_revision"), 0) - # First execution can seed runtime_controls from prompt arguments. After - # that, prompt JSON is treated as a stale fallback unless its revision is - # newer than the backend runtime state. - if not state.get("runtime_controls_initialized") or prompt_revision > current_revision: - controls = _set_runtime_controls( - state, - mode=prompt_mode, - change_every=prompt_change_every, - start_checkpoint=prompt_start_checkpoint, - active_filter=prompt_filter, - use_local_list=prompt_use_local_list, - settings_revision=max(prompt_revision, current_revision), - mark_initialized=True, - ) - else: - controls = _runtime_controls_from_state(state) - - mode = _valid_mode(controls.get("mode", prompt_mode)) - change_every = _valid_change_every(controls.get("change_every", prompt_change_every)) - active_filter = _normalize_status_list(controls.get("active_filter", prompt_filter)) - use_local_list = bool(controls.get("use_local_list", prompt_use_local_list)) - start_checkpoint_used = _normalize_relpath(controls.get("start_checkpoint") or prompt_start_checkpoint) + 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 all_checkpoints = _get_checkpoint_list() if not all_checkpoints: - title = "Cycler : no checkpoints" - status_text = "Current: (no checkpoints)" - state["last_title"] = title - state["last_status_text"] = status_text - _send_cycler_update(node_id, title, status_text, tab_id=tab_id) return ("", "", "checkpoint") - if start_checkpoint_used not in all_checkpoints: - # Backend fallback only. The frontend must still keep the visible - # combo value valid so ComfyUI's queue-time validation does not fail. - start_checkpoint_used = all_checkpoints[0] - controls = _set_runtime_controls(state, start_checkpoint=start_checkpoint_used, mark_initialized=bool(state.get("runtime_controls_initialized", False))) + 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) - 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) + 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 - 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)) + # 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)) status_text = _build_cycler_status_text( "local_list", ckpt_name, active_filter, False, - [], - 1, - change_every, - global_match_count, - len(remaining_local_items), - "local list", - local_items=remaining_local_items, - use_local_list=True, - ) - local_items = remaining_local_items - else: - repeat_count = max(0, int(state.get("repeat_count", 0))) - last_ckpt = _normalize_relpath(state.get("last_normal_ckpt_name") or state.get("last_ckpt_name", "")) - can_hold_last = last_ckpt in candidate_set and repeat_count > 0 and repeat_count < change_every - - def find_next_increment(current_index: int): - if not candidates: - return all_checkpoints[0], 0 - n = len(all_checkpoints) - start = max(0, min(n - 1, current_index)) - for offset in range(n): - idx = (start + offset) % n - ckpt = all_checkpoints[idx] - if ckpt in candidate_set: - return ckpt, idx - ckpt = candidates[0] - return ckpt, all_checkpoints.index(ckpt) - - def draw_from_shuffle_deck(): - # v8g: shuffle_once is a global shuffled increment. - # The deck is built from all checkpoints, while the active filter - # is applied when scanning the deck. Non-matching checkpoints are - # skipped AND consumed, matching increment's "cursor moved on" - # semantics without exposing an extra cursor UI. - all_set = set(all_checkpoints) - deck = [x for x in state.get("shuffle_deck", []) if x in all_set] - - def refill_deck(): - fresh = list(all_checkpoints) - random.shuffle(fresh) - return fresh - - if not deck: - deck = refill_deck() - - for _pass in range(2): - while deck: - ckpt = deck.pop(0) - if ckpt in candidate_set: - state["shuffle_deck"] = deck - return ckpt - # The remaining global deck had no item for the current - # filter. Start a new global deck and scan once more. - deck = refill_deck() - - # Defensive fallback. This should be unreachable when candidates - # is non-empty, but keeps the node alive if checkpoint state - # changes under us. - state["shuffle_deck"] = deck - return candidates[0] - - if can_hold_last: - ckpt_name = last_ckpt - selected_index = all_checkpoints.index(ckpt_name) - else: - if mode == "fixed": - requested = _normalize_relpath(start_checkpoint_used) - ckpt_name = requested if requested in candidate_set else candidates[0] - selected_index = all_checkpoints.index(ckpt_name) - elif mode == "randomize": - ckpt_name = random.choice(candidates) - selected_index = all_checkpoints.index(ckpt_name) - elif mode == "shuffle_once": - ckpt_name = draw_from_shuffle_deck() - selected_index = all_checkpoints.index(ckpt_name) - else: - ckpt_name, selected_index = find_next_increment(int(state.get("current_index", start_index()))) - - hold_index = repeat_count + 1 if can_hold_last else 1 - if hold_index >= change_every: - state["repeat_count"] = 0 - if mode == "increment": - next_index = selected_index + 1 - if next_index >= len(all_checkpoints): - next_index = 0 - state["cycle_count"] = int(state.get("cycle_count", 0)) + 1 - state["current_index"] = next_index - else: - state["current_index"] = selected_index - else: - state["repeat_count"] = hold_index - state["current_index"] = selected_index - - state["last_normal_ckpt_name"] = ckpt_name - title = _build_cycler_title("cycle", ckpt_name, hold_index, change_every, active_filter, fallback_all and mode != "fixed", len(local_items) if use_local_list else 0) - status_text = _build_cycler_status_text( - "cycle", - ckpt_name, - active_filter, - fallback_all and mode != "fixed", - [], + local_list, hold_index, - change_every, - global_match_count, - len(local_items), + 1, + match_count, + len(all_checkpoints), mode, - local_items=local_items, - use_local_list=use_local_list, + None, ) - if mode == "shuffle_once": - status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" + 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"] = [] - 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 + def is_match(ckpt: str) -> bool: + return _matches_filter(ckpt, active_filter, fallback_all) + + 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 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 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 + + 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)) + ) + + 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))) + + 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 + + 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, + ) state["last_ckpt_name"] = ckpt_name - state["last_source"] = source + state["last_normal_ckpt_name"] = ckpt_name state["last_title"] = title - state["last_status_text"] = status_text state["last_hold_index"] = hold_index - # Mirrors of runtime controls, not queue-prompt arguments. state["last_change_every"] = change_every state["last_mode"] = mode - state["last_start_checkpoint"] = start_checkpoint_used - - exec_state = _store_tab_execution_state(tab_id, { - "node": int(unique_id) if unique_id is not None else None, - "node_class": "CheckpointNameCycler", - "ckpt_name_str": ckpt_name, - "ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name), - "status": status, - "status_icon": STATUS_ICON[status], - "source": source, - "mode": "local_list" if use_local_source else mode, - "mode_used": "local_list" if use_local_source else mode, - "base_mode": mode, - "change_every": change_every, - "change_every_used": change_every, - "filter_used": list(active_filter), - "fallback_all": False if use_local_source else (fallback_all and mode != "fixed"), - "matches_at_resolve": global_match_count, - "local_list_count": len(local_items), - "hold_index": hold_index, - "title": title, - "status_text": status_text, - "last_execution_snapshot": snapshot, - }) - state["execution_revision"] = exec_state.get("execution_revision", 0) - snapshot["execution_revision"] = state["execution_revision"] - state["last_execution_snapshot"] = snapshot + state["last_source"] = source _send_cycler_update( node_id, title, status_text, tab_id=tab_id, ckpt_name_str=ckpt_name, - ckpt_name_safe=_ckpt_name_safe_from_relpath(ckpt_name), - status=status, - status_icon=STATUS_ICON[status], source=source, - mode="local_list" if use_local_source else mode, - base_mode=mode, + 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)) @@ -1697,9 +1757,15 @@ 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 = () @@ -1715,15 +1781,12 @@ 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 () @@ -1752,22 +1815,7 @@ class EphemeralPreview: pil_images = _tensor_batch_to_pil(image) sheet, meta = _build_contact_sheet(pil_images) meta["node_class"] = "EphemeralPreview" - exec_state = _get_tab_execution_state(hps_tab_id) - ckpt_name = exec_state.get("ckpt_name_str", "") - status = exec_state.get("status", _get_status(ckpt_name) if ckpt_name else "none") - if ckpt_name: - meta.update({ - "ckpt_name_str": ckpt_name, - "ckpt_name_safe": _ckpt_name_safe_from_relpath(ckpt_name), - "status": status, - "status_icon": STATUS_ICON.get(status, "—"), - "execution_revision": exec_state.get("execution_revision", 0), - }) - title = f"Preview : {STATUS_ICON[status]} {ckpt_name}" if status != "none" else f"Preview : {ckpt_name}" - _store_preview_state(hps_tab_id, unique_id, ckpt_name, status, execution_revision=exec_state.get("execution_revision", 0)) - else: - title = "Ephemeral Preview" - _send_preview(unique_id, title, sheet, meta, tab_id=hps_tab_id) + _send_preview(unique_id, "Ephemeral Preview", sheet, meta, tab_id=hps_tab_id) except Exception: logger.exception("EphemeralPreview failed") return () @@ -1777,13 +1825,17 @@ 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 = () @@ -1798,8 +1850,6 @@ 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 () @@ -1807,38 +1857,6 @@ 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() @@ -1859,7 +1877,10 @@ 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) @@ -1871,6 +1892,7 @@ 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, }) @@ -1880,21 +1902,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" + status = "none" # toggle off 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) @@ -1902,85 +1924,117 @@ async def tagger_set_status(request): _append_delete_record({ "version": 1, "type": "reserve", - "id": f"{int(time.time())}_{uuid.uuid4().hex}", + "id": f"{int(time.time())}_{uuid.uuid4().hex[:12]}", "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() - else: - # Cancel active delete reservation for this checkpoint if it existed. + elif current == "delete" and status != "delete": active = _active_delete_records().get(relpath) - if active and active.get("id"): + if active: _append_delete_record({ "version": 1, "type": "cancel", "id": active.get("id"), "ckpt_name_str": relpath, "cancelled_at": _now_iso(), - "reason": f"status_changed_to_{status}", }) _write_delete_script() - _store_tagger_state(tab_id, node_id, relpath, status) - _send_status_changed(relpath, tab_id=tab_id, node_id=node_id) + 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) + _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, - "ckpt_name_safe": _ckpt_name_safe_from_relpath(relpath), + "taggers": len(tagger_ids), + "previews": preview_count, "status": status, - "status_icon": STATUS_ICON[status], - "summary": _delete_status_summary(), }) -@routes.post(f"/{EXTENSION_PREFIX}/cycler/set_runtime_controls") -async def cycler_set_runtime_controls(request): - data = await request.json() - node_id = str(data.get("node_id", "")) - tab_id = _clean_tab_id(data.get("tab_id", "")) - state = _get_cycler_state(_state_key(tab_id, node_id)) - revision = _parse_saved_int(data.get("settings_revision"), _runtime_controls_from_state(state).get("settings_revision", 0)) - accepted, revision = _should_accept_settings_update(state, revision) - if accepted: - _set_runtime_controls( - state, - mode=data.get("mode") if "mode" in data else None, - change_every=data.get("change_every") if "change_every" in data else None, - start_checkpoint=data.get("start_checkpoint", "") if "start_checkpoint" in data else None, - active_filter=data.get("active_filter") if "active_filter" in data else None, - use_local_list=bool(data.get("use_local_list")) if "use_local_list" in data else None, - settings_revision=revision, - mark_initialized=True, - ) - _send_cycler_state_update(node_id, state, tab_id=tab_id) - payload = _cycler_state_payload(state) - payload.update({"ok": True, "accepted": accepted, "node_id": node_id}) - return web.json_response(payload) - - @routes.post(f"/{EXTENSION_PREFIX}/cycler/set_flags") async def cycler_set_flags(request): data = await request.json() node_id = str(data.get("node_id", "")) tab_id = _clean_tab_id(data.get("tab_id", "")) state = _get_cycler_state(_state_key(tab_id, node_id)) - revision = _parse_saved_int(data.get("settings_revision"), _parse_saved_int(state.get("settings_revision"), 0)) - accepted, revision = _should_accept_settings_update(state, revision) - if accepted: - _set_runtime_controls( - state, - mode=data.get("mode") if "mode" in data else None, - change_every=data.get("change_every") if "change_every" in data else None, - start_checkpoint=data.get("start_checkpoint", "") if "start_checkpoint" in data else None, - use_local_list=bool(data.get("use_local_list")) if "use_local_list" in data else None, - settings_revision=revision, - mark_initialized=True, - ) + 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 _send_cycler_state_update(node_id, state, tab_id=tab_id) - payload = _cycler_state_payload(state) - payload.update({"ok": True, "accepted": accepted, "use_local_list": state["use_local_list"]}) - return web.json_response(payload) + return web.json_response({"ok": True, "use_local_list": state["use_local_list"]}) @routes.post(f"/{EXTENSION_PREFIX}/cycler/set_filter") @@ -1988,25 +2042,16 @@ async def cycler_set_filter(request): data = await request.json() node_id = str(data.get("node_id", "")) tab_id = _clean_tab_id(data.get("tab_id", "")) - statuses = _normalize_status_list(data.get("statuses", [])) + statuses = [s for s in data.get("statuses", []) if s in STATUS_VALUES] state = _get_cycler_state(_state_key(tab_id, node_id)) - revision = _parse_saved_int(data.get("settings_revision"), _parse_saved_int(state.get("settings_revision"), 0)) - accepted, revision = _should_accept_settings_update(state, revision) - if accepted: - _set_runtime_controls( - state, - mode=data.get("mode") if "mode" in data else None, - change_every=data.get("change_every") if "change_every" in data else None, - start_checkpoint=data.get("start_checkpoint", "") if "start_checkpoint" in data else None, - active_filter=statuses.copy(), - settings_revision=revision, - mark_initialized=True, - ) + 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 _send_cycler_state_update(node_id, state, tab_id=tab_id) - logger.info("[CheckpointHandpickerSuite] Updated cycler filter for tab %s node %s: %s accepted=%s", tab_id, node_id, statuses or ["All"], accepted) - payload = _cycler_state_payload(state) - payload.update({"ok": True, "accepted": accepted, "node_id": node_id, "statuses": _runtime_controls_from_state(state).get("active_filter", [])}) - return web.json_response(payload) + 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}) @routes.post(f"/{EXTENSION_PREFIX}/cycler/local_list_append") @@ -2018,21 +2063,15 @@ 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): - 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) + state.setdefault("local_list", []).append(relpath) + state["override_queue"] = state["local_list"] updated += 1 _send_cycler_state_update(node_id, state, tab_id=tab_id) - payload = _cycler_state_payload(state) - payload.update({"node_id": node_id}) - states.append(payload) logger.info("[CheckpointHandpickerSuite] Pushed checkpoint to %s Local List(s) in tab %s: %s", updated, tab_id, relpath) - return web.json_response({"ok": True, "updated": updated, "states": states}) + return web.json_response({"ok": True, "updated": updated}) @routes.post(f"/{EXTENSION_PREFIX}/cycler/clear_local_list") @@ -2044,75 +2083,9 @@ 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) - 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, - }) + return web.json_response({"ok": True, "cleared": cleared}) NODE_CLASS_MAPPINGS = { diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 5313533..6f11115 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -1,7 +1,6 @@ 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"; @@ -58,10 +57,8 @@ 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); @@ -161,6 +158,9 @@ 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,6 +182,7 @@ 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 }; @@ -334,6 +335,7 @@ function installCheckpointRefreshFuturePatch(nodeType, nodeData) { }; } + function isNodeClass(node, className) { return node && (node.type === className || node.comfyClass === className); } @@ -366,6 +368,9 @@ 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; @@ -376,8 +381,6 @@ 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)) { @@ -401,90 +404,6 @@ 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; @@ -503,32 +422,6 @@ 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); } @@ -551,7 +444,6 @@ 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) { @@ -658,6 +550,9 @@ 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]; } @@ -707,47 +602,6 @@ 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); @@ -757,13 +611,6 @@ 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; }; @@ -774,10 +621,10 @@ function setupPreviewNode(nodeType) { if (this.flags?.collapsed) return; const img = this.__hpsPreview; const isImageDir = isNodeClass(this, "ImageDirPreview"); - const top = isImageDir ? 72 : 30; + const top = isImageDir ? 92 : 30; const margin = 8; - const messageX = Math.min(140, Math.max(margin, this.size[0] - 80)); - const captionY = isImageDir ? 36 : 20; + const messageX = isImageDir ? margin : Math.min(140, Math.max(margin, this.size[0] - 80)); + const captionY = isImageDir ? 76 : 20; const messageW = Math.max(1, this.size[0] - messageX - margin); const w = Math.max(1, this.size[0] - margin * 2); const h = Math.max(1, this.size[1] - top - margin); @@ -786,26 +633,13 @@ function setupPreviewNode(nodeType) { const st = this.__hpsPreviewState || {}; const isWarning = st.status && !["ready", "loading"].includes(st.status); if (isImageDir) { - ctx.fillStyle = isWarning ? "rgba(255,180,80,0.18)" : "rgba(0,0,0,0.18)"; - ctx.fillRect(messageX - 4, captionY - 13, messageW + 4, 18); + ctx.fillStyle = isWarning ? "rgba(255,180,80,0.18)" : "rgba(0,0,0,0.22)"; + ctx.fillRect(messageX - 4, captionY - 14, messageW + 8, 20); } ctx.fillStyle = isWarning ? "#FFD28A" : "#ddd"; 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 = 0; - 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); @@ -843,13 +677,11 @@ api.addEventListener(PREVIEW_EVENT, ({ detail }) => { const isEphemeral = isNodeClass(node, "EphemeralPreview"); if (isEphemeral && detail.image) { const execution = getExecutionState(); - 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); + 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); } else { node.title = "Ephemeral Preview"; } @@ -861,6 +693,7 @@ 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; @@ -890,44 +723,21 @@ 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: 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 }, + 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 }, 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 = selectorTitleText(node, value); + node.title = value ? `Selector : ${value}` : "Checkpoint List Selector"; } function selectorStatusText(result, prefix = "") { const s = result?.summary || {}; @@ -998,6 +808,7 @@ 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); @@ -1063,7 +874,7 @@ function selectorActionMode(node) { } function selectorActionLabel(node) { - return selectorActionMode(node) === "sync" ? "🎯 Sync Checkpoint" : "🏹 Push Local"; + return selectorActionMode(node) === "sync" ? "🎯 Sync Checkpoint" : "🏹 Push to Local List"; } async function loadSelector(node, mode = "list") { @@ -1090,8 +901,6 @@ 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) { @@ -1125,13 +934,6 @@ 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"); @@ -1156,20 +958,31 @@ 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) => ({ - node_id: n.id, - search_directory: imageDirSearchDirectory(n), - max_preview_images: imageDirMaxPreviewImages(n), - })), + 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), + }; + }), })), }); const result = await response.json(); - node.__hpsStatus = result.ok - ? `synced : ${selected} (${targets.taggers.length} tagger, ${targets.previews.length} preview)` - : (result.error || "Sync failed"); + if (result.ok) { + node.__hpsStatus = `synced : ${selected}`; + } else { + node.__hpsStatus = 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); } @@ -1188,12 +1001,8 @@ 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, }; } @@ -1203,7 +1012,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) { @@ -1218,13 +1027,10 @@ 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); @@ -1232,18 +1038,12 @@ 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?.(); @@ -1256,274 +1056,178 @@ 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) || hit(local, r.up) || hit(local, r.down)) return "pointer"; + 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"; 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"; - 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"; + 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" : ""; } 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, 520, 540); - installTabIdSupport(nodeType); + installMinSize(nodeType, 560, 520); installCursorCapture(); - installSelectorWheelCapture(); - const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; - ensureSize(this, 520, 540); + ensureSize(this, 560, 520); + installSelectorWheelCapture(); 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)) return; + if (hpsNodeCollapsed(this)) { + this.__hpsScrollbarDragging = false; + this.__hpsScrollbarDragOffset = 0; + 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, "▲", true); - drawButton(ctx, r.down, "▼", true); - - ctx.save(); - ctx.fillStyle = "#ccc"; + drawButton(ctx, r.up, "▲", selectorItems(this).length > SELECTOR_VISIBLE_ROWS); + drawButton(ctx, r.down, "▼", selectorItems(this).length > SELECTOR_VISIBLE_ROWS); + ctx.fillStyle = "#ddd"; ctx.font = "12px sans-serif"; - 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; + 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); } - 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) { - 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 = 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; - } + 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 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]; + }; + 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)) { + this.__hpsScrollbarDragging = true; + this.__hpsScrollbarDragOffset = sbHitPos[1] - sb.thumb.y; + } else { + setSelectorScrollFromScrollbarY(this, sbHitPos[1], sb.thumb.h); + } + 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]; 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 (event, pos, canvas) { - if (this.__hpsScrollbarDragging && event?.buttons === 0) { + nodeType.prototype.onMouseMove = function (e, pos) { + if (hpsNodeCollapsed(this)) { this.__hpsScrollbarDragging = false; - app.graph?.setDirtyCanvas?.(true, true); - return true; + this.__hpsScrollbarDragOffset = 0; + return origMouseMove ? origMouseMove.apply(this, arguments) : false; } - 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); + const local = selectorLocalFromEventOrPos(this, e, pos); + if (sb && local) setSelectorScrollFromScrollbarY(this, local[1], sb.thumb.h, this.__hpsScrollbarDragOffset || sb.thumb.h / 2); return true; } - return origMouseMove ? origMouseMove.apply(this, arguments) : false; }; - const origMouseUp = nodeType.prototype.onMouseUp; - nodeType.prototype.onMouseUp = function (event, pos, canvas) { + nodeType.prototype.onMouseUp = function () { if (this.__hpsScrollbarDragging) { - event?.preventDefault?.(); - event?.stopPropagation?.(); this.__hpsScrollbarDragging = false; - app.graph?.setDirtyCanvas?.(true, true); + this.__hpsScrollbarDragOffset = 0; + app.graph.setDirtyCanvas(true, true); return true; } - return origMouseUp ? origMouseUp.apply(this, arguments) : false; }; - - 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); + 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); return true; } - return origMouseWheel ? origMouseWheel.apply(this, arguments) : false; + return origWheel ? origWheel.apply(this, arguments) : false; }; } // ---------- Tagger ---------- -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 ""; -} - +const TAGGER_STATUSES = ["favorite", "nice", "keep", "delete"]; function currentTaggerPath(node) { - return node.__hpsTaggerPath || linkedCheckpointInputValue(node, "ckpt_name_str") || ""; + return node.__hpsTaggerPath || getWidget(node, "ckpt_name_str")?.value || ""; } function taggerButtons(node) { - // Keep controls right-aligned and away from LiteGraph input pins/labels. - const buttonW = 76; + const buttonW = 72; 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) => ({ + 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: 3, + y: 5, w: buttonW, - h: 26, + h: 24, })); } + function taggerDeleteEnabled(node) { const current = node.__hpsTaggerStatus || "none"; return current === "none" || current === "delete"; @@ -1540,7 +1244,7 @@ async function setTaggerStatus(node, status) { const result = await response.json(); if (result.ok) { node.__hpsTaggerStatus = result.status; - node.__hpsTaggerMessage = taggerCurrentMessage(result.status); + node.__hpsTaggerMessage = result.status === "none" ? "Current: — none" : `Current: ${STATUS_ICON[result.status]} ${STATUS_LABEL[result.status]}`; node.title = result.status === "none" ? `Tagger : ${ckpt}` : `Tagger : ${STATUS_ICON[result.status]} ${ckpt}`; } else { node.__hpsTaggerMessage = result.error || "Failed"; @@ -1558,20 +1262,13 @@ function taggerCursorAt(node, local) { } function setupTaggerNode(nodeType) { - installMinSize(nodeType, 450, 104); + installMinSize(nodeType, 450, 100); installTabIdSupport(nodeType); installCursorCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; - 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); + ensureSize(this, 450, 100); return r; }; const origDraw = nodeType.prototype.onDrawBackground; @@ -1597,11 +1294,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, 50); - const msg = this.__hpsTaggerMessage || taggerCurrentMessage(current); - ctx.fillStyle = current === "none" ? "#aaa" : "#ddd"; - ctx.fillText(msg, 8, 70); - if (current !== "none" && current !== "delete") { + 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.fillStyle = current === "none" ? "#ccc" : "#ddd"; + ctx.fillText(msg, 8, 72); + if (this.size[1] >= 110 && current !== "none" && current !== "delete") { ctx.fillStyle = "#aaa"; ctx.fillText("Delete is available only from none.", 8, 90); } @@ -1625,11 +1322,10 @@ api.addEventListener(TAGGER_EVENT, ({ detail }) => { if (!node) return; node.__hpsTaggerPath = detail.ckpt_name_str; node.__hpsTaggerStatus = detail.status; - node.__hpsTaggerMessage = taggerCurrentMessage(detail.status || "none"); + node.__hpsTaggerMessage = detail.status === "none" ? "Current: — none" : `Current: ${STATUS_ICON[detail.status]} ${STATUS_LABEL[detail.status]}`; if (detail.title) node.title = detail.title; app.graph.setDirtyCanvas(true, true); }); - let selectorGlobalRefreshTimer = null; function scheduleSelectorGlobalRefresh() { clearTimeout(selectorGlobalRefreshTimer); @@ -1641,46 +1337,47 @@ function scheduleSelectorGlobalRefresh() { } api.addEventListener(STATUS_CHANGED_EVENT, ({ detail }) => { - if (!isForThisTab(detail)) return; - for (const node of app.graph?._nodes || []) { - 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 (PREVIEW_CLASSES.has(node.type || node.comfyClass) && node.__hpsPreviewCkptName === detail.ckpt_name_str) { - node.__hpsPreviewStatus = detail.status || "none"; - setPreviewTitleFromCheckpoint(node, detail.ckpt_name_str, detail.status || "none"); - } - 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); - } - if (isNodeClass(node, CYCLER_CLASS) && node.__hpsCyclerCkptName === detail.ckpt_name_str) { - node.__hpsCyclerStatusValue = detail.status || "none"; - const oldTitle = String(node.title || ""); - const idx = oldTitle.indexOf(detail.ckpt_name_str); - if (idx >= 0) { - node.title = oldTitle.slice(0, idx) + titleDisplayForCheckpoint(detail.ckpt_name_str, detail.status || "none") + oldTitle.slice(idx + detail.ckpt_name_str.length); - } - } - } + if (detail?.scope !== "global") return; scheduleSelectorGlobalRefresh(); - app.graph.setDirtyCanvas(true, true); + + 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(); + } + + 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, "EphemeralPreview") && 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); + } + 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); + } + // Cycler title/status are rebuilt from backend state via CYCLER_EVENT. + // Do not patch the existing Cycler title here; stale icons can accumulate. + } }); // ---------- 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: 150, h: 24 }, - clearLocalList: { x: 164, y: 4, w: 122, h: 24 }, + localListToggle: { x: 8, y: 4, w: 122, h: 24 }, + clearLocalList: { x: 136, 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) }, }; @@ -1690,89 +1387,26 @@ function cyclerActiveFilter(node) { } async function pushCyclerFlags(node) { syncCyclerSettingsWidgets(node); - const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/set_flags`, { + 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, - ...getCyclerRuntimeControls(node), - })), + body: JSON.stringify(tabPayload({ node_id: node.id, use_local_list: !!node.__hpsUseLocalList, settings_revision: node.__hpsSettingsRevision ?? 0 })), }); - const result = await response.json(); - if (result?.ok) applyCyclerStatePayload(node, result); } async function pushCyclerFilter(node) { syncCyclerSettingsWidgets(node); - const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/set_filter`, { + 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, - ...getCyclerRuntimeControls(node), - })), + body: JSON.stringify(tabPayload({ node_id: node.id, statuses: cyclerActiveFilter(node), settings_revision: node.__hpsSettingsRevision ?? 0 })), }); - 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) { - const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/clear_local_list`, { + 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 ""; @@ -1792,48 +1426,37 @@ 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); - installCyclerRuntimeControlHooks(this); - setTimeout(() => restoreNodeStateFromBackend(this, CYCLER_CLASS), 0); + setTimeout(() => { restoreCyclerSettingsFromWidgets(this); pushCyclerFlags(this); pushCyclerFilter(this); }, 0); return r; }; const origConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const r = origConfigure ? origConfigure.apply(this, arguments) : undefined; restoreCyclerSettingsFromWidgets(this); - installCyclerRuntimeControlHooks(this); - setTimeout(() => restoreNodeStateFromBackend(this, CYCLER_CLASS), 0); + setTimeout(() => { restoreCyclerSettingsFromWidgets(this); pushCyclerFlags(this); pushCyclerFilter(this); }, 0); return r; }; const origDraw = nodeType.prototype.onDrawBackground; nodeType.prototype.onDrawBackground = function (ctx) { if (origDraw) origDraw.apply(this, arguments); ensureHiddenTabIdWidget(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; + syncCyclerSettingsWidgets(this); 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); - 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); + 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(); 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"); @@ -1877,7 +1500,13 @@ function setupCyclerNode(nodeType) { api.addEventListener(CYCLER_EVENT, ({ detail }) => { const node = nodeFromEvent(detail, CYCLER_CLASS); if (!node) return; - applyCyclerStatePayload(node, detail); + 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; app.graph.setDirtyCanvas(true, true); }); 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 12/16] =?UTF-8?q?=E7=A0=B4=E6=90=8D=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E3=82=92=E4=BF=AE=E5=BE=A9=E3=81=97=E3=81=A6?= =?UTF-8?q?=E4=BF=AE=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); }); From 439ce8416b9b5f80495db33c015cc17d64585309 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 01:29:32 +0900 Subject: [PATCH 13/16] =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=AB?= =?UTF-8?q?=E3=83=AA=E3=82=B9=E3=83=88=E3=81=AE=E8=A1=A8=E7=A4=BA=E3=82=92?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=81=97=E3=80=81=E3=83=97=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E8=A8=AD=E5=AE=9A=E3=81=AE=E3=83=9E=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=B3=E3=81=A8=E4=BD=8D=E7=BD=AE=E3=82=92=E8=AA=BF?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 4 ++-- web/checkpoint_handpicker_suite.js | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index e5cfeef..8ba0b88 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -1298,10 +1298,10 @@ def _checkpoint_total_count() -> int: def _build_local_list_lines(local_items: list[str], max_items: int = 20) -> list[str]: shown = [] - for item in list(local_items[:max_items]): + for index, item in enumerate(list(local_items[:max_items]), start=1): status = _get_status(item) icon = STATUS_ICON[status] if status != "none" else " " - shown.append(f"{icon} {item}") + shown.append(f"{index}. {icon} {item}") if len(local_items) > max_items: shown.append(f"... and {len(local_items) - max_items} more") return shown diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 21fae55..cd310ef 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -643,7 +643,7 @@ function drawButton(ctx, rect, label, enabled = true, active = false, color = nu ctx.font = `${active ? "bold " : ""}12px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2); + ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2, Math.max(1, rect.w - 8)); ctx.restore(); } @@ -774,10 +774,10 @@ function setupPreviewNode(nodeType) { if (this.flags?.collapsed) return; const img = this.__hpsPreview; const isImageDir = isNodeClass(this, "ImageDirPreview"); - const top = isImageDir ? 92 : 30; + const top = isImageDir ? 122 : 30; const margin = 8; const messageX = isImageDir ? margin : Math.min(140, Math.max(margin, this.size[0] - 80)); - const captionY = isImageDir ? 76 : 20; + const captionY = isImageDir ? 108 : 20; const messageW = Math.max(1, this.size[0] - messageX - margin); const w = Math.max(1, this.size[0] - margin * 2); const h = Math.max(1, this.size[1] - top - margin); @@ -786,7 +786,7 @@ function setupPreviewNode(nodeType) { const st = this.__hpsPreviewState || {}; const isWarning = st.status && !["ready", "loading"].includes(st.status); if (isImageDir) { - ctx.fillStyle = isWarning ? "rgba(255,180,80,0.18)" : "rgba(0,0,0,0.22)"; + ctx.fillStyle = isWarning ? "rgba(255,180,80,0.18)" : "rgba(0,0,0,0.28)"; ctx.fillRect(messageX - 4, captionY - 14, messageW + 8, 20); } ctx.fillStyle = isWarning ? "#FFD28A" : "#ddd"; @@ -798,7 +798,7 @@ function setupPreviewNode(nodeType) { 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 barY = 0; const barW = Math.max(1, this.size[0] - margin * 2); const barH = 6; ctx.fillStyle = "rgba(255,255,255,0.14)"; @@ -896,7 +896,7 @@ function selectorRects(node) { const arrowW = 32; const refreshW = 102; const listOnlyW = 92; - const pushW = selectorActionMode(node) === "sync" ? 150 : 132; + const pushW = selectorActionMode(node) === "sync" ? 150 : 174; 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. @@ -1063,7 +1063,7 @@ function selectorActionMode(node) { } function selectorActionLabel(node) { - return selectorActionMode(node) === "sync" ? "🎯 Sync Checkpoint" : "🏹 Push Local"; + return selectorActionMode(node) === "sync" ? "🎯 Sync Checkpoint" : "🏹 Push to Local List"; } async function loadSelector(node, mode = "list") { From 1070b11d6a0ba7d60e248b5f83c67b95b5ba5842 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 01:44:09 +0900 Subject: [PATCH 14/16] =?UTF-8?q?=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E8=A8=AD=E5=AE=9A=E3=81=AE=E4=BD=8D=E7=BD=AE=E3=81=A8?= =?UTF-8?q?=E3=82=B5=E3=82=A4=E3=82=BA=E3=82=92=E8=AA=BF=E6=95=B4=E3=81=97?= =?UTF-8?q?=E3=80=81=E8=AD=A6=E5=91=8A=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=81=AE=E8=A1=A8=E7=A4=BA=E3=82=B9=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/checkpoint_handpicker_suite.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index cd310ef..ebdd206 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -774,10 +774,10 @@ function setupPreviewNode(nodeType) { if (this.flags?.collapsed) return; const img = this.__hpsPreview; const isImageDir = isNodeClass(this, "ImageDirPreview"); - const top = isImageDir ? 122 : 30; + const top = isImageDir ? 72 : 30; const margin = 8; - const messageX = isImageDir ? margin : Math.min(140, Math.max(margin, this.size[0] - 80)); - const captionY = isImageDir ? 108 : 20; + const messageX = Math.min(140, Math.max(margin, this.size[0] - 80)); + const captionY = isImageDir ? 36 : 20; const messageW = Math.max(1, this.size[0] - messageX - margin); const w = Math.max(1, this.size[0] - margin * 2); const h = Math.max(1, this.size[1] - top - margin); @@ -786,8 +786,8 @@ function setupPreviewNode(nodeType) { const st = this.__hpsPreviewState || {}; const isWarning = st.status && !["ready", "loading"].includes(st.status); if (isImageDir) { - ctx.fillStyle = isWarning ? "rgba(255,180,80,0.18)" : "rgba(0,0,0,0.28)"; - ctx.fillRect(messageX - 4, captionY - 14, messageW + 8, 20); + ctx.fillStyle = isWarning ? "rgba(255,180,80,0.18)" : "rgba(0,0,0,0.18)"; + ctx.fillRect(messageX - 4, captionY - 13, messageW + 4, 18); } ctx.fillStyle = isWarning ? "#FFD28A" : "#ddd"; ctx.font = "12px sans-serif"; From d63bf581fde682990ed999e68262525ad090685f 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 02:03:28 +0900 Subject: [PATCH 15/16] =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=82=AB=E3=83=AB?= =?UTF-8?q?=E3=83=AA=E3=82=B9=E3=83=88=E3=81=AE=E8=A1=A8=E7=A4=BA=E3=82=92?= =?UTF-8?q?=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index 8ba0b88..d4724d1 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -521,9 +521,12 @@ def _cycler_state_payload(state: dict) -> dict: mode_used, local_items=valid_local_items, use_local_list=use_local, + shuffle_deck_remaining=( + len(state.get("shuffle_deck", [])) + if display_source != "local_list" and (snap.get("base_mode", mode_used) == "shuffle_once" or mode_used == "shuffle_once") + else None + ), ) - 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) @@ -1301,7 +1304,7 @@ def _build_local_list_lines(local_items: list[str], max_items: int = 20) -> list for index, item in enumerate(list(local_items[:max_items]), start=1): status = _get_status(item) icon = STATUS_ICON[status] if status != "none" else " " - shown.append(f"{index}. {icon} {item}") + shown.append(f"{index:>2}. {icon} {item}") if len(local_items) > max_items: shown.append(f"... and {len(local_items) - max_items} more") return shown @@ -1323,7 +1326,7 @@ def _build_cycler_title(source, ckpt_name, hold_index, change_every, active_filt return f"{prefix} : {body} ({hold_index}/{change_every})" -def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, queue, hold_index, change_every, match_count, local_count, mode, local_items=None, use_local_list=True) -> str: +def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, queue, hold_index, change_every, match_count, local_count, mode, local_items=None, use_local_list=True, shuffle_deck_remaining=None) -> str: status = _get_status(ckpt_name) if ckpt_name else "none" current = f"Current: {STATUS_ICON[status]} {ckpt_name}" if status != "none" and ckpt_name else (f"Current: {ckpt_name}" if ckpt_name else "Current: (none)") display_mode = "local list" if source == "local_list" else mode @@ -1334,6 +1337,8 @@ def _build_cycler_status_text(source, ckpt_name, active_filter, fallback_all, qu ] if fallback_all: lines.append("Filter fallback: all checkpoints") + if shuffle_deck_remaining is not None: + lines.append(f"Shuffle Deck Remaining: {shuffle_deck_remaining}") if use_local_list and local_count > 0: items = list(local_items or []) lines.append(f"Local List Remaining: {local_count} item(s)") @@ -1372,11 +1377,11 @@ def _build_cycler_idle_status_text(action: str, state: dict, detail: str = "") - mode_line, f"Filter: {_filter_display(active_filter)} Matches: {_filter_match_count(active_filter)} / {_checkpoint_total_count()}", ] + if (not local_job_active) and (controls.get("mode") == "shuffle_once" or snap.get("base_mode") == "shuffle_once" or snap.get("mode_used") == "shuffle_once"): + lines.append(f"Shuffle Deck Remaining: {len(state.get('shuffle_deck', []))}") if use_local and local_count > 0: lines.append(f"Local List Remaining: {local_count} item(s)") lines.extend(_build_local_list_lines(local_items)) - 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) @@ -1539,6 +1544,7 @@ class CheckpointNameCycler: "local list", local_items=remaining_local_items, use_local_list=True, + shuffle_deck_remaining=None, ) local_items = remaining_local_items else: @@ -1639,9 +1645,8 @@ class CheckpointNameCycler: mode, local_items=local_items, use_local_list=use_local_list, + shuffle_deck_remaining=len(state.get("shuffle_deck", [])) if mode == "shuffle_once" else None, ) - if mode == "shuffle_once": - status_text += f"\nShuffle Deck Remaining: {len(state.get('shuffle_deck', []))}" status = _get_status(ckpt_name) snapshot = { From 5f175fd4a3cfb14c11f8db418a117a92db3318d4 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 10:58:20 +0900 Subject: [PATCH 16/16] ComfyUI-CheckpointHandpickerSuite 0.1.3 --- RELEASE_NOTES.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3ded48e..ca03a72 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,22 +1,21 @@ -# ComfyUI-CheckpointHandpickerSuite v0.1.2 +# ComfyUI-CheckpointHandpickerSuite 0.1.3 -## v0.1.2 - Cycler filter persistence and UI collapse fixes +Stability and UI refinement release for Cycler, Selector, Tagger, and ImageDirPreview. -This is a bugfix release for Cycler workflow persistence and collapsed node UI behavior. +## Fixed -### Fixed +- Fixed a regression where Cycler filters could reset when switching tabs after the 0.1.2 filter persistence fix. +- Fixed Cycler title/status synchronization issues. +- Fixed stale Local List status icons after Tagger updates. +- Fixed Cycler title icon accumulation. +- Fixed ImageDirPreview progress display positioning. +- Restored the full `Push to Local List` selector button label. +- Confirmed delete script export and refresh behavior. -- Fixed an issue where `Checkpoint Name Cycler` status filter settings were reset after saving, loading, switching tabs, or frontend redraws. -- `Use Local List` ON/OFF state is now preserved with the workflow. -- Runtime-only state such as Local List contents, current index, repeat count, and shuffle deck is intentionally not saved. -- Fixed custom controls remaining visible when Suite nodes are collapsed. +## Improved -## Highlights +- Added numbered Local List entries in Cycler status. +- Improved Cycler status layout for current checkpoint, mode, source, filter, shuffle deck, and local list information. +- Improved Tagger `none` display readability. -- Checkpoint Name Cycler for batch checkpoint cycling -- Checkpoint List Selector for review and Local List workflows -- Checkpoint Status Tagger for favorite / nice / keep / delete-reserved tagging -- Ephemeral Preview for zero-disk preview display -- ImageDir Preview for output-folder review contact sheets -- Tab-local UI operation with shared checkpoint status -- Safe cleanup flow using delete reservation and confirmation script +This release is primarily a regression-fix and UI stabilization update. \ No newline at end of file