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] =?UTF-8?q?LocalList=E3=81=B8=E3=81=AE=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E9=A0=85=E7=9B=AE=E8=A4=87=E6=95=B0=E5=9B=9E=E7=99=BB=E9=8C=B2?= =?UTF-8?q?=E3=82=92=E8=A8=B1=E5=AE=B9=E3=81=97=E3=80=81=E3=82=B8=E3=83=A7?= =?UTF-8?q?=E3=83=96=E5=AE=9F=E8=A1=8C=E6=99=82=E3=81=AE=E9=99=A4=E5=8E=BB?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=81=AE=E4=B8=8D=E5=85=B7=E5=90=88=E3=82=92?= =?UTF-8?q?=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)";