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: "+ Input 2", token: "%input2%" }, { 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%" }, ]; const COUNTER_DIGITS = 3; 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.origType = widget.origType || widget.type; widget.computeSize = () => [0, -4]; 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"); if ( ui._dsiCounterResolved && ui._dsiCounterKey === `${folder}\u0000${pattern}${format}` ) { return `${folder}/${ui._dsiCounterResolved}`.replaceAll("\\", "/"); } pattern = pattern.replaceAll("%counter%", String(ui._dsiNextCounter || 1).padStart(COUNTER_DIGITS, "0")); return `${folder}/${pattern}${format}`.replaceAll("\\", "/"); } function scheduleCounterFetch(node, ui) { const folder = (ui.folderInput.value || "").trim(); const format = ui.format === "jpg" ? ".jpg" : ".png"; let pattern = ui.patternInput.value || DEFAULT_PATTERN; pattern = resolveDateTokens(pattern) .replaceAll("%input%", "input") .replaceAll("%input2%", "input2") .replaceAll("%width%", String(node._dsiImageWidth || 0)) .replaceAll("%height%", String(node._dsiImageHeight || 0)) .replaceAll("%batch_num%", "0"); const key = `${folder}\u0000${pattern}${format}`; if (ui._dsiCounterKey === key && ui._dsiCounterResolved) return; clearTimeout(ui._dsiCounterTimer); ui._dsiCounterTimer = setTimeout(async () => { try { const response = await fetch( `/dumas/api/save_image/next_counter?folder=${encodeURIComponent(folder)}&name=${encodeURIComponent(pattern + format)}&digits=${COUNTER_DIGITS}`, ); const data = await response.json(); if (!data?.ok) return; ui._dsiNextCounter = data.counter || 1; ui._dsiCounterResolved = data.resolved || ""; ui._dsiCounterKey = key; ui.prevPath.textContent = buildPreviewPath(node, ui); } catch (_error) { // Leave the fallback preview in place. } }, 150); } function imageUrl(meta) { return `/view?filename=${encodeURIComponent(meta.filename)}&type=${encodeURIComponent(meta.type)}&subfolder=${encodeURIComponent(meta.subfolder || "")}&t=${Date.now()}`; } function entrySrc(meta) { if (!meta) return null; if (meta.type === "output" && meta.filename) return imageUrl(meta); if (meta.token) return `/dumas/api/save_image/file?t=${encodeURIComponent(meta.token)}`; return null; } function canPreview(meta) { return !!entrySrc(meta); } function updateActionState(node, ui) { const hasImage = !!(node._dsiImages && node._dsiImages.length); const current = hasImage ? node._dsiImages[node._dsiImageIndex || 0] : null; const previewable = canPreview(current); ui.openButton.disabled = !previewable; ui.copyButton.disabled = !previewable; 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 src = entrySrc(entry); if (!src) return false; const response = await fetch(src); 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); scheduleCounterFetch(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.count.textContent = `${currentIndex + 1} / ${images.length}`; if (canPreview(current)) { ui.view.classList.add("has"); ui.placeholder.style.display = "none"; ui.img.style.display = "block"; ui.img.src = entrySrc(current); ui.info.textContent = images.length > 1 ? `Showing saved image ${currentIndex + 1} of ${images.length}` : "Showing latest saved image"; } else { ui.view.classList.remove("has"); ui.img.style.display = "none"; ui.placeholder.style.display = "flex"; ui.placeholder.textContent = "Saved outside ComfyUI output. Preview is unavailable here."; ui.info.textContent = current?.subfolder ? `Saved to ${current.subfolder}/${current.filename}` : `Saved to ${current.filename}`; } 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, _dsiNextCounter: 1, _dsiCounterResolved: "", _dsiCounterKey: "", _dsiCounterTimer: null, }; 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 () => { try { const response = await fetch(`/dumas/api/pick_directory?path=${encodeURIComponent((folderInput.value || "").trim())}`); const data = await response.json(); if (data?.ok && data.path) { folderInput.value = data.path; setWidgetValue(node, "folder", folderInput.value); renderPreview(node, ui); ui.info.textContent = `Selected folder: ${data.path}`; return; } if (data?.cancelled) { ui.info.textContent = "Folder selection cancelled"; return; } ui.info.textContent = data?.message || "Folder picker is unavailable here"; } catch (_error) { ui.info.textContent = "Folder picker is unavailable here"; } }); 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; const src = entrySrc(entry); if (!src) return; window.open(src, "_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); }; }, });