fix: 썰박스 포스터·SNS 메타데이터 생성 및 공유 미리보기 수정
This commit is contained in:
parent
5fcb964d7f
commit
048a8f36a8
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user