This commit is contained in:
Joachim 2021-05-25 23:04:28 +02:00
parent d1737b44bd
commit fa7334826c
9 changed files with 195 additions and 61 deletions

View file

@ -37,6 +37,7 @@ class Book(BookData):
publishedDate: str = "" publishedDate: str = ""
cover: Document = None cover: Document = None
preview_image: Document = None
type: str = "Book" type: str = "Book"

View file

@ -12,10 +12,8 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name="edition", model_name='book',
name="preview_image", name='preview_image',
field=bookwyrm.models.fields.ImageField( field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='cover_previews/'),
blank=True, null=True, upload_to="previews/"
),
), ),
] ]

View file

@ -6,7 +6,7 @@ from django.dispatch import receiver
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from bookwyrm import activitypub 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.settings import DOMAIN, DEFAULT_LANGUAGE
from bookwyrm.tasks import app from bookwyrm.tasks import app
@ -85,6 +85,9 @@ class Book(BookDataModel):
cover = fields.ImageField( cover = fields.ImageField(
upload_to="covers/", blank=True, null=True, alt_field="alt_text" 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) first_published_date = fields.DateTimeField(blank=True, null=True)
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", activitypub_field="work",
) )
edition_rank = fields.IntegerField(default=0) 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 activity_serializer = activitypub.Edition
name_field = "title" name_field = "title"
@ -302,6 +302,7 @@ def isbn_13_to_10(isbn_13):
@receiver(models.signals.post_save, sender=Edition) @receiver(models.signals.post_save, sender=Edition)
# pylint: disable=unused-argument def preview_image(instance, **kwargs):
def preview_image(instance, *args, **kwargs): updated_fields = kwargs["update_fields"]
generate_preview_image_task(instance, *args, **kwargs)
generate_preview_image_from_edition_task.delay(instance.id, updated_fields)

View file

@ -1,13 +1,17 @@
import colorsys
import math import math
import os
import textwrap import textwrap
from colorthief import ColorThief
from io import BytesIO from io import BytesIO
from PIL import Image, ImageDraw, ImageFont, ImageOps from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageColor
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import InMemoryUploadedFile from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db.models import Avg
from bookwyrm import models, settings from bookwyrm import models, settings
from bookwyrm.tasks import app from bookwyrm.tasks import app
@ -17,54 +21,64 @@ import logging
IMG_WIDTH = settings.PREVIEW_IMG_WIDTH IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT 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) TRANSPARENT_COLOR = (0, 0, 0, 0)
TEXT_COLOR = (16, 16, 16)
margin = math.ceil(IMG_HEIGHT / 10) margin = math.floor(IMG_HEIGHT / 10)
gutter = math.ceil(margin / 2) gutter = math.floor(margin / 2)
cover_img_limits = math.ceil(IMG_HEIGHT * 0.8) cover_img_limits = math.floor(IMG_HEIGHT * 0.8)
path = Path(__file__).parent.absolute() 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: try:
font_title = ImageFont.truetype("%s/PublicSans-Bold.ttf" % font_path, 48) font = ImageFont.truetype(font_path, size)
font_authors = ImageFont.truetype("%s/PublicSans-Regular.ttf" % font_path, 40)
except OSError: except OSError:
font_title = ImageFont.load_default() font = ImageFont.load_default()
font_authors = 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_layer_draw = ImageDraw.Draw(text_layer)
text_y = 0 text_y = 0
text_y = text_y + 6
# title # 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_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 text_y = text_y + font_title.getsize_multiline(title)[1] + 16
# subtitle # subtitle
authors_text = ", ".join(a.name for a in edition.authors.all()) authors_text = book.author_text
authors = textwrap.fill(authors_text, width=36) authors = textwrap.fill(authors_text, width=36)
text_layer_draw.multiline_text( text_layer_draw.multiline_text(
(0, text_y), authors, font=font_authors, fill=TEXT_COLOR (0, text_y), authors, font=font_authors, fill=TEXT_COLOR
) )
imageBox = text_layer.getbbox() text_layer_box = text_layer.getbbox()
return text_layer.crop(imageBox) return text_layer.crop(text_layer_box)
def generate_site_layer(text_x): def generate_instance_layer(content_width):
try: font_instance = get_font("light", size=28)
font_instance = ImageFont.truetype("%s/PublicSans-Light.ttf" % font_path, 28)
except OSError:
font_instance = ImageFont.load_default()
site = models.SiteSettings.objects.get() site = models.SiteSettings.objects.get()
@ -74,42 +88,157 @@ def generate_site_layer(text_x):
static_path = path.joinpath("static/images/logo-small.png") static_path = path.joinpath("static/images/logo-small.png")
logo_img = Image.open(static_path) 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) 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) instance_layer_draw = ImageDraw.Draw(instance_layer)
site_layer_draw.text((60, 10), site.name, font=font_instance, fill=TEXT_COLOR) 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): def generate_rating_layer(rating, content_width):
img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=BG_COLOR) font_icons = get_font("icomoon", size=60)
cover_img_layer = Image.open(edition.cover) icon_star_full = Image.open(path.joinpath("static/images/icons/star-full.png"))
cover_img_layer.thumbnail((cover_img_limits, cover_img_limits), Image.ANTIALIAS) 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) rating_layer_base = Image.new("RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR)
text_y = IMG_HEIGHT - margin - texts_layer.height 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 for r in range(math.floor(rating)):
img.paste(cover_img_layer, (margin, margin)) rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
img.alpha_composite(texts_layer, (text_x, text_y)) position_x = position_x + icon_size + icon_margin
img.alpha_composite(site_layer, (text_x, 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()) file_name = "%s.png" % str(uuid4())
image_buffer = BytesIO() image_buffer = BytesIO()
try: try:
try:
old_path = book.preview_image.path
except ValueError:
old_path = ''
# Save
img.save(image_buffer, format="png") img.save(image_buffer, format="png")
edition.preview_image = InMemoryUploadedFile( book.preview_image = InMemoryUploadedFile(
ContentFile(image_buffer.getvalue()), ContentFile(image_buffer.getvalue()),
"preview_image", "preview_image",
file_name, file_name,
@ -117,17 +246,17 @@ def generate_preview_image(edition):
image_buffer.tell(), image_buffer.tell(),
None, 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: finally:
image_buffer.close() image_buffer.close()
@app.task @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""" """generate preview_image after save"""
updated_fields = kwargs["update_fields"]
if not updated_fields or "preview_image" not in updated_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(book_id=book_id)
generate_preview_image(edition=instance)

View file

@ -37,10 +37,14 @@ LOCALE_PATHS = [
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 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_WIDTH = 1200
PREVIEW_IMG_HEIGHT = 630 PREVIEW_IMG_HEIGHT = 630
PREVIEW_TEXT_COLOR = '#363636'
PREVIEW_DEFAULT_COVER_COLOR = '#002549'
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,4 +1,5 @@
celery==4.4.2 celery==4.4.2
colorthief==0.2.1
Django==3.2.0 Django==3.2.0
django-model-utils==4.0.0 django-model-utils==4.0.0
environs==7.2.0 environs==7.2.0