diff --git a/README.md b/README.md index be9f500..fe95cdd 100644 --- a/README.md +++ b/README.md @@ -50,21 +50,27 @@ For the custom text integration, there is a variable (can be specified by the us _**After changing the `.json` file, we must reload the ComfyUI page, so the node can reload the updated file.**_ +--- +## Load Image (from path) +![LoadImage](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/LoadImageFromPath.png) +A node lets you load an image from any path in your computer, either by pasting an address, or by using a file picker to browse your drives. +It also displays the image in the node. +It's an enhanced version of the "[Load Image From Path (Enhanced)](https://github.com/Chaoses-Ib/ComfyUI_Ib_CustomNodes#load-image-from-path-enhanced)" from [`ComfyUI_Ib_CustomNodes`](ComfyUI_Ib_CustomNodes). --- ## Resolution Scale -![Demo workflow](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/res_scale1.png) +![ResolutionScale](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/res_scale1.png) A simple node that outputs the resolution of an image using the dimensions of an input image or some custom user-defined dimensions, using a Scale Factor. If there is an input image connected, setting either `width` or `height` to 0 will use the other dimension to scale the image (but always multiple of 4). --- ## Regex Text Chopper -![Demo workflow](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/regex_text.png) +![RegExChopper](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/regex_text.png) A node that "chops" a text using a regular expression and outputs the chopped parts of the text. --- ## Auto Save Workflow -![Demo workflow](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/auto_save_workflow.png) +![AutoSaveWorkflow](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/auto_save_workflow.png) This node can save the current workflow to a `.json` file, every time a generation job is run. - **UI controls** - **save_directory** is the directory where the `.json` file will be saved. @@ -82,7 +88,7 @@ This node can save the current workflow to a `.json` file, every time a generati **The PromptTermList nodes are now obsolete, and can mostly be replaced by the [Json Prompt Loader](#json-prompt-loader) node. I won't remove them for compatibility reasons, but I would recommend using the [JsonPromptLoader](#json-prompt-loader) node instead.** -![Demo workflow](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/Screen2.png) +![PromptTermList](https://raw.githubusercontent.com/noembryo/ComfyUI-noEmbryo/master/stuff/Screen2.png) These are some nodes that help with the creation of Prompts inside [ComfyUI](https://github.com/comfyanonymous/ComfyUI). ### Usage diff --git a/__init__.py b/__init__.py index f99e10a..68fb1b9 100644 --- a/__init__.py +++ b/__init__.py @@ -10,6 +10,7 @@ import os import io from os.path import isfile, join, isdir import importlib +import types from aiohttp import web from server import PromptServer @@ -24,7 +25,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {} node_list = [ "nodes", - # "load_image_from_dir", + # "load_image_from_path", ] for module_name in node_list: diff --git a/load_image_from_path.py b/load_image_from_path.py new file mode 100644 index 0000000..c8197fe --- /dev/null +++ b/load_image_from_path.py @@ -0,0 +1,371 @@ +import hashlib +from pathlib import Path +from typing import Iterable +import os +import json +import shutil + +from PIL import (Image, ImageOps, ImageSequence, ImageFile, UnidentifiedImageError, ) +import numpy as np +import torch + +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 x: x * (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]] + + +class LoadImageFromPath: + @classmethod + def INPUT_TYPES(cls): + return {"required": + {"image": ("STRING", + {"default": r"ComfyUI_00001_-assets\ComfyUI_00001_.png " + r"[output]"})}, + } + + CATEGORY = "noEmbryo" + + RETURN_TYPES = ("IMAGE", "MASK") + FUNCTION = "load_image" + + @staticmethod + def load_image(image): + image_path = LoadImageFromPath._resolve_path(image) + + i = _pillow(Image.open, image_path) + + image = [] + mask = [] + _pil_to_image_mask(i, image, mask) + return image[0], mask[0] + + @staticmethod + def _resolve_path(image) -> Path: + # Keep support for the old annotated forms + 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)) + + # noinspection PyTypeChecker + p = Path(image).expanduser() # No annotation → treat as a real filesystem path + 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 + def IS_CHANGED(cls, image): + image_path = LoadImageFromPath._resolve_path(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(cls, image): + if image is None: + return True + try: + image_path = LoadImageFromPath._resolve_path(image) + except ValueError as e: + return str(e) + if not image_path.exists(): + return "Invalid image path: {}".format(image_path) + if not image_path.is_file(): + return "Path is not a file: {}".format(image_path) + return True + + +# 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(cls): + return {"required": {"image": ("STRING", {"default": ""})}, } + + 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("/noembryo/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': + drives = [f"{d}:\\" for d in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' if + os.path.exists(f"{d}:\\")] + return web.json_response( + {'directories': drives, 'files': [], 'current_path': '', + 'parent_path': None, # Up stays disabled on the drive list + 'sort_method': sort_method}) + 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 + parent = os.path.dirname(path) + if parent == path: # At a filesystem root (e.g. "D:\" on Windows or "/" on Unix) + if os.name == 'nt': + parent_path = '' # empty string → show drive list + else: + parent_path = None # already at / + else: + parent_path = parent + + 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("/noembryo/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("/noembryo/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)}') diff --git a/nodes.py b/nodes.py index 772ac78..3fb36e6 100644 --- a/nodes.py +++ b/nodes.py @@ -3,6 +3,7 @@ import json from os.path import realpath, join, dirname, isabs, splitext, basename from datetime import datetime import folder_paths +from .load_image_from_path import LoadImageFromPathEnhanced MANIFEST = {"name": "noEmbryo Nodes", "version": (1, 0, 3), @@ -476,6 +477,7 @@ NODE_CLASS_MAPPINGS = {f"JsonPromptLoader -{__author__}": JsonPromptLoader, f"Resolution Scale -{__author__}": ResolutionScale, f"Regex Text Chopper -{__author__}": RegExTextChopper, f"Auto Save Workflow -{__author__}": AutoSaveWorkflow, + f"Load Image (from path) -{__author__}": LoadImageFromPathEnhanced, "PromptTermList1": PromptTermList1, "PromptTermList2": PromptTermList2, "PromptTermList3": PromptTermList3, @@ -488,6 +490,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {f"JsonPromptLoader -{__author__}": f"Json Prompt L f"Resolution Scale -{__author__}": f"Resolution Scale /{__author__}", f"Regex Text Chopper -{__author__}": f"Regex Text Chopper /{__author__}", f"Auto Save Workflow -{__author__}": f"Auto Save Workflow /{__author__}", + f"Load Image (from path) -{__author__}": f"Load Image (from path) /{__author__}", "PromptTermList1": f"PromptTermList 1 /{__author__}", "PromptTermList2": f"PromptTermList 2 /{__author__}", "PromptTermList3": f"PromptTermList 3 /{__author__}", diff --git a/pyproject.toml b/pyproject.toml index 0c9dbf5..98013cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,13 +2,14 @@ name = "comfyui-noembryo" description = """ A collection of nodes for ComyUI. +* "Load Image (from path)" is a node lets you load an image from any path in your computer, and also displays the image in the node. * "JsonPromptLoader" is a node that enhances a propt using a .json file with `item:prompt` pairs. * "Resolution Scale" is a node that resizes an image using pixels or scale factor. * "Regex Text Chopper" is a node that cuts text based on regex. * "Auto Save Workflow" can save the current workflow to a .json file automatically. * "PromptTermList (1-6)" nodes to help with the creation of Prompts. """ -version = "1.3.2" +version = "1.4.0" license = {text = "MIT License"} [project.urls] diff --git a/stuff/LoadImageFromPath.jpg b/stuff/LoadImageFromPath.jpg new file mode 100644 index 0000000..3c2bb84 Binary files /dev/null and b/stuff/LoadImageFromPath.jpg differ diff --git a/web/js/load_image_from_path.js b/web/js/load_image_from_path.js new file mode 100644 index 0000000..ca859d0 --- /dev/null +++ b/web/js/load_image_from_path.js @@ -0,0 +1,591 @@ +import {app} from "../../scripts/app.js"; +import {api} from "../../scripts/api.js"; + +// Store last browsed path +let lastBrowsedPath = ''; + +/** Return the directory of a path string (handles Windows/Unix + [input]/[output]/[temp] suffixes). */ +function dirnameOf(pathStr) { + if (!pathStr || typeof pathStr !== 'string') return ''; + // Strip annotation like " foo.png [output]" + let p = pathStr.replace(/\s*\[[^\]]+\]\s*$/, '').trim(); + if (!p) return ''; + // Remove trailing slash + p = p.replace(/[/\\]+$/, ''); + const lastSep = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); + if (lastSep <= 0) return p; // bare filename or drive root + return p.substring(0, lastSep); // directory part +} + +// File browser dialog +class FileBrowserDialog { + constructor(initialPath) { + // Prefer the path that is already in the node, then fall back to last browsed + const fromWidget = dirnameOf(initialPath); + this.currentPath = fromWidget || lastBrowsedPath || ''; + this.initialFilePath = initialPath || null; // ← keep the full path + 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); + + + const escHandler = (e) => { + // Don't steal keys while typing in the path box, except Esc + const inPathInput = e.target && e.target.id === 'pathInput'; + + if (e.key === 'Escape') { + e.preventDefault(); + close(); + return; + } + + if (inPathInput) return; // let Enter in the path box still mean "Go" + + if (e.key === 'Enter') { + e.preventDefault(); + if (this.selectedFile) { + selectAndClose(); // same as clicking "Select" + } + return; + } + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + document.getElementById('prevImg')?.click(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + document.getElementById('nextImg')?.click(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + document.getElementById('prevImg')?.click(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + document.getElementById('nextImg')?.click(); + } + }; + 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(`/noembryo/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 !== undefined && data.parent_path !== null) { + // "" is valid → opens the drive list on Windows + goUpBtn.onclick = () => this.loadDirectory(data.parent_path); + goUpBtn.disabled = false; + goUpBtn.style.opacity = '1'; + goUpBtn.style.cursor = 'pointer'; + } 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} +
+ `; + } + + this.fileList = []; // reset every time the folder changes + + // 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; + const fullPath = data.current_path + ? `${data.current_path}${data.current_path.endsWith('/') || data.current_path.endsWith('\\') ? '' : (data.current_path.includes('\\') ? '\\' : '/')}${file}` + : file; + this.fileList.push(fullPath); + html += ` +
+ 🖼️ + ${file} +
+ `; + } + + html += '
'; + content.innerHTML = html; + + // Add click handlers + this.attachEventHandlers(content); + + // If we opened from a pasted path, pre-select that file (once) + if (this.initialFilePath) { + const target = this.initialFilePath.replace(/\s*\[[^\]]+\]\s*$/, '').trim(); + const files = content.querySelectorAll('.browser-item.file'); + for (const item of files) { + const p = item.getAttribute('data-path') || ''; + // loose match: ignore slash direction and case (Windows) + if (p.replace(/\//g, '\\').toLowerCase() === target.replace(/\//g, '\\').toLowerCase()) { + item.click(); // reuses existing select + preview logic + item.scrollIntoView({block: 'nearest'}); + break; + } + } + this.initialFilePath = null; // only on the first open + } + + } 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 selectByPath(path) { + const content = document.getElementById('browserContent'); + if (!content) return; + const item = [...content.querySelectorAll('.browser-item.file')] + .find(el => (el.getAttribute('data-path') || '').replace(/\//g, '\\').toLowerCase() + === path.replace(/\//g, '\\').toLowerCase()); + if (!item) return; + item.click(); + item.scrollIntoView({block: 'nearest'}); + } + + async loadPreview(path) { + const previewArea = document.getElementById('previewArea'); + previewArea.innerHTML = '
Loading preview...
'; + + try { + const response = await api.fetchApi(`/noembryo/get_image_preview?path=${encodeURIComponent(path)}`); + const data = await response.json(); + + if (data.error) { + previewArea.innerHTML = `
Preview error: ${data.error}
`; + return; + } + + + const idx = (this.fileList || []).findIndex(p => + p.replace(/\//g, '\\').toLowerCase() === path.replace(/\//g, '\\').toLowerCase() + ); + const hasPrev = idx > 0; + const hasNext = idx >= 0 && idx < (this.fileList.length - 1); + + previewArea.innerHTML = ` +
+ + + +
+
+
${data.width} × ${data.height}
+
+ ${path.split(/[\\/]/).pop()} +
+
+ `; + + document.getElementById('prevImg').onclick = () => { + if (hasPrev) this.selectByPath(this.fileList[idx - 1]); + }; + document.getElementById('nextImg').onclick = () => { + if (hasNext) this.selectByPath(this.fileList[idx + 1]); + }; + + + } catch (error) { + previewArea.innerHTML = `
Error loading preview
`; + } + } +} + +app.registerExtension({ + name: "noEmbryo.LoadImageFromPath", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "Load Image (from path) -noEmbryo") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + const node = this; + + // Add browse button + node.addWidget("button", "🔍 Browse...", null, () => { + const pathWidget = node.widgets.find(w => w.name === "image"); + const currentPath = pathWidget ? pathWidget.value : ''; + + const browser = new FileBrowserDialog(currentPath); // ← pass it here + 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 = `/noembryo/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