Files
automatic/javascript/gallery.js
T
awsr c0b0a62512 Revert conversion to static class
It's probably fine if keeping the instance creation next to the class definition as long as it doesn't get moved to earlier in the file.
2026-01-04 00:37:44 -08:00

1097 lines
37 KiB
JavaScript

/* eslint-disable max-classes-per-file */
let ws;
let url;
let currentImage;
let pruneImagesTimer;
let outstanding = 0;
let lastSort = 0;
let lastSortName = 'None';
const galleryHashes = new Set();
let maintenanceController = new AbortController();
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,
};
const SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'tiff', 'jp2', 'jxl', 'gif', 'mp4', 'mkv', 'avi', 'mjpeg', 'mpg', 'avr'];
/**
* Wait for the `outstanding` variable to be below the specified value
* @param {number} num - Threshold for `outstanding`
* @param {AbortSignal} signal - AbortController signal
*/
async function awaitForOutstanding(num, signal) {
while (outstanding > num && !signal.aborted) await new Promise((resolve) => { setTimeout(resolve, 50); });
signal.throwIfAborted();
}
/**
* Wait for gallery to finish populating
* @param {number} expectedSize - Expected gallery size
* @param {AbortSignal} signal - AbortController signal
*/
async function awaitForGallery(expectedSize, signal) {
while (galleryHashes.size < 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.replaceSync(`
.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;
}
.gallery-folder:hover {
background-color: var(--button-primary-background-fill-hover);
}
.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.replaceSync(`
.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);
}
`);
}
fileStylesheet.replaceSync(`
.gallery-file {
object-fit: contain;
cursor: pointer;
height: ${opts.extra_networks_card_size}px;
width: ${opts.browser_fixed_width ? `${opts.extra_networks_card_size}px` : 'unset'};
}
.gallery-file:hover {
filter: grayscale(100%);
}
`);
}
// Classes
class SimpleProgressBar {
#container = document.createElement('div');
#progress = document.createElement('div');
#textDiv = document.createElement('div');
#text = document.createElement('span');
#visible = false;
#hideTimeout = null;
#interval = null;
#max = 0;
/** @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 = 'hsla(110, 32%, 35%, 0.80)'; // alt: '#27911d'
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) {
this.clear();
this.#max = total;
this.#interval = setInterval(() => {
this.#update(this.#monitoredSet.size, this.#max);
}, 250);
}
attachTo(element) {
if (element.hasChildNodes) {
element.innerHTML = '';
}
element.appendChild(this.#container);
}
clear() {
this.#stop();
clearTimeout(this.#hideTimeout);
this.#hideTimeout = null;
this.#container.style.display = 'none';
this.#visible = false;
this.#progress.style.width = '0';
this.#text.textContent = '';
}
#update(loaded, max) {
if (this.#hideTimeout) {
this.#hideTimeout = null;
}
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()
this.#hideTimeout = setTimeout(() => {
this.clear();
}, 1000);
}
}
#stop() {
clearInterval(this.#interval);
this.#interval = null;
}
}
const galleryProgressBar = 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 = [];
}
/**
* @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 {
constructor(name) {
super();
this.name = decodeURI(name);
this.style.overflowX = 'hidden';
this.shadow = this.attachShadow({ mode: 'open' });
this.shadow.adoptedStyleSheets = [folderStylesheet];
}
connectedCallback() {
const div = document.createElement('div');
div.className = 'gallery-folder';
div.innerHTML = `<span class="gallery-folder-icon">\uf03e</span> ${this.name}`;
div.title = this.name;
div.addEventListener('click', () => {
for (const folder of el.folders.children) {
if (folder.name === this.name) folder.shadow.firstElementChild.classList.add('gallery-folder-selected');
else folder.shadow.firstElementChild.classList.remove('gallery-folder-selected');
}
});
div.addEventListener('click', fetchFilesWS); // eslint-disable-line no-use-before-define
this.shadow.appendChild(div);
}
}
async function createThumb(img) {
const height = opts.extra_networks_card_size;
const width = opts.browser_fixed_width ? opts.extra_networks_card_size : 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;
let isFirstSeparator = true; // Flag to open the first separator by default
// 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 = 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';
}
}
}
}
async function delayFetchThumb(fn, signal) {
await awaitForOutstanding(16, signal);
try {
outstanding++;
const ts = Date.now().toString();
const res = await authFetch(`${window.api}/browser/thumb?file=${encodeURI(fn)}&ts=${ts}`, { 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--;
}
}
class GalleryFile extends HTMLElement {
/** @type {AbortSignal} */
#signal;
constructor(folder, file, signal) {
super();
this.folder = folder;
this.name = file;
this.#signal = signal;
this.size = 0;
this.mtime = 0;
this.hash = undefined;
this.exif = '';
this.width = 0;
this.height = 0;
this.src = `${this.folder}/${this.name}`;
this.shadow = this.attachShadow({ mode: 'open' });
this.shadow.adoptedStyleSheets = [fileStylesheet];
}
async connectedCallback() {
if (this.shadow.children.length > 0) {
return;
}
// 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.folder}/${this.name}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define
const cachedData = (this.hash && opts.browser_cache) ? await idbGet(this.hash).catch(() => undefined) : undefined;
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);
} else {
try {
const json = await delayFetchThumb(this.src, this.#signal);
if (!json) {
ok = false;
} 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);
if (opts.browser_cache) {
await idbAdd({
hash: this.hash,
folder: this.folder,
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}`;
}
}
if (this.#signal.aborted) { // Do not change the operations order from here...
return;
}
galleryHashes.add(this.hash);
if (!ok) {
return;
} // ... to here unless modifications are also being made to maintenance functionality and the usage of AbortController/AbortSignal
img.onclick = () => {
currentImage = this.src;
el.btnSend.click();
};
img.title = `Folder: ${this.folder}\nFile: ${this.name}\nSize: ${this.size.toLocaleString()} bytes\nModified: ${this.mtime.toLocaleString()}`;
if (this.shadow.children.length > 0) {
return; // avoid double-adding
}
this.title = img.title;
// Final visibility check based on search term.
const shouldDisplayBasedOnSearch = this.title.toLowerCase().includes(el.search.value.toLowerCase());
if (this.style.display !== 'none') { // Only proceed if not already hidden by a closed separator
this.style.display = shouldDisplayBasedOnSearch ? 'unset' : 'none';
}
this.shadow.appendChild(img);
}
}
// methods
const gallerySendImage = (_images) => [currentImage]; // invoked by gradio button
async function getHash(str, algo = 'SHA-256') {
try {
let hex = '';
const strBuf = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest(algo, strBuf);
const view = new DataView(hash);
for (let i = 0; i < hash.byteLength; i += 4) hex += (`00000000${view.getUint32(i).toString(16)}`).slice(-8);
return hex;
} catch {
return undefined;
}
}
/**
* 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', lastSortName]);
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.floor(t1 - t0).toLocaleString()}ms`);
}, 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;
});
};
async function gallerySort(btn) {
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
if (btn) lastSort = btn.charCodeAt(0);
const fragment = document.createDocumentFragment();
switch (lastSort) {
case 61789: // name asc
lastSortName = 'Name Ascending';
arr
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((node) => fragment.appendChild(node));
break;
case 61790: // name dsc
lastSortName = 'Name Descending';
arr
.sort((b, a) => a.name.localeCompare(b.name))
.forEach((node) => fragment.appendChild(node));
break;
case 61792: // size asc
lastSortName = 'Size Ascending';
arr
.sort((a, b) => a.size - b.size)
.forEach((node) => fragment.appendChild(node));
break;
case 61793: // size dsc
lastSortName = 'Size Descending';
arr
.sort((b, a) => a.size - b.size)
.forEach((node) => fragment.appendChild(node));
break;
case 61794: // resolution asc
lastSortName = 'Resolution Ascending';
arr
.sort((a, b) => a.width * a.height - b.width * b.height)
.forEach((node) => fragment.appendChild(node));
break;
case 61795: // resolution dsc
lastSortName = 'Resolution Descending';
arr
.sort((b, a) => a.width * a.height - b.width * b.height)
.forEach((node) => fragment.appendChild(node));
break;
case 61662:
lastSortName = 'Modified Ascending';
arr
.sort((a, b) => a.mtime - b.mtime)
.forEach((node) => fragment.appendChild(node));
break;
case 61661:
lastSortName = 'Modified Descending';
arr
.sort((b, a) => a.mtime - b.mtime)
.forEach((node) => fragment.appendChild(node));
break;
default:
lastSortName = 'None';
break;
}
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: char=${lastSort} len=${arr.length} time=${Math.floor(t1 - t0)} sort=${lastSortName}`);
updateStatusWithSort(['Images', arr.length.toLocaleString()], `${iconStopwatch} ${Math.floor(t1 - t0).toLocaleString()}ms`);
}
/**
* 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
* @returns {ClearMsgCallback}
*/
function showCleaningMsg(count) {
// 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: hsl(210 50 20 / 0.8); display: flex; align-items: center; justify-content: center; align-content: center; flex-wrap: wrap;';
msgDiv.style.cssText = 'display: block; background-color: hsl(0 0 10); color: white; padding: 12px; border-radius: 8px;';
msgText.style.cssText = 'font-size: 1.2em';
msgInfo.style.cssText = 'font-size: 0.9em; text-align: center;';
msgText.innerText = 'Thumbnail cleanup...';
msgInfo.innerText = `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('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
*/
async function thumbCacheCleanup(folder, imgCount, controller) {
if (!opts.browser_cache) return;
try {
if (typeof folder !== 'string' || typeof imgCount !== 'number') {
throw new Error('Function called with invalid arguments');
}
debug('Thumbnail DB cleanup: Waiting for gallery data to settle');
await awaitForGallery(imgCount, controller.signal);
} catch (err) {
debug(`Thumbnail DB cleanup: Skipping cleanup for "${folder}" due to "${err}"`);
return;
}
maintenanceQueue.enqueue({
signal: controller.signal,
callback: async () => {
log(`Thumbnail DB cleanup: Checking if "${folder}" needs cleaning`);
const t0 = performance.now();
const staticGalleryHashes = new Set(galleryHashes); // External context should be safe since this function run is guarded by AbortController/AbortSignal in the SimpleFunctionQueue
const cachedHashesCount = await idbCount(folder)
.catch((e) => {
error(`Thumbnail DB cleanup: Error when getting entry count for "${folder}".`, e);
return Infinity; // Forces next check to fail if something went wrong
});
const cleanupCount = cachedHashesCount - staticGalleryHashes.size;
if (cleanupCount < 500 || !Number.isFinite(cleanupCount)) {
// Don't run when there aren't many excess entries
return;
}
if (controller.signal.aborted) {
debug(`Thumbnail DB cleanup: Cancelling "${folder}" cleanup due to "${controller.signal.reason}"`);
return;
}
const cb_clearMsg = showCleaningMsg(cleanupCount);
const tRun = Date.now(); // Doesn't need high resolution
await idbFolderCleanup(staticGalleryHashes, folder, controller.signal)
.then((delcount) => {
const t1 = performance.now();
log(`Thumbnail DB cleanup: folder=${folder} kept=${staticGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`);
})
.catch((reason) => {
if (typeof reason === 'string' || (reason instanceof DOMException && reason.name === 'AbortError')) {
log('Thumbnail DB cleanup:', reason?.message || reason);
} else {
error('Thumbnail DB cleanup:', reason.message);
}
})
.finally(async () => {
// Ensure at least enough time to see that it's a message and not the UI breaking/flickering
await new Promise((resolve) => {
setTimeout(resolve, Math.min(1000, Math.max(1000 - (Date.now() - tRun), 0))); // Total display time of at least 1 second
});
cb_clearMsg();
});
},
});
}
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} time=${Math.floor(t1 - t0)}ms`);
updateStatusWithSort(['Folder', evt.target.name], ['Images', numFiles.toLocaleString()], `${iconStopwatch} ${Math.floor(t1 - t0).toLocaleString()}ms`);
galleryProgressBar.start(numFiles);
addSeparators();
thumbCacheCleanup(evt.target.name, numFiles, controller);
}
async function fetchFilesWS(evt) { // fetch file-by-file list over websockets
if (!url) return;
const controller = new AbortController(); // Only called here because fetchFilesHT isn't called directly
maintenanceController.abort('Gallery update'); // Abort previous controller
maintenanceController = controller; // Point to new controller for next time
galleryHashes.clear(); // Must happen AFTER the AbortController steps
galleryProgressBar.clear();
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); // Warning. This changes "evt".
} catch (err) {
log('gallery: ws connect error', err);
return;
}
log(`gallery: connected=${wsConnected} state=${ws?.readyState} url=${ws?.url}`);
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 % 100 === 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} time=${Math.floor(t1 - t0)}ms`);
updateStatusWithSort(['Folder', evt.target.name], ['Images', numFiles.toLocaleString()], `${iconStopwatch} ${Math.floor(t1 - t0).toLocaleString()}ms`);
galleryProgressBar.start(numFiles);
addSeparators();
thumbCacheCleanup(evt.target.name, numFiles, controller);
};
ws.onerror = (event) => {
log('gallery ws error', event);
};
ws.send(encodeURI(evt.target.name));
}
async function pruneImages() {
// TODO replace img.src with placeholder for images that are not visible
}
async function galleryVisible() {
// if (el.folders.children.length > 0) return;
const res = await authFetch(`${window.api}/browser/folders`);
if (!res || res.status !== 200) return;
el.folders.innerHTML = '';
url = res.url.split('/sdapi')[0].replace('http', 'ws'); // update global url as ws need fqdn
const folders = await res.json();
for (const folder of folders) {
const f = new GalleryFolder(folder);
el.folders.appendChild(f);
}
pruneImagesTimer = setInterval(pruneImages, 1000);
}
async function galleryHidden() {
if (pruneImagesTimer) clearInterval(pruneImagesTimer);
}
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:#ff3d00;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 initGallery() { // triggered on gradio change to monitor when ui gets sufficiently constructed
log('initGallery');
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');
if (!el.folders || !el.files || !el.status || !el.search) {
error('initGallery', 'Missing gallery elements');
return;
}
updateGalleryStyles();
injectGalleryStatusCSS();
setOverlayAnimation();
const progress = gradioApp().getElementById('tab-gallery-progress');
if (progress) {
galleryProgressBar.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';
const intersectionObserver = new IntersectionObserver((entries) => {
if (entries[0].intersectionRatio <= 0) galleryHidden();
if (entries[0].intersectionRatio > 0) galleryVisible();
});
intersectionObserver.observe(el.folders);
monitorGalleries();
}
// register on startup
customElements.define('gallery-folder', GalleryFolder);
customElements.define('gallery-file', GalleryFile);