Add folder image loader node
This commit is contained in:
@@ -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`
|
||||
|
||||
+114
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """ }[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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user