Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a803edd76 | ||
|
|
490e43e742 | ||
|
|
61b3e29078 | ||
|
|
60a4bd2ce6 | ||
|
|
8dc570a288 | ||
|
|
e775476ac1 | ||
|
|
a5425c7027 | ||
|
|
fc7d8c39b2 | ||
|
|
e8a8dd9309 | ||
|
|
fa26338c24 | ||
|
|
a1d2d2a1ba | ||
|
|
df2d6f1623 | ||
|
|
01c367732f | ||
|
|
01d6d0b69d | ||
|
|
59746dc1f9 | ||
|
|
1be2c861ab | ||
|
|
bb5cc522cd | ||
|
|
62d5fd7113 |
@@ -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
|
||||||
|

|
||||||
|
|
||||||
|
## Load Image From Path (Enhanced)
|
||||||
|
Enhanced with image preview, file browser and a `path` output, made by @alFrame:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
File browser:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Mask:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user