From fa7334826c2df4dec35419ee9c596898f3b40998 Mon Sep 17 00:00:00 2001 From: Joachim Date: Tue, 25 May 2021 23:04:28 +0200 Subject: [PATCH] Update --- bookwyrm/activitypub/book.py | 1 + .../migrations/0076_book_preview_image.py | 8 +- bookwyrm/models/book.py | 15 +- bookwyrm/preview_images.py | 225 ++++++++++++++---- bookwyrm/settings.py | 6 +- bookwyrm/static/images/icons/star-empty.png | Bin 0 -> 1200 bytes bookwyrm/static/images/icons/star-full.png | Bin 0 -> 923 bytes bookwyrm/static/images/icons/star-half.png | Bin 0 -> 1153 bytes requirements.txt | 1 + 9 files changed, 195 insertions(+), 61 deletions(-) create mode 100755 bookwyrm/static/images/icons/star-empty.png create mode 100755 bookwyrm/static/images/icons/star-full.png create mode 100755 bookwyrm/static/images/icons/star-half.png diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 1599b408a..ccb4c0ea3 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -37,6 +37,7 @@ class Book(BookData): publishedDate: str = "" cover: Document = None + preview_image: Document = None type: str = "Book" diff --git a/bookwyrm/migrations/0076_book_preview_image.py b/bookwyrm/migrations/0076_book_preview_image.py index 070be663f..c068e2e27 100644 --- a/bookwyrm/migrations/0076_book_preview_image.py +++ b/bookwyrm/migrations/0076_book_preview_image.py @@ -12,10 +12,8 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( - model_name="edition", - name="preview_image", - field=bookwyrm.models.fields.ImageField( - blank=True, null=True, upload_to="previews/" - ), + model_name='book', + name='preview_image', + field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='cover_previews/'), ), ] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 72f0547bf..af3005606 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -6,7 +6,7 @@ from django.dispatch import receiver from model_utils.managers import InheritanceManager from bookwyrm import activitypub -from bookwyrm.preview_images import generate_preview_image_task +from bookwyrm.preview_images import generate_preview_image_from_edition_task from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE from bookwyrm.tasks import app @@ -85,6 +85,9 @@ class Book(BookDataModel): cover = fields.ImageField( upload_to="covers/", blank=True, null=True, alt_field="alt_text" ) + preview_image = fields.ImageField( + upload_to="cover_previews/", blank=True, null=True, alt_field="alt_text" + ) first_published_date = fields.DateTimeField(blank=True, null=True) published_date = fields.DateTimeField(blank=True, null=True) @@ -207,9 +210,6 @@ class Edition(Book): activitypub_field="work", ) edition_rank = fields.IntegerField(default=0) - preview_image = fields.ImageField( - upload_to="previews/", blank=True, null=True, alt_field="alt_text" - ) activity_serializer = activitypub.Edition name_field = "title" @@ -302,6 +302,7 @@ def isbn_13_to_10(isbn_13): @receiver(models.signals.post_save, sender=Edition) -# pylint: disable=unused-argument -def preview_image(instance, *args, **kwargs): - generate_preview_image_task(instance, *args, **kwargs) +def preview_image(instance, **kwargs): + updated_fields = kwargs["update_fields"] + + generate_preview_image_from_edition_task.delay(instance.id, updated_fields) diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index b659f678b..d0da30839 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -1,13 +1,17 @@ +import colorsys import math +import os import textwrap +from colorthief import ColorThief from io import BytesIO -from PIL import Image, ImageDraw, ImageFont, ImageOps +from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageColor from pathlib import Path from uuid import uuid4 from django.core.files.base import ContentFile from django.core.files.uploadedfile import InMemoryUploadedFile +from django.db.models import Avg from bookwyrm import models, settings from bookwyrm.tasks import app @@ -17,54 +21,64 @@ import logging IMG_WIDTH = settings.PREVIEW_IMG_WIDTH IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT -BG_COLOR = (182, 186, 177) +BG_COLOR = settings.PREVIEW_BG_COLOR +TEXT_COLOR = settings.PREVIEW_TEXT_COLOR +DEFAULT_COVER_COLOR = settings.PREVIEW_DEFAULT_COVER_COLOR TRANSPARENT_COLOR = (0, 0, 0, 0) -TEXT_COLOR = (16, 16, 16) -margin = math.ceil(IMG_HEIGHT / 10) -gutter = math.ceil(margin / 2) -cover_img_limits = math.ceil(IMG_HEIGHT * 0.8) +margin = math.floor(IMG_HEIGHT / 10) +gutter = math.floor(margin / 2) +cover_img_limits = math.floor(IMG_HEIGHT * 0.8) path = Path(__file__).parent.absolute() -font_path = path.joinpath("static/fonts/public_sans") +font_dir = path.joinpath("static/fonts/public_sans") +icon_font_dir = path.joinpath("static/css/fonts") +def get_font(font_name, size=28): + if font_name == "light": + font_path = "%s/PublicSans-Light.ttf" % font_dir + if font_name == "regular": + font_path = "%s/PublicSans-Regular.ttf" % font_dir + elif font_name == "bold": + font_path = "%s/PublicSans-Bold.ttf" % font_dir + elif font_name == "icomoon": + font_path = "%s/icomoon.ttf" % icon_font_dir -def generate_texts_layer(edition, text_x): try: - font_title = ImageFont.truetype("%s/PublicSans-Bold.ttf" % font_path, 48) - font_authors = ImageFont.truetype("%s/PublicSans-Regular.ttf" % font_path, 40) + font = ImageFont.truetype(font_path, size) except OSError: - font_title = ImageFont.load_default() - font_authors = ImageFont.load_default() + font = ImageFont.load_default() - text_layer = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=TRANSPARENT_COLOR) + return font + + +def generate_texts_layer(book, content_width): + font_title = get_font("bold", size=48) + font_authors = get_font("regular", size=40) + + text_layer = Image.new("RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR) text_layer_draw = ImageDraw.Draw(text_layer) text_y = 0 - text_y = text_y + 6 - # title - title = textwrap.fill(edition.title, width=28) + title = textwrap.fill(book.title, width=28) text_layer_draw.multiline_text((0, text_y), title, font=font_title, fill=TEXT_COLOR) text_y = text_y + font_title.getsize_multiline(title)[1] + 16 # subtitle - authors_text = ", ".join(a.name for a in edition.authors.all()) + authors_text = book.author_text authors = textwrap.fill(authors_text, width=36) text_layer_draw.multiline_text( (0, text_y), authors, font=font_authors, fill=TEXT_COLOR ) - imageBox = text_layer.getbbox() - return text_layer.crop(imageBox) + text_layer_box = text_layer.getbbox() + return text_layer.crop(text_layer_box) -def generate_site_layer(text_x): - try: - font_instance = ImageFont.truetype("%s/PublicSans-Light.ttf" % font_path, 28) - except OSError: - font_instance = ImageFont.load_default() +def generate_instance_layer(content_width): + font_instance = get_font("light", size=28) site = models.SiteSettings.objects.get() @@ -74,42 +88,157 @@ def generate_site_layer(text_x): static_path = path.joinpath("static/images/logo-small.png") logo_img = Image.open(static_path) - site_layer = Image.new("RGBA", (IMG_WIDTH - text_x - margin, 50), color=BG_COLOR) + instance_layer = Image.new("RGBA", (content_width, 62), color=TRANSPARENT_COLOR) logo_img.thumbnail((50, 50), Image.ANTIALIAS) - site_layer.paste(logo_img, (0, 0)) + instance_layer.paste(logo_img, (0, 0)) - site_layer_draw = ImageDraw.Draw(site_layer) - site_layer_draw.text((60, 10), site.name, font=font_instance, fill=TEXT_COLOR) + instance_layer_draw = ImageDraw.Draw(instance_layer) + instance_layer_draw.text((60, 10), site.name, font=font_instance, fill=TEXT_COLOR) - return site_layer + line_width = 50 + 10 + font_instance.getsize(site.name)[0] + + line_layer = Image.new("RGBA", (line_width, 2), color=(*(ImageColor.getrgb(TEXT_COLOR)), 50)) + instance_layer.alpha_composite(line_layer, (0, 60)) + + return instance_layer -def generate_preview_image(edition): - img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=BG_COLOR) +def generate_rating_layer(rating, content_width): + font_icons = get_font("icomoon", size=60) - cover_img_layer = Image.open(edition.cover) - cover_img_layer.thumbnail((cover_img_limits, cover_img_limits), Image.ANTIALIAS) + icon_star_full = Image.open(path.joinpath("static/images/icons/star-full.png")) + icon_star_empty = Image.open(path.joinpath("static/images/icons/star-empty.png")) + icon_star_half = Image.open(path.joinpath("static/images/icons/star-half.png")) - text_x = margin + cover_img_layer.width + gutter + icon_size = 64 + icon_margin = 10 - texts_layer = generate_texts_layer(edition, text_x) - text_y = IMG_HEIGHT - margin - texts_layer.height + rating_layer_base = Image.new("RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR) + rating_layer_color = Image.new("RGBA", (content_width, icon_size), color=TEXT_COLOR) + rating_layer_mask = Image.new("RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR) - site_layer = generate_site_layer(text_x) + position_x = 0 - # Composite all layers - img.paste(cover_img_layer, (margin, margin)) - img.alpha_composite(texts_layer, (text_x, text_y)) - img.alpha_composite(site_layer, (text_x, margin)) + for r in range(math.floor(rating)): + rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0)) + position_x = position_x + icon_size + icon_margin + + if math.floor(rating) != math.ceil(rating): + rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0)) + position_x = position_x + icon_size + icon_margin + + for r in range(5 - math.ceil(rating)): + rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0)) + position_x = position_x + icon_size + icon_margin + + rating_layer_mask = rating_layer_mask.getchannel("A") + rating_layer_mask = ImageOps.invert(rating_layer_mask) + + rating_layer_composite = Image.composite(rating_layer_base, rating_layer_color, rating_layer_mask) + + return rating_layer_composite + + +def generate_default_cover(): + font_cover = get_font("light", size=28) + + cover_width = math.floor(cover_img_limits * .7) + default_cover = Image.new("RGB", (cover_width, cover_img_limits), color=DEFAULT_COVER_COLOR) + default_cover_draw = ImageDraw.Draw(default_cover) + + text = "no cover :(" + text_dimensions = font_cover.getsize(text) + text_coords = (math.floor((cover_width - text_dimensions[0]) / 2), + math.floor((cover_img_limits - text_dimensions[1]) / 2)) + default_cover_draw.text(text_coords, text, font=font_cover, fill='white') + + return default_cover + + +def generate_preview_image(book_id, rating=None): + book = models.Book.objects.select_subclasses().get(id=book_id) + + rating = models.Review.objects.filter( + privacy="public", + deleted=False, + book__in=[book_id], + ).aggregate(Avg("rating"))["rating__avg"] + + # Cover + try: + cover_img_layer = Image.open(book.cover) + cover_img_layer.thumbnail((cover_img_limits, cover_img_limits), Image.ANTIALIAS) + color_thief = ColorThief(book.cover) + dominant_color = color_thief.get_color(quality=1) + except: + cover_img_layer = generate_default_cover() + dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR) + + # Color + if BG_COLOR == 'use_dominant_color': + image_bg_color = "rgb(%s, %s, %s)" % dominant_color + # Lighten color + image_bg_color_rgb = [x/255.0 for x in ImageColor.getrgb(image_bg_color)] + image_bg_color_hls = colorsys.rgb_to_hls(*image_bg_color_rgb) + image_bg_color_hls = (image_bg_color_hls[0], 0.9, image_bg_color_hls[1]) + image_bg_color = tuple([math.ceil(x * 255) for x in colorsys.hls_to_rgb(*image_bg_color_hls)]) + else: + image_bg_color = BG_COLOR + + # Background (using the color) + img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color) + + # Contents + content_x = margin + cover_img_layer.width + gutter + content_width = IMG_WIDTH - content_x - margin + + instance_layer = generate_instance_layer(content_width) + texts_layer = generate_texts_layer(book, content_width) + + contents_layer = Image.new("RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR) + contents_composite_y = 0 + contents_layer.alpha_composite(instance_layer, (0, contents_composite_y)) + contents_composite_y = contents_composite_y + instance_layer.height + gutter + contents_layer.alpha_composite(texts_layer, (0, contents_composite_y)) + contents_composite_y = contents_composite_y + texts_layer.height + 30 + + if rating: + # Add some more margin + contents_composite_y = contents_composite_y + 30 + rating_layer = generate_rating_layer(rating, content_width) + contents_layer.alpha_composite(rating_layer, (0, contents_composite_y)) + contents_composite_y = contents_composite_y + rating_layer.height + 30 + + contents_layer_box = contents_layer.getbbox() + contents_layer_height = contents_layer_box[3] - contents_layer_box[1] + + contents_y = math.floor((IMG_HEIGHT - contents_layer_height) / 2) + # Remove Instance Layer from centering calculations + contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2) + + if contents_y < margin: + contents_y = margin + + cover_y = math.floor((IMG_HEIGHT - cover_img_layer.height) / 2) + + # Composite layers + img.paste(cover_img_layer, (margin, cover_y)) + img.alpha_composite(contents_layer, (content_x, contents_y)) file_name = "%s.png" % str(uuid4()) image_buffer = BytesIO() try: + try: + old_path = book.preview_image.path + except ValueError: + old_path = '' + + # Save img.save(image_buffer, format="png") - edition.preview_image = InMemoryUploadedFile( + book.preview_image = InMemoryUploadedFile( ContentFile(image_buffer.getvalue()), "preview_image", file_name, @@ -117,17 +246,17 @@ def generate_preview_image(edition): image_buffer.tell(), None, ) + book.save(update_fields=["preview_image"]) - edition.save(update_fields=["preview_image"]) + # Clean up old file after saving + if os.path.exists(old_path): + os.remove(old_path) finally: image_buffer.close() @app.task -def generate_preview_image_task(instance, *args, **kwargs): +def generate_preview_image_from_edition_task(book_id, updated_fields=None): """generate preview_image after save""" - updated_fields = kwargs["update_fields"] - if not updated_fields or "preview_image" not in updated_fields: - logging.warn("image name to delete", instance.preview_image.name) - generate_preview_image(edition=instance) + generate_preview_image(book_id=book_id) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index cee07e913..cef11630e 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -37,10 +37,14 @@ LOCALE_PATHS = [ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" -# preview image +# Preview image +# Specify RGB tuple or RGB hex strings, or 'use_dominant_color' +PREVIEW_BG_COLOR = 'use_dominant_color' PREVIEW_IMG_WIDTH = 1200 PREVIEW_IMG_HEIGHT = 630 +PREVIEW_TEXT_COLOR = '#363636' +PREVIEW_DEFAULT_COVER_COLOR = '#002549' # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ diff --git a/bookwyrm/static/images/icons/star-empty.png b/bookwyrm/static/images/icons/star-empty.png new file mode 100755 index 0000000000000000000000000000000000000000..896417ef69cb053e349720a11b12d7900154011b GIT binary patch literal 1200 zcmV;h1W)^kP)P5Yc6Q#r zdy|~+{O8_1k^g+0ESCRzSpWB`eXIuXG6=4}r*l{d0PTMd65ke)C04K!09t<$0K5{B zC04KsfNDRE0XL@Gzlf{?@JvKr8-Y(nT8ipOtpfU08-i~|1-26m~I6#0i?9AnEf>|iXXU{@m!=#et-ecPhL1%01)P85Dp;`wj^XuIkno~iUqZbS2#cc4v>BZV3+0q zzgcl%247$Uo zW@4A-bq=ou=>0(WWi~`CV51{O>dg{ft!Xm*cw@Z)h{Ruvq%=*$h!F!Hr^$)Oy_TS7 zH`NOu<&#|+DNsvUTvy=j1z-;^X*c!FuMrl&oS1vuu6vH#4twhbfT?q)sN-`;MWcjg z-d)%11z?~2jLZNE)ny6fK~%yH_NNpa84`M7imPc9*vP5sNyi;T&5txGcU1Z ztGEFYPpx#gGeHl4+Gt!OxP@E-QM-20rFsBR0>_$6X#pSK(s*fufS}gEBI04|53X~) zZSDn7s>pbz$0oO20+-P^fd9uPZNXsj%=aODv0DY|1(1ux2vH+A*VtF%5sJUpfB;@k z208%t0!ZazPGM+{a)V8$1h?1MfJm+7>O#E$Qhj+&aQXwTZ~2!PTnxzk*vE9abzD`b z7l4hzsZFWi2=DFJTR^z_5HA3m0W1JALXY5fJ_TW4r;V?3WdaWXBD?;(o82f;8dI;S z=1M6r^*w7df9}nZ=CcS7fK&z;Ex3h|1JT?QcmTvj2!2PPsy>JJsD0xevJS4MXA;dN zu?K*@{|AUsXMe4nuu643+hlFdqU!Bei$v^Nic-c4Sq`~Lx3cQx;1#*kzH O0000gjN7J_HSike*j=E2yHC@JOS`-LS=Xq zv;Z*Xed7@x1pm=wPR z;0O?A#g_osGlWI)r2zH_p-6lUfIUGd7GDcs4-mNM;UuSc$*e4gxme<4=}u=erEh7H zQ+3ir0YKfoL6F2NLtyCiULeTgy#Oo{el;EnJX%u~_CtHsAz=65Z1GED0QYK=UT&)w zKo9!}uJZ(50K=nhtpdSy#Lf$V<&b-dE!*c*{v;c`0M>Z&BN;yEKU#sL;4d8iFJpUQaVI=^p|1D{JQ$&_{!AbyF{aFI=LPVB$ z!72dR`eh8b7ViHdvI@Wx5qYT;J{6I-(GXSvVC~;f%6=CSVnOIw0pO8{yxGt)+zVC! zs98Vr2={_$0DIQY5JJ5m8bHnZzeVIqu_QvhAQHfq^{D}JJrM2%kpQ;VKKUQ3FY$sX z0DEg68Oo|K)C-~j>_vWLpdwu21rY#Rkw43@#0w$->{%ao&oV6Wf};VntX~4)@8Mo> zG=P@%HGpt0I1)gQ^)-NSFE|3g?Ud7LZFR?2>w&ZkTV4QAs>Uxf+eI&ctmM6v!uU7J z_9(37ugOr01?6+3On!jDR@z4|oIL<2b6rGG4y&N7SIIr2)nur7L9gL69H0OPNIw#= zOYdOvyGltWN9m}sWdxSc!^uLF9&-kV$$Nv%idzHNI)=E+w==#4{p z`ISo&Z}q}C+jl4@XN|vCUD-k4FTA!Fz@98MI5~#t=g1@vQsBI_D-2zNN7DlF5AgfJ6;N%=hhT#JG*3xHYq7Jw)SGXS`h z?*R}2VHAK{`Cb4=Ll^-tq`U>dkr2E9hLyJhI0}LXz*gl-(?gNd-7>j{AHbFf*k z$WS|3!lyNp+n4fMCa>v9B>o%-fJTNUryO@IL2onw>E~zcBUp@3^SGh&1eVEbhHh|aos9IEfZMZaW0no!Q^&2B6sL}=@z!XS;ttiXVfym{k+H5^MYAva7G3wWwC(J zp6AX4BOXwz%B%*^_Y>iH@9Rc!gSYV2Di3!i7y&RN18f0nftZ#FMgUL(=hNP!mfr(G zt$|0BL)RYwbDcK#0+`VQ(7O{y7_9%&r8N*-9;M%<3$^&;>Iu96YLU3t3s4+o^wD^f z;{SU<@D81UT$#WNpq7hU0Jap@&-H*{$47s`ewqSkOO6ZIy#Op3fX`9fZ~2uNmKc!v z>HSPcoZx=b@B-+`09kRA_x9@+5U!rU3!s$&$jc*&Yb$0D`cQRwpDPo10Pxv4|GJwN zlqij>_m;ILcTI;Sc>mVP5pyQ+0H|evqZQYnFhUK4ITLsQltl>Zj=E+_8OD+!p-0!G0-X-+TO=j6V07ixUr=I@{g