diff --git a/imgloader_node.py b/imgloader_node.py
index d9c8fae..372b260 100644
--- a/imgloader_node.py
+++ b/imgloader_node.py
@@ -16,20 +16,47 @@ import base64
import io
import os
import logging
+import pathlib
from typing import Tuple, Optional, Union
# Try to import ComfyUI utilities
try:
import folder_paths
+ import node_helpers
from comfy.model_management import soft_empty_cache
+ from nodes import PreviewImage, SaveImage
except ImportError:
# Fallback for development/testing
class MockFolderPaths:
@staticmethod
def get_input_directory():
return "input"
+
+ @staticmethod
+ def get_annotated_filepath(filename):
+ return os.path.join("input", filename)
+
+ @staticmethod
+ def exists_annotated_filepath(filename):
+ return os.path.exists(os.path.join("input", filename))
+
+ class MockNodeHelpers:
+ @staticmethod
+ def pillow(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ class MockPreviewImage:
+ def save_images(self, images, filename_prefix, prompt=None, extra_pnginfo=None):
+ return {"ui": {"images": []}}
+
+ class MockSaveImage:
+ def save_images(self, images, filename_prefix, prompt=None, extra_pnginfo=None):
+ return {"ui": {"images": []}}
folder_paths = MockFolderPaths()
+ node_helpers = MockNodeHelpers()
+ PreviewImage = MockPreviewImage
+ SaveImage = MockSaveImage
def soft_empty_cache():
pass
@@ -63,8 +90,19 @@ class ImageLoader:
@classmethod
def INPUT_TYPES(cls):
"""Define the input types and their configurations."""
+ # Get list of supported image files from input directory
+ input_dir = folder_paths.get_input_directory()
+ files = []
+ if os.path.exists(input_dir):
+ files = [f.name for f in pathlib.Path(input_dir).iterdir() if f.is_file()]
+
return {
- "required": {},
+ "required": {
+ "image": (sorted(files), {
+ "image_upload": True,
+ "tooltip": "Select an image file from the input directory"
+ })
+ },
"optional": {
"filepath": ("STRING", {
"default": "",
@@ -96,16 +134,27 @@ class ImageLoader:
inputs.append(f"{key}:{value}")
return hash(tuple(inputs))
- def load_image(self, filepath: str = "", base64: str = "", pasted_base64: str = "") -> Tuple[torch.Tensor, torch.Tensor]:
+ @classmethod
+ def VALIDATE_INPUTS(cls, **kwargs):
+ """Validate input parameters."""
+ image = kwargs.get("image", "")
+ if image and image.strip():
+ if not folder_paths.exists_annotated_filepath(image):
+ return f"Invalid image file: {image}"
+ return True
+
+ def load_image(self, image: str = "", filepath: str = "", base64: str = "", pasted_base64: str = "") -> Tuple[torch.Tensor, torch.Tensor]:
"""
Load an image from one of the available sources with precedence handling.
Precedence order:
1. Pasted image (from clipboard via JavaScript)
- 2. File path
- 3. Base64 string
+ 2. Image upload (file picker)
+ 3. File path
+ 4. Base64 string
Args:
+ image: Image file from file picker
filepath: Path to image file
base64: Base64 encoded image string
pasted_base64: Base64 data from clipboard paste (populated by JS)
@@ -114,26 +163,40 @@ class ImageLoader:
Tuple of (image_tensor, mask_tensor)
"""
try:
- image_data, source_info = self._get_image_data(filepath, base64, pasted_base64)
+ image_data, source_info = self._get_image_data(image, filepath, base64, pasted_base64)
if image_data is None:
logger.warning("No valid image source provided")
- return self._create_empty_tensors()
+ image_tensor, mask_tensor = self._create_empty_tensors()
+ else:
+ logger.info(f"Loading image from: {source_info}")
+ image_tensor, mask_tensor = self._process_image_data(image_data)
- logger.info(f"Loading image from: {source_info}")
- return self._process_image_data(image_data)
+ # Show the image in the UI
+ results = self.easySave(image_tensor, "imgloader", "Preview", None, None)
+ return {"ui": {"images": results},
+ "result": (image_tensor, mask_tensor)}
except Exception as e:
logger.error(f"Error loading image: {e}")
- return self._create_empty_tensors()
+ image_tensor, mask_tensor = self._create_empty_tensors()
+ results = self.easySave(image_tensor, "imgloader", "Preview", None, None)
+ return {"ui": {"images": results},
+ "result": (image_tensor, mask_tensor)}
finally:
# Clean up GPU memory
soft_empty_cache()
- def _get_image_data(self, filepath: str, base64_str: str, pasted_base64: str) -> Tuple[Optional[bytes], str]:
+ def _get_image_data(self, image: str, filepath: str, base64_str: str, pasted_base64: str) -> Tuple[Optional[bytes], str]:
"""
Extract image data from available sources following precedence rules.
+ Precedence order (higher precedence overrides lower):
+ 1. Clipboard paste (pasted_base64) - highest precedence
+ 2. File path input (filepath) - overrides image upload
+ 3. Base64 string input (base64_str) - overrides image upload
+ 4. Image upload (image) - lowest precedence
+
Returns:
Tuple of (image_data_bytes, source_description)
"""
@@ -146,16 +209,16 @@ class ImageLoader:
except Exception as e:
logger.warning(f"Failed to decode pasted image: {e}")
- # 2. Medium precedence: File path
+ # 2. Second precedence: File path input (overrides image upload)
if self._is_valid_input(filepath):
try:
- data = self._load_file_data(filepath)
+ data = self._load_file_data(filepath, use_annotated_path=False)
if data:
- return data, f"File: {os.path.basename(filepath)}"
+ return data, f"File Path: {os.path.basename(filepath)}"
except Exception as e:
logger.warning(f"Failed to load file {filepath}: {e}")
- # 3. Lowest precedence: Base64 string input
+ # 3. Third precedence: Base64 string input (overrides image upload)
if self._is_valid_input(base64_str):
try:
data = self._decode_base64_data(base64_str)
@@ -164,6 +227,15 @@ class ImageLoader:
except Exception as e:
logger.warning(f"Failed to decode base64 string: {e}")
+ # 4. Lowest precedence: Image upload from file picker
+ if self._is_valid_input(image):
+ try:
+ data = self._load_file_data(image, use_annotated_path=True)
+ if data:
+ return data, f"File Upload: {os.path.basename(image)}"
+ except Exception as e:
+ logger.warning(f"Failed to load uploaded image {image}: {e}")
+
return None, "No valid source"
def _is_valid_input(self, value: str) -> bool:
@@ -197,30 +269,46 @@ class ImageLoader:
logger.error(f"Base64 decode error: {e}")
return None
- def _load_file_data(self, filepath: str) -> Optional[bytes]:
+ def _load_file_data(self, filepath: str, use_annotated_path: bool = False) -> Optional[bytes]:
"""
Load image data from a file path.
Args:
filepath: File path (relative paths are resolved to input directory)
+ use_annotated_path: If True, use ComfyUI's annotated path system (for file picker)
Returns:
File contents as bytes or None if failed
"""
try:
- # Resolve relative paths to ComfyUI input directory
- if not os.path.isabs(filepath):
- filepath = os.path.join(folder_paths.get_input_directory(), filepath)
+ # Handle empty or None filepath
+ if not filepath or filepath.strip() == "":
+ return None
+
+ if use_annotated_path:
+ # Use ComfyUI's annotated filepath system for file picker uploads
+ full_path = folder_paths.get_annotated_filepath(filepath)
+ else:
+ # Handle manual file paths
+ if not os.path.sep in filepath and not "/" in filepath:
+ # If filepath doesn't contain path separators, it's likely from the dropdown
+ # and should be treated as a filename in the input directory
+ full_path = os.path.join(folder_paths.get_input_directory(), filepath)
+ elif not os.path.isabs(filepath):
+ # Resolve relative paths to ComfyUI input directory
+ full_path = os.path.join(folder_paths.get_input_directory(), filepath)
+ else:
+ full_path = filepath
# Validate file exists and is readable
- if not os.path.exists(filepath):
- raise FileNotFoundError(f"File not found: {filepath}")
+ if not os.path.exists(full_path):
+ raise FileNotFoundError(f"File not found: {full_path}")
- if not os.path.isfile(filepath):
- raise ValueError(f"Path is not a file: {filepath}")
+ if not os.path.isfile(full_path):
+ raise ValueError(f"Path is not a file: {full_path}")
# Read file data
- with open(filepath, 'rb') as f:
+ with open(full_path, 'rb') as f:
return f.read()
except Exception as e:
@@ -238,11 +326,11 @@ class ImageLoader:
Tuple of (image_tensor, mask_tensor)
"""
try:
- # Open image with PIL
- img = Image.open(io.BytesIO(image_data))
+ # Open image with PIL using node_helpers for better ComfyUI compatibility
+ img = node_helpers.pillow(Image.open, io.BytesIO(image_data))
# Apply EXIF rotation if present
- img = ImageOps.exif_transpose(img)
+ img = node_helpers.pillow(ImageOps.exif_transpose, img)
# Process RGB image
image_tensor = self._create_image_tensor(img)
@@ -320,6 +408,18 @@ class ImageLoader:
empty_mask = torch.zeros((1, 1, 1), dtype=torch.float32)
return empty_image, empty_mask
+
+ def easySave(self, images, filename_prefix, output_type, prompt=None, extra_pnginfo=None):
+ """Save or Preview Image"""
+ if output_type in ["Hide", "None"]:
+ return list()
+ elif output_type in ["Preview", "Preview&Choose"]:
+ filename_prefix = 'easyPreview'
+ results = PreviewImage().save_images(images, filename_prefix, prompt, extra_pnginfo)
+ return results['ui']['images']
+ else:
+ results = SaveImage().save_images(images, filename_prefix, prompt, extra_pnginfo)
+ return results['ui']['images']
# Node registration information
diff --git a/js/imgloader.js b/js/imgloader.js
index c968a27..a49de81 100644
--- a/js/imgloader.js
+++ b/js/imgloader.js
@@ -9,7 +9,7 @@
* - Preview functionality
*/
-import { app } from "/scripts/app.js";
+// Note: app is available globally in ComfyUI context
// Configuration constants
const CONFIG = {
@@ -87,6 +87,7 @@ class ImageLoaderNodeHandler {
* Find and store widget references
*/
findWidgets() {
+ this.widgets.image = this.node.widgets?.find(w => w.name === "image");
this.widgets.filepath = this.node.widgets?.find(w => w.name === "filepath");
this.widgets.base64 = this.node.widgets?.find(w => w.name === "base64");
this.widgets.pasted = this.node.widgets?.find(w => w.name === "pasted_base64");
@@ -122,6 +123,18 @@ class ImageLoaderNodeHandler {
* Setup event listeners for input handling
*/
setupEventListeners() {
+ // Image widget (file picker) change handler
+ if (this.widgets.image) {
+ const originalCallback = this.widgets.image.callback;
+ this.widgets.image.callback = (value) => {
+ if (value && value.trim()) {
+ this.clearOtherInputs('image');
+ this.updatePreview('image', value);
+ }
+ return originalCallback?.call(this.node, value);
+ };
+ }
+
// Filepath widget change handler
if (this.widgets.filepath) {
const originalCallback = this.widgets.filepath.callback;
@@ -157,108 +170,96 @@ class ImageLoaderNodeHandler {
});
}
- // Clipboard paste handler for the entire node
- const pasteHandler = (event) => this.handlePaste(event);
-
- // Add paste listener to node element and its container
- if (this.node.canvas) {
- this.node.canvas.addEventListener('paste', pasteHandler);
- this.eventListeners.push({
- element: this.node.canvas,
- type: 'paste',
- handler: pasteHandler
- });
- }
-
- // Also listen on the node's DOM element if available
- if (this.node.element) {
- this.node.element.addEventListener('paste', pasteHandler);
- this.eventListeners.push({
- element: this.node.element,
- type: 'paste',
- handler: pasteHandler
- });
- }
+ // Global paste handler for clipboard images
+ const globalPasteHandler = (event) => this.handlePaste(event);
+ document.addEventListener('paste', globalPasteHandler);
+ this.eventListeners.push({
+ element: document,
+ type: 'paste',
+ handler: globalPasteHandler
+ });
}
/**
* Setup drag and drop functionality
*/
setupDragAndDrop() {
- const nodeElement = this.node.element || this.node.canvas;
+ // Add drag and drop to the node itself
+ const nodeElement = this.node;
if (!nodeElement) return;
- const dragOverHandler = (event) => {
- event.preventDefault();
- event.stopPropagation();
-
- // Check if dragged items include images
+ // Store original handlers to avoid conflicts
+ const originalOnDragOver = nodeElement.onDragOver;
+ const originalOnDragLeave = nodeElement.onDragLeave;
+ const originalOnDrop = nodeElement.onDrop;
+
+ nodeElement.onDragOver = (event) => {
if (this.hasImageFiles(event.dataTransfer)) {
+ event.preventDefault();
+ event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
this.setDragState(true);
+ return true;
}
+ return originalOnDragOver?.call(nodeElement, event);
};
- const dragLeaveHandler = (event) => {
- event.preventDefault();
- event.stopPropagation();
-
- // Only clear drag state if leaving the node entirely
- if (!nodeElement.contains(event.relatedTarget)) {
- this.setDragState(false);
- }
- };
-
- const dropHandler = (event) => {
- event.preventDefault();
- event.stopPropagation();
+ nodeElement.onDragLeave = (event) => {
this.setDragState(false);
-
- this.handleDrop(event);
+ return originalOnDragLeave?.call(nodeElement, event);
};
- // Add drag and drop listeners
- nodeElement.addEventListener('dragover', dragOverHandler);
- nodeElement.addEventListener('dragleave', dragLeaveHandler);
- nodeElement.addEventListener('drop', dropHandler);
-
- this.eventListeners.push(
- { element: nodeElement, type: 'dragover', handler: dragOverHandler },
- { element: nodeElement, type: 'dragleave', handler: dragLeaveHandler },
- { element: nodeElement, type: 'drop', handler: dropHandler }
- );
+ nodeElement.onDrop = (event) => {
+ if (this.hasImageFiles(event.dataTransfer)) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.setDragState(false);
+ this.handleDrop(event);
+ return true;
+ }
+ return originalOnDrop?.call(nodeElement, event);
+ };
}
/**
* Setup image preview functionality
*/
setupPreview() {
- // Create preview container (initially hidden)
+ // For ComfyUI nodes, we'll create a preview that appears when hovering or when an image is loaded
+ // The preview will be positioned relative to the node
this.previewElement = document.createElement('div');
this.previewElement.style.cssText = `
- position: absolute;
- top: -10px;
- right: -10px;
+ position: fixed;
+ top: 10px;
+ right: 10px;
width: ${CONFIG.MAX_PREVIEW_SIZE}px;
max-height: ${CONFIG.MAX_PREVIEW_SIZE}px;
border: 2px solid #4CAF50;
- border-radius: 4px;
+ border-radius: 8px;
background: #fff;
display: none;
- z-index: 1000;
+ z-index: 9999;
overflow: hidden;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ pointer-events: none;
`;
- if (this.node.element) {
- this.node.element.style.position = 'relative';
- this.node.element.appendChild(this.previewElement);
- }
+ // Add to document body for better positioning
+ document.body.appendChild(this.previewElement);
+
+ // Store reference for cleanup
+ this.node._imageLoaderPreview = this.previewElement;
}
/**
* Handle clipboard paste events
*/
handlePaste(event) {
+ // Only handle if this node is focused or selected
+ if (!this.isNodeActive()) {
+ return;
+ }
+
const items = (event.clipboardData || event.originalEvent?.clipboardData)?.items;
if (!items) return;
@@ -277,6 +278,18 @@ class ImageLoaderNodeHandler {
}
}
+ /**
+ * Check if this node is currently active/selected
+ */
+ isNodeActive() {
+ // Check if the node is selected in the graph
+ if (this.node.graph && this.node.graph.canvas) {
+ return this.node.graph.canvas.selected_nodes &&
+ this.node.graph.canvas.selected_nodes[this.node.id];
+ }
+ return false;
+ }
+
/**
* Handle drag and drop events
*/
@@ -328,23 +341,54 @@ class ImageLoaderNodeHandler {
}
/**
- * Clear inputs other than the specified active one
+ * Clear inputs other than the specified active one based on precedence rules
+ *
+ * Precedence order:
+ * 1. Clipboard paste (highest) - clears all others
+ * 2. File path - clears image upload and base64
+ * 3. Base64 - clears image upload and filepath
+ * 4. Image upload (lowest) - clears filepath and base64
*/
clearOtherInputs(activeInput) {
- const inputs = ['filepath', 'base64', 'paste'];
+ // Define what each input type should clear
+ const clearingRules = {
+ 'paste': ['image', 'filepath', 'base64'], // Paste clears everything else
+ 'filepath': ['image', 'base64'], // Filepath clears image upload and base64
+ 'base64': ['image', 'filepath'], // Base64 clears image upload and filepath
+ 'image': ['filepath', 'base64'] // Image upload clears filepath and base64
+ };
- inputs.forEach(input => {
- if (input !== activeInput) {
- const widget = input === 'paste' ? this.widgets.pasted : this.widgets[input];
- if (widget && widget.value !== '') {
- widget.value = '';
+ const inputsToClear = clearingRules[activeInput] || [];
+
+ inputsToClear.forEach(inputName => {
+ const widget = inputName === 'paste' ? this.widgets.pasted : this.widgets[inputName];
+ if (widget && widget.value !== '') {
+ const oldValue = widget.value;
+ widget.value = '';
+
+ // Trigger change event to update the node
+ if (widget.callback) {
+ widget.callback('');
}
+
+ // Log the clearing action for debugging
+ console.info(`ImageLoader: Cleared ${inputName} (was: "${oldValue.substring(0, 50)}${oldValue.length > 50 ? '...' : ''}")`);
}
});
- // Hide preview if clearing
+ // Update preview for new active input
+ this.hidePreview();
if (activeInput !== 'paste') {
- this.hidePreview();
+ // For non-paste inputs, update preview after a short delay
+ setTimeout(() => {
+ if (activeInput === 'image' && this.widgets.image?.value) {
+ this.updatePreview('image', this.widgets.image.value);
+ } else if (activeInput === 'filepath' && this.widgets.filepath?.value) {
+ this.updatePreview('filepath', this.widgets.filepath.value);
+ } else if (activeInput === 'base64' && this.widgets.base64?.value) {
+ this.updatePreview('base64', this.widgets.base64.value);
+ }
+ }, 100);
}
}
@@ -382,24 +426,78 @@ class ImageLoaderNodeHandler {
let imageUrl = '';
- if (source === 'filepath') {
- // For file paths, we can't show preview without loading the file
- this.hidePreview();
- return;
+ if (source === 'filepath' || source === 'image') {
+ // For file paths, try to create a preview URL
+ if (value && value.trim()) {
+ // Try to load the image for preview
+ this.loadImagePreview(value);
+ return;
+ }
} else if (source === 'base64' || source === 'paste') {
imageUrl = value.startsWith('data:') ? value : `data:image/png;base64,${value}`;
}
if (imageUrl) {
- this.previewElement.innerHTML = `
-
- `;
- this.previewElement.style.display = 'block';
+ this.showPreview(imageUrl);
+ } else {
+ this.hidePreview();
}
}
+ /**
+ * Load image preview from file path
+ */
+ async loadImagePreview(filePath) {
+ try {
+ // For ComfyUI, we can try to access the image through the input directory
+ const imageUrl = `/view?filename=${encodeURIComponent(filePath)}&type=input`;
+
+ // Test if the image loads
+ const img = new Image();
+ img.onload = () => {
+ this.showPreview(imageUrl);
+ };
+ img.onerror = () => {
+ // If direct access fails, show a placeholder
+ this.showPreviewPlaceholder(filePath);
+ };
+ img.src = imageUrl;
+ } catch (error) {
+ this.showPreviewPlaceholder(filePath);
+ }
+ }
+
+ /**
+ * Show image preview
+ */
+ showPreview(imageUrl) {
+ if (!this.previewElement) return;
+
+ this.previewElement.innerHTML = `
+
+ `;
+ this.previewElement.style.display = 'block';
+ }
+
+ /**
+ * Show preview placeholder for file paths
+ */
+ showPreviewPlaceholder(fileName) {
+ if (!this.previewElement) return;
+
+ const baseName = fileName.split(/[\\/]/).pop() || fileName;
+ this.previewElement.innerHTML = `
+