diff --git a/__init__.py b/__init__.py index c79c5d8..5acc70d 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,4 @@ -from .image import * +from .image import LoadImageFromPath, PILToImage, PILToMask, ImageToPIL __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] @@ -16,4 +16,23 @@ NODE_DISPLAY_NAME_MAPPINGS = { 'ImageToPIL': 'Image To PIL', } +WEB_DIRECTORY = "./web" + +__version__ = "0.2.3" +__author__ = "Chaoses-Ib" +__title__ = "ComfyUI_Ib_CustomNodes" +__description__ = "Loads images, without creating a copy of the image to the input folder in your ComfyUI install directory." +__license__ = "MIT" +__changelog__ = [ + "v0.2.3 - Added a browse button and a image preview." +] + +__all__ = ['NODE_CLASS_MAPPINGS', + 'NODE_DISPLAY_NAME_MAPPINGS', + '__version__', + '__author__', + '__title__', + '__description__' +] + print("\033[34mIb Custom Nodes: \033[92mLoaded\033[0m") diff --git a/image.py b/image.py index fc49f98..117b9b1 100644 --- a/image.py +++ b/image.py @@ -3,11 +3,15 @@ import torch import hashlib from pathlib import Path from typing import Iterable +import os +import json from PIL import Image, ImageOps import numpy as np import folder_paths +from aiohttp import web +from server import PromptServer class LoadImageFromPath: @classmethod @@ -18,46 +22,59 @@ class LoadImageFromPath: CATEGORY = "image" - RETURN_TYPES = ("IMAGE", "MASK") + RETURN_TYPES = ("IMAGE", "MASK", "STRING") + RETURN_NAMES = ("IMAGE", "MASK", "path") FUNCTION = "load_image" + def load_image(self, image): - image_path = LoadImageFromPath._resolve_path(image) - + # Use the static method correctly + image_path = self._resolve_path(image) + i = Image.open(image_path) i = ImageOps.exif_transpose(i) - image = i.convert("RGB") - image = np.array(image).astype(np.float32) / 255.0 - image = torch.from_numpy(image)[None,] + image_out = i.convert("RGB") + image_out = np.array(image_out).astype(np.float32) / 255.0 + image_out = torch.from_numpy(image_out)[None,] if 'A' in i.getbands(): mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 mask = 1. - torch.from_numpy(mask) else: mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") - return (image, mask) - - def _resolve_path(image) -> Path: + + return (image_out, mask, image) + + def _resolve_path(self, image) -> Path: image_path = Path(folder_paths.get_annotated_filepath(image)) return image_path @classmethod def IS_CHANGED(s, 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() + # Need to handle this differently since it's a class method + try: + # Create a temporary instance to resolve the path + temp_instance = s() + image_path = temp_instance._resolve_path(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + except: + return image # Fallback @classmethod def VALIDATE_INPUTS(s, image): - # If image is an output of another node, it will be None during validation if image is None: return True - image_path = LoadImageFromPath._resolve_path(image) - if not image_path.exists(): - return "Invalid image path: {}".format(image_path) - - return True + try: + # Create a temporary instance to resolve the path + temp_instance = s() + image_path = temp_instance._resolve_path(image) + if not image_path.exists(): + return "Invalid image path: {}".format(image_path) + return True + except: + return "Error validating image path" class PILToImage: @classmethod @@ -145,4 +162,119 @@ class ImageToPIL: i = 255. * image.cpu().numpy() img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) pil_images.append(img) - return (pil_images,) \ No newline at end of file + return (pil_images,) + + +# Server endpoints for file browsing +@PromptServer.instance.routes.get("/ib_custom_nodes/browse_directory") +async def browse_directory(request): + """Browse directories and return file listings""" + try: + path = request.query.get('path', '') + + if not path: + # Return some common starting points + if os.name == 'nt': # Windows + drives = [f"{d}:\\" for d in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' if os.path.exists(f"{d}:\\")] + return web.json_response({ + 'directories': drives, + 'files': [], + 'current_path': '' + }) + 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: + for item in sorted(os.listdir(path)): + # Skip hidden files/folders (starting with .) + if item.startswith('.'): + continue + + item_path = os.path.join(path, item) + try: + if os.path.isdir(item_path): + directories.append(item) + elif os.path.isfile(item_path): + # Check if it's an image file + ext = os.path.splitext(item)[1].lower() + if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp', '.tiff', '.tif']: + files.append(item) + except (PermissionError, OSError): + # Skip items we can't access + continue + 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 + + return web.json_response({ + 'directories': directories, + 'files': files, + 'current_path': path, + 'parent_path': parent_path + }) + + except Exception as e: + return web.json_response({'error': str(e)}, status=500) + + +@PromptServer.instance.routes.get("/ib_custom_nodes/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) + + # Open and resize image for preview + img = Image.open(image_path) + img = ImageOps.exif_transpose(img) + + # Create thumbnail + max_size = (512, 512) + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # Convert to base64 + 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("/ib_custom_nodes/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') + + # Serve the file directly with CORS headers + 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)}') \ No newline at end of file diff --git a/web/ib_image_loader.js b/web/ib_image_loader.js new file mode 100644 index 0000000..5e2fe4b --- /dev/null +++ b/web/ib_image_loader.js @@ -0,0 +1,436 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +// Store last browsed path +let lastBrowsedPath = ''; + +// File browser dialog +class FileBrowserDialog { + constructor() { + this.currentPath = lastBrowsedPath || ''; + 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 = ` +