Add folder image loader node

This commit is contained in:
2026-08-19 08:17:23 +00:00
parent 24baee44e1
commit 34e9bea6d9
8 changed files with 1284 additions and 0 deletions
+6
View File
@@ -16,6 +16,12 @@
- Saves images to any folder, not just ComfyUI's output directory. - Saves images to any folder, not just ComfyUI's output directory.
- Supports filename tokens including `%input%`, `%input2%`, `%date:yyyy-MM-dd%`, `%counter%`, `%width%`, `%height%`, and `%batch_num%`. - Supports filename tokens including `%input%`, `%input2%`, `%date:yyyy-MM-dd%`, `%counter%`, `%width%`, `%height%`, and `%batch_num%`.
- `Load Images from Folder Dumas`
- Inputs: configured on-node via folder picker and image gallery
- Outputs: `image`, `mask`, `width`, `height`, `filename`, `index`, `total`
- Loads one or many images from any folder on disk as ComfyUI list outputs.
- Supports manual picks, `Select all`, `First N`, and `Select random` so each run can choose one random image from the visible folder tree.
- `Dumas H3 Plan Attach Scene Images` - `Dumas H3 Plan Attach Scene Images`
- Inputs: `plan`, `scene_index`, optional `image1`..`image7` - Inputs: `plan`, `scene_index`, optional `image1`..`image7`
- Outputs: `plan`, `connected_images` - Outputs: `plan`, `connected_images`
+114
View File
@@ -2,6 +2,8 @@ from .dumas_image_nodes import (
NODE_CLASS_MAPPINGS as IMAGE_NODE_CLASS_MAPPINGS, NODE_CLASS_MAPPINGS as IMAGE_NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS as IMAGE_NODE_DISPLAY_NAME_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as IMAGE_NODE_DISPLAY_NAME_MAPPINGS,
_next_counter_for_relative_path, _next_counter_for_relative_path,
_folder_is_image,
_list_folder_image_files,
resolve_serve_token, resolve_serve_token,
) )
from .dumas_json_nodes import ( from .dumas_json_nodes import (
@@ -21,11 +23,14 @@ WEB_DIRECTORY = "./js"
try: try:
import asyncio import asyncio
import io
import os import os
import string
import shutil import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
from PIL import Image, ImageOps
from aiohttp import web from aiohttp import web
from server import PromptServer from server import PromptServer
@@ -78,6 +83,20 @@ try:
except Exception: except Exception:
pass pass
def _dumas_is_path_under(child_path, parent_path):
try:
return os.path.commonpath([os.path.realpath(child_path), os.path.realpath(parent_path)]) == os.path.realpath(parent_path)
except ValueError:
return False
def _dumas_make_thumb(full_path):
image = Image.open(full_path)
image = ImageOps.exif_transpose(image).convert("RGB")
image.thumbnail((192, 192))
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=80)
return buffer.getvalue()
@PromptServer.instance.routes.get("/dumas/api/save_image/file") @PromptServer.instance.routes.get("/dumas/api/save_image/file")
async def dumas_save_image_file(request): async def dumas_save_image_file(request):
path = resolve_serve_token(request.query.get("t", "")) path = resolve_serve_token(request.query.get("t", ""))
@@ -133,6 +152,101 @@ try:
return web.json_response({"ok": True, "path": selected}) return web.json_response({"ok": True, "path": selected})
except Exception as exc: except Exception as exc:
return web.json_response({"ok": False, "message": str(exc)}) return web.json_response({"ok": False, "message": str(exc)})
@PromptServer.instance.routes.get("/dumas/api/load_images_folder/list")
async def dumas_load_images_folder_list(request):
headers = {"Cache-Control": "no-store"}
folder = request.query.get("path", "")
recursive = request.query.get("recursive", "0") == "1"
if not folder or not os.path.isdir(folder):
return web.json_response({"ok": False, "message": "Folder not found.", "files": []}, headers=headers)
real_folder = os.path.realpath(folder)
try:
loop = asyncio.get_running_loop()
files = await loop.run_in_executor(None, _list_folder_image_files, real_folder, recursive)
except Exception as exc:
return web.json_response({"ok": False, "message": f"Could not read folder: {exc}", "files": []}, headers=headers)
return web.json_response({"ok": True, "folder": real_folder, "files": files}, headers=headers)
@PromptServer.instance.routes.get("/dumas/api/load_images_folder/thumb")
async def dumas_load_images_folder_thumb(request):
folder = request.query.get("path", "")
rel_path = request.query.get("file", "")
if not folder or not rel_path or not os.path.isdir(folder):
return web.Response(status=404)
full_path = os.path.realpath(os.path.join(folder, rel_path))
if not _dumas_is_path_under(full_path, folder) or not os.path.isfile(full_path) or not _folder_is_image(os.path.basename(full_path)):
return web.Response(status=403)
try:
loop = asyncio.get_running_loop()
body = await loop.run_in_executor(None, _dumas_make_thumb, full_path)
return web.Response(body=body, content_type="image/jpeg", headers={"Cache-Control": "no-cache"})
except Exception:
return web.Response(status=404)
@PromptServer.instance.routes.get("/dumas/api/load_images_folder/browse")
async def dumas_load_images_folder_browse(request):
path = request.query.get("path", "")
try:
if not path:
dirs = []
if os.name == "nt":
for letter in string.ascii_uppercase:
drive = f"{letter}:\\"
if os.path.isdir(drive):
dirs.append({"name": drive, "path": drive, "images": -1})
else:
dirs.append({"name": "/", "path": "/", "images": -1})
return web.json_response({"ok": True, "path": "", "parent": None, "dirs": dirs})
if not os.path.isdir(path):
return web.json_response({"ok": False, "message": "Folder not found.", "dirs": []})
real_path = os.path.realpath(path)
parent = os.path.dirname(real_path)
if parent == real_path:
parent = ""
subdirs = []
try:
for name in sorted(os.listdir(real_path), key=str.lower):
full_path = os.path.join(real_path, name)
if os.path.isdir(full_path):
subdirs.append((name, full_path))
except OSError as exc:
return web.json_response({"ok": False, "message": f"Could not read folder: {exc}", "dirs": []})
should_count = len(subdirs) <= 60
dirs = []
for name, full_path in subdirs:
images = -1
if should_count:
try:
images = sum(1 for entry in os.listdir(full_path) if _folder_is_image(entry))
except OSError:
images = -1
dirs.append({"name": name, "path": full_path, "images": images})
return web.json_response({"ok": True, "path": real_path, "parent": parent, "dirs": dirs})
except Exception as exc:
return web.json_response({"ok": False, "message": str(exc), "dirs": []})
@PromptServer.instance.routes.get("/dumas/api/load_images_folder/pick_native")
async def dumas_load_images_folder_pick_native(request):
try:
if not _dumas_dialog_available():
return web.json_response({"ok": False, "unavailable": True})
start_path = str(request.query.get("path", "") or "").strip()
if start_path and not os.path.isdir(start_path):
start_path = ""
loop = asyncio.get_running_loop()
selected = await loop.run_in_executor(None, _dumas_native_folder_dialog, start_path)
if selected is None:
return web.json_response({"ok": False, "busy": True})
if selected and os.path.isdir(selected):
return web.json_response({"ok": True, "path": selected})
return web.json_response({"ok": False, "cancelled": True})
except Exception as exc:
return web.json_response({"ok": False, "message": str(exc)})
except Exception: except Exception:
pass pass
+279
View File
@@ -1,3 +1,4 @@
import hashlib
import json import json
import os import os
import random import random
@@ -22,6 +23,17 @@ _H3_PLAN_TYPE = "H3_CHAIN_PLAN"
_H3_PLAN_IMAGE_BINDINGS_KEY = "_dumas_scene_image_bindings" _H3_PLAN_IMAGE_BINDINGS_KEY = "_dumas_scene_image_bindings"
_H3_PLAN_IMAGE_BINDINGS = OrderedDict() _H3_PLAN_IMAGE_BINDINGS = OrderedDict()
_H3_PLAN_IMAGE_BINDINGS_CAP = 128 _H3_PLAN_IMAGE_BINDINGS_CAP = 128
_FOLDER_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif")
_LOAD_IMAGES_FOLDER_DEFAULT_STATE = {
"version": 1,
"folder": "",
"recursive": False,
"sort": "name",
"sort_dir": "asc",
"selected": [],
"selection_mode": "selected",
"first_n": 5,
}
def _clean_input_token_value(value): def _clean_input_token_value(value):
@@ -255,6 +267,155 @@ def _tensor_image_to_pil_image(tensor):
return Image.fromarray(np.clip(image_array, 0, 255).astype(np.uint8)) return Image.fromarray(np.clip(image_array, 0, 255).astype(np.uint8))
def _folder_loader_default_state():
return dict(_LOAD_IMAGES_FOLDER_DEFAULT_STATE)
def _parse_load_images_folder_state(state_json):
if not state_json:
return _folder_loader_default_state()
try:
parsed = json.loads(state_json)
except Exception:
return _folder_loader_default_state()
state = _folder_loader_default_state()
if isinstance(parsed, dict):
state.update({key: value for key, value in parsed.items() if key in state})
return state
def _folder_is_image(name):
return str(name or "").lower().endswith(_FOLDER_IMAGE_EXTS)
def _list_folder_image_files(real_folder, recursive):
files = []
if recursive:
for root, _dirs, names in os.walk(real_folder):
for name in names:
if not _folder_is_image(name):
continue
full_path = os.path.join(root, name)
try:
stat_result = os.stat(full_path)
except OSError:
continue
rel_path = os.path.relpath(full_path, real_folder).replace("\\", "/")
files.append(
{
"file": rel_path,
"name": name,
"size": stat_result.st_size,
"mtime": stat_result.st_mtime,
}
)
else:
for name in os.listdir(real_folder):
full_path = os.path.join(real_folder, name)
if not os.path.isfile(full_path) or not _folder_is_image(name):
continue
try:
stat_result = os.stat(full_path)
except OSError:
continue
files.append(
{
"file": name,
"name": name,
"size": stat_result.st_size,
"mtime": stat_result.st_mtime,
}
)
return files
def _sort_folder_image_files(files, sort_key, sort_dir):
ordered = list(files or [])
def sort_value(entry):
if sort_key == "date":
return (float(entry.get("mtime") or 0), str(entry.get("file") or "").lower())
return str(entry.get("file") or "").lower()
ordered.sort(key=sort_value, reverse=str(sort_dir or "").lower() == "desc")
return ordered
def _resolve_folder_selection(state, files):
ordered = _sort_folder_image_files(
files,
state.get("sort", "name"),
state.get("sort_dir", "asc"),
)
mode = str(state.get("selection_mode") or "selected").lower()
if mode == "all":
return [entry["file"] for entry in ordered]
if mode == "first_n":
try:
count = max(0, int(state.get("first_n", 0) or 0))
except Exception:
count = 0
return [entry["file"] for entry in ordered[:count]]
if mode == "random":
return [random.choice(ordered)["file"]] if ordered else []
present = {entry["file"] for entry in ordered}
selected = []
for rel_path in state.get("selected", []) or []:
if isinstance(rel_path, str) and rel_path in present:
selected.append(rel_path)
return selected
def _load_folder_image(path):
import numpy as np
try:
import torch
except Exception as exc:
raise RuntimeError("torch is required to load folder images") from exc
from PIL import Image, ImageOps, ImageSequence
try:
import comfy.model_management as comfy_model_management
tensor_dtype = comfy_model_management.intermediate_dtype()
except Exception:
tensor_dtype = torch.float32
try:
import node_helpers
image = node_helpers.pillow(Image.open, path)
except Exception:
image = Image.open(path)
frame = ImageOps.exif_transpose(next(ImageSequence.Iterator(image)))
if frame.mode == "I":
frame = frame.point(lambda px: px * (1 / 255))
rgb_image = frame.convert("RGB")
width, height = rgb_image.size
if "A" in frame.getbands():
alpha = np.array(frame.getchannel("A")).astype(np.float32) / 255.0
mask_image = Image.fromarray(((1.0 - alpha) * 255).astype(np.uint8), mode="L")
elif frame.mode == "P" and "transparency" in frame.info:
alpha = np.array(frame.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask_image = Image.fromarray(((1.0 - alpha) * 255).astype(np.uint8), mode="L")
else:
mask_image = Image.new("L", rgb_image.size, 0)
image_tensor = torch.from_numpy(np.array(rgb_image).astype(np.float32) / 255.0)[None,].to(
dtype=tensor_dtype
)
mask_tensor = torch.from_numpy(np.array(mask_image).astype(np.float32) / 255.0).unsqueeze(0).to(
dtype=tensor_dtype
)
return image_tensor, mask_tensor, int(width), int(height)
def _normalize_free_text(value): def _normalize_free_text(value):
return " ".join(str(value or "").split()).strip() return " ".join(str(value or "").split()).strip()
@@ -628,6 +789,122 @@ class DumasSaveImageNode:
return {"ui": {"images": ui_images}} return {"ui": {"images": ui_images}}
class DumasLoadImagesFolderNode:
DESCRIPTION = (
"Load many images from any folder on disk and feed them through your "
"workflow one at a time. Pick specific images, all images, the first N "
"images in sort order, or one random image per run."
)
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height", "filename", "index", "total")
OUTPUT_IS_LIST = (True, True, True, True, True, True, True)
FUNCTION = "load"
CATEGORY = "Dumas/Image"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"hidden": {
"LoadImagesFolderState": (
"STRING",
{"default": json.dumps(_LOAD_IMAGES_FOLDER_DEFAULT_STATE)},
),
},
}
def load(self, LoadImagesFolderState=""):
state = _parse_load_images_folder_state(LoadImagesFolderState)
folder = str(state.get("folder") or "").strip()
recursive = bool(state.get("recursive", False))
if not folder or not os.path.isdir(folder):
raise ValueError(
"Load Images from Folder: folder not found. Set a folder on the node first."
)
real_folder = os.path.realpath(folder)
files = _list_folder_image_files(real_folder, recursive)
selected = _resolve_folder_selection(state, files)
mode = str(state.get("selection_mode") or "selected").lower()
if not selected:
if mode == "random":
raise ValueError(
"Load Images from Folder: no images found for random selection."
)
raise ValueError(
"Load Images from Folder: no images selected. Use Pick images on the node."
)
images = []
masks = []
widths = []
heights = []
names = []
indices = []
count = 0
for rel_path in selected:
if not isinstance(rel_path, str) or not rel_path:
continue
full_path = os.path.realpath(os.path.join(real_folder, rel_path))
if not _is_within_directory(real_folder, full_path) or not os.path.isfile(full_path):
continue
try:
image_tensor, mask_tensor, width, height = _load_folder_image(full_path)
except Exception as exc:
print(f"[DumasLoadImagesFolder] failed to load {rel_path}: {exc}")
continue
images.append(image_tensor)
masks.append(mask_tensor)
widths.append(width)
heights.append(height)
if recursive:
names.append(os.path.splitext(rel_path)[0].replace("/", "_").replace("\\", "_"))
else:
names.append(os.path.splitext(os.path.basename(rel_path))[0])
count += 1
indices.append(count)
if not images:
raise ValueError(
"Load Images from Folder: none of the chosen images could be loaded."
)
totals = [count] * len(images)
return (images, masks, widths, heights, names, indices, totals)
@classmethod
def IS_CHANGED(cls, LoadImagesFolderState=""):
state = _parse_load_images_folder_state(LoadImagesFolderState)
folder = str(state.get("folder") or "").strip()
if not folder or not os.path.isdir(folder):
return hashlib.sha256((LoadImagesFolderState or "").encode("utf-8")).hexdigest()
real_folder = os.path.realpath(folder)
files = _list_folder_image_files(real_folder, bool(state.get("recursive", False)))
mode = str(state.get("selection_mode") or "selected").lower()
parts = [json.dumps({k: v for k, v in state.items() if k != "selected"}, sort_keys=True)]
if mode == "random":
for entry in _sort_folder_image_files(files, state.get("sort", "name"), state.get("sort_dir", "asc")):
parts.append(f"{entry['file']}:{entry.get('mtime', 0)}")
parts.append(f"random:{time.time_ns()}")
else:
for rel_path in _resolve_folder_selection(state, files):
full_path = os.path.realpath(os.path.join(real_folder, rel_path))
if not _is_within_directory(real_folder, full_path):
parts.append(f"{rel_path}:outside")
continue
try:
parts.append(f"{rel_path}:{os.stat(full_path).st_mtime_ns}")
except OSError:
parts.append(f"{rel_path}:missing")
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
class DumasH3PlanAttachSceneImagesNode: class DumasH3PlanAttachSceneImagesNode:
DESCRIPTION = ( DESCRIPTION = (
"Attach up to seven optional IMAGE sockets to one H3 Chain Plan scene " "Attach up to seven optional IMAGE sockets to one H3 Chain Plan scene "
@@ -952,6 +1229,7 @@ class DumasCharacterHelperNode:
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"DumasImageCompare": DumasImageCompareNode, "DumasImageCompare": DumasImageCompareNode,
"DumasSaveImage": DumasSaveImageNode, "DumasSaveImage": DumasSaveImageNode,
"DumasLoadImagesFolder": DumasLoadImagesFolderNode,
"DumasH3PlanAttachSceneImages": DumasH3PlanAttachSceneImagesNode, "DumasH3PlanAttachSceneImages": DumasH3PlanAttachSceneImagesNode,
"DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode, "DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode,
"DumasCharacterHelper": DumasCharacterHelperNode, "DumasCharacterHelper": DumasCharacterHelperNode,
@@ -960,6 +1238,7 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"DumasImageCompare": "Dumas Image Compare", "DumasImageCompare": "Dumas Image Compare",
"DumasSaveImage": "Save Image Dumas", "DumasSaveImage": "Save Image Dumas",
"DumasLoadImagesFolder": "Load Images from Folder Dumas",
"DumasH3PlanAttachSceneImages": "Dumas H3 Plan Attach Scene Images", "DumasH3PlanAttachSceneImages": "Dumas H3 Plan Attach Scene Images",
"DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images", "DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images",
"DumasCharacterHelper": "Dumas Character Helper", "DumasCharacterHelper": "Dumas Character Helper",
+40
View File
@@ -0,0 +1,40 @@
export async function listFolder(folder, recursive) {
try {
const url =
`/dumas/api/load_images_folder/list?path=${encodeURIComponent(folder)}` +
`&recursive=${recursive ? 1 : 0}`;
const response = await fetch(url, { cache: "no-store" });
return await response.json();
} catch (error) {
return { ok: false, message: String(error), files: [] };
}
}
export function thumbURL(folder, rel, mtime) {
return (
`/dumas/api/load_images_folder/thumb?path=${encodeURIComponent(folder)}` +
`&file=${encodeURIComponent(rel)}&mt=${Math.floor(mtime || 0)}`
);
}
export async function browseFolder(path) {
try {
const response = await fetch(
`/dumas/api/load_images_folder/browse?path=${encodeURIComponent(path || "")}`,
);
return await response.json();
} catch (error) {
return { ok: false, message: String(error), dirs: [] };
}
}
export async function pickNativeFolder(startPath) {
try {
const response = await fetch(
`/dumas/api/load_images_folder/pick_native?path=${encodeURIComponent(startPath || "")}`,
);
return await response.json();
} catch (error) {
return { ok: false, message: String(error) };
}
}
+276
View File
@@ -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);
};
},
});
+47
View File
@@ -0,0 +1,47 @@
export const COMFY_CLASS = "DumasLoadImagesFolder";
export const STATE_PROP = "loadImagesFolderState";
export const HIDDEN_INPUT_NAME = "LoadImagesFolderState";
export const DEFAULT_STATE = {
version: 1,
folder: "",
recursive: false,
sort: "name",
sort_dir: "asc",
selected: [],
selection_mode: "selected",
first_n: 5,
};
export function readState(node) {
const value = node.properties?.[STATE_PROP];
if (typeof value === "string" && value) {
try {
return { ...DEFAULT_STATE, ...JSON.parse(value) };
} catch (_error) {
return { ...DEFAULT_STATE };
}
}
return { ...DEFAULT_STATE };
}
export function writeState(node, state) {
if (!node.properties) node.properties = {};
node.properties[STATE_PROP] = JSON.stringify({ ...DEFAULT_STATE, ...(state || {}) });
}
export function sortFiles(files, sort, dir) {
const ordered = [...(files || [])];
ordered.sort((a, b) => {
if (sort === "date") {
const delta = (a?.mtime || 0) - (b?.mtime || 0);
if (delta !== 0) return dir === "desc" ? -delta : delta;
}
const delta = String(a?.file || "").localeCompare(String(b?.file || ""), undefined, {
numeric: true,
sensitivity: "base",
});
return dir === "desc" ? -delta : delta;
});
return ordered;
}
+415
View File
@@ -0,0 +1,415 @@
import { browseFolder, thumbURL } from "./api.mjs";
import { readState, sortFiles, writeState } from "./state.mjs";
const FOLDER_SVG =
'<svg viewBox="0 0 64 64" aria-hidden="true"><path d="M52.291,56.817H5.626c-1.006,0-1.922-.594-2.5-1.323-.752-.949-.846-2.209-.483-3.372l7.293-23.34c.522-1.67,1.625-2.992,3.453-3.243h46.148c2.155.308,3.418,2.045,3.193,4.245l-7.097,23.693c-.491,1.64-1.523,2.993-3.343,3.341ZM50.726,14.308h-21.805c-.429-.181-.717-.689-.997-1.031l-3.967-4.843c-.559-.682-1.432-1.249-2.369-1.25H6.186c-1.185,0-2.24.531-3.095,1.272-1.098.952-1.545,2.24-1.818,3.706v31.447c1.841-5.514,3.332-10.857,5.103-16.241.459-1.396,1.126-2.594,2.154-3.621,1.355-1.054,2.862-2.056,4.685-2.057h42.426c.669-2.549-.634-7.369-4.914-7.382Z"/></svg>';
function escapeHtml(value) {
return String(value).replace(/[&<>"]/g, (char) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char]
));
}
export function injectCSS() {
if (document.getElementById("dumas-lif-css")) return;
const style = document.createElement("style");
style.id = "dumas-lif-css";
style.textContent = `
.dlf-root { display:flex; flex-direction:column; gap:8px; padding:8px 10px; box-sizing:border-box; font-family:inherit; }
.dlf-folderrow { display:flex; gap:6px; }
.dlf-folder { flex:1; min-width:0; background:#141414; border:1px solid #3a3a3a; border-radius:5px; padding:7px 8px; color:#cfcfcf; font-size:11px; box-sizing:border-box; }
.dlf-folder:focus { outline:none; border-color:#e66a2c; }
.dlf-browse { display:flex; align-items:center; gap:5px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.16); border-radius:5px; color:#ddd; font-size:11px; padding:0 9px; cursor:pointer; white-space:nowrap; }
.dlf-browse:hover { border-color:#e66a2c; color:#fff; }
.dlf-browse svg { width:13px; height:13px; fill:currentColor; }
.dlf-pick { background:#e66a2c; border:1px solid #e66a2c; border-radius:6px; padding:8px; font-size:12px; color:#fff; text-align:center; font-weight:500; cursor:pointer; }
.dlf-pick:hover { filter:brightness(1.08); }
.dlf-pick.empty { background:rgba(255,255,255,0.05); border-color:rgba(255,255,255,0.16); color:#9a9a9a; }
.dlf-msg { font-size:11px; color:#e0a33e; line-height:1.4; }
.dlf-msg:empty { display:none; }
.dlf-menu, .dlf-gallery, .dlf-browse-pop { position:fixed; z-index:99999; background:#191919; box-shadow:0 14px 40px rgba(0,0,0,0.6); }
.dlf-menu { border:1px solid #3a3a3a; border-radius:6px; overflow:hidden; min-width:150px; }
.dlf-menu .it { padding:7px 11px; font-size:12px; color:#cfcfcf; cursor:pointer; display:flex; justify-content:space-between; gap:14px; }
.dlf-menu .it:hover { background:#2a2a2a; }
.dlf-menu .it.on { color:#e66a2c; }
.dlf-gallery, .dlf-browse-pop { border:1px solid #e66a2c; border-radius:9px; display:flex; flex-direction:column; }
.dlf-gallery { max-height:80vh; }
.dlf-gal-head { padding:9px 12px; border-bottom:1px solid #333; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.dlf-tbtn { background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.16); border-radius:5px; padding:5px 10px; font-size:11px; color:#ddd; cursor:pointer; user-select:none; }
.dlf-tbtn:hover { border-color:#e66a2c; color:#fff; }
.dlf-tbtn.active { border-color:#e66a2c; color:#fff; background:rgba(230,106,44,0.18); }
.dlf-firstwrap { display:flex; align-items:center; }
.dlf-firstwrap .dlf-tbtn { border-radius:5px 0 0 5px; }
.dlf-firstn { width:46px; background:#141414; border:1px solid rgba(255,255,255,0.16); border-left:none; border-radius:0 5px 5px 0; color:#e66a2c; font-size:11px; padding:5px 4px; text-align:center; box-sizing:border-box; }
.dlf-firstn:focus { outline:none; border-color:#e66a2c; }
.dlf-count { margin-left:auto; font-size:11px; color:#9a9a9a; white-space:nowrap; }
.dlf-count b { color:#e66a2c; }
.dlf-gal-body { padding:10px 12px; overflow:auto; }
.dlf-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(84px,1fr)); gap:7px; }
.dlf-thumb { position:relative; aspect-ratio:1; border-radius:5px; border:2px solid transparent; cursor:pointer; overflow:hidden; background:#0f0f0f; }
.dlf-thumb img { width:100%; height:100%; object-fit:cover; display:block; }
.dlf-thumb .veil { position:absolute; inset:0; background:rgba(0,0,0,0.45); }
.dlf-thumb.sel { border-color:#e66a2c; }
.dlf-thumb.sel .veil { opacity:0; }
.dlf-thumb .chk { position:absolute; top:3px; right:3px; width:16px; height:16px; border-radius:50%; background:#e66a2c; color:#fff; font-size:11px; display:none; align-items:center; justify-content:center; }
.dlf-thumb.sel .chk { display:flex; }
.dlf-thumb .nm { position:absolute; bottom:0; left:0; right:0; padding:2px 4px; font-size:9px; color:#eee; background:linear-gradient(transparent, rgba(0,0,0,0.75)); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.dlf-gal-empty { padding:30px; text-align:center; color:#888; font-size:12px; grid-column:1/-1; }
.dlf-gal-foot { padding:9px 12px; border-top:1px solid #333; display:flex; gap:10px; align-items:center; }
.dlf-subf { display:flex; align-items:center; gap:6px; font-size:11px; color:#bbb; cursor:pointer; user-select:none; }
.dlf-subf .box { width:12px; height:12px; border:1px solid #555; border-radius:3px; }
.dlf-subf.on .box { background:#e66a2c; border-color:#e66a2c; }
.dlf-done { margin-left:auto; background:#e66a2c; border:1px solid #e66a2c; border-radius:6px; padding:6px 16px; font-size:12px; color:#fff; cursor:pointer; }
.dlf-done:hover { filter:brightness(1.08); }
.dlf-bp-head { padding:9px 12px; border-bottom:1px solid #333; font-size:12px; color:#e66a2c; font-weight:600; }
.dlf-bp-crumb { padding:7px 12px 4px; font-size:11px; color:#999; word-break:break-all; }
.dlf-bp-list { padding:6px 10px 10px; overflow:auto; display:flex; flex-direction:column; gap:4px; }
.dlf-bp-item { display:flex; align-items:center; gap:8px; padding:7px 9px; background:#141414; border:1px solid #2c2c2c; border-radius:6px; cursor:pointer; font-size:12px; color:#ddd; }
.dlf-bp-item:hover { border-color:#e66a2c; background:#1c1c1c; }
.dlf-bp-item svg { width:13px; height:13px; fill:#e66a2c; flex:0 0 auto; }
.dlf-bp-item .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.dlf-bp-item .cnt { color:#777; font-size:11px; white-space:nowrap; }
.dlf-bp-item.up { color:#9a9a9a; }
.dlf-bp-empty { padding:14px; text-align:center; color:#777; font-size:12px; }
.dlf-bp-foot { padding:9px 12px; border-top:1px solid #333; display:flex; gap:8px; }
`;
document.head.appendChild(style);
}
function positionBelow(popup, anchorEl, width) {
const rect = anchorEl.getBoundingClientRect();
popup.style.width = `${width}px`;
popup.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`;
popup.style.top = `${rect.bottom + 4}px`;
requestAnimationFrame(() => {
const popupRect = popup.getBoundingClientRect();
if (popupRect.bottom > window.innerHeight - 8) {
popup.style.top = `${Math.max(8, window.innerHeight - 8 - popupRect.height)}px`;
}
});
}
function attachClosePopup(popup, onClose, ignoreSelector) {
const close = () => {
if (popup._dlfClosed) return;
popup._dlfClosed = true;
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("wheel", onWheel, true);
document.removeEventListener("keydown", onKey, true);
popup.remove();
onClose?.();
};
const inside = (target) =>
popup.contains(target) || (ignoreSelector && target.closest && target.closest(ignoreSelector));
const onDown = (event) => { if (!inside(event.target)) close(); };
const onWheel = (event) => { if (!inside(event.target)) close(); };
const onKey = (event) => { if (event.key === "Escape") close(); };
popup._dlfClose = close;
setTimeout(() => {
if (popup._dlfClosed) return;
document.addEventListener("mousedown", onDown, true);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("wheel", onWheel, true);
document.addEventListener("keydown", onKey, true);
}, 0);
return close;
}
export function openMiniMenu(anchorEl, items, currentValue, onPick) {
document.querySelectorAll(".dlf-menu").forEach((menu) => menu._dlfClose?.());
const menu = document.createElement("div");
menu.className = "dlf-menu";
for (const item of items) {
const row = document.createElement("div");
row.className = `it${item.value === currentValue ? " on" : ""}`;
row.innerHTML = `<span>${escapeHtml(item.label)}</span>`;
row.addEventListener("click", () => {
menu._dlfClose?.();
onPick(item.value);
});
menu.appendChild(row);
}
document.body.appendChild(menu);
positionBelow(menu, anchorEl, Math.max(150, anchorEl.getBoundingClientRect().width));
attachClosePopup(menu);
}
export function buildRoot() {
const root = document.createElement("div");
root.className = "dlf-root";
root.innerHTML =
`<div class="dlf-folderrow">` +
`<input class="dlf-folder" type="text" spellcheck="false" placeholder="Folder path - type, paste, or Browse">` +
`<button class="dlf-browse" type="button" title="Browse for a folder">${FOLDER_SVG}<span class="dlf-browse-lbl">Browse</span></button>` +
`</div>` +
`<button class="dlf-pick empty" type="button" title="Choose which images to load">Pick images · 0 / 0</button>` +
`<div class="dlf-msg"></div>`;
return {
root,
folderInput: root.querySelector(".dlf-folder"),
browseBtn: root.querySelector(".dlf-browse"),
browseLbl: root.querySelector(".dlf-browse-lbl"),
pickBtn: root.querySelector(".dlf-pick"),
msgEl: root.querySelector(".dlf-msg"),
};
}
const SORTS = [
{ value: "name|asc", label: "Name ↑" },
{ value: "name|desc", label: "Name ↓" },
{ value: "date|asc", label: "Date ↑" },
{ value: "date|desc", label: "Date ↓" },
];
function randomPreviewFile(files) {
if (!files.length) return "";
return files[Math.floor(Math.random() * files.length)]?.file || "";
}
export function openPickGallery(node, anchorEl, ctx) {
document.querySelectorAll(".dlf-gallery").forEach((gallery) => gallery._dlfClose?.());
const gallery = document.createElement("div");
gallery.className = "dlf-gallery";
gallery.innerHTML =
`<div class="dlf-gal-head">` +
`<div class="dlf-tbtn" data-act="all" title="Select every image in this folder">Select all</div>` +
`<div class="dlf-tbtn" data-act="none" title="Deselect all">None</div>` +
`<div class="dlf-firstwrap"><div class="dlf-tbtn" data-act="first" title="Select the first N images">First</div>` +
`<input class="dlf-firstn" type="number" min="1" value="5" title="How many images First selects"></div>` +
`<div class="dlf-tbtn" data-act="random" title="Pick one random image each run">Select random</div>` +
`<div class="dlf-count"><b class="dlf-cn">0</b> / <span class="dlf-ct">0</span> active</div>` +
`</div>` +
`<div class="dlf-gal-body"><div class="dlf-grid"></div></div>` +
`<div class="dlf-gal-foot">` +
`<div class="dlf-subf" title="Also include images inside sub-folders"><span class="box"></span> Include subfolders</div>` +
`<div class="dlf-tbtn" data-act="sort" title="Change the sort order">Sort: Name ↑</div>` +
`<div class="dlf-done" data-act="done" title="Apply this selection and close">Done</div>` +
`</div>`;
document.body.appendChild(gallery);
const grid = gallery.querySelector(".dlf-grid");
const countCurrent = gallery.querySelector(".dlf-cn");
const countTotal = gallery.querySelector(".dlf-ct");
const firstInput = gallery.querySelector(".dlf-firstn");
const recursiveToggle = gallery.querySelector(".dlf-subf");
const sortButton = gallery.querySelector('[data-act="sort"]');
const randomButton = gallery.querySelector('[data-act="random"]');
const allButton = gallery.querySelector('[data-act="all"]');
const firstButton = gallery.querySelector('[data-act="first"]');
let state = readState(node);
const manualSelection = new Set(state.selected || []);
let randomPreview = "";
function activeFiles() {
const files = sortFiles(node._dlfFiles || [], state.sort, state.sort_dir);
if (state.selection_mode === "all") return new Set(files.map((file) => file.file));
if (state.selection_mode === "first_n") {
const count = Math.max(0, Math.min(parseInt(state.first_n, 10) || 0, files.length));
return new Set(files.slice(0, count).map((file) => file.file));
}
if (state.selection_mode === "random") {
if (!randomPreview || !files.some((file) => file.file === randomPreview)) {
randomPreview = randomPreviewFile(files);
}
return randomPreview ? new Set([randomPreview]) : new Set();
}
return manualSelection;
}
function commit() {
const fresh = readState(node);
fresh.selected = [...manualSelection];
fresh.sort = state.sort;
fresh.sort_dir = state.sort_dir;
fresh.recursive = state.recursive;
fresh.selection_mode = state.selection_mode;
fresh.first_n = Math.max(1, parseInt(firstInput.value, 10) || 1);
writeState(node, fresh);
state = fresh;
ctx.onChange?.(node);
}
function renderGrid() {
grid.innerHTML = "";
const files = sortFiles(node._dlfFiles || [], state.sort, state.sort_dir);
const active = activeFiles();
sortButton.textContent = `Sort: ${SORTS.find((item) => item.value === `${state.sort}|${state.sort_dir}`)?.label || "Name ↑"}`;
recursiveToggle.classList.toggle("on", !!state.recursive);
allButton.classList.toggle("active", state.selection_mode === "all");
firstButton.classList.toggle("active", state.selection_mode === "first_n");
randomButton.classList.toggle("active", state.selection_mode === "random");
countCurrent.textContent = active.size;
countTotal.textContent = files.length;
firstInput.value = String(Math.max(1, parseInt(state.first_n, 10) || 1));
firstInput.max = String(files.length || 1);
if (!files.length) {
const empty = document.createElement("div");
empty.className = "dlf-gal-empty";
empty.textContent = node._dlfListError || "No images in this folder.";
grid.appendChild(empty);
return;
}
for (const file of files) {
const cell = document.createElement("div");
cell.className = `dlf-thumb${active.has(file.file) ? " sel" : ""}`;
cell.innerHTML =
`<img loading="lazy" src="${thumbURL(state.folder, file.file, file.mtime)}" onerror="this.style.display='none'">` +
`<div class="veil"></div><div class="chk">✓</div>` +
`<div class="nm">${escapeHtml(file.name)}</div>`;
cell.addEventListener("click", () => {
state.selection_mode = "selected";
randomPreview = "";
if (manualSelection.has(file.file)) manualSelection.delete(file.file);
else manualSelection.add(file.file);
commit();
renderGrid();
});
grid.appendChild(cell);
}
}
gallery.querySelector('[data-act="all"]').addEventListener("click", () => {
state.selection_mode = "all";
randomPreview = "";
commit();
renderGrid();
});
gallery.querySelector('[data-act="none"]').addEventListener("click", () => {
state.selection_mode = "selected";
manualSelection.clear();
randomPreview = "";
commit();
renderGrid();
});
gallery.querySelector('[data-act="first"]').addEventListener("click", () => {
state.selection_mode = "first_n";
randomPreview = "";
commit();
renderGrid();
});
gallery.querySelector('[data-act="random"]').addEventListener("click", () => {
state.selection_mode = "random";
randomPreview = randomPreviewFile(sortFiles(node._dlfFiles || [], state.sort, state.sort_dir));
commit();
renderGrid();
});
firstInput.addEventListener("input", () => {
if (state.selection_mode !== "first_n") return;
commit();
renderGrid();
});
firstInput.addEventListener("keydown", (event) => {
event.stopImmediatePropagation();
if (event.key === "Enter") {
event.preventDefault();
commit();
renderGrid();
}
});
sortButton.addEventListener("click", () => {
openMiniMenu(sortButton, SORTS, `${state.sort}|${state.sort_dir}`, (value) => {
const [sort, sortDir] = value.split("|");
state.sort = sort;
state.sort_dir = sortDir;
if (state.selection_mode === "random") {
randomPreview = randomPreviewFile(sortFiles(node._dlfFiles || [], state.sort, state.sort_dir));
}
commit();
renderGrid();
});
});
recursiveToggle.addEventListener("click", async () => {
state.recursive = !state.recursive;
writeState(node, state);
grid.innerHTML = '<div class="dlf-gal-empty">Loading…</div>';
await ctx.refreshListing(node);
if (gallery._dlfClosed) return;
state = readState(node);
manualSelection.clear();
(state.selected || []).forEach((file) => manualSelection.add(file));
randomPreview = "";
renderGrid();
});
gallery.querySelector('[data-act="done"]').addEventListener("click", () => gallery._dlfClose?.());
node._dlfGallery = gallery;
attachClosePopup(gallery, () => {
document.querySelectorAll(".dlf-menu").forEach((menu) => menu._dlfClose?.());
if (node._dlfGallery === gallery) node._dlfGallery = null;
}, ".dlf-menu");
positionBelow(gallery, anchorEl, Math.min(560, window.innerWidth - 16));
renderGrid();
}
export function openBrowsePopup(node, anchorEl, ctx) {
document.querySelectorAll(".dlf-browse-pop").forEach((popup) => popup._dlfClose?.());
const popup = document.createElement("div");
popup.className = "dlf-browse-pop";
popup.innerHTML =
`<div class="dlf-bp-head">Choose a folder</div>` +
`<div class="dlf-bp-crumb"></div>` +
`<div class="dlf-bp-list"></div>` +
`<div class="dlf-bp-foot">` +
`<div class="dlf-tbtn" data-act="cancel">Cancel</div>` +
`<div class="dlf-done" data-act="use">Use this folder</div>` +
`</div>`;
document.body.appendChild(popup);
const crumb = popup.querySelector(".dlf-bp-crumb");
const list = popup.querySelector(".dlf-bp-list");
const useButton = popup.querySelector('[data-act="use"]');
let current = ctx.startPath || "";
async function nav(path) {
list.innerHTML = '<div class="dlf-bp-empty">Loading…</div>';
const response = await browseFolder(path);
if (popup._dlfClosed) return;
if (!response.ok) {
list.innerHTML = `<div class="dlf-bp-empty">${escapeHtml(response.message || "Could not open this folder.")}</div>`;
return;
}
current = response.path || "";
useButton.style.opacity = current ? "" : "0.4";
useButton.style.pointerEvents = current ? "" : "none";
crumb.innerHTML = current ? `Location: <b style="color:#ddd">${escapeHtml(current)}</b>` : "This PC";
list.innerHTML = "";
if (response.parent !== null && response.parent !== undefined) {
const up = document.createElement("div");
up.className = "dlf-bp-item up";
up.innerHTML = '<span style="width:13px;text-align:center;flex:0 0 auto">↰</span> <span class="nm">.. (up one level)</span>';
up.addEventListener("click", () => nav(response.parent || ""));
list.appendChild(up);
}
if (!response.dirs.length) {
const empty = document.createElement("div");
empty.className = "dlf-bp-empty";
empty.textContent = current ? "No sub-folders here. Use this folder." : "No drives found.";
list.appendChild(empty);
}
for (const dir of response.dirs) {
const item = document.createElement("div");
item.className = "dlf-bp-item";
const count = dir.images >= 0 ? `<span class="cnt">${dir.images} image${dir.images === 1 ? "" : "s"}</span>` : "";
item.innerHTML = `${FOLDER_SVG}<span class="nm">${escapeHtml(dir.name)}</span>${count}`;
item.addEventListener("click", () => nav(dir.path));
list.appendChild(item);
}
}
popup.querySelector('[data-act="cancel"]').addEventListener("click", () => popup._dlfClose?.());
popup.querySelector('[data-act="use"]').addEventListener("click", () => {
if (current) ctx.onPick(current);
popup._dlfClose?.();
});
node._dlfBrowsePopup = popup;
attachClosePopup(popup, () => {
if (node._dlfBrowsePopup === popup) node._dlfBrowsePopup = null;
});
positionBelow(popup, anchorEl, Math.min(440, window.innerWidth - 16));
nav(current);
}
+107
View File
@@ -387,6 +387,113 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertEqual(result["ui"]["images"], []) self.assertEqual(result["ui"]["images"], [])
self.assertEqual(FakePILImage.saved_paths, []) self.assertEqual(FakePILImage.saved_paths, [])
def test_load_images_folder_uses_manual_selected_files(self):
node = self.image_nodes.DumasLoadImagesFolderNode()
folder = tempfile.mkdtemp(prefix="dumas-load-folder-manual-")
for name in ("b.png", "a.png", "notes.txt"):
open(os.path.join(folder, name), "a", encoding="utf-8").close()
state = json.dumps(
{
"folder": folder,
"recursive": False,
"sort": "name",
"sort_dir": "asc",
"selection_mode": "selected",
"selected": ["b.png", "a.png"],
}
)
with mock.patch.object(
self.image_nodes,
"_load_folder_image",
side_effect=lambda path: (f"image:{os.path.basename(path)}", f"mask:{os.path.basename(path)}", 32, 24),
):
result = node.load(state)
self.assertEqual(result[0], ["image:b.png", "image:a.png"])
self.assertEqual(result[1], ["mask:b.png", "mask:a.png"])
self.assertEqual(result[4], ["b", "a"])
self.assertEqual(result[5], [1, 2])
self.assertEqual(result[6], [2, 2])
def test_load_images_folder_first_n_uses_sorted_files(self):
node = self.image_nodes.DumasLoadImagesFolderNode()
folder = tempfile.mkdtemp(prefix="dumas-load-folder-first-")
for name in ("c.png", "a.png", "b.png"):
open(os.path.join(folder, name), "a", encoding="utf-8").close()
state = json.dumps(
{
"folder": folder,
"recursive": False,
"sort": "name",
"sort_dir": "asc",
"selection_mode": "first_n",
"first_n": 2,
}
)
with mock.patch.object(
self.image_nodes,
"_load_folder_image",
side_effect=lambda path: (f"image:{os.path.basename(path)}", f"mask:{os.path.basename(path)}", 64, 48),
):
result = node.load(state)
self.assertEqual(result[0], ["image:a.png", "image:b.png"])
self.assertEqual(result[4], ["a", "b"])
self.assertEqual(result[6], [2, 2])
def test_load_images_folder_random_selects_one_visible_image(self):
node = self.image_nodes.DumasLoadImagesFolderNode()
folder = tempfile.mkdtemp(prefix="dumas-load-folder-random-")
for name in ("a.png", "b.png", "c.png"):
open(os.path.join(folder, name), "a", encoding="utf-8").close()
state = json.dumps(
{
"folder": folder,
"recursive": False,
"sort": "name",
"sort_dir": "asc",
"selection_mode": "random",
}
)
with mock.patch.object(
self.image_nodes.random,
"choice",
side_effect=lambda files: files[1],
), mock.patch.object(
self.image_nodes,
"_load_folder_image",
side_effect=lambda path: (f"image:{os.path.basename(path)}", f"mask:{os.path.basename(path)}", 80, 60),
):
result = node.load(state)
self.assertEqual(result[0], ["image:b.png"])
self.assertEqual(result[4], ["b"])
self.assertEqual(result[5], [1])
self.assertEqual(result[6], [1])
def test_load_images_folder_random_is_changed_changes_each_run(self):
node = self.image_nodes.DumasLoadImagesFolderNode()
folder = tempfile.mkdtemp(prefix="dumas-load-folder-changed-")
open(os.path.join(folder, "a.png"), "a", encoding="utf-8").close()
state = json.dumps(
{
"folder": folder,
"recursive": False,
"sort": "name",
"sort_dir": "asc",
"selection_mode": "random",
}
)
with mock.patch.object(self.image_nodes.time, "time_ns", side_effect=[1, 2]):
changed_1 = node.IS_CHANGED(state)
changed_2 = node.IS_CHANGED(state)
self.assertNotEqual(changed_1, changed_2)
def test_h3_plan_scene_images_attach_and_extract(self): def test_h3_plan_scene_images_attach_and_extract(self):
attach_node = self.image_nodes.DumasH3PlanAttachSceneImagesNode() attach_node = self.image_nodes.DumasH3PlanAttachSceneImagesNode()
extract_node = self.image_nodes.DumasH3PlanExtractSceneImagesNode() extract_node = self.image_nodes.DumasH3PlanExtractSceneImagesNode()