Initial release of ComfyUI Subfolder Image Loader
Features: - Subfolder navigation and dynamic image filtering - Support for all common image formats - Transparency mask extraction - Security validation and error handling - JavaScript frontend for real-time UI updates
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.claude/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local test files
|
||||
test_*
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 rdomunky
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# ComfyUI Subfolder Image Loader
|
||||
|
||||
A ComfyUI custom node that enhances image loading with subfolder organization and dynamic filtering.
|
||||
|
||||
## Features
|
||||
|
||||
- **Subfolder Navigation**: Organize your input images into subfolders and select them easily
|
||||
- **Dynamic Image Filtering**: Image list automatically updates when you change subfolder selection
|
||||
- **Two-Stage Selection**: First select a subfolder, then choose an image from within it
|
||||
- **Format Support**: PNG, JPG, JPEG, WebP, BMP, TIFF formats
|
||||
- **Transparency Support**: Automatic alpha channel/mask extraction from RGBA images
|
||||
- **Refresh Functionality**: Right-click menu option to refresh file listings
|
||||
- **Security**: Path validation prevents directory traversal attacks
|
||||
|
||||
## Installation
|
||||
|
||||
### Via ComfyUI Manager (Recommended)
|
||||
1. Open ComfyUI Manager
|
||||
2. Search for "Subfolder Image Loader"
|
||||
3. Install the node
|
||||
|
||||
### Manual Installation
|
||||
1. Navigate to your ComfyUI custom nodes directory:
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes/
|
||||
```
|
||||
2. Clone this repository:
|
||||
```bash
|
||||
git clone https://github.com/rdomunky/comfyui-subfolderimageloader.git
|
||||
```
|
||||
3. Restart ComfyUI
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Organize Images**: Place your images in subfolders within ComfyUI's `input/` directory:
|
||||
```
|
||||
ComfyUI/input/
|
||||
├── portraits/
|
||||
│ ├── person1.jpg
|
||||
│ └── person2.png
|
||||
├── landscapes/
|
||||
│ ├── mountain.jpg
|
||||
│ └── beach.png
|
||||
└── textures/
|
||||
├── wood.jpg
|
||||
└── metal.png
|
||||
```
|
||||
|
||||
2. **Add Node**: In ComfyUI, add the "Subfolder Image Loader" node (found in `image/loaders` category)
|
||||
|
||||
3. **Select Subfolder**: Choose a subfolder from the dropdown (or leave empty for root folder)
|
||||
|
||||
4. **Choose Image**: The image dropdown will automatically filter to show only images from the selected subfolder
|
||||
|
||||
5. **Load Image**: The node outputs the same data as the standard Load Image node:
|
||||
- `IMAGE`: The loaded image tensor
|
||||
- `MASK`: Alpha channel mask (if present)
|
||||
- `STRING`: Filename
|
||||
- `INT`: Width
|
||||
- `INT`: Height
|
||||
|
||||
## Tips
|
||||
|
||||
- **Refresh Files**: Right-click the node → "🔄 Refresh Files" to update file listings without restarting
|
||||
- **UI Quirk**: Due to ComfyUI limitations, the image dropdown display may require a mouse movement to visually update (functionality works correctly)
|
||||
- **Empty Folders**: Subfolders with no images will still appear in the subfolder list
|
||||
- **Nested Folders**: Only direct subfolders are supported (no nested subfolder navigation)
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **Path Security**: All file paths are validated to prevent directory traversal
|
||||
- **API Endpoint**: Provides `/subfolder_loader/refresh` for dynamic file listing updates
|
||||
- **Error Handling**: Graceful fallback to empty image tensors on errors
|
||||
- **Performance**: Includes optional file caching for improved performance
|
||||
|
||||
## Comparison with Standard Load Image
|
||||
|
||||
| Feature | Standard Load Image | Subfolder Image Loader |
|
||||
|---------|-------------------|----------------------|
|
||||
| Subfolder support | ❌ | ✅ |
|
||||
| Dynamic filtering | ❌ | ✅ |
|
||||
| File organization | ❌ | ✅ |
|
||||
| Transparency masks | ✅ | ✅ |
|
||||
| Same outputs | ✅ | ✅ |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Images not appearing**: Ensure images are in supported formats and located in ComfyUI's input directory or its subfolders.
|
||||
|
||||
**Subfolder not updating**: Try the refresh option or restart ComfyUI if subfolders don't appear.
|
||||
|
||||
**JavaScript not loading**: Make sure the `web/` directory and `WEB_DIRECTORY` in `__init__.py` are properly configured.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues or pull requests.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
18
__init__.py
Normal file
18
__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# __init__.py
|
||||
from .subfolder_loader import SubfolderImageLoader
|
||||
|
||||
# Web directory for frontend assets
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
# Required: Maps node identifier to class
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SubfolderImageLoader": SubfolderImageLoader,
|
||||
}
|
||||
|
||||
# Optional: Maps identifier to display name
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SubfolderImageLoader": "Subfolder Image Loader",
|
||||
}
|
||||
|
||||
# Export for ComfyUI discovery
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
91
fixsubfolder.patch
Normal file
91
fixsubfolder.patch
Normal file
@@ -0,0 +1,91 @@
|
||||
--- a/web/js/subfolder_ui.js
|
||||
+++ b/web/js/subfolder_ui.js
|
||||
@@ -35,32 +35,46 @@ app.registerExtension({
|
||||
const originalImageOptions = [...imageWidget.options.values];
|
||||
|
||||
// Function to filter images based on selected subfolder
|
||||
const filterImages = (selectedSubfolder) => {
|
||||
let filteredImages;
|
||||
+ let displayNames;
|
||||
|
||||
if (!selectedSubfolder || selectedSubfolder === "") {
|
||||
// Show only root images (no / in filename)
|
||||
filteredImages = originalImageOptions.filter(img => !img.includes('/'));
|
||||
+ displayNames = filteredImages;
|
||||
} else {
|
||||
// Show only images from selected subfolder
|
||||
const prefix = selectedSubfolder + "/";
|
||||
filteredImages = originalImageOptions
|
||||
- .filter(img => img.startsWith(prefix))
|
||||
- .map(img => img.substring(prefix.length)); // Remove prefix for display
|
||||
+ .filter(img => img.startsWith(prefix));
|
||||
+ displayNames = filteredImages
|
||||
+ .map(img => img.substring(prefix.length)); // Remove prefix for display only
|
||||
}
|
||||
|
||||
// Update image widget options
|
||||
- imageWidget.options.values = filteredImages;
|
||||
+ imageWidget.options.values = displayNames;
|
||||
|
||||
- // Update widget value if current selection is no longer valid
|
||||
- if (filteredImages.length > 0 && !filteredImages.includes(imageWidget.value)) {
|
||||
- imageWidget.value = filteredImages[0];
|
||||
- imageWidget.callback?.(filteredImages[0]);
|
||||
+ // Check if current value is still valid
|
||||
+ let currentValueValid = false;
|
||||
+ if (selectedSubfolder && imageWidget.value && !imageWidget.value.includes('/')) {
|
||||
+ // If we have a plain filename, check if it exists in the display names
|
||||
+ currentValueValid = displayNames.includes(imageWidget.value);
|
||||
+ } else if (!selectedSubfolder && imageWidget.value && !imageWidget.value.includes('/')) {
|
||||
+ // Root folder - check if current value exists in root
|
||||
+ currentValueValid = displayNames.includes(imageWidget.value);
|
||||
+ }
|
||||
+
|
||||
+ // Update widget value if current selection is no longer valid
|
||||
+ if (!currentValueValid && displayNames.length > 0) {
|
||||
+ imageWidget.value = displayNames[0];
|
||||
+ imageWidget.callback?.(displayNames[0]);
|
||||
}
|
||||
|
||||
// Force widget UI update
|
||||
if (imageWidget.element) {
|
||||
// Clear existing options
|
||||
imageWidget.element.innerHTML = "";
|
||||
|
||||
// Add new options
|
||||
- filteredImages.forEach(img => {
|
||||
+ displayNames.forEach((img, index) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = img;
|
||||
option.textContent = img;
|
||||
@@ -68,6 +82,9 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
imageWidget.element.value = imageWidget.value;
|
||||
}
|
||||
+
|
||||
+ // Trigger graph change to update the node
|
||||
+ node.graph?.change();
|
||||
};
|
||||
|
||||
// Set up subfolder change handler
|
||||
@@ -138,8 +155,20 @@ app.registerExtension({
|
||||
content: "Open in new tab",
|
||||
callback: () => {
|
||||
const subfolder = node.widgets.find(w => w.name === "subfolder")?.value || "";
|
||||
const filename = widget.value;
|
||||
- const url = `/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
+
|
||||
+ // Construct the correct URL based on whether we're in a subfolder
|
||||
+ let url;
|
||||
+ if (subfolder) {
|
||||
+ // For subfolder images, we need to pass just the filename
|
||||
+ url = `/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
+ } else {
|
||||
+ // For root images, pass the filename as is
|
||||
+ url = `/view?filename=${encodeURIComponent(filename)}`;
|
||||
+ }
|
||||
+
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
362
subfolder_loader.py
Normal file
362
subfolder_loader.py
Normal file
@@ -0,0 +1,362 @@
|
||||
# subfolder_loader.py
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Tuple, Optional
|
||||
import folder_paths
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
# Optional: Add server route for refresh functionality
|
||||
try:
|
||||
import server
|
||||
from aiohttp import web
|
||||
|
||||
@server.PromptServer.instance.routes.post("/subfolder_loader/refresh")
|
||||
async def refresh_file_listings(request):
|
||||
"""API endpoint to refresh file listings."""
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = data.get('node_id')
|
||||
subfolder = data.get('subfolder', '')
|
||||
|
||||
# Get fresh file listings
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
subfolders = SubfolderImageLoader.get_subfolders(input_dir)
|
||||
|
||||
# Get filtered images for the specified subfolder
|
||||
if subfolder:
|
||||
filtered_images = SubfolderImageLoader.get_images_for_subfolder(subfolder)
|
||||
else:
|
||||
filtered_images = SubfolderImageLoader.get_images_for_subfolder("")
|
||||
|
||||
# Also get all images for client-side filtering if needed
|
||||
all_images = SubfolderImageLoader.get_all_images_with_paths(input_dir)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'subfolders': subfolders,
|
||||
'images': all_images, # All images with paths for client filtering
|
||||
'filtered_images': filtered_images, # Pre-filtered images for the subfolder
|
||||
'current_subfolder': subfolder
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Refresh error: {str(e)}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
class SubfolderImageLoader:
|
||||
"""
|
||||
Enhanced image loader with subfolder selection support.
|
||||
|
||||
This node allows you to organize your input images into subfolders and then
|
||||
dynamically select images from specific subfolders. First select a subfolder,
|
||||
then choose an image - the image list will automatically filter to show only
|
||||
images from the selected subfolder.
|
||||
|
||||
Features:
|
||||
- Subfolder navigation within ComfyUI's input directory
|
||||
- Dynamic image filtering based on selected subfolder
|
||||
- Support for PNG, JPG, JPEG, WebP, BMP, TIFF formats
|
||||
- Alpha channel/transparency mask extraction
|
||||
- Right-click menu option to refresh file listings
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
|
||||
# Get available subfolders
|
||||
subfolders = cls.get_subfolders(input_dir)
|
||||
|
||||
# Start with images from root folder (no subfolder selected)
|
||||
default_images = cls.get_images_for_subfolder("")
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"subfolder": (subfolders, {
|
||||
"default": subfolders[0] if subfolders else "",
|
||||
"tooltip": "Select a subfolder from your input directory. Leave empty for root folder. The image list will update automatically when you change this."
|
||||
}),
|
||||
"image": (default_images if default_images else [""], {
|
||||
"default": default_images[0] if default_images else "",
|
||||
"tooltip": "Choose an image from the selected subfolder. This list is filtered based on your subfolder selection."
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"load_mask": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Extract alpha channel as mask from RGBA/transparent images. Disable if you don't need transparency masks."
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_images_for_subfolder(cls, subfolder: str = "") -> list:
|
||||
"""Get filtered images for a specific subfolder."""
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
all_images = cls.get_all_images_with_paths(input_dir)
|
||||
|
||||
if not subfolder or subfolder == "":
|
||||
# Root folder - show only images without subfolder (no slash)
|
||||
return [img for img in all_images if '/' not in img]
|
||||
else:
|
||||
# Specific subfolder - show only images from that subfolder, without prefix
|
||||
prefix = subfolder + "/"
|
||||
filtered = [img[len(prefix):] for img in all_images
|
||||
if img.startswith(prefix) and '/' not in img[len(prefix):]]
|
||||
return filtered
|
||||
|
||||
@classmethod
|
||||
def get_subfolders(cls, base_path: str) -> List[str]:
|
||||
"""Get list of subfolders in the base directory."""
|
||||
if not os.path.exists(base_path):
|
||||
return [""]
|
||||
|
||||
subfolders = [""] # Include root/no subfolder option
|
||||
|
||||
try:
|
||||
for item in sorted(os.listdir(base_path)):
|
||||
item_path = os.path.join(base_path, item)
|
||||
if os.path.isdir(item_path) and not item.startswith('.'):
|
||||
subfolders.append(item)
|
||||
except PermissionError:
|
||||
logging.warning(f"Permission denied accessing {base_path}")
|
||||
|
||||
return subfolders
|
||||
|
||||
@classmethod
|
||||
def get_all_images_with_paths(cls, base_path: str) -> List[str]:
|
||||
"""Get all images with their relative paths."""
|
||||
all_images = []
|
||||
|
||||
if not os.path.exists(base_path):
|
||||
return all_images
|
||||
|
||||
# Get images from root
|
||||
root_images = cls.get_images_from_folder(base_path)
|
||||
all_images.extend(root_images)
|
||||
|
||||
# Get images from each subfolder with path prefix
|
||||
for subfolder in cls.get_subfolders(base_path):
|
||||
if subfolder: # Skip empty root option
|
||||
subfolder_path = os.path.join(base_path, subfolder)
|
||||
images = cls.get_images_from_folder(subfolder_path)
|
||||
# Add with subfolder prefix
|
||||
for img in images:
|
||||
all_images.append(f"{subfolder}/{img}")
|
||||
|
||||
return sorted(all_images)
|
||||
|
||||
@classmethod
|
||||
def get_images_from_folder(cls, folder_path: str) -> List[str]:
|
||||
"""Get image files from a specific folder."""
|
||||
if not os.path.exists(folder_path):
|
||||
return []
|
||||
|
||||
valid_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif'}
|
||||
images = []
|
||||
|
||||
try:
|
||||
for file in sorted(os.listdir(folder_path)):
|
||||
if os.path.splitext(file.lower())[1] in valid_extensions:
|
||||
images.append(file)
|
||||
except PermissionError:
|
||||
logging.warning(f"Permission denied accessing {folder_path}")
|
||||
|
||||
return images
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, subfolder="", image="", **kwargs):
|
||||
"""Validate inputs before execution."""
|
||||
if not image:
|
||||
return "No image specified"
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
|
||||
# Handle the case where image might contain subfolder prefix
|
||||
clean_image = image
|
||||
actual_subfolder = subfolder
|
||||
|
||||
# If image contains a path separator, extract the subfolder and filename
|
||||
if '/' in image:
|
||||
parts = image.split('/')
|
||||
if len(parts) == 2:
|
||||
potential_subfolder, clean_image = parts
|
||||
# Use the subfolder from the image path if no subfolder is explicitly set
|
||||
if not actual_subfolder:
|
||||
actual_subfolder = potential_subfolder
|
||||
|
||||
# Build the full path
|
||||
if actual_subfolder:
|
||||
file_path = os.path.join(input_dir, actual_subfolder, clean_image)
|
||||
else:
|
||||
file_path = os.path.join(input_dir, clean_image)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
# Log for debugging
|
||||
logging.error(f"File not found: {file_path}")
|
||||
logging.error(f"Original - Subfolder: '{subfolder}', Image: '{image}'")
|
||||
logging.error(f"Processed - Subfolder: '{actual_subfolder}', Clean image: '{clean_image}'")
|
||||
return f"Image file not found: {clean_image}"
|
||||
|
||||
# Validate it's within the input directory
|
||||
try:
|
||||
file_path_abs = os.path.abspath(file_path)
|
||||
input_dir_abs = os.path.abspath(input_dir)
|
||||
if not file_path_abs.startswith(input_dir_abs):
|
||||
return "Invalid file path: outside input directory"
|
||||
except Exception:
|
||||
return "Invalid file path"
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, subfolder="", image="", **kwargs):
|
||||
"""Control when node re-executes."""
|
||||
if not image:
|
||||
return False
|
||||
|
||||
try:
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
|
||||
# Build path
|
||||
if subfolder:
|
||||
file_path = os.path.join(input_dir, subfolder, image)
|
||||
else:
|
||||
file_path = os.path.join(input_dir, image)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
return os.path.getmtime(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "mask", "filename", "width", "height")
|
||||
CATEGORY = "image/loaders"
|
||||
FUNCTION = "load_image"
|
||||
|
||||
DESCRIPTION = "Load images from subfolders with dynamic filtering. Organize your images in subfolders and select them easily."
|
||||
|
||||
def load_image(self, subfolder: str = "", image: str = "", load_mask: bool = True, **kwargs) -> Tuple:
|
||||
"""
|
||||
Load image from the specified subfolder.
|
||||
|
||||
Args:
|
||||
subfolder: Selected subfolder name
|
||||
image: Image filename or path (may contain subfolder prefix)
|
||||
load_mask: Whether to extract alpha channel as mask
|
||||
|
||||
Returns:
|
||||
Tuple of (image_tensor, mask_tensor, filename, width, height)
|
||||
"""
|
||||
try:
|
||||
if not image:
|
||||
raise ValueError("No image specified")
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
|
||||
# Handle the case where image might contain subfolder prefix
|
||||
clean_image = image
|
||||
actual_subfolder = subfolder
|
||||
|
||||
# If image contains a path separator, extract the subfolder and filename
|
||||
if '/' in image:
|
||||
parts = image.split('/')
|
||||
if len(parts) == 2:
|
||||
potential_subfolder, clean_image = parts
|
||||
# Use the subfolder from the image path if no subfolder is explicitly set
|
||||
if not actual_subfolder:
|
||||
actual_subfolder = potential_subfolder
|
||||
|
||||
# Build the full path
|
||||
if actual_subfolder:
|
||||
file_path = os.path.join(input_dir, actual_subfolder, clean_image)
|
||||
else:
|
||||
file_path = os.path.join(input_dir, clean_image)
|
||||
|
||||
# Validate path is safe
|
||||
file_path_abs = os.path.abspath(file_path)
|
||||
input_dir_abs = os.path.abspath(input_dir)
|
||||
if not file_path_abs.startswith(input_dir_abs):
|
||||
raise ValueError("Invalid file path: outside input directory")
|
||||
|
||||
# Check file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"Image file not found: {file_path}")
|
||||
|
||||
# Load and process image
|
||||
image_tensor, mask_tensor = self.process_image(file_path, load_mask)
|
||||
|
||||
# Get image dimensions
|
||||
height, width = image_tensor.shape[1:3]
|
||||
|
||||
# Return just the clean filename, not the full path
|
||||
return (image_tensor, mask_tensor, clean_image, width, height)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading image '{image}' from subfolder '{subfolder}': {str(e)}")
|
||||
# Return empty tensors on error
|
||||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
empty_mask = torch.zeros((1, 512, 512), dtype=torch.float32)
|
||||
return (empty_image, empty_mask, "error", 512, 512)
|
||||
|
||||
def process_image(self, file_path: str, load_mask: bool = True) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Process image file into tensors."""
|
||||
with Image.open(file_path) as img:
|
||||
# Store original mode
|
||||
original_mode = img.mode
|
||||
mask_array = None
|
||||
|
||||
# Handle different image modes
|
||||
if img.mode == 'RGBA' and load_mask:
|
||||
# Extract alpha channel as mask before conversion
|
||||
mask_array = np.array(img.getchannel('A'))
|
||||
img = img.convert('RGB')
|
||||
elif img.mode == 'P':
|
||||
# Convert palette images
|
||||
if 'transparency' in img.info:
|
||||
img = img.convert('RGBA')
|
||||
if load_mask:
|
||||
mask_array = np.array(img.getchannel('A'))
|
||||
img = img.convert('RGB')
|
||||
else:
|
||||
img = img.convert('RGB')
|
||||
elif img.mode == 'L':
|
||||
# Convert grayscale to RGB
|
||||
img = img.convert('RGB')
|
||||
elif img.mode not in ['RGB']:
|
||||
# Convert any other mode to RGB
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Convert to numpy array
|
||||
image_array = np.array(img, dtype=np.float32) / 255.0
|
||||
|
||||
# Ensure we have 3 channels
|
||||
if len(image_array.shape) == 2:
|
||||
image_array = np.stack([image_array] * 3, axis=-1)
|
||||
|
||||
# Convert to tensor
|
||||
image_tensor = torch.from_numpy(image_array)
|
||||
|
||||
# Add batch dimension
|
||||
image_tensor = image_tensor.unsqueeze(0)
|
||||
|
||||
# Create mask tensor
|
||||
if load_mask and mask_array is not None:
|
||||
mask_tensor = torch.from_numpy(mask_array.astype(np.float32) / 255.0)
|
||||
mask_tensor = mask_tensor.unsqueeze(0)
|
||||
else:
|
||||
# Create default mask (all opaque)
|
||||
h, w = image_tensor.shape[1:3]
|
||||
mask_tensor = torch.ones((1, h, w), dtype=torch.float32)
|
||||
|
||||
return image_tensor, mask_tensor
|
||||
49
utils/file_helpers.py
Normal file
49
utils/file_helpers.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# utils/file_helpers.py
|
||||
import os
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
import logging
|
||||
|
||||
class FileCache:
|
||||
"""Simple file listing cache to improve performance."""
|
||||
|
||||
def __init__(self, cache_timeout: int = 5):
|
||||
self.cache_timeout = cache_timeout
|
||||
self._cache: Dict[str, List[str]] = {}
|
||||
self._timestamps: Dict[str, float] = {}
|
||||
|
||||
def get_files(self, directory: str, force_refresh: bool = False) -> List[str]:
|
||||
"""Get cached file listing or refresh if needed."""
|
||||
current_time = time.time()
|
||||
|
||||
if (not force_refresh and
|
||||
directory in self._timestamps and
|
||||
current_time - self._timestamps[directory] < self.cache_timeout):
|
||||
return self._cache.get(directory, [])
|
||||
|
||||
# Refresh cache
|
||||
files = self._scan_directory(directory)
|
||||
self._cache[directory] = files
|
||||
self._timestamps[directory] = current_time
|
||||
|
||||
return files
|
||||
|
||||
def _scan_directory(self, directory: str) -> List[str]:
|
||||
"""Scan directory for image files."""
|
||||
if not os.path.exists(directory):
|
||||
return []
|
||||
|
||||
valid_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif'}
|
||||
files = []
|
||||
|
||||
try:
|
||||
for file in os.listdir(directory):
|
||||
if os.path.splitext(file.lower())[1] in valid_extensions:
|
||||
files.append(file)
|
||||
except PermissionError:
|
||||
logging.warning(f"Permission denied accessing {directory}")
|
||||
|
||||
return sorted(files)
|
||||
|
||||
# Global cache instance
|
||||
file_cache = FileCache()
|
||||
150
web/js/subfolder_image_loader.js
Normal file
150
web/js/subfolder_image_loader.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
// Extension for SubfolderImageLoader dynamic input filtering
|
||||
app.registerExtension({
|
||||
name: "comfyui.SubfolderImageLoader",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "SubfolderImageLoader") {
|
||||
const originalNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = originalNodeCreated?.apply(this, arguments);
|
||||
|
||||
// Setup widget callbacks after node is fully created
|
||||
setTimeout(() => {
|
||||
const subfolderWidget = this.widgets?.find(w => w.name === "subfolder");
|
||||
|
||||
if (subfolderWidget) {
|
||||
const originalCallback = subfolderWidget.callback;
|
||||
|
||||
subfolderWidget.callback = (value, ...args) => {
|
||||
if (originalCallback) {
|
||||
originalCallback.call(subfolderWidget, value, ...args);
|
||||
}
|
||||
|
||||
this.updateImageList(value);
|
||||
};
|
||||
|
||||
// Also run initial filtering based on current subfolder value
|
||||
const currentSubfolder = subfolderWidget.value || "";
|
||||
this.updateImageList(currentSubfolder);
|
||||
|
||||
} else {
|
||||
console.error("SubfolderImageLoader: Could not find subfolder widget!");
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Method to update image list based on selected subfolder
|
||||
nodeType.prototype.updateImageList = async function(selectedSubfolder) {
|
||||
try {
|
||||
// Get the image widget
|
||||
const imageWidget = this.widgets?.find(w => w.name === "image");
|
||||
if (!imageWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch updated image list from the backend
|
||||
const response = await fetch("/subfolder_loader/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
node_id: this.id,
|
||||
subfolder: selectedSubfolder || ""
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("SubfolderImageLoader: Failed to refresh image list:", response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
console.error("SubfolderImageLoader: Server error:", data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the pre-filtered images from the server
|
||||
const filteredImages = data.filtered_images || [];
|
||||
|
||||
// Remove and recreate the image widget
|
||||
const imageIndex = this.widgets.findIndex(w => w.name === "image");
|
||||
if (imageIndex >= 0) {
|
||||
// Store the old widget's callback for reference
|
||||
const oldWidget = this.widgets[imageIndex];
|
||||
const oldCallback = oldWidget.callback;
|
||||
|
||||
// Remove the old widget
|
||||
this.widgets.splice(imageIndex, 1);
|
||||
|
||||
// Determine the new value - always use first image from filtered list
|
||||
const newValue = filteredImages.length > 0 ? filteredImages[0] : "";
|
||||
|
||||
// Create new widget with updated options
|
||||
const newWidget = this.addWidget(
|
||||
"combo",
|
||||
"image",
|
||||
newValue,
|
||||
(value) => {
|
||||
// Call original callback if it existed
|
||||
if (oldCallback) {
|
||||
oldCallback.call(newWidget, value);
|
||||
}
|
||||
},
|
||||
{
|
||||
values: filteredImages.length > 0 ? filteredImages : [""]
|
||||
}
|
||||
);
|
||||
|
||||
// Move the new widget to the correct position
|
||||
if (imageIndex < this.widgets.length - 1) {
|
||||
const widget = this.widgets.pop();
|
||||
this.widgets.splice(imageIndex, 0, widget);
|
||||
}
|
||||
|
||||
// Force update the node's properties
|
||||
this.setProperty("image", newValue);
|
||||
|
||||
// Trigger the widget callback to ensure the change is registered
|
||||
if (newWidget.callback) {
|
||||
newWidget.callback(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Force UI update
|
||||
// Note: ComfyUI has a quirk where combo widget visual updates sometimes require
|
||||
// mouse movement to refresh the display. The functionality works correctly,
|
||||
// but the visual update may be delayed until the next mouse interaction.
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
} catch (error) {
|
||||
console.error("SubfolderImageLoader: Error updating image list:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Override the original getExtraMenuOptions to add refresh option
|
||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
const result = originalGetExtraMenuOptions?.apply(this, arguments) || [];
|
||||
|
||||
options.push({
|
||||
content: "🔄 Refresh Files",
|
||||
callback: () => {
|
||||
const subfolderWidget = this.widgets?.find(w => w.name === "subfolder");
|
||||
const currentSubfolder = subfolderWidget?.value || "";
|
||||
this.updateImageList(currentSubfolder);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user