clean up thumb generation

This commit is contained in:
dave 2021-08-17 22:26:06 -07:00
parent 435be8d112
commit c3fb648ec3
3 changed files with 91 additions and 58 deletions

View File

@ -3,10 +3,11 @@ import math
import time import time
import logging import logging
import cherrypy import cherrypy
from collections import defaultdict
from urllib.parse import urlparse from urllib.parse import urlparse
from datetime import datetime, timedelta from datetime import datetime, timedelta
from photoapp.thumb import ThumbGenerator 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.dbsession import DatabaseSession
from photoapp.common import pwhash from photoapp.common import pwhash
from photoapp.api import PhotosApi, LibraryManager from photoapp.api import PhotosApi, LibraryManager
@ -241,6 +242,8 @@ class ThumbnailView(object):
@cherrypy.expose @cherrypy.expose
def index(self, item_type, thumb_size, uuid): 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] uuid = uuid.split(".")[0]
query = photoset_auth_filter(db.query(Photo).join(PhotoSet)) query = photoset_auth_filter(db.query(Photo).join(PhotoSet))
@ -250,25 +253,38 @@ class ThumbnailView(object):
assert query assert query
# prefer making thumbs from jpeg to avoid loading large raws formats = defaultdict(list)
# jk we can't load raws anyway
first = None
best = None
for photo in query.all(): for photo in query.all():
if first is None: formats[photo.format].append(photo)
first = photo formats = dict(formats)
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)
if thumb_fobj: # prefer image files. If an image is available assume it is the target or intended to be the thumbnail.
return cherrypy.lib.static.serve_fileobj(thumb_fobj, "image/jpeg") imtypes = regular_mimes.intersection(set(formats.keys()))
else: 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") return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "assets/img/unknown.svg"), "image/svg+xml")

View File

@ -11,40 +11,70 @@ from contextlib import closing
import logging 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): class ThumbGenerator(object):
def __init__(self, library, storage): def __init__(self, library, storage):
self.library = library self.library = library
self.storage = storage self.storage = storage
self._failed_thumbs_cache = defaultdict(dict) self._failed_thumbs_cache = defaultdict(dict)
def get_datedir_path(self, date): def make_photo_thumb(self, photo, style_name):
"""
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):
""" """
Create a thumbnail of the given photo, scaled/cropped to the given named style 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 :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 dest = thumb_path(style_name, photo.uuid)
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))
if self.storage.exists(dest): if self.storage.exists(dest):
return self.storage.open(dest, "rb") 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 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_width = photo.width
i_height = photo.height i_height = photo.height
im_is_rotated = photo.orientation % 2 != 0 or i_height > i_width 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: if im_is_rotated and flip_ok:
thumb_width, thumb_height = thumb_height, thumb_width 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_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 this seems bad thumb_height = min(thumb_height, i_height if i_height > 0 else 999999999)
# TODO have the subprocess download the file # TODO have the subprocess download the file
# TODO thundering herd # TODO thundering herd
@ -64,11 +95,12 @@ class ThumbGenerator(object):
with open(fpath, 'wb') as ftmpdest: with open(fpath, 'wb') as ftmpdest:
copyfileobj(fsrc, 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.start()
p.join() p.join()
if p.exitcode != 0: 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 return None
with closing(self.storage.open(dest, 'wb')) as fdest: with closing(self.storage.open(dest, 'wb')) as fdest:
@ -76,20 +108,3 @@ class ThumbGenerator(object):
fthumblocal.seek(0) fthumblocal.seek(0)
return fthumblocal 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)

View File

@ -60,12 +60,14 @@ known_mimes = set.union(*[i["mimes"] for i in ftypes.values()])
# we can pull metadata out of these # we can pull metadata out of these
# jpg, png, gif etc # jpg, png, gif etc
regular_images = set([extension for extension, ftype in ftypes.items() if ftype["category"] == fcategory.image]) 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 # "derived" files, treated as black boxes, we can't open them because proprietary
# cr2, xmp, etc # cr2, xmp, etc
files_raw = set([extension for extension, ftype in ftypes.items() if ftype["category"] == fcategory.raw]) files_raw = set([extension for extension, ftype in ftypes.items() if ftype["category"] == fcategory.raw])
# video types # video types
# mp4, mov, etc # mp4, mov, etc
files_video = set([extension for extension, ftype in ftypes.items() if ftype["category"] == fcategory.video]) 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): def mime2ext(mime):