From 1a27871c70d6de976f3cd33f25eb3bd1518df294 Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Wed, 20 Mar 2024 15:32:55 -0400 Subject: [PATCH] initial version of native gallery --- .eslintrc.json | 2 + CHANGELOG.md | 26 ++- README.md | 5 +- javascript/gallery.js | 350 +++++++++++++++++++++++++++++++++++++++++ modules/api/api.py | 5 +- modules/api/gallery.py | 114 ++++++++++++++ modules/ui.py | 6 + modules/ui_gallery.py | 33 ++++ 8 files changed, 537 insertions(+), 4 deletions(-) create mode 100644 javascript/gallery.js create mode 100644 modules/api/gallery.py create mode 100644 modules/ui_gallery.py 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')]