2935 lines
109 KiB
JavaScript
2935 lines
109 KiB
JavaScript
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);
|
||
},
|
||
});
|