diff --git a/README.md b/README.md index 3a51681..40913cc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # ComfyUI_Ib_CustomNodes +## Installation +Install via the Custom Nodes Manager in [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager). + ## Load Image From Path ```python def LoadImageFromPath( @@ -27,11 +30,21 @@ The image path can be in the following format: `ComfyUI_00001_-assets\ComfyUI_00001_.png [temp]` -## Installation -Install via the Custom Nodes Manager in [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager). +### Usage Example +![usage](images/LoadImageFromPath.jpg) -## Usage Example -![usage](./usage.jpg) +## Load Image From Path (Enhanced) +Enhanced with image preview, file browser and a `path` output, made by @alFrame: + +![](images/LoadImageFromPath-ImageAndPath.jpg) + +File browser: + +![](images/LoadImageFromPath-FileBrowser.jpg) + +Mask: + +![](images/LoadImageFromPath-Mask.jpg) ## PIL.Image ```python diff --git a/__init__.py b/__init__.py index c79c5d8..25f414c 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,10 @@ -from .image import * +from .image import LoadImageFromPath, LoadImageFromPathEnhanced, PILToImage, PILToMask, ImageToPIL __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] NODE_CLASS_MAPPINGS = { 'LoadImageFromPath': LoadImageFromPath, + 'LoadImageFromPathEnhanced': LoadImageFromPathEnhanced, 'PILToImage': PILToImage, 'PILToMask': PILToMask, 'ImageToPIL': ImageToPIL, @@ -11,9 +12,12 @@ NODE_CLASS_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = { 'LoadImageFromPath': 'Load Image From Path', + 'LoadImageFromPathEnhanced': 'Load Image From Path (Enhanced)', 'PILToImage': 'PIL To Image', 'PILToMask': 'PIL To Mask', 'ImageToPIL': 'Image To PIL', } +WEB_DIRECTORY = "./web" + print("\033[34mIb Custom Nodes: \033[92mLoaded\033[0m") diff --git a/image.py b/image.py index fc49f98..b055060 100644 --- a/image.py +++ b/image.py @@ -3,11 +3,19 @@ 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 + +# =================================================================================== +# ORIGINAL VERSION OF THE NODE +# =================================================================================== class LoadImageFromPath: @classmethod @@ -145,4 +153,223 @@ 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,) + +# =================================================================================== +# ENHANCED VERSION WITH FILE BROWSER +# Features: Full file browser, sorting, preview, any path access, path output added +# =================================================================================== + +class LoadImageFromPathEnhanced: + @classmethod + def INPUT_TYPES(s): + return {"required": + {"image": ("STRING", {"default": r"ComfyUI_00001_-assets\ComfyUI_00001_.png [output]"})}, + } + + CATEGORY = "image" + + RETURN_TYPES = ("IMAGE", "MASK", "STRING") + RETURN_NAMES = ("IMAGE", "MASK", "path") + FUNCTION = "load_image" + + def load_image(self, image): + # Use the static method correctly + image_path = self._resolve_path(image) + + i = Image.open(image_path) + i = ImageOps.exif_transpose(i) + 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) + + # Ensure mask has 3 dimensions: + mask = mask.unsqueeze(0) + else: + mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") + + 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): + # 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 None: + 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" + +# 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', '') + sort_method = request.query.get('sort', 'name_asc') # Default to name ascending + + 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: + items = [] + for item in 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): + item_type = 'directory' + stat = os.stat(item_path) + 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']: + item_type = 'file' + stat = os.stat(item_path) + else: + continue + else: + continue + except (PermissionError, OSError): + # Skip items we can't access + 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']) + + # Separate back into directories and files + 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 + + 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("/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/images/LoadImageFromPath-FileBrowser.jpg b/images/LoadImageFromPath-FileBrowser.jpg new file mode 100644 index 0000000..6a116b5 Binary files /dev/null and b/images/LoadImageFromPath-FileBrowser.jpg differ diff --git a/images/LoadImageFromPath-ImageAndPath.jpg b/images/LoadImageFromPath-ImageAndPath.jpg new file mode 100644 index 0000000..b6d0ec0 Binary files /dev/null and b/images/LoadImageFromPath-ImageAndPath.jpg differ diff --git a/images/LoadImageFromPath-Mask.jpg b/images/LoadImageFromPath-Mask.jpg new file mode 100644 index 0000000..c34112e Binary files /dev/null and b/images/LoadImageFromPath-Mask.jpg differ diff --git a/usage.jpg b/images/LoadImageFromPath.jpg similarity index 100% rename from usage.jpg rename to images/LoadImageFromPath.jpg diff --git a/pyproject.toml b/pyproject.toml index f2a77aa..9854e04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,11 @@ [project] name = "ComfyUI_Ib_CustomNodes" -version = "0.2.3" +version = "0.3.0" +authors = [ + {name = "Chaoses-Ib"}, + {name = "alFrame"}, + {name = "gibiee"}, +] description = 'Load Image From Path, PIL.Image' readme = "README.md" # requires-python = ">=3.8" diff --git a/web/ib_image_loader.js b/web/ib_image_loader.js new file mode 100644 index 0000000..dffe611 --- /dev/null +++ b/web/ib_image_loader.js @@ -0,0 +1,475 @@ +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; + + // Add the sort change handler + const sortSelect = overlay.querySelector('#sortMethod'); + sortSelect.onchange = () => { + this.loadDirectory(this.currentPath); + }; + + // 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 sortMethod = document.getElementById('sortMethod') ? document.getElementById('sortMethod').value : 'name_asc'; + const response = await api.fetchApi(`/ib_custom_nodes/browse_directory?path=${encodeURIComponent(path)}&sort=${encodeURIComponent(sortMethod)}`); + const data = await response.json(); + + if (data.error) { + content.innerHTML = `
Error: ${data.error}
`; + return; + } + + const sortSelect = document.getElementById('sortMethod'); + if (sortSelect && data.sort_method) { + sortSelect.value = data.sort_method; + } + + // 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 === "LoadImageFromPathEnhanced") { + 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(); + + // Add cache busting to prevent cached issues + img.src = imgUrl + '&t=' + Date.now(); + + img.onload = () => { + // Ensure node.imgs exists and is properly set + if (!node.imgs) { + node.imgs = []; + } + node.imgs = [img]; + node.imageIndex = 0; + + // Force a more comprehensive refresh + app.graph.setDirtyCanvas(true, true); + + // Also trigger node refresh specifically + if (node.onDrawBackground) { + node.onDrawBackground(); + } + }; + + img.onerror = () => { + console.warn('Failed to load image preview:', filePath); + // Clear any broken images but keep the node functional + node.imgs = null; + node.imageIndex = 0; + app.graph.setDirtyCanvas(true, true); + }; + + } catch (e) { + console.error('Failed to load image:', e); + node.imgs = null; + node.imageIndex = 0; + app.graph.setDirtyCanvas(true, true); + } + } + }); + }); + + return result; + }; + } + } +}); \ No newline at end of file