diff --git a/photoapp/daemon.py b/photoapp/daemon.py index e5f55ea..c1f3445 100644 --- a/photoapp/daemon.py +++ b/photoapp/daemon.py @@ -6,7 +6,7 @@ import cherrypy from collections import defaultdict from urllib.parse import urlparse from datetime import datetime, timedelta -from photoapp.thumb import ThumbGenerator +from photoapp.thumbtool import ThumbGenerator 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 @@ -258,33 +258,32 @@ class ThumbnailView(object): formats[photo.format].append(photo) formats = dict(formats) + thumb_from = None + # 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 + thumb_jpegs = formats.get("image/jpeg") + if thumb_jpegs: + thumb_from = thumb_jpegs[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) + vtypes = video_mimes.intersection(set(formats.keys())) + if vtypes and not thumb_from: + t = vtypes.pop() # TODO arbitrary + thumb_from = formats[t][0] # TODO arbitrary - 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") + thumb_fobj = self.master.thumbtool.make_thumb(thumb_from, thumb_size) - 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 + if thumb_fobj: + return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg") + else: + cherrypy.response.headers["Cache-Control"] = "no-store, must-revalidate, max-age=0" + cherrypy.response.headers["Age"] = "0" 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 8ec0609..04b3b20 100644 --- a/photoapp/thumb.py +++ b/photoapp/thumb.py @@ -1,14 +1,8 @@ import os import sys import traceback -from time import time -from collections import defaultdict -from multiprocessing import Process from PIL import Image, ImageOps -import tempfile -from shutil import copyfileobj -from contextlib import closing -import logging +from multiprocessing import Process # style tuples: max x, max y, rotate ok) @@ -20,91 +14,60 @@ THUMB_STYLES = {"tiny": (80, 80, False), "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 image_style(image, style_name, orientation): + """ + Given an input PIL Image object, return a scaled/cropped image object of style_name + """ + thumb_width, thumb_height, flip_ok = THUMB_STYLES[style_name] + i_width, i_height = image.size - 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 - """ + im_is_rotated = orientation % 2 != 0 or i_height > i_width - dest = thumb_path(style_name, photo.uuid) + if im_is_rotated and flip_ok: + thumb_width, thumb_height = thumb_height, thumb_width + + # prevents scale-up (why do i want this?) + thumb_width = min(thumb_width, i_width) if i_width > 0 else thumb_width + thumb_height = min(thumb_height, i_height) if i_height > 0 else thumb_height + + image = image.rotate(90 * orientation, expand=True) + return ImageOps.fit(image, (thumb_width, thumb_height), Image.ANTIALIAS) - if self.storage.exists(dest): - return self.storage.open(dest, "rb") +def image_file_style(src_path, output_path, style_name, orientation): + """ + Given an input and output image paths, create a scaled/cropped image file of style_name + """ + image = Image.open(src_path) + if image.mode != "RGB": + image = image.convert("RGB") + output = image_style(image, style_name, orientation) + output.save(output_path, 'JPEG') - if photo.uuid in self._failed_thumbs_cache[style_name]: - return None +def _image_file_style_process_worker(src_path, output_path, style_name, orientation): + try: + image_file_style(src_path, output_path, style_name, orientation) + except Exception: + traceback.print_exc() + if os.path.exists(output_path): + os.unlink(output_path) + sys.exit(1) - # if photo.width is None: # todo better detection of images that PIL can't open - # return None +def image_file_style_process(src_path, output_path, style_name, orientation): + """ + Same as image_file_style but the image manipulation is done in a background process + """ + p = Process(target=_image_file_style_process_worker, + args=(src_path, output_path, style_name, orientation)) + p.start() + p.join() + if p.exitcode != 0: + return False - 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 - - 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) - thumb_height = min(thumb_height, i_height if i_height > 0 else 999999999) - - - # TODO have the subprocess download the file - # TODO thundering herd - with tempfile.TemporaryDirectory() as tmpdir: - fthumblocal = tempfile.NamedTemporaryFile(delete=True) - fpath = os.path.join(tmpdir, "image") - with self.library.storage.open(photo.path, 'rb') as fsrc: - with open(fpath, 'wb') as ftmpdest: - copyfileobj(fsrc, ftmpdest) - - 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_name][photo.uuid] = True # dont retry failed generations - return None - - with closing(self.storage.open(dest, 'wb')) as fdest: - copyfileobj(fthumblocal, fdest) - - fthumblocal.seek(0) - return fthumblocal + return True diff --git a/photoapp/thumbtool.py b/photoapp/thumbtool.py new file mode 100644 index 0000000..15e176e --- /dev/null +++ b/photoapp/thumbtool.py @@ -0,0 +1,48 @@ +import os +from collections import defaultdict +import tempfile +from shutil import copyfileobj +from contextlib import closing +from photoapp.thumb import thumb_path, image_file_style_process + + +class ThumbGenerator(object): + def __init__(self, library, storage): + self.library = library + self.storage = storage + self._failed_thumbs_cache = defaultdict(dict) + + def make_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 + """ + dest = thumb_path(style_name, photo.uuid) + + if self.storage.exists(dest): + return self.storage.open(dest, "rb") + + if photo.uuid in self._failed_thumbs_cache[style_name]: + return None + + # if photo.width is None: # todo better detection of images that PIL can't open + # return None + + # TODO have the subprocess download the file + # TODO thundering herd + with tempfile.TemporaryDirectory() as tmpdir: + fthumblocal = tempfile.NamedTemporaryFile(delete=True) + fpath = os.path.join(tmpdir, "image") + with self.library.storage.open(photo.path, 'rb') as fsrc: + with open(fpath, 'wb') as ftmpdest: + copyfileobj(fsrc, ftmpdest) + + if not image_file_style_process(fpath, fthumblocal.name, style_name, photo.orientation): + self._failed_thumbs_cache[style_name][photo.uuid] = True # dont retry failed generations + return + + with closing(self.storage.open(dest, 'wb')) as fdest: + copyfileobj(fthumblocal, fdest) + + fthumblocal.seek(0) + return fthumblocal