Update
This commit is contained in:
parent
d1737b44bd
commit
fa7334826c
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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/"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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 |
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue