Compare commits

...

2 commits

Author SHA1 Message Date
8af98a44ae improve cover contrast
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-20 23:12:22 +02:00
12e64e6c72 schedule channel updating 2022-05-20 14:30:48 +02:00
10 changed files with 232 additions and 108 deletions

20
poetry.lock generated
View file

@ -60,11 +60,11 @@ cffi = ">=1.0.0"
[[package]]
name = "certifi"
version = "2022.5.18"
version = "2022.5.18.1"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = ">=3.5"
python-versions = ">=3.6"
[[package]]
name = "cffi"
@ -220,7 +220,7 @@ testing = ["mock (>=2.0.0)"]
[[package]]
name = "feedparser"
version = "6.0.8"
version = "6.0.9"
description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds"
category = "main"
optional = false
@ -273,7 +273,7 @@ export = ["jinja2 (>=2.7,<3)"]
[[package]]
name = "identify"
version = "2.5.0"
version = "2.5.1"
description = "File identification library for Python"
category = "dev"
optional = false
@ -845,8 +845,8 @@ brotlicffi = [
{file = "brotlicffi-1.0.9.2.tar.gz", hash = "sha256:0c248a68129d8fc6a217767406c731e498c3e19a7be05ea0a90c3c86637b7d96"},
]
certifi = [
{file = "certifi-2022.5.18-py3-none-any.whl", hash = "sha256:8d15a5a7fde18536a249c49e07e8e462b8fc13de21b3c80e8a68315dfa227c99"},
{file = "certifi-2022.5.18.tar.gz", hash = "sha256:6ae10321df3e464305a46e997da41ea56c1d311fb9ff1dd4e04d6f14653ec63a"},
{file = "certifi-2022.5.18.1-py3-none-any.whl", hash = "sha256:f1d53542ee8cbedbe2118b5686372fb33c297fcd6379b050cca0ef13a597382a"},
{file = "certifi-2022.5.18.1.tar.gz", hash = "sha256:9c5705e395cd70084351dd8ad5c41e65655e08ce46f2ec9cf6c2c08390f71eb7"},
]
cffi = [
{file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
@ -988,8 +988,8 @@ django-rq = [
{file = "django_rq-2.5.1-py2.py3-none-any.whl", hash = "sha256:7be1e10e7091555f9f36edf100b0dbb205ea2b98683d74443d2bdf3c6649a03f"},
]
feedparser = [
{file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"},
{file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"},
{file = "feedparser-6.0.9-py3-none-any.whl", hash = "sha256:a522b2b81f3914a74ae44161a341940f74811bd29be5b4c2a689e6e6be51cd39"},
{file = "feedparser-6.0.9.tar.gz", hash = "sha256:dad42e7beaec55f99c08b2b0cf7288bc7cfd24b6f72c8ef85478bcb55648cd42"},
]
filelock = [
{file = "filelock-3.7.0-py3-none-any.whl", hash = "sha256:c7b5fdb219b398a5b28c8e4c1893ef5f98ece6a38c6ab2c22e26ec161556fed6"},
@ -1010,8 +1010,8 @@ honcho = [
{file = "honcho-1.1.0.tar.gz", hash = "sha256:c5eca0bded4bef6697a23aec0422fd4f6508ea3581979a3485fc4b89357eb2a9"},
]
identify = [
{file = "identify-2.5.0-py2.py3-none-any.whl", hash = "sha256:3acfe15a96e4272b4ec5662ee3e231ceba976ef63fd9980ed2ce9cc415df393f"},
{file = "identify-2.5.0.tar.gz", hash = "sha256:c83af514ea50bf2be2c4a3f2fb349442b59dc87284558ae9ff54191bff3541d2"},
{file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"},
{file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"},
]
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},

View file

@ -0,0 +1,3 @@
__version__ = "0.0.1"
default_app_config = "ucast.apps.UcastConfig"

View file

@ -4,3 +4,8 @@ from django.apps import AppConfig
class UcastConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "ucast"
def ready(self):
from ucast.tasks import download
download.schedule_update_channels()

View file

@ -1,4 +1,5 @@
import math
import random
from importlib import resources
from pathlib import Path
from typing import List, Literal, Optional, Tuple
@ -6,7 +7,7 @@ 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, ImageFilter, ImageFont
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
from ucast.service import typ
@ -16,6 +17,7 @@ CoverStyle = Literal["blur", "gradient"]
CHAR_ELLIPSIS = ""
COVER_WIDTH = 500
MIN_CONTRAST = 4.5
def _split_text(
@ -30,7 +32,7 @@ def _split_text(
:param text: Input text
:param font: Pillow ImageFont
:param line_spacing: Line spacing [px]
:return:
:return: List of lines
"""
if height < font.size:
return []
@ -99,7 +101,6 @@ def _draw_text_box(
:param color: Text color
:param line_spacing: Line spacing [px]
:param vertical_center: Center text vertically in the box
:return:
"""
x_tl, y_tl, x_br, y_br = box
height = y_br - y_tl
@ -144,7 +145,11 @@ def _interpolate_color(color_from: typ.Color, color_to: typ.Color, steps: int):
yield [round(f + det * i) for f, det in zip(color_from, det_co)]
def _get_text_color(bg_color) -> typ.Color:
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.
@ -152,26 +157,19 @@ def _get_text_color(bg_color) -> typ.Color:
:param bg_color: Background color
:return: Text color
"""
color_decimal = tuple([c / 255 for c in bg_color])
c_blk = wcag_contrast_ratio.rgb((0, 0, 0), color_decimal)
c_wht = wcag_contrast_ratio.rgb((1, 1, 1), color_decimal)
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,
top_color: typ.Color,
bottom_color: typ.Color,
style: CoverStyle,
):
def _get_baseimage(thumbnail: Image.Image, style: CoverStyle):
"""
Return the background image for the cover.
:param thumbnail: Thumbnail image object
:param top_color: Top color of the thumbnail image
:param bottom_color: Bottom color of the thumbnail image
:param style: Style of the cover image
:return: Base image
"""
@ -179,6 +177,15 @@ def _get_baseimage(
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(
@ -217,27 +224,139 @@ def _resize_thumbnail(thumbnail: Image.Image) -> Image.Image:
)
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,
top_color: typ.Color,
bottom_color: typ.Color,
):
cover_draw = ImageDraw.Draw(cover)
) -> Image.Image:
# Add channel avatar
avt_margin = 0
avt_size = 0
tn_16_9_height = int(COVER_WIDTH / 16 * 9)
tn_16_9_margin = int((COVER_WIDTH - tn_16_9_height) / 2)
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)
avt_size = tn_16_9_margin - 2 * avt_margin
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.LANCZOS)
circle_mask = Image.new("L", (avt_size, avt_size))
@ -246,42 +365,7 @@ def _draw_text_avatar(
cover.paste(avt, (avt_margin, avt_margin), circle_mask)
# Add text
text_margin_x = 16
text_margin_topleft = avt_margin + avt_size + text_margin_x
text_vertical_offset = -17
text_line_space = -4
fnt = ImageFont.truetype(SourceSansPro, 50)
top_text_color = _get_text_color(top_color)
bottom_text_color = _get_text_color(bottom_color)
_draw_text_box(
cover_draw,
(
text_margin_topleft,
text_vertical_offset,
COVER_WIDTH - text_margin_x,
tn_16_9_margin,
),
channel,
fnt,
top_text_color,
text_line_space,
)
_draw_text_box(
cover_draw,
(
text_margin_x,
COVER_WIDTH - tn_16_9_margin + text_vertical_offset,
COVER_WIDTH - text_margin_x,
COVER_WIDTH,
),
title,
fnt,
bottom_text_color,
text_line_space,
)
return cover
def _create_cover_image(
@ -303,20 +387,14 @@ def _create_cover_image(
"""
tn = _resize_thumbnail(thumbnail)
# Get dominant colors from the top and bottom 20% of the thumbnail image
top_part = tn.crop((0, 0, COVER_WIDTH, int(tn.height * 0.2)))
bottom_part = tn.crop((0, int(tn.height * 0.8), COVER_WIDTH, tn.height))
top_color = _get_dominant_color(top_part)
bottom_color = _get_dominant_color(bottom_part)
cover = _get_baseimage(tn, style)
cover = _get_baseimage(tn, top_color, bottom_color, 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))
_draw_text_avatar(cover, avatar, title, channel, top_color, bottom_color)
return cover
@ -332,7 +410,7 @@ def _create_blank_cover_image(
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, bg_color, bg_color)
_draw_text_avatar(cover, avatar, title, channel)
return cover

View file

@ -9,7 +9,6 @@ from typing import List, Optional
import feedparser
import requests
from django.conf import settings
from mutagen import id3
from yt_dlp import YoutubeDL
@ -291,9 +290,7 @@ def get_channel_videos_from_scraper(
"""
videos = []
for item in scrapetube.get_channel(
channel_url_from_id(channel_id), limit, settings.YOUTUBE_SCRAPE_DELAY
):
for item in scrapetube.get_channel(channel_url_from_id(channel_id), limit):
video_id = item.get("videoId")
if not video_id:
logging.warning(

View file

@ -1,6 +1,8 @@
import os
from datetime import datetime
import django_rq
from django.conf import settings
from django.utils import timezone
from ucast.models import Channel, Video
@ -118,6 +120,15 @@ def import_channel(channel_id: str, limit: int = None):
_load_scraped_video(vid, channel)
def schedule_update_channels():
django_rq.get_scheduler().schedule(
datetime.utcnow(),
update_channels,
id="schedule_update_channels",
interval=settings.YT_UPDATE_INTERVAL,
)
def update_channels():
"""
Update all channels from their RSS feeds and download new videos.

34
ucast/tasks/library.py Normal file
View file

@ -0,0 +1,34 @@
import os
from ucast.models import Video
from ucast.service import cover, storage
def recreate_cover(video: Video):
store = storage.Storage()
cf = store.get_channel_folder(video.channel.slug)
thumbnail_file = cf.get_thumbnail(video.slug)
cover_file = cf.get_cover(video.slug)
if not os.path.isfile(cf.file_avatar):
print(f"could not find avatar for channel {video.channel_id}")
return
if not os.path.isfile(thumbnail_file):
print(f"could not find thumbnail for video {video.id}")
return
cover.create_cover_file(
thumbnail_file,
cf.file_avatar,
video.title,
video.channel.name,
cover.COVER_STYLE_BLUR,
cover_file,
)
def recreate_covers():
for video in Video.objects.all():
recreate_cover(video)

View file

@ -10,7 +10,7 @@ from PIL import Image, ImageChops
from ucast import tests
from ucast.service import youtube
VIDEO_ID_SINTEL = "eRsGyueVLvQ"
VIDEO_ID_THETADEV = "ZPxEr4YdWt8"
VIDEO_ID_SHORT = "lcQZ6YwQHiw"
VIDEO_ID_PERSUASION = "DWjFW7Yq1fA"
@ -21,14 +21,14 @@ CHANNEL_URL_BLENDER = "https://www.youtube.com/c/BlenderFoundation"
@pytest.fixture(scope="module")
def video_info() -> youtube.VideoDetails:
return youtube.get_video_details(VIDEO_ID_SINTEL)
return youtube.get_video_details(VIDEO_ID_THETADEV)
def test_download_thumbnail(video_info):
tmpdir_o = tempfile.TemporaryDirectory()
tmpdir = Path(tmpdir_o.name)
tn_file = tmpdir / "thumbnail"
expected_tn_file = tests.DIR_TESTFILES / "thumbnail" / "t2.webp"
expected_tn_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp"
tn_file = youtube.download_thumbnail(video_info, tn_file)
assert tn_file.suffix == ".webp"
@ -41,32 +41,26 @@ def test_download_thumbnail(video_info):
def test_get_video_info(video_info):
assert video_info.id == VIDEO_ID_SINTEL
assert video_info.title == "Sintel - Open Movie by Blender Foundation"
assert video_info.channel_id == "UCSMOQeBJ2RAnuFungnQOxLg"
assert video_info.id == VIDEO_ID_THETADEV
assert video_info.title == "ThetaDev @ Embedded World 2019"
assert video_info.channel_id == "UCGiJh0NZ52wRhYKYnuZI08Q"
assert (
video_info.description
== """Help us making Free/Open Movies: https://cloud.blender.org/join
== """This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-)
"Sintel" is an independently produced short film, initiated by the Blender Foundation \
as a means to further improve and validate the free/open source 3D creation suite \
Blender. With initial funding provided by 1000s of donations via the internet \
community, it has \
again proven to be a viable development model for both open 3D technology as for \
independent animation film.
This 15 minute film has been realized in the studio of the Amsterdam Blender \
Institute, by an international team of artists and developers. In addition to \
that, several crucial technical and creative targets have been realized online, \
by developers and artists and teams all over the world.
Sorry for the late upload, I just didn't have time to edit my footage.
www.sintel.org"""
Embedded World: https://www.embedded-world.de/
My website: https://thdev.org
Twitter: https://twitter.com/Theta_Dev"""
)
assert video_info.duration == 888
assert video_info.duration == 267
assert not video_info.is_currently_live
assert not video_info.is_livestream
assert not video_info.is_short
assert video_info.published == datetime.datetime(
2010, 9, 30, tzinfo=datetime.timezone.utc
2019, 6, 2, tzinfo=datetime.timezone.utc
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 268 KiB

View file

@ -84,7 +84,7 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"ucast.apps.UcastConfig",
"ucast",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
@ -203,13 +203,12 @@ STATICFILES_DIRS = [resources.path("ucast", "static")]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Delay between YouTube API calls
YOUTUBE_SCRAPE_DELAY = 1
REDIS_HOST = get_env("REDIS_HOST", "localhost")
REDIS_PORT = get_env("REDIS_PORT", 6379)
REDIS_PASSWORD = get_env("REDIS_PASSWORD", "")
REDIS_DB = get_env("REDIS_DB", 0)
REDIS_QUEUE_TIMEOUT = get_env("REDIS_QUEUE_TIMEOUT", 600)
REDIS_QUEUE_RESULT_TTL = 600
RQ_QUEUES = {
"default": {
@ -217,8 +216,11 @@ RQ_QUEUES = {
"PORT": REDIS_PORT,
"DB": REDIS_DB,
"PASSWORD": REDIS_PASSWORD,
"DEFAULT_TIMEOUT": get_env("REDIS_QUEUE_TIMEOUT", 360),
"DEFAULT_TIMEOUT": REDIS_QUEUE_TIMEOUT,
"DEFAULT_RESULT_TTL": REDIS_QUEUE_RESULT_TTL,
}
}
RQ_SHOW_ADMIN_LINK = True
YT_UPDATE_INTERVAL = get_env("YT_UPDATE_INTERVAL", 900)