diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f4be47c..10dd819c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## Update for 2024-03-23 - **Features**: - - **Gallery**: + - **Gallery**: list, preview, search through all your images and videos! implemented as infinite-scroll with client-side-caching and lazy-loading while being fully async and non-blocking search or sort by path, name, size, width, height, mtime or any image metadata item, also with extended syntax like *width > 1000* *settings*: optional additional user-defined folders, thumbnails in fixed or variable aspect-ratio diff --git a/javascript/gallery.js b/javascript/gallery.js index b73fd512f..2306d0c5c 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -100,8 +100,8 @@ async function delayFetchThumb(fn) { } const json = await res.json(); outstanding--; - if (!res || !json || json.error) { - console.error(json.error); + if (!res || !json || json.error || Object.keys(json).length === 0) { + if (json.error) console.error(json.error); return undefined; } return json; @@ -125,7 +125,7 @@ class GalleryFile extends HTMLElement { async connectedCallback() { if (this.shadow.children.length > 0) return; const ext = this.name.split('.').pop().toLowerCase(); - if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return; + if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'mp4'].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'; @@ -158,7 +158,7 @@ class GalleryFile extends HTMLElement { } }; let ok = true; - if (cache) { + if (cache && cache.img) { img.src = cache.img; this.exif = cache.exif; this.width = cache.width; diff --git a/modules/api/gallery.py b/modules/api/gallery.py index bb60194f6..f792da5d6 100644 --- a/modules/api/gallery.py +++ b/modules/api/gallery.py @@ -21,7 +21,7 @@ OPTS_FOLDERS = [ "outdir_img2img_samples", "outdir_control_samples", "outdir_extras_samples", - "outdir_save" + "outdir_save", "outdir_video", "outdir_init_images", "outdir_grids", @@ -73,6 +73,29 @@ class ConnectionManager: def register_api(app: FastAPI): # register api manager = ConnectionManager() + def get_video_thumbnail(filepath): + from modules.ui_control_helpers import get_video_params + try: + frames, fps, duration, width, height, codec, frame = get_video_params(filepath, capture=True) + h = shared.opts.extra_networks_card_size + w = shared.opts.extra_networks_card_size if shared.opts.browser_fixed_width else width * h // height + frame = frame.convert('RGB') + frame.thumbnail((w, h), Image.Resampling.HAMMING) + buffered = io.BytesIO() + frame.save(buffered, format='jpeg') + data_url = f'data:image/jpeg;base64,{base64.b64encode(buffered.getvalue()).decode("ascii")}' + frame.close() + content = { + 'exif': f'Codec: {codec}, Frames: {frames}, Duration: {duration:.2f} sec, FPS: {fps:.2f}', + 'data': data_url, + 'width': width, + 'height': height, + } + return content + except Exception as e: + shared.log.error(f'Gallery video: file="{filepath}" {e}') + return {} + @app.get('/sdapi/v1/browser/folders', response_model=List[str]) def get_folders(): folders = [shared.opts.data.get(f, '') for f in OPTS_FOLDERS] @@ -92,24 +115,28 @@ def register_api(app: FastAPI): # register api async def get_thumb(file: str): try: decoded = unquote(file) - image = Image.open(decoded) - geninfo, _items = images.read_info_from_image(image) - h = shared.opts.extra_networks_card_size - w = shared.opts.extra_networks_card_size if shared.opts.browser_fixed_width else image.width * h // image.height - width, height = image.width, image.height - image = image.convert('RGB') - image.thumbnail((w, h), Image.Resampling.HAMMING) - buffered = io.BytesIO() - image.save(buffered, format='jpeg') - data_url = f'data:image/jpeg;base64,{base64.b64encode(buffered.getvalue()).decode("ascii")}' - image.close() - content = { - 'exif': geninfo, - 'data': data_url, - 'width': width, - 'height': height, - } - return JSONResponse(content=content) + if decoded.lower().endswith('.mp4'): + content = get_video_thumbnail(decoded) + return JSONResponse(content=content) + else: + image = Image.open(decoded) + geninfo, _items = images.read_info_from_image(image) + h = shared.opts.extra_networks_card_size + w = shared.opts.extra_networks_card_size if shared.opts.browser_fixed_width else image.width * h // image.height + width, height = image.width, image.height + image = image.convert('RGB') + image.thumbnail((w, h), Image.Resampling.HAMMING) + buffered = io.BytesIO() + image.save(buffered, format='jpeg') + data_url = f'data:image/jpeg;base64,{base64.b64encode(buffered.getvalue()).decode("ascii")}' + image.close() + content = { + 'exif': geninfo, + 'data': data_url, + 'width': width, + 'height': height, + } + return JSONResponse(content=content) except Exception as e: shared.log.error(f'Gallery: {file} {e}') content = { 'error': str(e) } @@ -135,7 +162,7 @@ def register_api(app: FastAPI): # register api await manager.send(ws, dct) await manager.send(ws, '#END#') t1 = time.time() - shared.log.debug(f'Gallery: folder={folder} files={numFiles} time={t1-t0:.3f}') + shared.log.debug(f'Gallery: folder="{folder}" files={numFiles} time={t1-t0:.3f}') except WebSocketDisconnect: debug('Browser WS unexpected disconnect') manager.disconnect(ws) diff --git a/modules/shared.py b/modules/shared.py index ac87984dd..5ff34d09c 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -778,6 +778,14 @@ class Options: return return super(Options, self).__setattr__(key, value) # pylint: disable=super-with-arguments + def get(self, item): + if self.data is not None: + if item in self.data: + return self.data[item] + if item in self.data_labels: + return self.data_labels[item].default + return super(Options, self).__getattribute__(item) # pylint: disable=super-with-arguments + def __getattr__(self, item): if self.data is not None: if item in self.data: diff --git a/modules/ui_control_helpers.py b/modules/ui_control_helpers.py index 74081cb08..b41f8f6f7 100644 --- a/modules/ui_control_helpers.py +++ b/modules/ui_control_helpers.py @@ -71,21 +71,31 @@ def display_units(num_units): return (num_units * [gr.update(visible=True)]) + ((max_units - num_units) * [gr.update(visible=False)]) +def get_video_params(filepath: str, capture: bool = False): + import cv2 + from modules.control.util import decode_fourcc + video = cv2.VideoCapture(filepath) + if not video.isOpened(): + msg = f'Control: video open failed: path="{filepath}"' + shared.log.error(msg) + raise RuntimeError(msg) + frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) + fps = video.get(cv2.CAP_PROP_FPS) + duration = float(frames) / fps + w, h = int(video.get(cv2.CAP_PROP_FRAME_WIDTH)), int(video.get(cv2.CAP_PROP_FRAME_HEIGHT)) + codec = decode_fourcc(video.get(cv2.CAP_PROP_FOURCC)) + frame = None + if capture: + _status, frame = video.read() + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + frame = Image.fromarray(frame) + video.release() + return frames, fps, duration, w, h, codec, frame + + def get_video(filepath: str): try: - import cv2 - from modules.control.util import decode_fourcc - video = cv2.VideoCapture(filepath) - if not video.isOpened(): - msg = f'Control: video open failed: path="{filepath}"' - shared.log.error(msg) - return msg - frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) - fps = video.get(cv2.CAP_PROP_FPS) - duration = float(frames) / fps - w, h = int(video.get(cv2.CAP_PROP_FRAME_WIDTH)), int(video.get(cv2.CAP_PROP_FRAME_HEIGHT)) - codec = decode_fourcc(video.get(cv2.CAP_PROP_FOURCC)) - video.release() + frames, fps, duration, w, h, codec, _cap = get_video_params(filepath) shared.log.debug(f'Control: input video: path={filepath} frames={frames} fps={fps} size={w}x{h} codec={codec}') msg = f'Control input | Video | Size {w}x{h} | Frames {frames} | FPS {fps:.2f} | Duration {duration:.2f} | Codec {codec}' return msg diff --git a/modules/ui_gallery.py b/modules/ui_gallery.py index 32d060788..d76373d41 100644 --- a/modules/ui_gallery.py +++ b/modules/ui_gallery.py @@ -2,25 +2,39 @@ import os from datetime import datetime import gradio as gr from PIL import Image -from modules import ui_symbols, ui_common, images +from modules import ui_symbols, ui_common, images, ui_control_helpers from modules.ui_components import ToolButton -def read_image(fn): +def read_media(fn): if not os.path.isfile(fn): - return [[], '', f'Image not found: {fn}'] + return [[], None, '', f'Image not found: {fn}'] stat = os.stat(fn) - image = Image.open(fn) - image.already_saved_as = fn - geninfo, _items = images.read_info_from_image(image) - log = f''' -
Image {image.width} x {image.height} - | Format {image.format} - | Mode {image.mode} - | Size {stat.st_size:,} - | Modified {datetime.fromtimestamp(stat.st_mtime)}
Video {w} x {h} + | Codec {codec} + | Frames {frames:,} + | FPS {fps} + | Duration {duration:,} + | Size {stat.st_size:,} + | Modified {datetime.fromtimestamp(stat.st_mtime)}
Image {image.width} x {image.height} + | Format {image.format} + | Mode {image.mode} + | Size {stat.st_size:,} + | Modified {datetime.fromtimestamp(stat.st_mtime)}