All checks were successful
continuous-integration/drone/push Build is passing
remove emoji from cover titles
451 lines
13 KiB
Python
451 lines
13 KiB
Python
import math
|
|
import random
|
|
from importlib import resources
|
|
from pathlib import Path
|
|
from typing import List, Literal, Optional, Tuple
|
|
|
|
import wcag_contrast_ratio
|
|
from colorthief import ColorThief
|
|
from fonts.ttf import SourceSansPro
|
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
|
|
|
|
from ucast.service import typ, util
|
|
|
|
COVER_STYLE_BLUR = "blur"
|
|
COVER_STYLE_GRADIENT = "gradient"
|
|
CoverStyle = Literal["blur", "gradient"]
|
|
|
|
CHAR_ELLIPSIS = "…"
|
|
COVER_WIDTH = 500
|
|
MIN_CONTRAST = 4.5
|
|
|
|
|
|
def _split_text(
|
|
height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0
|
|
) -> List[str]:
|
|
"""
|
|
Split and trim the input text so it can be printed to a certain
|
|
area of an image.
|
|
|
|
:param height: Image area height [px]
|
|
:param width: Image area width [px]
|
|
:param text: Input text
|
|
:param font: Pillow ImageFont
|
|
:param line_spacing: Line spacing [px]
|
|
:return: List of lines
|
|
"""
|
|
if height < font.size:
|
|
return []
|
|
|
|
max_lines = math.floor((height - font.size) / (font.size + line_spacing)) + 1
|
|
|
|
lines = []
|
|
line = ""
|
|
|
|
for word in text.split(" "):
|
|
if len(lines) >= max_lines:
|
|
line = word
|
|
break
|
|
|
|
if line == "":
|
|
nline = word
|
|
else:
|
|
nline = line + " " + word
|
|
|
|
if font.getsize(nline)[0] <= width:
|
|
line = nline
|
|
elif line != "":
|
|
lines.append(line)
|
|
line = word
|
|
else:
|
|
# try to trim current word
|
|
while nline:
|
|
nline = nline[:-1]
|
|
nline_e = nline + CHAR_ELLIPSIS
|
|
if font.getsize(nline_e)[0] <= width:
|
|
lines.append(nline_e)
|
|
break
|
|
|
|
if line != "":
|
|
if len(lines) >= max_lines:
|
|
# Drop the last line and add ... to the end
|
|
lastline = lines[-1] + CHAR_ELLIPSIS
|
|
if font.getsize(lastline)[0] <= width:
|
|
lines[-1] = lastline
|
|
else:
|
|
i_last_space = lines[-1].rfind(" ")
|
|
lines[-1] = lines[-1][:i_last_space] + CHAR_ELLIPSIS
|
|
else:
|
|
lines.append(line)
|
|
|
|
return lines
|
|
|
|
|
|
def _draw_text_box(
|
|
draw: ImageDraw.ImageDraw,
|
|
box: Tuple[int, int, int, int],
|
|
text: str,
|
|
font: ImageFont.FreeTypeFont,
|
|
color: typ.Color = (0, 0, 0),
|
|
line_spacing=0,
|
|
vertical_center=True,
|
|
):
|
|
"""
|
|
Draw a text box to an image. The text gets automatically
|
|
wrapped and trimmed to fit.
|
|
|
|
:param draw: Pillow ImageDraw object
|
|
:param box: Coordinates of the text box ``(x_tl, y_tl, x_br, y_br)``
|
|
:param text: Text to be printed
|
|
:param font: Pillow ImageFont
|
|
:param color: Text color
|
|
:param line_spacing: Line spacing [px]
|
|
:param vertical_center: Center text vertically in the box
|
|
"""
|
|
x_tl, y_tl, x_br, y_br = box
|
|
height = y_br - y_tl
|
|
width = x_br - x_tl
|
|
sanitized_text = util.strip_emoji(text)
|
|
|
|
lines = _split_text(height, width, sanitized_text, font, line_spacing)
|
|
|
|
y_start = y_tl
|
|
if vertical_center:
|
|
text_height = len(lines) * (font.size + line_spacing) - line_spacing
|
|
y_start += int((height - text_height) / 2)
|
|
|
|
for i, line in enumerate(lines):
|
|
y_pos = y_start + i * (font.size + line_spacing)
|
|
draw.text((x_tl, y_pos), line, color, font)
|
|
|
|
|
|
def _get_dominant_color(img: Image.Image) -> typ.Color:
|
|
"""
|
|
Return the dominant color of an image using the ColorThief library.
|
|
|
|
:param img: Pillow Image object
|
|
:return: dominant color
|
|
"""
|
|
thief = ColorThief.__new__(ColorThief)
|
|
thief.image = img
|
|
return thief.get_color()
|
|
|
|
|
|
def _interpolate_color(color_from: typ.Color, color_to: typ.Color, steps: int):
|
|
"""
|
|
Return a generator providing colors within the given range. Useful to create
|
|
gradients.
|
|
|
|
:param color_from: Starting color
|
|
:param color_to: Ending color
|
|
:param steps: Number of steps
|
|
:return: Generator providing the colors
|
|
"""
|
|
det_co = [(t - f) / steps for f, t in zip(color_from, color_to)]
|
|
for i in range(steps):
|
|
yield [round(f + det * i) for f, det in zip(color_from, det_co)]
|
|
|
|
|
|
def _color_to_float(color: typ.Color) -> tuple[float, ...]:
|
|
return tuple(c / 255 for c in color)
|
|
|
|
|
|
def _get_text_color(bg_color: typ.Color) -> typ.Color:
|
|
"""
|
|
Return the text color (black or white) with the largest contrast
|
|
to a given background color.
|
|
|
|
:param bg_color: Background color
|
|
:return: Text color
|
|
"""
|
|
color_float = _color_to_float(bg_color)
|
|
c_blk = wcag_contrast_ratio.rgb((0, 0, 0), color_float)
|
|
c_wht = wcag_contrast_ratio.rgb((1, 1, 1), color_float)
|
|
if c_wht > c_blk:
|
|
return 255, 255, 255
|
|
return 0, 0, 0
|
|
|
|
|
|
def _get_baseimage(thumbnail: Image.Image, style: CoverStyle):
|
|
"""
|
|
Return the background image for the cover.
|
|
|
|
:param thumbnail: Thumbnail image object
|
|
:param style: Style of the cover image
|
|
:return: Base image
|
|
"""
|
|
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH))
|
|
|
|
if style == COVER_STYLE_GRADIENT:
|
|
# Thumbnail with color gradient background
|
|
|
|
# Get dominant colors from the top and bottom 20% of the thumbnail image
|
|
top_part = thumbnail.crop((0, 0, COVER_WIDTH, int(thumbnail.height * 0.2)))
|
|
bottom_part = thumbnail.crop(
|
|
(0, int(thumbnail.height * 0.8), COVER_WIDTH, thumbnail.height)
|
|
)
|
|
top_color = _get_dominant_color(top_part)
|
|
bottom_color = _get_dominant_color(bottom_part)
|
|
|
|
cover_draw = ImageDraw.Draw(cover)
|
|
|
|
for i, color in enumerate(
|
|
_interpolate_color(top_color, bottom_color, cover.height)
|
|
):
|
|
cover_draw.line(((0, i), (cover.width, i)), tuple(color), 1)
|
|
else:
|
|
# Thumbnail with blurred background
|
|
ctn_width = int(COVER_WIDTH / thumbnail.height * thumbnail.width)
|
|
ctn_x_left = int((ctn_width - COVER_WIDTH) / 2)
|
|
|
|
ctn = thumbnail.resize(
|
|
(ctn_width, COVER_WIDTH), Image.Resampling.LANCZOS
|
|
).filter(ImageFilter.GaussianBlur(20))
|
|
cover.paste(ctn, (-ctn_x_left, 0))
|
|
|
|
return cover
|
|
|
|
|
|
def _resize_thumbnail(thumbnail: Image.Image) -> Image.Image:
|
|
"""
|
|
Scale thumbnail image down to cover size and remove black bars
|
|
|
|
:param thumbnail: Thumbnail image object
|
|
:return: Resized thumbnail image object
|
|
"""
|
|
# Scale the thumbnail image down to cover size
|
|
tn_resize_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height)
|
|
tn_16_9_height = int(COVER_WIDTH / 16 * 9)
|
|
tn_height = min(tn_resize_height, tn_16_9_height)
|
|
tn_crop_y_top = int((tn_resize_height - tn_height) / 2)
|
|
tn_crop_y_bottom = tn_resize_height - tn_crop_y_top
|
|
|
|
return thumbnail.resize(
|
|
(COVER_WIDTH, tn_resize_height), Image.Resampling.LANCZOS
|
|
).crop((0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom))
|
|
|
|
|
|
def _prepare_text_background(
|
|
base_img: Image.Image, bboxes: List[Tuple[int, int, int, int]]
|
|
) -> Tuple[Image.Image, typ.Color]:
|
|
"""
|
|
Return the preferred text color (black or white) and darken
|
|
the image if necessary
|
|
|
|
:param base_img: Image object
|
|
:param bboxes: Text boxes
|
|
:return: Updated image, text color
|
|
"""
|
|
rng = random.Random()
|
|
rng.seed(0x9B38D30461B7F0E6)
|
|
|
|
min_contrast_bk = 22
|
|
min_contrast_wt = 22
|
|
worst_color_wt = None
|
|
|
|
def corr_x(x: int) -> int:
|
|
return min(max(0, x), base_img.width)
|
|
|
|
def corr_y(y: int) -> int:
|
|
return min(max(0, y), base_img.height)
|
|
|
|
for bbox in bboxes:
|
|
x_tl, y_tl, x_br, y_br = bbox
|
|
x_tl = corr_x(x_tl)
|
|
y_tl = corr_y(y_tl)
|
|
x_br = corr_x(x_br)
|
|
y_br = corr_y(y_br)
|
|
|
|
height = y_br - y_tl
|
|
width = x_br - x_tl
|
|
|
|
for _ in range(math.ceil(width * height * 0.01)):
|
|
target_pos = (rng.randint(x_tl, x_br - 1), rng.randint(y_tl, y_br - 1))
|
|
img_color = base_img.getpixel(target_pos)
|
|
img_color_float = _color_to_float(img_color)
|
|
|
|
ct_bk = wcag_contrast_ratio.rgb((0, 0, 0), img_color_float)
|
|
ct_wt = wcag_contrast_ratio.rgb((1, 1, 1), img_color_float)
|
|
|
|
if ct_bk < min_contrast_bk:
|
|
min_contrast_bk = ct_bk
|
|
|
|
if ct_wt < min_contrast_wt:
|
|
worst_color_wt = img_color
|
|
min_contrast_wt = ct_wt
|
|
|
|
if min_contrast_bk >= MIN_CONTRAST:
|
|
return base_img, (0, 0, 0)
|
|
if min_contrast_wt >= MIN_CONTRAST:
|
|
return base_img, (255, 255, 255)
|
|
|
|
pixel = Image.new("RGB", (1, 1), worst_color_wt)
|
|
|
|
for i in range(1, 100):
|
|
brightness_f = 1 - i / 100
|
|
contrast_f = 1 - i / 1000
|
|
|
|
pixel_c = ImageEnhance.Brightness(pixel).enhance(brightness_f)
|
|
pixel_c = ImageEnhance.Contrast(pixel_c).enhance(contrast_f)
|
|
new_color = pixel_c.getpixel((0, 0))
|
|
|
|
if (
|
|
wcag_contrast_ratio.rgb((1, 1, 1), _color_to_float(new_color))
|
|
>= MIN_CONTRAST
|
|
):
|
|
new_img = ImageEnhance.Brightness(base_img).enhance(brightness_f)
|
|
new_img = ImageEnhance.Contrast(new_img).enhance(contrast_f)
|
|
return new_img, (255, 255, 255)
|
|
|
|
return base_img, (255, 255, 255)
|
|
|
|
|
|
def _draw_text_avatar(
|
|
cover: Image.Image,
|
|
avatar: Optional[Image.Image],
|
|
title: str,
|
|
channel: str,
|
|
) -> Image.Image:
|
|
# Add channel avatar
|
|
avt_margin = 0
|
|
avt_size = 0
|
|
|
|
tn_16_9_height = int(COVER_WIDTH / 16 * 9) # typical: 281
|
|
tn_16_9_margin = int((COVER_WIDTH - tn_16_9_height) / 2) # typical: 110
|
|
|
|
if avatar:
|
|
avt_margin = int(tn_16_9_margin * 0.05) # typical: 14
|
|
avt_size = tn_16_9_margin - 2 * avt_margin # typical: 82
|
|
|
|
# Add text
|
|
text_margin_x = 16
|
|
text_margin_topleft = avt_margin + avt_size + text_margin_x # typical: 112
|
|
text_vertical_offset = -17
|
|
text_line_space = -4
|
|
|
|
fnt = ImageFont.truetype(SourceSansPro, 50)
|
|
top_text_box = ( # typical: (112, -17, 484, 110)
|
|
text_margin_topleft,
|
|
text_vertical_offset,
|
|
COVER_WIDTH - text_margin_x,
|
|
tn_16_9_margin,
|
|
)
|
|
bottom_text_box = ( # typical: (16, 373, 484, 500)
|
|
text_margin_x,
|
|
COVER_WIDTH - tn_16_9_margin + text_vertical_offset,
|
|
COVER_WIDTH - text_margin_x,
|
|
COVER_WIDTH,
|
|
)
|
|
|
|
cover, text_color = _prepare_text_background(cover, [top_text_box, bottom_text_box])
|
|
cover_draw = ImageDraw.Draw(cover)
|
|
|
|
_draw_text_box(
|
|
cover_draw,
|
|
top_text_box,
|
|
channel,
|
|
fnt,
|
|
text_color,
|
|
text_line_space,
|
|
)
|
|
_draw_text_box(
|
|
cover_draw,
|
|
bottom_text_box,
|
|
title,
|
|
fnt,
|
|
text_color,
|
|
text_line_space,
|
|
)
|
|
|
|
if avatar:
|
|
avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS)
|
|
|
|
circle_mask = Image.new("L", (avt_size, avt_size))
|
|
circle_mask_draw = ImageDraw.Draw(circle_mask)
|
|
circle_mask_draw.ellipse((0, 0, avt_size, avt_size), 255)
|
|
|
|
cover.paste(avt, (avt_margin, avt_margin), circle_mask)
|
|
|
|
return cover
|
|
|
|
|
|
def _create_cover_image(
|
|
thumbnail: Image.Image,
|
|
avatar: Optional[Image.Image],
|
|
title: str,
|
|
channel: str,
|
|
style: CoverStyle,
|
|
) -> Image.Image:
|
|
"""
|
|
Create a cover image from video metadata and thumbnail
|
|
|
|
:param thumbnail: Thumbnail image object
|
|
:param avatar: Creator avatar image object
|
|
:param title: Video title
|
|
:param channel: Channel name
|
|
:param style: Style of cover image
|
|
:return: Cover image
|
|
"""
|
|
tn = _resize_thumbnail(thumbnail)
|
|
|
|
cover = _get_baseimage(tn, style)
|
|
|
|
cover = _draw_text_avatar(cover, avatar, title, channel)
|
|
|
|
# Insert thumbnail image in the middle
|
|
tn_margin = int((COVER_WIDTH - tn.height) / 2)
|
|
cover.paste(tn, (0, tn_margin))
|
|
|
|
return cover
|
|
|
|
|
|
def _create_blank_cover_image(
|
|
avatar: Optional[Image.Image], title: str, channel: str
|
|
) -> Image.Image:
|
|
bg_color = (16, 16, 16)
|
|
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH), bg_color)
|
|
|
|
yt_icon_path = resources.path("ucast.resources", "yt_icon.png")
|
|
yt_icon = Image.open(yt_icon_path)
|
|
yt_icon_x_left = int((COVER_WIDTH - yt_icon.width) / 2)
|
|
yt_icon_y_top = int((COVER_WIDTH - yt_icon.height) / 2)
|
|
cover.paste(yt_icon, (yt_icon_x_left, yt_icon_y_top))
|
|
|
|
_draw_text_avatar(cover, avatar, title, channel)
|
|
|
|
return cover
|
|
|
|
|
|
def create_cover_file(
|
|
thumbnail_path: Optional[Path],
|
|
avatar_path: Optional[Path],
|
|
title: str,
|
|
channel: str,
|
|
style: CoverStyle,
|
|
cover_path: Path,
|
|
):
|
|
"""
|
|
Create a cover image from video metadata and thumbnail
|
|
and save it to disk.
|
|
|
|
:param thumbnail_path: Path of thumbnail image
|
|
:param avatar_path: Path of avatar image
|
|
:param title: Video title
|
|
:param channel: Channel name
|
|
:param style: Style of cover image
|
|
:param cover_path: Save path of cover image
|
|
"""
|
|
thumbnail = None
|
|
if thumbnail_path:
|
|
thumbnail = Image.open(thumbnail_path)
|
|
|
|
avatar = None
|
|
if avatar_path:
|
|
avatar = Image.open(avatar_path)
|
|
|
|
if thumbnail:
|
|
cvr = _create_cover_image(thumbnail, avatar, title, channel, style)
|
|
else:
|
|
cvr = _create_blank_cover_image(avatar, title, channel)
|
|
|
|
cvr.save(cover_path)
|