LocalListへの同一項目複数回登録を許容し、ジョブ実行時の除去処理の不具合を修正

This commit is contained in:
るみなー先生
2026-06-06 14:51:36 +09:00
parent 3bf4afabd6
commit a5d8287f9a
2 changed files with 68 additions and 46 deletions
+37 -21
View File
@@ -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
+31 -25
View File
@@ -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)";