diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 1599b408..ccb4c0ea 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 070be663..c068e2e2 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 72f0547b..af300560 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 b659f678..d0da3083 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 cee07e91..cef11630 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 00000000..896417ef Binary files /dev/null and b/bookwyrm/static/images/icons/star-empty.png differ diff --git a/bookwyrm/static/images/icons/star-full.png b/bookwyrm/static/images/icons/star-full.png new file mode 100755 index 00000000..6d78caf0 Binary files /dev/null and b/bookwyrm/static/images/icons/star-full.png differ diff --git a/bookwyrm/static/images/icons/star-half.png b/bookwyrm/static/images/icons/star-half.png new file mode 100755 index 00000000..75e4eadc Binary files /dev/null and b/bookwyrm/static/images/icons/star-half.png differ diff --git a/requirements.txt b/requirements.txt index 289d6fe6..5c4c6466 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ celery==4.4.2 +colorthief==0.2.1 Django==3.2.0 django-model-utils==4.0.0 environs==7.2.0