18 Commits
Author SHA1 Message Date
Chaoses-Ib 8a803edd76 Merge pull request #20 from noembryo/main
Fix opening images outside the ComfyUI/input/ directory

https://github.com/Comfy-Org/ComfyUI/pull/14734
2026-08-16 18:44:58 +08:00
noEmbryo 490e43e742 Fix opening images outside the ComfyUI/input/ directory
Refactor _resolve_path to support both annotated and real filesystem paths.
Update VALIDATE_INPUTS to handle exceptions from _resolve_path.
2026-08-16 11:41:13 +03:00
Chaoses-Ib 61b3e29078 Merge pull request #19 from alFrame/feature/browse-and-preview 2025-12-05 22:19:43 +08:00
Chaoses-Ib 60a4bd2ce6 feat(image): _pil_to_image_mask() 2025-12-05 22:14:01 +08:00
Alex Furer 8dc570a288 Fixed mask dimension mismatch (revised)
Fixed mask dimension mismatch by adding batch dimension to mask output to ensure compatibility with nodes expecting proper batch-formatted tensors.
2025-12-05 13:36:54 +01:00
Alex Furer e775476ac1 Fixed problem with Inpaint Crop (Improved) node
- Fixed mask dimension mismatch by adding a batch dimension to ensure masks are returned as (1, H, W) instead of (H, W), making it compatible with nodes like Inpaint-CropAndStitch that expect proper batch-formatted tensors.
2025-12-05 13:09:29 +01:00
Chaoses-Ib a5425c7027 Merge pull request #18 from alFrame/feature/browse-and-preview 2025-10-29 00:09:40 +08:00
Alex Furer fc7d8c39b2 Fixed mask editor breaking on second launch
- Proper clipspace file handling
2025-10-28 16:46:40 +01:00
Chaoses-Ib e8a8dd9309 Merge pull request #17 from alFrame/feature/browse-and-preview 2025-10-28 22:07:55 +08:00
Chaoses-Ib fa26338c24 docs: add LoadImageFromPathEnhanced 2025-10-28 22:04:57 +08:00
Chaoses-Ib a1d2d2a1ba chore: add authors and bump version 2025-10-28 21:50:59 +08:00
Alex Furer df2d6f1623 Added 'LoadImageFromPathEnhanced' to the existing node
- Created new __Init__.py
- Merged the original node's code into the image.py
- Adjusted the ib_image_loader.js to reflect the change
2025-10-28 13:18:21 +01:00
Alex Furer 01c367732f PR_01
- Added sorting to the file browser dialog (date and file name)
- Fixed mask problem mask = mask.unsqueeze(0) when image doesn't have a mask
- Added screenshots
2025-10-27 19:58:06 +01:00
Alex Furer 01d6d0b69d 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?)
2025-10-25 00:31:07 +02:00
Chaoses-Ib 59746dc1f9 docs(image): update PIL.Image 2025-02-08 21:11:28 +08:00
Chaoses-IbandEdward 1be2c861ab fix(image): PILToImage and PILToMask
https://github.com/Chaoses-Ib/ComfyScript/issues/83

Co-authored-by: Edward <65374678+mcmanusef@users.noreply.github.com>
2024-12-22 20:09:07 +08:00
Chaoses-Ib bb5cc522cd Merge pull request #6 from gibiee/pr1 2024-10-13 23:24:52 +08:00
gibiee 62d5fd7113 add usage.jpg 2024-10-14 00:03:51 +09:00
9 changed files with 856 additions and 24 deletions
+21 -1
View File
@@ -1,4 +1,7 @@
# ComfyUI_Ib_CustomNodes # ComfyUI_Ib_CustomNodes
## Installation
Install via the Custom Nodes Manager in [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager).
## Load Image From Path ## Load Image From Path
```python ```python
def LoadImageFromPath( def LoadImageFromPath(
@@ -27,6 +30,22 @@ The image path can be in the following format:
`ComfyUI_00001_-assets\ComfyUI_00001_.png [temp]` `ComfyUI_00001_-assets\ComfyUI_00001_.png [temp]`
### Usage Example
![usage](images/LoadImageFromPath.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 ## PIL.Image
```python ```python
def PILToImage( def PILToImage(
@@ -42,4 +61,5 @@ def PILToMask(
def ImageToPIL( def ImageToPIL(
images: Image images: Image
) -> PilImage ) -> PilImage
``` ```
Mainly for use with [ComfyScript](https://github.com/Chaoses-Ib/ComfyScript)'s real mode.
+5 -1
View File
@@ -1,9 +1,10 @@
from .image import * from .image import LoadImageFromPath, LoadImageFromPathEnhanced, PILToImage, PILToMask, ImageToPIL
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
'LoadImageFromPath': LoadImageFromPath, 'LoadImageFromPath': LoadImageFromPath,
'LoadImageFromPathEnhanced': LoadImageFromPathEnhanced,
'PILToImage': PILToImage, 'PILToImage': PILToImage,
'PILToMask': PILToMask, 'PILToMask': PILToMask,
'ImageToPIL': ImageToPIL, 'ImageToPIL': ImageToPIL,
@@ -11,9 +12,12 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
'LoadImageFromPath': 'Load Image From Path', 'LoadImageFromPath': 'Load Image From Path',
'LoadImageFromPathEnhanced': 'Load Image From Path (Enhanced)',
'PILToImage': 'PIL To Image', 'PILToImage': 'PIL To Image',
'PILToMask': 'PIL To Mask', 'PILToMask': 'PIL To Mask',
'ImageToPIL': 'Image To PIL', 'ImageToPIL': 'Image To PIL',
} }
WEB_DIRECTORY = "./web"
print("\033[34mIb Custom Nodes: \033[92mLoaded\033[0m") print("\033[34mIb Custom Nodes: \033[92mLoaded\033[0m")
+349 -21
View File
@@ -3,11 +3,99 @@ import torch
import hashlib import hashlib
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
import os
import json
import shutil
from PIL import Image, ImageOps from PIL import (
Image, ImageOps, ImageSequence,
ImageFile, UnidentifiedImageError,
)
import numpy as np import numpy as np
import folder_paths 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 i: i * (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]]
# ===================================================================================
# ORIGINAL VERSION OF THE NODE
# ===================================================================================
class LoadImageFromPath: class LoadImageFromPath:
@classmethod @classmethod
@@ -23,21 +111,26 @@ class LoadImageFromPath:
def load_image(self, image): def load_image(self, image):
image_path = LoadImageFromPath._resolve_path(image) image_path = LoadImageFromPath._resolve_path(image)
i = Image.open(image_path) i = _pillow(Image.open, image_path)
i = ImageOps.exif_transpose(i)
image = i.convert("RGB") image = []
image = np.array(image).astype(np.float32) / 255.0 mask = []
image = torch.from_numpy(image)[None,] _pil_to_image_mask(i, image, mask)
if 'A' in i.getbands(): return (image[0], mask[0])
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: def _resolve_path(image) -> Path:
image_path = Path(folder_paths.get_annotated_filepath(image)) # Keep support for the old annotated forms
return image_path 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))
# No annotation → treat as a real filesystem path
p = Path(image).expanduser()
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 @classmethod
def IS_CHANGED(s, image): def IS_CHANGED(s, image):
@@ -49,14 +142,16 @@ class LoadImageFromPath:
@classmethod @classmethod
def VALIDATE_INPUTS(s, image): def VALIDATE_INPUTS(s, image):
# If image is an output of another node, it will be None during validation
if image is None: if image is None:
return True return True
try:
image_path = LoadImageFromPath._resolve_path(image) image_path = LoadImageFromPath._resolve_path(image)
except ValueError as e:
return str(e)
if not image_path.exists(): if not image_path.exists():
return "Invalid image path: {}".format(image_path) return "Invalid image path: {}".format(image_path)
if not image_path.is_file():
return "Path is not a file: {}".format(image_path)
return True return True
class PILToImage: class PILToImage:
@@ -71,7 +166,9 @@ class PILToImage:
CATEGORY = 'image/PIL' CATEGORY = 'image/PIL'
def pil_images_to_images(images: Iterable[Image.Image]) -> torch.Tensor: def pil_images_to_images(self, images: Iterable[Image.Image]) -> tuple[torch.Tensor]:
'''
```
pil_images = images pil_images = images
images = [] images = []
@@ -91,6 +188,11 @@ class PILToImage:
images = images[0] images = images[0]
return (images,) return (images,)
```
'''
image = []
_pil_to_image_mask(images, image, None)
return (image[0],)
class PILToMask: class PILToMask:
@classmethod @classmethod
@@ -104,7 +206,9 @@ class PILToMask:
CATEGORY = 'image/PIL' CATEGORY = 'image/PIL'
def pil_images_to_masks(images: Iterable[Image.Image]) -> torch.Tensor: def pil_images_to_masks(self, images: Iterable[Image.Image]) -> tuple[torch.Tensor]:
'''
```
pil_images = images pil_images = images
masks = [] masks = []
@@ -126,6 +230,11 @@ class PILToMask:
masks = masks[0] masks = masks[0]
return (masks,) return (masks,)
```
'''
mask = []
_pil_to_image_mask(images, None, mask)
return (mask[0],)
class ImageToPIL: class ImageToPIL:
@classmethod @classmethod
@@ -145,4 +254,223 @@ class ImageToPIL:
i = 255. * image.cpu().numpy() i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
pil_images.append(img) pil_images.append(img)
return (pil_images,) return (pil_images,)
# =================================================================================================================
# ENHANCED VERSION WITH FILE BROWSER AND MASK EDITOR SUPPORT
# Features: Full file browser, sorting, preview, any path access, clipspace support and path as a string output
# =================================================================================================================
# 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(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_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("/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')
if not path:
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):
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
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)
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("/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')
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)}')
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+6 -1
View File
@@ -1,6 +1,11 @@
[project] [project]
name = "ComfyUI_Ib_CustomNodes" name = "ComfyUI_Ib_CustomNodes"
version = "0.2.2" version = "0.3.0"
authors = [
{name = "Chaoses-Ib"},
{name = "alFrame"},
{name = "gibiee"},
]
description = 'Load Image From Path, PIL.Image' description = 'Load Image From Path, PIL.Image'
readme = "README.md" readme = "README.md"
# requires-python = ">=3.8" # requires-python = ">=3.8"
+475
View File
@@ -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 = `
<h3 style="margin: 0; font-size: 14px; font-weight: 600;">Select Image</h3>
<div style="display: flex; gap: 8px; align-items: center;">
<select id="sortMethod" style="background: #444; border: 1px solid #666; color: white; padding: 4px 8px; border-radius: 3px; font-size: 11px; cursor: pointer;">
<option value="name_asc">A → Z</option>
<option value="name_desc">Z → A</option>
<option value="date_desc">Newest</option>
<option value="date_asc">Oldest</option>
</select>
<button id="closeBrowser" style="background: #c00; border: none; color: white; padding: 4px 8px; cursor: pointer; border-radius: 3px; font-size: 12px; font-weight: bold;">✕</button>
</div>
`;
// 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 = `
<button id="selectFile" style="background: #2a7a2a; border: none; color: white; padding: 6px 16px; cursor: pointer; border-radius: 3px; font-size: 12px;" disabled>Select</button>
<button id="cancelBrowser" style="background: #555; border: none; color: white; padding: 6px 16px; cursor: pointer; border-radius: 3px; font-size: 12px;">Cancel</button>
`;
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 = '<div style="text-align: center; padding: 40px; color: #888; font-size: 12px;">Loading...</div>';
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 = `<div style="color: #f55; padding: 20px; text-align: center; font-size: 12px;">Error: ${data.error}</div>`;
return;
}
const sortSelect = document.getElementById('sortMethod');
if (sortSelect && data.sort_method) {
sortSelect.value = data.sort_method;
}
// Update path display
pathDisplay.innerHTML = `
<button id="goUp" style="background: #444; border: none; color: white; padding: 3px 8px; cursor: pointer; border-radius: 2px; font-size: 11px;">↑ Up</button>
<input type="text" id="pathInput" value="${data.current_path || ''}" style="
flex: 1;
background: #1a1a1a;
border: 1px solid #555;
color: #eee;
padding: 4px 8px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 11px;
" />
<button id="goPath" style="background: #444; border: none; color: white; padding: 3px 8px; cursor: pointer; border-radius: 2px; font-size: 11px;">Go</button>
`;
// 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 = '<div style="display: flex; flex-direction: column; gap: 2px;">';
// 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 += `
<div class="browser-item directory" data-path="${fullPath}" style="
padding: 4px 8px;
background: #333;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
border: 1px solid transparent;
">
<span style="font-size: 16px;">📁</span>
<span style="color: #eee;">${dir}</span>
</div>
`;
}
// 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 += `
<div class="browser-item file" data-path="${fullPath}" style="
padding: 4px 8px;
background: #2a2a2a;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
border: 1px solid transparent;
">
<span style="font-size: 16px;">🖼️</span>
<span style="color: #ccc;">${file}</span>
</div>
`;
}
html += '</div>';
content.innerHTML = html;
// Add click handlers
this.attachEventHandlers(content);
} catch (error) {
content.innerHTML = `<div style="color: #f55; padding: 20px; text-align: center; font-size: 12px;">Error loading directory: ${error.message}</div>`;
}
}
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 = '<div style="color: #888; font-size: 11px;">Loading preview...</div>';
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 = `<div style="color: #f55; font-size: 11px;">Preview error: ${data.error}</div>`;
return;
}
previewArea.innerHTML = `
<img src="${data.preview}" style="max-height: 60px; max-width: 100px; border: 1px solid #555; border-radius: 3px;" />
<div style="font-size: 11px; color: #ccc;">
<div>${data.width} × ${data.height}</div>
<div style="margin-top: 3px; word-break: break-all; max-width: 400px; color: #999;">${path.split(/[\\/]/).pop()}</div>
</div>
`;
} catch (error) {
previewArea.innerHTML = `<div style="color: #f55; font-size: 11px;">Error loading preview</div>`;
}
}
}
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;
};
}
}
});