fix: 공유 썸네일 크기 조절
This commit is contained in:
parent
a727d4f1ac
commit
e8a268e7b4
10
README.md
10
README.md
@ -71,7 +71,15 @@ DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
||||
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
||||
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_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: 운영)
|
||||
|
||||
# ================================
|
||||
|
||||
@ -5,7 +5,7 @@ castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `
|
||||
인증은 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 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
|
||||
# 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐)
|
||||
from app.video.models import VideoReaction
|
||||
from app.video.services.share_image import build_og_card
|
||||
from app.video.services.share_page import (
|
||||
build_ssul_share_html,
|
||||
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(
|
||||
"/{content_id}",
|
||||
response_model=SsulDetailResponse,
|
||||
|
||||
@ -15,7 +15,15 @@ Video API Router
|
||||
|
||||
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 sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@ -61,6 +69,7 @@ from app.video.worker.video_task import (
|
||||
_fail_and_refund,
|
||||
download_and_upload_video_to_blob,
|
||||
)
|
||||
from app.video.services.share_image import build_og_card
|
||||
from app.video.services.share_page import (
|
||||
build_video_share_html,
|
||||
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(
|
||||
"/{video_id}",
|
||||
summary="단일 영상 상세 조회",
|
||||
|
||||
182
app/video/services/share_image.py
Normal file
182
app/video/services/share_image.py
Normal 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()
|
||||
@ -11,10 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.home.models import Project
|
||||
from app.ssulbox.models import SsulContent
|
||||
from app.video.models import Video
|
||||
from app.video.services.share_image import og_image_size
|
||||
|
||||
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
|
||||
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
|
||||
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -166,11 +166,14 @@ def _build_share_html(
|
||||
fallback_image_url = _resolve_default_image_url(
|
||||
configured_default_image_url,
|
||||
frontend_base,
|
||||
share_url=share_url,
|
||||
)
|
||||
image_url = _absolute_http_url(poster_url) or fallback_image_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)
|
||||
|
||||
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, "", ""))
|
||||
|
||||
|
||||
def _resolve_default_image_url(
|
||||
configured_url: str,
|
||||
frontend_base: str,
|
||||
*,
|
||||
share_url: str = "",
|
||||
) -> str:
|
||||
"""포스터가 없을 때 사용할 기본 OG 이미지 URL을 반환합니다.
|
||||
def _resolve_default_image_url(configured_url: str, frontend_base: str) -> str:
|
||||
"""공유 URL을 몰라 카드 엔드포인트를 못 쓸 때의 기본 이미지 URL입니다.
|
||||
|
||||
우선순위:
|
||||
1. ``SHARE_DEFAULT_IMAGE_URL`` (.env)
|
||||
2. 공유 URL과 같은 API 베이스 ``.../static/images/ado2_image.png``
|
||||
(``/api/video/share/1`` 이면 ``/api/static/...``)
|
||||
3. ``SHARE_FRONTEND_URL`` + ``/assets/images/ado2_image.png``
|
||||
2. ``SHARE_FRONTEND_URL`` + ``/assets/images/ado2_image.png``
|
||||
"""
|
||||
configured_absolute_url = _absolute_http_url(configured_url)
|
||||
if 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}"
|
||||
|
||||
|
||||
@ -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:
|
||||
"""폴백 로고처럼 크기를 아는 이미지에만 width/height 메타를 붙입니다."""
|
||||
if image_url != fallback_image_url:
|
||||
def _resolve_og_image_url(
|
||||
canonical_url: str | None,
|
||||
*,
|
||||
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 ""
|
||||
width, height = _DEFAULT_OG_IMAGE_SIZE
|
||||
width, height = og_image_size()
|
||||
return (
|
||||
f' <meta property="og:image:width" content="{width}">\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:
|
||||
"""값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다."""
|
||||
candidate = (value or "").strip()
|
||||
|
||||
63
config.py
63
config.py
@ -61,6 +61,68 @@ class ProjectSettings(BaseSettings):
|
||||
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):
|
||||
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
|
||||
GEMINI_API_KEY: str = Field(default="your-gemeni-api-key") # 기본값 추가
|
||||
@ -776,6 +838,7 @@ class SsulboxSettings(BaseSettings):
|
||||
|
||||
|
||||
prj_settings = ProjectSettings()
|
||||
share_image_settings = ShareImageSettings()
|
||||
cors_settings = CORSSettings()
|
||||
apikey_settings = APIKeySettings()
|
||||
db_settings = DatabaseSettings()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user