initial version of native gallery

This commit is contained in:
Vladimir Mandic
2024-03-20 15:32:55 -04:00
parent 0dd0fe27f0
commit 1a27871c70
8 changed files with 537 additions and 4 deletions
+2
View File
@@ -89,6 +89,8 @@
// imageviewer.js
"modalPrevImage": "readonly",
"modalNextImage": "readonly",
"galleryClickEventHandler": "readonly",
"getExif": "readonly",
// logMonitor.js
"jobStatusEl": "readonly",
// loader.js
+25 -1
View File
@@ -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
+3 -2
View File
@@ -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**
+350
View File
@@ -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 = `
<b>Folder:</b> ${this.folder}<br>
<b>File:</b> ${this.name}<br>
<b>Resolution:</b> <span id="gallery-resolution"></span><br>
<b>Size:</b> ${this.size.toLocaleString()} bytes<br>
<b>Modified:</b> ${this.mtime.toLocaleString()}<br>
<br>
<span id="gallery-exif"></span>
`;
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('<br>', '\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);
+4 -1
View File
@@ -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)
+114
View File
@@ -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)
+6
View File
@@ -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")]
+33
View File
@@ -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')]