183 lines
6.2 KiB
Python
183 lines
6.2 KiB
Python
"""공유 카드용 OG 이미지 합성.
|
|
|
|
크롤러가 만드는 링크 카드의 비율은 ``og:image`` 파일의 실제 픽셀 크기가 결정한다.
|
|
``og:image:width``/``height`` 는 다운로드 전 자리를 잡는 힌트일 뿐이므로, 원하는
|
|
비율을 얻으려면 이미지 자체를 그 비율로 만들어야 한다.
|
|
|
|
영상 포스터는 세로형(9:16)이 기본이라 그대로 쓰면 플랫폼이 가운데를 잘라낸다.
|
|
그래서 원본 전체가 보이도록 축소해 가운데 얹고, 남는 좌우 여백은 같은 이미지를
|
|
확대·블러한 배경으로 채운다.
|
|
|
|
크기·블러·밝기·품질은 모두 ``.env`` (``SHARE_OG_*``, :class:`config.ShareImageSettings`)
|
|
에서 조절한다.
|
|
"""
|
|
|
|
import io
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
from PIL import Image, ImageEnhance, ImageFilter, UnidentifiedImageError
|
|
|
|
from app.utils.logger import get_logger
|
|
from config import share_image_settings
|
|
|
|
logger = get_logger("share_image")
|
|
|
|
_BYTES_PER_MB = 1024 * 1024
|
|
|
|
|
|
def og_image_size() -> tuple[int, int]:
|
|
"""``.env`` 가 정한 공유 카드 크기입니다. 이 비율이 링크 카드 비율이 된다."""
|
|
return (
|
|
share_image_settings.SHARE_OG_IMAGE_WIDTH,
|
|
share_image_settings.SHARE_OG_IMAGE_HEIGHT,
|
|
)
|
|
|
|
|
|
async def build_og_card(
|
|
poster_url: str | None,
|
|
fallback_image_url: str | None = None,
|
|
) -> bytes:
|
|
"""포스터를 내려받아 공유 카드 JPEG를 만듭니다.
|
|
|
|
포스터 → 설정된 기본 이미지 → 브랜드 단색 순으로 물러난다. 어느 단계에서
|
|
실패해도 크롤러가 404 대신 항상 규격 이미지를 받는다.
|
|
"""
|
|
for candidate in (poster_url, fallback_image_url):
|
|
if not _is_fetchable_url(candidate):
|
|
continue
|
|
source = await fetch_poster_bytes(candidate or "")
|
|
if source is not None:
|
|
return compose_og_image(source)
|
|
|
|
return compose_og_image(None)
|
|
|
|
|
|
def _is_fetchable_url(value: str | None) -> bool:
|
|
"""내려받아도 되는 절대 HTTP(S) URL인지 확인합니다."""
|
|
candidate = (value or "").strip()
|
|
if not candidate:
|
|
return False
|
|
|
|
try:
|
|
parts = urlsplit(candidate)
|
|
except ValueError:
|
|
return False
|
|
|
|
return parts.scheme.lower() in {"http", "https"} and bool(parts.hostname)
|
|
|
|
|
|
async def fetch_poster_bytes(url: str) -> bytes | None:
|
|
"""포스터 원본을 내려받고, 실패하면 ``None``을 반환합니다."""
|
|
try:
|
|
timeout = share_image_settings.SHARE_OG_FETCH_TIMEOUT_SECONDS
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
response = await client.get(url)
|
|
response.raise_for_status()
|
|
content = response.content
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"[share_image] 포스터 다운로드에 실패했습니다 (url=%s, error=%s: %s)",
|
|
url,
|
|
type(exc).__name__,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
if len(content) > share_image_settings.SHARE_OG_MAX_SOURCE_MB * _BYTES_PER_MB:
|
|
logger.warning(
|
|
"[share_image] 포스터가 너무 큽니다 (url=%s, bytes=%s)",
|
|
url,
|
|
len(content),
|
|
)
|
|
return None
|
|
|
|
return content
|
|
|
|
|
|
def compose_og_image(source: bytes | None) -> bytes:
|
|
"""원본을 설정된 카드 크기로 합성한 JPEG 바이트를 반환합니다.
|
|
|
|
원본이 없거나 이미지로 읽히지 않으면 브랜드 단색 카드를 돌려주어, 크롤러가
|
|
항상 크기를 아는 이미지를 받도록 한다.
|
|
"""
|
|
size = og_image_size()
|
|
original = _open_image(source)
|
|
if original is None:
|
|
canvas = Image.new(
|
|
"RGB",
|
|
size,
|
|
share_image_settings.SHARE_OG_BACKGROUND_COLOR,
|
|
)
|
|
return _encode(canvas)
|
|
|
|
canvas = _blurred_background(original, size)
|
|
foreground = _contain(original, size)
|
|
offset = (
|
|
(size[0] - foreground.width) // 2,
|
|
(size[1] - foreground.height) // 2,
|
|
)
|
|
canvas.paste(foreground, offset)
|
|
return _encode(canvas)
|
|
|
|
|
|
def _open_image(source: bytes | None) -> Image.Image | None:
|
|
"""바이트를 RGB 이미지로 열고, 이미지가 아니면 ``None``을 반환합니다."""
|
|
if not source:
|
|
return None
|
|
|
|
try:
|
|
return Image.open(io.BytesIO(source)).convert("RGB")
|
|
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
|
logger.warning(
|
|
"[share_image] 포스터를 이미지로 읽지 못했습니다 (error=%s: %s)",
|
|
type(exc).__name__,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
|
|
def _blurred_background(original: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
"""원본을 카드 전체에 꽉 채워 블러하고 어둡게 누른 배경을 만듭니다."""
|
|
blurred = _cover(original, size).filter(
|
|
ImageFilter.GaussianBlur(share_image_settings.SHARE_OG_BACKGROUND_BLUR)
|
|
)
|
|
return ImageEnhance.Brightness(blurred).enhance(
|
|
share_image_settings.SHARE_OG_BACKGROUND_BRIGHTNESS
|
|
)
|
|
|
|
|
|
def _cover(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
"""비율을 지키며 목표 크기를 덮도록 확대한 뒤 가운데를 잘라냅니다."""
|
|
target_width, target_height = size
|
|
scale = max(target_width / image.width, target_height / image.height)
|
|
scaled = image.resize(
|
|
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
|
|
Image.LANCZOS,
|
|
)
|
|
left = (scaled.width - target_width) // 2
|
|
top = (scaled.height - target_height) // 2
|
|
return scaled.crop((left, top, left + target_width, top + target_height))
|
|
|
|
|
|
def _contain(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
"""비율을 지키며 목표 크기 안에 전부 들어가도록 축소합니다."""
|
|
target_width, target_height = size
|
|
scale = min(target_width / image.width, target_height / image.height)
|
|
return image.resize(
|
|
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
|
|
Image.LANCZOS,
|
|
)
|
|
|
|
|
|
def _encode(image: Image.Image) -> bytes:
|
|
"""카드를 JPEG 바이트로 인코딩합니다."""
|
|
buffer = io.BytesIO()
|
|
image.save(
|
|
buffer,
|
|
format="JPEG",
|
|
quality=share_image_settings.SHARE_OG_JPEG_QUALITY,
|
|
optimize=True,
|
|
)
|
|
return buffer.getvalue()
|