fix: 썰박스 포스터·SNS 메타데이터 생성 및 공유 미리보기 수정

This commit is contained in:
김성경 2026-08-20 13:21:43 +09:00
parent 5fcb964d7f
commit 048a8f36a8
5 changed files with 178 additions and 35 deletions

View File

@ -70,10 +70,6 @@ class SeoService:
session: AsyncSession,
) -> YoutubeDescriptionResponse:
"""썰박스 콘텐츠용 SEO 생성 — ADO2 와 다른 프롬프트(시트 ssul_upload)를 쓴다."""
from app.ssulbox.constants import SCENARIO_NAMES
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import get_ssul_upload_prompt
try:
content = (
await session.execute(
@ -93,24 +89,12 @@ class SeoService:
if has_stored_sns_metadata(content):
return self._response_from_row(content)
input_data = {
"store_name": content.store_name or "",
"region": content.region or "",
"scenario_name": SCENARIO_NAMES.get(content.scenario, content.scenario),
}
chatgpt = ChatgptService(timeout=180)
out = await chatgpt.generate_structured_output(
get_ssul_upload_prompt(), input_data
)
result = YoutubeDescriptionResponse(
title=out.title,
description=out.description,
keywords=out.keywords,
)
apply_sns_metadata(content, result.title, result.description, result.keywords)
result = await self.generate_and_save_for_ssul(content_id, session)
if result is None:
raise HTTPException(
status_code=404, detail="콘텐츠를 찾을 수 없습니다."
)
await session.commit()
logger.info(f"[SEO_SERVICE] Saved ssul metadata - content_id: {content_id}")
return result
except HTTPException:
@ -122,6 +106,50 @@ class SeoService:
detail=f"썰박스 SEO 생성에 실패했습니다. : {str(e)}",
)
async def generate_and_save_for_ssul(
self,
content_id: int,
session: AsyncSession,
) -> YoutubeDescriptionResponse | None:
"""GPT로 썰박스 SNS 메타데이터를 생성해 저장합니다. 워커/온디맨드 공용."""
content = await session.get(SsulContent, content_id)
if content is None:
logger.warning(f"[SEO_SERVICE] SsulContent NOT FOUND - content_id: {content_id}")
return None
if has_stored_sns_metadata(content):
return self._response_from_row(content)
result = await self._generate_ssul_seo_description(content)
apply_sns_metadata(content, result.title, result.description, result.keywords)
await session.flush()
logger.info(f"[SEO_SERVICE] Saved ssul metadata - content_id: {content_id}")
return result
async def _generate_ssul_seo_description(
self,
content: SsulContent,
) -> YoutubeDescriptionResponse:
"""썰박스 전용 프롬프트로 제목/설명/해시태그를 생성합니다."""
from app.ssulbox.constants import SCENARIO_NAMES
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import get_ssul_upload_prompt
input_data = {
"store_name": content.store_name or "",
"region": content.region or "",
"scenario_name": SCENARIO_NAMES.get(content.scenario, content.scenario),
}
chatgpt = ChatgptService(timeout=180)
out = await chatgpt.generate_structured_output(
get_ssul_upload_prompt(), input_data
)
return YoutubeDescriptionResponse(
title=out.title,
description=out.description,
keywords=out.keywords,
)
async def generate_and_save_for_video(
self,
video_id: int,

View File

@ -13,6 +13,7 @@ from typing import Optional
from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.video_poster import extract_first_frame, generate_and_store_poster
from config import azure_blob_settings, ssulbox_settings
logger = get_logger("ssulbox")
@ -64,3 +65,53 @@ async def upload_ssul_video(
logger.info(f"[upload_ssul_video] OK id={content_id} url={uploader.public_url}")
return uploader.public_url
def _ssul_task_id(content_id: int) -> str:
return f"{ssulbox_settings.SSULBOX_BLOB_PREFIX}-{content_id}"
async def generate_ssul_poster(
mp4_path: Path, user_uuid: Optional[str], content_id: int
) -> Optional[str]:
"""영상 첫 프레임을 포스터로 만들고 URL을 반환합니다. 실패 시 None.
Blob이 켜져 있으면 ADO2와 같은 업로더를 쓰고, 꺼져 있으면 로컬 mp4 옆에
jpg를 두어 `/ssul-videos/` 로 서빙한다.
"""
try:
if blob_enabled() and user_uuid:
url = await generate_and_store_poster(
video_path=mp4_path,
user_uuid=user_uuid,
task_id=_ssul_task_id(content_id),
file_stem=_ssul_task_id(content_id),
)
if url:
logger.info(f"[generate_ssul_poster] OK id={content_id} url={url}")
return url
logger.warning(f"[generate_ssul_poster] Blob 포스터 없음 id={content_id}")
return None
image_bytes = await extract_first_frame(mp4_path)
if not image_bytes:
return None
poster_path = mp4_path.with_suffix(".jpg")
poster_path.write_bytes(image_bytes)
try:
rel = poster_path.resolve().relative_to(
ssulbox_settings.output_path.resolve()
)
except ValueError:
logger.warning(
f"[generate_ssul_poster] output 밖 경로 id={content_id} path={poster_path}"
)
return None
url = f"/ssul-videos/{rel.as_posix()}"
logger.info(f"[generate_ssul_poster] 로컬 서빙 id={content_id} url={url}")
return url
except Exception as e:
logger.warning(
f"[generate_ssul_poster] 실패 id={content_id} - {type(e).__name__}: {e}"
)
return None

View File

@ -22,6 +22,7 @@ from app.credit.services.credit_service import (
)
from app.ssulbox.constants import JOB_TYPE_SSUL, ORPHAN_STATUSES, SsulTaskStatus
from app.ssulbox.models import SsulContent
from app.ssulbox.services.blob_service import generate_ssul_poster, upload_ssul_video
# castad 와 **같은 규칙으로** 지역을 뽑는다. 통합 목록에서 한 필터가 양쪽을
# 걸러야 하므로 region 값의 형식이 일치해야 한다.
from app.utils.address_parser import extract_region_from_address
@ -146,8 +147,6 @@ async def finalize_task(
# Blob 이 설정돼 있으면 업로드하고 로컬은 커밋 후 정리한다.
# 아니면 로컬 경로를 그대로 서빙한다.
try:
from app.ssulbox.services.blob_service import upload_ssul_video
video_url = await upload_ssul_video(mp4_path, row.user_uuid, content_id)
if video_url:
cleanup = job_dir
@ -168,6 +167,16 @@ async def finalize_task(
logger.error(f"[finalize_task] output 밖 경로 id={content_id} path={mp4_path}")
row.video_url = video_url
try:
poster_url = await generate_ssul_poster(mp4_path, row.user_uuid, content_id)
if poster_url:
row.poster_url = poster_url
except Exception as e:
logger.warning(
f"[finalize_task] 포스터 생성 실패 id={content_id} - "
f"{type(e).__name__}: {e}",
exc_info=True,
)
# 생성 시점에 이미 채워진 값(검색으로 사용자가 직접 고른 업장)이 우선이다.
# 여기 들어오는 값은 생성 로그에서 뒤늦게 주워온 것이므로 덮어쓰지 않는다.
if store_name and not row.store_name:

View File

@ -182,6 +182,21 @@ async def _finalize(
task_service.cleanup_job_dir(cleanup)
async def _try_generate_sns_metadata(content_id: int) -> None:
"""제목/설명/해시태그 생성 실패가 완료 처리에 영향을 주지 않도록 격리합니다."""
from app.social.services.seo_service import seo_service
try:
async with BackgroundSessionLocal() as session:
await seo_service.generate_and_save_for_ssul(content_id, session)
await session.commit()
except Exception as e:
logger.warning(
f"[ssul {content_id}] SNS 메타데이터 생성 실패: {e}",
exc_info=True,
)
async def _fail(content_id: int, error: str) -> None:
async with BackgroundSessionLocal() as session:
try:
@ -439,10 +454,17 @@ def _run(content_id: int) -> None:
if not mp4.is_absolute():
mp4 = (engine_dir / mp4).resolve()
# finalize 는 Blob 업로드를 포함해 오래 걸리므로 위임 타임아웃을 넉넉히
# finalize 는 Blob 업로드·포스터를 포함해 오래 걸리므로 위임 타임아웃을 넉넉히
_run_db(
_finalize(content_id, mp4, job.get("store_name")), timeout=600
)
# ADO2 video_task 와 같이 완료 커밋 뒤에 SEO를 돌린다. 실패해도 영상은 유지.
try:
_run_db(_try_generate_sns_metadata(content_id), timeout=240)
except Exception as e:
logger.warning(
f"[ssul {content_id}] SNS 메타데이터 위임 실패: {type(e).__name__}: {e}"
)
job["status"] = "done"
job["step"] = 4

View File

@ -170,6 +170,7 @@ def _build_share_html(
)
image_url = _absolute_http_url(poster_url) or fallback_image_url
canonical_url = _absolute_http_url(share_url) or detail_url
image_size_tags = _og_image_size_tags(image_url, fallback_image_url)
escaped_title = escape(title, quote=True)
escaped_description = escape(description, quote=True)
@ -190,8 +191,8 @@ def _build_share_html(
<meta property="og:description" content="{escaped_description}">
<meta property="og:image" content="{escaped_image_url}">
<meta property="og:image:alt" content="{escaped_title}">
<meta property="og:url" content="{escaped_canonical_url}">
<meta property="og:type" content="video.other">
{image_size_tags} <meta property="og:url" content="{escaped_canonical_url}">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{escaped_title}">
@ -206,7 +207,13 @@ def _build_share_html(
<a id="continue-link" href="{escaped_detail_url}">콘텐츠 보기</a>
</main>
<script>
window.location.replace(document.getElementById("continue-link").href);
(function () {{
var ua = navigator.userAgent || "";
if (/bot|crawl|spider|slurp|facebookexternalhit|Facebot|Twitterbot|LinkedInBot|Pinterest|Slackbot|Telegram|WhatsApp|Discord|Kakao|kakaotalk|Embedly|redditbot|Applebot/i.test(ua)) {{
return;
}}
window.location.replace(document.getElementById("continue-link").href);
}})();
</script>
</body>
</html>
@ -271,28 +278,54 @@ def _resolve_default_image_url(
우선순위:
1. ``SHARE_DEFAULT_IMAGE_URL`` (.env)
2. 공유 API 호스트 ``/static/images/ado2_image.png``
2. 공유 URL과 같은 API 베이스 ``.../static/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)
if configured_absolute_url:
return configured_absolute_url
share_origin = _origin_from_url(share_url)
if share_origin:
return f"{share_origin}{DEFAULT_SHARE_IMAGE_STATIC_PATH}"
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}"
def _origin_from_url(value: str) -> str | None:
"""URL에서 scheme + host(+port) origin만 추출합니다."""
absolute_url = _absolute_http_url(value)
_DEFAULT_OG_IMAGE_SIZE = (385, 385)
def _og_image_size_tags(image_url: str, fallback_image_url: str) -> str:
"""폴백 로고처럼 크기를 아는 이미지에만 width/height 메타를 붙입니다."""
if image_url != fallback_image_url:
return ""
width, height = _DEFAULT_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)
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
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: