From fe290b76ea93bbf37df9e4a9fa9119cb254502df Mon Sep 17 00:00:00 2001 From: rdomunky Date: Sat, 19 Jul 2025 10:08:50 -0700 Subject: [PATCH] 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 --- .gitignore | 37 ++++ LICENSE | 21 ++ README.md | 100 +++++++++ __init__.py | 18 ++ fixsubfolder.patch | 91 ++++++++ subfolder_loader.py | 362 +++++++++++++++++++++++++++++++ utils/file_helpers.py | 49 +++++ web/js/subfolder_image_loader.js | 150 +++++++++++++ 8 files changed, 828 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 fixsubfolder.patch create mode 100644 subfolder_loader.py create mode 100644 utils/file_helpers.py create mode 100644 web/js/subfolder_image_loader.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b71fc67 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce1a290 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9edcb56 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3be45c6 --- /dev/null +++ b/__init__.py @@ -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'] diff --git a/fixsubfolder.patch b/fixsubfolder.patch new file mode 100644 index 0000000..2a21f45 --- /dev/null +++ b/fixsubfolder.patch @@ -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'); + } + }, diff --git a/subfolder_loader.py b/subfolder_loader.py new file mode 100644 index 0000000..9d9c876 --- /dev/null +++ b/subfolder_loader.py @@ -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 diff --git a/utils/file_helpers.py b/utils/file_helpers.py new file mode 100644 index 0000000..e0757b9 --- /dev/null +++ b/utils/file_helpers.py @@ -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() diff --git a/web/js/subfolder_image_loader.js b/web/js/subfolder_image_loader.js new file mode 100644 index 0000000..475c67d --- /dev/null +++ b/web/js/subfolder_image_loader.js @@ -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; + }; + } + } +}); \ No newline at end of file