diff --git a/CHANGELOG.md b/CHANGELOG.md index 672311bc9..8265ddb25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log for SD.Next -## Update for 2026-05-04 +## Update for 2026-05-05 - **Features** - **Multi-image** workflows! @@ -22,6 +22,7 @@ order of stages detemines order of images passed to model - **Kanvas** *magic-wand* tool now works on mask layer and auto-creates mask based on perceptual tolerance - **Gallery** add thumbnail size slider + - **Gallery** add quick info/download/delete buttons on thumbnail hover - **Control** - remove buttons: *input/control/process* - move params *control input type* to control menu section @@ -29,6 +30,7 @@ preprocessor output can still be generated by clicking preview button in in control unit and it will render into normal output area - **Internal** - refactor `pip` installer, thanks @awsr + - remove obsolete `lora` stepwise and functional code, thanks @awsr - **Fixes** - add missing `jquery` and `sparkline` js scripts - save handle already decoded images diff --git a/TODO.md b/TODO.md index c35a96aaf..38d32097f 100644 --- a/TODO.md +++ b/TODO.md @@ -4,7 +4,6 @@ ### Assigned -- Gallery: quick delete/download/info @vladmandic - Chat-based interface, @vladmandic - Control tab verify overrides handling, @vladmandic - Reimplement `llama` remover for Kanvas, @vladmandic diff --git a/javascript/gallery.js b/javascript/gallery.js index 7a285510e..b82d94c81 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -3,6 +3,7 @@ let ws; let url; let currentSize = 0; let currentSort = 'none'; +let currentName = ''; let currentImage = null; let currentGalleryFolder = null; let pruneImagesTimer; @@ -557,6 +558,8 @@ class GalleryFile extends HTMLElement { img.onpointerenter = () => { el.overlay.display = 'block'; this.shadow.appendChild(el.overlay); + currentImage = this.src; + currentName = this.name; }; img.onpointerleave = () => { el.overlay.display = 'none'; @@ -1017,24 +1020,24 @@ async function thumbCacheCleanup(folder, imgCount, controller, force = false) { 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'); + debug('thumbCacheCleanup: wait'); await awaitForGallery(imgCount, controller.signal); } catch (err) { - debug(`Thumbnail DB cleanup: Skipping cleanup for "${folder}" due to "${err}"`); + error('thumbCacheCleanup', { folder, error: err }); return; } maintenanceQueue.enqueue({ signal: controller.signal, callback: async () => { - log(`Thumbnail DB cleanup: Checking if "${folder}" needs cleaning`); + log('maintenanceQueue', { folder }); const t0 = performance.now(); const keptGalleryHashes = force ? new Set() : new Set(galleryHashes.values()); // External context should be safe since this function run is guarded by AbortController/AbortSignal in the SimpleFunctionQueue const folderNormalized = folder.replace(/\/+/g, '/').replace(/\/$/, ''); const recursiveFolder = IDBKeyRange.bound(folderNormalized, `${folderNormalized}\uffff`, false, true); const cachedHashesCount = await idbCount(recursiveFolder) .catch((e) => { - error(`Thumbnail DB cleanup: Error when getting entry count for "${folder}".`, e); + error('maintenanceQueue', { folder, error: e }); return Infinity; // Forces next check to fail if something went wrong }); const cleanupCount = cachedHashesCount - keptGalleryHashes.size; @@ -1044,21 +1047,21 @@ async function thumbCacheCleanup(folder, imgCount, controller, force = false) { } if (controller.signal.aborted) { - debug(`Thumbnail DB cleanup: Cancelling "${folder}" cleanup due to "${controller.signal.reason}"`); + debug('maintenanceQueue', { folder, reason: controller.signal.reason }); return; } const cb_clearMsg = showCleaningMsg(cleanupCount); await idbFolderCleanup(keptGalleryHashes, recursiveFolder, controller.signal) .then((delcount) => { const t1 = performance.now(); - log(`Thumbnail DB cleanup: folder=${folder} kept=${keptGalleryHashes.size} deleted=${delcount} time=${Math.round(t1 - t0)}ms`); + log('maintenanceQueue', { folder, kept: keptGalleryHashes.size, deleted: delcount, time: Math.round(t1 - t0) }); timer(`thumbnailDBCleanup:${folder}`, t1 - t0); currentGalleryFolder = null; el.clearCacheFolder.innerText = ''; updateStatusWithSort('Thumbnail cache cleared'); }) .catch((e) => { - SimpleFunctionQueue.abortLogger('Thumbnail DB cleanup:', e); + SimpleFunctionQueue.abortLogger('thumbCacheCleanup', e); }) .finally(async () => { await new Promise((resolve) => { setTimeout(resolve, 1000); }); @@ -1332,18 +1335,51 @@ async function initGalleryAutoRefresh() { } async function overlayDelete(evt) { - console.log('galleryDelete', evt); + const res = await authFetch(`${window.api}/delete-image?file=${encodeURIComponent(currentImage)}`); evt.stopPropagation(); + if (!res || res.status !== 200) { + error('galleryDelete', { file: currentImage, status: res?.status, statusText: res?.statusText }); + return; + } + const data = await res.json(); + log('galleryDelete', data); + GalleryFolder.getActive()?.click(); } async function overlayDownload(evt) { - console.log('galleryDownload', evt); + log('galleryDownload', currentImage); + const link = document.createElement('a'); + link.href = `/file=${encodeURIComponent(currentImage)}`; + link.download = currentName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); evt.stopPropagation(); } async function overlayInfo(evt) { - console.log('galleryInfo', evt); evt.stopPropagation(); + const tgt = document.getElementById('html_info_formatted_gallery'); + if (!tgt) return; + const res = await authFetch(`${window.api}/png-info?file=${encodeURI(currentImage)}`); + if (!res || res.status !== 200) return; + const data = await res.json(); + log('galleryInfo res', data); + const prompt = data?.parameters?.Prompt || ''; + const negative = data?.parameters?.Negative || data?.parameters?.['Negative prompt'] || ''; + const raw = data?.info || ''; + const params = data?.parameters || {}; + delete params.Prompt; + delete params.Negative; + delete params['Negative prompt']; + const paramsFormatted = Object.entries(params).map(([key, value]) => `${key}: ${value}`).join('
'); + tgt.innerHTML = ` +
File: ${currentImage}
+
Prompt: ${prompt}
+
Negative: ${negative}
+
${paramsFormatted}
+
Raw:
${raw}
+ `; } async function createOverlay() { diff --git a/modules/api/api.py b/modules/api/api.py index 098d83f98..93c5bd213 100644 --- a/modules/api/api.py +++ b/modules/api/api.py @@ -93,6 +93,9 @@ class Api: self.add_api_route("/sdapi/v1/unets", endpoints.get_unets, methods=["GET"], response_model=list[models.ItemUNet]) # functional api + self.add_api_route("/sdapi/v1/file", endpoints.get_file, methods=["GET"], tags=["Functional"]) + self.add_api_route("/sdapi/v1/delete-image", endpoints.get_deleteimage, methods=["GET"], tags=["Functional"]) + self.add_api_route("/sdapi/v1/png-info", endpoints.get_pnginfo, methods=["GET"], response_model=models.ResImageInfo, tags=["Functional"]) self.add_api_route("/sdapi/v1/png-info", endpoints.post_pnginfo, methods=["POST"], response_model=models.ResImageInfo, tags=["Functional"]) self.add_api_route("/sdapi/v1/checkpoint", endpoints.get_checkpoint, methods=["GET"], tags=["Functional"]) self.add_api_route("/sdapi/v1/checkpoint", endpoints.set_checkpoint, methods=["POST"], tags=["Functional"]) diff --git a/modules/api/endpoints.py b/modules/api/endpoints.py index 2d27c6003..3e83e836e 100644 --- a/modules/api/endpoints.py +++ b/modules/api/endpoints.py @@ -323,6 +323,73 @@ def get_extensions_list(): }) return ext_list +def get_file(file: str): + import os + from pathlib import Path + from starlette.responses import FileResponse + from fastapi.exceptions import HTTPException + allowed_dirs = shared.demo.allowed_paths + if not file.strip(): + raise HTTPException(status_code=400, detail="file path is required") + if not any(Path(folder).absolute() in Path(file).absolute().parents for folder in allowed_dirs): + raise HTTPException(status_code=403, detail=f"file {file}: must be in one of allowed directories") + if not os.path.exists(file): + raise HTTPException(status_code=404, detail=f"file not found: {file}") + if os.path.isdir(file): + raise HTTPException(status_code=403, detail=f"file {file}: is a directory") + return FileResponse(file, media_type='application/octet-stream', filename=file) + +def get_deleteimage(file: str): + import os + from pathlib import Path + from fastapi.exceptions import HTTPException + allowed_dirs = shared.demo.allowed_paths + if not file.strip(): + raise HTTPException(status_code=400, detail="file path is required") + if not any(Path(folder).absolute() in Path(file).absolute().parents for folder in allowed_dirs): + raise HTTPException(status_code=403, detail=f"file {file}: must be in one of allowed directories") + if not os.path.exists(file): + raise HTTPException(status_code=404, detail=f"file not found: {file}") + if os.path.isdir(file): + raise HTTPException(status_code=403, detail=f"file {file}: is a directory") + if os.path.splitext(file)[1].lower() not in (".png", ".jpg", ".jpeg", ".webp"): + raise HTTPException(status_code=403, detail=f"file {file}: not an image file") + try: + os.remove(file) + return {"deleted": f"{file}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"error deleting file {file}: {str(e)}") from e + +def get_pnginfo(file: str): + """Extract generation parameters from a image file path. Returns raw info string and parsed parameters dict.""" + import os + from pathlib import Path + from PIL import Image + from fastapi.exceptions import HTTPException + from modules import images, infotext + allowed_dirs = shared.demo.allowed_paths + if not file.strip(): + raise HTTPException(status_code=400, detail="file path is required") + if not any(Path(folder).absolute() in Path(file).absolute().parents for folder in allowed_dirs): + raise HTTPException(status_code=403, detail=f"file {file}: must be in one of allowed directories") + if os.path.splitext(file)[1].lower() not in (".png", ".jpg", ".jpeg", ".webp"): + raise HTTPException(status_code=403, detail=f"file {file}: not an image file") + if not os.path.isfile(file): + raise HTTPException(status_code=403, detail=f"file {file}: not an image file") + image = None + try: + image = Image.open(file) + image.load() + except Exception as e: + raise HTTPException(status_code=403, detail=f"file {file}: not an image file") from e + if image is None: + raise HTTPException(status_code=403, detail=f"file {file}: not an image file") + geninfo, items = images.read_info_from_image(image) + if geninfo is None: + geninfo = "" + params = infotext.parse(geninfo) + return models.ResImageInfo(info=geninfo, items=items, parameters=params) + def post_pnginfo(req: models.ReqImageInfo): """Extract generation parameters from a PNG image's metadata. Returns raw info string and parsed parameters dict.""" from modules import images, script_callbacks, infotext