fix: 공유 썸네일 크기 조절

This commit is contained in:
김성경 2026-08-20 14:42:58 +09:00
parent a727d4f1ac
commit e8a268e7b4
6 changed files with 374 additions and 47 deletions

View File

@ -71,7 +71,15 @@ DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 페이지 → 영상 상세 이동 프론트 URL (로컬: http://localhost:3000, 테스트: https://dev.castad.net) SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 페이지 → 영상 상세 이동 프론트 URL (로컬: http://localhost:3000, 테스트: https://dev.castad.net)
SHARE_API_BASE_URL= # 공유 OG 페이지의 외부 공개 API URL (예: https://dev-ssul.castad.net/api). 프록시가 /api 를 떼면 필수 SHARE_API_BASE_URL= # 공유 OG 페이지의 외부 공개 API URL (예: https://dev-ssul.castad.net/api). 프록시가 /api 를 떼면 필수
SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 OG 이미지 (비우면 API /static/images/ado2_image.png) SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 공유 카드 원본으로 쓸 이미지 (비우면 브랜드 단색 카드)
SHARE_OG_IMAGE_WIDTH=1200 # 공유 카드 가로 픽셀 (링크 카드 비율을 결정. 600 미만이면 페이스북이 작은 카드로 렌더)
SHARE_OG_IMAGE_HEIGHT=630 # 공유 카드 세로 픽셀 (1200x630 = 1.91:1 표준)
SHARE_OG_BACKGROUND_COLOR=#011c1e # 포스터를 못 읽었을 때 카드를 채울 브랜드 색
SHARE_OG_BACKGROUND_BLUR=40 # 세로 포스터의 좌우 여백을 채우는 배경 블러 반경
SHARE_OG_BACKGROUND_BRIGHTNESS=0.55 # 배경 밝기 비율 (1.0=원본 밝기, 낮추면 가운데가 도드라짐)
SHARE_OG_JPEG_QUALITY=85 # 공유 카드 JPEG 품질 (1~100)
SHARE_OG_FETCH_TIMEOUT_SECONDS=5.0 # 포스터 원본 다운로드 타임아웃(초)
SHARE_OG_MAX_SOURCE_MB=20 # 합성에 쓸 포스터 원본 최대 크기(MB)
DEBUG=True # 디버그 모드 (True: 개발, False: 운영) DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
# ================================ # ================================

View File

@ -5,7 +5,7 @@ castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기). 인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
""" """
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy import func, select from sqlalchemy import func, select
@ -29,6 +29,7 @@ from app.user.dependencies.auth import get_current_user, get_current_user_option
from app.user.models import User from app.user.models import User
# 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐) # 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐)
from app.video.models import VideoReaction from app.video.models import VideoReaction
from app.video.services.share_image import build_og_card
from app.video.services.share_page import ( from app.video.services.share_page import (
build_ssul_share_html, build_ssul_share_html,
get_ssul_share_data, get_ssul_share_data,
@ -230,6 +231,42 @@ async def get_ssul_share_page(
) )
@router.get(
"/share/{content_id}/og.jpg",
summary="썰박스 공유 카드 이미지",
description=(
"포스터를 1200x630 카드로 합성해 반환합니다. 세로 포스터는 좌우 여백을 "
"블러 배경으로 채워 잘리지 않게 합니다."
),
response_class=Response,
responses={
200: {"content": {"image/jpeg": {}}, "description": "공유 카드 JPEG 반환"},
404: {"description": "공유 가능한 완료 콘텐츠를 찾을 수 없음"},
},
)
async def get_ssul_share_og_image(
content_id: int,
session: AsyncSession = Depends(get_session),
) -> Response:
"""크롤러가 링크 카드에 쓸 규격 이미지를 반환합니다."""
share_data = await get_ssul_share_data(session, content_id)
if share_data is None:
raise HTTPException(status_code=404, detail="공유 가능한 콘텐츠를 찾을 수 없습니다.")
card = await build_og_card(
share_data.poster_url,
prj_settings.SHARE_DEFAULT_IMAGE_URL,
)
return Response(
content=card,
media_type="image/jpeg",
headers={
"Cache-Control": "public, max-age=86400",
"X-Content-Type-Options": "nosniff",
},
)
@router.get( @router.get(
"/{content_id}", "/{content_id}",
response_model=SsulDetailResponse, response_model=SsulDetailResponse,

View File

@ -15,7 +15,15 @@ Video API Router
from typing import Literal from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
Query,
Request,
Response,
)
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -61,6 +69,7 @@ from app.video.worker.video_task import (
_fail_and_refund, _fail_and_refund,
download_and_upload_video_to_blob, download_and_upload_video_to_blob,
) )
from app.video.services.share_image import build_og_card
from app.video.services.share_page import ( from app.video.services.share_page import (
build_video_share_html, build_video_share_html,
get_video_share_data, get_video_share_data,
@ -1131,6 +1140,42 @@ async def get_video_share_page(
) )
@router.get(
"/share/{video_id}/og.jpg",
summary="영상 공유 카드 이미지",
description=(
"포스터를 1200x630 카드로 합성해 반환합니다. 세로 포스터는 좌우 여백을 "
"블러 배경으로 채워 잘리지 않게 합니다."
),
response_class=Response,
responses={
200: {"content": {"image/jpeg": {}}, "description": "공유 카드 JPEG 반환"},
404: {"description": "공유 가능한 완료 영상을 찾을 수 없음"},
},
)
async def get_video_share_og_image(
video_id: int,
session: AsyncSession = Depends(get_session),
) -> Response:
"""크롤러가 링크 카드에 쓸 규격 이미지를 반환합니다."""
share_data = await get_video_share_data(session, video_id)
if share_data is None:
raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.")
card = await build_og_card(
share_data.poster_url,
prj_settings.SHARE_DEFAULT_IMAGE_URL,
)
return Response(
content=card,
media_type="image/jpeg",
headers={
"Cache-Control": "public, max-age=86400",
"X-Content-Type-Options": "nosniff",
},
)
@router.get( @router.get(
"/{video_id}", "/{video_id}",
summary="단일 영상 상세 조회", summary="단일 영상 상세 조회",

View File

@ -0,0 +1,182 @@
"""공유 카드용 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()

View File

@ -11,10 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.home.models import Project from app.home.models import Project
from app.ssulbox.models import SsulContent from app.ssulbox.models import SsulContent
from app.video.models import Video from app.video.models import Video
from app.video.services.share_image import og_image_size
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai" FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png" DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -166,11 +166,14 @@ def _build_share_html(
fallback_image_url = _resolve_default_image_url( fallback_image_url = _resolve_default_image_url(
configured_default_image_url, configured_default_image_url,
frontend_base, frontend_base,
share_url=share_url,
) )
image_url = _absolute_http_url(poster_url) or fallback_image_url
canonical_url = _absolute_http_url(share_url) canonical_url = _absolute_http_url(share_url)
image_size_tags = _og_image_size_tags(image_url, fallback_image_url) image_url = _resolve_og_image_url(
canonical_url,
poster_url=poster_url,
fallback_image_url=fallback_image_url,
)
image_size_tags = _og_image_size_tags(image_url, canonical_url)
canonical_tags = _canonical_tags(canonical_url) canonical_tags = _canonical_tags(canonical_url)
escaped_title = escape(title, quote=True) escaped_title = escape(title, quote=True)
@ -320,28 +323,17 @@ def _normalise_frontend_base_url(value: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, path, "", "")) return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
def _resolve_default_image_url( def _resolve_default_image_url(configured_url: str, frontend_base: str) -> str:
configured_url: str, """공유 URL을 몰라 카드 엔드포인트를 못 쓸 때의 기본 이미지 URL입니다.
frontend_base: str,
*,
share_url: str = "",
) -> str:
"""포스터가 없을 때 사용할 기본 OG 이미지 URL을 반환합니다.
우선순위: 우선순위:
1. ``SHARE_DEFAULT_IMAGE_URL`` (.env) 1. ``SHARE_DEFAULT_IMAGE_URL`` (.env)
2. 공유 URL과 같은 API 베이스 ``.../static/images/ado2_image.png`` 2. ``SHARE_FRONTEND_URL`` + ``/assets/images/ado2_image.png``
(``/api/video/share/1`` 이면 ``/api/static/...``)
3. ``SHARE_FRONTEND_URL`` + ``/assets/images/ado2_image.png``
""" """
configured_absolute_url = _absolute_http_url(configured_url) configured_absolute_url = _absolute_http_url(configured_url)
if configured_absolute_url: if configured_absolute_url:
return configured_absolute_url return configured_absolute_url
share_api_base = _api_base_from_share_url(share_url)
if share_api_base:
return f"{share_api_base}{DEFAULT_SHARE_IMAGE_STATIC_PATH}"
return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}" return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}"
@ -361,41 +353,41 @@ def _canonical_tags(canonical_url: str | None) -> str:
) )
_DEFAULT_OG_IMAGE_SIZE = (385, 385) #: 공유 URL 뒤에 붙는 OG 카드 이미지 경로. 라우터의 엔드포인트 경로와 같아야 한다.
OG_IMAGE_PATH_SUFFIX = "/og.jpg"
def _og_image_size_tags(image_url: str, fallback_image_url: str) -> str: def _resolve_og_image_url(
"""폴백 로고처럼 크기를 아는 이미지에만 width/height 메타를 붙입니다.""" canonical_url: str | None,
if image_url != fallback_image_url: *,
poster_url: str | None,
fallback_image_url: str,
) -> str:
"""카드 비율을 보장하는 OG 이미지 URL을 고릅니다.
공유 URL을 알면 원본을 1200x630으로 합성해 주는 엔드포인트를 쓴다. 포스터가
세로형이어도 플랫폼이 가운데를 잘라내지 않는다. 공유 URL을 모르면 절대 주소를
만들 수 없으므로 원본 포스터를 그대로 내보낸다.
"""
if not canonical_url:
return _absolute_http_url(poster_url) or fallback_image_url
parts = urlsplit(canonical_url)
path = f"{parts.path.rstrip('/')}{OG_IMAGE_PATH_SUFFIX}"
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
def _og_image_size_tags(image_url: str, canonical_url: str | None) -> str:
"""크기를 아는 합성 카드에만 width/height 메타를 붙입니다."""
if not canonical_url or not image_url.endswith(OG_IMAGE_PATH_SUFFIX):
return "" return ""
width, height = _DEFAULT_OG_IMAGE_SIZE width, height = og_image_size()
return ( return (
f' <meta property="og:image:width" content="{width}">\n' f' <meta property="og:image:width" content="{width}">\n'
f' <meta property="og:image:height" content="{height}">\n' f' <meta property="og:image:height" content="{height}">\n'
) )
def _api_base_from_share_url(share_url: str) -> str | None:
"""공유 URL에서 API 베이스를 만듭니다. ``/api/video/share/1`` → ``https://host/api``."""
absolute_url = _absolute_http_url(share_url)
if not absolute_url:
return None
parts = urlsplit(absolute_url)
origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
path = parts.path or ""
prefix = ""
for marker in ("/video/share/", "/ssul/share/"):
idx = path.find(marker)
if idx >= 0:
prefix = path[:idx].rstrip("/")
break
else:
return origin
return f"{origin}{prefix}" if prefix else origin
def _absolute_http_url(value: str | None) -> str | None: def _absolute_http_url(value: str | None) -> str | None:
"""값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다.""" """값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다."""
candidate = (value or "").strip() candidate = (value or "").strip()

View File

@ -61,6 +61,68 @@ class ProjectSettings(BaseSettings):
model_config = _base_config model_config = _base_config
class ShareImageSettings(BaseSettings):
"""SNS 공유 카드(``og:image``) 합성 설정.
링크 카드의 비율은 메타 태그가 아니라 이미지 파일의 실제 픽셀 크기가 결정하므로,
비율을 바꾸려면 아래 width/height 를 바꿔야 한다. 기본값 1200x630(1.91:1)은
페이스북·링크드인·슬랙·트위터가 공통으로 큰 카드로 렌더하는 규격이다.
"""
SHARE_OG_IMAGE_WIDTH: int = Field(
default=1200,
ge=200,
le=4096,
description="공유 카드 가로 픽셀 (.env: SHARE_OG_IMAGE_WIDTH). 600 미만이면 페이스북이 작은 카드로 렌더",
)
SHARE_OG_IMAGE_HEIGHT: int = Field(
default=630,
ge=200,
le=4096,
description="공유 카드 세로 픽셀 (.env: SHARE_OG_IMAGE_HEIGHT)",
)
SHARE_OG_BACKGROUND_COLOR: str = Field(
default="#011c1e",
pattern=r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$",
description="포스터를 못 읽었을 때 카드를 채울 브랜드 색 (.env: SHARE_OG_BACKGROUND_COLOR)",
)
SHARE_OG_BACKGROUND_BLUR: int = Field(
default=40,
ge=0,
le=200,
description="세로 포스터의 좌우 여백을 채우는 배경 블러 반경 (.env: SHARE_OG_BACKGROUND_BLUR)",
)
SHARE_OG_BACKGROUND_BRIGHTNESS: float = Field(
default=0.55,
gt=0.0,
le=1.0,
description=(
"배경 밝기 비율 (.env: SHARE_OG_BACKGROUND_BRIGHTNESS). "
"1.0 이면 원본 밝기, 낮추면 가운데 콘텐츠가 도드라진다"
),
)
SHARE_OG_JPEG_QUALITY: int = Field(
default=85,
ge=1,
le=100,
description="공유 카드 JPEG 품질 (.env: SHARE_OG_JPEG_QUALITY)",
)
SHARE_OG_FETCH_TIMEOUT_SECONDS: float = Field(
default=5.0,
gt=0.0,
le=60.0,
description="포스터 원본 다운로드 타임아웃 초 (.env: SHARE_OG_FETCH_TIMEOUT_SECONDS)",
)
SHARE_OG_MAX_SOURCE_MB: int = Field(
default=20,
ge=1,
le=200,
description="합성에 쓸 포스터 원본 최대 크기 MB (.env: SHARE_OG_MAX_SOURCE_MB)",
)
model_config = _base_config
class APIKeySettings(BaseSettings): class APIKeySettings(BaseSettings):
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가 CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
GEMINI_API_KEY: str = Field(default="your-gemeni-api-key") # 기본값 추가 GEMINI_API_KEY: str = Field(default="your-gemeni-api-key") # 기본값 추가
@ -776,6 +838,7 @@ class SsulboxSettings(BaseSettings):
prj_settings = ProjectSettings() prj_settings = ProjectSettings()
share_image_settings = ShareImageSettings()
cors_settings = CORSSettings() cors_settings = CORSSettings()
apikey_settings = APIKeySettings() apikey_settings = APIKeySettings()
db_settings = DatabaseSettings() db_settings = DatabaseSettings()