From 01d6d0b69d03ced0db0885b04789cb21735a144a Mon Sep 17 00:00:00 2001 From: Alex Furer Date: Sat, 25 Oct 2025 00:31:07 +0200 Subject: [PATCH] v0.2.3 - Added a browse button to the node - Added an output to the node output the path as text - Added a browse file dialog (troubles with OS dialog) - Added version info and author etc. to the __init__.py (maybe remove again?) --- __init__.py | 21 +- image.py | 174 ++++++++++++++-- web/ib_image_loader.js | 436 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 609 insertions(+), 22 deletions(-) create mode 100644 web/ib_image_loader.js 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 = ` +

Select Image

+
+ +
+ `; + + // Create path display - EDITABLE VERSION + const pathDisplay = document.createElement('div'); + pathDisplay.id = 'pathDisplay'; + pathDisplay.style.cssText = ` + padding: 8px 15px; + background: #333; + border-bottom: 1px solid #444; + font-family: 'Courier New', monospace; + font-size: 11px; + display: flex; + align-items: center; + gap: 8px; + color: #ccc; + `; + + // Create browser content area + const content = document.createElement('div'); + content.id = 'browserContent'; + content.style.cssText = ` + flex: 1; + overflow-y: auto; + padding: 4px; + background: #1a1a1a; + `; + + // Create preview area + const previewArea = document.createElement('div'); + previewArea.id = 'previewArea'; + previewArea.style.cssText = ` + border-top: 1px solid #444; + padding: 12px 15px; + display: flex; + gap: 12px; + align-items: center; + min-height: 80px; + background: #2a2a2a; + font-size: 12px; + `; + + // Create footer with buttons + const footer = document.createElement('div'); + footer.style.cssText = ` + padding: 12px 15px; + border-top: 1px solid #444; + display: flex; + justify-content: flex-end; + gap: 8px; + background: #333; + `; + footer.innerHTML = ` + + + `; + + dialog.appendChild(header); + dialog.appendChild(pathDisplay); + dialog.appendChild(content); + dialog.appendChild(previewArea); + dialog.appendChild(footer); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + // ESC key handler + const escHandler = (e) => { + if (e.key === 'Escape') { + close(); + } + }; + document.addEventListener('keydown', escHandler); + + // Close function + const close = () => { + document.removeEventListener('keydown', escHandler); + document.body.removeChild(overlay); + }; + + // Event handlers + overlay.querySelector('#closeBrowser').onclick = close; + overlay.querySelector('#cancelBrowser').onclick = close; + + const selectAndClose = () => { + if (this.selectedFile) { + const separator = this.selectedFile.includes('\\') ? '\\' : '/'; + lastBrowsedPath = this.selectedFile.substring(0, this.selectedFile.lastIndexOf(separator)); + this.callback(this.selectedFile); + close(); + } + }; + + overlay.querySelector('#selectFile').onclick = selectAndClose; + + // Load initial directory + await this.loadDirectory(this.currentPath); + } + + async loadDirectory(path) { + this.currentPath = path; + const content = document.getElementById('browserContent'); + const pathDisplay = document.getElementById('pathDisplay'); + const previewArea = document.getElementById('previewArea'); + + content.innerHTML = '
Loading...
'; + previewArea.innerHTML = ''; + this.selectedFile = null; + document.getElementById('selectFile').disabled = true; + + try { + const response = await api.fetchApi(`/ib_custom_nodes/browse_directory?path=${encodeURIComponent(path)}`); + const data = await response.json(); + + if (data.error) { + content.innerHTML = `
Error: ${data.error}
`; + return; + } + + // Update path display + pathDisplay.innerHTML = ` + + + + `; + + // Add event handler for the Go button and Enter key + const pathInput = document.getElementById('pathInput'); + const goPathBtn = document.getElementById('goPath'); + + const goToPath = () => { + const newPath = pathInput.value.trim(); + if (newPath) { + this.loadDirectory(newPath); + } + }; + + goPathBtn.onclick = goToPath; + pathInput.onkeypress = (e) => { + if (e.key === 'Enter') { + goToPath(); + } + }; + + const goUpBtn = document.getElementById('goUp'); + if (data.parent_path) { + goUpBtn.onclick = () => this.loadDirectory(data.parent_path); + } else { + goUpBtn.disabled = true; + goUpBtn.style.opacity = '0.4'; + goUpBtn.style.cursor = 'not-allowed'; + } + + // Build file list - LIST VIEW ONLY + let html = '
'; + + // Directories in list view + for (const dir of data.directories) { + const fullPath = data.current_path ? `${data.current_path}${data.current_path.endsWith('/') || data.current_path.endsWith('\\') ? '' : (path.includes('\\') ? '\\' : '/')}${dir}` : dir; + html += ` +
+ 📁 + ${dir} +
+ `; + } + + // Files in list view + for (const file of data.files) { + const fullPath = data.current_path ? `${data.current_path}${data.current_path.endsWith('/') || data.current_path.endsWith('\\') ? '' : (data.current_path.includes('\\') ? '\\' : '/')}${file}` : file; + html += ` +
+ 🖼️ + ${file} +
+ `; + } + + html += '
'; + content.innerHTML = html; + + // Add click handlers + this.attachEventHandlers(content); + + } catch (error) { + content.innerHTML = `
Error loading directory: ${error.message}
`; + } + } + + attachEventHandlers(content) { + // Click handlers for directories + content.querySelectorAll('.browser-item.directory').forEach(item => { + item.onclick = () => { + const path = item.getAttribute('data-path'); + this.loadDirectory(path); + }; + item.onmouseenter = () => { + item.style.background = '#3a3a3a'; + item.style.border = '1px solid #666'; + }; + item.onmouseleave = () => { + item.style.background = '#333'; + item.style.border = '1px solid transparent'; + }; + }); + + // Click handlers for files + content.querySelectorAll('.browser-item.file').forEach(item => { + let clickTimer; + let clickCount = 0; + + const selectFile = async () => { + // Remove selection from other items + content.querySelectorAll('.browser-item.file').forEach(i => { + i.style.background = '#2a2a2a'; + i.style.border = '1px solid transparent'; + }); + + // Highlight this item + item.style.background = '#2a4a2a'; + item.style.border = '1px solid #3a7a3a'; + + const path = item.getAttribute('data-path'); + this.selectedFile = path; + document.getElementById('selectFile').disabled = false; + + // Load preview + await this.loadPreview(path); + }; + + // Click handler for both single and double click + item.onclick = (e) => { + clickCount++; + + if (clickCount === 1) { + clickTimer = setTimeout(() => { + // Single click behavior + selectFile(); + clickCount = 0; + }, 300); + } else if (clickCount === 2) { + // Double click behavior + clearTimeout(clickTimer); + const path = item.getAttribute('data-path'); + this.selectedFile = path; + const separator = path.includes('\\') ? '\\' : '/'; + lastBrowsedPath = path.substring(0, path.lastIndexOf(separator)); + this.callback(path); + document.body.querySelector('[style*="z-index: 10000"]')?.remove(); + clickCount = 0; + } + }; + + item.onmouseenter = () => { + if (item.style.background !== '#2a4a2a') { + item.style.background = '#3a3a3a'; + item.style.border = '1px solid #666'; + } + }; + item.onmouseleave = () => { + if (item.style.background !== '#2a4a2a') { + item.style.background = '#2a2a2a'; + item.style.border = '1px solid transparent'; + } + }; + }); + } + + async loadPreview(path) { + const previewArea = document.getElementById('previewArea'); + previewArea.innerHTML = '
Loading preview...
'; + + try { + const response = await api.fetchApi(`/ib_custom_nodes/get_image_preview?path=${encodeURIComponent(path)}`); + const data = await response.json(); + + if (data.error) { + previewArea.innerHTML = `
Preview error: ${data.error}
`; + return; + } + + previewArea.innerHTML = ` + +
+
${data.width} × ${data.height}
+
${path.split(/[\\/]/).pop()}
+
+ `; + } catch (error) { + previewArea.innerHTML = `
Error loading preview
`; + } + } +} + +app.registerExtension({ + name: "Ib.ImageLoader", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "LoadImageFromPath") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + + nodeType.prototype.onNodeCreated = function() { + const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + const node = this; + + // Add browse button - THIS WAS MISSING! + node.addWidget("button", "🔍 Browse...", null, () => { + const browser = new FileBrowserDialog(); + browser.show(async (filePath) => { + const pathWidget = node.widgets.find(w => w.name === "image"); + if (pathWidget) { + pathWidget.value = filePath; + + // Load image using ComfyUI's proper system + try { + const filename = filePath.split(/[\\/]/).pop(); + const imgUrl = `/ib_custom_nodes/serve_image?path=${encodeURIComponent(filePath)}&filename=${encodeURIComponent(filename)}`; + const img = new Image(); + img.src = imgUrl; + + img.onload = () => { + // Set up the image array exactly like ComfyUI does + node.imgs = [img]; + node.imageIndex = 0; + + // This triggers ComfyUI's built-in preview system + app.graph.setDirtyCanvas(true); + }; + + } catch (e) { + console.error('Failed to load image:', e); + } + } + }); + }); + + return result; + }; + } + } +}); \ No newline at end of file