diff --git a/README.md b/README.md index dbd3e5c..c7483e4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ - 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%`. +- `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` - Inputs: `plan`, `scene_index`, optional `image1`..`image7` - Outputs: `plan`, `connected_images` diff --git a/__init__.py b/__init__.py index 7c35938..02d4188 100644 --- a/__init__.py +++ b/__init__.py @@ -2,6 +2,8 @@ from .dumas_image_nodes import ( NODE_CLASS_MAPPINGS as IMAGE_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as IMAGE_NODE_DISPLAY_NAME_MAPPINGS, _next_counter_for_relative_path, + _folder_is_image, + _list_folder_image_files, resolve_serve_token, ) from .dumas_json_nodes import ( @@ -21,11 +23,14 @@ WEB_DIRECTORY = "./js" try: import asyncio + import io import os + import string import shutil import subprocess import sys import threading + from PIL import Image, ImageOps from aiohttp import web from server import PromptServer @@ -78,6 +83,20 @@ try: except Exception: 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") async def dumas_save_image_file(request): path = resolve_serve_token(request.query.get("t", "")) @@ -133,6 +152,101 @@ try: return web.json_response({"ok": True, "path": selected}) except Exception as 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: pass diff --git a/dumas_image_nodes.py b/dumas_image_nodes.py index 2183e0b..0a6d58a 100644 --- a/dumas_image_nodes.py +++ b/dumas_image_nodes.py @@ -1,3 +1,4 @@ +import hashlib import json import os 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 = OrderedDict() _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): @@ -255,6 +267,155 @@ def _tensor_image_to_pil_image(tensor): 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): return " ".join(str(value or "").split()).strip() @@ -628,6 +789,122 @@ class DumasSaveImageNode: 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: DESCRIPTION = ( "Attach up to seven optional IMAGE sockets to one H3 Chain Plan scene " @@ -952,6 +1229,7 @@ class DumasCharacterHelperNode: NODE_CLASS_MAPPINGS = { "DumasImageCompare": DumasImageCompareNode, "DumasSaveImage": DumasSaveImageNode, + "DumasLoadImagesFolder": DumasLoadImagesFolderNode, "DumasH3PlanAttachSceneImages": DumasH3PlanAttachSceneImagesNode, "DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode, "DumasCharacterHelper": DumasCharacterHelperNode, @@ -960,6 +1238,7 @@ NODE_CLASS_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = { "DumasImageCompare": "Dumas Image Compare", "DumasSaveImage": "Save Image Dumas", + "DumasLoadImagesFolder": "Load Images from Folder Dumas", "DumasH3PlanAttachSceneImages": "Dumas H3 Plan Attach Scene Images", "DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images", "DumasCharacterHelper": "Dumas Character Helper", diff --git a/js/load_images_folder/api.mjs b/js/load_images_folder/api.mjs new file mode 100644 index 0000000..8e1a4a8 --- /dev/null +++ b/js/load_images_folder/api.mjs @@ -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) }; + } +} diff --git a/js/load_images_folder/index.js b/js/load_images_folder/index.js new file mode 100644 index 0000000..19bd07d --- /dev/null +++ b/js/load_images_folder/index.js @@ -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); + }; + }, +}); diff --git a/js/load_images_folder/state.mjs b/js/load_images_folder/state.mjs new file mode 100644 index 0000000..549ea20 --- /dev/null +++ b/js/load_images_folder/state.mjs @@ -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; +} diff --git a/js/load_images_folder/ui.mjs b/js/load_images_folder/ui.mjs new file mode 100644 index 0000000..2353a26 --- /dev/null +++ b/js/load_images_folder/ui.mjs @@ -0,0 +1,415 @@ +import { browseFolder, thumbURL } from "./api.mjs"; +import { readState, sortFiles, writeState } from "./state.mjs"; + +const FOLDER_SVG = + ''; + +function escapeHtml(value) { + return String(value).replace(/[&<>"]/g, (char) => ( + { "&": "&", "<": "<", ">": ">", '"': """ }[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 = `${escapeHtml(item.label)}`; + 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 = + `
` + + `` + + `` + + `
` + + `` + + `
`; + 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 = + `
` + + `
Select all
` + + `
None
` + + `
First
` + + `
` + + `
Select random
` + + `
0 / 0 active
` + + `
` + + `
` + + `
` + + `
Include subfolders
` + + `
Sort: Name ↑
` + + `
Done
` + + `
`; + 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 = + `` + + `
` + + `
${escapeHtml(file.name)}
`; + 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 = '
Loading…
'; + 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 = + `
Choose a folder
` + + `
` + + `
` + + `
` + + `
Cancel
` + + `
Use this folder
` + + `
`; + 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 = '
Loading…
'; + const response = await browseFolder(path); + if (popup._dlfClosed) return; + if (!response.ok) { + list.innerHTML = `
${escapeHtml(response.message || "Could not open this folder.")}
`; + return; + } + current = response.path || ""; + useButton.style.opacity = current ? "" : "0.4"; + useButton.style.pointerEvents = current ? "" : "none"; + crumb.innerHTML = current ? `Location: ${escapeHtml(current)}` : "This PC"; + list.innerHTML = ""; + if (response.parent !== null && response.parent !== undefined) { + const up = document.createElement("div"); + up.className = "dlf-bp-item up"; + up.innerHTML = ' .. (up one level)'; + 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 ? `${dir.images} image${dir.images === 1 ? "" : "s"}` : ""; + item.innerHTML = `${FOLDER_SVG}${escapeHtml(dir.name)}${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); +} diff --git a/tests/test_dumas_image_nodes.py b/tests/test_dumas_image_nodes.py index bc4135b..5522fd1 100644 --- a/tests/test_dumas_image_nodes.py +++ b/tests/test_dumas_image_nodes.py @@ -387,6 +387,113 @@ class DumasImageNodeTests(unittest.TestCase): self.assertEqual(result["ui"]["images"], []) 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): attach_node = self.image_nodes.DumasH3PlanAttachSceneImagesNode() extract_node = self.image_nodes.DumasH3PlanExtractSceneImagesNode()