import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; // v9m: DirectLink active button style, one-time confirmation dialog, and ckpt_name_str-only Tagger wiring cleanup. // v9l: UI polish, DirectLink green highlight, Tagger layout fix, and Cycler Tagger activation repair. // v9k: DirectLink mode and output-side delete script folder. // v9d: Selector thumbnail popup follows row hover or current selection inside node. // 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"; const TAGGER_EVENT = "ruminar.checkpoint_handpicker_suite.tagger"; const STATUS_CHANGED_EVENT = "ruminar.checkpoint_handpicker_suite.status_changed"; const TAG_TRANSFER_EVENT = "ruminar.checkpoint_handpicker_suite.tag_transfer"; const SELECTOR_CLASS = "CheckpointListSelector"; const CYCLER_CLASS = "CheckpointNameCycler"; const TAGGER_CLASS = "CheckpointStatusTagger"; const TAG_TRANSFER_CLASS = "CheckpointTagExportImport"; const TAG_TRANSFER_DIRECTORY_WIDGET = "tag_transfer_directory"; const TAG_TRANSFER_DIRECTORY_PLACEHOLDER = "Default: output/CheckpointHandpickerSuite/"; const PREVIEW_CLASSES = new Set(["EphemeralPreview", "ImageDirPreview"]); const HPS_TAB_ID = (globalThis.crypto?.randomUUID?.() || `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`); function hpsExecutionStore() { globalThis.__hpsExecutionState = globalThis.__hpsExecutionState || {}; return globalThis.__hpsExecutionState; } function getExecutionState() { return hpsExecutionStore()[HPS_TAB_ID] || null; } function setExecutionState(detail) { if (!detail?.ckpt_name_str) return; const status = detail.status || "none"; hpsExecutionStore()[HPS_TAB_ID] = { ckpt_name_str: detail.ckpt_name_str, status, status_icon: detail.status_icon || STATUS_ICON[status] || "", cycler_node_id: detail.node ?? null, source: detail.source || "", mode: detail.mode || "", updated_at: Date.now(), }; } function titleDisplayForCheckpoint(ckptName, status = "none") { if (!ckptName) return ""; const icon = STATUS_ICON[status] || ""; return status && status !== "none" ? `${icon} ${ckptName}` : ckptName; } function setPreviewTitleFromCheckpoint(node, ckptName, status = "none") { if (!node || !ckptName) return; node.title = `Preview : ${titleDisplayForCheckpoint(ckptName, status)}`; } function patchCheckpointTitle(node, prefix, ckptName, status = "none") { if (!node || !ckptName) return; const oldTitle = String(node.title || ""); const idx = oldTitle.indexOf(ckptName); const suffix = idx >= 0 ? oldTitle.slice(idx + ckptName.length) : ""; node.title = `${prefix} : ${titleDisplayForCheckpoint(ckptName, status)}${suffix}`; } const STATUS_ORDER = ["god", "favorite", "nice", "keep", "delete", "none"]; const TAGGER_STATUS_ORDER = ["god", "favorite", "nice", "keep", "delete"]; const STATUS_ICON = { god: "๐Ÿ‘‘", favorite: "๐Ÿ’›", nice: "๐Ÿ‘", keep: "โœ”", delete: "๐Ÿ—‘", none: "โ€”" }; const STATUS_LABEL = { god: "god!", favorite: "favorite", nice: "nice", keep: "keep", delete: "delete", none: "none" }; const STATUS_CURRENT_LABEL = { god: "god!", favorite: "favorite", nice: "nice", keep: "keep", delete: "Marked for deletion", none: "none" }; function getWidget(node, name) { return node.widgets?.find((w) => w.name === name); } let lastCheckpointValues = null; function getNodeTypeName(node) { return String(node?.type ?? node?.comfyClass ?? node?.constructor?.type ?? ""); } function getNodeDataName(nodeData) { return String(nodeData?.name ?? ""); } function findWidget(node, name) { return (node?.widgets ?? []).find((widget) => widget?.name === name); } function findComboWidget(node, name) { const widget = findWidget(node, name); if (!widget) return null; if (Array.isArray(widget?.options?.values) || widget?.type === "combo") return widget; return null; } function findCheckpointWidget(node) { return findComboWidget(node, "ckpt_name") || findComboWidget(node, "checkpoint") || findComboWidget(node, "checkpoint_name"); } function findStartCheckpointWidget(node) { return findComboWidget(node, "start_checkpoint"); } function outputNames(node) { return (node?.outputs ?? []).map((output) => String(output?.name ?? "").toLowerCase()); } function hasSelectorOutputs(node) { const names = outputNames(node); return names.includes("ckpt_name") && names.includes("ckpt_name_str"); } function hasLoaderOutputs(node) { const names = outputNames(node); return names.includes("model") && names.includes("clip") && names.includes("vae"); } function isCheckpointSelectorLikeNode(node) { if (!node) return false; const typeName = getNodeTypeName(node); if (typeName.includes("CheckpointNameSelector")) return true; if (typeName === "CheckpointLoaderSimple") return true; if (hasSelectorOutputs(node) && Boolean(findCheckpointWidget(node))) return true; if (hasLoaderOutputs(node) && Boolean(findCheckpointWidget(node))) return true; return false; } function chooseCheckpointReplacement(oldValues, newValues, currentValue) { if (!Array.isArray(newValues) || newValues.length === 0) return ""; if (newValues.includes(currentValue)) return currentValue; const oldIndex = Array.isArray(oldValues) ? oldValues.indexOf(currentValue) : -1; if (oldIndex >= 0) { for (let index = oldIndex + 1; index < oldValues.length; index++) { const candidate = oldValues[index]; if (newValues.includes(candidate)) return candidate; } for (let index = oldIndex - 1; index >= 0; index--) { const candidate = oldValues[index]; if (newValues.includes(candidate)) return candidate; } return newValues[Math.min(oldIndex, newValues.length - 1)] ?? newValues[0]; } return newValues[0]; } function arraysEqual(left, right) { if (!Array.isArray(left) || !Array.isArray(right)) return false; if (left.length !== right.length) return false; for (let index = 0; index < left.length; index++) { if (left[index] !== right[index]) return false; } return true; } function isCheckpointSlotName(name) { const text = String(name ?? "").toLowerCase(); return text.includes("ckpt") || text.includes("checkpoint"); } function patchCheckpointSlotTypes(node, checkpoints) { const values = [...(checkpoints || [])]; for (const output of node?.outputs ?? []) { if (!isCheckpointSlotName(output?.name)) continue; output.type = [...values]; const links = Array.isArray(output.links) ? output.links : []; for (const linkId of links) { const link = app.graph?.links?.[linkId]; if (!link) continue; const targetNode = app.graph?.getNodeById?.(link.target_id); const targetInput = targetNode?.inputs?.[link.target_slot]; if (targetInput && isCheckpointSlotName(targetInput.name)) { targetInput.type = [...values]; } } } for (const input of node?.inputs ?? []) { if (isCheckpointSlotName(input?.name)) { input.type = [...values]; } } } function updateCheckpointComboWidget(node, widget, checkpoints) { if (!widget || !Array.isArray(checkpoints) || checkpoints.length === 0) { return { changed: false, valueChanged: false, oldValue: widget?.value, newValue: widget?.value }; } if (!widget.options) widget.options = {}; const oldValues = Array.isArray(widget.options.values) ? [...widget.options.values] : []; const oldValue = widget.value; const newValues = [...checkpoints]; const newValue = chooseCheckpointReplacement(oldValues, newValues, oldValue); widget.options.values = newValues; widget.value = newValue; const valuesChanged = !arraysEqual(oldValues, newValues); const valueChanged = oldValue !== newValue; if (valueChanged && typeof widget.callback === "function") { try { widget.callback(widget.value); } catch (error) { console.warn("[CheckpointHandpickerSuite] checkpoint widget callback failed", error); } } if (valuesChanged || valueChanged) { node?.setDirtyCanvas?.(true, true); } return { changed: valuesChanged || valueChanged, valueChanged, oldValue, newValue }; } function applyCheckpointListToWidgetNode(node, checkpoints) { if (!isCheckpointSelectorLikeNode(node)) { return { matched: false, changed: false, valueChanged: false }; } const widget = findCheckpointWidget(node); if (!widget) { patchCheckpointSlotTypes(node, checkpoints); return { matched: true, changed: false, valueChanged: false, reason: "checkpoint widget not found" }; } const result = updateCheckpointComboWidget(node, widget, checkpoints); patchCheckpointSlotTypes(node, checkpoints); return { matched: true, ...result }; } function applyCheckpointListToCyclerNode(node, checkpoints) { if (!isNodeClass(node, CYCLER_CLASS)) { return { matched: false, changed: false, valueChanged: false }; } const widget = findStartCheckpointWidget(node); if (!widget) { patchCheckpointSlotTypes(node, checkpoints); node?.setDirtyCanvas?.(true, true); return { matched: true, changed: false, valueChanged: false, reason: "start_checkpoint widget not found" }; } const result = updateCheckpointComboWidget(node, widget, checkpoints); patchCheckpointSlotTypes(node, checkpoints); return { matched: true, ...result }; } function applyCheckpointValuesToGraph(checkpoints) { if (!Array.isArray(checkpoints) || checkpoints.length === 0) { return { widgetMatched: 0, widgetChanged: 0, valueChanged: 0, cyclerMatched: 0, cyclerChanged: 0, cyclerValueChanged: 0 }; } let widgetMatched = 0; let widgetChanged = 0; let valueChanged = 0; let cyclerMatched = 0; let cyclerChanged = 0; let cyclerValueChanged = 0; for (const node of app.graph?._nodes ?? []) { const widgetResult = applyCheckpointListToWidgetNode(node, checkpoints); if (widgetResult.matched) { widgetMatched += 1; if (widgetResult.changed) widgetChanged += 1; if (widgetResult.valueChanged) { valueChanged += 1; console.log( `[CheckpointHandpickerSuite] ${getNodeTypeName(node)} #${node.id}: ` + `${widgetResult.oldValue} -> ${widgetResult.newValue}` ); } } const cyclerResult = applyCheckpointListToCyclerNode(node, checkpoints); if (cyclerResult.matched) { cyclerMatched += 1; if (cyclerResult.changed) cyclerChanged += 1; if (cyclerResult.valueChanged) cyclerValueChanged += 1; } } app.graph?.setDirtyCanvas?.(true, true); return { widgetMatched, widgetChanged, valueChanged, cyclerMatched, cyclerChanged, cyclerValueChanged }; } async function checkpointValuesFromObjectInfoFallback() { try { const info = await api.fetchApi("/object_info", { cache: "no-store" }); if (!info.ok) return []; const objectInfo = await info.json(); const loaderValues = objectInfo?.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0]; if (Array.isArray(loaderValues)) return loaderValues; const cyclerValues = objectInfo?.CheckpointNameCycler?.input?.required?.start_checkpoint?.[0]; if (Array.isArray(cyclerValues)) return cyclerValues; } catch (error) { console.warn("[CheckpointHandpickerSuite] object_info fallback failed", error); } return []; } async function checkpointValuesFromRefreshPayload(result) { if (Array.isArray(result?.checkpoint_values) && result.checkpoint_values.length) { return result.checkpoint_values; } const fallback = await checkpointValuesFromObjectInfoFallback(); if (fallback.length) return fallback; if (Array.isArray(result?.items)) return result.items.map((item) => item.ckpt_name_str).filter(Boolean); return []; } function installCheckpointRefreshFuturePatch(nodeType, nodeData) { const name = getNodeDataName(nodeData); if ( name !== CYCLER_CLASS && name !== "CheckpointLoaderSimple" && !name.includes("CheckpointNameSelector") && !name.includes("CheckpointListSelector") ) { return; } if (nodeType.prototype.__hpsCheckpointRefreshFuturePatchInstalled) return; nodeType.prototype.__hpsCheckpointRefreshFuturePatchInstalled = true; const originalOnNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function (...args) { const result = originalOnNodeCreated?.apply(this, args); if (lastCheckpointValues) { setTimeout(() => { applyCheckpointListToWidgetNode(this, lastCheckpointValues); applyCheckpointListToCyclerNode(this, lastCheckpointValues); app.graph?.setDirtyCanvas?.(true, true); }, 0); } return result; }; } function isNodeClass(node, className) { return node && (node.type === className || node.comfyClass === className); } function tabPayload(payload = {}) { return { ...payload, tab_id: HPS_TAB_ID }; } function isForThisTab(detail) { return detail?.scope === "global" || detail?.tab_id === HPS_TAB_ID; } function nodeFromEvent(detail, className) { if (!isForThisTab(detail)) return null; const node = app.graph?.getNodeById(Number(detail?.node)); if (!isNodeClass(node, className)) return null; return node; } function ensureHiddenWidgetValue(node, name, value) { let w = getWidget(node, name); if (!w && typeof node?.addWidget === "function") { try { w = node.addWidget("text", name, value ?? "", () => {}, {}); w.serialize = true; } catch (error) { console.warn("[CheckpointHandpickerSuite] failed to add hidden widget", name, error); } } if (!w) return false; if (value !== undefined) w.value = value; w.type = "hidden"; w.hidden = true; w.disabled = true; w.serialize = true; w.options = { ...(w.options || {}), hidden: true }; w.computeSize = () => [0, -4]; w.draw = () => {}; return true; } const CYCLER_FILTER_STATUSES = ["god", "favorite", "nice", "keep", "delete", "none"]; function normalizeCyclerFilterStatuses(value) { let raw = value; if (Array.isArray(raw)) { // already usable } else { const text = String(raw ?? "").trim(); if (!text) return []; try { raw = JSON.parse(text); } catch { raw = text.split(",").map((x) => x.trim()); } } if (typeof raw === "string") raw = [raw]; if (!Array.isArray(raw)) return []; const set = new Set(raw.map((x) => String(x).trim()).filter((x) => CYCLER_FILTER_STATUSES.includes(x))); return CYCLER_FILTER_STATUSES.filter((x) => set.has(x)); } 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; if (["1", "true", "yes", "on"].includes(text)) return true; if (["0", "false", "no", "off"].includes(text)) return false; 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) { 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); } function restoreCyclerSettingsFromWidgets(node) { const filterWidget = getWidget(node, "hps_filter_statuses"); const filterText = String(filterWidget?.value ?? "").trim(); if (filterText) { node.__hpsFilterStatuses = normalizeCyclerFilterStatuses(filterText); } else if (!Array.isArray(node.__hpsFilterStatuses)) { node.__hpsFilterStatuses = []; } 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)); ensureStartCheckpointWidgetValid(node); } 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) { ensureHiddenTabIdWidget(node); setTimeout(() => { if (ensureHiddenTabIdWidget(node)) app.graph?.setDirtyCanvas?.(true, true); }, 0); setTimeout(() => { if (ensureHiddenTabIdWidget(node)) app.graph?.setDirtyCanvas?.(true, true); }, 100); } function installTabIdSupport(nodeType) { if (nodeType.prototype.__hpsTabIdInstalled) return; nodeType.prototype.__hpsTabIdInstalled = true; const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; scheduleHideTabIdWidget(this); return r; }; const origAdded = nodeType.prototype.onAdded; nodeType.prototype.onAdded = function () { const r = origAdded ? origAdded.apply(this, arguments) : undefined; scheduleHideTabIdWidget(this); return r; }; const origConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const r = origConfigure ? origConfigure.apply(this, arguments) : undefined; scheduleHideTabIdWidget(this); return r; }; } function ensureSize(node, w, h) { if (!node.size) return; node.size[0] = Math.max(node.size[0], w); node.size[1] = Math.max(node.size[1], h); } function installMinSize(nodeType, minW, minH) { if (nodeType.prototype.__hpsMinSizeInstalled) return; nodeType.prototype.__hpsMinSizeInstalled = true; const origResize = nodeType.prototype.onResize; nodeType.prototype.onResize = function (size) { if (size) { size[0] = Math.max(size[0], minW); size[1] = Math.max(size[1], minH); } ensureSize(this, minW, minH); return origResize ? origResize.apply(this, arguments) : undefined; }; const origConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const r = origConfigure ? origConfigure.apply(this, arguments) : undefined; ensureSize(this, minW, minH); return r; }; } function setCanvasCursor(cursor) { const canvasEl = app.canvas?.canvas; if (canvasEl) canvasEl.style.cursor = cursor || ""; } function drawRounded(ctx, x, y, w, h, r = 6) { ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x, y, w, h, r); else ctx.rect(x, y, w, h); } function drawButton(ctx, rect, label, enabled = true, active = false, color = null, opts = {}) { ctx.save(); const bg = color || (active ? "rgba(70,140,110,0.82)" : enabled ? "rgba(80,120,180,0.65)" : "rgba(80,80,80,0.26)"); ctx.fillStyle = bg; ctx.strokeStyle = enabled ? (active ? "rgba(235,255,245,0.95)" : "rgba(200,230,255,0.75)") : "rgba(150,150,150,0.28)"; ctx.lineWidth = active ? 3 : 1; drawRounded(ctx, rect.x, rect.y, rect.w, rect.h, 6); ctx.fill(); ctx.stroke(); ctx.fillStyle = opts.textColor || (enabled ? "#fff" : "#888"); 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, Math.max(1, rect.w - 8)); ctx.restore(); } function hit(pos, rect) { return pos && pos[0] >= rect.x && pos[0] <= rect.x + rect.w && pos[1] >= rect.y && pos[1] <= rect.y + rect.h; } function localPos(node, pos) { if (!pos) return pos; return [pos[0] - (node.pos?.[0] || 0), pos[1] - (node.pos?.[1] || 0)]; } function candidatePositions(node, pos) { if (!pos) return []; const graphToLocal = localPos(node, pos); return [pos, graphToLocal]; } function hitAny(node, pos, rect) { return candidatePositions(node, pos).some((p) => hit(p, rect)); } function graphEventToLocal(node, event) { let graphPos = null; try { graphPos = app.canvas?.convertEventToCanvasOffset?.(event); } catch { graphPos = null; } if (!graphPos) return null; return [graphPos[0] - (node.pos?.[0] || 0), graphPos[1] - (node.pos?.[1] || 0)]; } function hpsNodeCollapsed(node) { return Boolean(node?.flags?.collapsed); } function hpsLocalInsideNode(node, local) { return Boolean(local) && local[0] >= 0 && local[1] >= 0 && local[0] <= (node.size?.[0] || 0) && local[1] <= (node.size?.[1] || 0); } let cursorCaptureInstalled = false; function installCursorCapture() { if (cursorCaptureInstalled) return; cursorCaptureInstalled = true; const canvasEl = app.canvas?.canvas; if (!canvasEl) return; canvasEl.addEventListener("mousemove", (event) => { const graph = app.graph; if (!graph) return; const allNodes = graph._nodes || []; for (const node of allNodes) { if (!(node.type === SELECTOR_CLASS || node.comfyClass === SELECTOR_CLASS)) continue; if (node.flags?.collapsed) { clearSelectorThumbnailHover(node); continue; } const local = graphEventToLocal(node, event); if (!hpsLocalInsideNode(node, local)) clearSelectorThumbnailHover(node); } const nodes = [...allNodes].reverse(); for (const node of nodes) { if (node.flags?.collapsed) continue; let cursor = ""; if (node.type === SELECTOR_CLASS || node.comfyClass === SELECTOR_CLASS) cursor = selectorCursorAt(node, graphEventToLocal(node, event)); else if (node.type === TAGGER_CLASS || node.comfyClass === TAGGER_CLASS) cursor = taggerCursorAt(node, graphEventToLocal(node, event)); else if (node.type === TAG_TRANSFER_CLASS || node.comfyClass === TAG_TRANSFER_CLASS) cursor = tagTransferCursorAt(node, graphEventToLocal(node, event)); else if (node.type === CYCLER_CLASS || node.comfyClass === CYCLER_CLASS) cursor = cyclerCursorAt(node, graphEventToLocal(node, event)); if (cursor) { setCanvasCursor(cursor); return; } } setCanvasCursor(""); }); canvasEl.addEventListener("mouseleave", () => { for (const node of app.graph?._nodes || []) { if (node.type === SELECTOR_CLASS || node.comfyClass === SELECTOR_CLASS) clearSelectorThumbnailHover(node); } 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 (isTaggerDirectLinkBlocked(node)) { clearTaggerTarget(node); } else 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 ---------- let hpsContextMenuEl = null; let hpsContextMenuHideInstalled = false; let imageDirHoverCaptureInstalled = false; function ensureHpsContextMenu() { if (hpsContextMenuEl) return hpsContextMenuEl; const el = document.createElement("div"); el.style.position = "fixed"; el.style.zIndex = "99999"; el.style.minWidth = "220px"; el.style.background = "rgba(22,22,22,0.98)"; el.style.border = "1px solid rgba(255,255,255,0.18)"; el.style.borderRadius = "8px"; el.style.boxShadow = "0 10px 28px rgba(0,0,0,0.45)"; el.style.padding = "6px"; el.style.display = "none"; el.style.font = "12px sans-serif"; el.style.color = "#eee"; document.body.appendChild(el); hpsContextMenuEl = el; return el; } function hideHpsContextMenu() { const el = ensureHpsContextMenu(); el.style.display = "none"; el.innerHTML = ""; } function installHpsContextMenuHide() { if (hpsContextMenuHideInstalled) return; hpsContextMenuHideInstalled = true; document.addEventListener("mousedown", (event) => { if (hpsContextMenuEl?.contains?.(event.target)) return; hideHpsContextMenu(); }, true); document.addEventListener("scroll", () => hideHpsContextMenu(), true); window.addEventListener("blur", () => hideHpsContextMenu()); } function showHpsContextMenu(items, clientX, clientY) { installHpsContextMenuHide(); const el = ensureHpsContextMenu(); el.innerHTML = ""; const menuItems = Array.isArray(items) ? items : []; for (const item of menuItems) { const row = document.createElement("div"); row.textContent = String(item?.label || ""); row.style.padding = "7px 10px"; row.style.borderRadius = "6px"; row.style.whiteSpace = "nowrap"; row.style.userSelect = "none"; const enabled = item?.enabled !== false && typeof item?.onClick === "function"; if (enabled) { row.style.cursor = "pointer"; row.addEventListener("mouseenter", () => { row.style.background = "rgba(255,255,255,0.10)"; }); row.addEventListener("mouseleave", () => { row.style.background = "transparent"; }); row.addEventListener("mousedown", (event) => { event.preventDefault(); event.stopPropagation(); hideHpsContextMenu(); item.onClick(); }); } else { row.style.color = "#999"; row.style.cursor = "default"; } el.appendChild(row); } el.style.left = `${Math.max(4, Number(clientX || 0))}px`; el.style.top = `${Math.max(4, Number(clientY || 0))}px`; el.style.display = "block"; const rect = el.getBoundingClientRect(); const maxX = Math.max(4, window.innerWidth - rect.width - 4); const maxY = Math.max(4, window.innerHeight - rect.height - 4); el.style.left = `${Math.min(Math.max(4, Number(clientX || 0)), maxX)}px`; el.style.top = `${Math.min(Math.max(4, Number(clientY || 0)), maxY)}px`; } function previewGeometry(node) { const isImageDir = isNodeClass(node, "ImageDirPreview"); const top = isImageDir ? 72 : 30; const margin = 8; const w = Math.max(1, (node.size?.[0] || 340) - margin * 2); const h = Math.max(1, (node.size?.[1] || 300) - top - margin); return { isImageDir, top, margin, w, h }; } function previewDrawBox(node) { const img = node?.__hpsPreview; if (!img) return null; const g = previewGeometry(node); let dw = g.w; let dh = dw * (img.height / img.width); if (dh > g.h) { dh = g.h; dw = dh * (img.width / img.height); } const x = g.margin + (g.w - dw) / 2; const y = g.top + (g.h - dh) / 2; return { x, y, w: dw, h: dh, imageW: img.width, imageH: img.height }; } function previewLocalFromEventOrPos(node, event, pos) { return graphEventToLocal(node, event) || localPos(node, pos); } function imageDirPreviewItemAtLocal(node, local) { if (!node || !isNodeClass(node, "ImageDirPreview") || !local) return null; const st = node.__hpsPreviewState || {}; const sourcePaths = Array.isArray(st.source_paths) ? st.source_paths : []; const count = Number(st.count || sourcePaths.length || 0); const cols = Number(st.columns || 0); const rows = Number(st.rows || 0); const tileW = Number(st.tile_width || 0); const tileH = Number(st.tile_height || 0); const gap = Number(st.gap || 0); if (!count || !cols || !rows || !tileW || !tileH || !sourcePaths.length) return null; const box = previewDrawBox(node); if (!box || !hit(local, box)) return null; const sx = (local[0] - box.x) * (box.imageW / box.w); const sy = (local[1] - box.y) * (box.imageH / box.h); const stepX = tileW + gap; const stepY = tileH + gap; if (stepX <= 0 || stepY <= 0) return null; const col = Math.floor(sx / stepX); const row = Math.floor(sy / stepY); if (col < 0 || row < 0 || col >= cols || row >= rows) return null; const inTileX = sx - col * stepX; const inTileY = sy - row * stepY; if (inTileX < 0 || inTileX > tileW || inTileY < 0 || inTileY > tileH) return null; const index = row * cols + col; if (index < 0 || index >= count || index >= sourcePaths.length) return null; if (isImageDirDeletedIndex(node, index)) return null; return { index, sourcePath: String(sourcePaths[index] || ""), ckptName: String(st.ckpt_name_str || "") }; } function imageDirPreviewTileRect(node, index) { const st = node.__hpsPreviewState || {}; const cols = Number(st.columns || 0); const tileW = Number(st.tile_width || 0); const tileH = Number(st.tile_height || 0); const gap = Number(st.gap || 0); if (!cols || !tileW || !tileH || index == null || index < 0) return null; const box = previewDrawBox(node); if (!box) return null; const row = Math.floor(index / cols); const col = index % cols; const sx = col * (tileW + gap); const sy = row * (tileH + gap); const scaleX = box.w / box.imageW; const scaleY = box.h / box.imageH; return { x: box.x + sx * scaleX, y: box.y + sy * scaleY, w: tileW * scaleX, h: tileH * scaleY }; } function clearImageDirHover(node) { if (!node) return; if (node.__hpsHoveredPreviewIndex != null) { node.__hpsHoveredPreviewIndex = null; app.graph?.setDirtyCanvas?.(true, true); } } function setImageDirHover(node, item) { const index = item?.index ?? null; if (node.__hpsHoveredPreviewIndex !== index) { node.__hpsHoveredPreviewIndex = index; app.graph?.setDirtyCanvas?.(true, true); } } function updateImageDirHoverFromEvent(event) { let handled = false; const nodes = [...(app.graph?._nodes || [])].reverse(); for (const node of nodes) { if (!node || node.flags?.collapsed || !isNodeClass(node, "ImageDirPreview")) continue; const local = graphEventToLocal(node, event); if (!hpsLocalInsideNode(node, local)) { clearImageDirHover(node); continue; } const item = imageDirPreviewItemAtLocal(node, local); setImageDirHover(node, item); handled = true; break; } if (!handled) { for (const node of app.graph?._nodes || []) { if (isNodeClass(node, "ImageDirPreview")) clearImageDirHover(node); } } } function installImageDirHoverCapture() { if (imageDirHoverCaptureInstalled) return; const canvasEl = app.canvas?.canvas; if (!canvasEl) return; imageDirHoverCaptureInstalled = true; canvasEl.addEventListener("mousemove", (event) => updateImageDirHoverFromEvent(event), true); canvasEl.addEventListener("mouseleave", () => { for (const node of app.graph?._nodes || []) { if (isNodeClass(node, "ImageDirPreview")) clearImageDirHover(node); } }); } const NO_IMAGE_ASSET_URL = new URL("./assets/no_image.svg", import.meta.url).href; let noImageAsset = null; let noImageAssetRequested = false; function ensureNoImageAsset() { if (noImageAsset || noImageAssetRequested) return noImageAsset; noImageAssetRequested = true; const img = new Image(); img.onload = () => { noImageAsset = img; app.graph?.setDirtyCanvas?.(true, true); }; img.onerror = () => { noImageAsset = null; }; img.src = NO_IMAGE_ASSET_URL; return null; } function deletedImageDirIndexSet(node) { const raw = node?.__hpsPreviewState?.deleted_indices; if (!Array.isArray(raw) || !raw.length) return null; return new Set(raw.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v >= 0)); } function isImageDirDeletedIndex(node, index) { if (index == null || index < 0) return false; const set = deletedImageDirIndexSet(node); return !!set && set.has(Number(index)); } function updateNodeDeletedIndices(node, values) { if (!node) return; const next = Array.isArray(values) ? values.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v >= 0) : []; node.__hpsPreviewState = { ...(node.__hpsPreviewState || {}), deleted_indices: next }; } function drawNoImagePlaceholder(ctx, rect) { ctx.save(); ctx.fillStyle = "rgba(20,20,20,0.72)"; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); const asset = noImageAsset || ensureNoImageAsset(); if (asset) { ctx.drawImage(asset, rect.x, rect.y, rect.w, rect.h); } else { ctx.strokeStyle = "rgba(255,255,255,0.16)"; ctx.lineWidth = 1; ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, Math.max(1, rect.w - 1), Math.max(1, rect.h - 1)); ctx.fillStyle = "rgba(255,255,255,0.88)"; ctx.font = "bold 12px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("NO IMAGE", rect.x + rect.w / 2, rect.y + rect.h / 2, Math.max(1, rect.w - 10)); ctx.textAlign = "left"; ctx.textBaseline = "alphabetic"; } ctx.restore(); } async function fetchImageDirContextMenu(node, itemIndex) { const st = node.__hpsPreviewState || {}; const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/context_menu`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: node.id, item_index: itemIndex, preview_session_id: st.preview_session_id || "", tab_id: HPS_TAB_ID }), }); return await response.json(); } async function setImageDirCheckpointThumbnail(node, itemIndex) { const st = node.__hpsPreviewState || {}; const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/set_thumbnail`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: node.id, item_index: itemIndex, preview_session_id: st.preview_session_id || "", tab_id: HPS_TAB_ID }), }); const result = await response.json(); if (result?.ok) { selectorThumbnailCache.delete(selectorThumbnailCacheKey(result.ckpt_name_str || "")); node.__hpsPreviewContextMessage = result.message || "Checkpoint thumbnail updated."; node.__hpsPreviewContextMessageAt = Date.now(); } else { node.__hpsPreviewContextMessage = result?.error || "Failed to set checkpoint thumbnail."; node.__hpsPreviewContextMessageAt = Date.now(); } app.graph?.setDirtyCanvas?.(true, true); } async function deleteImageDirPreviewImage(node, itemIndex) { const st = node.__hpsPreviewState || {}; const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/delete_image`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: node.id, item_index: itemIndex, preview_session_id: st.preview_session_id || "", tab_id: HPS_TAB_ID }), }); const result = await response.json(); if (result?.ok) { if (Array.isArray(result.deleted_indices)) updateNodeDeletedIndices(node, result.deleted_indices); if (result.deleted) clearImageDirHover(node); node.__hpsPreviewContextMessage = result.message || (result.deleted ? "Preview image deleted." : "Preview image not found."); node.__hpsPreviewContextMessageAt = Date.now(); } else { node.__hpsPreviewContextMessage = result?.error || "Failed to delete preview image."; node.__hpsPreviewContextMessageAt = Date.now(); } app.graph?.setDirtyCanvas?.(true, true); } async function openImageDirContextMenu(node, item, event) { hideHpsContextMenu(); try { const result = await fetchImageDirContextMenu(node, item.index); if (!result?.ok) { showHpsContextMenu([{ label: result?.error || "Preview item not found", enabled: false }], event.clientX, event.clientY); return; } const items = (result.items || []).map((entry) => { if (entry?.id === "delete_image" && entry?.enabled !== false) { return { label: entry.label || "Delete image", enabled: true, onClick: async () => { if (!globalThis.confirm?.("Delete this image?")) return; await deleteImageDirPreviewImage(node, item.index); }, }; } if (entry?.id === "set_thumbnail" && entry?.enabled !== false) { return { label: entry.label || "Set as checkpoint thumbnail", enabled: true, onClick: () => setImageDirCheckpointThumbnail(node, item.index) }; } return null; }).filter(Boolean); if (!items.length) { hideHpsContextMenu(); return; } showHpsContextMenu(items, event.clientX, event.clientY); } catch (error) { showHpsContextMenu([{ label: "Failed to open menu", enabled: false }], event.clientX, event.clientY); } } function setupPreviewNode(nodeType) { installMinSize(nodeType, 340, 300); installTabIdSupport(nodeType); installCursorCapture(); installImageDirHoverCapture(); const origCreated = nodeType.prototype.onNodeCreated; 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; }; const origDraw = nodeType.prototype.onDrawBackground; nodeType.prototype.onDrawBackground = function (ctx) { if (origDraw) origDraw.apply(this, arguments); ensureHiddenTabIdWidget(this); if (this.flags?.collapsed) return; const img = this.__hpsPreview; const g = previewGeometry(this); const messageX = Math.min(140, Math.max(g.margin, this.size[0] - 80)); const captionY = g.isImageDir ? 36 : 20; const messageW = Math.max(1, this.size[0] - messageX - g.margin); ctx.save(); if (this.__hpsPreviewCaption) { const st = this.__hpsPreviewState || {}; const isWarning = st.status && !["ready", "loading"].includes(st.status); if (g.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 ? "#FFD28A" : "#ddd"; ctx.font = "12px sans-serif"; ctx.fillText(this.__hpsPreviewCaption, messageX, captionY, messageW); } if (g.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 = g.margin; const barY = 0; const barW = Math.max(1, this.size[0] - g.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); } const drawBox = previewDrawBox(this); if (img && drawBox) { ctx.drawImage(img, drawBox.x, drawBox.y, drawBox.w, drawBox.h); if (g.isImageDir) { for (const index of deletedImageDirIndexSet(this) || []) { const rect = imageDirPreviewTileRect(this, index); if (rect) drawNoImagePlaceholder(ctx, rect); } } if (g.isImageDir && this.__hpsHoveredPreviewIndex != null && !isImageDirDeletedIndex(this, this.__hpsHoveredPreviewIndex)) { const rect = imageDirPreviewTileRect(this, this.__hpsHoveredPreviewIndex); if (rect) { ctx.save(); ctx.strokeStyle = "rgba(255,255,255,0.95)"; ctx.lineWidth = 2; ctx.strokeRect(rect.x + 1, rect.y + 1, Math.max(1, rect.w - 2), Math.max(1, rect.h - 2)); ctx.strokeStyle = "rgba(0,0,0,0.75)"; ctx.lineWidth = 1; ctx.strokeRect(rect.x + 3, rect.y + 3, Math.max(1, rect.w - 6), Math.max(1, rect.h - 6)); ctx.restore(); } } } else { const st = this.__hpsPreviewState || {}; const isLoading = st.status === "loading" || !!st.progress; if (!isLoading) { const text = this.__hpsPreviewCaption || "no preview"; ctx.fillStyle = "rgba(255,255,255,0.15)"; ctx.font = "12px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, this.size[0] / 2, g.top + g.h / 2, Math.max(1, g.w - 16)); ctx.textAlign = "left"; ctx.textBaseline = "alphabetic"; } } const contextMsg = this.__hpsPreviewContextMessage || ""; const msgAge = Date.now() - Number(this.__hpsPreviewContextMessageAt || 0); if (contextMsg && msgAge < 5000 && g.isImageDir) { ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(8, Math.max(46, g.top - 22), Math.max(140, this.size[0] - 16), 18); ctx.fillStyle = "#ddd"; ctx.font = "12px sans-serif"; ctx.fillText(contextMsg, 12, Math.max(60, g.top - 9), Math.max(120, this.size[0] - 24)); } ctx.restore(); }; const origMouseDown = nodeType.prototype.onMouseDown; nodeType.prototype.onMouseDown = function (event, pos, canvas) { if (isNodeClass(this, "ImageDirPreview") && event?.button === 0) { const local = previewLocalFromEventOrPos(this, event, pos); const item = imageDirPreviewItemAtLocal(this, local); if (item) { event.preventDefault?.(); event.stopPropagation?.(); event.stopImmediatePropagation?.(); setImageDirHover(this, item); openImageDirContextMenu(this, item, event); return true; } hideHpsContextMenu(); } return origMouseDown ? origMouseDown.apply(this, arguments) : false; }; } api.addEventListener(PREVIEW_EVENT, ({ detail }) => { if (!isForThisTab(detail)) return; const node = app.graph?.getNodeById(Number(detail?.node)); if (!node || !PREVIEW_CLASSES.has(node.type || node.comfyClass)) return; if (detail.node_class && !isNodeClass(node, detail.node_class)) return; 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); } else { node.title = "Ephemeral Preview"; } } else if (detail.title) { node.title = detail.title; } node.__hpsPreviewState = { ...(node.__hpsPreviewState || {}), ...detail }; if (isNodeClass(node, "ImageDirPreview")) node.__hpsHoveredPreviewIndex = null; 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) { if (!detail.progress) node.__hpsPreview = null; app.graph.setDirtyCanvas(true, true); return; } const img = new Image(); img.onload = () => { node.__hpsPreview = img; node.__hpsPreviewCaption = detail.progress_message || detail.message || node.__hpsPreviewCaption; app.graph.setDirtyCanvas(true, true); }; img.src = `data:image/${detail.format};base64,${detail.image}`; }); // ---------- Selector ---------- const SELECTOR_VISIBLE_ROWS = 20; const ROW_H = 20; const SELECTOR_THUMBNAIL_POPUP_W = 250; const SELECTOR_THUMBNAIL_POPUP_H = 250; const selectorThumbnailCache = new Map(); function selectorThumbnailCacheKey(ckptName) { return String(ckptName || ""); } function clearSelectorThumbnailHover(node) { if (!node) return; if (node.__hpsHoverCkptName || node.__hpsHoverRowIndex != null || node.__hpsHoverSource) { node.__hpsHoverCkptName = ""; node.__hpsHoverRowIndex = null; node.__hpsHoverSource = ""; app.graph?.setDirtyCanvas?.(true, true); } } function selectorVisibleRowIndexForCheckpoint(node, ckptName) { if (!ckptName) return null; const idx = selectorItems(node).findIndex((entry) => entry.ckpt_name_str === ckptName); if (idx < 0) return null; const row = idx - (node.__hpsScroll || 0); return row >= 0 && row < SELECTOR_VISIBLE_ROWS ? row : null; } function setSelectorThumbnailHover(node, ckptName, rowIndex = null, source = "") { if (!node || !ckptName) { clearSelectorThumbnailHover(node); return; } if (node.__hpsHoverCkptName !== ckptName || node.__hpsHoverRowIndex !== rowIndex || node.__hpsHoverSource !== source) { node.__hpsHoverCkptName = ckptName; node.__hpsHoverRowIndex = rowIndex; node.__hpsHoverSource = source; requestSelectorThumbnail(node, ckptName); app.graph?.setDirtyCanvas?.(true, true); } } function selectorItemAtLocal(node, local) { if (!local) return null; const r = selectorRects(node); if (!hit(local, r.list)) return null; const row = Math.floor((local[1] - r.list.y) / ROW_H); if (row < 0 || row >= SELECTOR_VISIBLE_ROWS) return null; const item = selectorItems(node)[(node.__hpsScroll || 0) + row]; if (!item) return null; return { item, row }; } async function requestSelectorThumbnail(node, ckptName) { const key = selectorThumbnailCacheKey(ckptName); if (!key) return; const cached = selectorThumbnailCache.get(key); if (cached?.status === "loading" || cached?.status === "ready" || cached?.status === "missing") return; selectorThumbnailCache.set(key, { status: "loading" }); app.graph?.setDirtyCanvas?.(true, true); try { const query = new URLSearchParams({ ckpt_name_str: key }); const response = await api.fetchApi(`/${EXTENSION_PREFIX}/selector/thumbnail?${query.toString()}`, { cache: "no-store" }); const result = await response.json(); if (!result?.ok || !result.found || !result.image) { selectorThumbnailCache.set(key, { status: "missing" }); app.graph?.setDirtyCanvas?.(true, true); return; } const img = new Image(); img.onload = () => { selectorThumbnailCache.set(key, { status: "ready", image: img, width: result.width || img.width, height: result.height || img.height, thumbnail_path: result.thumbnail_path || "", }); app.graph?.setDirtyCanvas?.(true, true); }; img.onerror = () => { selectorThumbnailCache.set(key, { status: "missing" }); app.graph?.setDirtyCanvas?.(true, true); }; img.src = `data:image/${result.format || "jpeg"};base64,${result.image}`; } catch (error) { selectorThumbnailCache.set(key, { status: "missing", error: String(error) }); app.graph?.setDirtyCanvas?.(true, true); } } function updateSelectorThumbnailHover(node, event, pos) { if (!node || hpsNodeCollapsed(node)) return; const local = selectorLocalFromEventOrPos(node, event, pos); if (!hpsLocalInsideNode(node, local)) { clearSelectorThumbnailHover(node); return; } const hitItem = selectorItemAtLocal(node, local); if (hitItem?.item?.ckpt_name_str) { setSelectorThumbnailHover(node, hitItem.item.ckpt_name_str, hitItem.row, "list"); return; } const selected = selectorSelected(node); if (selected) { setSelectorThumbnailHover(node, selected, selectorVisibleRowIndexForCheckpoint(node, selected), "selected"); return; } clearSelectorThumbnailHover(node); } function drawSelectorThumbnailPopup(ctx, node) { const ckpt = node.__hpsHoverCkptName || ""; if (!ckpt) return; const r = selectorRects(node); const cache = selectorThumbnailCache.get(selectorThumbnailCacheKey(ckpt)) || { status: "loading" }; const x = r.list.x + r.list.w + 12; const rowIndex = Number.isFinite(node.__hpsHoverRowIndex) ? node.__hpsHoverRowIndex : null; const preferredY = rowIndex == null ? 46 : r.list.y + rowIndex * ROW_H - 8; const y = Math.max(8, Math.min(preferredY, (node.size?.[1] || 540) - SELECTOR_THUMBNAIL_POPUP_H - 8)); const w = SELECTOR_THUMBNAIL_POPUP_W; const h = SELECTOR_THUMBNAIL_POPUP_H; ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.65)"; ctx.shadowBlur = 16; ctx.fillStyle = "rgba(20,20,20,0.94)"; ctx.strokeStyle = "rgba(255,255,255,0.28)"; ctx.lineWidth = 1; drawRounded(ctx, x, y, w, h, 10); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; const pad = 8; const titleH = 22; ctx.fillStyle = "#ddd"; ctx.font = "12px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(ckpt, x + pad, y + 14, w - pad * 2); const imgBox = { x: x + pad, y: y + titleH + pad, w: w - pad * 2, h: h - titleH - pad * 2 }; if (cache.status === "ready" && cache.image) { const img = cache.image; let dw = imgBox.w; let dh = dw * (img.height / img.width); if (dh > imgBox.h) { dh = imgBox.h; dw = dh * (img.width / img.height); } const ix = imgBox.x + (imgBox.w - dw) / 2; const iy = imgBox.y + (imgBox.h - dh) / 2; ctx.drawImage(img, ix, iy, dw, dh); } else { ctx.fillStyle = "rgba(255,255,255,0.08)"; ctx.fillRect(imgBox.x, imgBox.y, imgBox.w, imgBox.h); ctx.fillStyle = cache.status === "missing" ? "#aaa" : "#ddd"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(cache.status === "missing" ? "No sidecar thumbnail" : "Loading thumbnail...", imgBox.x + imgBox.w / 2, imgBox.y + imgBox.h / 2, imgBox.w - 12); } ctx.restore(); } function selectorWidget(node) { return getWidget(node, "checkpoint"); } function hideSelectorWidget(node) { const w = selectorWidget(node); if (w) { w.type = "hidden"; w.computeSize = () => [0, -4]; } } function selectorRects(node) { const margin = 8; const gap = 6; const buttonY = 8; const buttonH = 24; const arrowW = 32; const refreshW = 102; const pushW = selectorActionMode(node) === "sync" ? 150 : 174; const pushX = margin + refreshW + 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 }, 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 node ? (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); updateDirectLinkTaggers(node); } function selectorStatusText(result, prefix = "") { const s = result?.summary || {}; return `${prefix}${s.total ?? 0} total (๐Ÿ‘‘:${s.god ?? 0}, ๐Ÿ’›:${s.favorite ?? 0}, ๐Ÿ‘:${s.nice ?? 0}, โœ”:${s.keep ?? 0}, ๐Ÿ—‘:${s.delete ?? 0}, โ€”:${s.none ?? 0})`; } function getSelectorReviewTargets(node) { const outIndex = node.outputs?.findIndex((o) => o.name === "ckpt_name_str") ?? -1; if (outIndex < 0) return { taggers: [], previews: [] }; const output = node.outputs?.[outIndex]; const links = output?.links || []; const taggers = []; const previews = []; for (const linkId of links) { const link = app.graph?.links?.[linkId]; if (!link) continue; const target = app.graph?.getNodeById?.(link.target_id); if (!target) continue; const input = target.inputs?.[link.target_slot]; if (input?.name !== "ckpt_name_str") continue; if (isNodeClass(target, TAGGER_CLASS)) taggers.push(target); if (isNodeClass(target, "ImageDirPreview")) previews.push(target); } return { taggers, previews }; } function stringValue(value) { if (value == null) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return ""; } function firstWidgetValue(node, preferredSlot = null, seen = new Set()) { if (!node || seen.has(node.id)) return ""; seen.add(node.id); const widgets = node.widgets || []; const preferredNames = new Set(["text", "string", "value", "search_directory", "directory", "path"]); if (preferredSlot != null && widgets[preferredSlot]) { const v = stringValue(widgets[preferredSlot].value).trim(); if (v) return v; } for (const w of widgets) { const name = String(w?.name || "").toLowerCase(); if (name === "hps_tab_id" || w?.hidden) continue; if (!preferredNames.has(name)) continue; const v = stringValue(w.value).trim(); if (v) return v; } for (const w of widgets) { const name = String(w?.name || "").toLowerCase(); if (name === "hps_tab_id" || w?.hidden) continue; const v = stringValue(w.value).trim(); if (v) return v; } for (const v of node.widgets_values || []) { const s = stringValue(v).trim(); if (s) return s; } const props = node.properties || {}; for (const key of ["value", "text", "string", "search_directory", "directory", "path"]) { const s = stringValue(props[key]).trim(); if (s) return s; } const type = String(node.type || node.comfyClass || "").toLowerCase(); if (type.includes("reroute") || type.includes("relay")) { return linkedInputValue(node, node.inputs?.[0]?.name || "", seen); } return ""; } function linkedInputValue(node, inputName, seen = new Set()) { const index = node.inputs?.findIndex((i) => i.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 ""; return firstWidgetValue(source, link?.origin_slot, seen); } function imageDirSearchDirectory(node) { const direct = stringValue(getWidget(node, "search_directory")?.value).trim(); if (direct) return direct; const linked = linkedInputValue(node, "search_directory").trim(); if (linked) return linked; return ""; } function clampInt(value, min, max, fallback) { const n = Number.parseInt(value, 10); if (!Number.isFinite(n)) return fallback; return Math.max(min, Math.min(max, n)); } function imageDirMaxPreviewImages(node) { return clampInt(getWidget(node, "max_preview_images")?.value, 1, 80, 12); } function markImageDirPreviewLoading(node, ckptName) { if (!isNodeClass(node, "ImageDirPreview")) return; const maxPreviewImages = imageDirMaxPreviewImages(node); const message = "Searching preview images..."; node.__hpsPreviewState = { ...(node.__hpsPreviewState || {}), node_class: "ImageDirPreview", ckpt_name_str: ckptName, status: "loading", message, progress_message: message, progress_value: 0, progress_total: maxPreviewImages, max_preview_images: maxPreviewImages, }; node.__hpsPreviewCaption = message; } function nextAnimationFrame() { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } function selectorActionMode(node) { const targets = getSelectorReviewTargets(node); if (targets.previews.length) return "sync"; if (targets.taggers.length) return "directlink"; return "push"; } function selectorDirectLinkEnabled(node) { return selectorActionMode(node) === "directlink" && !!node.__hpsDirectLinkEnabled; } function selectorActionLabel(node) { const mode = selectorActionMode(node); if (mode === "sync") return "๐ŸŽฏ Sync Checkpoint"; if (mode === "directlink") return selectorDirectLinkEnabled(node) ? "๐ŸŒ• DirectLink ON" : "๐ŸŒ‘ DirectLink OFF"; return "๐Ÿน Push to Local List"; } const DIRECT_LINK_CONFIRM_MESSAGE = "Enable DirectLink?\n\nUse this mode only while checking generated images on a tablet, shared folder, or external viewer.\n\nOtherwise, connect ImageDirPreview and use Sync mode.\n\nTagger will label the checkpoint selected in ListSelector."; let hpsDirectLinkConfirmShown = false; function directLinkTaggerTargets(node) { const targets = getSelectorReviewTargets(node); return selectorActionMode(node) === "directlink" ? targets.taggers : []; } function clearTaggerTarget(node) { if (!node) return; node.__hpsTaggerPath = ""; node.__hpsTaggerStatus = "none"; node.__hpsTaggerMessage = ""; if (isNodeClass(node, TAGGER_CLASS)) node.title = "Tagger"; } function setTaggerTargetSnapshot(node, ckptName, status = "none") { if (!node || !ckptName) return; node.__hpsTaggerPath = ckptName; node.__hpsTaggerStatus = status || "none"; node.__hpsTaggerMessage = taggerCurrentMessage(node.__hpsTaggerStatus); node.title = node.__hpsTaggerStatus === "none" ? `Tagger : ${ckptName}` : `Tagger : ${STATUS_ICON[node.__hpsTaggerStatus]} ${ckptName}`; } function updateDirectLinkTaggers(node) { const taggers = directLinkTaggerTargets(node); if (!taggers.length) return; if (!selectorDirectLinkEnabled(node)) { for (const tagger of taggers) clearTaggerTarget(tagger); app.graph?.setDirtyCanvas?.(true, true); return; } const selected = selectorSelected(node); if (!selected) { for (const tagger of taggers) clearTaggerTarget(tagger); app.graph?.setDirtyCanvas?.(true, true); return; } const status = selectorStatusFor(node, selected); for (const tagger of taggers) setTaggerTargetSnapshot(tagger, selected, status); app.graph?.setDirtyCanvas?.(true, true); } function toggleSelectorDirectLink(node) { if (selectorActionMode(node) !== "directlink") return; if (!node.__hpsDirectLinkEnabled) { if (!hpsDirectLinkConfirmShown) { hpsDirectLinkConfirmShown = true; if (!globalThis.confirm?.(DIRECT_LINK_CONFIRM_MESSAGE)) return; } node.__hpsDirectLinkEnabled = true; } else { node.__hpsDirectLinkEnabled = false; } updateDirectLinkTaggers(node); node.__hpsStatus = selectorDirectLinkEnabled(node) ? "DirectLink ON: Tagger follows selected checkpoint." : "DirectLink OFF: connect ImageDirPreview for Sync mode, or enable DirectLink for external review."; app.graph?.setDirtyCanvas?.(true, true); } async function loadSelector(node, mode = "list") { node.__hpsLoading = true; app.graph.setDirtyCanvas(true, true); try { const path = mode === "refresh" ? `/${EXTENSION_PREFIX}/refresh_all` : `/${EXTENSION_PREFIX}/list_checkpoints`; } catch {} } const EXTENSION_PREFIX = "checkpoint_handpicker_suite"; async function refreshSelector(node, all = false) { node.__hpsLoading = true; app.graph.setDirtyCanvas(true, true); try { const response = await api.fetchApi(`/${EXTENSION_PREFIX}/${all ? "refresh_all" : "list_checkpoints"}`, { method: all ? "POST" : "GET" }); const result = await response.json(); node.__hpsItems = result.items || []; node.__hpsStatus = result.status_text || selectorStatusText(result); if (!selectorSelected(node) && node.__hpsItems.length) { setSelectorSelected(node, node.__hpsItems[0].ckpt_name_str); } 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) { selectorThumbnailCache.clear(); const checkpointValues = await checkpointValuesFromRefreshPayload(result); lastCheckpointValues = checkpointValues; const stats = applyCheckpointValuesToGraph(checkpointValues); node.__hpsStatus = `${node.__hpsStatus || "Refresh All"}\nUpdated ${stats.widgetChanged}/${stats.widgetMatched} checkpoint widgets, ${stats.cyclerMatched} cycler(s), ${checkpointValues.length} checkpoint choices`; console.log("[CheckpointHandpickerSuite] Refresh All widget sync", stats, { checkpointCount: checkpointValues.length, patchedClasses: result.patched_classes || [], }); } } catch (e) { node.__hpsStatus = String(e); } finally { node.__hpsLoading = false; app.graph.setDirtyCanvas(true, true); } } async function pushSelectedToLocalList(node) { const selected = selectorSelected(node); if (!selected) return; const response = await api.fetchApi(`/${EXTENSION_PREFIX}/cycler/local_list_append`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(tabPayload({ ckpt_name_str: selected, target_node_ids: (app.graph?._nodes || []) .filter((n) => isNodeClass(n, CYCLER_CLASS) && n.__hpsUseLocalList !== false) .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"); app.graph.setDirtyCanvas(true, true); } async function syncSelectedCheckpoint(node) { const selected = selectorSelected(node); if (!selected) return; const targets = getSelectorReviewTargets(node); for (const preview of targets.previews) { markImageDirPreviewLoading(preview, selected); } if (targets.previews.length) { app.graph.setDirtyCanvas(true, true); await nextAnimationFrame(); } const response = await api.fetchApi(`/${EXTENSION_PREFIX}/review/sync_checkpoint`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(tabPayload({ 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), })), })), }); const result = await response.json(); node.__hpsStatus = result.ok ? `synced : ${selected} (${targets.taggers.length} tagger, ${targets.previews.length} preview)` : (result.error || "Sync failed"); app.graph.setDirtyCanvas(true, true); } function maxSelectorScroll(node) { return Math.max(0, selectorItems(node).length - SELECTOR_VISIBLE_ROWS); } function scrollSelector(node, delta) { node.__hpsScroll = Math.max(0, Math.min(maxSelectorScroll(node), (node.__hpsScroll || 0) + delta)); clearSelectorThumbnailHover(node); app.graph.setDirtyCanvas(true, true); } 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 { 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 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.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++) { 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, 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, 520, 540); hideSelectorWidget(this); 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; const r = selectorRects(this); if (selectorActionMode(this) !== "directlink" && this.__hpsDirectLinkEnabled) { this.__hpsDirectLinkEnabled = false; } drawButton(ctx, r.refreshAll, "๐Ÿ”„ Refresh All", !this.__hpsLoading); drawButton(ctx, r.pushLocalList, selectorActionLabel(this), !this.__hpsLoading, selectorDirectLinkEnabled(this)); drawButton(ctx, r.up, "โ–ฒ", true); drawButton(ctx, r.down, "โ–ผ", true); ctx.save(); ctx.fillStyle = "#ccc"; 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); drawSelectorThumbnailPopup(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); const local = selectorLocalFromEventOrPos(this, event, pos); if (!local) return origMouseDown ? origMouseDown.apply(this, arguments) : false; if (hit(local, r.refreshAll)) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); refreshSelector(this, true); return true; } if (hit(local, r.pushLocalList)) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); const mode = selectorActionMode(this); if (mode === "sync") syncSelectedCheckpoint(this); else if (mode === "directlink") toggleSelectorDirectLink(this); else pushSelectedToLocalList(this); return true; } if (hit(local, r.up)) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); scrollSelector(this, -SELECTOR_VISIBLE_ROWS); return true; } if (hit(local, r.down)) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); scrollSelector(this, SELECTOR_VISIBLE_ROWS); return true; } const sb = selectorScrollbar(this); if (sb) { if (hit(local, sb.thumb)) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); this.__hpsScrollbarDragging = true; this.__hpsScrollbarDragOffset = local[1] - sb.thumb.y; this.__hpsScrollbarStartY = local[1]; this.__hpsScrollbarStartScroll = sb.scroll; app.graph?.setDirtyCanvas?.(true, true); return true; } if (hit(local, sb.track)) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); if (local[1] < sb.thumb.y) { scrollSelector(this, -SELECTOR_VISIBLE_ROWS); } else { scrollSelector(this, SELECTOR_VISIBLE_ROWS); } return true; } } if (hit(local, r.list)) { const row = Math.floor((local[1] - r.list.y) / ROW_H); const item = selectorItems(this)[(this.__hpsScroll || 0) + row]; if (item) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); setSelectorSelected(this, item.ckpt_name_str); app.graph.setDirtyCanvas(true, 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) { 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; } updateSelectorThumbnailHover(this, event, pos); 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 (event, pos, canvas) { const r = selectorRects(this); const local = selectorLocalFromEventOrPos(this, event, pos); if (hit(local, r.list)) { event?.preventDefault?.(); event?.stopPropagation?.(); event?.stopImmediatePropagation?.(); scrollSelector(this, event.deltaY > 0 ? 3 : -3); return true; } return origMouseWheel ? origMouseWheel.apply(this, arguments) : false; }; } // ---------- Tag Export / Import ---------- function tagTransferDirectoryValue(node) { const inputValue = node?.__hpsTagTransferDirectoryUI?.input?.value; if (inputValue !== undefined) return String(inputValue); return String(getWidget(node, TAG_TRANSFER_DIRECTORY_WIDGET)?.value ?? ""); } function hideTagTransferDirectorySavedWidget(widget) { if (!widget) return; widget.type = "hidden"; widget.hidden = true; widget.disabled = true; widget.serialize = true; widget.options = { ...(widget.options || {}), hidden: true }; widget.computeSize = () => [0, -4]; widget.draw = () => {}; for (const element of [widget.element, widget.inputEl, widget.domWidget?.element, widget.options?.element]) { if (!(element instanceof HTMLElement)) continue; element.hidden = true; element.setAttribute("aria-hidden", "true"); element.style.setProperty("display", "none", "important"); } } function buildTagTransferDirectoryUI(node) { const savedWidget = getWidget(node, TAG_TRANSFER_DIRECTORY_WIDGET); if (!savedWidget) return; if (node.__hpsTagTransferDirectoryUI) { node.__hpsTagTransferDirectoryUI.input.value = String(savedWidget.value ?? ""); return; } savedWidget.label = "Tag Transfer Directory (full path only / network paths OK)"; savedWidget.options = { ...(savedWidget.options || {}), placeholder: TAG_TRANSFER_DIRECTORY_PLACEHOLDER }; if (typeof node.addDOMWidget !== "function") return; const root = document.createElement("div"); root.style.display = "flex"; root.style.flexDirection = "column"; root.style.gap = "4px"; root.style.width = "100%"; root.style.height = "58px"; root.style.boxSizing = "border-box"; root.style.padding = "2px 0"; const label = document.createElement("label"); label.textContent = "Tag Transfer Directory (full path only / network paths OK)"; label.style.fontSize = "11px"; label.style.color = "rgba(230,230,230,0.88)"; label.style.userSelect = "none"; const input = document.createElement("input"); input.type = "text"; input.spellcheck = false; input.value = String(savedWidget.value ?? ""); input.placeholder = TAG_TRANSFER_DIRECTORY_PLACEHOLDER; input.style.width = "100%"; input.style.height = "30px"; input.style.boxSizing = "border-box"; input.style.padding = "5px 8px"; input.style.border = "1px solid rgba(200,220,245,0.42)"; input.style.borderRadius = "4px"; input.style.background = "rgba(20,20,20,0.72)"; input.style.color = "inherit"; input.style.fontFamily = "monospace"; input.style.fontSize = "11px"; root.append(label, input); for (const eventName of ["pointerdown", "mousedown", "mouseup", "click", "dblclick", "keydown", "keyup"]) { input.addEventListener(eventName, (event) => event.stopPropagation()); } input.addEventListener("input", () => { savedWidget.value = input.value; savedWidget.callback?.(input.value); node.graph?.change?.(); app.graph?.setDirtyCanvas?.(true, true); }); input.addEventListener("change", () => { savedWidget.value = input.value; }); try { const domWidget = node.addDOMWidget("tag_transfer_directory_editor", "hps-tag-transfer-directory", root, { serialize: false, hideOnZoom: false, getValue: () => input.value, setValue: (value) => { input.value = String(value ?? ""); savedWidget.value = input.value; }, }); domWidget.computeSize = (width) => [width, 62]; node.__hpsTagTransferDirectoryUI = { root, label, input, domWidget }; hideTagTransferDirectorySavedWidget(savedWidget); } catch (error) { console.warn("[CheckpointHandpickerSuite] failed to create Tag Transfer Directory editor", error); } } function tagTransferRects(node) { const margin = 8; const gap = 8; const buttonW = 118; const buttonH = 28; return { exportButton: { x: margin, y: 76, w: buttonW, h: buttonH }, importButton: { x: margin + buttonW + gap, y: 76, w: buttonW, h: buttonH }, result: { x: margin, y: 116, w: Math.max(1, node.size[0] - margin * 2), h: Math.max(1, node.size[1] - 126) }, }; } function wrapTagTransferText(ctx, text, maxWidth) { const lines = []; for (const rawLine of String(text || "").split("\n")) { if (!rawLine) { lines.push(""); continue; } let current = ""; for (const char of rawLine) { const candidate = current + char; if (current && ctx.measureText(candidate).width > maxWidth) { lines.push(current); current = char; } else { current = candidate; } } lines.push(current); } return lines; } function tagTransferCursorAt(node, local) { if (!local) return ""; const r = tagTransferRects(node); if (hit(local, r.exportButton) || hit(local, r.importButton)) { return node.__hpsTagTransferBusy ? "not-allowed" : "pointer"; } return ""; } async function runTagTransfer(node, operation) { if (!node || node.__hpsTagTransferBusy) return; node.__hpsTagTransferBusy = true; node.__hpsTagTransferScroll = 0; node.__hpsTagTransferResult = operation === "export" ? "Exporting..." : "Importing..."; app.graph?.setDirtyCanvas?.(true, true); try { const response = await api.fetchApi(`/${EXTENSION_PREFIX}/tag_transfer/${operation}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ directory: tagTransferDirectoryValue(node) }), }); let result = null; try { result = await response.json(); } catch { result = null; } if (operation === "import" && result?.ok && Array.isArray(result.changes)) { applyTagTransferChanges({ scope: "global", changes: result.changes }); } if (result?.message) { node.__hpsTagTransferResult = result.message; } else if (result?.error) { node.__hpsTagTransferResult = `${operation === "export" ? "Export" : "Import"} failed.\n\n${result.error}`; } else { node.__hpsTagTransferResult = `${operation === "export" ? "Export" : "Import"} failed.\n\nHTTP ${response.status}`; } } catch (error) { node.__hpsTagTransferResult = `${operation === "export" ? "Export" : "Import"} failed.\n\n${error}`; } finally { node.__hpsTagTransferBusy = false; app.graph?.setDirtyCanvas?.(true, true); } } function setupTagTransferNode(nodeType) { installMinSize(nodeType, 420, 320); installCursorCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = origCreated ? origCreated.apply(this, arguments) : undefined; ensureSize(this, 420, 320); buildTagTransferDirectoryUI(this); this.__hpsTagTransferBusy = false; this.__hpsTagTransferResult = "Ready."; this.__hpsTagTransferScroll = 0; return result; }; const origConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const result = origConfigure ? origConfigure.apply(this, arguments) : undefined; setTimeout(() => buildTagTransferDirectoryUI(this), 0); return result; }; const origDraw = nodeType.prototype.onDrawBackground; nodeType.prototype.onDrawBackground = function (ctx) { if (origDraw) origDraw.apply(this, arguments); if (hpsNodeCollapsed(this)) return; const r = tagTransferRects(this); const enabled = !this.__hpsTagTransferBusy; drawButton(ctx, r.exportButton, "๐Ÿ“ค Export", enabled); drawButton(ctx, r.importButton, "๐Ÿ“ฅ Import", enabled); ctx.save(); ctx.fillStyle = "rgba(12,12,12,0.72)"; ctx.strokeStyle = "rgba(210,210,210,0.28)"; ctx.lineWidth = 1; drawRounded(ctx, r.result.x, r.result.y, r.result.w, r.result.h, 5); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.rect(r.result.x + 1, r.result.y + 1, r.result.w - 2, r.result.h - 2); ctx.clip(); ctx.fillStyle = "#ddd"; ctx.font = "12px monospace"; ctx.textAlign = "left"; ctx.textBaseline = "top"; const lineHeight = 16; const lines = wrapTagTransferText(ctx, this.__hpsTagTransferResult || "Ready.", r.result.w - 18); const visibleLines = Math.max(1, Math.floor((r.result.h - 12) / lineHeight)); const maxScroll = Math.max(0, lines.length - visibleLines); this.__hpsTagTransferMaxScroll = maxScroll; this.__hpsTagTransferScroll = Math.max(0, Math.min(maxScroll, this.__hpsTagTransferScroll || 0)); const start = this.__hpsTagTransferScroll; lines.slice(start, start + visibleLines).forEach((line, index) => { ctx.fillText(line, r.result.x + 7, r.result.y + 7 + index * lineHeight, r.result.w - 18); }); if (maxScroll > 0) { const trackH = r.result.h - 12; const thumbH = Math.max(18, trackH * (visibleLines / lines.length)); const thumbY = r.result.y + 6 + (trackH - thumbH) * (start / maxScroll); ctx.fillStyle = "rgba(220,220,220,0.35)"; ctx.fillRect(r.result.x + r.result.w - 7, thumbY, 3, thumbH); } ctx.restore(); }; const origMouseDown = nodeType.prototype.onMouseDown; nodeType.prototype.onMouseDown = function (event, pos) { if (hpsNodeCollapsed(this)) return origMouseDown ? origMouseDown.apply(this, arguments) : false; const r = tagTransferRects(this); if (hitAny(this, pos, r.exportButton)) { if (!this.__hpsTagTransferBusy) runTagTransfer(this, "export"); return true; } if (hitAny(this, pos, r.importButton)) { if (!this.__hpsTagTransferBusy) runTagTransfer(this, "import"); return true; } return origMouseDown ? origMouseDown.apply(this, arguments) : false; }; const origMouseWheel = nodeType.prototype.onMouseWheel; nodeType.prototype.onMouseWheel = function (event) { if (hpsNodeCollapsed(this)) return origMouseWheel ? origMouseWheel.apply(this, arguments) : false; const local = graphEventToLocal(this, event); if (hit(local, tagTransferRects(this).result) && (this.__hpsTagTransferMaxScroll || 0) > 0) { const direction = event.deltaY > 0 ? 3 : -3; this.__hpsTagTransferScroll = Math.max(0, Math.min(this.__hpsTagTransferMaxScroll, (this.__hpsTagTransferScroll || 0) + direction)); app.graph?.setDirtyCanvas?.(true, true); event?.preventDefault?.(); event?.stopPropagation?.(); return true; } return origMouseWheel ? origMouseWheel.apply(this, arguments) : false; }; } // ---------- Tagger ---------- function linkedCheckpointInputSource(node, inputName) { const index = node.inputs?.findIndex((input) => input.name === inputName) ?? -1; if (index < 0) return null; const linkId = node.inputs?.[index]?.link; if (linkId == null) return null; const link = app.graph?.links?.[linkId]; const source = link ? app.graph?.getNodeById?.(link.origin_id) : null; return source ? { link, source } : null; } function isTaggerDirectLinkBlocked(node) { const info = linkedCheckpointInputSource(node, "ckpt_name_str"); const source = info?.source; return !!(source && isNodeClass(source, SELECTOR_CLASS) && selectorActionMode(source) === "directlink" && !selectorDirectLinkEnabled(source)); } function linkedCheckpointInputValue(node, inputName) { const info = linkedCheckpointInputSource(node, inputName); const source = info?.source; const link = info?.link; if (!source) return ""; if (isNodeClass(source, CYCLER_CLASS)) return source.__hpsCyclerCkptName || ""; if (isNodeClass(source, SELECTOR_CLASS)) { const mode = selectorActionMode(source); if (mode === "directlink") return selectorDirectLinkEnabled(source) ? (selectorSelected(source) || "") : ""; return ""; } const outputName = String(source.outputs?.[link.origin_slot]?.name || "").toLowerCase(); if (outputName === "ckpt_name_str") { const ckptWidget = findCheckpointWidget(source) || findStartCheckpointWidget(source); const value = String(ckptWidget?.value || ""); return value.endsWith(".safetensors") ? value : ""; } return ""; } function currentTaggerPath(node) { if (isTaggerDirectLinkBlocked(node)) return ""; const info = linkedCheckpointInputSource(node, "ckpt_name_str"); const source = info?.source; if (source && isNodeClass(source, SELECTOR_CLASS)) { if (selectorActionMode(source) === "directlink") return selectorDirectLinkEnabled(source) ? (selectorSelected(source) || "") : ""; return node.__hpsTaggerPath || ""; } return node.__hpsTaggerPath || linkedCheckpointInputValue(node, "ckpt_name_str") || ""; } function taggerButtons(node) { // Keep positive status controls on the first row and delete separated below. const buttonW = 76; const gap = 6; const right = Math.max(442, (node.size?.[0] || 450) - 8); const topStatuses = ["god", "favorite", "nice", "keep"]; const totalW = topStatuses.length * buttonW + (topStatuses.length - 1) * gap; const startX = right - totalW; const buttons = topStatuses.map((status, i) => ({ status, x: startX + i * (buttonW + gap), y: 3, w: buttonW, h: 26, })); buttons.push({ status: "delete", x: right - buttonW, y: 35, w: buttonW, h: 26 }); return buttons; } function taggerDeleteEnabled(node) { if (!currentTaggerPath(node)) return false; const current = node.__hpsTaggerStatus || "none"; return current === "none" || current === "delete"; } function taggerButtonsEnabled(node, status) { if (!currentTaggerPath(node)) return false; return status !== "delete" || taggerDeleteEnabled(node); } async function setTaggerStatus(node, status) { const ckpt = currentTaggerPath(node); if (!ckpt) { clearTaggerTarget(node); app.graph?.setDirtyCanvas?.(true, true); return; } if (status === "delete" && !taggerDeleteEnabled(node)) return; const response = await api.fetchApi(`/${EXTENSION_PREFIX}/tagger/set_status`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(tabPayload({ ckpt_name_str: ckpt, status })), }); const result = await response.json(); if (result.ok) { node.__hpsTaggerStatus = 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"; } app.graph.setDirtyCanvas(true, true); } function taggerCursorAt(node, local) { if (!local) return ""; for (const b of taggerButtons(node)) { if (!hit(local, b)) continue; if (!taggerButtonsEnabled(node, b.status)) return "not-allowed"; return "pointer"; } return ""; } function normalizeTaggerHeight(node) { if (!node?.size) return; // v9l moves the message area up by 21px. Shrink only default-ish old layouts; // user-expanded nodes should keep their chosen height. if (node.size[1] > 107 && node.size[1] <= 128) node.size[1] = 107; } function setupTaggerNode(nodeType) { installMinSize(nodeType, 450, 107); installTabIdSupport(nodeType); installCursorCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; ensureSize(this, 450, 107); normalizeTaggerHeight(this); 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; normalizeTaggerHeight(this); setTimeout(() => restoreNodeStateFromBackend(this, TAGGER_CLASS), 0); return r; }; const origDraw = nodeType.prototype.onDrawBackground; nodeType.prototype.onDrawBackground = function (ctx) { if (origDraw) origDraw.apply(this, arguments); ensureHiddenTabIdWidget(this); if (hpsNodeCollapsed(this)) return; ctx.save(); const p = currentTaggerPath(this); const linkedSource = linkedCheckpointInputSource(this, "ckpt_name_str")?.source; const current = p ? (this.__hpsTaggerStatus || selectorStatusFor(linkedSource, p) || "none") : "none"; for (const b of taggerButtons(this)) { const enabled = taggerButtonsEnabled(this, b.status); const buttonColor = b.status === "delete" && enabled ? "rgba(105,90,90,0.65)" : null; drawButton( ctx, b, `${STATUS_ICON[b.status]} ${STATUS_LABEL[b.status]}`, enabled, p && current === b.status, buttonColor, { textColor: enabled ? undefined : "#888" } ); } ctx.fillStyle = "#ddd"; ctx.font = "12px sans-serif"; if (p) { ctx.fillText(p, 8, 55); const msg = this.__hpsTaggerMessage || taggerCurrentMessage(current); ctx.fillStyle = current === "none" ? "#ccc" : "#ddd"; ctx.fillText(msg, 8, 75); if (current !== "none" && current !== "delete") { ctx.fillStyle = "#aaa"; ctx.fillText("Delete is available only from none.", 8, 95); } else if (current === "delete") { ctx.fillStyle = "#aaa"; ctx.fillText("Delete script exported to output. ([y/N])", 8, 95); } } else { ctx.fillStyle = "#ccc"; ctx.fillText(taggerCurrentMessage("none"), 8, 55); } ctx.restore(); }; const origMouseDown = nodeType.prototype.onMouseDown; nodeType.prototype.onMouseDown = function (e, pos) { if (hpsNodeCollapsed(this)) return origMouseDown ? origMouseDown.apply(this, arguments) : false; for (const b of taggerButtons(this)) { if (hitAny(this, pos, b)) { if (!taggerButtonsEnabled(this, b.status)) return true; setTaggerStatus(this, b.status); return true; } } return origMouseDown ? origMouseDown.apply(this, arguments) : false; }; } api.addEventListener(TAGGER_EVENT, ({ detail }) => { const node = nodeFromEvent(detail, TAGGER_CLASS); if (!node) return; if (isTaggerDirectLinkBlocked(node)) { clearTaggerTarget(node); app.graph.setDirtyCanvas(true, true); return; } node.__hpsTaggerPath = detail.ckpt_name_str; node.__hpsTaggerStatus = 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); selectorGlobalRefreshTimer = setTimeout(() => { for (const node of app.graph?._nodes || []) { if (isNodeClass(node, SELECTOR_CLASS)) refreshSelector(node, false); } }, 400); } 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 (selectorDirectLinkEnabled(node)) updateDirectLinkTaggers(node); } } 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); } // Cycler title/status are rebuilt from backend state via CYCLER_EVENT. // Do not patch the existing Cycler title here; stale status icons can accumulate. } scheduleSelectorGlobalRefresh(); app.graph.setDirtyCanvas(true, true); }); function applyTagTransferChanges(detail) { if (!isForThisTab(detail) || !Array.isArray(detail?.changes)) return; const changes = new Map(detail.changes.map((change) => [change.ckpt_name_str, change])); const execution = getExecutionState(); if (execution && changes.has(execution.ckpt_name_str)) { const change = changes.get(execution.ckpt_name_str); execution.status = change.status || "none"; execution.status_icon = change.status_icon || STATUS_ICON[execution.status] || ""; } for (const node of app.graph?._nodes || []) { if (isNodeClass(node, SELECTOR_CLASS)) { for (const item of selectorItems(node)) { const change = changes.get(item.ckpt_name_str); if (change) item.status = change.status || "none"; } const selected = selectorSelected(node); if (selected && changes.has(selected)) { node.title = selectorTitleText(node, selected); if (selectorDirectLinkEnabled(node)) updateDirectLinkTaggers(node); } } if (PREVIEW_CLASSES.has(node.type || node.comfyClass)) { const change = changes.get(node.__hpsPreviewCkptName); if (change) { node.__hpsPreviewStatus = change.status || "none"; setPreviewTitleFromCheckpoint(node, change.ckpt_name_str, node.__hpsPreviewStatus); } } if (isNodeClass(node, TAGGER_CLASS)) { const path = currentTaggerPath(node); const change = changes.get(path); if (change) { node.__hpsTaggerStatus = change.status || "none"; node.__hpsTaggerMessage = taggerCurrentMessage(node.__hpsTaggerStatus); patchCheckpointTitle(node, "Tagger", path, node.__hpsTaggerStatus); } } if (isNodeClass(node, CYCLER_CLASS)) { const change = changes.get(node.__hpsCyclerCkptName); if (change) node.__hpsCyclerStatusValue = change.status || "none"; } } scheduleSelectorGlobalRefresh(); app.graph?.setDirtyCanvas?.(true, true); } api.addEventListener(TAG_TRANSFER_EVENT, ({ detail }) => { applyTagTransferChanges(detail); }); // ---------- Cycler ---------- 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 }, filter, statusBox: { x: 8, y: 140, w: node.size[0] - 16, h: Math.max(80, node.size[1] - 150) }, }; } function cyclerActiveFilter(node) { return node.__hpsFilterStatuses || []; } async function pushCyclerFlags(node) { syncCyclerSettingsWidgets(node); 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, ...getCyclerRuntimeControls(node), })), }); 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`, { 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), })), }); 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`, { 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 ""; const r = cyclerRects(node); if (hit(local, r.localListToggle) || hit(local, r.clearLocalList)) return "pointer"; for (const b of r.filter) { if (hit(local, b)) return "pointer"; } return ""; } function setupCyclerNode(nodeType) { installMinSize(nodeType, 560, 260); installTabIdSupport(nodeType); installCursorCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; ensureSize(this, 560, 260); restoreCyclerSettingsFromWidgets(this); 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); 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); 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); 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"); lines.forEach((line, i) => ctx.fillText(line, r.statusBox.x + 8, r.statusBox.y + 18 + i * 14)); ctx.restore(); }; const origMouseDown = nodeType.prototype.onMouseDown; nodeType.prototype.onMouseDown = function (e, pos) { if (hpsNodeCollapsed(this)) return origMouseDown ? origMouseDown.apply(this, arguments) : false; 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); return true; } if (hitAny(this, pos, r.clearLocalList)) { clearLocalList(this); return true; } for (const b of r.filter) { if (!hitAny(this, pos, b)) continue; if (b.status === "all") { this.__hpsFilterStatuses = []; } else { const set = new Set(this.__hpsFilterStatuses || []); 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); return true; } return origMouseDown ? origMouseDown.apply(this, arguments) : false; }; } api.addEventListener(CYCLER_EVENT, ({ detail }) => { const node = nodeFromEvent(detail, CYCLER_CLASS); if (!node) return; applyCyclerStatePayload(node, detail); app.graph.setDirtyCanvas(true, true); }); app.registerExtension({ name: EXT, async beforeRegisterNodeDef(nodeType, nodeData) { installCheckpointRefreshFuturePatch(nodeType, nodeData); if (PREVIEW_CLASSES.has(nodeData.name)) return setupPreviewNode(nodeType); if (nodeData.name === SELECTOR_CLASS) return setupSelectorNode(nodeType); if (nodeData.name === TAGGER_CLASS) return setupTaggerNode(nodeType); if (nodeData.name === TAG_TRANSFER_CLASS) return setupTagTransferNode(nodeType); if (nodeData.name === CYCLER_CLASS) return setupCyclerNode(nodeType); }, });