This commit is contained in:
noembryo
2026-08-16 15:46:15 +03:00
parent 276e0f1551
commit e4b6fcf9d3
7 changed files with 979 additions and 6 deletions
+10 -4
View File
@@ -50,21 +50,27 @@ For the custom text integration, there is a variable (can be specified by the us
<u>_**After changing the `.json` file, we must reload the ComfyUI page, so the node can reload the updated file.**_</u>
---
## 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
<u>**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.**</u>
![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
+2 -1
View File
@@ -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:
+371
View File
@@ -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)}')
+3
View File
@@ -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__}",
+2 -1
View File
@@ -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]
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+591
View File
@@ -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 = `
<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);
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 = '<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(`/noembryo/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 !== 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 = '<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>
`;
}
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 += `
<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);
// 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 = `<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 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 = '<div style="color: #888; font-size: 11px;">Loading preview...</div>';
try {
const response = await api.fetchApi(`/noembryo/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;
}
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 = `
<div style="display:flex; align-items:center; gap:6px;">
<button id="prevImg" ${hasPrev ? '' : 'disabled'}
style="background:#444; border:none; color:white; width:28px; height:28px;
border-radius:3px; cursor:${hasPrev ? 'pointer' : 'not-allowed'};
opacity:${hasPrev ? 1 : 0.35}; font-size:14px;">◀</button>
<img src="${data.preview}"
style="max-height:60px; max-width:100px; border:1px solid #555; border-radius:3px;" />
<button id="nextImg" ${hasNext ? '' : 'disabled'}
style="background:#444; border:none; color:white; width:28px; height:28px;
border-radius:3px; cursor:${hasNext ? 'pointer' : 'not-allowed'};
opacity:${hasNext ? 1 : 0.35}; font-size:14px;">▶</button>
</div>
<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>
`;
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 = `<div style="color: #f55; font-size: 11px;">Error loading preview</div>`;
}
}
}
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;
};
}
}
});