Files
ausboss 05038baaa3 Five basics the normal nodes have: sums, any wire into Show Text, name tokens, word weights, Image Size's numbers
Found in a sweep of the pack against ComfyUI's own nodes: things the normal
node does that the pack's node did not.

- Every number box takes a sum. Click a number, type 1024*1.5 or
  (1920-64)/2 and press Enter, as in ComfyUI's own number boxes: + - * / %,
  brackets and a sign. The reader is the pack's own (sumValue in
  js/shared/scrub_input.mjs) and is only asked when the text is not a plain
  number.
- Show Text takes any wire. Its input was STRING only. A number, an on/off
  value or a list is shown too; a picture or a latent is shown by its size.
  The output stays STRING and carries the text that is shown, so text
  passes through unchanged and a number comes out as text.
- Save Image takes name tokens in the file name, as ComfyUI's own Save
  Image does: %date:yyyy-MM-dd% and %Node.setting% are filled in the
  browser when a run is sent (js/shared/name_tokens.mjs, now shared with
  Save Video), %width% %height% and the time tokens on the server. The
  path preview shows them. A linked filename is never read for tokens.
- Ctrl + Up and Ctrl + Down change a word's weight in the pack's prompt
  boxes (Text, Krea 2 Encode). The card's text box stopped the key before
  ComfyUI's own listener saw it; those two keys now go on from a multiline
  box, and from nowhere else.
- Image Size shows what it measured beside its outputs after a run
  ("width: 1024"). The numbers go into the output labels, so the node keeps
  its size. ComfyUI says nothing about a node whose answer came from its
  cache, so the numbers are kept in the node's properties and come back
  when a workflow is opened again; a changed picture wire clears them.

No input was added, renamed or reordered. Help pages, README lines, tests
and CHANGELOG entries are included.
2026-10-11 17:10:52 -05:00

393 lines
19 KiB
JavaScript

// Save Image 🆎: one compact card in place of the widget stack.
//
// Folder (with a Browse into ComfyUI's output folder), Filename, a live
// Path preview, the name tags as small chips, the Format pills and the
// Embed workflow pill. The saved picture below is js/input_preview's panel.
// The standard widgets stay the source of truth and stay serialized in
// their old order; the card only reads and writes them.
// When the `filename` input is linked the name comes from upstream: the
// field shows {{filename}} and the tags fold away, since an exact name is
// never decorated.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { installNameTokens, previewName as previewTokens } from "../shared/name_tokens.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { ensureCardCss } from "../shared/widget_card.mjs";
import { holdNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
import {
FORMAT_LABELS, TAGS, namingMode, previewFolder, previewName, tagsEnabled,
} from "../shared/save_image_naming.mjs";
const NODE_CLASS = "AUSBOSS_NODES_SaveImage";
const WIDGET_NAME = "ausboss_save_image_card";
const CSS_ID = "ausboss-save-image-css";
const NODE_MIN_WIDTH = 340;
const NAME_TITLE = "The local filename; subfolders are allowed. The tags below are appended to it. Tokens work: %date:yyyy-MM-dd%, %width%, %KSampler.seed%.";
// Captions, fields, the two-line preview, chips, pills, the embed row and
// the gaps between them; the frontend's wrapper insets ride on top.
const CARD_HEIGHT = 288;
const WRAPPER_INSET = 16;
const HIDDEN = [
"filename_prefix", "format", "save_metadata", "exact_name", "on_existing",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch",
];
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
const MONO = `ui-monospace,SFMono-Regular,Menlo,Consolas,monospace`;
function ensureCss() {
ensureCardCss();
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-save{box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;gap:6px;padding:8px 9px;overflow:hidden;border:1px solid rgba(0,180,170,.22);border-radius:8px;background:rgba(0,0,0,.28);font:12px/1.3 ${FONT};color:#c8dddd;pointer-events:none}
.ausboss-save-cap{height:14px;flex:none;color:#5f7674;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;user-select:none}
.ausboss-save-row{display:flex;align-items:center;gap:6px;flex:none;height:26px;pointer-events:auto}
.ausboss-save .ausboss-card-seg.ausboss-save-format{height:36px;flex:none;pointer-events:auto}
.ausboss-save .ausboss-save-format button{font-size:12px}
.ausboss-save-row.linked .ausboss-card-text{color:#8ba3a1;font-family:${MONO}}
.ausboss-save-tag{flex:none;color:${BRAND};font-size:11px;padding:0 4px;user-select:none}
.ausboss-save-preview{flex:none;padding:6px 9px;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;font-family:${MONO};overflow:hidden;pointer-events:auto}
.ausboss-save-preview .folder{color:#5f7674;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ausboss-save-preview .name{color:#d8ecea;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
.ausboss-save-preview.saved .name{color:${BRAND}}
.ausboss-save-chips{display:flex;flex-wrap:wrap;gap:5px;flex:none;pointer-events:auto}
.ausboss-save-chip{height:22px;padding:0 9px;border:1px solid #3a4047;border-radius:6px;background:transparent;color:#8ba3a1;font:600 10.5px/1 ${FONT};cursor:pointer;white-space:nowrap}
.ausboss-save-chip:hover{color:#fff;border-color:${BRAND}}
.ausboss-save-chip.on{background:${BRAND};border-color:${BRAND};color:#04201d}
.ausboss-save-chips.off .ausboss-save-chip{opacity:.4;pointer-events:none}
.ausboss-save-embed-label{flex:0 0 30%;color:#8ba3a1;font-size:11px;user-select:none}
.ausboss-save-browse{position:fixed;z-index:10000;width:260px;max-height:320px;overflow:auto;padding:4px;border:1px solid #3a4047;border-radius:7px;background:#1c1f23;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px/1.3 ${FONT};color:#d8ecea}
.ausboss-save-browse-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 ${FONT};letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ausboss-save-browse button{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
.ausboss-save-browse button:hover{background:#2a3037}
.ausboss-save-browse button.use{color:${BRAND};font-weight:600}
`;
document.head.append(style);
}
const el = (tag, className = "", text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
function widget(node, name) {
return node.widgets?.find((item) => item.name === name) ?? null;
}
function value(node, name, fallback) {
const target = widget(node, name);
return target ? target.value : fallback;
}
function filenameLinked(node) {
return Boolean(node.inputs?.some((input) => input?.name === "filename" && input.link !== null && input.link !== undefined));
}
function setValue(node, name, next) {
const target = widget(node, name);
if (!target || target.value === next) return false;
target.value = next;
target.callback?.(next, app.canvas, node);
node.graph?.setDirtyCanvas?.(true, true);
return true;
}
function readValues(node) {
const values = {};
for (const name of HIDDEN) values[name] = value(node, name, undefined);
values.filenameLinked = filenameLinked(node);
return values;
}
// ---------- the Browse popup: subfolders of ComfyUI's output folder ----------
let openPopup = null;
function closeBrowse() {
openPopup?.abort.abort();
openPopup?.element.remove();
openPopup = null;
}
async function listFolders(path) {
const response = await api.fetchApi(`/ausboss/save_image/folders?path=${encodeURIComponent(path)}`);
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || "Could not list folders.");
return response.json();
}
async function openBrowse(state, anchor) {
closeBrowse();
const popup = el("div", "ausboss-save-browse");
const rect = anchor.getBoundingClientRect();
popup.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 270))}px`;
popup.style.top = `${rect.bottom + 4}px`;
document.body.append(popup);
// The dismiss listeners live and die with this popup, so a second Browse
// never leaves an old one behind to close the new popup.
const abort = new AbortController();
openPopup = { element: popup, anchor, abort };
const current = String(value(state.node, "output_dir", "")).trim().replace(/\\/g, "/");
let path = /^([A-Za-z]:[\\/]|\/|~)/.test(current) ? "" : current.replace(/^\/+|\/+$/g, "");
const render = async () => {
popup.textContent = "";
popup.append(el("div", "ausboss-save-browse-head", `ComfyUI/output/${path}`));
let listing;
try {
listing = await listFolders(path);
} catch (error) {
popup.append(el("div", "ausboss-save-browse-head", error.message));
return;
}
path = listing.path;
const use = el("button", "use", path ? `Use “${path}”` : "Use the output folder");
use.addEventListener("click", () => { setValue(state.node, "output_dir", path); state.refresh(); notifyAusbossChange(); closeBrowse(); });
popup.append(use);
if (path) {
const up = el("button", "", "↑ up one level");
up.addEventListener("click", () => { path = path.split("/").slice(0, -1).join("/"); render(); });
popup.append(up);
}
for (const folder of listing.folders) {
const button = el("button", "", `📁 ${folder}`);
button.addEventListener("click", () => { path = path ? `${path}/${folder}` : folder; render(); });
popup.append(button);
}
if (!listing.folders.length) popup.append(el("div", "ausboss-save-browse-head", "no subfolders here"));
};
window.addEventListener("pointerdown", (event) => {
if (popup.contains(event.target) || event.target === anchor) return;
closeBrowse();
}, { capture: true, signal: abort.signal });
window.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
event.stopPropagation();
closeBrowse();
}, { capture: true, signal: abort.signal });
await render();
}
// ---------- the card ----------
function textField(state, name, placeholder, title) {
const input = el("input", "ausboss-card-text");
input.type = "text"; input.placeholder = placeholder; input.title = title; input.spellcheck = false;
// A field may stand in for another widget (the name field shows a legacy
// exact name); dataset.target names the widget it currently edits.
const target = () => input.dataset.target || name;
const commit = () => {
if (input.disabled) return;
if (setValue(state.node, target(), input.value.trim())) { state.savedPath = null; notifyAusbossChange(); }
state.refresh();
};
input.addEventListener("change", commit);
input.addEventListener("blur", commit);
input.addEventListener("keydown", (event) => {
keepKeyInField(event);
if (event.key === "Enter") input.blur();
if (event.key === "Escape") { input.value = String(value(state.node, target(), "")); input.blur(); }
});
return input;
}
function buildCard(node) {
ensureCss();
const root = el("div", "ausboss-save");
const state = { node, root, savedPath: null, alive: true };
node.__ausbossSaveImage = state;
for (const name of HIDDEN) {
const target = widget(node, name);
if (!target) continue;
hideWidget(target);
// The legacy caption is a multiline DOM widget: collapsing its layout is
// not enough, its textarea has to go too. Its value still saves.
if (target.element) target.element.style.display = "none";
}
// Folder
root.append(el("div", "ausboss-save-cap", "Folder"));
const folderRow = el("div", "ausboss-save-row");
const folder = textField(state, "output_dir", "ComfyUI/output", "Subfolder of ComfyUI's output folder to save into, such as sets/portraits. Empty saves to the output folder itself; Save Image never writes outside it.");
const browse = el("button", "ausboss-card-btn", "Browse");
browse.title = "Pick a subfolder of ComfyUI's output folder";
browse.addEventListener("click", () => {
if (openPopup?.anchor === browse) closeBrowse();
else openBrowse(state, browse);
});
folderRow.append(folder, browse);
root.append(folderRow);
// Filename
root.append(el("div", "ausboss-save-cap", "Filename"));
const nameRow = el("div", "ausboss-save-row");
const nameField = textField(state, "filename_prefix", "image", NAME_TITLE);
const nameTag = el("span", "ausboss-save-tag", "");
nameRow.append(nameField, nameTag);
root.append(nameRow);
// Path preview
root.append(el("div", "ausboss-save-cap", "Path preview"));
const preview = el("div", "ausboss-save-preview");
const previewFolderLine = el("div", "folder");
const previewNameLine = el("div", "name");
preview.append(previewFolderLine, previewNameLine);
preview.title = "The name this save will produce; after a run, the first file it actually wrote.";
root.append(preview);
// Tags
const chips = el("div", "ausboss-save-chips");
const chipButtons = new Map();
for (const tag of TAGS) {
const chip = el("button", "ausboss-save-chip", tag.label);
chip.type = "button"; chip.title = tag.title;
chip.addEventListener("click", () => {
if (setValue(node, tag.key, !Boolean(value(node, tag.key, false)))) { state.savedPath = null; notifyAusbossChange(); }
state.refresh();
});
chips.append(chip); chipButtons.set(tag.key, chip);
}
root.append(chips);
// Format: the pill alone, tall enough to read as the one big choice on
// the card - the three names say what it is.
const seg = el("div", "ausboss-card-seg ausboss-save-format");
const formatButtons = new Map();
const formatTitles = {
png: "Lossless and most compatible. Preserves alpha and can embed the workflow for drag-back into ComfyUI.",
"webp lossless": "Lossless and usually smaller than PNG. Preserves alpha; the workflow rides in EXIF.",
"jxl lossless": "Lossless and compact. Needs pillow-jxl-plugin in ComfyUI's python; browser and node preview support is limited.",
};
for (const [key, label] of Object.entries(FORMAT_LABELS)) {
const button = el("button", "", label);
button.type = "button"; button.title = formatTitles[key] ?? key;
button.addEventListener("click", () => { if (setValue(node, "format", key)) { state.savedPath = null; notifyAusbossChange(); } state.refresh(); });
seg.append(button); formatButtons.set(key, button);
}
root.append(seg);
// Embed workflow: a no | yes pill, the pack's boolean.
const embedRow = el("div", "ausboss-save-row");
const embedSeg = el("div", "ausboss-card-seg ausboss-card-bool");
embedSeg.setAttribute("role", "radiogroup");
const embedButtons = new Map();
const embedTitle = "Embed stores the prompt and workflow in the image for drag-back. Off writes a clean image for sharing or datasets.";
for (const [on, text] of [[false, "no"], [true, "yes"]]) {
const button = el("button", "", text);
button.type = "button"; button.title = embedTitle;
button.addEventListener("click", () => { if (setValue(node, "save_metadata", on)) notifyAusbossChange(); state.refresh(); });
embedSeg.append(button); embedButtons.set(on, button);
}
embedRow.append(el("span", "ausboss-save-embed-label", "Embed workflow"), embedSeg);
root.append(embedRow);
root.addEventListener("pointerdown", (event) => {
if (event.target.closest("button,input,select")) event.stopPropagation();
});
state.refresh = () => {
if (!state.alive) return;
const values = readValues(node);
const mode = namingMode({ filenameLinked: values.filenameLinked, exactName: values.exact_name });
if (document.activeElement !== folder) folder.value = String(values.output_dir ?? "");
nameRow.classList.toggle("linked", mode === "linked");
nameField.disabled = mode === "linked";
if (mode === "linked") {
nameField.value = "{{filename}}"; nameTag.textContent = "linked";
nameField.title = "The name comes from the filename input.";
} else if (mode === "exact") {
if (document.activeElement !== nameField) nameField.value = String(values.exact_name ?? "");
nameTag.textContent = "exact";
nameField.title = "Legacy exact_name: saved under exactly this name, no tags. Clear it to compose the name here.";
} else {
if (document.activeElement !== nameField) nameField.value = String(values.filename_prefix ?? "");
nameTag.textContent = "";
nameField.title = NAME_TITLE;
}
// An exact-mode edit writes the exact name; a local-mode edit the prefix.
nameField.dataset.target = mode === "exact" ? "exact_name" : "filename_prefix";
if (state.savedPath) {
const slash = state.savedPath.lastIndexOf("/");
previewFolderLine.textContent = slash >= 0 ? (state.savedPath.startsWith("/") ? state.savedPath.slice(0, slash + 1) : `ComfyUI/output/${state.savedPath.slice(0, slash + 1)}`) : "ComfyUI/output/";
previewNameLine.textContent = state.savedPath.slice(slash + 1);
preview.classList.add("saved");
} else {
// Name tokens show as what they will be: %date:yyyy-MM-dd% as today's date.
const shown = { ...values, filename_prefix: previewTokens(values.filename_prefix, app), exact_name: previewTokens(values.exact_name, app) };
previewFolderLine.textContent = previewFolder(shown);
previewNameLine.textContent = previewName(shown);
preview.classList.remove("saved");
}
chips.classList.toggle("off", !tagsEnabled(values));
for (const [key, chip] of chipButtons) chip.classList.toggle("on", Boolean(values[key]));
for (const [key, button] of formatButtons) button.classList.toggle("on", values.format === key);
const embed = Boolean(values.save_metadata);
for (const [on, button] of embedButtons) {
button.classList.toggle("on", on === embed);
button.setAttribute("aria-checked", String(on === embed));
}
};
const domWidget = node.addDOMWidget(WIDGET_NAME, WIDGET_NAME, root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => CARD_HEIGHT + WRAPPER_INSET,
});
keepDomWidgetWidthAuto(domWidget);
// Not saved with the workflow either: options.serialize only keeps it out
// of the prompt, and saved values come back by position.
domWidget.serialize = false;
// A constant-height card, pinned on purpose (tests/panel_guards.test.mjs,
// fixedByDesign): fields and pills, not a stage that follows the node.
domWidget.computeSize = (width) => [Math.max(NODE_MIN_WIDTH, Number(width || node.size?.[0] || NODE_MIN_WIDTH)), CARD_HEIGHT + WRAPPER_INSET];
// Pinned in Nodes 2.0 as well: the saved picture below takes the node's
// spare height there too.
pinVuePanelHeight(domWidget);
domWidget.options.minNodeSize = [NODE_MIN_WIDTH, 120];
// A pinned card is skipped by the frontend's width rule, so the card's
// number becomes the node's floor here (panel_layout.mjs).
holdNodeMinWidth(domWidget, NODE_MIN_WIDTH);
// Ahead of the legacy caption textarea, which stays hidden underneath.
const index = node.widgets.indexOf(domWidget);
const firstDom = node.widgets.findIndex((item) => item !== domWidget && item.element);
if (index >= 0 && firstDom >= 0 && firstDom < index) {
node.widgets.splice(index, 1); node.widgets.splice(firstDom, 0, domWidget);
}
for (const name of HIDDEN) {
const target = widget(node, name);
if (target) chainCallback(target, "callback", () => state.refresh());
}
chainCallback(node, "onConfigure", () => queueMicrotask(() => {
for (const name of HIDDEN) { const target = widget(node, name); if (target) { hideWidget(target); if (target.element) target.element.style.display = "none"; } }
state.savedPath = null; state.refresh();
}));
chainCallback(node, "onConnectionsChange", () => queueMicrotask(() => state.refresh()));
chainCallback(node, "onExecuted", (output) => {
if (isForeignRun()) return;
const saved = output?.ausboss_saved_path?.[0];
if (typeof saved === "string" && saved) { state.savedPath = saved; state.refresh(); }
});
chainCallback(node, "onRemoved", () => { state.alive = false; closeBrowse(); });
state.refresh();
node.setSize?.([Math.max(node.size?.[0] || 0, NODE_MIN_WIDTH), node.computeSize?.()[1] || CARD_HEIGHT]);
return state;
}
app.registerExtension({
name: "ausboss.save_image",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, HIDDEN);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
if (this.__ausbossSaveImage) return;
buildCard(this);
// %date:yyyy-MM-dd% and %Node.setting% in the name (js/shared/name_tokens.mjs).
installNameTokens(this, ["filename_prefix", "exact_name"], app);
});
},
});