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( 콘텐츠 보기 @@ -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' \n' + f' \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: