YT-AI-Automation / backend /src /core /thumbnail_builder.py
github-actions
Sync Docker Space
5f3e9f5
"""Public PNG thumbnail renderer.
The model mirrors the React editor style: a fixed-size canvas plus
absolutely-positioned elements. Pillow renders that model directly for the
public Flask endpoint.
"""
from __future__ import annotations
import io
import ipaddress
import os
import socket
from dataclasses import dataclass
from typing import Iterable
from urllib.parse import urlparse
import requests
from PIL import Image, ImageDraw, ImageFont, ImageOps
CANVAS_SIZE = (1920, 1080)
_IMAGE_TIMEOUT_SECONDS = 8
_MAX_IMAGE_BYTES = 12 * 1024 * 1024
@dataclass(frozen=True)
class ThumbnailParams:
class_name: str
chapter_num: str
chapter_title: str
chapter_title2: str = ""
image_url: str = ""
year: str = "2082"
template: str = "default"
def render_thumbnail_png(params: ThumbnailParams) -> bytes:
"""Render a 1920x1080 PNG for query-string driven thumbnail requests."""
base = Image.new("RGB", CANVAS_SIZE, "#f4c400")
draw = ImageDraw.Draw(base)
photo = _fetch_image(params.image_url)
if photo is None:
photo = _placeholder_image()
photo = ImageOps.fit(photo.convert("RGB"), (760, 760), method=Image.Resampling.LANCZOS)
base.paste(photo, (1050, 160))
_draw_decor(base, draw)
_draw_text_layout(draw, params)
out = io.BytesIO()
base.save(out, format="PNG", optimize=True)
return out.getvalue()
def _draw_decor(base: Image.Image, draw: ImageDraw.ImageDraw) -> None:
draw.rounded_rectangle((90, 110, 975, 910), radius=28, fill="#fff3c4")
draw.rounded_rectangle((110, 130, 955, 890), radius=22, outline="#ef4444", width=8)
draw.rectangle((0, 0, CANVAS_SIZE[0], 70), fill="#e11d48")
draw.rectangle((0, CANVAS_SIZE[1] - 70, CANVAS_SIZE[0], CANVAS_SIZE[1]), fill="#e11d48")
draw.polygon([(990, 120), (1840, 120), (1770, 930), (1030, 930)], fill="#dc2626")
draw.polygon([(1030, 150), (1800, 150), (1735, 900), (1070, 900)], fill="#991b1b")
mask = Image.new("L", CANVAS_SIZE, 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle((1040, 150, 1820, 930), radius=34, fill=255)
shadow = Image.new("RGB", CANVAS_SIZE, "#7f1d1d")
base.paste(shadow, mask=mask.point(lambda p: int(p * 0.25)))
def _draw_text_layout(draw: ImageDraw.ImageDraw, params: ThumbnailParams) -> None:
font_bold = _font(86, bold=True)
font_title = _font(116, bold=True)
font_title2 = _font(104, bold=True)
font_badge = _font(54, bold=True)
font_small = _font(44, bold=True)
draw.rounded_rectangle((145, 175, 450, 270), radius=18, fill="#dc2626")
draw.text((178, 194), f"Class {params.class_name}", font=font_badge, fill="#ffffff")
draw.rounded_rectangle((520, 175, 910, 270), radius=18, fill="#111827")
draw.text((555, 194), f"Year {params.year}", font=font_badge, fill="#ffffff")
draw.text((150, 350), f"Chapter {params.chapter_num}", font=font_bold, fill="#111827")
_center_text(draw, params.chapter_title, (140, 460, 930, 600), font_title, "#dc2626")
if params.chapter_title2:
_center_text(draw, params.chapter_title2, (140, 610, 930, 750), font_title2, "#dc2626")
draw.rounded_rectangle((200, 785, 875, 865), radius=18, fill="#dc2626")
_center_text(draw, "Complete Nepali Guide", (200, 792, 875, 858), font_small, "#ffffff")
def _center_text(
draw: ImageDraw.ImageDraw,
text: str,
box: tuple[int, int, int, int],
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
fill: str,
) -> None:
bbox = draw.textbbox((0, 0), text, font=font)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
x = box[0] + ((box[2] - box[0]) - width) / 2
y = box[1] + ((box[3] - box[1]) - height) / 2 - bbox[1]
draw.text((x, y), text, font=font, fill=fill)
def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
candidates: Iterable[str] = (
os.environ.get("THUMBNAIL_FONT_PATH") or "",
r"C:\Windows\Fonts\mangalb.ttf" if bold else r"C:\Windows\Fonts\mangal.ttf",
r"C:\Windows\Fonts\Nirmala.ttc",
"/usr/share/fonts/truetype/noto/NotoSansDevanagari-Bold.ttf" if bold else "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
)
for path in candidates:
if path and os.path.isfile(path):
return ImageFont.truetype(path, size=size)
return ImageFont.load_default()
def _fetch_image(url: str) -> Image.Image | None:
parsed = urlparse((url or "").strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
if _is_private_host(parsed.hostname or ""):
return None
try:
response = requests.get(url, timeout=_IMAGE_TIMEOUT_SECONDS, stream=True)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if content_type and not content_type.lower().startswith("image/"):
return None
data = bytearray()
for chunk in response.iter_content(64 * 1024):
data.extend(chunk)
if len(data) > _MAX_IMAGE_BYTES:
return None
return Image.open(io.BytesIO(data))
except Exception:
return None
def _is_private_host(hostname: str) -> bool:
try:
addresses = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except OSError:
return True
for addr in addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast:
return True
return False
def _placeholder_image() -> Image.Image:
image = Image.new("RGB", (760, 760), "#fde68a")
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((70, 70, 690, 690), radius=44, fill="#f97316")
draw.ellipse((210, 150, 550, 490), fill="#fff7ed")
draw.rectangle((170, 520, 590, 620), fill="#fff7ed")
return image