clean up thumb generation
This commit is contained in:
parent
435be8d112
commit
c3fb648ec3
@ -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")
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
Loading…
Reference in New Issue
Block a user