diff --git a/README.md b/README.md index be9f500..fe95cdd 100644 --- a/README.md +++ b/README.md @@ -50,21 +50,27 @@ For the custom text integration, there is a variable (can be specified by the us _**After changing the `.json` file, we must reload the ComfyUI page, so the node can reload the updated file.**_ +--- +## Load Image (from path) + +A node lets you load an image from any path in your computer, either by pasting an address, or by using a file picker to browse your drives. +It also displays the image in the node. +It's an enhanced version of the "[Load Image From Path (Enhanced)](https://github.com/Chaoses-Ib/ComfyUI_Ib_CustomNodes#load-image-from-path-enhanced)" from [`ComfyUI_Ib_CustomNodes`](ComfyUI_Ib_CustomNodes). --- ## Resolution Scale - + A simple node that outputs the resolution of an image using the dimensions of an input image or some custom user-defined dimensions, using a Scale Factor. If there is an input image connected, setting either `width` or `height` to 0 will use the other dimension to scale the image (but always multiple of 4). --- ## Regex Text Chopper - + A node that "chops" a text using a regular expression and outputs the chopped parts of the text. --- ## Auto Save Workflow - + This node can save the current workflow to a `.json` file, every time a generation job is run. - **UI controls** - **save_directory** is the directory where the `.json` file will be saved. @@ -82,7 +88,7 @@ This node can save the current workflow to a `.json` file, every time a generati **The PromptTermList nodes are now obsolete, and can mostly be replaced by the [Json Prompt Loader](#json-prompt-loader) node. I won't remove them for compatibility reasons, but I would recommend using the [JsonPromptLoader](#json-prompt-loader) node instead.** - + These are some nodes that help with the creation of Prompts inside [ComfyUI](https://github.com/comfyanonymous/ComfyUI). ### Usage diff --git a/__init__.py b/__init__.py index f99e10a..68fb1b9 100644 --- a/__init__.py +++ b/__init__.py @@ -10,6 +10,7 @@ import os import io from os.path import isfile, join, isdir import importlib +import types from aiohttp import web from server import PromptServer @@ -24,7 +25,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {} node_list = [ "nodes", - # "load_image_from_dir", + # "load_image_from_path", ] for module_name in node_list: diff --git a/load_image_from_path.py b/load_image_from_path.py new file mode 100644 index 0000000..c8197fe --- /dev/null +++ b/load_image_from_path.py @@ -0,0 +1,371 @@ +import hashlib +from pathlib import Path +from typing import Iterable +import os +import json +import shutil + +from PIL import (Image, ImageOps, ImageSequence, ImageFile, UnidentifiedImageError, ) +import numpy as np +import torch + +import folder_paths +from aiohttp import web +from server import PromptServer + + +def _pillow(fn, arg): + prev_value = None + try: + x = fn(arg) + except (OSError, UnidentifiedImageError, ValueError): + # PIL issues #4472 and #2445, also fixes ComfyUI issue #3416 + prev_value = ImageFile.LOAD_TRUNCATED_IMAGES + ImageFile.LOAD_TRUNCATED_IMAGES = True + x = fn(arg) + finally: + if prev_value is not None: + ImageFile.LOAD_TRUNCATED_IMAGES = prev_value + return x + + +def _pil_to_image_mask(img: 'Image.Image | Iterable[Image.Image]', + output_image: 'list[torch.Tensor] | None', + output_mask: 'list[torch.Tensor] | None'): + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ['MPO'] + + if not isinstance(img, Iterable): + if img.format not in excluded_formats: + img = ImageSequence.Iterator(img) + else: + img = [img] + + for i in img: + i: Image.Image + i = _pillow(ImageOps.exif_transpose, i) + + if i.mode == 'I': + i = i.point(lambda x: x * (1 / 255)) + + if len(output_images) == 0 and len(output_masks) == 0: + w = i.size[0] + h = i.size[1] + elif i.size[0] != w or i.size[1] != h: + continue + + if output_image is not None: + image = i.convert("RGB") + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + output_images.append(image) + + if output_mask is not None: + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + elif i.mode == 'P' and 'transparency' in i.info: + # https://github.com/comfyanonymous/ComfyUI/pull/7539 + mask = np.array(i.convert('RGBA').getchannel('A')).astype( + np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") + # (H, W) -> (1, H, W) + mask = mask.unsqueeze(0) + output_masks.append(mask) + + if len(output_images) > 1: + if output_image is not None: + output_image[:] = [torch.cat(output_images, dim=0)] + if output_mask is not None: + output_mask[:] = [torch.cat(output_masks, dim=0)] + else: + if output_image is not None: + output_image[:] = [output_images[0]] + if output_mask is not None: + output_mask[:] = [output_masks[0]] + + +class LoadImageFromPath: + @classmethod + def INPUT_TYPES(cls): + return {"required": + {"image": ("STRING", + {"default": r"ComfyUI_00001_-assets\ComfyUI_00001_.png " + r"[output]"})}, + } + + CATEGORY = "noEmbryo" + + RETURN_TYPES = ("IMAGE", "MASK") + FUNCTION = "load_image" + + @staticmethod + def load_image(image): + image_path = LoadImageFromPath._resolve_path(image) + + i = _pillow(Image.open, image_path) + + image = [] + mask = [] + _pil_to_image_mask(i, image, mask) + return image[0], mask[0] + + @staticmethod + def _resolve_path(image) -> Path: + # Keep support for the old annotated forms + name, base_dir = folder_paths.annotated_filepath(image) + if base_dir is not None: + # Annotated path – still go through the secure helper + return Path(folder_paths.get_annotated_filepath(image)) + + # noinspection PyTypeChecker + p = Path(image).expanduser() # No annotation → treat as a real filesystem path + if not p.is_absolute(): + # Relative path without annotation → relative to input (old behaviour) + p = Path(folder_paths.get_input_directory()) / p + return p.resolve() + + @classmethod + def IS_CHANGED(cls, image): + image_path = LoadImageFromPath._resolve_path(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(cls, image): + if image is None: + return True + try: + image_path = LoadImageFromPath._resolve_path(image) + except ValueError as e: + return str(e) + if not image_path.exists(): + return "Invalid image path: {}".format(image_path) + if not image_path.is_file(): + return "Path is not a file: {}".format(image_path) + return True + + +# Global cache to track image paths and ClipSpace mappings +_image_path_cache = {} +_clipspace_mappings = {} # Maps expected filename -> actual filename + + +class LoadImageFromPathEnhanced(LoadImageFromPath): + @classmethod + def INPUT_TYPES(cls): + return {"required": {"image": ("STRING", {"default": ""})}, } + + CATEGORY = "image" + RETURN_TYPES = ("IMAGE", "MASK", "STRING") + RETURN_NAMES = ("IMAGE", "MASK", "path") + FUNCTION = "load_image_enhanced" + + def load_image_enhanced(self, image): + # Get the full path + image_path = LoadImageFromPath._resolve_path(image) + + # Call the parent class's load_image method + image_tensor, mask = super().load_image(image) + + # Register this image in our cache for mask editor support + filename = os.path.basename(str(image_path)) + _image_path_cache[filename] = str(image_path) + _image_path_cache[str(image_path)] = str(image_path) + + # Return image, mask, AND the original input path string + return image_tensor, mask, image + + +# Middleware to handle clipspace file resolution +@web.middleware +async def clipspace_resolver_middleware(request, handler): + """ + Middleware to intercept /api/view requests and resolve clipspace filename mismatches. + This fixes the issue where mask editor looks for 'clipspace-mask-X.png' but + the actual file is 'clipspace-painted-masked-X.png' + """ + if request.path == '/api/view': + filename = request.query.get('filename', '') + subfolder = request.query.get('subfolder', '') + + # Only intercept clipspace requests looking for 'clipspace-mask-' files + if subfolder == 'clipspace' and filename.startswith('clipspace-mask-'): + input_dir = folder_paths.get_input_directory() + clipspace_dir = os.path.join(input_dir, 'clipspace') + + # Check if the requested file exists + requested_path = os.path.join(clipspace_dir, filename) + + if not os.path.exists(requested_path): + # Try to find the actual file with 'painted-masked' naming + number = filename.replace('clipspace-mask-', '').replace('.png', '') + alternative_filename = f'clipspace-painted-masked-{number}.png' + alternative_path = os.path.join(clipspace_dir, alternative_filename) + + if os.path.exists(alternative_path): + # Create a symlink or copy to the expected filename + try: + # Try symlink first (faster) + if os.name != 'nt': # Unix-like systems + if not os.path.exists(requested_path): + os.symlink(alternative_path, requested_path) + else: # Windows - use copy instead + shutil.copy2(alternative_path, requested_path) + except Exception as e: + print(f"[IB] Could not create link/copy: {e}") + + # Continue with normal handling + return await handler(request) + + +# Register middleware +PromptServer.instance.app.middlewares.append(clipspace_resolver_middleware) + + +# Server endpoints for file browsing +@PromptServer.instance.routes.get("/noembryo/browse_directory") +async def browse_directory(request): + """Browse directories and return file listings""" + try: + path = request.query.get('path', '') + sort_method = request.query.get('sort', 'name_asc') + + if not path: + if os.name == 'nt': + drives = [f"{d}:\\" for d in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' if + os.path.exists(f"{d}:\\")] + return web.json_response( + {'directories': drives, 'files': [], 'current_path': '', + 'parent_path': None, # Up stays disabled on the drive list + 'sort_method': sort_method}) + else: # Unix-like + path = os.path.expanduser('~') + + path = os.path.abspath(path) + + if not os.path.exists(path) or not os.path.isdir(path): + return web.json_response({'error': 'Invalid path'}, status=400) + + directories = [] + files = [] + + try: + items = [] + for item in os.listdir(path): + if item.startswith('.'): + continue + + item_path = os.path.join(path, item) + try: + if os.path.isdir(item_path): + item_type = 'directory' + stat = os.stat(item_path) + elif os.path.isfile(item_path): + ext = os.path.splitext(item)[1].lower() + if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp', + '.tiff', '.tif']: + item_type = 'file' + stat = os.stat(item_path) + else: + continue + else: + continue + except (PermissionError, OSError): + continue + + items.append({'name': item, 'type': item_type, 'path': item_path, + 'modified': stat.st_mtime}) + + # Apply sorting + if sort_method == 'name_asc': + items.sort(key=lambda x: x['name'].lower()) + elif sort_method == 'name_desc': + items.sort(key=lambda x: x['name'].lower(), reverse=True) + elif sort_method == 'date_desc': + items.sort(key=lambda x: x['modified'], reverse=True) + elif sort_method == 'date_asc': + items.sort(key=lambda x: x['modified']) + + for item in items: + if item['type'] == 'directory': + directories.append(item['name']) + else: + files.append(item['name']) + + except PermissionError: + return web.json_response({'error': 'Permission denied'}, status=403) + + # parent_path = os.path.dirname(path) if path != os.path.dirname(path) else None + parent = os.path.dirname(path) + if parent == path: # At a filesystem root (e.g. "D:\" on Windows or "/" on Unix) + if os.name == 'nt': + parent_path = '' # empty string → show drive list + else: + parent_path = None # already at / + else: + parent_path = parent + + return web.json_response( + {'directories': directories, 'files': files, 'current_path': path, + 'parent_path': parent_path, 'sort_method': sort_method}) + + except Exception as e: + return web.json_response({'error': str(e)}, status=500) + + +@PromptServer.instance.routes.get("/noembryo/get_image_preview") +async def get_image_preview(request): + """Get a preview of an image at the given path""" + try: + image_path = request.query.get('path', '') + + if not image_path or not os.path.exists(image_path): + return web.json_response({'error': 'Invalid image path'}, status=400) + + img = _pillow(Image.open, image_path) + img = _pillow(ImageOps.exif_transpose, img) + + max_size = (512, 512) + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + from io import BytesIO + import base64 + + buffer = BytesIO() + img.save(buffer, format='PNG') + img_str = base64.b64encode(buffer.getvalue()).decode() + + return web.json_response( + {'preview': f'data:image/png;base64,{img_str}', 'width': img.width, + 'height': img.height}) + + except Exception as e: + return web.json_response({'error': str(e)}, status=500) + + +@PromptServer.instance.routes.get("/noembryo/serve_image") +async def serve_image(request): + """Serve image file directly""" + try: + image_path = request.query.get('path', '') + + if not image_path or not os.path.exists(image_path): + return web.Response(status=404, text='Image not found') + + response = web.FileResponse(image_path) + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = '*' + return response + + except Exception as e: + return web.Response(status=500, text=f'Error serving image: {str(e)}') diff --git a/nodes.py b/nodes.py index 772ac78..3fb36e6 100644 --- a/nodes.py +++ b/nodes.py @@ -3,6 +3,7 @@ import json from os.path import realpath, join, dirname, isabs, splitext, basename from datetime import datetime import folder_paths +from .load_image_from_path import LoadImageFromPathEnhanced MANIFEST = {"name": "noEmbryo Nodes", "version": (1, 0, 3), @@ -476,6 +477,7 @@ NODE_CLASS_MAPPINGS = {f"JsonPromptLoader -{__author__}": JsonPromptLoader, f"Resolution Scale -{__author__}": ResolutionScale, f"Regex Text Chopper -{__author__}": RegExTextChopper, f"Auto Save Workflow -{__author__}": AutoSaveWorkflow, + f"Load Image (from path) -{__author__}": LoadImageFromPathEnhanced, "PromptTermList1": PromptTermList1, "PromptTermList2": PromptTermList2, "PromptTermList3": PromptTermList3, @@ -488,6 +490,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {f"JsonPromptLoader -{__author__}": f"Json Prompt L f"Resolution Scale -{__author__}": f"Resolution Scale /{__author__}", f"Regex Text Chopper -{__author__}": f"Regex Text Chopper /{__author__}", f"Auto Save Workflow -{__author__}": f"Auto Save Workflow /{__author__}", + f"Load Image (from path) -{__author__}": f"Load Image (from path) /{__author__}", "PromptTermList1": f"PromptTermList 1 /{__author__}", "PromptTermList2": f"PromptTermList 2 /{__author__}", "PromptTermList3": f"PromptTermList 3 /{__author__}", diff --git a/pyproject.toml b/pyproject.toml index 0c9dbf5..98013cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,13 +2,14 @@ name = "comfyui-noembryo" description = """ A collection of nodes for ComyUI. +* "Load Image (from path)" is a node lets you load an image from any path in your computer, and also displays the image in the node. * "JsonPromptLoader" is a node that enhances a propt using a .json file with `item:prompt` pairs. * "Resolution Scale" is a node that resizes an image using pixels or scale factor. * "Regex Text Chopper" is a node that cuts text based on regex. * "Auto Save Workflow" can save the current workflow to a .json file automatically. * "PromptTermList (1-6)" nodes to help with the creation of Prompts. """ -version = "1.3.2" +version = "1.4.0" license = {text = "MIT License"} [project.urls] diff --git a/stuff/LoadImageFromPath.jpg b/stuff/LoadImageFromPath.jpg new file mode 100644 index 0000000..3c2bb84 Binary files /dev/null and b/stuff/LoadImageFromPath.jpg differ diff --git a/web/js/load_image_from_path.js b/web/js/load_image_from_path.js new file mode 100644 index 0000000..ca859d0 --- /dev/null +++ b/web/js/load_image_from_path.js @@ -0,0 +1,591 @@ +import {app} from "../../scripts/app.js"; +import {api} from "../../scripts/api.js"; + +// Store last browsed path +let lastBrowsedPath = ''; + +/** Return the directory of a path string (handles Windows/Unix + [input]/[output]/[temp] suffixes). */ +function dirnameOf(pathStr) { + if (!pathStr || typeof pathStr !== 'string') return ''; + // Strip annotation like " foo.png [output]" + let p = pathStr.replace(/\s*\[[^\]]+\]\s*$/, '').trim(); + if (!p) return ''; + // Remove trailing slash + p = p.replace(/[/\\]+$/, ''); + const lastSep = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); + if (lastSep <= 0) return p; // bare filename or drive root + return p.substring(0, lastSep); // directory part +} + +// File browser dialog +class FileBrowserDialog { + constructor(initialPath) { + // Prefer the path that is already in the node, then fall back to last browsed + const fromWidget = dirnameOf(initialPath); + this.currentPath = fromWidget || lastBrowsedPath || ''; + this.initialFilePath = initialPath || null; // ← keep the full path + this.selectedFile = null; + this.callback = null; + } + + async show(callback) { + this.callback = callback; + + // Create dialog overlay + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + `; + + // Make overlay focusable + overlay.tabIndex = -1; + overlay.focus(); + + // Create dialog container + const dialog = document.createElement('div'); + dialog.style.cssText = ` + background: #2a2a2a; + border: 1px solid #555; + border-radius: 6px; + width: 85%; + max-width: 900px; + height: 75%; + display: flex; + flex-direction: column; + color: #eee; + font-family: system-ui, sans-serif; + font-size: 13px; + box-shadow: 0 10px 30px rgba(0,0,0,0.5); + `; + + // Create header with view controls + const header = document.createElement('div'); + header.style.cssText = ` + padding: 12px 15px; + border-bottom: 1px solid #444; + display: flex; + justify-content: space-between; + align-items: center; + background: #333; + `; + header.innerHTML = ` +