diff --git a/app/social/services/seo_service.py b/app/social/services/seo_service.py
index dad4463..2f53919 100644
--- a/app/social/services/seo_service.py
+++ b/app/social/services/seo_service.py
@@ -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,
diff --git a/app/ssulbox/services/blob_service.py b/app/ssulbox/services/blob_service.py
index adb72ec..cdd1066 100644
--- a/app/ssulbox/services/blob_service.py
+++ b/app/ssulbox/services/blob_service.py
@@ -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
diff --git a/app/ssulbox/services/task_service.py b/app/ssulbox/services/task_service.py
index 3199dfb..0031df0 100644
--- a/app/ssulbox/services/task_service.py
+++ b/app/ssulbox/services/task_service.py
@@ -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:
diff --git a/app/ssulbox/worker/job_manager.py b/app/ssulbox/worker/job_manager.py
index 8c19d18..e92aa1c 100644
--- a/app/ssulbox/worker/job_manager.py
+++ b/app/ssulbox/worker/job_manager.py
@@ -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
diff --git a/app/video/services/share_page.py b/app/video/services/share_page.py
index fc2ba4c..16dc184 100644
--- a/app/video/services/share_page.py
+++ b/app/video/services/share_page.py
@@ -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(
-
-
+{image_size_tags}
+
@@ -206,7 +207,13 @@ def _build_share_html(
콘텐츠 보기