From c69193793342563f8bcb36e6aef149b8f36ac11a Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Wed, 5 Aug 2026 11:23:57 +0000 Subject: [PATCH] Restore Pixaroma-style save image UI --- js/save_image/index.js | 461 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 js/save_image/index.js diff --git a/js/save_image/index.js b/js/save_image/index.js new file mode 100644 index 0000000..3811426 --- /dev/null +++ b/js/save_image/index.js @@ -0,0 +1,461 @@ +import { app } from "/scripts/app.js"; + +const NODE_NAME = "DumasSaveImage"; +const MIN_W = 474; +const MIN_H = 620; +const DEFAULT_PATTERN = "image_%date:yyyy-MM-dd%_%counter%"; +const ACCENT = "#e66a2c"; +const CHIPS = [ + { label: "+ Input name", token: "%input%" }, + { label: "+ Date", token: "%date:yyyy-MM-dd%" }, + { label: "+ Time", token: "%date:hh-mm-ss%" }, + { label: "+ Counter", token: "%counter%" }, + { label: "+ Width", token: "%width%" }, + { label: "+ Height", token: "%height%" }, + { label: "+ Batch #", token: "%batch_num%" }, +]; + +let cssDone = false; + +function injectCSS() { + if (cssDone || document.getElementById("dumas-save-image-css")) { + cssDone = true; + return; + } + cssDone = true; + const style = document.createElement("style"); + style.id = "dumas-save-image-css"; + style.textContent = [ + ".dsi-root{position:relative;width:100%;height:100%;box-sizing:border-box;}", + ".dsi-inner{position:absolute;inset:0;display:flex;flex-direction:column;gap:10px;padding:8px 10px 6px;box-sizing:border-box;overflow:hidden;font-family:'Segoe UI',system-ui,sans-serif;}", + ".dsi-lab{display:block;font-size:10px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--dsi-acc,#e66a2c);margin-bottom:4px;}", + ".dsi-row{display:flex;gap:6px;align-items:center;}", + ".dsi-field{flex:1;min-width:0;background:#1d1d1d;border:1px solid #444;border-radius:4px;color:#e0e0e0;padding:5px 8px;font-size:12px;outline:none;box-sizing:border-box;font-family:inherit;width:100%;}", + ".dsi-field:focus{border-color:var(--dsi-acc,#e66a2c);}", + ".dsi-field.mono{font-family:Consolas,ui-monospace,monospace;}", + ".dsi-btn{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.16);color:#ccc;border-radius:4px;padding:5px 10px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:5px;white-space:nowrap;flex:0 0 auto;font-family:inherit;user-select:none;}", + ".dsi-btn:hover{background:var(--dsi-acc,#e66a2c);border-color:var(--dsi-acc,#e66a2c);color:#fff;}", + ".dsi-btn:disabled{opacity:.45;cursor:default;}", + ".dsi-btn:disabled:hover{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.16);color:#ccc;}", + ".dsi-primary{background:var(--dsi-acc,#e66a2c);border-color:var(--dsi-acc,#e66a2c);color:#fff;}", + ".dsi-primary:hover{background:#ff7d58;border-color:#ff7d58;color:#fff;}", + ".dsi-grow{flex:1 1 0;min-width:0;}", + ".dsi-hint{font-size:10px;color:#8f8f8f;line-height:1.45;}", + ".dsi-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;}", + ".dsi-chip{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);color:#ccc;border-radius:4px;padding:2px 7px;font-size:11px;cursor:pointer;font-family:inherit;user-select:none;}", + ".dsi-chip:hover{border-color:var(--dsi-acc,#e66a2c);color:#eee;}", + ".dsi-prev{background:#1d1d1d;border:1px solid #444;border-radius:4px;padding:6px 8px;margin-top:8px;}", + ".dsi-prevlab{font-size:10px;color:#8f8f8f;margin-bottom:2px;}", + ".dsi-prevpath{font-family:Consolas,ui-monospace,monospace;font-size:11px;color:#ffb59e;word-break:break-all;line-height:1.45;}", + ".dsi-seg{display:inline-flex;border:1px solid #444;border-radius:999px;overflow:hidden;flex:0 0 auto;}", + ".dsi-seg button{background:#1d1d1d;color:#aaa;border:none;padding:4px 11px;font-size:12px;cursor:pointer;font-family:inherit;}", + ".dsi-seg button.on{background:var(--dsi-acc,#e66a2c);color:#fff;}", + ".dsi-btnrow{display:flex;flex-wrap:wrap;gap:6px;align-items:stretch;}", + ".dsi-viewsec{flex:1 1 0;min-height:0;display:flex;flex-direction:column;gap:4px;}", + ".dsi-view{position:relative;flex:1 1 0;min-height:180px;background:#151515;border:1px solid #3c3c3c;border-radius:4px;overflow:hidden;}", + ".dsi-view.has{background:transparent;border-color:transparent;}", + ".dsi-img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:none;}", + ".dsi-ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#777;font-size:11px;padding:8px;text-align:center;}", + ".dsi-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,.5);border:1px solid rgba(255,255,255,.18);color:#ddd;border-radius:4px;padding:4px 7px;font-size:11px;cursor:pointer;display:none;}", + ".dsi-view.has:hover .dsi-nav.show{display:block;}", + ".dsi-nav.prev{left:6px;}", + ".dsi-nav.next{right:6px;}", + ".dsi-nav:hover{background:var(--dsi-acc,#e66a2c);border-color:var(--dsi-acc,#e66a2c);color:#fff;}", + ".dsi-count{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.55);color:#ddd;font-size:11px;padding:1px 7px;border-radius:3px;display:none;}", + ".dsi-info{flex:0 0 auto;text-align:center;font-size:11px;color:#8f8f8f;min-height:14px;line-height:1.25;word-break:break-word;}", + ].join("\n"); + document.head.appendChild(style); +} + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; +} + +function hideWidget(widget) { + if (!widget || widget.__dsiHidden) return; + widget.__dsiHidden = true; + widget.computeSize = () => [0, -4]; + widget.type = "hidden"; + widget.hidden = true; +} + +function getWidget(node, name) { + return node.widgets?.find((widget) => widget?.name === name) || null; +} + +function setWidgetValue(node, name, value) { + const widget = getWidget(node, name); + if (!widget) return; + widget.value = value; + widget.callback?.(value, app.canvas, node, app); + node.setDirtyCanvas?.(true, true); +} + +function pad(value, width) { + return String(value).padStart(width, "0"); +} + +function resolveDateTokens(value) { + if (!value.includes("%date:")) return value; + const now = new Date(); + return value.replace(/%date:([^%]+)%/g, (_match, format) => format.replace( + /yyyy|yy|MM|dd|hh|mm|ss/g, + (token) => { + if (token === "yyyy") return pad(now.getFullYear(), 4); + if (token === "yy") return String(now.getFullYear()).slice(-2); + if (token === "MM") return pad(now.getMonth() + 1, 2); + if (token === "dd") return pad(now.getDate(), 2); + if (token === "hh") return pad(now.getHours(), 2); + if (token === "mm") return pad(now.getMinutes(), 2); + if (token === "ss") return pad(now.getSeconds(), 2); + return token; + }, + )); +} + +function buildPreviewPath(node, ui) { + const folder = (ui.folderInput.value || "").trim() || "ComfyUI output"; + const format = ui.format === "jpg" ? ".jpg" : ".png"; + let pattern = ui.patternInput.value || DEFAULT_PATTERN; + pattern = resolveDateTokens(pattern); + pattern = pattern + .replaceAll("%input%", "input") + .replaceAll("%input2%", "input2") + .replaceAll("%width%", String(node._dsiImageWidth || 0)) + .replaceAll("%height%", String(node._dsiImageHeight || 0)) + .replaceAll("%batch_num%", "0") + .replaceAll("%counter%", "001"); + return `${folder}/${pattern}${format}`.replaceAll("\\", "/"); +} + +function imageUrl(meta) { + return `/view?filename=${encodeURIComponent(meta.filename)}&type=${encodeURIComponent(meta.type)}&subfolder=${encodeURIComponent(meta.subfolder || "")}&t=${Date.now()}`; +} + +function updateActionState(node, ui) { + const hasImage = !!(node._dsiImages && node._dsiImages.length); + ui.openButton.disabled = !hasImage; + ui.copyButton.disabled = !hasImage; + ui.prevButton.classList.toggle("show", !!(hasImage && node._dsiImages.length > 1)); + ui.nextButton.classList.toggle("show", !!(hasImage && node._dsiImages.length > 1)); + ui.count.style.display = hasImage && node._dsiImages.length > 1 ? "block" : "none"; +} + +async function copyCurrentImage(node) { + const entry = node._dsiImages?.[node._dsiImageIndex || 0]; + if (!entry || !navigator.clipboard?.write || typeof ClipboardItem === "undefined") return false; + const response = await fetch(imageUrl(entry)); + if (!response.ok) return false; + const blob = await response.blob(); + await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]); + return true; +} + +function renderPreview(node, ui) { + const images = node._dsiImages || []; + const currentIndex = Math.max(0, Math.min(node._dsiImageIndex || 0, images.length - 1)); + node._dsiImageIndex = currentIndex; + ui.prevPath.textContent = buildPreviewPath(node, ui); + ui.folderButton.title = (ui.folderInput.value || "").trim() + ? `Copy folder path: ${(ui.folderInput.value || "").trim()}` + : "Using ComfyUI output folder"; + + if (!images.length) { + ui.view.classList.remove("has"); + ui.img.style.display = "none"; + ui.placeholder.style.display = "flex"; + ui.count.style.display = "none"; + ui.info.textContent = ui.saveOnRun + ? "Run the workflow to save and preview the result here" + : "Preview mode is on. Run the workflow to show the latest image here"; + updateActionState(node, ui); + return; + } + + const current = images[currentIndex]; + ui.view.classList.add("has"); + ui.placeholder.style.display = "none"; + ui.img.style.display = "block"; + ui.img.src = imageUrl(current); + ui.count.textContent = `${currentIndex + 1} / ${images.length}`; + ui.info.textContent = images.length > 1 + ? `Showing saved image ${currentIndex + 1} of ${images.length}` + : "Showing latest saved image"; + updateActionState(node, ui); +} + +function syncFromWidgets(node, ui) { + ui.folderInput.value = String(getWidget(node, "folder")?.value || ""); + ui.patternInput.value = String(getWidget(node, "pattern")?.value || DEFAULT_PATTERN); + ui.format = String(getWidget(node, "format")?.value || "png").toLowerCase() === "jpg" ? "jpg" : "png"; + ui.saveOnRun = !!getWidget(node, "save_on_run")?.value; + ui.embedWorkflow = !!getWidget(node, "embed_workflow")?.value; + ui.fmtPng.classList.toggle("on", ui.format === "png"); + ui.fmtJpg.classList.toggle("on", ui.format === "jpg"); + ui.modeSave.classList.toggle("on", ui.saveOnRun); + ui.modePreview.classList.toggle("on", !ui.saveOnRun); + renderPreview(node, ui); +} + +function appendToken(input, token) { + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + input.value = `${input.value.slice(0, start)}${token}${input.value.slice(end)}`; + input.focus(); + input.setSelectionRange(start + token.length, start + token.length); +} + +function createRoot(node) { + injectCSS(); + const root = el("div", "dsi-root"); + root.style.setProperty("--dsi-acc", ACCENT); + const inner = el("div", "dsi-inner"); + root.appendChild(inner); + + const folderSection = el("div"); + const folderRow = el("div", "dsi-row"); + const folderInput = el("input", "dsi-field"); + folderInput.type = "text"; + folderInput.spellcheck = false; + folderInput.placeholder = "ComfyUI output folder"; + const browseButton = el("button", "dsi-btn", "Browse"); + browseButton.type = "button"; + folderRow.append(folderInput, browseButton); + folderSection.append(folderRow, el("div", "dsi-hint", "Leave empty for the ComfyUI output folder. For your own folder, click Browse once.")); + inner.appendChild(folderSection); + + const nameSection = el("div"); + nameSection.appendChild(el("span", "dsi-lab", "Filename")); + const patternInput = el("input", "dsi-field mono"); + patternInput.type = "text"; + patternInput.spellcheck = false; + patternInput.placeholder = DEFAULT_PATTERN; + nameSection.appendChild(patternInput); + const chips = el("div", "dsi-chips"); + for (const chip of CHIPS) { + const chipButton = el("button", "dsi-chip", chip.label); + chipButton.type = "button"; + chipButton.addEventListener("click", () => { + appendToken(patternInput, chip.token); + setWidgetValue(node, "pattern", patternInput.value); + renderPreview(node, ui); + }); + chips.appendChild(chipButton); + } + const previewBox = el("div", "dsi-prev"); + previewBox.appendChild(el("div", "dsi-prevlab", "Will save as")); + const previewPath = el("div", "dsi-prevpath", ""); + previewBox.appendChild(previewPath); + nameSection.append(chips, previewBox, el("div", "dsi-hint", "Type / in the name to create subfolders.")); + inner.appendChild(nameSection); + + const buttonRowSection = el("div"); + const buttonRow = el("div", "dsi-btnrow"); + const openButton = el("button", "dsi-btn dsi-primary dsi-grow", "Open"); + openButton.type = "button"; + const copyButton = el("button", "dsi-btn dsi-primary dsi-grow", "Copy"); + copyButton.type = "button"; + const folderButton = el("button", "dsi-btn dsi-primary dsi-grow", "Folder"); + folderButton.type = "button"; + const formatSeg = el("div", "dsi-seg"); + const fmtPng = el("button", null, "PNG"); + fmtPng.type = "button"; + const fmtJpg = el("button", null, "JPG"); + fmtJpg.type = "button"; + formatSeg.append(fmtPng, fmtJpg); + const modeSeg = el("div", "dsi-seg"); + const modeSave = el("button", null, "Save"); + modeSave.type = "button"; + const modePreview = el("button", null, "Preview"); + modePreview.type = "button"; + modeSeg.append(modeSave, modePreview); + buttonRow.append(openButton, copyButton, folderButton, formatSeg, modeSeg); + buttonRowSection.appendChild(buttonRow); + inner.appendChild(buttonRowSection); + + const viewSection = el("div", "dsi-viewsec"); + const view = el("div", "dsi-view"); + const img = el("img", "dsi-img"); + const placeholder = el("div", "dsi-ph", "Run the workflow to save and preview the result here"); + const prevButton = el("button", "dsi-nav prev", "◀"); + prevButton.type = "button"; + const nextButton = el("button", "dsi-nav next", "▶"); + nextButton.type = "button"; + const count = el("div", "dsi-count", ""); + view.append(img, placeholder, prevButton, nextButton, count); + const info = el("div", "dsi-info", ""); + viewSection.append(view, info); + inner.appendChild(viewSection); + + const ui = { + root, + folderInput, + browseButton, + patternInput, + prevPath: previewPath, + fmtPng, + fmtJpg, + modeSave, + modePreview, + openButton, + copyButton, + folderButton, + view, + img, + placeholder, + prevButton, + nextButton, + count, + info, + format: "png", + saveOnRun: true, + embedWorkflow: true, + }; + + folderInput.addEventListener("input", () => { + setWidgetValue(node, "folder", folderInput.value); + renderPreview(node, ui); + }); + patternInput.addEventListener("input", () => { + setWidgetValue(node, "pattern", patternInput.value); + renderPreview(node, ui); + }); + browseButton.addEventListener("click", async () => { + if (typeof window.showDirectoryPicker !== "function") return; + try { + const handle = await window.showDirectoryPicker(); + if (!handle) return; + folderInput.value = handle.name || folderInput.value; + setWidgetValue(node, "folder", folderInput.value); + renderPreview(node, ui); + } catch (_error) { + // Ignore picker cancel/failure. + } + }); + fmtPng.addEventListener("click", () => { + ui.format = "png"; + setWidgetValue(node, "format", "png"); + syncFromWidgets(node, ui); + }); + fmtJpg.addEventListener("click", () => { + ui.format = "jpg"; + setWidgetValue(node, "format", "jpg"); + syncFromWidgets(node, ui); + }); + modeSave.addEventListener("click", () => { + ui.saveOnRun = true; + setWidgetValue(node, "save_on_run", true); + syncFromWidgets(node, ui); + }); + modePreview.addEventListener("click", () => { + ui.saveOnRun = false; + setWidgetValue(node, "save_on_run", false); + syncFromWidgets(node, ui); + }); + openButton.addEventListener("click", () => { + const entry = node._dsiImages?.[node._dsiImageIndex || 0]; + if (!entry) return; + window.open(imageUrl(entry), "_blank", "noopener,noreferrer"); + }); + copyButton.addEventListener("click", async () => { + try { + await copyCurrentImage(node); + ui.info.textContent = "Copied current image to clipboard"; + } catch (_error) { + ui.info.textContent = "Could not copy image to clipboard"; + } + }); + folderButton.addEventListener("click", async () => { + const text = (folderInput.value || "").trim() || "ComfyUI output folder"; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + ui.info.textContent = `Copied folder path: ${text}`; + } + } catch (_error) { + ui.info.textContent = text; + } + }); + prevButton.addEventListener("click", () => { + if (!node._dsiImages?.length) return; + node._dsiImageIndex = (node._dsiImageIndex - 1 + node._dsiImages.length) % node._dsiImages.length; + renderPreview(node, ui); + }); + nextButton.addEventListener("click", () => { + if (!node._dsiImages?.length) return; + node._dsiImageIndex = (node._dsiImageIndex + 1) % node._dsiImages.length; + renderPreview(node, ui); + }); + + node._dsiUI = ui; + syncFromWidgets(node, ui); + return root; +} + +app.registerExtension({ + name: "Dumas.SaveImage", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== NODE_NAME) return; + + const originalOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + originalOnNodeCreated?.apply(this, arguments); + this.hideOutputImages = true; + this._dsiImages = []; + this._dsiImageIndex = 0; + this._dsiImageWidth = 0; + this._dsiImageHeight = 0; + + hideWidget(getWidget(this, "folder")); + hideWidget(getWidget(this, "pattern")); + hideWidget(getWidget(this, "format")); + hideWidget(getWidget(this, "quality")); + hideWidget(getWidget(this, "embed_workflow")); + hideWidget(getWidget(this, "save_on_run")); + + const root = createRoot(this); + const widget = this.addDOMWidget("dumas_save_image", "dumas_save_image", root, { + serialize: false, + hideOnZoom: false, + }); + widget.computeLayoutSize = () => ({ minHeight: MIN_H, minWidth: MIN_W }); + + this.size[0] = Math.max(this.size[0] || 0, MIN_W); + this.size[1] = Math.max(this.size[1] || 0, MIN_H); + }; + + const originalOnExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (output) { + originalOnExecuted?.apply(this, arguments); + const ui = this._dsiUI; + this._dsiImages = Array.isArray(output?.images) ? output.images : []; + this._dsiImageIndex = Math.max(0, this._dsiImages.length - 1); + if (this._dsiImages.length) { + const first = this._dsiImages[0]; + const image = new Image(); + image.onload = () => { + this._dsiImageWidth = image.naturalWidth || 0; + this._dsiImageHeight = image.naturalHeight || 0; + ui && renderPreview(this, ui); + }; + image.src = imageUrl(first); + } + ui && renderPreview(this, ui); + }; + + const originalOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + originalOnConfigure?.apply(this, arguments); + this._dsiUI && syncFromWidgets(this, this._dsiUI); + }; + + const originalOnRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function () { + this._dsiUI = null; + return originalOnRemoved?.apply(this, arguments); + }; + }, +});