├── .github └── workflows │ └── publish.yml ├── README.md ├── __init__.py ├── js └── LoadImageGallery.js ├── pyproject.toml └── requirements.txt /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - "pyproject.toml" 10 | 11 | # permissions: 12 | # issues: write 13 | 14 | jobs: 15 | publish-node: 16 | name: Publish Custom Node to registry 17 | runs-on: ubuntu-latest 18 | if: github.event.repository.fork == false 19 | # if: ${{ github.repository_owner == 'OgreLemonSoup' }} 20 | steps: 21 | - name: Check out code 22 | uses: actions/checkout@v4 23 | - name: Publish Custom Node 24 | uses: Comfy-Org/publish-node-action@main 25 | with: 26 | ## Add your own personal access token to your Github Repository secrets and reference it here. 27 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gallery&Tabs 2 | 3 | ## Features 4 | - Image Gallery 5 | - Tabs for Subfolders and Symlink 6 | 7 | ![4602](https://github.com/user-attachments/assets/a13c771c-81fb-46c8-8d22-66c80d2d370b) 8 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import base64 3 | from PIL import Image 4 | from server import PromptServer 5 | from aiohttp import web 6 | import folder_paths 7 | from nodes import LoadImage 8 | 9 | try: 10 | from nodes import LoadImageMask 11 | HAS_LOAD_IMAGE_MASK = True 12 | except ImportError: 13 | HAS_LOAD_IMAGE_MASK = False 14 | 15 | try: 16 | from nodes import LoadImageOutput 17 | HAS_LOAD_IMAGE_OUTPUT = True 18 | except ImportError: 19 | HAS_LOAD_IMAGE_OUTPUT = False 20 | 21 | # Save the original INPUT_TYPES method 22 | original_input_types = { 23 | "LoadImage": LoadImage.INPUT_TYPES 24 | } 25 | 26 | if HAS_LOAD_IMAGE_MASK: 27 | original_input_types["LoadImageMask"] = LoadImageMask.INPUT_TYPES 28 | 29 | if HAS_LOAD_IMAGE_OUTPUT: 30 | original_input_types["LoadImageOutput"] = LoadImageOutput.INPUT_TYPES 31 | 32 | # Path to the thumbnails directory 33 | THUMBNAILS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "thumbnails") 34 | if not os.path.exists(THUMBNAILS_DIR): 35 | os.makedirs(THUMBNAILS_DIR) 36 | 37 | # Get safe filename for thumbnail 38 | def get_thumbnail_path(filename): 39 | safe_filename = filename.replace(os.sep, "__").replace("/", "__").replace("\\", "__").replace(" ", "_") 40 | return os.path.join(THUMBNAILS_DIR, f"{safe_filename}.webp") 41 | 42 | # Create thumbnail from image file 43 | def create_thumbnail(file_path, size=(80, 80)): 44 | try: 45 | img = Image.open(file_path) 46 | 47 | # Calculate aspect ratio 48 | width, height = img.size 49 | aspect_ratio = width / height 50 | 51 | # Crop to square from center 52 | if aspect_ratio > 1: 53 | new_width = height 54 | left = (width - new_width) // 2 55 | img = img.crop((left, 0, left + new_width, height)) 56 | else: 57 | new_height = width 58 | top = (height - new_height) // 2 59 | img = img.crop((0, top, width, top + new_height)) 60 | 61 | # Resize to thumbnail size 62 | img = img.resize(size, Image.LANCZOS) 63 | 64 | # Save as WebP 65 | thumbnail_path = get_thumbnail_path(file_path.replace(folder_paths.get_input_directory() + os.sep, "")) 66 | img.save(thumbnail_path, "WEBP", quality=80) 67 | 68 | return thumbnail_path 69 | except Exception as e: 70 | print(f"Error creating thumbnail for {file_path}: {str(e)}") 71 | return None 72 | 73 | def get_enhanced_files(): 74 | input_dir = folder_paths.get_input_directory() 75 | exclude_folders = ["clipspace", "3d"] 76 | additional_files = [] 77 | 78 | for root, dirs, files in os.walk(input_dir, followlinks=True): 79 | if root == input_dir: 80 | continue 81 | rel_path = os.path.relpath(root, input_dir) 82 | parts = rel_path.split(os.sep) 83 | 84 | if any(part in exclude_folders for part in parts): 85 | continue 86 | dirs[:] = [d for d in dirs if d not in exclude_folders] 87 | 88 | for file in files: 89 | if not folder_paths.filter_files_content_types(files, ["image"]): 90 | continue 91 | 92 | file_path = os.path.join(root, file) 93 | rel_file_path = os.path.relpath(file_path, input_dir) 94 | 95 | additional_files.append(rel_file_path) 96 | 97 | thumbnail_path = get_thumbnail_path(rel_file_path) 98 | if not os.path.exists(thumbnail_path): 99 | create_thumbnail(file_path) 100 | return sorted(additional_files) 101 | 102 | @classmethod 103 | def enhanced_load_image_input_types(cls): 104 | original_result = original_input_types["LoadImage"]() 105 | original_files = original_result["required"]["image"][0] 106 | additional_files = get_enhanced_files() 107 | 108 | combined_files = original_files + additional_files 109 | original_result["required"]["image"] = (sorted(combined_files), original_result["required"]["image"][1]) 110 | return original_result 111 | 112 | LoadImage.INPUT_TYPES = enhanced_load_image_input_types 113 | 114 | if HAS_LOAD_IMAGE_MASK: 115 | @classmethod 116 | def enhanced_load_image_mask_input_types(cls): 117 | original_result = original_input_types["LoadImageMask"]() 118 | if "required" in original_result and "image" in original_result["required"]: 119 | param_name = "image" 120 | elif "required" in original_result and "mask" in original_result["required"]: 121 | param_name = "mask" 122 | else: 123 | return original_result 124 | 125 | original_files = original_result["required"][param_name][0] 126 | additional_files = get_enhanced_files() 127 | 128 | if isinstance(original_files, list): 129 | combined_files = original_files + additional_files 130 | original_result["required"][param_name] = (sorted(combined_files), original_result["required"][param_name][1]) 131 | 132 | return original_result 133 | 134 | LoadImageMask.INPUT_TYPES = enhanced_load_image_mask_input_types 135 | 136 | if HAS_LOAD_IMAGE_OUTPUT: 137 | @classmethod 138 | def enhanced_load_image_output_input_types(cls): 139 | original_result = original_input_types["LoadImageOutput"]() 140 | if "required" in original_result and "image" in original_result["required"]: 141 | param_name = "image" 142 | else: 143 | return original_result 144 | 145 | original_files = original_result["required"][param_name][0] 146 | additional_files = get_enhanced_files() 147 | 148 | if isinstance(original_files, list): 149 | combined_files = original_files + additional_files 150 | original_result["required"][param_name] = (sorted(combined_files), original_result["required"][param_name][1]) 151 | elif isinstance(original_files, str): 152 | # print(f"Warning: original_files for {param_name} is a string, not a list") 153 | original_result["required"][param_name] = (original_files, original_result["required"][param_name][1]) 154 | 155 | return original_result 156 | 157 | LoadImageOutput.INPUT_TYPES = enhanced_load_image_output_input_types 158 | 159 | 160 | try: 161 | from send2trash import send2trash 162 | USE_SEND2TRASH = True 163 | except ImportError: 164 | USE_SEND2TRASH = False 165 | 166 | @PromptServer.instance.routes.post("/delete_file") 167 | async def delete_file(request): 168 | try: 169 | data = await request.json() 170 | filename = os.path.normpath(data.get('filename')) 171 | if not filename: 172 | return web.Response(status=400, text="Filename not provided") 173 | 174 | input_dir = folder_paths.get_input_directory() 175 | file_path = os.path.join(input_dir, filename) 176 | 177 | if not os.path.exists(file_path): 178 | return web.Response(status=404, text="File not found") 179 | 180 | thumbnail_path = get_thumbnail_path(filename) 181 | if os.path.exists(thumbnail_path): 182 | os.remove(thumbnail_path) 183 | 184 | if USE_SEND2TRASH: 185 | send2trash(file_path) 186 | message = "File moved to trash successfully" 187 | else: 188 | os.remove(file_path) 189 | message = "File deleted successfully" 190 | 191 | return web.Response(status=200, text=message) 192 | except Exception as e: 193 | print(f"Error deleting file: {str(e)}") 194 | return web.Response(status=500, text="Internal server error") 195 | 196 | @PromptServer.instance.routes.get("/get_thumbnail/{filename:.*}") 197 | async def get_thumbnail(request): 198 | try: 199 | filename = request.match_info['filename'] 200 | thumbnail_path = get_thumbnail_path(filename) 201 | 202 | if not os.path.exists(thumbnail_path): 203 | input_dir = folder_paths.get_input_directory() 204 | file_path = os.path.join(input_dir, filename) 205 | 206 | if os.path.exists(file_path): 207 | thumbnail_path = create_thumbnail(file_path) 208 | if not thumbnail_path: 209 | return web.Response(status=404, text="Failed to create thumbnail") 210 | else: 211 | return web.Response(status=404, text="Image file not found") 212 | 213 | return web.FileResponse(thumbnail_path) 214 | except Exception as e: 215 | print(f"Error getting thumbnail: {str(e)}") 216 | return web.Response(status=500, text="Internal server error") 217 | 218 | @PromptServer.instance.routes.post("/get_thumbnails_batch") 219 | async def get_thumbnails_batch(request): 220 | try: 221 | data = await request.json() 222 | filenames = data.get('filenames', []) 223 | 224 | if not filenames: 225 | return web.Response(status=400, text="No filenames provided") 226 | 227 | result = {} 228 | input_dir = folder_paths.get_input_directory() 229 | 230 | for filename in filenames: 231 | thumbnail_path = get_thumbnail_path(filename) 232 | if not os.path.exists(thumbnail_path): 233 | file_path = os.path.join(input_dir, filename) 234 | if os.path.exists(file_path): 235 | thumbnail_path = create_thumbnail(file_path) 236 | 237 | if os.path.exists(thumbnail_path): 238 | with open(thumbnail_path, "rb") as f: 239 | file_content = f.read() 240 | base64_data = base64.b64encode(file_content).decode('utf-8') 241 | result[filename] = f"data:image/webp;base64,{base64_data}" 242 | 243 | return web.json_response(result) 244 | except Exception as e: 245 | print(f"Error getting thumbnails batch: {str(e)}") 246 | return web.Response(status=500, text="Internal server error") 247 | 248 | @PromptServer.instance.routes.post("/cleanup_thumbnails") 249 | async def cleanup_thumbnails(request): 250 | try: 251 | data = await request.json() 252 | active_files = data.get('active_files', []) 253 | 254 | if not active_files: 255 | return web.Response(status=400, text="No active files provided") 256 | 257 | thumbnails = [f for f in os.listdir(THUMBNAILS_DIR) if f.endswith('.webp')] 258 | removed_count = 0 259 | 260 | active_thumbnails = [get_thumbnail_path(f).split(os.sep)[-1] for f in active_files] 261 | 262 | for thumbnail in thumbnails: 263 | if thumbnail not in active_thumbnails: 264 | os.remove(os.path.join(THUMBNAILS_DIR, thumbnail)) 265 | removed_count += 1 266 | 267 | return web.Response(status=200, text=f"Removed {removed_count} stale thumbnails") 268 | except Exception as e: 269 | print(f"Error cleaning up thumbnails: {str(e)}") 270 | return web.Response(status=500, text="Internal server error") 271 | 272 | @PromptServer.instance.routes.get("/check_thumbnails_service") 273 | async def check_thumbnails_service(request): 274 | try: 275 | if os.path.exists(THUMBNAILS_DIR): 276 | return web.Response(status=200, text="Thumbnails service is available") 277 | else: 278 | try: 279 | os.makedirs(THUMBNAILS_DIR) 280 | return web.Response(status=200, text="Thumbnails directory created") 281 | except: 282 | return web.Response(status=500, text="Could not create thumbnails directory") 283 | except Exception as e: 284 | print(f"Error checking thumbnails service: {str(e)}") 285 | return web.Response(status=500, text="Internal server error") 286 | 287 | NODE_CLASS_MAPPINGS = {} 288 | WEB_DIRECTORY = "./js" 289 | __all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY'] -------------------------------------------------------------------------------- /js/LoadImageGallery.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | let thumbnailCache = new Map(); 3 | // Adds a gallery to the Load Image node and tabs for Load Checkpoint/Lora/etc Nodes 4 | 5 | const ext = { 6 | name: "Comfy.LoadImageGallery", 7 | async init() { 8 | const ctxMenu = LiteGraph.ContextMenu; 9 | const style = document.createElement('style'); 10 | style.textContent = ` 11 | .comfy-context-menu-filter { 12 | grid-column: 1 / -1; 13 | } 14 | .tabs { 15 | grid-column: 1 / -1; 16 | display: flex; 17 | flex-wrap: wrap; 18 | width: auto; 19 | } 20 | .image-entry { 21 | width: 80px; 22 | height: 80px; 23 | background-size: cover; 24 | background-position: center; 25 | border-radius: 4px; 26 | display: flex; 27 | align-items: center; 28 | justify-content: center; 29 | overflow: hidden; 30 | font-size: 0!important; 31 | position: relative; 32 | } 33 | .delete-button { 34 | position: absolute; 35 | top: 2px; 36 | right: 2px; 37 | width: 20px; 38 | height: 20px; 39 | background-color: rgba(255, 0, 0, 0.7); 40 | color: white; 41 | border-radius: 50%; 42 | display: flex; 43 | justify-content: center; 44 | cursor: pointer; 45 | font-size: 14px !important; 46 | } 47 | .tab-button { 48 | position: absolute; 49 | top: 2px; 50 | left: 2px; 51 | width: 20px; 52 | height: 20px; 53 | background-color: rgba(0, 100, 255, 0.7); 54 | color: white; 55 | border-radius: 50%; 56 | display: flex; 57 | justify-content: center; 58 | cursor: pointer; 59 | font-size: 14px !important; 60 | } 61 | .tab { 62 | padding: 5px 10px; 63 | margin-right: 5px; 64 | background-color: transparent; 65 | border: none; 66 | cursor: pointer; 67 | } 68 | .tab:last-child { 69 | margin-right: 0; 70 | } 71 | .tab.active { 72 | border-bottom: 3px solid #64b5f6; 73 | } 74 | `; 75 | document.head.append(style); 76 | let FirstRun = true; 77 | 78 | async function preloadThumbnailsBatch(filenames) { 79 | if (!window.thumbnailCache) { 80 | window.thumbnailCache = new Map(); 81 | } 82 | 83 | try { 84 | const response = await fetch('/get_thumbnails_batch', { 85 | method: 'POST', 86 | headers: { 87 | 'Content-Type': 'application/json', 88 | }, 89 | body: JSON.stringify({ filenames }), 90 | }); 91 | 92 | if (response.ok) { 93 | const data = await response.json(); 94 | for (const [filename, dataUrl] of Object.entries(data)) { 95 | window.thumbnailCache.set(filename, dataUrl); 96 | } 97 | console.log(`Preloaded ${Object.keys(data).length} thumbnails`); 98 | } 99 | } catch (error) { 100 | console.error("Error preloading thumbnails batch:", error); 101 | } 102 | } 103 | 104 | 105 | // Clean up stale thumbnails 106 | function CleanDB(values) { 107 | fetch('/cleanup_thumbnails', { 108 | method: 'POST', 109 | headers: { 110 | 'Content-Type': 'application/json', 111 | }, 112 | body: JSON.stringify({ active_files: values }), 113 | }) 114 | .then(response => { 115 | if (response.ok) { 116 | console.log("Cleaned up stale thumbnails"); 117 | } else { 118 | console.error("Failed to clean up thumbnails"); 119 | } 120 | }) 121 | .catch(error => { 122 | console.error("Error during thumbnails cleanup:", error); 123 | }); 124 | 125 | FirstRun = false; 126 | } 127 | 128 | // Delete file and its thumbnail 129 | async function deleteFile(filename) { 130 | try { 131 | const response = await fetch('/delete_file', { 132 | method: 'POST', 133 | headers: { 134 | 'Content-Type': 'application/json', 135 | }, 136 | body: JSON.stringify({ filename }), 137 | }); 138 | if (response.ok) { 139 | console.log(`File ${filename} deleted successfully`); 140 | 141 | return true; 142 | } else { 143 | console.error(`Failed to delete file ${filename}`); 144 | return false; 145 | } 146 | } catch (error) { 147 | console.error('Error deleting file:', error); 148 | return false; 149 | } 150 | } 151 | 152 | // Get thumbnail from server 153 | async function getThumbnail(filename) { 154 | // Check cache first 155 | if (window.thumbnailCache && window.thumbnailCache.has(filename)) { 156 | return window.thumbnailCache.get(filename); 157 | } 158 | 159 | try { 160 | // Check if thumbnail exists on server 161 | const response = await fetch(`/get_thumbnail/${encodeURIComponent(filename)}`); 162 | if (response.ok) { 163 | const blob = await response.blob(); 164 | const url = URL.createObjectURL(blob); 165 | 166 | // Store in cache 167 | if (!window.thumbnailCache) { 168 | window.thumbnailCache = new Map(); 169 | } 170 | window.thumbnailCache.set(filename, url); 171 | 172 | return url; 173 | } 174 | return null; 175 | } catch (error) { 176 | console.error("Error fetching thumbnail:", error); 177 | return null; 178 | } 179 | } 180 | 181 | 182 | // Check if thumbnails service is available 183 | async function checkThumbnailsService() { 184 | try { 185 | const response = await fetch('/check_thumbnails_service'); 186 | return response.ok; 187 | } catch (error) { 188 | console.error("Thumbnails service unavailable:", error); 189 | return false; 190 | } 191 | } 192 | 193 | // Initialize thumbnails service 194 | const thumbnailsServiceAvailable = await checkThumbnailsService(); 195 | if (!thumbnailsServiceAvailable) { 196 | console.warn("Thumbnails service is not available. Some features may not work properly."); 197 | } 198 | 199 | 200 | LiteGraph.ContextMenu = function (values, options) { 201 | const ctx = ctxMenu.call(this, values, options); 202 | if (options?.className === "dark" && values?.length > 0) { 203 | const items = Array.from(ctx.root.querySelectorAll(".litemenu-entry")); 204 | let displayedItems = [...items]; 205 | 206 | function UpdatePosition() { 207 | let top = options.event.clientY - 10; 208 | const bodyRect = document.body.getBoundingClientRect(); 209 | const rootRect = ctx.root.getBoundingClientRect(); 210 | if (bodyRect.height && top > bodyRect.height - rootRect.height - 10) { 211 | top = Math.max(0, bodyRect.height - rootRect.height - 10); 212 | } 213 | ctx.root.style.top = top + "px"; 214 | } 215 | 216 | requestAnimationFrame(() => { 217 | const currentNode = LGraphCanvas.active_canvas.current_node; 218 | const clickedComboValue = currentNode.widgets?.filter( 219 | (w) => w.type === "combo" && w.options.values.length === values.length 220 | ).find( 221 | (w) => w.options.values.every((v, i) => v === values[i]) 222 | )?.value; 223 | let selectedIndex = clickedComboValue ? values.findIndex((v) => v === clickedComboValue) : 0; 224 | if (selectedIndex < 0) { 225 | selectedIndex = 0; 226 | } 227 | 228 | const selectedItem = displayedItems[selectedIndex]; 229 | let valuesnames; 230 | let rgthreeon = false; 231 | if ( 232 | typeof values[values.length - 1]?.rgthree_originalValue === 'string' && 233 | values[values.length - 1].rgthree_originalValue.trim() !== '' 234 | ) { 235 | valuesnames = values.map(item => 236 | typeof item?.rgthree_originalValue === 'string' && item.rgthree_originalValue.trim() !== '' 237 | ? item.rgthree_originalValue 238 | : 'rgthreefolder' 239 | ); 240 | rgthreeon = true; 241 | } else { 242 | valuesnames = values; 243 | } 244 | 245 | 246 | //Tabs 247 | if (!rgthreeon) { 248 | const hasBackslash = valuesnames.some(value => value.includes('\\')); 249 | const hasForwardSlash = valuesnames.some(value => value.includes('/')); 250 | 251 | if (hasBackslash || hasForwardSlash) { 252 | const input = ctx.root.querySelector('input'); 253 | const separator = hasBackslash ? '\\' : '/'; 254 | 255 | // Create a data structure for folders and files 256 | const structure = { Root: { files: [] } }; 257 | items.forEach(entry => { 258 | const path = entry.getAttribute('data-value'); 259 | const parts = path.split(separator); 260 | let current = structure; 261 | if (parts.length === 1) { 262 | structure.Root.files.push(entry); 263 | } else { 264 | for (let i = 0; i < parts.length - 1; i++) { 265 | const folder = parts[i]; 266 | if (!current[folder]) current[folder] = { files: [] }; 267 | current = current[folder]; 268 | } 269 | current.files.push(entry); 270 | } 271 | }); 272 | 273 | // Function for creating tabs 274 | function createTabs(container, structure) { 275 | Object.keys(structure).forEach(key => { 276 | if (key === 'files') return; 277 | const tab = document.createElement('button'); 278 | tab.textContent = key; 279 | tab.className = 'tab'; 280 | tab.onclick = () => showGroup(container, key, structure); 281 | if (key === 'Root') { 282 | container.prepend(tab); 283 | } else { 284 | container.appendChild(tab); 285 | } 286 | }); 287 | } 288 | 289 | // Function to display the contents of a folder 290 | function showGroup(container, folder, parent) { 291 | // Removing existing subfolder tabs 292 | const subtabs = container.querySelectorAll('.subtabs'); 293 | subtabs.forEach(subtab => subtab.remove()); 294 | 295 | const current = parent[folder]; 296 | const files = current.files || []; 297 | const subfolders = Object.keys(current).filter(key => key !== 'files'); 298 | 299 | // Hide all files and folders 300 | items.forEach(entry => entry.style.display = 'none'); 301 | 302 | // Display files in the current folder 303 | if (folder === 'Root') { 304 | items.forEach(item => { 305 | const itemPath = item.getAttribute('data-value'); 306 | if (!itemPath.includes(separator)) { 307 | item.style.display = 'block'; 308 | } 309 | }); 310 | } else { 311 | files.forEach(file => file.style.display = 'block'); 312 | } 313 | 314 | // Display tabs for nested folders 315 | if (subfolders.length > 0) { 316 | const subtabsContainer = document.createElement('div'); 317 | subtabsContainer.className = 'subtabs'; 318 | container.appendChild(subtabsContainer); 319 | createTabs(subtabsContainer, current); 320 | 321 | // Display the contents of nested folders 322 | subfolders.forEach(subfolder => { 323 | const subtab = Array.from(subtabsContainer.querySelectorAll('button')).find(tab => tab.textContent === subfolder); 324 | if (subtab) { 325 | subtab.onclick = () => showGroup(subtabsContainer, subfolder, current); 326 | } 327 | }); 328 | } 329 | 330 | // Remove old tabs 331 | container.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active')); 332 | const tabs = container.querySelectorAll('button'); 333 | tabs.forEach(tab => { 334 | if (tab.textContent === folder) { 335 | tab.classList.add('active'); 336 | } 337 | }); 338 | } 339 | 340 | // Creating a Container for Tabs 341 | const tabsContainer = document.createElement('div'); 342 | tabsContainer.className = 'tabs'; 343 | input.insertAdjacentElement('afterend', tabsContainer); 344 | 345 | createTabs(tabsContainer, structure); 346 | 347 | // Select the active tab 348 | const selectedPath = selectedItem.getAttribute('data-value').split(separator); 349 | const selectedFolders = selectedPath.slice(0, -1); 350 | 351 | if (selectedFolders.length === 0) { 352 | showGroup(tabsContainer, 'Root', structure); 353 | } else { 354 | let currentContainer = tabsContainer; 355 | let currentParent = structure; 356 | 357 | selectedFolders.forEach((folder, index) => { 358 | showGroup(currentContainer, folder, currentParent); 359 | 360 | const subtabs = currentContainer.querySelectorAll('.subtabs'); 361 | currentContainer = subtabs[subtabs.length - 1]; 362 | currentParent = currentParent[folder]; 363 | 364 | if (index < selectedFolders.length - 1) { 365 | const nextFolder = selectedFolders[index + 1]; 366 | const tabs = currentContainer.querySelectorAll('button'); 367 | tabs.forEach(tab => { 368 | if (tab.textContent === nextFolder) { 369 | tab.classList.add('active'); 370 | } 371 | }); 372 | } 373 | }); 374 | } 375 | 376 | UpdatePosition(); 377 | } 378 | } else { 379 | const input = ctx.root.querySelector('input'); 380 | const tabsContainer = document.createElement('div'); 381 | tabsContainer.className = 'tabs'; 382 | input.insertAdjacentElement('afterend', tabsContainer); 383 | } 384 | 385 | //Gallery 386 | if (valuesnames.length > 0 && currentNode.type.startsWith("LoadImage")) { 387 | const isChannelList = currentNode.type === "LoadImageMask" && 388 | valuesnames.some(v => ["alpha", "red", "green", "blue"].includes(v)); 389 | if (!isChannelList) { 390 | if (FirstRun) { 391 | CleanDB(valuesnames); 392 | } 393 | if (displayedItems.length > 30) { 394 | UpdatePosition(); 395 | } 396 | options.scroll_speed = 0.5; 397 | ctx.root.style.display = 'grid'; 398 | ctx.root.style.gridTemplateColumns = 'repeat(auto-fit, minmax(88px, 1fr))'; 399 | ctx.root.style.maxWidth = "880px"; 400 | const tabsContainer = ctx.root.querySelector('.tabs'); 401 | if (tabsContainer) { 402 | const tabsWidth = Array.from(tabsContainer.children) 403 | .reduce((width, tab) => width + tab.offsetWidth, 0); 404 | 405 | const cellWidth = 88; 406 | const minCells = 4; 407 | const maxCells = 10; 408 | 409 | const requiredCells = Math.ceil(tabsWidth / cellWidth); 410 | 411 | const finalCells = Math.max(minCells, Math.min(requiredCells, maxCells)); 412 | 413 | ctx.root.style.gridTemplateColumns = `repeat(${finalCells}, ${cellWidth}px)`; 414 | } 415 | items.forEach((entry, index) => { 416 | const filename = valuesnames[index]; 417 | if (filename !== "rgthreefolder") { 418 | entry.classList.add('image-entry'); 419 | entry.setAttribute('title', filename); 420 | } 421 | }); 422 | // Preload all thumbnails at once 423 | preloadThumbnailsBatch(valuesnames).then(() => { 424 | 425 | items.forEach((entry, index) => { 426 | const filename = valuesnames[index]; 427 | if (filename !== "rgthreefolder") { 428 | 429 | // Use cached thumbnail or load a new one 430 | let thumbnailUrl = window.thumbnailCache.get(filename); 431 | if (!thumbnailUrl) { 432 | thumbnailUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAACXBIWXMAAAsTAAALEwEAmpwYAAAE7mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDIgNzkuZjM1NGVmYywgMjAyMy8xMS8wOS0xMjo0MDoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI1LjUgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyNS0wNS0yMVQxODoyMjozNyswMzowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjUtMDUtMjFUMTg6MjI6NTkrMDM6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjUtMDUtMjFUMTg6MjI6NTkrMDM6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjkyNmFjZTg2LTM0ZDUtMWM0OS05ZTkyLTg3NDQ1ZGQ3ZWQ5NSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo5MjZhY2U4Ni0zNGQ1LTFjNDktOWU5Mi04NzQ0NWRkN2VkOTUiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo5MjZhY2U4Ni0zNGQ1LTFjNDktOWU5Mi04NzQ0NWRkN2VkOTUiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjkyNmFjZTg2LTM0ZDUtMWM0OS05ZTkyLTg3NDQ1ZGQ3ZWQ5NSIgc3RFdnQ6d2hlbj0iMjAyNS0wNS0yMVQxODoyMjozNyswMzowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI1LjUgKFdpbmRvd3MpIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pjdh6DQAAAPZSURBVHic7dtNaBx1GMfx3zM7LwtCtVAUteJJUJBQBaFgLahQ0EjcF/FkpQFbRMSbFnopyUUQQREKNuCp6GXd+cewoQq+1RfwpBehCDkILSISEHyJyU47Py+JLHVtsvPM7D+Lz+e2szt/Hr6ZmZ3dJEISprjA9wCTzgIqWUAlC6hkAZUsoJIFVLKAShZQyQIqWUAlC6hkAZUsoJIFVLKAShZQyQIqWUAlC6hkAZUsoJIFVArLXvBKqzVN4CxEbi97baXLkucnQufOl7lo6UcgRd7ehfEAYD+D4GzZi1ZxCu+vYM2y3FH2gnYNVLKAShZQqfR34VGQ7AP4bvPhfSIS+5ynCH9HILlC8kDi3MHEuYMkD4Bc8TZPQd4CShAcry8uXtx6XF9cvChBcNzXPEV5CUhgI1xd/era7WEQfEly3cdMRXkJKECCvXtv/NcTWXYTRBIPIxXm7RTuB8HLw7YJID7mKcrbu7AAJ/ut1s0i8i4AMM+fAXDM1zxFeb2NATBLchYAIBN14P3DbqSVLKDSRAcksEbyZ5K5rxkmNeD3Qh6Ja7U9iXO3xll2C4DTmx8Nx8r3m8jISH4Wh+GMdDp/bG2TXm8VwHzWbn+R5/kHIrJnXPNM1BFI4HwchtOD8QZF3e7nAB4m8Mu4ZhprQAK/k5yDyOG8Vrs7B5oELuxsZ3bjWq0hnc5f13tZ4ty3BA4B+LGEkbclZf+zYdZuD1+QXMnD8Il6p/PDtU/1m82XCLwhIsN/oOS5KAxnpdO5utM5/pyZuS0Kw48A3Du4Pep2S73hHMsRSOCbCHhwWDwAiJ17i8BTQ79IIBeiqaljo8QDgBuWln6KNjYOk/y64Ng7Mo6A78e12iPi3HWvS3XnXABMk/xtaxuBM5Fzz8vcXKHbFFle/jXOsiMESv1V5qBKA5J8NUrTp7e7bm2JnPuU5LOb+84nafqiAKprjPR6a/G+fU+CfE+zzn+pJCDJjMBziXOnRgmw1mrdGYi8DuBk4tzpsuaRhYUsmpo6WtZ6gyoJGIg8nqTpO6Pss95q3RWSFwC8Gafpa2XPVPQysJ1KAkZp+vEor19vNO4JgE8YBPOxc2eqmKkq3j+JbDSb9wNYJvBK0u2e8z3PqLwG7DebDxBYIvBCPU2dz1mKquIUvtxvNA5t96Ks3X6UIr1A5Gjducrj9dvthwBcKnvd0j+JXGk2H2MQLGD3/ZHRJSFPhGn6YZmLlh7w/2aivo3ZjSygkgVUsoBKFlDJAipZQCULqGQBlSygkgVUsoBKFlDJAipZQCULqGQBlSygkgVUsoBKFlDJAipZQKW/ARxMLqI3fOOSAAAAAElFTkSuQmCC'; 433 | } 434 | 435 | entry.style.backgroundImage = `url('${thumbnailUrl}')`; 436 | 437 | // Delete button 438 | const deleteButton = document.createElement('div'); 439 | deleteButton.classList.add('delete-button'); 440 | deleteButton.textContent = '×'; 441 | deleteButton.setAttribute('title', 'Delete'); 442 | deleteButton.addEventListener('click', async (e) => { 443 | e.stopPropagation(); 444 | if (await deleteFile(filename)) { 445 | entry.remove(); 446 | valuesnames.splice(index, 1); 447 | } 448 | }); 449 | entry.appendChild(deleteButton); 450 | } 451 | }); 452 | 453 | }); 454 | } 455 | } 456 | }); 457 | } 458 | 459 | return ctx; 460 | }; 461 | 462 | LiteGraph.ContextMenu.prototype = ctxMenu.prototype; 463 | }, 464 | } 465 | 466 | app.registerExtension(ext); -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-load-image-gallery" 3 | description = "Adds a gallery to the Load Image node and tabs for Load Checkpoint/Lora/etc nodes" 4 | version = "2.1" 5 | # license = {file = "LICENSE"} 6 | 7 | [project.urls] 8 | Repository = "https://github.com/OgreLemonSoup/ComfyUI-Load-Image-Gallery" 9 | # Used by Comfy Registry https://comfyregistry.org 10 | 11 | [tool.comfy] 12 | PublisherId = "ogrelemonsoup" 13 | DisplayName = "ComfyUI-Gallery-and-Tabs" 14 | Icon = "" 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | send2trash --------------------------------------------------------------------------------