diff --git a/.eslintrc.json b/.eslintrc.json
index 5ce9714eb..6bd030605 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -89,6 +89,8 @@
// imageviewer.js
"modalPrevImage": "readonly",
"modalNextImage": "readonly",
+ "galleryClickEventHandler": "readonly",
+ "getExif": "readonly",
// logMonitor.js
"jobStatusEl": "readonly",
// loader.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef4218233..3abe3a579 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,34 @@
- Include reference styles
- Quick apply style
- Add refine workflow in img2img
-- New image browser
## Update for 2024-03-20
+Gallery:
+- All operations are async, non-blocking and auto-cancelled as needed
+ This gives up a little bit of raw performance, but there is no "wait for current folder which has 10k images"
+ Right now enumerate is ~100-300 images/sec which is sufficient to show first screen of images near-instant
+- Images are loaded using lazy-loading, meaning on-demand when they are scrolled into view
+ But...If you want to have 10k images in a folder and then scroll-to-bottom, well...
+- Majority of work is done client-side in browser without needing for typical Gradio round-trip
+- Nothing cached by choice, images are always up-to-date (I may add thumbnail caching later)
+- Search is matching any part of folder/filename and image properties, but not image metadata (yet)
+- Search allows for syntax like `size > 1000000` or `width > 1000`
+ Operators are `<>=` and keys are `size`, `width`, `height`, `mtime`
+- Sort and search can easily handle 20k images-per-sec
+- Folders are scanned recursively
+- Optional user-defined folders: settings -> image options -> image browser
+- Thumbnails can be fixed or varible width, set in settings -> image options -> image browser
+Gallery ToDo:
+- Send to buttons row
+- Client-side caching? thumbnails, metadata
+- Search by metadata
+
+Changes:
+- Removed built-in extensions: *ControlNet* and *Image-Browser*
+ as both *image-browser* and *controlnet* have native equivalents
+ both can still be installed by user if desired
+Improvements:
- Styles apply wildcards to params
- Make metadata in full screen viewer optional
diff --git a/README.md b/README.md
index f4dd1f514..4b3dac026 100644
--- a/README.md
+++ b/README.md
@@ -228,9 +228,10 @@ List of available parameters, run `webui --help` for the full & up-to-date list:
SD.Next comes with several extensions pre-installed:
-- [ControlNet](https://github.com/Mikubill/sd-webui-controlnet) (*active in backend: original only*)
+- [System Info](https://github.com/vladmandic/sd-extension-system-info)
+- [chaiNNer](https://github.com/vladmandic/sd-extension-chainner)
+- [RemBg](https://github.com/vladmandic/sd-extension-rembg)
- [Agent Scheduler](https://github.com/ArtVentureX/sd-webui-agent-scheduler)
-- [Image Browser](https://github.com/AlUlkesh/stable-diffusion-webui-images-browser)
### **Collab**
diff --git a/javascript/gallery.js b/javascript/gallery.js
new file mode 100644
index 000000000..a980aa06c
--- /dev/null
+++ b/javascript/gallery.js
@@ -0,0 +1,350 @@
+/* eslint-disable max-classes-per-file */
+
+let ws;
+let url;
+const el = {
+ folders: undefined,
+ files: undefined,
+ image: undefined,
+ search: undefined,
+ status: undefined,
+};
+
+// HTML Elements
+
+class GalleryFolder extends HTMLElement {
+ constructor(name) {
+ super();
+ this.name = name;
+ this.shadow = this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ const style = document.createElement('style');
+ style.textContent = `
+ .gallery-folder {
+ cursor: pointer;
+ padding: 8px 6px 8px 6px;
+ }
+ .gallery-folder:hover {
+ background-color: var(--button-primary-background-fill-hover);
+ }
+ `;
+ this.shadow.appendChild(style);
+ const div = document.createElement('div');
+ div.className = 'gallery-folder';
+ div.textContent = `\uf44a ${this.name}`;
+ div.addEventListener('click', fetchFiles); // eslint-disable-line no-use-before-define
+ this.shadow.appendChild(div);
+ }
+}
+
+class GalleryImage extends HTMLElement {
+ constructor(folder, name, size, mtime) {
+ super();
+ this.folder = folder;
+ this.name = name;
+ this.size = size;
+ this.mtime = mtime;
+ this.shadow = this.attachShadow({ mode: 'open' });
+ }
+
+ async connectedCallback() {
+ const style = document.createElement('style');
+ style.textContent = `
+ .gallery-image {
+ text-align: center;
+ }
+ .gallery-image > img {
+ cursor: pointer;
+ user-select: none;
+ max-width: 100%;
+ max-height: 60vh;
+ }
+ .gallery-image-text {
+ text-align: left;
+ padding: 8px;
+ line-height: 1.3em;
+ }
+ `;
+ this.shadow.appendChild(style);
+ const div = document.createElement('div');
+ div.className = 'gallery-image';
+
+ const text = document.createElement('div');
+ text.className = 'gallery-image-text';
+ text.innerHTML = `
+ Folder: ${this.folder}
+ File: ${this.name}
+ Resolution:
+ Size: ${this.size.toLocaleString()} bytes
+ Modified: ${this.mtime.toLocaleString()}
+
+
+ `;
+
+ const img = document.createElement('img');
+ img.id = 'gallery-image';
+ img.onload = async () => {
+ const resolutionEl = this.shadow.getElementById('gallery-resolution');
+ if (resolutionEl) resolutionEl.innerText = `${img.naturalWidth} x ${img.naturalHeight}`;
+ const exifData = await getExif(img);
+ const exifEl = this.shadow.getElementById('gallery-exif');
+ if (exifEl) exifEl.innerHTML = exifData;
+ };
+ img.loading = 'lazy';
+ img.src = `file=${this.folder}/${this.name}`;
+ img.title = `Folder: ${this.folder}\nFile: ${this.name}\nResolution: ${img.naturalWidth} x ${img.naturalHeight}\nSize: ${this.size.toLocaleString()} bytes\nModified: ${this.mtime.toLocaleString()}`;
+ img.addEventListener('click', galleryClickEventHandler, true);
+ div.appendChild(img);
+ div.appendChild(text);
+ this.shadow.appendChild(div);
+ }
+}
+
+class GalleryFile extends HTMLElement {
+ constructor({ folder, file, size, mtime }) {
+ super();
+ this.folder = folder;
+ this.name = file;
+ this.size = size;
+ this.mtime = new Date(1000 * mtime);
+ this.hash = undefined;
+ this.exif = '';
+ this.width = 0;
+ this.height = 0;
+ this.shadow = this.attachShadow({ mode: 'open' });
+ }
+
+ async connectedCallback() {
+ const ext = this.name.split('.').pop().toLowerCase();
+ if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return;
+ this.hash = await getHash(`${this.folder}/${this.name}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define
+ const style = document.createElement('style');
+ const width = opts.browser_fixed_width ? `${opts.extra_networks_card_size}px` : 'unset';
+ style.textContent = `
+ .gallery-file {
+ object-fit: contain;
+ cursor: pointer;
+ height: ${opts.extra_networks_card_size}px;
+ width: ${width};
+ }
+ .gallery-file:hover {
+ filter: grayscale(100%);
+ }
+ `;
+
+ this.shadow.appendChild(style);
+ const img = document.createElement('img');
+ img.className = 'gallery-file';
+ img.loading = 'lazy';
+ img.title = `Folder: ${this.folder}\nFile: ${this.name}\nSize: ${this.size.toLocaleString()} bytes\nModified: ${this.mtime.toLocaleString()}`;
+ img.onload = async () => {
+ this.width = img.naturalWidth;
+ this.height = img.naturalHeight;
+ img.title += `\nResolution: ${this.width} x ${this.height}`;
+ this.title = img.title;
+ // let exif = await getExif(img);
+ // if (exif) this.exif = exif.replaceAll('
', '\n').replace(/<\/?[^>]+(>|$)/g, "");
+ };
+ img.src = `file=${this.folder}/${this.name}`;
+ img.onclick = () => {
+ el.image.innerHTML = '';
+ const image = new GalleryImage(this.folder, this.name, this.size, this.mtime);
+ el.image.appendChild(image);
+ };
+ this.title = img.title;
+ this.style.display = this.title.toLowerCase().includes(el.search.value.toLowerCase()) ? 'unset' : 'none';
+ this.shadow.appendChild(img);
+ }
+}
+
+// methods
+
+async function getHash(str, algo = 'SHA-256') {
+ const strBuf = new TextEncoder().encode(str);
+ const hash = await crypto.subtle.digest(algo, strBuf);
+ let hex = '';
+ 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;
+}
+
+async function wsConnect(socket, timeout = 2000) {
+ 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)); // eslint-disable-line no-promise-executor-return
+ loop++;
+ }
+ return isOpened();
+}
+
+async function gallerySearch(evt) {
+ if (el.search.busy) clearTimeout(el.search.busy);
+ el.search.busy = setTimeout(async () => {
+ let numFound = 0;
+ const all = Array.from(el.files.children);
+ const str = el.search.value.toLowerCase();
+ const r = /^(.+)([=<>])(.*)/;
+ const t0 = performance.now();
+ for (const f of all) {
+ 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);
+ console.log('HERE', key, op, val, f[key]);
+ if (((op === '=') && (f[key] === val)) || ((op === '>') && (f[key] > val)) || ((op === '<') && (f[key] < val))) {
+ f.style.display = 'unset';
+ numFound++;
+ } else {
+ f.style.display = 'none';
+ }
+ } else if (f.title?.toLowerCase().includes(str) || f.exif?.toLowerCase().includes(str)) {
+ f.style.display = 'unset';
+ numFound++;
+ } else {
+ f.style.display = 'none';
+ }
+ const t1 = performance.now();
+ el.status.innerText = `Filter | ${f.folder} | ${numFound.toLocaleString()}/${all.length.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`;
+ }
+ }, 250);
+}
+
+async function gallerySort(btn) {
+ const t0 = performance.now();
+ document.querySelectorAll('.gallery-separator').forEach((node) => el.files.removeChild(node)); // cannot sort separators
+ const arr = Array.from(el.files.children);
+ switch (btn.charCodeAt(0)) {
+ case 61789:
+ arr
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .forEach((node) => el.files.appendChild(node));
+ break;
+ case 61790:
+ arr
+ .sort((b, a) => a.name.localeCompare(b.name))
+ .forEach((node) => el.files.appendChild(node));
+ break;
+ case 61792:
+ arr
+ .sort((a, b) => a.size - b.size)
+ .forEach((node) => el.files.appendChild(node));
+ break;
+ case 61793:
+ arr
+ .sort((b, a) => a.size - b.size)
+ .forEach((node) => el.files.appendChild(node));
+ break;
+ case 61794:
+ arr
+ .sort((a, b) => a.width * a.height - b.width * b.height)
+ .forEach((node) => el.files.appendChild(node));
+ break;
+ case 61795:
+ arr
+ .sort((b, a) => a.width * a.height - b.width * b.height)
+ .forEach((node) => el.files.appendChild(node));
+ break;
+ case 61662:
+ arr
+ .sort((a, b) => a.mtime - b.mtime)
+ .forEach((node) => el.files.appendChild(node));
+ break;
+ case 61661:
+ arr
+ .sort((b, a) => a.mtime - b.mtime)
+ .forEach((node) => el.files.appendChild(node));
+ break;
+ default:
+ break;
+ }
+ const t1 = performance.now();
+ el.status.innerText = `Sort | ${arr.length.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`;
+}
+
+async function fetchFiles(evt) { // fetch file-by-file list over websockets
+ el.files.innerHTML = '';
+ if (!url) return;
+ if (ws && ws.readyState === WebSocket.OPEN) ws.close(); // abort previous request
+ ws = new WebSocket(`${url}/sdapi/v1/browser/files`);
+ await wsConnect(ws);
+ let numFiles = 0;
+ el.status.innerText = `Folder | ${evt.target.name}`;
+ const t0 = performance.now();
+ let t1 = performance.now();
+ let lastDir;
+ ws.onmessage = (event) => { // time is 20% list 80% create item
+ numFiles++;
+ t1 = performance.now();
+ if (event.data === '#END#') {
+ ws.close();
+ } else {
+ const json = JSON.parse(event.data);
+ const dir = json.file.match(/(.*)[\/\\]/) || '';
+ if (dir?.[1] !== lastDir) { // create separator
+ lastDir = dir[1];
+ const sep = document.createElement('div');
+ sep.className = 'gallery-separator';
+ sep.innerText = lastDir;
+ sep.title = lastDir;
+ el.files.appendChild(sep);
+ }
+ const file = new GalleryFile(json);
+ el.files.appendChild(file);
+ el.status.innerText = `Folder | ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`;
+ }
+ };
+ ws.onclose = (event) => {
+ // log('gallery ws file enum', event);
+ };
+ ws.onerror = (event) => {
+ log('gallery ws error', event);
+ };
+ ws.send(evt.target.name);
+}
+
+async function galleryVisible() {
+ // if (el.folders.children.length > 0) return;
+ const res = await fetch('/sdapi/v1/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);
+ }
+}
+
+async function galleryHidden() { /**/ }
+
+async function galleryObserve() { // triggered on gradio change to monitor when ui gets sufficiently constructed
+ log('initBrowser');
+ el.folders = gradioApp().getElementById('tab-gallery-folders');
+ el.files = gradioApp().getElementById('tab-gallery-files');
+ el.image = gradioApp().getElementById('tab-gallery-image');
+ el.status = gradioApp().getElementById('tab-gallery-status');
+ el.search = gradioApp().querySelector('#tab-gallery-search textarea');
+ el.search.addEventListener('input', gallerySearch);
+
+ const intersectionObserver = new IntersectionObserver((entries) => {
+ if (entries[0].intersectionRatio <= 0) galleryHidden();
+ if (entries[0].intersectionRatio > 0) galleryVisible();
+ });
+ intersectionObserver.observe(el.folders);
+}
+
+// register on startup
+
+customElements.define('gallery-folder', GalleryFolder);
+customElements.define('gallery-file', GalleryFile);
+customElements.define('gallery-image', GalleryImage);
+onUiLoaded(galleryObserve);
diff --git a/modules/api/api.py b/modules/api/api.py
index 398719aa3..2a8f68c51 100644
--- a/modules/api/api.py
+++ b/modules/api/api.py
@@ -5,7 +5,7 @@ from fastapi import FastAPI, APIRouter, Depends, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.exceptions import HTTPException
from modules import errors, shared, postprocessing
-from modules.api import models, endpoints, script, helpers, server, nvml, generate, process, control
+from modules.api import models, endpoints, script, helpers, server, nvml, generate, process, control, gallery
errors.install()
@@ -83,6 +83,9 @@ class Api:
self.add_api_route("/sdapi/v1/reload-checkpoint", endpoints.post_reload_checkpoint, methods=["POST"])
self.add_api_route("/sdapi/v1/refresh-vae", endpoints.post_refresh_vae, methods=["POST"])
+ # gallery api
+ gallery.register_api(app)
+
def add_api_route(self, path: str, endpoint, **kwargs):
if (shared.cmd_opts.auth or shared.cmd_opts.auth_file) and shared.cmd_opts.api_only:
return self.app.add_api_route(path, endpoint, dependencies=[Depends(self.auth)], **kwargs)
diff --git a/modules/api/gallery.py b/modules/api/gallery.py
new file mode 100644
index 000000000..d6113d57c
--- /dev/null
+++ b/modules/api/gallery.py
@@ -0,0 +1,114 @@
+import os
+from typing import List
+from fastapi import FastAPI
+from fastapi.responses import JSONResponse
+from starlette.websockets import WebSocket, WebSocketState, WebSocketDisconnect
+from pydantic import BaseModel, Field # pylint: disable=no-name-in-module
+from modules import shared, files_cache
+
+
+debug = shared.log.debug if os.environ.get('SD_BROWSER_DEBUG', None) is not None else lambda *args, **kwargs: None
+
+
+OPTS_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",
+]
+
+### class definitions
+
+class ReqFiles(BaseModel):
+ folder: str = Field(title="Folder")
+
+### ws connection manager
+
+class ConnectionManager:
+ def __init__(self):
+ self.active: list[WebSocket] = []
+
+ async def connect(self, ws: WebSocket):
+ await ws.accept()
+ agent = ws._headers.get("user-agent", "") # pylint: disable=protected-access
+ debug(f'Browser WS connect: client={ws.client.host} agent="{agent}"')
+ self.active.append(ws)
+
+ def disconnect(self, ws: WebSocket):
+ debug(f'Browser WS disconnect: client={ws.client.host}')
+ self.active.remove(ws)
+
+ async def send(self, ws: WebSocket, data: str|dict|bytes):
+ # debug(f'Browser WS send: client={ws.client.host} data={type(data)}')
+ if ws.client_state != WebSocketState.CONNECTED:
+ return
+ if isinstance(data, bytes):
+ await ws.send_bytes(data)
+ elif isinstance(data, dict):
+ await ws.send_json(data)
+ elif isinstance(data, str):
+ await ws.send_text(data)
+ else:
+ debug(f'Browser WS send: client={ws.client.host} data={type(data)} unknown')
+
+ async def broadcast(self, data: str|dict|bytes):
+ for ws in self.active:
+ await self.send(ws, data)
+
+### api definitions
+
+def register_api(app: FastAPI): # register api
+ manager = ConnectionManager()
+
+ @app.get('/sdapi/v1/browser/folders', response_model=List[str])
+ def get_folders():
+ folders = [shared.opts.data.get(f, '') for f in OPTS_FOLDERS]
+ folders += list(shared.opts.browser_folders.split(','))
+ folders = [f.strip() for f in folders if f != '']
+ folders = list(dict.fromkeys(folders)) # filter duplicates
+ folders = [f for f in folders if os.path.isdir(f)]
+ if shared.demo is not None:
+ for f in folders:
+ if os.path.isabs(f) and f not in shared.demo.allowed_paths:
+ debug(f'Browser folders allow: {f}')
+ shared.demo.allowed_paths.append(f)
+ debug(f'Browser folders: {folders}')
+ return JSONResponse(content=folders)
+
+ @app.websocket("/sdapi/v1/browser/files")
+ async def ws_files(ws: WebSocket):
+ try:
+ await manager.connect(ws)
+ folder = await ws.receive_text()
+ debug(f'Browser WS folder: {folder}')
+ for f in files_cache.directory_files(folder, recursive=True):
+ file = os.path.relpath(f, folder)
+ stat = os.stat(f)
+ dct = {
+ 'folder': folder,
+ 'file': file,
+ 'size': stat.st_size,
+ 'mtime': stat.st_mtime,
+ }
+ await manager.send(ws, dct)
+ await manager.send(ws, '#END#')
+ except WebSocketDisconnect:
+ debug('Browser WS unexpected disconnect')
+ manager.disconnect(ws)
+
+ @app.websocket("/sdapi/v1/browser/file/{file}")
+ async def ws_file(ws: WebSocket, file: str):
+ try:
+ await manager.connect(ws)
+ with open(file, 'rb') as f: # noqa: ASYNC101
+ await manager.send(ws, f.read())
+ except WebSocketDisconnect:
+ manager.disconnect(ws)
diff --git a/modules/ui.py b/modules/ui.py
index e0f21dcd4..45f7ed79f 100644
--- a/modules/ui.py
+++ b/modules/ui.py
@@ -154,6 +154,11 @@ def create_ui(startup_timer = None):
ui_models.create_ui()
timer.startup.record("ui-models")
+ with gr.Blocks(analytics_enabled=False) as gallery_interface:
+ from modules import ui_gallery
+ ui_gallery.create_ui()
+ timer.startup.record("ui-gallery")
+
def create_setting_component(key, is_quicksettings=False):
def fun():
return opts.data[key] if key in opts.data else opts.data_labels[key].default
@@ -370,6 +375,7 @@ def create_ui(startup_timer = None):
interfaces += [(img2img_interface, "Image", "img2img")]
interfaces += [(control_interface, "Control", "control")] if control_interface is not None else []
interfaces += [(extras_interface, "Process", "process")]
+ interfaces += [(gallery_interface, "Gallery", "gallery")]
interfaces += [(models_interface, "Models", "models")]
interfaces += script_callbacks.ui_tabs_callback()
interfaces += [(settings_interface, "System", "system")]
diff --git a/modules/ui_gallery.py b/modules/ui_gallery.py
new file mode 100644
index 000000000..c5ddceb63
--- /dev/null
+++ b/modules/ui_gallery.py
@@ -0,0 +1,33 @@
+import os
+import gradio as gr
+from modules import shared, ui_symbols
+from modules.ui_components import ToolButton
+
+
+debug = shared.log.debug if os.environ.get('SD_GALLERY_DEBUG', None) is not None else lambda *args, **kwargs: None
+
+
+def create_ui():
+ with gr.Blocks() as tab:
+ with gr.Row():
+ sort_buttons = []
+ sort_buttons.append(ToolButton(value=ui_symbols.sort_alpha_asc, show_label=False))
+ sort_buttons.append(ToolButton(value=ui_symbols.sort_alpha_dsc, show_label=False))
+ sort_buttons.append(ToolButton(value=ui_symbols.sort_size_asc, show_label=False))
+ sort_buttons.append(ToolButton(value=ui_symbols.sort_size_dsc, show_label=False))
+ sort_buttons.append(ToolButton(value=ui_symbols.sort_num_asc, show_label=False))
+ sort_buttons.append(ToolButton(value=ui_symbols.sort_num_dsc, show_label=False))
+ sort_buttons.append(ToolButton(value=ui_symbols.sort_time_asc, show_label=False))
+ sort_buttons.append(ToolButton(value=ui_symbols.sort_time_dsc, show_label=False))
+ gr.Textbox(show_label=False, placeholder='Search', elem_id='tab-gallery-search')
+ gr.HTML('', elem_id='tab-gallery-status')
+ for btn in sort_buttons:
+ btn.click(fn=None, _js='gallerySort', inputs=[btn], outputs=[])
+ with gr.Row():
+ with gr.Column():
+ gr.HTML('', elem_id='tab-gallery-folders')
+ with gr.Column():
+ gr.HTML('', elem_id='tab-gallery-files')
+ with gr.Column():
+ gr.HTML('', elem_id='tab-gallery-image')
+ return [(tab, 'Gallery', 'tab-gallery')]