/* eslint-disable max-classes-per-file, @typescript-eslint/no-use-before-define */ import './js/sha256'; import { log, debug, error } from './logger'; import { authFetch } from './authWrap'; import { gradioApp, onAfterUiUpdate } from './script'; import { timer } from './timers'; import { monitorOption } from './settings'; import { idbAdd, idbCount, idbFolderCleanup, idbClearAll, idbGet, idbKeys, idbIsReady, } from './indexdb'; let ws; let url; let currentSize = 0; let currentSort = 'none'; let currentName = ''; let currentImage = null; let currentTitle = ''; let currentGalleryFolder = null; let pruneImagesTimer; let outstanding = 0; let gallerySelection = { files: [], index: -1 }; let maintenanceController = new AbortController(); const maxFetchRequests = 32; const fragmentSize = 100; const minCleanupCount = 1000; const minCleanupTime = 1000 * 60 * 60; // 1 hour const folderStylesheet = new CSSStyleSheet(); const fileStylesheet = new CSSStyleSheet(); const iconStopwatch = String.fromCodePoint(9201); // Store separator states for the session const separatorStates = new Map(); const el = { folders: undefined, files: undefined, search: undefined, status: undefined, btnSend: undefined, overlay: undefined, size: undefined, }; const cleanupTimers = {}; const maintenanceTimers = {}; const fetchQueue = []; const SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'tiff', 'jp2', 'jxl', 'gif', 'mp4', 'mkv', 'avi', 'mjpeg', 'mpg', 'avr']; const gallerySorter = { nameA: { name: 'Name Ascending', func: (a, b) => a.name.localeCompare(b.name) }, nameD: { name: 'Name Descending', func: (b, a) => a.name.localeCompare(b.name) }, sizeD: { name: 'Size Ascending', func: (a, b) => a.size - b.size }, sizeA: { name: 'Size Descending', func: (b, a) => a.size - b.size }, resD: { name: 'Resolution Ascending', func: (a, b) => a.width * a.height - b.width * b.height }, resA: { name: 'Resolution Descending', func: (b, a) => a.width * a.height - b.width * b.height }, modD: { name: 'Modified Ascending', func: (a, b) => a.mtime - b.mtime }, modA: { name: 'Modified Descending', func: (b, a) => a.mtime - b.mtime }, none: { name: 'None', func: undefined }, }; let sortMode = gallerySorter.none; async function getHash(str) { let hex = ''; const strBuf = new TextEncoder().encode(str); let hashBuf; if (crypto?.subtle?.digest) { hashBuf = await crypto.subtle.digest('SHA-256', strBuf); } else { const hashResult: any = hash(strBuf); hashBuf = hashResult.buffer; // from sha256.js } const view = new DataView(hashBuf); for (let i = 0; i < hashBuf.byteLength; i += 4) hex += (`00000000${view.getUint32(i).toString(16)}`).slice(-8); return hex; } function getVisibleGalleryFiles() { if (!el.files) return []; return Array.from(el.files.children).filter((node) => node.name && node.offsetParent); } function updateGallerySelectionClasses(files = gallerySelection.files, index = gallerySelection.index) { files.forEach((file, i) => { file.classList.toggle('gallery-file-selected', i === index); }); } function refreshGallerySelection() { updateGallerySelectionClasses(gallerySelection.files, -1); const files = getVisibleGalleryFiles(); const index = files.findIndex((file) => file.src === currentImage); gallerySelection = { files, index }; updateGallerySelectionClasses(files, index); } function resetGallerySelection() { updateGallerySelectionClasses(gallerySelection.files, -1); gallerySelection = { files: [], index: -1 }; currentImage = null; currentName = ''; currentTitle = ''; } function applyGallerySelection(index, { send = true } = {}) { if (!gallerySelection.files.length) refreshGallerySelection(); const { files } = gallerySelection; if (!files.length) return; if (!Number.isInteger(index) || index < 0 || index >= files.length) { log('gallery selection index out of range', index, files.length); resetGallerySelection(); return; } gallerySelection.index = index; currentImage = files[index].src; currentName = files[index].name; currentTitle = files[index].title; updateGallerySelectionClasses(files, index); if (send && el.btnSend) el.btnSend.click(); } function setGallerySelectionByElement(element, options) { if (!gallerySelection.files.length) refreshGallerySelection(); let index = gallerySelection.files.findIndex((file) => file === element); if (index < 0) { refreshGallerySelection(); index = gallerySelection.files.findIndex((file) => file === element); } if (index >= 0) applyGallerySelection(index, options); } function buildGalleryFileUrl(path) { return new URL(`/file=${encodeURI(path)}`, window.location.origin).toString(); } window.getGallerySelection = () => ({ index: gallerySelection.index, files: gallerySelection.files }); window.setGallerySelection = (index, options) => applyGallerySelection(index, options); window.getGallerySelectedUrl = () => (currentImage ? buildGalleryFileUrl(currentImage) : null); /** * Wait for gallery to finish populating * @param {number} expectedSize - Expected gallery size * @param {AbortSignal} signal - AbortController signal */ async function awaitForGallery(expectedSize, signal) { while (Math.max(galleryHashes.size, galleryHashes.fallback) < expectedSize && !signal.aborted) await new Promise((resolve) => { setTimeout(resolve, 500); }); // longer interval because it's a low priority check signal.throwIfAborted(); } function updateGalleryStyles() { if (opts.theme_type?.toLowerCase() === 'modern') { folderStylesheet.replace(` .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; background-color: var(--sd-button-normal-color); border-radius: var(--sd-border-radius); text-align: left; direction: rtl; /* Used to overflow the beginning instead of the end */ min-width: 12em; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition-duration: 0.2s; transition-property: color, opacity, background-color, border-color; transition-timing-function: ease-out; } .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover, var(--sd-button-hover-color)); } .gallery-folder-selected { background-color: var(--sd-button-selected-color); color: var(--sd-button-selected-text-color); } .gallery-folder-icon { font-size: 1.2em; color: var(--sd-button-icon-color); margin-right: 1em; filter: drop-shadow(1px 1px 2px black); float: left; } `); } else { folderStylesheet.replace(` .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; max-width: 200px; overflow-x: hidden; text-wrap: nowrap; text-overflow: ellipsis; } .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } .gallery-folder-selected { background-color: var(--button-primary-background-fill); } `); } const size = el.size ? el.size.value : opts.extra_networks_card_size; fileStylesheet.replace(` .gallery-file { object-fit: contain; cursor: pointer; height: ${size}px; width: ${opts.browser_fixed_width ? `${size}px` : 'unset'}; } .gallery-file:hover { filter: grayscale(100%); } .gallery-overlay { position: absolute; height: 24px; background-color: rgba(0,0,0,0.7); display: block; text-align: right; padding: 4px; font-size: 1.2em; letter-spacing: 0.5em; width: 140px; margin-top: calc(140px - 32px); opacity: 75%; } :host(.gallery-file-selected) .gallery-file { box-shadow: 0 0 0 2px var(--sd-button-selected-color); } `); } // Classes class HashSet extends Set { fallback = 0; constructor(val?: any) { super(val); this.fallback = 0; } override add(value: any): this { ++this.fallback; super.add(value); return this; } override clear(): void { this.fallback = 0; super.clear(); } } const galleryHashes = new HashSet(); class SimpleProgressBar { #container = document.createElement('div'); #progress = document.createElement('div'); #textDiv = document.createElement('div'); #text = document.createElement('span'); #visible = false; #interval: ReturnType | undefined; #max = 0; defaultStats = { queue: 0, fetch: 0, hash: 0, db: 0, cached: 0, fetched: 0, failed: 0, error: 0, callback: 0, elapsed: 0, count: 0 }; stats = { ...this.defaultStats }; /** @type {Set} */ #monitoredSet; constructor(monitoredSet) { this.#monitoredSet = monitoredSet; // This is required because incrementing a variable with a class method turned out to not be an atomic operation this.#container.style.cssText = 'position:relative; overflow:hidden; border-radius:var(--sd-border-radius); width:100%; background-color:hsla(0,0%,36%,0.3); height:1.2rem; margin:0; padding:0; display:none;'; this.#progress.style.cssText = 'position:absolute; left:0; height:100%; width:0; transition:width 200ms;'; this.#progress.style.backgroundColor = 'var(--sd-main-accent-color)'; this.#textDiv.style.cssText = 'position:relative; margin:auto; width:max-content; height:100%;'; this.#text.style.cssText = 'user-select:none; color:white;'; this.#textDiv.append(this.#text); this.#container.append(this.#progress, this.#textDiv); } start(total) { if (total <= 0) return; this.hide(); this.#max = total; this.#interval = setInterval(() => this.update(this.#monitoredSet.size, this.#max), 100); } attachTo(element) { if (element.hasChildNodes) element.innerHTML = ''; element.appendChild(this.#container); } hide() { this.#container.style.display = 'none'; this.#visible = false; this.#progress.style.width = '0'; this.#text.textContent = ''; } update(loaded, max) { this.#progress.style.width = `${Math.floor((loaded / max) * 100)}%`; this.#text.textContent = `${loaded}/${max}`; if (!this.#visible) { this.#container.style.display = 'block'; this.#visible = true; } if (loaded >= max) this.stop(); } stop() { clearInterval(this.#interval); this.#interval = undefined; if (this.stats.count) { debug('gallery: thumbnail stats', this.stats); this.stats = { ...this.defaultStats }; } setTimeout(() => this.hide(), 100); } } const pb = new SimpleProgressBar(galleryHashes); /* This isn't as robust as the Web Locks API, but it will at least work if accessing a remote machine without HTTPS */ class SimpleFunctionQueue { #id; #running; #queue; constructor(id) { this.#id = id; this.#running = false; this.#queue = []; } static abortLogger(identifier, result) { if (typeof result === 'string' || (result instanceof DOMException && result.name === 'AbortError')) { log(identifier, typeof result === 'object' && result !== null ? (result as any).message || result : result); } else { error(identifier, result.message); } } /** * @param {{ * signal: AbortSignal, * callback: Function * }} config */ enqueue(config) { if (!(config.signal instanceof AbortSignal) || typeof config.callback !== 'function') { throw new Error('Invalid configuration. Object must contain an AbortSignal and a function'); } if (config.signal.aborted) { debug(`${this.#id} Queue: Skipping addition to queue due to "${config.signal.reason}"`); return; } this.#queue.push(config); this.#tryRunNext(); } async #tryRunNext() { if (this.#running || !this.#queue.length) return; try { const { signal, callback } = this.#queue.shift(); if (signal.aborted) { return; } this.#running = true; if (callback.constructor.name.toLowerCase() === 'asyncfunction') { await callback(); } else { callback(); } } catch (err) { error(`${this.#id} Queue:`, err); } finally { this.#running = false; this.#tryRunNext(); } } } // HTML Elements class GalleryFolder extends HTMLElement { static folders = new Set(); /** @type {GalleryFolder | null} */ static #active = null; constructor(folder) { super(); // Support both old format (string) and new format (object with path and label) if (typeof folder === 'object' && folder !== null) { this.name = decodeURI(folder.path || ''); this.label = decodeURI(folder.label || folder.path || ''); } else { this.name = decodeURI(folder); this.label = this.name; } this.style.overflowX = 'hidden'; this.shadow = this.attachShadow({ mode: 'open' }); this.shadow.adoptedStyleSheets = [folderStylesheet]; this.div = document.createElement('div'); } connectedCallback() { if (GalleryFolder.folders.has(this)) return; // Element is just being moved this.div.className = 'gallery-folder'; this.div.innerHTML = `\uf03e ${this.label}`; this.div.title = this.name; // Show full path on hover this.addEventListener('click', this.updateSelected); this.addEventListener('click', fetchFilesWS); this.shadow.appendChild(this.div); GalleryFolder.folders.add(this); if (this.name === currentGalleryFolder) { this.updateSelected(); } } async disconnectedCallback() { await Promise.resolve(); // Wait for other microtasks (such as element moving) if (this.isConnected) return; GalleryFolder.folders.delete(this); if (GalleryFolder.#active === this) { GalleryFolder.#active = null; } } static getActive() { return GalleryFolder.#active; } updateSelected() { this.div.classList.add('gallery-folder-selected'); GalleryFolder.#active = this; for (const folder of GalleryFolder.folders as any) { if (folder !== this) folder.div.classList.remove('gallery-folder-selected'); } } } /** * Event-driven wait mechanism replacing the 50ms polling loop * @param {AbortSignal} signal - AbortController signal */ async function awaitForOutstanding(signal) { if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); if (outstanding < maxFetchRequests) return; // if we are under the limit, proceed immediately await new Promise((resolve, reject) => { // otherwise, register a promise and wait for the queue to resolve us const onResolve = () => { // define what happens when this item gets its turn or gets aborted signal?.removeEventListener('abort', onAbort); resolve(true); }; const onAbort = () => { const idx = fetchQueue.findIndex((item) => item.resolve === onResolve); // remove this specific request from the waiting queue if aborted if (idx !== -1) fetchQueue.splice(idx, 1); reject(new DOMException('Aborted', 'AbortError')); }; fetchQueue.push({ resolve: onResolve }); signal?.addEventListener('abort', onAbort); }); } async function delayFetchThumb(fn, signal) { const t0 = performance.now(); try { await awaitForOutstanding(signal); } catch (err) { if ((err as DOMException).name === 'AbortError') return undefined; throw err; } pb.stats.queue = (pb.stats.queue || 0) + Math.round(performance.now() - t0); const t1 = performance.now(); try { outstanding++; const ts = t0.toString(); const res = await authFetch(`${window.api}/browser/thumb?file=${encodeURI(fn)}&ts=${ts}&exif=false`, { priority: 'low' }); if (!res.ok) { error(`fetchThumb: ${res.statusText}`); return undefined; } const json = await res.json(); if (!res || !json || json.error || Object.keys(json).length === 0) { if (json.error) error(`fetchThumb: ${json.error}`); return undefined; } return json; } finally { outstanding--; pb.stats.fetch = (pb.stats.fetch || 0) + Math.round(performance.now() - t1); if (fetchQueue.length > 0 && outstanding < maxFetchRequests) { // queue magic: trigger the next waiting fetch immediately const nextRequest = fetchQueue.shift(); nextRequest.resolve(); // This wakes up the next awaitForOutstanding() } } } class GalleryFile extends HTMLElement { /** @type {AbortSignal} */ #signal; constructor(folder, file, signal) { super(); this.folder = folder; this.name = file; this.#signal = signal; this.src = `${this.folder}/${this.name}`.replace(/\/+/g, '/'); // Ensure no //, ///, etc... this.fullFolder = this.src.replace(/\/[^/]+$/, ''); this.size = 0; this.mtime = 0; this.hash = undefined; this.exif = ''; this.width = 0; this.height = 0; this.shadow = this.attachShadow({ mode: 'open' }); this.shadow.adoptedStyleSheets = [fileStylesheet]; this.firstRun = true; } async connectedCallback() { if (!this.firstRun) return; // Element is just being moved this.firstRun = false; const t0 = performance.now(); pb.stats.count = (pb.stats.count || 0) + 1; // Check separator state early to hide the element immediately const dir = this.name.match(/(.*)[/\\]/); if (dir && dir[1]) { const dirPath = dir[1]; const isOpen = separatorStates.get(dirPath); if (isOpen === false) this.style.display = 'none'; } this.hash = await getHash(`${this.src}/${this.size}/${this.mtime}`) .catch((err) => { error('getHash:', err); return null; }); pb.stats.hash = (pb.stats.hash || 0) + Math.round(performance.now() - t0); let cachedData; if (opts.browser_cache) { const t1 = performance.now(); // if (!pb.stats.keys) pb.stats.keys = await idbKeys(); // if (pb.stats.keys && this.hash && pb.stats.keys.includes(this.hash)) cachedData = await idbGet(this.hash).catch(() => undefined); cachedData = await idbGet(this.hash).catch(() => undefined); pb.stats.db = (pb.stats.db || 0) + Math.round(performance.now() - t1); } const img = document.createElement('img'); img.className = 'gallery-file'; img.loading = 'lazy'; img.onload = async () => { img.title += `\nResolution: ${this.width} x ${this.height}`; this.title = img.title; if (!cachedData && opts.browser_cache) { if ((this.width === 0) || (this.height === 0)) { // fetch thumb failed so we use actual image this.width = img.naturalWidth; this.height = img.naturalHeight; } } }; let ok = true; if (cachedData?.img) { img.src = cachedData.img; this.exif = cachedData.exif; this.width = cachedData.width; this.height = cachedData.height; this.size = cachedData.size; this.mtime = new Date(cachedData.mtime); pb.stats.cached = (pb.stats.cached || 0) + 1; } else { try { const json = await delayFetchThumb(this.src, this.#signal); if (!json) { ok = false; pb.stats.failed = (pb.stats.failed || 0) + 1; } else { img.src = json.data; this.exif = json.exif; this.width = json.width; this.height = json.height; this.size = json.size; this.mtime = new Date(json.mtime); pb.stats.fetched = (pb.stats.fetched || 0) + 1; if (opts.browser_cache && this.hash) { idbAdd({ hash: this.hash, folder: this.fullFolder, file: this.name, size: this.size, mtime: this.mtime, width: this.width, height: this.height, src: this.src, exif: this.exif, img: img.src, // exif: await getExif(img), // alternative client-side exif // img: await createThumb(img), // alternative client-side thumb }); } } } catch (err) { // thumb fetch failed so assign actual image img.src = `file=${this.src}`; pb.stats.error = (pb.stats.error || 0) + 1; } } pb.stats.callback = (pb.stats.callback || 0) + Math.round(performance.now() - t0); if (this.#signal.aborted) return; galleryHashes.add(this.hash); if (!ok) return; img.onclick = () => { setGallerySelectionByElement(this, { send: true }); }; img.onpointerenter = () => { el.overlay.display = 'block'; this.shadow.appendChild(el.overlay); currentImage = this.src; currentName = this.name; currentTitle = this.title; }; img.onpointerleave = () => { el.overlay.display = 'none'; }; img.title = `Folder: ${this.folder}\nFile: ${this.name}\nSize: ${this.size.toLocaleString()} bytes\nModified: ${this.mtime.toLocaleString()}`; this.title = img.title; const shouldDisplayBasedOnSearch = this.title.toLowerCase().includes(el.search.value.toLowerCase()); // Final visibility check based on search term. if (this.style.display !== 'none') this.style.display = shouldDisplayBasedOnSearch ? 'flex' : 'none'; // Only proceed if not already hidden by a closed separator this.shadow.appendChild(img); pb.stats.elapsed = (pb.stats.elapsed || 0) + Math.round(performance.now() - t0); } } async function createThumb(img) { const sizeEl = document.getElementById('gallery-thumb-size'); currentSize = sizeEl ? parseInt(sizeEl.value, 10) : opts.extra_networks_card_size; const height = currentSize; const width = opts.browser_fixed_width ? currentSize : 0; const canvas = document.createElement('canvas'); const scaleY = height / img.height; const scaleX = width > 0 ? width / img.width : scaleY; const scale = Math.min(scaleX, scaleY); const scaledWidth = img.width * scale; const scaledHeight = img.height * scale; canvas.width = scaledWidth; canvas.height = scaledHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, scaledWidth, scaledHeight); const dataURL = canvas.toDataURL('image/jpeg', 0.5); return dataURL; } async function handleSeparator(separator) { separator.classList.toggle('gallery-separator-hidden'); const nowHidden = separator.classList.contains('gallery-separator-hidden'); // Store the state (true = open, false = closed) separatorStates.set(separator.title, !nowHidden); // Update arrow and count const arrow = separator.querySelector('.gallery-separator-arrow'); arrow.style.transform = nowHidden ? 'rotate(0deg)' : 'rotate(90deg)'; const all = Array.from(el.files.children); for (const f of all) { if (!f.name) continue; // Skip separators // Check if file belongs to this exact directory const fileDir = f.name.match(/(.*)[/\\]/); const fileDirPath = fileDir ? fileDir[1] : ''; if (separator.title.length > 0 && fileDirPath === separator.title) { f.style.display = nowHidden ? 'none' : 'unset'; } } // Note: Count is not updated here on manual toggle, as it reflects the total. // If I end up implementing it, the search function will handle dynamic count updates. } async function addSeparators() { document.querySelectorAll('.gallery-separator').forEach((node) => { el.files.removeChild(node); }); const all = Array.from(el.files.children); let lastDir; // Count root files (files without a directory path) const hasRootFiles = all.some((f) => f.name && !f.name.match(/[/\\]/)); // Only auto-open first separator if there are no root files to display let isFirstSeparator = !hasRootFiles; // First pass: create separators for (const f of all) { let dir = f.name?.match(/(.*)[/\\]/); if (!dir) dir = ''; else dir = dir[1]; if (dir !== lastDir) { lastDir = dir; if (dir.length > 0) { // Count files in this directory let fileCount = 0; for (const file of all) { if (!file.name) continue; const fileDir = file.name.match(/(.*)[/\\]/); const fileDirPath = fileDir ? fileDir[1] : ''; if (fileDirPath === dir) fileCount++; } const sep = document.createElement('div'); sep.className = 'gallery-separator'; sep.title = dir; // Default to open for the first separator if no state is saved, otherwise closed. const isOpen = separatorStates.has(dir) ? separatorStates.get(dir) : isFirstSeparator; separatorStates.set(dir, isOpen); // Ensure it's in the map if (isFirstSeparator) isFirstSeparator = false; // Subsequent separators will default to closed if (!isOpen) { sep.classList.add('gallery-separator-hidden'); } // Create arrow span const arrow = document.createElement('span'); arrow.className = 'gallery-separator-arrow'; arrow.textContent = '▶'; arrow.style.transform = isOpen ? 'rotate(90deg)' : 'rotate(0deg)'; // Create directory name span const dirName = document.createElement('span'); dirName.className = 'gallery-separator-name'; dirName.textContent = dir; dirName.title = dir; // Show full path on hover // Create count span const count = document.createElement('span'); count.className = 'gallery-separator-count'; count.textContent = `${fileCount} files`; sep.dataset.totalFiles = String(fileCount); // Store total count for search filtering sep.appendChild(arrow); sep.appendChild(dirName); sep.appendChild(count); sep.onclick = () => handleSeparator(sep); el.files.insertBefore(sep, f); } } } // Second pass: hide files in closed directories for (const f of all) { if (!f.name) continue; // Skip separators const dir = f.name.match(/(.*)[/\\]/); if (dir && dir[1]) { const dirPath = dir[1]; const isOpen = separatorStates.get(dirPath); if (isOpen === false) { f.style.display = 'none'; } } } } // methods const gallerySendImage = (_images) => [currentImage]; // invoked by gradio button window.gallerySendImage = gallerySendImage; /** * Helper function to update status with sort mode * @param {...string|[string, string]} messages - Each can be either a string to use as-is, or an array of a string label and value * @returns {void} */ function updateStatusWithSort(...messages) { if (!el.status) return; messages.unshift(['Sort', sortMode.name]); const fragment = document.createDocumentFragment(); for (let i = 0; i < messages.length; i++) { const div = document.createElement('div'); if (Array.isArray(messages[i])) { const [text1, text2] = messages[i]; const tDiv1 = document.createElement('div'); tDiv1.innerText = `${text1}:`; const tDiv2 = document.createElement('div'); tDiv2.innerText = text2; tDiv2.title = text2; div.append(tDiv1, tDiv2); } else { const tDiv1 = document.createElement('div'); tDiv1.innerText = messages[i]; div.append(tDiv1); } fragment.append(div); } if (el.status.hasChildNodes()) el.status.innerHTML = ''; el.status.append(fragment); } async function injectGalleryStatusCSS() { const style = document.createElement('style'); style.textContent = ` #tab-gallery-status { display: inline-flex; flex-flow: row wrap; justify-content: ${opts.theme_type?.toLowerCase() === 'modern' ? 'flex-start' : 'flex-end'}; } #tab-gallery-status > div { display: flex; max-width: 100%; white-space: nowrap; & div { &:first-child { flex-shrink: 0; margin-right: 4px; } &:last-child:not(:first-child) { flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } } } #tab-gallery-status > div:not(:last-child)::after { content: '|'; margin-inline: 6px; }`; document.head.append(style); } async function wsConnect(socket, timeout = 5000) { const intrasleep = 100; const ttl = timeout / intrasleep; const isOpened = () => (socket.readyState === WebSocket.OPEN); if (socket.readyState !== WebSocket.CONNECTING) return isOpened(); let loop = 0; while (socket.readyState === WebSocket.CONNECTING && loop < ttl) { await new Promise((resolve) => { setTimeout(resolve, intrasleep); }); loop++; } return isOpened(); } async function gallerySearch() { if (el.search.busy) clearTimeout(el.search.busy); el.search.busy = setTimeout(async () => { const t0 = performance.now(); const str = el.search.value.toLowerCase(); const allFiles = Array.from(el.files.children).filter((node) => node.name); const allSeparators = Array.from(el.files.children).filter((node) => node.classList.contains('gallery-separator')); // If search is cleared, restore original view if (str === '') { allSeparators.forEach((sep) => { sep.style.display = 'flex'; const isOpen = separatorStates.has(sep.title) ? separatorStates.get(sep.title) : false; const countSpan = sep.querySelector('.gallery-separator-count'); if (countSpan && sep.dataset.totalFiles) { countSpan.textContent = `${sep.dataset.totalFiles} files`; } const arrow = sep.querySelector('.gallery-separator-arrow'); sep.classList.toggle('gallery-separator-hidden', !isOpen); if (arrow) arrow.style.transform = isOpen ? 'rotate(90deg)' : 'rotate(0deg)'; }); allFiles.forEach((f) => { const dir = f.name.match(/(.*)[/\\]/); const dirPath = (dir && dir[1]) ? dir[1] : ''; const isOpen = separatorStates.get(dirPath); f.style.display = (!dirPath || isOpen) ? 'unset' : 'none'; }); updateStatusWithSort('Filter', 'Cleared', ['Images', allFiles.length.toLocaleString()]); return; } // --- Search logic --- let totalFound = 0; const directoryMatches = new Map(); const fileMatches = new WeakSet(); const r = /^(.+)([=<>])(.*)/; for (const f of allFiles) { let isMatch = false; if (r.test(str)) { const match = str.match(r); const key = match[1].trim(); const op = match[2].trim(); let val = match[3].trim(); if (key === 'mtime') val = new Date(val); if (((op === '=') && (f[key] === val)) || ((op === '>') && (f[key] > val)) || ((op === '<') && (f[key] < val))) { isMatch = true; } } else if (f.title?.toLowerCase().includes(str) || f.exif?.toLowerCase().includes(str)) { isMatch = true; } if (isMatch) { fileMatches.add(f); totalFound++; const dir = f.name.match(/(.*)[/\\]/); const dirPath = (dir && dir[1]) ? dir[1] : ''; directoryMatches.set(dirPath, (directoryMatches.get(dirPath) || 0) + 1); } } // Update separators based on search results for (const sep of allSeparators) { const dirPath = sep.title; const foundCount = directoryMatches.get(dirPath) || 0; if (foundCount > 0) { sep.style.display = 'flex'; // Show separator sep.classList.remove('gallery-separator-hidden'); // Force open const arrow = sep.querySelector('.gallery-separator-arrow'); if (arrow) arrow.style.transform = 'rotate(90deg)'; // Removed file count update during search as it was buggy. } else { sep.style.display = 'none'; // Hide separator } } // Update file visibility for (const f of allFiles) { f.style.display = fileMatches.has(f) ? 'unset' : 'none'; } const t1 = performance.now(); updateStatusWithSort('Filter', ['Images', `${totalFound.toLocaleString()} / ${allFiles.length.toLocaleString()}`], `${iconStopwatch} ${Math.round(t1 - t0).toLocaleString()}ms`); timer(`galleryFilter:${str}`, t1 - t0); refreshGallerySelection(); }, 250); } const findDuplicates = (arr, key) => { const map = new Map(); return arr.filter((item) => { const value = item[key]; if (map.has(value)) return true; map.set(value, true); return false; }); }; export async function gallerySort(key) { // if currentSort does not start with key, default to key+A // else if currentSort ends with A change to D and vice versa for toggling sort order if (currentSort.startsWith(key)) currentSort = currentSort.endsWith('A') ? `${key}D` : `${key}A`; else currentSort = `${key}A`; if (!Object.hasOwn(gallerySorter, currentSort)) { error(`Gallery: "${currentSort}" is not a valid gallery sorting key`); return; } const t0 = performance.now(); const arr = Array.from(el.files.children).filter((node) => node.name); // filter out separators if (arr.length === 0) return; // no files to sort const fragment = document.createDocumentFragment(); // Helper to get directory path from a file node const getDirPath = (node) => { const match = node.name.match(/(.*)[/\\]/); return match ? match[1] : ''; }; // Partition into root files and subfolder files - root files always stay at top const rootFiles = arr.filter((node) => !getDirPath(node)); const subfolderFiles = arr.filter((node) => getDirPath(node)); // Group subfolder files by directory const folderGroups = new Map(); for (const file of subfolderFiles) { const dir = getDirPath(file); if (!folderGroups.has(dir)) { folderGroups.set(dir, []); } folderGroups.get(dir).push(file); } sortMode = gallerySorter[currentSort]; // Sort root files rootFiles.sort(sortMode.func); rootFiles.forEach((node) => fragment.appendChild(node)); // Sort folder names alphabetically, then sort files within each folder const sortedFolderNames = Array.from(folderGroups.keys()).sort((a, b) => a.localeCompare(b)); for (const folderName of sortedFolderNames) { const files = folderGroups.get(folderName); files.sort(sortMode.func); files.forEach((node) => fragment.appendChild(node)); } if (fragment.children.length === 0) return; el.files.innerHTML = ''; el.files.appendChild(fragment); addSeparators(); // After sorting and adding separators, ensure files respect separator states const all = Array.from(el.files.children); for (const f of all) { if (!f.name) continue; // Skip separators const dir = f.name.match(/(.*)[/\\]/); if (dir && dir[1]) { const dirPath = dir[1]; const isOpen = separatorStates.get(dirPath); if (isOpen === false) { f.style.display = 'none'; } } } const t1 = performance.now(); log(`gallerySort: sort=${sortMode.name} len=${arr.length} time=${Math.floor(t1 - t0)}`); updateStatusWithSort(['Images', arr.length.toLocaleString()], `${iconStopwatch} ${Math.round(t1 - t0).toLocaleString()}ms`); timer(`gallerySort:${sortMode.name}`, t1 - t0); refreshGallerySelection(); } window.gallerySort = gallerySort; /** * Function for removing the cleaning overlay * @callback ClearMsgCallback * @returns {void} */ /** * Generate and display the overlay to announce cleanup is in progress. * @param {number} count - Number of entries being cleaned up * @param {boolean} all - Indicate that all thumbnails are being cleared * @returns {ClearMsgCallback} */ function showCleaningMsg(count, all = false) { // Rendering performance isn't a priority since this doesn't run often const parent = el.folders.parentElement; const cleaningOverlay = document.createElement('div'); const msgDiv = document.createElement('div'); const msgText = document.createElement('div'); const msgInfo = document.createElement('div'); const anim = document.createElement('span'); parent.style.position = 'relative'; cleaningOverlay.style.cssText = 'position: absolute; height: 100%; width: 100%; background-color: var(--sd-main-accent-color); display: flex; align-items: center; justify-content: center; align-content: center; flex-wrap: wrap; opacity: 0.8; border-radius: var(--sd-border-radius);'; msgDiv.style.cssText = 'display: block; color: var(--sd-button-normal-color); padding: 12px; border-radius: 8px; border-radius: var(--sd-border-radius);'; msgText.style.cssText = 'font-size: 1.2em'; msgInfo.style.cssText = 'font-size: 0.9em; text-align: center;'; msgText.innerText = 'Thumbnail cleanup...'; msgInfo.innerText = all ? 'Clearing all entries' : `Found ${count} old entries`; anim.classList.add('idbBusyAnim'); msgDiv.append(msgText, msgInfo); cleaningOverlay.append(msgDiv, anim); parent.append(cleaningOverlay); return () => { cleaningOverlay.remove(); }; } const maintenanceQueue = new SimpleFunctionQueue('Gallery Maintenance'); /** * Handles calling the cleanup function for the thumbnail cache * @param {string} folder - Folder to clean * @param {number} imgCount - Expected number of images in gallery * @param {AbortController} controller - AbortController that's handling this task * @param {boolean} force - Force full cleanup of the folder */ async function thumbCacheCleanup(folder, imgCount, controller, force = false) { if (!opts.browser_cache && !force) return; if (!folder || !imgCount) return; if (Date.now() - cleanupTimers[folder] < minCleanupTime) return; // Prevents multiple calls in quick succession cleanupTimers[folder] = Date.now(); try { if (typeof folder !== 'string' || typeof imgCount !== 'number') { throw new Error('Function called with invalid arguments'); } debug('thumbCacheCleanup', { folder, imgCount }); await awaitForGallery(imgCount, controller.signal); } catch (err) { error('thumbCacheCleanup', { folder, error: err }); return; } maintenanceQueue.enqueue({ signal: controller.signal, callback: async () => { if (Date.now() - maintenanceTimers[folder] < minCleanupTime) return; maintenanceTimers[folder] = Date.now(); const t0 = performance.now(); const keptGalleryHashes = force ? new Set() : new Set(galleryHashes.values()); // External context should be safe since this function run is guarded by AbortController/AbortSignal in the SimpleFunctionQueue const folderNormalized = folder.replace(/\/+/g, '/').replace(/\/$/, ''); const recursiveFolder = IDBKeyRange.bound(folderNormalized, `${folderNormalized}\uffff`, false, true); if (keptGalleryHashes.size < minCleanupCount && !force) return; // Don't run when there aren't many excess entries const cachedHashesCount = await idbCount(recursiveFolder) .catch((e) => { error('maintenanceQueue', { folder, error: e }); return Infinity; // Forces next check to fail if something went wrong }); const cleanupCount = cachedHashesCount - keptGalleryHashes.size; if (!force && (cleanupCount < minCleanupCount || !Number.isFinite(cleanupCount))) return; // Don't run when there aren't many excess entries log('galleryMaintenance', { folder }); if (controller.signal.aborted) { debug('maintenanceQueue', { folder, reason: controller.signal.reason }); return; } const cb_clearMsg = showCleaningMsg(cleanupCount); await idbFolderCleanup(keptGalleryHashes, recursiveFolder, controller.signal) .then((delcount) => { const t1 = performance.now(); log('galleryMaintenance', { folder, kept: keptGalleryHashes.size, deleted: delcount, time: Math.round(t1 - t0) }); timer(`thumbnailDBCleanup:${folder}`, t1 - t0); currentGalleryFolder = null; updateStatusWithSort('Thumbnail cache cleared'); }) .catch((reason) => { SimpleFunctionQueue.abortLogger('thumbCacheCleanup', reason); }) .finally(async () => { await new Promise((resolve) => { setTimeout(resolve, 1000); }); // Delay removal by 1 second to ensure at least minimum visibility cb_clearMsg(); }); }, }); } function resetGalleryState(reason) { maintenanceController.abort(reason); const controller = new AbortController(); maintenanceController = controller; galleryHashes.clear(); // Must happen AFTER the AbortController steps pb.hide(); resetGallerySelection(); return controller; } function clearCacheIfDisabled(browser_cache) { if (browser_cache === false) { log('thumbCacheCleanup', { disabled: true }); const controller = resetGalleryState('Clearing all thumbnails from cache'); maintenanceQueue.enqueue({ signal: controller.signal, callback: async () => { const t0 = performance.now(); const cb_clearMsg = showCleaningMsg(0, true); await idbClearAll(controller.signal) .then(() => { log('thumbCacheCleanup', { time: Math.floor(performance.now() - t0) }); currentGalleryFolder = null; updateStatusWithSort('Thumbnail cache cleared'); }) .catch((e) => { SimpleFunctionQueue.abortLogger('thumbCacheCleanup', e); }) .finally(async () => { await new Promise((resolve) => { setTimeout(resolve, 1000); }); cb_clearMsg(); }); }, }); } } export function clearCache() { if (!currentGalleryFolder) return; const controller = resetGalleryState('Clearing folder thumbnails cache'); el.files.innerHTML = ''; log('clearCache', { folder: currentGalleryFolder }); thumbCacheCleanup(currentGalleryFolder, 0, controller, true); } window.clearCache = clearCache; async function fetchFilesHT(evt, controller) { const t0 = performance.now(); const fragment = document.createDocumentFragment(); updateStatusWithSort(['Folder', evt.target.name], 'in-progress'); let numFiles = 0; const res = await authFetch(`${window.api}/browser/files?folder=${encodeURI(evt.target.name)}`); if (!res || res.status !== 200) { updateStatusWithSort(['Folder', evt.target.name], ['Failed', res?.statusText || 'No response']); return; } const jsonData = await res.json(); for (const line of jsonData) { const data = decodeURI(line).split('##F##'); const fileName = data[1]; const ext = fileName.split('.').pop().toLowerCase(); if (SUPPORTED_EXTENSIONS.includes(ext)) { numFiles++; const f = new GalleryFile(data[0], fileName, controller.signal); fragment.appendChild(f); } } if (controller.signal.aborted) return; el.files.appendChild(fragment); const t1 = performance.now(); log(`gallery: folder=${evt.target.name} num=${numFiles} method=http time=${Math.floor(t1 - t0)}ms`); timer(`galleryFetch:${evt.target.name}`, t1 - t0); updateStatusWithSort(['Folder', evt.target.name], ['Images', numFiles.toLocaleString()], `${iconStopwatch} ${Math.floor(t1 - t0).toLocaleString()}ms`); pb.start(numFiles); addSeparators(); refreshGallerySelection(); thumbCacheCleanup(evt.target.name, numFiles, controller); } async function fetchFilesWS(evt) { // fetch file-by-file list over websockets if (!url) return; // Abort previous controller and point to new controller for next time const controller = resetGalleryState('Gallery update'); // Called here because fetchFilesHT isn't called directly el.files.innerHTML = ''; updateGalleryStyles(); if (ws && ws.readyState === WebSocket.OPEN) ws.close(); // abort previous request let wsConnected = false; try { ws = new WebSocket(`${url}/sdapi/v1/browser/files`); wsConnected = await wsConnect(ws); } catch (err) { log('gallery: ws connect error', err); return; } log(`gallery: connected=${wsConnected} state=${ws?.readyState} url=${ws?.url}`); currentGalleryFolder = evt.target.name; if (!wsConnected) { await fetchFilesHT(evt, controller); // fallback to http return; } updateStatusWithSort(['Folder', evt.target.name]); const t0 = performance.now(); let numFiles = 0; let t1 = performance.now(); let fragment = document.createDocumentFragment(); ws.onmessage = (event) => { t1 = performance.now(); const data = decodeURI(event.data).split('##F##'); if (data[0] === '#END#') { ws.close(); } else { const fileName = data[1]; const ext = fileName.split('.').pop().toLowerCase(); if (SUPPORTED_EXTENSIONS.includes(ext)) { const file = new GalleryFile(data[0], fileName, controller.signal); numFiles++; fragment.appendChild(file); if (numFiles % fragmentSize === 0) { updateStatusWithSort(['Folder', evt.target.name], ['Images', numFiles.toLocaleString()], 'in-progress', `${iconStopwatch} ${Math.floor(t1 - t0).toLocaleString()}ms`); el.files.appendChild(fragment); fragment = document.createDocumentFragment(); } } } }; ws.onclose = (event) => { if (controller.signal.aborted) return; el.files.appendChild(fragment); // gallerySort(); log(`gallery: folder=${evt.target.name} num=${numFiles} method=ws time=${Math.floor(t1 - t0)}ms`); updateStatusWithSort(['Folder', evt.target.name], ['Images', numFiles.toLocaleString()], `${iconStopwatch} ${Math.floor(t1 - t0).toLocaleString()}ms`); pb.start(numFiles); addSeparators(); refreshGallerySelection(); thumbCacheCleanup(evt.target.name, numFiles, controller); }; ws.onerror = (event) => { log('gallery ws error', event); }; ws.send(encodeURI(evt.target.name)); } async function updateFolders() { // if (el.folders.children.length > 0) return; const res = await authFetch(`${window.api}/browser/folders`); if (!res || res.status !== 200) return; url = res.url.split('/sdapi')[0].replace('http', 'ws'); // update global url as ws need fqdn const folders = await res.json(); el.folders.innerHTML = ''; for (const folder of folders) { const f = new GalleryFolder(folder); el.folders.appendChild(f); } } async function monitorGalleries() { async function galleryMutation(mutations) { const galleries = mutations.filter((m) => m.target?.classList?.contains('preview')); for (const gallery of galleries) { const links = gallery.target.querySelectorAll('a'); for (const link of links) { const href = link.getAttribute('href'); if (!href) continue; const fn = href.split('/').pop().split('\\').pop(); link.setAttribute('download', fn); } } } const galleryElements = gradioApp().querySelectorAll('.gradio-gallery'); for (const gallery of galleryElements) { const galleryObserver = new MutationObserver(galleryMutation); galleryObserver.observe(gallery, { childList: true, subtree: true, attributes: true }); } } async function setOverlayAnimation() { const busyAnimation = document.createElement('style'); // eslint-disable-next-line @stylistic/max-len busyAnimation.textContent = '.idbBusyAnim{width:16px;height:16px;border-radius:50%;display:block;margin:40px;position:relative;background:#aa3d00;color:#fff;box-shadow:-24px 0,24px 0;box-sizing:border-box;animation:2s ease-in-out infinite overlayRotation}@keyframes overlayRotation{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}'; document.head.append(busyAnimation); } async function initGalleryAutoRefresh() { const isModern = opts.theme_type?.toLowerCase() === 'modern'; let galleryTab = isModern ? document.getElementById('gallery_tabitem') : document.getElementById('tab_gallery'); let timeout = 0; while (!galleryTab && timeout++ < 60) { await new Promise((resolve) => { setTimeout(resolve, 2500); }); galleryTab = isModern ? document.getElementById('gallery_tabitem') : document.getElementById('tab_gallery'); } if (!galleryTab) { error('Gallery: timeout'); return; } const displayNoneRegEx = /display:\s*none/; async function galleryAutoRefresh(mutations) { if (!opts.browser_gallery_autoupdate) return; for (const mutation of mutations) { switch (mutation.attributeName) { case 'class': if (mutation.oldValue.includes('hidden') && !mutation.target.classList.contains('hidden')) { await updateFolders(); GalleryFolder.getActive()?.click(); } break; case 'style': if (displayNoneRegEx.test(mutation.oldValue) && !displayNoneRegEx.test(mutation.target.style.display)) { await updateFolders(); GalleryFolder.getActive()?.click(); } break; default: break; } } } const galleryVisObserver = new MutationObserver(galleryAutoRefresh); galleryVisObserver.observe(galleryTab, { attributeFilter: ['class', 'style'], attributeOldValue: true }); } async function overlayDelete(evt) { const res = await authFetch(`${window.api}/delete-image?file=${encodeURIComponent(currentImage)}`); evt.stopPropagation(); if (!res || res.status !== 200) { error('galleryDelete', { file: currentImage, status: res?.status, statusText: res?.statusText }); return; } const data = await res.json(); log('galleryDelete', data); GalleryFolder.getActive()?.click(); } async function overlayDownload(evt) { log('galleryDownload', currentImage); const link = document.createElement('a'); link.href = `/file=${encodeURIComponent(currentImage)}`; link.download = currentName; document.body.appendChild(link); link.click(); document.body.removeChild(link); evt.stopPropagation(); } async function overlayInfo(evt) { evt.stopPropagation(); const tgt = document.getElementById('html_info_formatted_gallery'); if (!tgt) return; const res = await authFetch(`${window.api}/png-info?file=${encodeURI(currentImage)}`); if (!res || res.status !== 200) return; const data = await res.json(); log('galleryInfo res', data); const prompt = data?.parameters?.Prompt || ''; const negative = data?.parameters?.Negative || data?.parameters?.['Negative prompt'] || ''; const raw = data?.info || ''; const params = data?.parameters || {}; delete params.Prompt; delete params.Negative; delete params['Negative prompt']; const paramsFormatted = Object.entries(params).map(([key, value]) => `${key}: ${value}`).join(' | '); tgt.innerHTML = `
File: ${currentImage}
Prompt: ${prompt}
Negative: ${negative}
${paramsFormatted}
Raw:
${raw}
`; const img = document.querySelector('#gallery_gallery img'); if (img) img.src = `/file=${encodeURIComponent(currentImage)}?t=${Date.now()}`; // Force refresh in case info endpoint is faster than cache update const status = document.querySelector('#html_log_gallery p'); if (status) status.innerText = currentTitle; } async function createOverlay() { if (el.overlay) return; el.overlay = document.createElement('div'); el.overlay.className = 'gallery-overlay'; const btnDownload = document.createElement('span'); btnDownload.innerHTML = '\udb85\udc64'; btnDownload.title = 'Download image'; btnDownload.style.cursor = 'pointer'; btnDownload.addEventListener('click', overlayDownload); const btnDelete = document.createElement('span'); btnDelete.innerHTML = '\uf05c'; btnDelete.title = 'Delete image'; btnDelete.style.cursor = 'pointer'; btnDelete.addEventListener('click', overlayDelete); const btnInfo = document.createElement('span'); btnInfo.innerHTML = '\uf05a'; btnInfo.title = 'Image metadata'; btnInfo.style.cursor = 'pointer'; btnInfo.addEventListener('click', overlayInfo); el.overlay.append(btnInfo, btnDelete, btnDownload); } async function blockQueueUntilReady() { // Add block to maintenanceQueue until cache is ready maintenanceQueue.enqueue({ signal: new AbortController().signal, // Use standalone AbortSignal that can't be aborted callback: async () => { let timeout = 0; while (!idbIsReady() && timeout++ < 60) { await new Promise((resolve) => { setTimeout(resolve, 1000); }); } if (!idbIsReady()) { throw new Error('Timed out waiting for thumbnail cache'); } }, }); } export async function initGallery() { // triggered on gradio change to monitor when ui gets sufficiently constructed const t0 = performance.now(); el.folders = gradioApp().getElementById('tab-gallery-folders'); el.files = gradioApp().getElementById('tab-gallery-files'); el.status = gradioApp().getElementById('tab-gallery-status'); el.search = gradioApp().querySelector('#tab-gallery-search textarea'); el.size = document.getElementById('tab-gallery-thumb-size'); if (!el.folders || !el.files || !el.status || !el.search) { error('initGallery', 'Missing gallery elements'); return; } if (el.size) { el.size.value = opts.extra_networks_card_size; el.size.addEventListener('input', updateGalleryStyles); } blockQueueUntilReady(); // Run first createOverlay(); updateGalleryStyles(); injectGalleryStatusCSS(); setOverlayAnimation(); const progress = gradioApp().getElementById('tab-gallery-progress'); if (progress) pb.attachTo(progress); else log('initGallery', 'Failed to attach loading progress bar'); el.search.addEventListener('input', gallerySearch); el.btnSend = gradioApp().getElementById('tab-gallery-send-image'); document.getElementById('tab-gallery-files').style.height = opts.logmonitor_show ? '75vh' : '85vh'; monitorGalleries(); updateFolders(); initGalleryAutoRefresh(); [ 'browser_folders', 'outdir_samples', 'outdir_txt2img_samples', 'outdir_img2img_samples', 'outdir_control_samples', 'outdir_extras_samples', 'outdir_save', 'outdir_video', 'outdir_init_images', 'outdir_grids', 'outdir_txt2img_grids', 'outdir_img2img_grids', 'outdir_control_grids', ].forEach((op) => { monitorOption(op, updateFolders); }); const t1 = performance.now(); log('initGallery', Math.round(t1 - t0)); timer('initGallery', t1 - t0); } // register on startup customElements.define('gallery-folder', GalleryFolder); customElements.define('gallery-file', GalleryFile);