Files

487 lines
22 KiB
JavaScript

// ManualSampler -- save / load / delete preset UI for the Manual
// Sampler node. The node itself is a regular widget-only node; the
// only frontend concern is the preset management (mirrors the Sigma
// Curves preset UI).
import { app } from "../../scripts/app.js";
// ----- Themed dialogs (same primitives as sigma_curves.js) -----------
const _DIALOG_BG = "var(--p-overlay-modal-background, var(--comfy-menu-bg, #2a2a2a))";
const _DIALOG_FG = "var(--p-text-color, var(--fg-color, #eee))";
const _DIALOG_BORDER = "var(--p-overlay-modal-border-color, var(--border-color, #444))";
const _INPUT_BG = "var(--p-form-field-background, var(--comfy-input-bg, #1a1a1a))";
const _INPUT_FG = "var(--p-form-field-color, var(--input-text, #eee))";
const _INPUT_BORDER = "var(--p-form-field-border-color, var(--border-color, #555))";
const _BTN_BG = "var(--p-button-secondary-background, var(--comfy-input-bg, #3a3a3a))";
const _BTN_FG = "var(--p-button-secondary-color, var(--fg-color, #eee))";
const _BTN_BORDER = "var(--p-button-secondary-border-color, var(--border-color, #555))";
const _BTN_PRIMARY_BG = "var(--p-button-primary-background, var(--p-primary-color, #4a8cd0))";
const _BTN_PRIMARY_FG = "var(--p-button-primary-color, #fff)";
const _BTN_PRIMARY_BORDER = "var(--p-button-primary-border-color, var(--p-primary-color, #4a8cd0))";
function _overlay() {
const el = document.createElement("div");
el.style.cssText = `
position: fixed; inset: 0;
background: rgba(0,0,0,0.55);
z-index: 10000;
display: flex; align-items: center; justify-content: center;
font-family: var(--p-font-family, system-ui, -apple-system, "Segoe UI", sans-serif);
`;
return el;
}
function _dialog(minWidth = 360) {
const d = document.createElement("div");
d.style.cssText = `
background: ${_DIALOG_BG}; color: ${_DIALOG_FG};
border: 1px solid ${_DIALOG_BORDER};
border-radius: 6px; padding: 18px 20px 16px;
min-width: ${minWidth}px; max-width: 520px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
`;
return d;
}
function _btn(label, primary = false, danger = false) {
const b = document.createElement("button");
b.textContent = label;
let bg = _BTN_BG, fg = _BTN_FG, bd = _BTN_BORDER;
if (primary) { bg = _BTN_PRIMARY_BG; fg = _BTN_PRIMARY_FG; bd = _BTN_PRIMARY_BORDER; }
if (danger) {
bg = "var(--p-button-danger-background, #c44)";
fg = "var(--p-button-danger-color, #fff)";
bd = "var(--p-button-danger-border-color, #c44)";
}
b.style.cssText = `
background: ${bg}; color: ${fg}; border: 1px solid ${bd};
padding: 6px 14px; border-radius: 4px; cursor: pointer;
font-size: 13px; font-family: inherit; min-width: 72px;
`;
b.onmouseenter = () => { b.style.opacity = "0.85"; };
b.onmouseleave = () => { b.style.opacity = "1"; };
return b;
}
function showThemedPrompt({ title, message, defaultValue = "", placeholder = "",
okLabel = "Save", cancelLabel = "Cancel" }) {
return new Promise((resolve) => {
const ov = _overlay(); const dlg = _dialog(380);
if (title) {
const t = document.createElement("div");
t.textContent = title;
t.style.cssText = "font-weight:600;font-size:14px;margin-bottom:10px;";
dlg.appendChild(t);
}
if (message) {
const m = document.createElement("div");
m.textContent = message;
m.style.cssText = "font-size:12px;line-height:1.5;opacity:0.85;margin-bottom:12px;white-space:pre-wrap;";
dlg.appendChild(m);
}
const inp = document.createElement("input");
inp.type = "text"; inp.value = defaultValue; inp.placeholder = placeholder;
inp.style.cssText = `
width: 100%; box-sizing: border-box;
background: ${_INPUT_BG}; color: ${_INPUT_FG};
border: 1px solid ${_INPUT_BORDER};
padding: 7px 9px; border-radius: 4px; font-size: 13px;
font-family: inherit; outline: none;
`;
dlg.appendChild(inp);
const row = document.createElement("div");
row.style.cssText = "display:flex;justify-content:flex-end;gap:8px;margin-top:14px;";
const cb = _btn(cancelLabel, false), ob = _btn(okLabel, true);
row.appendChild(cb); row.appendChild(ob); dlg.appendChild(row);
ov.appendChild(dlg); document.body.appendChild(ov);
setTimeout(() => { inp.focus(); inp.select(); }, 0);
const close = (v) => { ov.parentNode && ov.parentNode.removeChild(ov); resolve(v); };
cb.onclick = () => close(null);
ob.onclick = () => close(inp.value);
ov.onclick = (e) => { if (e.target === ov) close(null); };
inp.onkeydown = (e) => {
if (e.key === "Enter") { e.preventDefault(); close(inp.value); }
if (e.key === "Escape") { e.preventDefault(); close(null); }
};
});
}
function showThemedConfirm({ title, message, okLabel = "OK",
cancelLabel = "Cancel", danger = false }) {
return new Promise((resolve) => {
const ov = _overlay(); const dlg = _dialog(340);
if (title) {
const t = document.createElement("div");
t.textContent = title;
t.style.cssText = "font-weight:600;font-size:14px;margin-bottom:10px;";
dlg.appendChild(t);
}
const m = document.createElement("div");
m.textContent = message || "";
m.style.cssText = "font-size:13px;line-height:1.5;white-space:pre-wrap;";
dlg.appendChild(m);
const row = document.createElement("div");
row.style.cssText = "display:flex;justify-content:flex-end;gap:8px;margin-top:16px;";
const cb = _btn(cancelLabel, false), ob = _btn(okLabel, !danger, danger);
row.appendChild(cb); row.appendChild(ob); dlg.appendChild(row);
ov.appendChild(dlg); document.body.appendChild(ov);
setTimeout(() => ob.focus(), 0);
const close = (v) => { ov.parentNode && ov.parentNode.removeChild(ov); resolve(v); };
cb.onclick = () => close(false); ob.onclick = () => close(true);
ov.onclick = (e) => { if (e.target === ov) close(false); };
document.addEventListener("keydown", function onKey(e) {
if (!ov.parentNode) { document.removeEventListener("keydown", onKey, true); return; }
if (e.key === "Escape") { e.preventDefault(); close(false); }
if (e.key === "Enter") { e.preventDefault(); close(true); }
}, true);
});
}
function showToast(message, kind = "info", duration = 3200) {
const t = document.createElement("div");
t.textContent = message;
const accent =
kind === "error" ? "var(--p-button-danger-background, #c44)" :
kind === "success" ? "var(--p-button-success-background, #5a4)" :
kind === "warn" ? "var(--p-button-warn-background, #d80)" :
"var(--p-primary-color, #4a8cd0)";
t.style.cssText = `
position: fixed; bottom: 24px; right: 24px;
background: ${_DIALOG_BG}; color: ${_DIALOG_FG};
border: 1px solid ${_DIALOG_BORDER};
border-left: 4px solid ${accent};
padding: 10px 16px; border-radius: 4px;
z-index: 10001; font-size: 13px; max-width: 380px;
font-family: var(--p-font-family, system-ui, sans-serif);
box-shadow: 0 4px 14px rgba(0,0,0,0.45);
white-space: pre-wrap; line-height: 1.4;
opacity: 0; transform: translateY(8px);
transition: opacity 0.18s, transform 0.18s;
`;
document.body.appendChild(t);
requestAnimationFrame(() => {
t.style.opacity = "1"; t.style.transform = "translateY(0)";
});
setTimeout(() => {
t.style.opacity = "0"; t.style.transform = "translateY(8px)";
setTimeout(() => t.parentNode && t.parentNode.removeChild(t), 250);
}, duration);
}
// ----- Server helpers -------------------------------------------------
async function refreshNodeDefs() {
try {
const cmd = app?.extensionManager?.command;
if (cmd && typeof cmd.execute === "function") {
await cmd.execute("Comfy.RefreshNodeDefinitions");
}
} catch (e) {
console.warn("[ManualSampler] RefreshNodeDefinitions failed:", e);
}
}
async function fetchSamplerInfo(name) {
try {
const r = await fetch(
`/RES4SHO/manual_sampler/sampler_info?name=${encodeURIComponent(name)}`);
if (!r.ok) return { params: [], accepts_var_kwargs: false };
return await r.json();
} catch (e) {
console.warn("[ManualSampler] sampler_info fetch failed:", e);
return { params: [], accepts_var_kwargs: false };
}
}
async function listPresets() {
try {
const r = await fetch("/RES4SHO/manual_sampler/presets");
if (!r.ok) throw new Error("HTTP " + r.status);
return await r.json();
} catch (e) {
console.warn("[ManualSampler] listPresets failed:", e);
return { presets: {}, prefix: "manual_sampler_" };
}
}
async function savePreset(name, base_sampler, eta_override, s_noise, stages) {
const r = await fetch("/RES4SHO/manual_sampler/preset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name, base_sampler, eta_override, s_noise, stages,
}),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || ("HTTP " + r.status));
return data;
}
async function deletePreset(name) {
const r = await fetch(
`/RES4SHO/manual_sampler/preset?name=${encodeURIComponent(name)}`,
{ method: "DELETE" });
const data = await r.json().catch(() => ({}));
return !!data.ok;
}
let _lastInteractionEvent = null;
if (typeof window !== "undefined") {
document.addEventListener("mousedown", (e) => { _lastInteractionEvent = e; }, true);
document.addEventListener("pointerdown", (e) => { _lastInteractionEvent = e; }, true);
}
function _evtFromMouse() {
const lm = app?.canvas?.last_mouse;
if (Array.isArray(lm) && lm.length >= 2) {
return { clientX: lm[0], clientY: lm[1] };
}
return { clientX: 200, clientY: 200 };
}
// ----- Extension registration -----------------------------------------
app.registerExtension({
name: "RES4SHO.ManualSampler",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== "ManualSampler") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
const node = this;
const baseW = this.widgets?.find(w => w.name === "base_sampler");
const stagesW = this.widgets?.find(w => w.name === "stages");
const etaW = this.widgets?.find(w => w.name === "eta_override");
const snW = this.widgets?.find(w => w.name === "s_noise");
const dataW = this.widgets?.find(w => w.name === "preset_data");
// Hide preset_data; it's edit-via-frontend storage only.
// Coerce its value to a string and override serializeValue
// so that third-party extensions hooking string widgets (e.g.
// the presetText extension which calls value.replace(...)) can
// never receive null and crash graphToPrompt.
if (dataW) {
dataW.type = "hidden";
dataW.computeSize = () => [0, -4];
if (dataW.value == null) dataW.value = "";
const origSerialize = dataW.serializeValue;
dataW.serializeValue = function () {
const v = (typeof origSerialize === "function")
? origSerialize.apply(this, arguments) : this.value;
return v == null ? "" : v;
};
}
// Toggle a number widget's visibility by collapsing its row.
// Use an explicit state flag because some widgets default
// to ``computeSize === undefined`` (LiteGraph supplies the
// default). A naive "restore only if the saved value isn't
// undefined" guard leaves the widget permanently collapsed
// after the first hide/show cycle.
function setHidden(widget, hidden) {
if (!widget) return;
if (hidden) {
if (widget._ms_hidden_state !== "hidden") {
widget._ms_origType = widget.type;
widget._ms_origComputeSize = widget.computeSize;
widget._ms_hidden_state = "hidden";
}
widget.type = "hidden";
widget.computeSize = () => [0, -4];
} else {
if (widget._ms_hidden_state === "hidden") {
widget.type = widget._ms_origType;
widget.computeSize = widget._ms_origComputeSize;
widget._ms_hidden_state = "shown";
}
}
}
async function refreshSamplerInfo() {
if (!baseW || !baseW.value) return;
const info = await fetchSamplerInfo(baseW.value);
const params = (info.params || []).map(p => p.name);
const paramSet = new Set(params);
const acceptsEta = paramSet.has("eta") || info.accepts_var_kwargs;
const acceptsSn = paramSet.has("s_noise") || info.accepts_var_kwargs;
const acceptsStages = paramSet.has("stages") || info.accepts_var_kwargs;
setHidden(stagesW, !acceptsStages);
setHidden(etaW, !acceptsEta);
setHidden(snW, !acceptsSn);
// Surface the accepted-params info via the base_sampler
// widget's tooltip + a console line so the user can see
// what the chosen sampler actually exposes (including
// non-standard knobs like eta_peak / s_churn / r that we
// can't bind to a widget without per-sampler UI).
const standard = new Set(["eta", "s_noise", "stages"]);
const others = params.filter(n => !standard.has(n));
const widgetParts = [];
if (acceptsStages) widgetParts.push("stages");
if (acceptsEta) widgetParts.push("eta");
if (acceptsSn) widgetParts.push("s_noise");
let summary;
if (info.accepts_var_kwargs) {
summary = "accepts **kwargs (any)";
} else if (params.length === 0) {
summary = "no tunable knobs";
} else {
summary = "tunable: " + params.join(", ");
}
if (baseW) {
baseW.options = Object.assign(baseW.options || {}, {
tooltip: `${baseW.value}: ${summary}`,
});
}
console.debug(
`[ManualSampler] ${baseW.value}: `,
`widget-bound=[${widgetParts.join(", ") || "none"}], `,
`other params=[${others.join(", ") || "none"}], `,
`var_kwargs=${!!info.accepts_var_kwargs}`);
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
}
// Wrap the base_sampler combo's callback so that picking a
// new sampler refreshes the info / visibility.
if (baseW) {
const orig = baseW.callback;
baseW.callback = function (...args) {
const ret = orig?.apply(this, args);
refreshSamplerInfo();
return ret;
};
// Initial paint
refreshSamplerInfo();
}
this.addWidget(
"button", "save preset…", null,
async () => {
if (!baseW) return;
const name = await showThemedPrompt({
title: "Save manual sampler preset",
message: "Allowed: letters, digits, spaces, dashes, "
+ "underscores (max 64 chars). The preset will appear "
+ "in every sampler dropdown as "
+ "\"manual_sampler_<name>\" once node definitions "
+ "refresh.",
placeholder: "e.g. my_dpmpp_eta",
okLabel: "Save",
});
if (name === null) return;
const trimmed = String(name).trim();
if (!trimmed) return;
try {
const eta = (etaW?.value ?? -1);
// Only persist stages / s_noise if the chosen sampler
// accepts them -- otherwise the saved preset would
// carry an inert value that misleads on reload.
// We rely on the type the widget is currently set to
// (set by setHidden) so the check is robust even if
// the widget never had its hidden state cycled.
const stagesAccepted = stagesW && stagesW.type !== "hidden";
const snAccepted = snW && snW.type !== "hidden";
const result = await savePreset(
trimmed,
baseW.value,
eta < 0 ? null : eta,
snAccepted ? (snW.value ?? null) : null,
stagesAccepted ? (stagesW.value ?? null) : null,
);
await refreshNodeDefs();
showToast(
`Saved "${trimmed}".\n`
+ `Available as "${result.sampler || ("manual_sampler_" + trimmed)}" `
+ "in sampler dropdowns.",
"success");
} catch (e) {
showToast("Save failed: " + e.message, "error");
}
},
{ serialize: false },
);
this.addWidget(
"button", "load preset…", null,
async () => {
const data = await listPresets();
const names = Object.keys(data.presets || {});
if (!names.length) {
showToast(
"No saved presets yet. Configure a sampler "
+ "and use \"save preset…\" first.", "info");
return;
}
const evt = _lastInteractionEvent || _evtFromMouse();
new LiteGraph.ContextMenu(names, {
event: evt,
callback: (selected) => {
const p = data.presets[selected];
if (!p) return;
if (baseW && p.base_sampler) baseW.value = p.base_sampler;
if (stagesW && p.stages != null) stagesW.value = p.stages;
if (etaW) etaW.value = (p.eta_override == null) ? -1 : p.eta_override;
if (snW) snW.value = (p.s_noise == null) ? 1.0 : p.s_noise;
if (dataW) dataW.value = JSON.stringify({
loaded_from: selected,
base_sampler: p.base_sampler,
stages: p.stages,
eta_override: p.eta_override,
s_noise: p.s_noise,
});
// Fire the wrapped callback so visibility
// toggles for the loaded sampler.
if (baseW && typeof baseW.callback === "function") {
baseW.callback(baseW.value, app.canvas, this);
}
this.setDirtyCanvas?.(true, true);
showToast(`Loaded "${selected}".`, "success", 2000);
},
});
},
{ serialize: false },
);
this.addWidget(
"button", "delete preset…", null,
async () => {
const data = await listPresets();
const names = Object.keys(data.presets || {});
if (!names.length) {
showToast("No saved presets to delete.", "info");
return;
}
const evt = _lastInteractionEvent || _evtFromMouse();
new LiteGraph.ContextMenu(names, {
event: evt,
callback: async (selected) => {
const ok = await showThemedConfirm({
title: "Delete preset?",
message: `Delete preset "${selected}"?\n\n`
+ "This also unregisters its "
+ `"manual_sampler_${selected}" sampler.`,
okLabel: "Delete",
danger: true,
});
if (!ok) return;
const success = await deletePreset(selected);
if (success) {
await refreshNodeDefs();
showToast(`Deleted "${selected}".`, "success", 2000);
} else {
showToast("Delete failed.", "error");
}
},
});
},
{ serialize: false },
);
return r;
};
},
});