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);
}
}
},
});