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); });