From c3fb648ec3e8943f0809901dd5a6f4a92614efbe Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 17 Aug 2021 22:26:06 -0700 Subject: [PATCH] clean up thumb generation --- photoapp/daemon.py | 52 ++++++++++++++++--------- photoapp/thumb.py | 95 +++++++++++++++++++++++++++------------------- photoapp/types.py | 2 + 3 files changed, 91 insertions(+), 58 deletions(-) diff --git a/photoapp/daemon.py b/photoapp/daemon.py index 147afda..e5f55ea 100644 --- a/photoapp/daemon.py +++ b/photoapp/daemon.py @@ -3,10 +3,11 @@ import math import time import logging import cherrypy +from collections import defaultdict from urllib.parse import urlparse from datetime import datetime, timedelta from photoapp.thumb import ThumbGenerator -from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, mime2ext +from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, mime2ext, regular_mimes, video_mimes from photoapp.dbsession import DatabaseSession from photoapp.common import pwhash from photoapp.api import PhotosApi, LibraryManager @@ -241,6 +242,8 @@ class ThumbnailView(object): @cherrypy.expose def index(self, item_type, thumb_size, uuid): + # TODO some lock around calls to this based on photoset uuid + # TODO it is currently arbitrary which jpg of many or which video of many becomes the thumb. Make it certain. uuid = uuid.split(".")[0] query = photoset_auth_filter(db.query(Photo).join(PhotoSet)) @@ -250,25 +253,38 @@ class ThumbnailView(object): assert query - # prefer making thumbs from jpeg to avoid loading large raws - # jk we can't load raws anyway - first = None - best = None + formats = defaultdict(list) for photo in query.all(): - if first is None: - first = photo - if photo.format == "image/jpeg": - best = photo - break - thumb_from = best or first - if not thumb_from: - raise cherrypy.HTTPError(404) - # TODO some lock around calls to this based on uuid - thumb_fobj = self.master.thumbtool.make_thumb(thumb_from, thumb_size) + formats[photo.format].append(photo) + formats = dict(formats) - if thumb_fobj: - return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg") - else: + # prefer image files. If an image is available assume it is the target or intended to be the thumbnail. + imtypes = regular_mimes.intersection(set(formats.keys())) + if imtypes: + # prefer making thumbs from jpeg to avoid loading large raws + # jk we can't load raws anyway + thumb_from = formats.get("image/jpeg") + if thumb_from: + thumb_from = thumb_from[0] # TODO if we're thumbing a set this is an arbitrary image picked + else: + t = imtypes.pop() # TODO arbitrary + thumb_from = formats[t][0] # TODO arbitrary + + thumb_fobj = self.master.thumbtool.make_photo_thumb(thumb_from, thumb_size) + + if thumb_fobj: + return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg") + else: + return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml") + + elif video_mimes.intersection(set(formats.keys())): + # call out to the video thumb service + #TODO call out - placeholder for now + #TODO thumb service configuration option or show the ? (or a thumb that suggests its a video?) + #TODO make this set no cache headers + return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml") + + else: # No convertable file types return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml") diff --git a/photoapp/thumb.py b/photoapp/thumb.py index 266de3e..8ec0609 100644 --- a/photoapp/thumb.py +++ b/photoapp/thumb.py @@ -11,40 +11,70 @@ from contextlib import closing import logging +# style tuples: max x, max y, rotate ok) +# rotate ok means x and y maxes can be swapped if it fits the image's aspect ratio better +THUMB_STYLES = {"tiny": (80, 80, False), + "small": (100, 100, False), + "feed": (250, 250, False), + "preview": (1024, 768, True), + "big": (2048, 1536, True)} + + +def gen_thumb(src_img, dest_img, width, height, rotation): + start = time() + # TODO lock around the dir creation + os.makedirs(os.path.split(dest_img)[0], exist_ok=True) + image = Image.open(src_img) + if image.mode != "RGB": + image = image.convert("RGB") + image = image.rotate(90 * rotation, expand=True) + thumb = ImageOps.fit(image, (width, height), Image.ANTIALIAS) + thumb.save(dest_img, 'JPEG') + logging.info("Generated {} in {}s".format(dest_img, round(time() - start, 4))) + return dest_img + + +def gen_thumb_process(src_img, dest_img, width, height, rotation): + try: + return gen_thumb(src_img, dest_img, width, height, rotation) + except Exception: + traceback.print_exc() + if os.path.exists(dest_img): + os.unlink(dest_img) + sys.exit(1) + + +def thumb_path(style_name, uuid): + return os.path.join("thumbs", style_name, uuid[0:2], "{}.jpg".format(uuid)) + + class ThumbGenerator(object): def __init__(self, library, storage): self.library = library self.storage = storage self._failed_thumbs_cache = defaultdict(dict) - def get_datedir_path(self, date): - """ - Return a path like 2018/3/31 given a datetime object representing the same date - """ - return os.path.join(str(date.year), str(date.month), str(date.day)) - - def make_thumb(self, photo, style): + def make_photo_thumb(self, photo, style_name): """ Create a thumbnail of the given photo, scaled/cropped to the given named style :return: local path to thumbnail file or None if creation failed or was blocked """ - # style tuples: max x, max y, rotate ok) - # rotate ok means x and y maxes can be swapped if it fits the image's aspect ratio better - styles = {"tiny": (80, 80, False), - "small": (100, 100, False), - "feed": (250, 250, False), - "preview": (1024, 768, True), - "big": (2048, 1536, True)} - dest = os.path.join("thumbs", style, photo.uuid[0:2], "{}.jpg".format(photo.uuid)) + + dest = thumb_path(style_name, photo.uuid) + if self.storage.exists(dest): return self.storage.open(dest, "rb") - if photo.width is None: # todo better detection of images that PIL can't open - return None - if photo.uuid in self._failed_thumbs_cache[style]: + + + if photo.uuid in self._failed_thumbs_cache[style_name]: return None - thumb_width, thumb_height, flip_ok = styles[style] + + # if photo.width is None: # todo better detection of images that PIL can't open + # return None + + thumb_width, thumb_height, flip_ok = THUMB_STYLES[style_name] i_width = photo.width i_height = photo.height im_is_rotated = photo.orientation % 2 != 0 or i_height > i_width @@ -52,8 +82,9 @@ class ThumbGenerator(object): if im_is_rotated and flip_ok: thumb_width, thumb_height = thumb_height, thumb_width - thumb_width = min(thumb_width, i_width if i_width > 0 else 999999999) # TODO do we even have photo.width if PIL can't read the image? - thumb_height = min(thumb_height, i_height if i_height > 0 else 999999999) # TODO this seems bad + thumb_width = min(thumb_width, i_width if i_width > 0 else 999999999) + thumb_height = min(thumb_height, i_height if i_height > 0 else 999999999) + # TODO have the subprocess download the file # TODO thundering herd @@ -64,11 +95,12 @@ class ThumbGenerator(object): with open(fpath, 'wb') as ftmpdest: copyfileobj(fsrc, ftmpdest) - p = Process(target=self.gen_thumb, args=(fpath, fthumblocal.name, thumb_width, thumb_height, photo.orientation)) + p = Process(target=gen_thumb_process, + args=(fpath, fthumblocal.name, thumb_width, thumb_height, photo.orientation)) p.start() p.join() if p.exitcode != 0: - self._failed_thumbs_cache[style][photo.uuid] = True # dont retry failed generations + self._failed_thumbs_cache[style_name][photo.uuid] = True # dont retry failed generations return None with closing(self.storage.open(dest, 'wb')) as fdest: @@ -76,20 +108,3 @@ class ThumbGenerator(object): fthumblocal.seek(0) return fthumblocal - - @staticmethod - def gen_thumb(src_img, dest_img, width, height, rotation): - try: - start = time() - # TODO lock around the dir creation - os.makedirs(os.path.split(dest_img)[0], exist_ok=True) - image = Image.open(src_img) - image = image.rotate(90 * rotation, expand=True) - thumb = ImageOps.fit(image, (width, height), Image.ANTIALIAS) - thumb.save(dest_img, 'JPEG') - logging.info("Generated {} in {}s".format(dest_img, round(time() - start, 4))) - except Exception: - traceback.print_exc() - if os.path.exists(dest_img): - os.unlink(dest_img) - sys.exit(1) diff --git a/photoapp/types.py b/photoapp/types.py index e5e2a92..72e4faf 100644 --- a/photoapp/types.py +++ b/photoapp/types.py @@ -60,12 +60,14 @@ known_mimes = set.union(*[i["mimes"] for i in ftypes.values()]) # we can pull metadata out of these # jpg, png, gif etc regular_images = set([extension for extension, ftype in ftypes.items() if ftype["category"] == fcategory.image]) +regular_mimes = set().union(*[ftype["mimes"] for ftype in ftypes.values() if ftype["category"] == fcategory.image]) # "derived" files, treated as black boxes, we can't open them because proprietary # cr2, xmp, etc files_raw = set([extension for extension, ftype in ftypes.items() if ftype["category"] == fcategory.raw]) # video types # mp4, mov, etc files_video = set([extension for extension, ftype in ftypes.items() if ftype["category"] == fcategory.video]) +video_mimes = set().union(*[ftype["mimes"] for ftype in ftypes.values() if ftype["category"] == fcategory.video]) def mime2ext(mime):