diff --git a/Rayko_Load_Images_From_Dir.py b/Rayko_Load_Images_From_Dir.py new file mode 100644 index 0000000..533c4dd --- /dev/null +++ b/Rayko_Load_Images_From_Dir.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025-2026 Raykosan (RaykoStudio) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import glob +import torch +import numpy as np +import folder_paths +from PIL import Image +from typing import List, Dict, Any + + +class RSLoadImagesFromDir: + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]: + return { + "required": { + "folder_path": ("STRING", {"default": "", "multiline": False}), + "filter_type": (["*.*", "*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp", "*.gif", "custom"], {"default": "*.png"}), + "start_index": ("INT", {"default": 1, "min": 1, "max": 999999, "step": 1}), + "end_index": ("INT", {"default": 1, "min": 1, "max": 999999, "step": 1}), + }, + "optional": { + "custom_filter": ("STRING", {"default": "*.png", "multiline": False}), + } + } + + RETURN_TYPES = ("IMAGE", "STRING", "STRING", "INT") + RETURN_NAMES = ("image", "filename", "folder_path", "number_of_files") + OUTPUT_IS_LIST = (True, True, False, False) + FUNCTION = "load_images" + CATEGORY = "🦊 RaykoStudio" + DESCRIPTION = "Load images from directory. Preserves original size and alpha channel." + + def load_images( + self, + folder_path: str, + filter_type: str, + start_index: int, + end_index: int, + custom_filter: str = "*.png" + ): + if not folder_path or not os.path.isdir(folder_path): + raise ValueError(f"Invalid folder path: {folder_path}") + + pattern = custom_filter if filter_type == "custom" else filter_type + search_pattern = os.path.join(folder_path, pattern) + + file_list = sorted( + [f for f in glob.glob(search_pattern) if os.path.isfile(f)], + key=lambda x: os.path.basename(x).lower() + ) + + total_files = len(file_list) + images_list: List[torch.Tensor] = [] + filenames_list: List[str] = [] + + if total_files == 0: + return (images_list, filenames_list, folder_path, total_files) + + start_idx = max(0, min(start_index - 1, total_files - 1)) + end_idx = max(start_idx, min(end_index - 1, total_files - 1)) + + for idx in range(start_idx, end_idx + 1): + filepath = file_list[idx] + filename = os.path.basename(filepath) + + try: + img = Image.open(filepath) + img_np = np.array(img).astype(np.float32) / 255.0 + + if img_np.ndim == 2: + img_np = np.stack([img_np] * 3, axis=-1) + + img_tensor = torch.from_numpy(img_np).unsqueeze(0) + images_list.append(img_tensor) + filenames_list.append(filename) + + except Exception as e: + print(f"[RaykoStudio] Error loading {filename}: {e}") + + return (images_list, filenames_list, folder_path, total_files) + + +NODE_CLASS_MAPPINGS = {"RSLoadImagesFromDir": RSLoadImagesFromDir} +NODE_DISPLAY_NAME_MAPPINGS = {"RSLoadImagesFromDir": "🦊 RS Load Images From Dir"} \ No newline at end of file diff --git a/__init__.py b/__init__.py index 2111215..034d798 100644 --- a/__init__.py +++ b/__init__.py @@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'} REQUIRED_MODULES: Set[str] = set() PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio" -__version__ = "0.33.2" +__version__ = "0.34.0" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index ef61a94..e6387dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.33.2", + "version": "0.34.0", "description": "Set of custom nodes for ComfyUI providing additional image processing capabilities", "author": "Raykosan", "license": "Apache License 2.0", @@ -24,6 +24,7 @@ "Rayko_Insert_Crop.py" "Rayko_Intermediate_Spline_Mask.py" "Rayko_Last_Frame.py" + "Rayko_Load_Images_From_Dir.py" "Rayko_Loop_Switch.py" "Rayko_LoRA Loader.py" "Rayko_Models_Loader.py" diff --git a/pyproject.toml b/pyproject.toml index 6bb4fcf..2c3cb6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI_RaykoStudio" description = "Set of custom nodes for ComfyUI providing additional image processing capabilities" -version = "0.33.2" +version = "0.34.0" license = { file = "LICENSE" } dependencies = ["torch", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"] diff --git a/web/rs_load_images_dir.js b/web/rs_load_images_dir.js new file mode 100644 index 0000000..39efce8 --- /dev/null +++ b/web/rs_load_images_dir.js @@ -0,0 +1,516 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "RaykoStudio.LoadImagesFromDir", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name !== "RSLoadImagesFromDir") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + + nodeType.prototype.onNodeCreated = function() { + const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + const node = this; + + node.data = { + folder_path: "", + filter_type: "*.png", + start_index: 1, + end_index: 1, + custom_filter: "*.png" + }; + + const filters = ["*.*", "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif", "custom"]; + + const NODE_WIDTH = 260; + node.targetWidth = NODE_WIDTH; + + const MIN_HEIGHT_NORMAL = 255; + const MIN_HEIGHT_CUSTOM = 280; + + node.setSize([NODE_WIDTH, MIN_HEIGHT_NORMAL]); + node.min_size = [NODE_WIDTH, MIN_HEIGHT_NORMAL]; + + const wPath = node.widgets?.find(w => w.name === "folder_path"); + const wFilter = node.widgets?.find(w => w.name === "filter_type"); + const wStart = node.widgets?.find(w => w.name === "start_index"); + const wEnd = node.widgets?.find(w => w.name === "end_index"); + const wCustom = node.widgets?.find(w => w.name === "custom_filter"); + + [wPath, wFilter, wStart, wEnd, wCustom].forEach(w => { + if (w) { + w.hidden = true; + if (w.element) w.element.style.display = "none"; + } + }); + + if (wPath) wPath.serializeValue = () => node.data.folder_path; + if (wFilter) wFilter.serializeValue = () => node.data.filter_type; + if (wStart) wStart.serializeValue = () => parseInt(node.data.start_index); + if (wEnd) wEnd.serializeValue = () => parseInt(node.data.end_index); + if (wCustom) wCustom.serializeValue = () => node.data.custom_filter; + + if (wPath?.value) node.data.folder_path = wPath.value; + if (wFilter?.value) node.data.filter_type = wFilter.value; + if (wStart?.value) node.data.start_index = parseInt(wStart.value); + if (wEnd?.value) node.data.end_index = parseInt(wEnd.value); + if (wCustom?.value) node.data.custom_filter = wCustom.value; + + node.uiElements = []; + node.dropdownMenu = null; + node.customModal = null; + + const syncData = () => { + if (wPath) wPath.value = node.data.folder_path; + if (wFilter) wFilter.value = node.data.filter_type; + if (wStart) wStart.value = parseInt(node.data.start_index); + if (wEnd) wEnd.value = parseInt(node.data.end_index); + if (wCustom) wCustom.value = node.data.custom_filter; + + if (node.graph) node.graph.changeTracker?.dispatchEvent(new Event("change")); + node.setDirtyCanvas(true, true); + }; + + const updateNodeSize = () => { + const targetHeight = node.data.filter_type === "custom" ? MIN_HEIGHT_CUSTOM : MIN_HEIGHT_NORMAL; + node.min_size = [NODE_WIDTH, targetHeight]; + + if (Math.abs(node.size[1] - targetHeight) > 1) { + node.setSize([node.size[0], targetHeight]); + node.setDirtyCanvas(true, true); + } + }; + + node.onResize = function() { + const minHeight = this.data.filter_type === "custom" ? MIN_HEIGHT_CUSTOM : MIN_HEIGHT_NORMAL; + if (this.size[0] < NODE_WIDTH) { + this.size[0] = NODE_WIDTH; + } + if (this.size[1] < minHeight) { + this.size[1] = minHeight; + } + this.setDirtyCanvas(true, true); + }; + + const closeDropdown = (e) => { + if (node.dropdownMenu && !node.dropdownMenu.contains(e.target)) { + node.dropdownMenu.remove(); + node.dropdownMenu = null; + } + }; + + const showCustomPrompt = (title, defaultValue, callback) => { + if (node.customModal) { + node.customModal.remove(); + node.customModal = null; + } + + const backdrop = document.createElement("div"); + backdrop.style.cssText = ` + position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0, 0, 0, 0.5); z-index: 10002; + display: flex; align-items: center; justify-content: center; + `; + + const modal = document.createElement("div"); + modal.style.cssText = ` + background: #1a1a1a; border: 1px solid #444; border-radius: 3px; + padding: 16px; min-width: 320px; max-width: 500px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7); + font-family: sans-serif; font-size: 12px; color: #ddd; + `; + + const titleEl = document.createElement("div"); + titleEl.textContent = title; + titleEl.style.cssText = ` + margin-bottom: 12px; font-size: 13px; color: #fff; font-weight: bold; + `; + modal.appendChild(titleEl); + + const input = document.createElement("input"); + input.type = "text"; + input.value = defaultValue || ""; + input.style.cssText = ` + width: 100%; box-sizing: border-box; padding: 8px 10px; + background: #222; border: 1px solid #666; border-radius: 3px; + color: #fff; font-size: 12px; outline: none; + font-family: monospace; + `; + input.onfocus = () => { input.style.borderColor = "#2196F3"; }; + input.onblur = () => { input.style.borderColor = "#666"; }; + modal.appendChild(input); + + const btnContainer = document.createElement("div"); + btnContainer.style.cssText = ` + display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; + `; + + const btnCancel = document.createElement("button"); + btnCancel.textContent = "Cancel"; + btnCancel.style.cssText = ` + padding: 6px 16px; background: #2a2a2a; border: 1px solid #888; + border-radius: 3px; color: #ddd; font-size: 12px; cursor: pointer; + font-family: sans-serif; + `; + btnCancel.onmouseover = () => { btnCancel.style.background = "#3a3a3a"; }; + btnCancel.onmouseout = () => { btnCancel.style.background = "#2a2a2a"; }; + + const btnOk = document.createElement("button"); + btnOk.textContent = "OK"; + btnOk.style.cssText = ` + padding: 6px 16px; background: #2196F3; border: 1px solid #2196F3; + border-radius: 3px; color: #fff; font-size: 12px; cursor: pointer; + font-family: sans-serif; font-weight: bold; + `; + btnOk.onmouseover = () => { btnOk.style.background = "#1976D2"; }; + btnOk.onmouseout = () => { btnOk.style.background = "#2196F3"; }; + + btnContainer.appendChild(btnCancel); + btnContainer.appendChild(btnOk); + modal.appendChild(btnContainer); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + node.customModal = backdrop; + + const close = (result) => { + backdrop.remove(); + node.customModal = null; + document.removeEventListener("keydown", keyHandler); + if (callback) callback(result); + }; + + const confirm = () => close(input.value); + const cancel = () => close(null); + + btnOk.onclick = (e) => { e.stopPropagation(); confirm(); }; + btnCancel.onclick = (e) => { e.stopPropagation(); cancel(); }; + backdrop.onclick = (e) => { + if (e.target === backdrop) cancel(); + }; + + const keyHandler = (e) => { + if (e.key === "Enter") { + e.preventDefault(); + confirm(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }; + document.addEventListener("keydown", keyHandler); + + setTimeout(() => { + input.focus(); + input.select(); + }, 10); + }; + + const truncatePath = (ctx, path, maxWidth) => { + if (!path) return "Folder not selected"; + + const ellipsis = "..."; + + if (ctx.measureText(path).width <= maxWidth) { + return path; + } + + let truncated = path; + while (truncated.length > 0 && ctx.measureText(ellipsis + truncated).width > maxWidth) { + truncated = truncated.substring(1); + } + + return ellipsis + truncated; + }; + + const drawRoundedRect = (ctx, x, y, w, h, r) => { + if (ctx.roundRect) { + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); + ctx.fill(); + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.arcTo(x + w, y, x + w, y + r, r); + ctx.lineTo(x + w, y + h - r); + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); + ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + }; + + node.onDrawForeground = function(ctx) { + if (this.flags.collapsed) return; + + const [w, h] = this.size; + const padding = 10; + const rowHeight = 23; + const borderRadius = 3; + + let currentY = 35 + 60; + + ctx.font = "12px sans-serif"; + ctx.textBaseline = "middle"; + + const pathDisplayHeight = 20; + const pathDisplayY = currentY; + + ctx.fillStyle = "#1a1a1a"; + ctx.strokeStyle = "#333"; + drawRoundedRect(ctx, padding, pathDisplayY, w - padding * 2, pathDisplayHeight, borderRadius); + + const pathText = node.data.folder_path || "Folder not selected"; + const maxTextWidth = w - padding * 2 - 10; + const displayPath = truncatePath(ctx, pathText, maxTextWidth); + + ctx.fillStyle = node.data.folder_path ? "#aaa" : "#666"; + ctx.textAlign = "left"; + ctx.fillText(displayPath, padding + 5, pathDisplayY + pathDisplayHeight / 2); + + currentY += pathDisplayHeight + 7; + + ctx.fillStyle = "#2a2a2a"; + ctx.strokeStyle = "#2196F3"; + drawRoundedRect(ctx, padding, currentY, w - padding * 2, rowHeight, borderRadius); + ctx.fillStyle = "#2196F3"; + ctx.textAlign = "center"; + ctx.fillText("πŸ“ Select folder", w / 2, currentY + rowHeight / 2); + + this.uiElements.push({ type: "btn_folder", x: padding, y: currentY, w: w - padding * 2, h: rowHeight }); + currentY += rowHeight + 7; + + const filterLabel = `β–Ό ${node.data.filter_type}`; + ctx.fillStyle = "#2a2a2a"; + ctx.strokeStyle = "#4CAF50"; + drawRoundedRect(ctx, padding, currentY, w - padding * 2, rowHeight, borderRadius); + ctx.fillStyle = "#4CAF50"; + ctx.textAlign = "center"; + ctx.fillText(filterLabel, w / 2, currentY + rowHeight / 2); + + this.uiElements.push({ type: "btn_filter", x: padding, y: currentY, w: w - padding * 2, h: rowHeight }); + currentY += rowHeight + 7; + + const drawNumberInput = (label, value, yPos, dataType) => { + const labelW = 40; + const btnW = 20; + const gap = 4; + const totalW = w - padding * 2; + const valW = totalW - labelW - (btnW * 2) - (gap * 2); + + ctx.fillStyle = "#aaa"; + ctx.textAlign = "left"; + ctx.fillText(label, padding, yPos + rowHeight / 2); + + const btnX1 = padding + labelW + gap; + ctx.fillStyle = "#333"; + ctx.strokeStyle = "#888"; + drawRoundedRect(ctx, btnX1, yPos, btnW, rowHeight, borderRadius); + ctx.fillStyle = "#fff"; + ctx.textAlign = "center"; + ctx.fillText("-", btnX1 + btnW / 2, yPos + rowHeight / 2); + + const valX = btnX1 + btnW + gap; + ctx.fillStyle = "#222"; + ctx.strokeStyle = "#666"; + drawRoundedRect(ctx, valX, yPos, valW, rowHeight, borderRadius); + ctx.fillStyle = "#fff"; + ctx.textAlign = "center"; + ctx.fillText(String(value), valX + valW / 2, yPos + rowHeight / 2); + + const btnX2 = valX + valW + gap; + ctx.fillStyle = "#333"; + ctx.strokeStyle = "#888"; + drawRoundedRect(ctx, btnX2, yPos, btnW, rowHeight, borderRadius); + ctx.fillStyle = "#fff"; + ctx.textAlign = "center"; + ctx.fillText("+", btnX2 + btnW / 2, yPos + rowHeight / 2); + + this.uiElements.push({ type: `btn_${dataType}_minus`, x: btnX1, y: yPos, w: btnW, h: rowHeight }); + this.uiElements.push({ type: `val_${dataType}`, x: valX, y: yPos, w: valW, h: rowHeight }); + this.uiElements.push({ type: `btn_${dataType}_plus`, x: btnX2, y: yPos, w: btnW, h: rowHeight }); + }; + + drawNumberInput("Start", node.data.start_index, currentY, "start"); + currentY += rowHeight + 5; + + drawNumberInput("End", node.data.end_index, currentY, "end"); + currentY += rowHeight + 12; + + if (node.data.filter_type === "custom") { + ctx.fillStyle = "#aaa"; + ctx.textAlign = "left"; + ctx.fillText("Custom", padding, currentY + rowHeight / 2); + + const custValW = w - padding * 2 - 45; + const custX = padding + 45; + + ctx.fillStyle = "#222"; + ctx.strokeStyle = "#FF9800"; + drawRoundedRect(ctx, custX, currentY, custValW, rowHeight, borderRadius); + ctx.fillStyle = "#FF9800"; + ctx.textAlign = "left"; + ctx.fillText(node.data.custom_filter, custX + 5, currentY + rowHeight / 2); + + this.uiElements.push({ type: "val_custom", x: custX, y: currentY, w: custValW, h: rowHeight }); + } + }; + + node.onMouseDown = function(event, pos) { + const [x, y] = pos; + this.uiElements = []; + this.onDrawForeground(app.canvas.ctx); + + for (const el of this.uiElements) { + if (x >= el.x && x <= el.x + el.w && y >= el.y && y <= el.y + el.h) { + if (el.type === "btn_folder") { + showCustomPrompt( + "Π’Π²Π΅Π΄ΠΈΡ‚Π΅ ΠΏΡƒΡ‚ΡŒ ΠΊ ΠΏΠ°ΠΏΠΊΠ΅", + this.data.folder_path, + (result) => { + if (result !== null) { + this.data.folder_path = result.trim(); + syncData(); + } + } + ); + return true; + } + + if (el.type === "btn_filter") { + if (this.dropdownMenu) this.dropdownMenu.remove(); + + const menu = document.createElement("div"); + menu.style.cssText = ` + position: fixed; background: #1a1a1a; border: 1px solid #444; + border-radius: 3px; max-height: 255px; overflow-y: auto; + z-index: 10001; box-shadow: 0 4px 20px rgba(0,0,0,0.5); min-width: 150px; + `; + + filters.forEach(f => { + const item = document.createElement("div"); + item.textContent = f; + item.style.cssText = ` + padding: 8px 12px; cursor: pointer; color: #ddd; font-size: 12px; + border-bottom: 1px solid #333; + background: ${f === this.data.filter_type ? '#333' : '#1a1a1a'}; + `; + item.onmouseover = () => item.style.background = "#444"; + item.onmouseout = () => item.style.background = f === this.data.filter_type ? '#333' : "#1a1a1a"; + item.onclick = (e) => { + e.stopPropagation(); + this.data.filter_type = f; + syncData(); + updateNodeSize(); + menu.remove(); + this.dropdownMenu = null; + }; + menu.appendChild(item); + }); + + const canvasRect = app.canvas.canvas.getBoundingClientRect(); + const ds = app.canvas.ds; + const nodeScreenX = canvasRect.left + ((this.pos[0] + ds.offset[0]) * ds.scale); + const nodeScreenY = canvasRect.top + ((this.pos[1] + ds.offset[1]) * ds.scale); + + menu.style.left = (nodeScreenX + el.x) + "px"; + menu.style.top = (nodeScreenY + el.y + el.h + 5) + "px"; + + document.body.appendChild(menu); + this.dropdownMenu = menu; + setTimeout(() => document.addEventListener("mousedown", closeDropdown), 100); + return true; + } + + if (el.type === "btn_start_minus") { + this.data.start_index = Math.max(1, parseInt(this.data.start_index) - 1); + syncData(); + return true; + } + if (el.type === "btn_start_plus") { + this.data.start_index = parseInt(this.data.start_index) + 1; + syncData(); + return true; + } + if (el.type === "val_start") { + showCustomPrompt( + "ΠΠ°Ρ‡Π°Π»ΡŒΠ½Ρ‹ΠΉ индСкс (ΠΌΠΈΠ½. 1)", + String(this.data.start_index), + (result) => { + if (result !== null) { + this.data.start_index = Math.max(1, parseInt(result) || 1); + syncData(); + } + } + ); + return true; + } + + if (el.type === "btn_end_minus") { + this.data.end_index = Math.max(1, parseInt(this.data.end_index) - 1); + syncData(); + return true; + } + if (el.type === "btn_end_plus") { + this.data.end_index = parseInt(this.data.end_index) + 1; + syncData(); + return true; + } + if (el.type === "val_end") { + showCustomPrompt( + "ΠšΠΎΠ½Π΅Ρ‡Π½Ρ‹ΠΉ индСкс", + String(this.data.end_index), + (result) => { + if (result !== null) { + this.data.end_index = Math.max(1, parseInt(result) || 1); + syncData(); + } + } + ); + return true; + } + + if (el.type === "val_custom") { + showCustomPrompt( + "ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠΉ Ρ„ΠΈΠ»ΡŒΡ‚Ρ€ (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€, *_mask.png)", + this.data.custom_filter, + (result) => { + if (result !== null) { + this.data.custom_filter = result.trim() || "*.png"; + syncData(); + } + } + ); + return true; + } + } + } + return false; + }; + + const originalOnRemoved = node.onRemoved; + node.onRemoved = function() { + if (this.dropdownMenu) { + this.dropdownMenu.remove(); + this.dropdownMenu = null; + } + if (this.customModal) { + this.customModal.remove(); + this.customModal = null; + } + document.removeEventListener("mousedown", closeDropdown); + if (originalOnRemoved) originalOnRemoved.apply(this, arguments); + }; + + syncData(); + updateNodeSize(); + + return result; + }; + } +}); \ No newline at end of file