const ROOT_FOLDER_NAME = "ComfyUI-CustomizableSave"; import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; async function populateDirectoryOptions(directoryWidget, node) { const refreshButton = node.widgets.find(w => w.name === "πŸ”„ Refresh" || w.name === "πŸ”„ Loading..."); if (refreshButton) { refreshButton.name = "πŸ”„ Loading..."; refreshButton.disabled = true; node.setDirtyCanvas(true, true); } try { const resp = await api.fetchApi(`/${ROOT_FOLDER_NAME}/get_output_dirs`); if (resp.status === 200) { const dirs = await resp.json(); // --- πŸ’‘ ν‘œμ‹œ ν…μŠ€νŠΈ μˆ˜μ • 둜직 λ³€κ²½ πŸ’‘ --- // 1. μ‹€μ œ κ°’κ³Ό ν‘œμ‹œλ  이름을 λ§€ν•‘ν•˜λŠ” 객체λ₯Ό λ§Œλ“­λ‹ˆλ‹€. const valueMap = { "": "output" }; // { μ‹€μ œ κ°’: ν‘œμ‹œ 이름 } dirs.forEach(dir => { valueMap[dir] = `output/${dir}`; }); // 2. 화면에 보여쀄 μ΄λ¦„λ“€μ˜ λͺ©λ‘μ„ μƒμ„±ν•©λ‹ˆλ‹€. const displayNames = Object.values(valueMap); directoryWidget.options.values = displayNames; const currentValue = directoryWidget.value; // 3. ν˜„μž¬ μ‹€μ œ κ°’(currentValue)에 ν•΄λ‹Ήν•˜λŠ” ν‘œμ‹œ 이름(display name)을 μ°Ύμ•„ μœ„μ ―μ— μ„€μ •ν•©λ‹ˆλ‹€. // μœ„μ ―μ˜ 'value'λŠ” 이제 화면에 ν‘œμ‹œλ˜λŠ” ν…μŠ€νŠΈκ°€ λ©λ‹ˆλ‹€. directoryWidget.value = valueMap[currentValue] || "output"; // 4. callback ν•¨μˆ˜: μ‚¬μš©μžκ°€ ν‘œμ‹œ 이름을 μ„ νƒν•˜λ©΄, μ‹€μ œ κ°’μœΌλ‘œ λ³€ν™˜ν•˜μ—¬ λ…Έλ“œμ— μ €μž₯ν•©λ‹ˆλ‹€. // 이 뢀뢄이 ν•΅μ‹¬μž…λ‹ˆλ‹€. ComfyUIλŠ” 여기에 μ €μž₯된 값을 λ°±μ—”λ“œλ‘œ μ „λ‹¬ν•©λ‹ˆλ‹€. directoryWidget.callback = function(v) { const realValue = Object.keys(valueMap).find(key => valueMap[key] === v); this.value = realValue; // μœ„μ ―μ˜ μ‹€μ œ 값을 λ‚΄λΆ€μ μœΌλ‘œ λ³€κ²½ }; // μ½œλ°±μ„ μˆ˜λ™μœΌλ‘œ ν•œλ²ˆ ν˜ΈμΆœν•˜μ—¬ μ΄ˆκΈ°κ°’μ„ μ‹€μ œ κ°’μœΌλ‘œ μ„€μ •ν•©λ‹ˆλ‹€. directoryWidget.callback(directoryWidget.value); } else { console.error("Failed to fetch output directories:", await resp.text()); } } catch (error) { console.error("Error fetching output directories:", error); } finally { if (refreshButton) { refreshButton.name = "πŸ”„ Refresh"; refreshButton.disabled = false; } node.computeSize(); node.setDirtyCanvas(true, true); } } app.registerExtension({ name: "MilleN2ium.AdvancedSave", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "AdvancedSave") { const onAdded = nodeType.prototype.onAdded; nodeType.prototype.onAdded = function () { onAdded?.apply(this, arguments); if (this.widgets.find(w => w.name === "dnd_filename_pattern")) return; const widgetIndex = this.widgets.findIndex(w => w.name === "directory"); if (widgetIndex === -1) { console.error("Could not find 'directory' widget."); return; } const originalWidget = this.widgets[widgetIndex]; const originalValue = originalWidget.value; const newComboWidget = this.addWidget("combo", "directory", originalValue, () => {}, { values: ["(Loading...)"] }); this.widgets.pop(); this.widgets.splice(widgetIndex, 1, newComboWidget); const directoryWidget = newComboWidget; populateDirectoryOptions(directoryWidget, this); const refreshButton = this.addWidget("button", "πŸ”„ Refresh", null, () => populateDirectoryOptions(directoryWidget, this)); refreshButton.serialize = false; const openFolderButton = this.addWidget("button", "πŸ“‚ Open Folder", null, async () => { if (directoryWidget.value === null || directoryWidget.value === undefined) return; try { await api.fetchApi(`/${ROOT_FOLDER_NAME}/open_folder`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ directory: directoryWidget.value }), }); } catch (err) { console.error("[AdvancedSave] Failed to request open_folder:", err); } }); openFolderButton.serialize = false; // (filename_pattern 및 DnD κ΄€λ ¨ μ½”λ“œλŠ” κΈ°μ‘΄κ³Ό λ™μΌν•˜κ²Œ μœ μ§€λ©λ‹ˆλ‹€) const patternWidget = this.widgets.find(w => w.name === "filename_pattern"); patternWidget.inputEl.readOnly = true; Object.assign(patternWidget.inputEl.style, { opacity: "0.6", cursor: "not-allowed", marginTop: "4px" }); const widgetWrapper = document.createElement("div"); const availableContainer = document.createElement("div"); availableContainer.className = "advsave-container advsave-item-picker"; const dropArea = document.createElement("div"); dropArea.className = "advsave-container advsave-drop-area"; const AVAILABLE_ITEMS = { "[prefix]": "Prefix", "[seed]": "Seed", "[counter]": "Counter (Session)", "[date]": "Date", "[time]": "Time", "[width]": "Width", "[height]": "Height" }; const createToken = (id, text) => { const token = document.createElement("div"); token.className = "advsave-token"; token.textContent = text; token.dataset.id = id; token.draggable = true; if (id.startsWith('_custom_')) { token.classList.add("custom-text"); } const removeBtn = document.createElement("div"); removeBtn.className = "advsave-remove-btn"; removeBtn.textContent = "x"; removeBtn.onclick = (e) => { e.stopPropagation(); token.remove(); updatePatternWidgetFromTokens(); }; token.appendChild(removeBtn); token.addEventListener("dragstart", (e) => { e.dataTransfer.setData('text/plain', id); setTimeout(() => token.classList.add("dragging"), 0); }); token.addEventListener("dragend", () => { token.classList.remove("dragging"); }); return token; }; const updatePatternWidgetFromTokens = () => { const tokens = Array.from(dropArea.querySelectorAll(".advsave-token")); const patternArray = tokens.map(t => { const id = t.dataset.id; return id.startsWith('_custom_') ? id.substring(8) : id; }); const newValue = JSON.stringify(patternArray, null, 2); if(patternWidget.value !== newValue) { patternWidget.value = newValue; } }; Object.entries({ ...AVAILABLE_ITEMS, "_text": "Text" }).forEach(([id, text]) => { const item = document.createElement("button"); item.className = "advsave-item"; item.textContent = text; item.addEventListener("mousedown", (e) => { e.stopPropagation(); e.preventDefault(); let newToken; if (id === '_text') { const invalidChars = '\\/:*?"<>|'; const invalidCharsMsg = 'μ‚¬μš© λΆˆκ°€: ' + invalidChars.split('').join(' '); const customText = prompt(`ν…μŠ€νŠΈλ₯Ό μž…λ ₯ν•˜μ„Έμš” (예: '_', '-', 'portrait'):\n\n${invalidCharsMsg}`, "_"); if (customText) { const hasInvalidChars = customText.split('').some(char => invalidChars.includes(char)); if (hasInvalidChars) { alert(`파일 이름에 μ‚¬μš©ν•  수 μ—†λŠ” λ¬Έμžκ°€ ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. λ‹€μŒ λ¬ΈμžλŠ” μ‚¬μš©ν•˜μ§€ λ§ˆμ„Έμš”: ${invalidChars.split('').join(' ')}`); } else { newToken = createToken(`_custom_${customText}`, customText); } } } else { newToken = createToken(id, text); } if (newToken) { dropArea.appendChild(newToken); updatePatternWidgetFromTokens(); } }); availableContainer.appendChild(item); }); const getDropTarget = (container, clientY) => { const children = [...container.children].filter(child => !child.classList.contains("dragging")); for (const child of children) { const box = child.getBoundingClientRect(); if (clientY < box.top + box.height / 2) return child; } return null; }; dropArea.addEventListener("dragover", (e) => { e.preventDefault(); dropArea.classList.add("drag-over"); }); dropArea.addEventListener("dragleave", () => { dropArea.classList.remove("drag-over"); }); dropArea.addEventListener("drop", (e) => { e.preventDefault(); dropArea.classList.remove("drag-over"); const draggingToken = dropArea.querySelector('.dragging'); if (!draggingToken) return; const dropTarget = getDropTarget(dropArea, e.clientY); if (dropTarget) { dropArea.insertBefore(draggingToken, dropTarget); } else { dropArea.appendChild(draggingToken); } updatePatternWidgetFromTokens(); }); const availableWidget = document.createElement("div"); availableWidget.className = "advsave-widget"; availableWidget.innerHTML = ``; availableWidget.appendChild(availableContainer); widgetWrapper.appendChild(availableWidget); const dropAreaWidget = document.createElement("div"); dropAreaWidget.className = "advsave-widget"; dropAreaWidget.innerHTML = ``; dropAreaWidget.appendChild(dropArea); widgetWrapper.appendChild(dropAreaWidget); const domWidget = this.addDOMWidget("dnd_filename_pattern", "dnd", widgetWrapper, { getValue() { return patternWidget.value; }, setValue(v) { if (patternWidget.value !== v) { patternWidget.value = v; } dropArea.innerHTML = ""; try { const pattern = JSON.parse(v || '[]'); pattern.forEach(id => { let text = AVAILABLE_ITEMS[id]; let tokenId = id; if (!text) { text = id; tokenId = `_custom_${id}`; } dropArea.appendChild(createToken(tokenId, text)); }); } catch (e) { console.error("Failed to parse pattern:", v, e); } } }); domWidget.value = patternWidget.value; }; const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (info) { onConfigure?.apply(this, arguments); if (info.widgets_values) { const directoryIndex = this.widgets.findIndex(w => w.name === "directory"); if (directoryIndex > -1 && info.widgets_values.length > directoryIndex) { const directoryWidget = this.widgets[directoryIndex]; directoryWidget.value = info.widgets_values[directoryIndex]; } } const domWidget = this.widgets.find(w => w.name === "dnd_filename_pattern"); if (!domWidget) return; let valueToRestore = null; const patternIndex = this.widgets.findIndex(w => w.name === "filename_pattern"); if (info.widgets_values && patternIndex !== -1 && info.widgets_values.length > patternIndex) { valueToRestore = info.widgets_values[patternIndex]; } if (valueToRestore !== null) { domWidget.value = valueToRestore; } }; if (!document.getElementById("my-nodes-advanced-save-style")) { const styleSheet = document.createElement("style"); styleSheet.id = "my-nodes-advanced-save-style"; styleSheet.innerText = ` .advsave-widget { margin: 5px 0; } .advsave-label { font-size: 0.8em; color: var(--input-text); margin-bottom: 2px; display: block; } .advsave-container { display: flex; flex-wrap: wrap; gap: 5px; padding: 5px; border: 1px solid var(--input-border); border-radius: 4px; background-color: var(--comfy-input-bg); min-width: 0; } .advsave-item { cursor: pointer; } .advsave-item, .advsave-token { padding: 4px 8px; border-radius: 12px; font-size: 0.9em; background-color: var(--s-toolbar-bg); color: var(--s-toolbar-text); border: 1px solid var(--s-toolbar-border-color); user-select: none; white-space: nowrap; } button.advsave-item { border: 1px solid var(--s-toolbar-border-color); background: var(--s-toolbar-bg); font-family: inherit; text-align: inherit; } button.advsave-item:hover { filter: brightness(1.2); } .advsave-token { cursor: grab; position: relative; background-color: #3a5775; } .advsave-token.custom-text { background-color: #4a75a1; } .advsave-token.dragging { opacity: 0.5; } .advsave-token .advsave-remove-btn { position: absolute; top: -5px; right: -5px; width: 16px; height: 16px; border-radius: 50%; background-color: #ff4444; color: white; border: 1px solid white; font-size: 10px; text-align: center; line-height: 14px; cursor: pointer; font-family: monospace; } .advsave-drop-area { min-height: 32px; flex-basis: 100%; } .drag-over { background-color: var(--descrip-text); } `; document.head.appendChild(styleSheet); } } }, });