Add folder image loader node
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { applyAdaptiveCanvasOnly } from "../shared/nodes2.mjs";
|
||||
import { listFolder, pickNativeFolder } from "./api.mjs";
|
||||
import {
|
||||
COMFY_CLASS,
|
||||
HIDDEN_INPUT_NAME,
|
||||
readState,
|
||||
writeState,
|
||||
} from "./state.mjs";
|
||||
import {
|
||||
buildRoot,
|
||||
injectCSS,
|
||||
openBrowsePopup,
|
||||
openPickGallery,
|
||||
} from "./ui.mjs";
|
||||
|
||||
const MIN_W = 300;
|
||||
const DEFAULT_W = 370;
|
||||
|
||||
function hideJsonWidget(widgets, name) {
|
||||
const widget = (widgets || []).find((entry) => entry?.name === name);
|
||||
if (!widget) return;
|
||||
widget.type = "hidden";
|
||||
widget.computeSize = () => [0, 0];
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
if (!value) return "";
|
||||
let normalized = String(value).trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (/^[A-Za-z]:$/.test(normalized)) normalized += "/";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function stripInputs(node) {
|
||||
if (!node?.inputs?.length) return;
|
||||
for (let index = node.inputs.length - 1; index >= 0; index -= 1) {
|
||||
if (node.inputs[index]?.link != null) {
|
||||
try {
|
||||
node.disconnectInput(index);
|
||||
} catch (_error) {
|
||||
// Ignore failed disconnects and keep stripping.
|
||||
}
|
||||
}
|
||||
node.removeInput(index);
|
||||
}
|
||||
}
|
||||
|
||||
function selectionSummary(state, total) {
|
||||
const mode = state.selection_mode || "selected";
|
||||
if (mode === "all") return `All ${total} / ${total}`;
|
||||
if (mode === "first_n") return `First ${Math.min(Math.max(parseInt(state.first_n, 10) || 0, 0), total)} / ${total}`;
|
||||
if (mode === "random") return `Random ${total ? 1 : 0} / ${total}`;
|
||||
return `${(state.selected || []).length} / ${total}`;
|
||||
}
|
||||
|
||||
function renderUI(node) {
|
||||
const ui = node._dlfUI;
|
||||
if (!ui) return;
|
||||
const state = readState(node);
|
||||
if (document.activeElement !== ui.folderInput) {
|
||||
ui.folderInput.value = state.folder || "";
|
||||
}
|
||||
const total = (node._dlfFiles || []).length;
|
||||
ui.pickBtn.textContent = `Pick images · ${selectionSummary(state, total)}`;
|
||||
ui.pickBtn.classList.toggle("empty", total === 0);
|
||||
ui.msgEl.textContent = node._dlfListError || "";
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
async function refreshListing(node) {
|
||||
const requestId = (node._dlfListReq = (node._dlfListReq || 0) + 1);
|
||||
const state = readState(node);
|
||||
if (!state.folder) {
|
||||
node._dlfFiles = [];
|
||||
node._dlfListError = "";
|
||||
renderUI(node);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await listFolder(state.folder, state.recursive);
|
||||
if (node._dlfListReq !== requestId || !node._dlfUI) return;
|
||||
if (response?.ok) {
|
||||
node._dlfFiles = response.files || [];
|
||||
node._dlfListError = node._dlfFiles.length ? "" : "No images found in this folder.";
|
||||
} else {
|
||||
node._dlfFiles = [];
|
||||
node._dlfListError = response?.message || "Folder not found.";
|
||||
}
|
||||
|
||||
const present = new Set((node._dlfFiles || []).map((file) => file.file));
|
||||
const nextState = readState(node);
|
||||
const kept = (nextState.selected || []).filter((file) => present.has(file));
|
||||
if (kept.length !== (nextState.selected || []).length) {
|
||||
nextState.selected = kept;
|
||||
writeState(node, nextState);
|
||||
}
|
||||
renderUI(node);
|
||||
}
|
||||
|
||||
async function setFolder(node, folder) {
|
||||
const normalized = normalizePath(folder);
|
||||
const state = readState(node);
|
||||
const changed = (state.folder || "") !== normalized;
|
||||
state.folder = normalized;
|
||||
if (changed) state.selected = [];
|
||||
writeState(node, state);
|
||||
await refreshListing(node);
|
||||
}
|
||||
|
||||
function setupNode(node) {
|
||||
injectCSS();
|
||||
hideJsonWidget(node.widgets, HIDDEN_INPUT_NAME);
|
||||
stripInputs(node);
|
||||
|
||||
const ui = buildRoot();
|
||||
node._dlfUI = ui;
|
||||
const widget = node.addDOMWidget("dumas_load_images_folder", "custom", ui.root, {
|
||||
getValue: () => null,
|
||||
setValue: () => {},
|
||||
serialize: false,
|
||||
getMinHeight: () => 96,
|
||||
});
|
||||
widget.computeLayoutSize = () => ({ minHeight: 96, minWidth: 1 });
|
||||
applyAdaptiveCanvasOnly(widget);
|
||||
|
||||
ui.folderInput.addEventListener("keydown", (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
ui.folderInput.blur();
|
||||
}
|
||||
});
|
||||
ui.folderInput.addEventListener("change", () => setFolder(node, ui.folderInput.value.trim()));
|
||||
ui.folderInput.addEventListener("paste", () => {
|
||||
setTimeout(() => {
|
||||
const value = ui.folderInput.value.trim();
|
||||
if (normalizePath(value) !== (readState(node).folder || "")) setFolder(node, value);
|
||||
}, 0);
|
||||
});
|
||||
ui.browseBtn.addEventListener("click", async () => {
|
||||
const start = readState(node).folder || "";
|
||||
const previous = ui.browseLbl.textContent;
|
||||
ui.browseBtn.disabled = true;
|
||||
ui.browseLbl.textContent = "Opening…";
|
||||
let response;
|
||||
try {
|
||||
response = await pickNativeFolder(start);
|
||||
} catch (_error) {
|
||||
response = { ok: false };
|
||||
}
|
||||
ui.browseBtn.disabled = false;
|
||||
ui.browseLbl.textContent = previous || "Browse";
|
||||
if (response?.ok && response.path) {
|
||||
await setFolder(node, response.path);
|
||||
return;
|
||||
}
|
||||
if (response?.cancelled) return;
|
||||
openBrowsePopup(node, ui.browseBtn, {
|
||||
startPath: start,
|
||||
onPick: (folder) => setFolder(node, folder),
|
||||
});
|
||||
});
|
||||
ui.pickBtn.addEventListener("click", async () => {
|
||||
const typed = ui.folderInput.value.trim();
|
||||
if (typed !== (readState(node).folder || "")) await setFolder(node, typed);
|
||||
const state = readState(node);
|
||||
if (!state.folder) {
|
||||
ui.folderInput.focus();
|
||||
node._dlfListError = "Set a folder first.";
|
||||
renderUI(node);
|
||||
return;
|
||||
}
|
||||
ui.pickBtn.disabled = true;
|
||||
try {
|
||||
await refreshListing(node);
|
||||
} finally {
|
||||
ui.pickBtn.disabled = false;
|
||||
}
|
||||
openPickGallery(node, ui.pickBtn, {
|
||||
onChange: renderUI,
|
||||
refreshListing,
|
||||
});
|
||||
});
|
||||
|
||||
node.size[0] = Math.max(node.size[0] || 0, DEFAULT_W);
|
||||
node.size[1] = Math.max(node.size[1] || 0, 110);
|
||||
queueMicrotask(() => refreshListing(node));
|
||||
}
|
||||
|
||||
function collectNodes(graph, out) {
|
||||
if (!graph) return;
|
||||
const nodes = graph._nodes || graph.nodes || [];
|
||||
for (const node of nodes) {
|
||||
if (node?.comfyClass === COMFY_CLASS) out.push(node);
|
||||
const inner = node?.subgraph || node?.graph || node?._graph;
|
||||
if (inner && inner !== graph) collectNodes(inner, out);
|
||||
}
|
||||
}
|
||||
|
||||
function matchNode(nodes, promptId) {
|
||||
let node = nodes.find((entry) => String(entry.id) === String(promptId));
|
||||
if (node) return node;
|
||||
const tail = String(promptId).split(":").pop();
|
||||
node = nodes.find((entry) => String(entry.id) === tail);
|
||||
return node || null;
|
||||
}
|
||||
|
||||
function injectState(result) {
|
||||
const output = result?.output;
|
||||
if (!output) return;
|
||||
const nodes = [];
|
||||
collectNodes(app.graph, nodes);
|
||||
for (const id in output) {
|
||||
const entry = output[id];
|
||||
if (!entry || entry.class_type !== COMFY_CLASS) continue;
|
||||
const node = matchNode(nodes, id);
|
||||
if (!node) continue;
|
||||
if (!entry.inputs) entry.inputs = {};
|
||||
entry.inputs[HIDDEN_INPUT_NAME] = JSON.stringify(readState(node));
|
||||
}
|
||||
}
|
||||
|
||||
function installGraphToPromptHook() {
|
||||
if (app._dlfGraphPatched) return;
|
||||
app._dlfGraphPatched = true;
|
||||
const original = app.graphToPrompt.bind(app);
|
||||
app.graphToPrompt = async function graphToPromptPatched(...args) {
|
||||
const result = await original(...args);
|
||||
injectState(result);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Dumas.LoadImagesFolder",
|
||||
setup() {
|
||||
installGraphToPromptHook();
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== COMFY_CLASS) return;
|
||||
|
||||
const originalNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function onNodeCreated() {
|
||||
const result = originalNodeCreated?.apply(this, arguments);
|
||||
setupNode(this);
|
||||
return result;
|
||||
};
|
||||
|
||||
const originalConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function onConfigure() {
|
||||
const result = originalConfigure?.apply(this, arguments);
|
||||
stripInputs(this);
|
||||
queueMicrotask(() => refreshListing(this));
|
||||
return result;
|
||||
};
|
||||
|
||||
const originalDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function onDrawForeground(ctx) {
|
||||
const result = originalDrawForeground?.call(this, ctx);
|
||||
this.size[0] = Math.max(this.size[0], MIN_W);
|
||||
return result;
|
||||
};
|
||||
|
||||
nodeType.prototype.onConnectInput = function onConnectInput() {
|
||||
return false;
|
||||
};
|
||||
|
||||
const originalRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function onRemoved() {
|
||||
this._dlfGallery?._dlfClose?.();
|
||||
this._dlfBrowsePopup?._dlfClose?.();
|
||||
document.querySelectorAll(".dlf-menu").forEach((menu) => menu._dlfClose?.());
|
||||
return originalRemoved?.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user