Compare commits

...

16 Commits

Author SHA1 Message Date
9aa4e0a343 fix: 이미지 태그 EASONING_EFFORT = "low" 지정 2026-09-07 11:31:32 +09:00
ac7e4bde9f fix(video): 재생 URL에 SAS 토큰을 붙여 영상 seek 안 되는 문제 수정 2026-08-24 10:16:40 +09:00
6197fcdc91 fix(crawling): homepages를 GraphQL 대신 __APOLLO_STATE__에서 추출
placeDetail(base)의 homepages 필드는 스키마에 없어 전체 크롤링이
400으로 실패했다(컨테이너 검증 완료). 쿼리에서 제거하고, WTM 우회용
브라우저가 이미 로드하는 place 페이지의 __APOLLO_STATE__ JSON을 캡처해
대표(repr) 링크를 추출한다. 브라우저 캡처 실패 시 HTML 직접 파싱으로
보충한다(직접 요청은 429로 막히는 환경이 있어 best-effort).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS
2026-08-21 11:30:01 +09:00
87cb3eb3cb fix(db): create_db_tables 목록에 Comment·VideoReaction 테이블 추가
신규 DB 셋업 시 두 테이블이 생성되지 않아 /video/all 등 댓글·좋아요
조회가 즉시 실패하던 누락을 보완한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS
2026-08-21 11:12:20 +09:00
98c6515192 feat(marketing): 직접 입력 경로에 official_site_url 입력 지원
POST /marketing 요청에 official_site_url(선택, 기본 null)을 추가한다.
http/https 스킴만 허용하고 2048자 초과는 422로 거부하며, 값이 있으면
marketing.official_site_url에 저장돼 기존 영상 조회 응답에 그대로 노출된다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS
2026-08-21 10:57:42 +09:00
1ef8e82638 feat(crawling): 업체 공식 링크 수집·저장 및 official_site_url 응답 연동
- 네이버 placeDetail GraphQL base에 homepages 필드 추가, 대표(repr)
  링크 우선으로 살아있는 URL을 추출한다 (실서비스 응답 구조 확인 완료)
- marketing 테이블에 official_site_url 컬럼 추가: 크롤링 시 플레이스
  홈페이지 항목을 저장하고, 없으면 유저가 입력한 크롤링 소스 URL을 저장
- GET /video/{id}, /video/all의 official_site_url은 저장값 우선,
  컬럼 도입 전 기존 행은 place_id 기반 플레이스 URL로 폴백
- 기존 테이블에는 수동 마이그레이션 필요:
  ALTER TABLE marketing ADD COLUMN official_site_url VARCHAR(2048) NULL
  COMMENT '업체 공식 링크' AFTER place_id;

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS
2026-08-21 10:49:46 +09:00
61bdc27cd3 feat(video): 영상 응답에 official_site_url 필드 추가
GET /video/{id}와 GET /video/all 응답에 업체 공식 페이지 URL을 추가한다.
Project.marketing_intelligence → MarketingIntel.place_id("nv{place_id}")를
경유해 네이버 플레이스 URL로 복원하며, 크롤링 없이 직접 입력된 업체는
null로 내려간다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVSeUT2pEmeHc6hG6gVXUS
2026-08-21 10:44:44 +09:00
9d40313fbb fix: 페이스북 공유하기 수정 2026-08-21 08:48:08 +09:00
1b982b6ae1 fix: 공유하기 수정 2026-08-20 16:35:19 +09:00
a2d84421d2 feat(video): 제목·설명·해시태그를 영상 완료 시 DB에 저장
업로드 시점 Redis SEO 캐시 대신 video 행을 기준으로 두고, 게시 시에만 변경분을 갱신한다.
2026-08-19 10:17:22 +09:00
c7149deb1f fix: 배포환경에 맞게 공유URL 적용되도록 수정 2026-08-18 17:23:50 +09:00
51ef659f16 fix:공유하기 기본이미지 + 내용 2026-08-18 14:55:32 +09:00
1507a0f859 feat(archive): 영상 목록 조회에 내 좋아요 여부(is_liked_by_me) 포함 2026-08-18 11:02:19 +09:00
11c358da3c feat(video): 영상 공유용 Open Graph 페이지 라우트 추가 2026-08-18 11:02:15 +09:00
8e9e12fe0b feat(video): 영상 첫 프레임 썸네일 생성 및 저장 2026-08-13 17:23:06 +09:00
4c4297c37a fix(creatomate): 슬롯명이 명명 규칙을 어겨도 썸네일·태그 배정이 폴백되도록 수정 2026-08-12 15:31:02 +09:00
33 changed files with 1264 additions and 127 deletions

7
.gitignore vendored
View File

@ -32,8 +32,11 @@ media/
*.ipynb_checkpoint* *.ipynb_checkpoint*
# Static files # Static files (공유 기본 이미지는 예외로 추적)
static/ static/*
!static/images/
static/images/*
!static/images/ado2_image.png
# Log files # Log files
*.log *.log

View File

@ -69,6 +69,9 @@ PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포
PROJECT_VERSION=0.1.0 # 프로젝트 버전 PROJECT_VERSION=0.1.0 # 프로젝트 버전
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명 DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL 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)
DEBUG=True # 디버그 모드 (True: 개발, False: 운영) DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
# ================================ # ================================

View File

@ -16,8 +16,13 @@ from app.user.dependencies.auth import get_current_user
from app.user.models import User from app.user.models import User
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.utils.upload_blob_as_request import to_playback_url
from app.comment.models import Comment from app.comment.models import Comment
from app.database.like_cache import get_like_counts, mset_like_counts from app.database.like_cache import (
bulk_is_user_liked,
get_like_counts,
mset_like_counts,
)
from app.video.models import Video, VideoReaction from app.video.models import Video, VideoReaction
from app.video.schemas.video_schema import VideoListItem from app.video.schemas.video_schema import VideoListItem
@ -149,6 +154,24 @@ async def get_videos(
if vid not in db_found_ids: if vid not in db_found_ids:
like_count_map[vid] = 0 like_count_map[vid] = 0
# is_liked_by_me: Redis user-set 기준, 캐시 미스 시 현재 사용자 상태만 DB 조회
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
needs_db_lookup = [
vid for vid, liked in raw_liked.items()
if liked is None and like_count_map.get(vid, 0) > 0
]
if needs_db_lookup:
liked_video_ids = set((await session.execute(
select(VideoReaction.video_id).where(
VideoReaction.video_id.in_(needs_db_lookup),
VideoReaction.user_uuid == current_user.user_uuid,
)
)).scalars().all())
for vid in needs_db_lookup:
raw_liked[vid] = vid in liked_video_ids
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
# VideoListItem으로 변환 # VideoListItem으로 변환
items = [ items = [
VideoListItem( VideoListItem(
@ -156,10 +179,15 @@ async def get_videos(
store_name=project.store_name, store_name=project.store_name,
region=project.region, region=project.region,
task_id=video.task_id, task_id=video.task_id,
result_movie_url=video.result_movie_url, result_movie_url=to_playback_url(video.result_movie_url),
poster_url=video.poster_url,
title=video.title,
description=video.description,
hashtags=video.hashtags,
created_at=video.created_at, created_at=video.created_at,
like_count=like_count_map.get(video.id) or 0, like_count=like_count_map.get(video.id) or 0,
comment_count=comment_count or 0, comment_count=comment_count or 0,
is_liked_by_me=liked_map.get(video.id, False),
) )
for video, project, comment_count in rows for video, project, comment_count in rows
] ]

View File

@ -46,7 +46,7 @@ router = APIRouter(prefix="/comment", tags=["Comment"])
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글) - **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
## 참고 ## 참고
- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책). - 작성자 닉네임/프로필 이미지는 카카오 로그인 정보를 그대로 사용합니다 (클라이언트에서 지정 불가).
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth). - 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
""", """,
response_model=CommentCreateResponse, response_model=CommentCreateResponse,
@ -71,7 +71,7 @@ async def post_comment(
session=session, session=session,
video_id=video_id, video_id=video_id,
user_uuid=current_user.user_uuid, user_uuid=current_user.user_uuid,
nickname=body.nickname, nickname=current_user.nickname,
content=body.content, content=body.content,
parent_id=body.parent_id, parent_id=body.parent_id,
) )
@ -79,6 +79,7 @@ async def post_comment(
return CommentCreateResponse( return CommentCreateResponse(
id=comment.id, id=comment.id,
nickname=comment.nickname or "익명", nickname=comment.nickname or "익명",
profile_image_url=current_user.profile_image_url,
parent_id=comment.parent_id, parent_id=comment.parent_id,
content=comment.content, content=comment.content,
created_at=comment.created_at, created_at=comment.created_at,
@ -101,7 +102,7 @@ async def post_comment(
## 참고 ## 참고
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다. - 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
- 작성자 정보는 노출되지 않으며, is_mine으로 본인 댓글 여부만 확인 가능합니다. - 작성자 닉네임/프로필 이미지는 카카오 로그인 정보 기준이며, is_mine으로 본인 댓글 여부도 확인 가능합니다.
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우). - 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
""", """,
response_model=PaginatedResponse[CommentItem], response_model=PaginatedResponse[CommentItem],

View File

@ -17,7 +17,8 @@ class Comment(Base):
2-depth 구조 (최상위 댓글 + 대댓글 1단계). 2-depth 구조 (최상위 댓글 + 대댓글 1단계).
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글. parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책). 작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며,
프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다.
""" """
__tablename__ = "comment" __tablename__ = "comment"
@ -54,7 +55,7 @@ class Comment(Base):
comment="NULL=최상위 댓글, 값=대댓글의 부모 id", comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
) )
nickname: Mapped[Optional[str]] = mapped_column( nickname: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True, comment="댓글 작성자 닉네임 (null이면 익명)" String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)"
) )
content: Mapped[str] = mapped_column( content: Mapped[str] = mapped_column(
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)" String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"

View File

@ -5,7 +5,6 @@ from pydantic import BaseModel, Field
class CommentCreateRequest(BaseModel): class CommentCreateRequest(BaseModel):
nickname: Optional[str] = Field(None, min_length=1, max_length=50, description="작성자 닉네임 (미입력 시 익명)")
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)") content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id") parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
@ -14,7 +13,8 @@ class ReplyItem(BaseModel):
"""대댓글 응답""" """대댓글 응답"""
id: int = Field(..., description="댓글 고유 ID") id: int = Field(..., description="댓글 고유 ID")
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)") content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
is_deleted: bool = Field(..., description="삭제 여부") is_deleted: bool = Field(..., description="삭제 여부")
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부") is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
@ -25,7 +25,8 @@ class CommentItem(BaseModel):
"""최상위 댓글 응답 — replies 포함""" """최상위 댓글 응답 — replies 포함"""
id: int = Field(..., description="댓글 고유 ID") id: int = Field(..., description="댓글 고유 ID")
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)") content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
is_deleted: bool = Field(..., description="삭제 여부") is_deleted: bool = Field(..., description="삭제 여부")
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부") is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
@ -35,7 +36,8 @@ class CommentItem(BaseModel):
class CommentCreateResponse(BaseModel): class CommentCreateResponse(BaseModel):
id: int = Field(..., description="생성된 댓글 고유 ID") id: int = Field(..., description="생성된 댓글 고유 ID")
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')") nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필)")
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)") parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
content: str = Field(..., description="댓글 본문") content: str = Field(..., description="댓글 본문")
created_at: datetime = Field(..., description="작성 일시") created_at: datetime = Field(..., description="작성 일시")

View File

@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.comment.models import Comment from app.comment.models import Comment
from app.comment.schemas.comment_schema import CommentItem, ReplyItem from app.comment.schemas.comment_schema import CommentItem, ReplyItem
from app.user.models import User
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.video.models import Video from app.video.models import Video
@ -37,6 +38,7 @@ def _build_comment_items(
parents: list, parents: list,
replies_map: dict, replies_map: dict,
current_user_uuid: Optional[str], current_user_uuid: Optional[str],
profile_image_map: dict,
) -> List[CommentItem]: ) -> List[CommentItem]:
items = [] items = []
for c in parents: for c in parents:
@ -45,6 +47,7 @@ def _build_comment_items(
ReplyItem( ReplyItem(
id=r.id, id=r.id,
nickname=r.nickname or "익명", nickname=r.nickname or "익명",
profile_image_url=profile_image_map.get(r.user_uuid),
content=None if r.is_deleted else r.content, content=None if r.is_deleted else r.content,
is_deleted=r.is_deleted, is_deleted=r.is_deleted,
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False, is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
@ -56,6 +59,7 @@ def _build_comment_items(
CommentItem( CommentItem(
id=c.id, id=c.id,
nickname=c.nickname or "익명", nickname=c.nickname or "익명",
profile_image_url=profile_image_map.get(c.user_uuid),
content=None if c.is_deleted else c.content, content=None if c.is_deleted else c.content,
is_deleted=c.is_deleted, is_deleted=c.is_deleted,
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False, is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
@ -70,7 +74,7 @@ async def create_comment(
session: AsyncSession, session: AsyncSession,
video_id: int, video_id: int,
user_uuid: str, user_uuid: str,
nickname: str, nickname: Optional[str],
content: str, content: str,
parent_id: Optional[int], parent_id: Optional[int],
) -> Comment: ) -> Comment:
@ -143,6 +147,7 @@ async def list_comments(
parents = (await session.execute(parents_q)).scalars().all() parents = (await session.execute(parents_q)).scalars().all()
replies_map: dict = defaultdict(list) replies_map: dict = defaultdict(list)
replies: list = []
if parents: if parents:
parent_ids = [c.id for c in parents] parent_ids = [c.id for c in parents]
replies_q = ( replies_q = (
@ -157,7 +162,16 @@ async def list_comments(
for r in replies: for r in replies:
replies_map[r.parent_id].append(r) replies_map[r.parent_id].append(r)
items = _build_comment_items(list(parents), replies_map, current_user_uuid) # 작성자 프로필 이미지는 스냅샷을 저장하지 않고, 응답 시 User 테이블을 조인해 최신값을 조회한다.
user_uuids = {c.user_uuid for c in parents} | {r.user_uuid for r in replies}
profile_image_map: dict = {}
if user_uuids:
profile_q = select(User.user_uuid, User.profile_image_url).where(
User.user_uuid.in_(user_uuids)
)
profile_image_map = {uuid: url for uuid, url in (await session.execute(profile_q)).all()}
items = _build_comment_items(list(parents), replies_map, current_user_uuid, profile_image_map)
return PaginatedResponse.create( return PaginatedResponse.create(
items=items, items=items,

View File

@ -80,7 +80,8 @@ async def create_db_tables():
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401 from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
from app.lyric.models import Lyric # noqa: F401 from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song, SongTimestamp # noqa: F401 from app.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video # noqa: F401 from app.video.models import Video, VideoReaction # noqa: F401
from app.comment.models import Comment # noqa: F401
from app.sns.models import SNSUploadTask # noqa: F401 from app.sns.models import SNSUploadTask # noqa: F401
from app.social.models import SocialUpload # noqa: F401 from app.social.models import SocialUpload # noqa: F401
from app.dashboard.models import Dashboard # noqa: F401 from app.dashboard.models import Dashboard # noqa: F401
@ -98,6 +99,8 @@ async def create_db_tables():
Song.__table__, Song.__table__,
SongTimestamp.__table__, SongTimestamp.__table__,
Video.__table__, Video.__table__,
VideoReaction.__table__,
Comment.__table__,
SNSUploadTask.__table__, SNSUploadTask.__table__,
SocialUpload.__table__, SocialUpload.__table__,
MarketingIntel.__table__, MarketingIntel.__table__,

View File

@ -417,8 +417,16 @@ async def _crawling_logic(url: str, session: AsyncSession):
) )
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장) # Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장)
# 공식 링크: 플레이스 홈페이지 항목 우선, 없으면 유저가 입력한 크롤링 소스 URL
# (컬럼 길이를 넘는 긴 검색 URL은 place_id 기반 표준 플레이스 URL로 대체)
official_site_url = scraper.official_site_url or url
if len(official_site_url) > 2048:
official_site_url = (
f"https://map.naver.com/p/entry/place/{scraper.place_id[2:]}"
)
marketing_intel = MarketingIntel( marketing_intel = MarketingIntel(
place_id=scraper.place_id, place_id=scraper.place_id,
official_site_url=official_site_url,
intel_result=marketing_analysis.model_dump(), intel_result=marketing_analysis.model_dump(),
) )
session.add(marketing_intel) session.add(marketing_intel)
@ -486,6 +494,7 @@ async def _crawling_logic(url: str, session: AsyncSession):
- **customer_name**: 업체명 / 브랜드명 (필수) - **customer_name**: 업체명 / 브랜드명 (필수)
- **address**: 도로명 또는 지번 주소 (필수) - **address**: 도로명 또는 지번 주소 (필수)
- **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류 - **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류
- **official_site_url**: 업체 공식 홈페이지 링크 (선택, http/https만 허용, 최대 2048자). 영상 응답의 official_site_url로 노출
## 반환 정보 ## 반환 정보
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info) - **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
@ -529,6 +538,7 @@ async def manual_marketing(
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음) # Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
marketing_intel = MarketingIntel( marketing_intel = MarketingIntel(
place_id=None, place_id=None,
official_site_url=request_body.official_site_url,
intel_result=marketing_analysis.model_dump(), intel_result=marketing_analysis.model_dump(),
) )
session.add(marketing_intel) session.add(marketing_intel)

View File

@ -274,6 +274,7 @@ class MarketingIntel(Base):
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
place_id : 데이터 소스별 식별자 place_id : 데이터 소스별 식별자
official_site_url : 업체 공식 링크 (플레이스 홈페이지 항목, 없으면 크롤링 소스 URL)
intel_result : 마케팅 분석 결과물 json intel_result : 마케팅 분석 결과물 json
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
""" """
@ -302,6 +303,12 @@ class MarketingIntel(Base):
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)", comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
) )
official_site_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 시 NULL)",
)
intel_result : Mapped[dict[str, Any]] = mapped_column( intel_result : Mapped[dict[str, Any]] = mapped_column(
JSON, JSON,
nullable=False, nullable=False,

View File

@ -1,6 +1,6 @@
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.utils.prompts.schemas import MarketingPromptOutput from app.utils.prompts.schemas import MarketingPromptOutput
class CrawlingRequest(BaseModel): class CrawlingRequest(BaseModel):
@ -261,6 +261,7 @@ class ManualMarketingRequest(BaseModel):
"store_name": "스테이 머뭄", "store_name": "스테이 머뭄",
"address": "전북특별자치도 군산시 절골길 18", "address": "전북특별자치도 군산시 절골길 18",
"category": "펜션", "category": "펜션",
"official_site_url": "https://www.staymeomoom.com",
} }
} }
) )
@ -268,6 +269,23 @@ class ManualMarketingRequest(BaseModel):
store_name: str = Field(..., description="업체명 / 브랜드명") store_name: str = Field(..., description="업체명 / 브랜드명")
address: str = Field(..., description="도로명 또는 지번 주소") address: str = Field(..., description="도로명 또는 지번 주소")
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류") category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류")
official_site_url: Optional[str] = Field(
default=None,
max_length=2048,
description="업체 공식 홈페이지 링크 (선택, http/https만 허용). 영상 응답의 official_site_url로 노출됨",
)
@field_validator("official_site_url")
@classmethod
def _validate_official_site_url(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
v = v.strip()
if not v:
return None
if not v.startswith(("http://", "https://")):
raise ValueError("official_site_url은 http:// 또는 https://로 시작해야 합니다.")
return v
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):

View File

@ -33,5 +33,5 @@ async def youtube_seo_description(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> YoutubeDescriptionResponse: ) -> YoutubeDescriptionResponse:
return await seo_service.get_youtube_seo_description( return await seo_service.get_youtube_seo_description(
request_body.task_id, current_user, session request_body.video_id, current_user, session
) )

View File

@ -95,8 +95,6 @@ YOUTUBE_SCOPES = [
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필 "https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
] ]
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
# ============================================================================= # =============================================================================
# Instagram/Facebook OAuth Scopes (추후 구현) # Instagram/Facebook OAuth Scopes (추후 구현)
# ============================================================================= # =============================================================================

View File

@ -8,12 +8,12 @@ from pydantic import BaseModel, ConfigDict, Field
class YoutubeDescriptionRequest(BaseModel): class YoutubeDescriptionRequest(BaseModel):
"""유튜브 SEO Description 제안 요청""" """유튜브 SEO Description 제안 요청"""
task_id: str = Field(..., description="작업 고유 식별자") video_id: int = Field(..., description="영상 고유 ID")
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"example": { "example": {
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e" "video_id": 123
} }
} }
) )

View File

@ -1,88 +1,132 @@
""" """
유튜브 SEO 서비스 유튜브 SEO 서비스
SEO description 생성 및 Redis 캐싱 로직을 처리합니다. 영상 제목/설명/해시태그를 생성하고 video 테이블에 저장합니다.
""" """
import json import json
import logging import logging
from fastapi import HTTPException from fastapi import HTTPException, status
from redis.asyncio import Redis
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import db_settings
from app.home.models import MarketingIntel, Project from app.home.models import MarketingIntel, Project
from app.social.constants import YOUTUBE_SEO_HASH
from app.social.schemas import YoutubeDescriptionResponse from app.social.schemas import YoutubeDescriptionResponse
from app.social.services.sns_metadata import apply_sns_metadata, has_stored_sns_metadata
from app.user.models import User from app.user.models import User
from app.utils.prompts.chatgpt_prompt import ChatgptService from app.video.models import Video
from app.utils.prompts.prompts import yt_upload_prompt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
redis_seo_client = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=0,
decode_responses=True,
)
class SeoService: class SeoService:
"""유튜브 SEO 비즈니스 로직 서비스""" """유튜브 SEO 비즈니스 로직 서비스"""
async def get_youtube_seo_description( async def get_youtube_seo_description(
self, self,
task_id: str, video_id: int,
current_user: User, current_user: User,
session: AsyncSession, session: AsyncSession,
) -> YoutubeDescriptionResponse: ) -> YoutubeDescriptionResponse:
""" """
유튜브 SEO description 생성 저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다.
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
""" """
logger.info( logger.info(
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / task_id: {task_id}" f"[SEO_SERVICE] Load metadata - user: {current_user.user_uuid} / video_id: {video_id}"
) )
cached = await self._get_from_redis(task_id) video = await self._get_owned_video(video_id, current_user.user_uuid, session)
if cached: if video is None:
return cached raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
)
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}") if has_stored_sns_metadata(video):
result = await self._generate_seo_description(task_id, current_user, session) return self._response_from_video(video)
await self._set_to_redis(task_id, result)
result = await self.generate_and_save_for_video(video.id, session)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
)
await session.commit()
return result return result
async def generate_and_save_for_video(
self,
video_id: int,
session: AsyncSession,
) -> YoutubeDescriptionResponse | None:
"""GPT로 SEO를 생성해 지정한 video 행에 저장합니다. 워커/온디맨드 공용."""
video_result = await session.execute(select(Video).where(Video.id == video_id))
video = video_result.scalar_one_or_none()
if video is None:
logger.warning(f"[SEO_SERVICE] Video NOT FOUND - video_id: {video_id}")
return None
if has_stored_sns_metadata(video):
return self._response_from_video(video)
result = await self._generate_seo_description(video.task_id, session)
apply_sns_metadata(video, result.title, result.description, result.keywords)
await session.flush()
logger.info(f"[SEO_SERVICE] Saved metadata - video_id: {video_id}")
return result
async def _get_owned_video(
self,
video_id: int,
user_uuid: str,
session: AsyncSession,
) -> Video | None:
result = await session.execute(
select(Video)
.join(Project, Project.id == Video.project_id)
.where(
Video.id == video_id,
Project.user_uuid == user_uuid,
Video.is_deleted.is_(False),
)
)
return result.scalar_one_or_none()
async def _generate_seo_description( async def _generate_seo_description(
self, self,
task_id: str, task_id: str,
current_user: User,
session: AsyncSession, session: AsyncSession,
) -> YoutubeDescriptionResponse: ) -> YoutubeDescriptionResponse:
"""GPT를 사용하여 SEO description 생성""" """GPT를 사용하여 SEO description 생성"""
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}") from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import yt_upload_prompt
logger.info(f"[SEO_SERVICE] Generating SEO - task_id: {task_id}")
try: try:
project_result = await session.execute( project_result = await session.execute(
select(Project) select(Project)
.where( .where(Project.task_id == task_id)
Project.task_id == task_id,
Project.user_uuid == current_user.user_uuid,
)
.order_by(Project.created_at.desc()) .order_by(Project.created_at.desc())
.limit(1) .limit(1)
) )
project = project_result.scalar_one_or_none() project = project_result.scalar_one_or_none()
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
)
marketing_result = await session.execute( marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence) select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
) )
marketing_intelligence = marketing_result.scalar_one_or_none() marketing_intelligence = marketing_result.scalar_one_or_none()
if marketing_intelligence is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="마케팅 인텔리전스를 찾을 수 없습니다.",
)
hashtags = marketing_intelligence.intel_result["target_keywords"] hashtags = marketing_intelligence.intel_result["target_keywords"]
@ -94,12 +138,13 @@ class SeoService:
), ),
"language": project.language, "language": project.language,
"target_keywords": hashtags, "target_keywords": hashtags,
"industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum "industry": project.industry or "",
} }
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
chatgpt = ChatgptService(timeout=180) chatgpt = ChatgptService(timeout=180)
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data) yt_seo_output = await chatgpt.generate_structured_output(
yt_upload_prompt, yt_seo_input_data
)
return YoutubeDescriptionResponse( return YoutubeDescriptionResponse(
title=yt_seo_output.title, title=yt_seo_output.title,
@ -107,6 +152,8 @@ class SeoService:
keywords=hashtags, keywords=hashtags,
) )
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}") logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
raise HTTPException( raise HTTPException(
@ -114,18 +161,12 @@ class SeoService:
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}", detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
) )
async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None: def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
field = f"task_id:{task_id}" return YoutubeDescriptionResponse(
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field) title=video.title or "",
if yt_seo_info: description=video.description or "",
return YoutubeDescriptionResponse(**json.loads(yt_seo_info)) keywords=list(video.hashtags or []),
return None )
async def _set_to_redis(self, task_id: str, yt_seo: YoutubeDescriptionResponse) -> None:
field = f"task_id:{task_id}"
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
await redis_seo_client.hset(YOUTUBE_SEO_HASH, field, yt_seo_info)
await redis_seo_client.expire(YOUTUBE_SEO_HASH, 3600)
seo_service = SeoService() seo_service = SeoService()

View File

@ -0,0 +1,36 @@
"""SNS 업로드용 영상 메타데이터 비교/반영 헬퍼."""
from app.video.models import Video
def has_stored_sns_metadata(video: Video) -> bool:
"""video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
return bool(video.title)
def sns_metadata_changed(
video: Video,
title: str,
description: str | None,
tags: list[str] | None,
) -> bool:
"""게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다."""
stored_tags = list(video.hashtags or [])
incoming_tags = list(tags or [])
return (
(video.title or "") != title
or (video.description or "") != (description or "")
or stored_tags != incoming_tags
)
def apply_sns_metadata(
video: Video,
title: str,
description: str | None,
hashtags: list[str] | None,
) -> None:
"""video 행에 SNS 메타데이터를 반영합니다."""
video.title = title
video.description = description
video.hashtags = list(hashtags or [])

View File

@ -26,6 +26,7 @@ from app.social.schemas import (
SocialUploadRequest, SocialUploadRequest,
) )
from app.social.services.account_service import SocialAccountService from app.social.services.account_service import SocialAccountService
from app.social.services.sns_metadata import apply_sns_metadata, sns_metadata_changed
from app.social.worker.upload_task import process_social_upload from app.social.worker.upload_task import process_social_upload
from app.user.models import User from app.user.models import User
from app.video.models import Video from app.video.models import Video
@ -76,6 +77,12 @@ class SocialUploadService:
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.", detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
) )
if sns_metadata_changed(video, body.title, body.description, body.tags):
apply_sns_metadata(video, body.title, body.description, body.tags)
logger.info(
f"[UPLOAD_SERVICE] video SNS 메타데이터 갱신 - video_id: {body.video_id}"
)
# 2. 소셜 계정 조회 및 소유권 검증 # 2. 소셜 계정 조회 및 소유권 검증
account = await self._account_service.get_account_by_id( account = await self._account_service.get_account_by_id(
user_uuid=current_user.user_uuid, user_uuid=current_user.user_uuid,

View File

@ -6,6 +6,10 @@ from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommen
import asyncio import asyncio
# medium 추론은 출력 비용의 80%를 차지하고, minimal은 A/B 비교(4회)에서 narrative 점수가
# welcome 단계로 편향되고 태그를 과다 선택하는 패턴이 반복돼 low로 고정한다.
IMAGE_TAG_REASONING_EFFORT = "low"
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list
chatgpt = ChatgptService(model_type="gpt") chatgpt = ChatgptService(model_type="gpt")
image_input_data = { image_input_data = {
@ -17,7 +21,7 @@ async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_
"motion_recommended" : list(MotionRecommended) "motion_recommended" : list(MotionRecommended)
} }
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False) image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT)
return image_result return image_result
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
@ -31,7 +35,7 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
"motion_recommended" : list(MotionRecommended) "motion_recommended" : list(MotionRecommended)
}for image_url in image_url_list] }for image_url in image_url_list]
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list] image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], True, silent = True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for image_input_data in image_input_data_list]
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True) image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
MAX_RETRY = 2 MAX_RETRY = 2
for _ in range(MAX_RETRY): for _ in range(MAX_RETRY):
@ -40,7 +44,7 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
if not failed_idx: if not failed_idx:
break break
retried = await asyncio.gather( retried = await asyncio.gather(
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx], *[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], True, silent=True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for i in failed_idx],
return_exceptions=True return_exceptions=True
) )
for i, result in zip(failed_idx, retried): for i, result in zip(failed_idx, retried):

View File

@ -574,8 +574,15 @@ class CreatomateService:
다운로드 실패를 배제로 오판해 풀 전체가 날아가는 것을 막기 위함. 다운로드 실패를 배제로 오판해 풀 전체가 날아가는 것을 막기 위함.
일반 씬 슬롯은 태그 점수를 그대로 반환한다. 일반 씬 슬롯은 태그 점수를 그대로 반환한다.
""" """
scores = self.calculate_image_slot_score_multi(pool_subset, slot) is_thumbnail = slot.endswith(THUMBNAIL_SLOT_MARKER)
if not thumbnail_fitness_map or not slot.endswith(THUMBNAIL_SLOT_MARKER): if is_thumbnail and self.parse_slot_name_to_tag(slot) is None:
# 슬롯명이 명명 규칙을 어겨 태그 매칭이 불가능한 썸네일 슬롯.
# 태그 점수를 0으로 두면 pool 첫 컷이 뽑혀 사실상 무작위가 되므로
# 전 이미지 중립(1.0)으로 두고 아래 픽셀 적합도만으로 순위를 가른다.
scores = [1.0] * len(pool_subset)
else:
scores = self.calculate_image_slot_score_multi(pool_subset, slot)
if not thumbnail_fitness_map or not is_thumbnail:
return scores return scores
adjusted = [] adjusted = []
@ -589,6 +596,31 @@ class CreatomateService:
adjusted.append(score * fitness["score"]) adjusted.append(score * fitness["score"])
return adjusted return adjusted
def _collect_thumbnail_slots(self, template_component_data: dict) -> list[str]:
"""배정 대상 썸네일 슬롯(-9999)을 수집합니다.
일반 씬 슬롯과 달리 슬롯명 파싱에 실패해도 제외하지 않는다. 썸네일은
노출 면적이 가장 큰 표면이라, 미배정 시 modify_element가 템플릿 원본
(샘플 이미지)을 그대로 남겨 완성 영상에 그대로 나가기 때문이다. 태그
매칭이 불가능한 슬롯은 _slot_scores_with_fitness가 픽셀 적합도만으로
고른다. 단 '-fixed' 고정 자산은 여기서도 제외한다.
파싱 실패는 템플릿 슬롯명 오타이므로 ERROR로 남겨 드러나게 한다
(조용히 넘어가면 샘플 이미지가 나가도 아무도 알아채지 못한다).
"""
slots = [
name for name, t in template_component_data.items()
if t == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
and not is_fixed_slot_name(name)
]
for name in slots:
if self.parse_slot_name_to_tag(name) is None:
logger.error(
f"[_collect_thumbnail_slots] 썸네일 슬롯명이 명명 규칙 위반 — "
f"'{name}' — 템플릿 슬롯명 수정 필요. 픽셀 적합도 기준으로 폴백 배정합니다."
)
return slots
def rank_thumbnail_candidates( def rank_thumbnail_candidates(
self, self,
template: dict, template: dict,
@ -607,11 +639,7 @@ class CreatomateService:
후보가 없는 슬롯은 키 자체를 포함하지 않는다. 후보가 없는 슬롯은 키 자체를 포함하지 않는다.
""" """
component = self.parse_template_component_name(template["source"]["elements"]) component = self.parse_template_component_name(template["source"]["elements"])
thumbnail_slots = [ thumbnail_slots = self._collect_thumbnail_slots(component)
name for name, t in component.items()
if t == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None
]
result: dict[str, list[dict]] = {} result: dict[str, list[dict]] = {}
for slot in thumbnail_slots: for slot in thumbnail_slots:
scores = self._slot_scores_with_fitness(taged_image_list, slot, thumbnail_fitness_map) scores = self._slot_scores_with_fitness(taged_image_list, slot, thumbnail_fitness_map)
@ -669,9 +697,13 @@ class CreatomateService:
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지 # 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고 # 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고
# 무작위 이미지로 덮어써진다. # 무작위 이미지로 덮어써진다.
# 썸네일 슬롯(-9999)은 파싱 실패해도 배정해야 하므로 여기서 제외하고
# _collect_thumbnail_slots가 별도로 수집한다.
image_slots = [ image_slots = [
name for name, t in template_component_data.items() name for name, t in template_component_data.items()
if t == "image" and not is_fixed_slot_name(name) and self.parse_slot_name_to_tag(name) is not None if t == "image" and not is_fixed_slot_name(name)
and not name.endswith(THUMBNAIL_SLOT_MARKER)
and self.parse_slot_name_to_tag(name) is not None
] ]
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"] text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
@ -686,8 +718,7 @@ class CreatomateService:
# thumbnail_choice(비전 LLM 최종 선택)가 해당 슬롯에 있으면 그 이미지를, # thumbnail_choice(비전 LLM 최종 선택)가 해당 슬롯에 있으면 그 이미지를,
# 없으면(선택 실패/미제공) 결정론적 최고점 컷을 사용한다 — 폴백 안전. # 없으면(선택 실패/미제공) 결정론적 최고점 컷을 사용한다 — 폴백 안전.
thumbnail_choice = thumbnail_choice or {} thumbnail_choice = thumbnail_choice or {}
thumbnail_slots = [s for s in image_slots if s.endswith(THUMBNAIL_SLOT_MARKER)] thumbnail_slots = self._collect_thumbnail_slots(template_component_data)
image_slots = [s for s in image_slots if not s.endswith(THUMBNAIL_SLOT_MARKER)]
for slot in thumbnail_slots: for slot in thumbnail_slots:
if not pool: if not pool:
logger.warning(f"[template_matching_taged_image] 이미지 풀 없음 — 썸네일 슬롯 배정 불가: {slot}") logger.warning(f"[template_matching_taged_image] 이미지 풀 없음 — 썸네일 슬롯 배정 불가: {slot}")
@ -885,7 +916,13 @@ class CreatomateService:
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다. """슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative} 슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
파싱 실패 시 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
위치 기반 파싱에 실패하면 토큰 위치를 무시한 대조로 한 번 더 시도한다
(_parse_slot_name_loosely). 슬롯명 오타로 슬롯이 배정에서 빠지면
modify_element가 템플릿 원본(샘플 이미지)을 그대로 남겨 완성 영상에
그대로 나가기 때문이다.
둘 다 실패하면 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
""" """
try: try:
tag_list = slot_name.split("-") tag_list = slot_name.split("-")
@ -907,9 +944,64 @@ class CreatomateService:
} }
return tag_dict return tag_dict
except (ValueError, IndexError) as e: except (ValueError, IndexError) as e:
loose = self._parse_slot_name_loosely(tag_list)
if loose is not None:
logger.warning(
f"[parse_slot_name_to_tag] 슬롯명이 명명 규칙 위반: '{slot_name}' — {e} — "
f"위치 무시 대조로 복구: { {k: v.value for k, v in loose.items()} } — 템플릿 슬롯명 수정 권장"
)
return loose
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 파싱 실패: '{slot_name}' — {e} — 슬롯 skip") logger.warning(f"[parse_slot_name_to_tag] 슬롯명 파싱 실패: '{slot_name}' — {e} — 슬롯 skip")
return None return None
def _parse_slot_name_loosely(self, tag_list: list[str]) -> dict[str, StrEnum] | None:
"""토큰 위치를 무시하고 각 enum에 대조해 태그를 복구합니다.
위치 기반 파싱이 실패했을 때만 호출한다. 토큰 하나는 한 카테고리에만
쓰이며, 앞선 토큰부터 순서대로 소비한다(중복 후보가 있으면 앞선 것 채택
— 위치 기반과 같은 값을 고르게 된다).
space_type/subject/narrative는 필수다. 이 셋을 못 채우면 슬롯명이 아닌
것으로 보고 None을 반환한다(고정 자산·비규칙 요소가 배정 대상에 섞여
무작위 이미지로 덮어써지는 것을 막기 위함). camera/motion은 선택이며
못 찾으면 키 자체를 넣지 않는다 — 점수 계산은 태그 딕셔너리를 순회하므로
없는 키는 자연히 가중치에서 빠진다.
"""
used: set[int] = set()
def take(converter) -> StrEnum | None:
for idx, token in enumerate(tag_list):
if idx in used:
continue
try:
value = converter(token)
except ValueError:
continue
if value is not None:
used.add(idx)
return value
return None
space_type = take(SpaceType)
subject = take(Subject)
narrative = take(NarrativePhase)
if space_type is None or subject is None or narrative is None:
return None
camera = take(Camera)
motion = take(lambda t: MOTION_TOKEN_NORMALIZATION.get(t) or MotionRecommended(t))
tag_dict: dict[str, StrEnum] = {
"space_type": space_type,
"subject": subject,
"narrative_preference": narrative,
}
if camera is not None:
tag_dict["camera"] = camera
if motion is not None:
tag_dict["motion_recommended"] = motion
return tag_dict
def elements_connect_resource_blackbox( def elements_connect_resource_blackbox(
self, self,
elements: list, elements: list,

View File

@ -131,7 +131,8 @@ if (originalQuery) {
cls, cls,
place_id: str, place_id: str,
payloads: list[dict], payloads: list[dict],
) -> list[dict | None] | None: capture_apollo: bool = False,
) -> list[dict | None] | None | tuple[list[dict | None] | None, str | None]:
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다. """실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
네이버 pcmap GraphQL은 두 헤더를 검사한다: 네이버 pcmap GraphQL은 두 헤더를 검사한다:
@ -144,12 +145,19 @@ if (originalQuery) {
Args: Args:
place_id: 네이버 place ID place_id: 네이버 place ID
payloads: GraphQL POST 본문 목록 payloads: GraphQL POST 본문 목록
capture_apollo: True면 place 페이지에 인라인된 __APOLLO_STATE__
JSON 문자열도 함께 캡처해 (results, apollo_json) 튜플로 반환.
(GraphQL base가 노출하지 않는 homepages 등 SSR 캐시 전용 필드용)
Returns: Returns:
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None. payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None.
capture_apollo=True면 (위 결과, apollo_json 또는 None) 튜플.
""" """
def _ret(results, apollo=None):
return (results, apollo) if capture_apollo else results
if not cls.is_ready: if not cls.is_ready:
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다") logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
return None return _ret(None)
page = await cls._new_stealth_page() page = await cls._new_stealth_page()
captured: dict = {} captured: dict = {}
@ -185,7 +193,17 @@ if (originalQuery) {
if not captured.get("tok"): if not captured.get("tok"):
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패") logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패")
return None return _ret(None)
apollo_json: str | None = None
if capture_apollo:
try:
apollo_json = await page.evaluate(
"() => { try { return JSON.stringify(window.__APOLLO_STATE__ || null); }"
" catch (e) { return null; } }"
)
except Exception as e:
logger.warning(f"[NvMapPwScraper] __APOLLO_STATE__ 캡처 실패: {e}")
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -220,11 +238,11 @@ if (originalQuery) {
results.append(None) results.append(None)
else: else:
results.append(r) results.append(r)
return results return _ret(results, apollo_json)
except Exception as e: except Exception as e:
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}") logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
return None return _ret(None)
finally: finally:
await page.close() await page.close()

View File

@ -112,6 +112,7 @@ query getVisitorReviewStats($id: String!) {
self.facility_info: str | None = None self.facility_info: str | None = None
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count) self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend) self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend)
self.official_site_url: str | None = None # 업체 공식 링크 (base.homepages 대표 URL)
def _get_request_headers(self) -> dict: def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy() headers = self.DEFAULT_HEADERS.copy()
@ -256,9 +257,11 @@ query getVisitorReviewStats($id: String!) {
# self.scrap_type = "GraphQL-Browser" # self.scrap_type = "GraphQL-Browser"
# ── 실제 브라우저로 WTM 캡차 우회 ── # ── 실제 브라우저로 WTM 캡차 우회 ──
data, stats_data, extra_photo_urls, biz_photo_urls = await self._scrap_via_browser(place_id) data, stats_data, extra_photo_urls, biz_photo_urls, homepage_url = await self._scrap_via_browser(place_id)
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None) # 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
fac_data = await self._get_facility_string(place_id) # 홈페이지 링크는 브라우저 캡처가 실패한 경우에만 HTML 경로로 보충한다.
fac_data, html_homepage = await self._get_facility_and_homepage(place_id)
homepage_url = homepage_url or html_homepage
self.scrap_type = "GraphQL-Browser" self.scrap_type = "GraphQL-Browser"
self.rawdata = data self.rawdata = data
@ -297,14 +300,43 @@ query getVisitorReviewStats($id: String!) {
self.facility_info = fac_data self.facility_info = fac_data
self.voted_keyword_stats = stats_data self.voted_keyword_stats = stats_data
self.menu_info = business.get("menus") or None self.menu_info = business.get("menus") or None
self.official_site_url = homepage_url
return return
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict]]: @staticmethod
def _extract_homepage_from_html(html: str) -> str | None:
"""플레이스 페이지 HTML의 __APOLLO_STATE__에서 홈페이지 링크를 추출한다.
GraphQL placeDetail(base)는 homepages 필드를 노출하지 않으므로(400),
SSR 페이지에 인라인된 Apollo 캐시의 "homepages" 객체를 직접 파싱한다.
대표(repr) 링크 우선, 죽은 링크(isDeadUrl)는 제외. 홈페이지 항목은
자체 홈페이지 외에 인스타그램/블로그 등일 수도 있다 — 업체가 대표로
등록한 링크를 그대로 신뢰한다.
"""
decoder = json.JSONDecoder()
search_from = 0
while True:
idx = html.find('"homepages":', search_from)
if idx == -1:
return None
search_from = idx + 1
try:
homepages, _ = decoder.raw_decode(html, idx + len('"homepages":'))
except ValueError:
continue
if not isinstance(homepages, dict):
continue
candidates = [homepages.get("repr"), *(homepages.get("etc") or [])]
for item in candidates:
if isinstance(item, dict) and item.get("url") and not item.get("isDeadUrl"):
return item["url"]
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict], str | None]:
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다. """직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
Returns: Returns:
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls) (overview_data, review_stats_details, extra_photo_urls, biz_photo_urls, homepage_url)
Raises: Raises:
GraphQLException: 브라우저 폴백마저 실패한 경우 GraphQLException: 브라우저 폴백마저 실패한 경우
@ -382,10 +414,14 @@ query getVisitorReviewStats($id: String!) {
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads] payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
MAX_RETRY = 3 MAX_RETRY = 3
results = None results = None
apollo_json: str | None = None
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(1, MAX_RETRY + 1): for attempt in range(1, MAX_RETRY + 1):
try: try:
results = await NvMapPwScraper.fetch_graphql(place_id, payloads) results, captured_apollo = await NvMapPwScraper.fetch_graphql(
place_id, payloads, capture_apollo=True
)
apollo_json = apollo_json or captured_apollo
except Exception as e: except Exception as e:
last_error = e last_error = e
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}") logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
@ -439,8 +475,17 @@ query getVisitorReviewStats($id: String!) {
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}" f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
) )
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}") # 홈페이지 링크: GraphQL base는 homepages를 노출하지 않으므로(400),
return data, stats_data, extra_photo_urls, biz_urls # 브라우저가 로드한 place 페이지의 __APOLLO_STATE__ JSON에서 추출한다.
homepage_url = (
self._extract_homepage_from_html(apollo_json) if apollo_json else None
)
logger.info(
f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}, "
f"homepage: {homepage_url or '없음'}"
)
return data, stats_data, extra_photo_urls, biz_urls, homepage_url
async def _call_get_accommodation(self, place_id: str) -> dict: async def _call_get_accommodation(self, place_id: str) -> dict:
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다. """GraphQL API를 호출하여 숙소 정보를 가져옵니다.
@ -521,29 +566,39 @@ query getVisitorReviewStats($id: String!) {
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}") logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
return None return None
async def _get_facility_string(self, place_id: str) -> str | None: async def _get_facility_and_homepage(self, place_id: str) -> tuple[str | None, str | None]:
"""장소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다. """장소 페이지에서 편의시설 정보와 홈페이지 링크를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
Args: Args:
place_id: 네이버 지도 장소 ID place_id: 네이버 지도 장소 ID
Returns: Returns:
편의시설 정보 문자열 또는 None (편의시설 정보 문자열 또는 None, 홈페이지 링크 또는 None)
""" """
facility: str | None = None
homepage: str | None = None
place_types = ["place", "accommodation", "restaurant"] place_types = ["place", "accommodation", "restaurant"]
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
for place_type in place_types: for place_type in place_types:
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home" url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
async with session.get(url, headers=self._get_request_headers()) as response: async with session.get(url, headers=self._get_request_headers()) as response:
soup = bs4.BeautifulSoup(await response.read(), "html.parser") raw = await response.read()
if homepage is None:
homepage = self._extract_homepage_from_html(
raw.decode("utf-8", errors="ignore")
)
if facility is None:
soup = bs4.BeautifulSoup(raw, "html.parser")
c_elem = soup.find("span", "place_blind", string="편의") c_elem = soup.find("span", "place_blind", string="편의")
if c_elem: if c_elem:
return c_elem.parent.parent.find("div").string facility = c_elem.parent.parent.find("div").string
return None if facility is not None and homepage is not None:
break
return facility, homepage
except Exception as e: except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}") logger.warning(f"[NvMapScraper] Failed to get facility/homepage info: {e}")
return None return facility, homepage
# if __name__ == "__main__": # if __name__ == "__main__":

View File

@ -46,7 +46,20 @@ class ChatgptService:
) )
case _: case _:
raise NotImplementedError(f"Unknown Provider : {model_type}") raise NotImplementedError(f"Unknown Provider : {model_type}")
def _log_usage(self, response, model: str, output_format: type[BaseModel]) -> None:
usage = getattr(response, "usage", None)
if usage is None:
return
# 토큰 소모량 로깅 (필요 시 주석 해제)
# cached = getattr(getattr(usage, "prompt_tokens_details", None), "cached_tokens", None) or 0
# reasoning = getattr(getattr(usage, "completion_tokens_details", None), "reasoning_tokens", None) or 0
# logger.info(
# f"[ChatgptService({self.model_type})] usage model={model} output={output_format.__name__} "
# f"prompt={usage.prompt_tokens} cached={cached} "
# f"completion={usage.completion_tokens} reasoning={reasoning} total={usage.total_tokens}"
# )
async def _call_pydantic_output( async def _call_pydantic_output(
self, self,
prompt : str, prompt : str,
@ -113,9 +126,10 @@ class ChatgptService:
self, self,
prompt : str, prompt : str,
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것 output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str, model : str,
img_url : str, img_url : str,
image_detail_high : bool) -> BaseModel: image_detail_high : bool,
reasoning_effort : Optional[str] = None) -> BaseModel:
content = [] content = []
if img_url: if img_url:
content.append({ content.append({
@ -129,13 +143,16 @@ class ChatgptService:
"type": "text", "type": "text",
"text": prompt "text": prompt
}) })
# gpt-5.4 계열/Gemini 호환 엔드포인트는 허용 값이 다르거나 파라미터를 거부하므로 지정된 경우에만 전달
extra_kwargs = {"reasoning_effort": reasoning_effort} if reasoning_effort else {}
last_error = None last_error = None
for attempt in range(self.max_retries + 1): for attempt in range(self.max_retries + 1):
try: try:
response = await self.client.beta.chat.completions.parse( response = await self.client.beta.chat.completions.parse(
model=model, model=model,
messages=[{"role": "user", "content": content}], messages=[{"role": "user", "content": content}],
response_format=output_format response_format=output_format,
**extra_kwargs,
) )
except (ValidationError, json.JSONDecodeError) as e: except (ValidationError, json.JSONDecodeError) as e:
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters). # 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
@ -148,6 +165,7 @@ class ChatgptService:
if attempt < self.max_retries: if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...") logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
continue continue
self._log_usage(response, model, output_format)
# Response 디버그 로깅 # Response 디버그 로깅
# logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}") # logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
# logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}") # logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
@ -225,6 +243,7 @@ class ChatgptService:
continue continue
raise last_error raise last_error
self._log_usage(response, model, output_format)
choice = response.choices[0] choice = response.choices[0]
if choice.finish_reason == "stop": if choice.finish_reason == "stop":
return choice.message.parsed return choice.message.parsed
@ -242,7 +261,8 @@ class ChatgptService:
input_data : dict, input_data : dict,
img_url : Optional[str] = None, img_url : Optional[str] = None,
img_detail_high : bool = False, img_detail_high : bool = False,
silent : bool = True silent : bool = True,
reasoning_effort : Optional[str] = None,
) -> BaseModel: ) -> BaseModel:
prompt_text = prompt.build_prompt(input_data, silent) prompt_text = prompt.build_prompt(input_data, silent)
@ -253,5 +273,5 @@ class ChatgptService:
# GPT API 호출 # GPT API 호출
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model) #parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high) # parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high) parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high, reasoning_effort)
return parsed return parsed

View File

@ -90,6 +90,23 @@ async def close_shared_blob_client() -> None:
logger.info("[AzureBlobUploader] Shared HTTP client closed") logger.info("[AzureBlobUploader] Shared HTTP client closed")
def to_playback_url(blob_url: str | None) -> str | None:
"""DB에 저장된 SAS 미포함 공개 URL에 읽기용 SAS 토큰을 붙여 반환합니다.
Azure Blob 익명(공개) 접근은 x-ms-version 헤더가 없어 Range 요청을 지원하지
않는 구버전 API로 처리되어 브라우저에서 영상/오디오 seek이 동작하지 않는다.
SAS 토큰(sv= 포함)을 붙이면 최신 API 버전으로 처리되어 Range/Accept-Ranges가
정상 동작한다. DB에는 SAS 없는 URL을 그대로 저장하고, 응답 시점에만 붙인다.
"""
if not blob_url:
return blob_url
sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN.strip("?'\"")
if not sas_token:
return blob_url
separator = "&" if "?" in blob_url else "?"
return f"{blob_url}{separator}{sas_token}"
class AzureBlobUploader: class AzureBlobUploader:
"""Azure Blob Storage 업로드 클래스 """Azure Blob Storage 업로드 클래스

173
app/utils/video_poster.py Normal file
View File

@ -0,0 +1,173 @@
"""영상 파일에서 SNS 공유용 포스터 이미지를 생성하고 저장합니다."""
import asyncio
from pathlib import Path
from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader
logger = get_logger("video_poster")
FFMPEG_TIMEOUT_SECONDS = 30.0
FFMPEG_CLEANUP_TIMEOUT_SECONDS = 5.0
_STDERR_LOG_LIMIT = 500
async def _kill_and_wait(process: asyncio.subprocess.Process) -> None:
"""실행 중인 ffmpeg 프로세스를 종료하고 자원을 회수합니다."""
try:
process.kill()
except ProcessLookupError:
pass
except Exception as exc:
logger.warning(
"[video_poster] ffmpeg 프로세스 종료에 실패했습니다: %s",
exc,
)
try:
await asyncio.wait_for(
process.wait(),
timeout=FFMPEG_CLEANUP_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(
"[video_poster] 종료한 ffmpeg 프로세스 회수 시간이 초과되었습니다 "
"(timeout=%ss)",
FFMPEG_CLEANUP_TIMEOUT_SECONDS,
)
except Exception as exc:
logger.warning(
"[video_poster] ffmpeg 프로세스 회수에 실패했습니다: %s",
exc,
)
def _format_stderr(stderr: bytes) -> str:
"""ffmpeg 표준 오류를 로그에 안전한 길이의 문자열로 변환합니다."""
return stderr.decode("utf-8", errors="replace").strip()[-_STDERR_LOG_LIMIT:]
async def extract_first_frame(video_path: str | Path) -> bytes | None:
"""로컬 영상의 첫 프레임을 JPEG 바이트로 추출하고 실패 시 ``None``을 반환합니다."""
process: asyncio.subprocess.Process | None = None
try:
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-nostdin",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(video_path),
"-frames:v",
"1",
"-f",
"image2pipe",
"-c:v",
"mjpeg",
"pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=FFMPEG_TIMEOUT_SECONDS,
)
except TimeoutError:
await _kill_and_wait(process)
logger.warning(
"[video_poster] ffmpeg 첫 프레임 추출 시간이 초과되었습니다 "
"(path=%s, timeout=%ss)",
video_path,
FFMPEG_TIMEOUT_SECONDS,
)
return None
if process.returncode != 0:
logger.warning(
"[video_poster] ffmpeg 첫 프레임 추출에 실패했습니다 "
"(path=%s, returncode=%s, stderr=%s)",
video_path,
process.returncode,
_format_stderr(stderr),
)
return None
if not stdout:
logger.warning(
"[video_poster] ffmpeg가 빈 이미지를 반환했습니다 (path=%s)",
video_path,
)
return None
return stdout
except asyncio.CancelledError:
if process is not None:
await _kill_and_wait(process)
raise
except Exception as exc:
if process is not None:
await _kill_and_wait(process)
logger.warning(
"[video_poster] 첫 프레임 추출 중 오류가 발생했습니다 "
"(path=%s, error=%s: %s)",
video_path,
type(exc).__name__,
exc,
)
return None
async def generate_and_store_poster(
*,
video_path: str | Path,
user_uuid: str,
task_id: str,
file_stem: str,
) -> str | None:
"""첫 프레임을 Blob에 저장하고 공개 URL을 반환하며, 실패 시 ``None``을 반환합니다."""
try:
image_bytes = await extract_first_frame(video_path)
if image_bytes is None:
return None
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
uploaded = await uploader.upload_image_bytes(
image_bytes,
f"{file_stem}.jpg",
)
if not uploaded:
logger.warning(
"[video_poster] 포스터 Blob 업로드에 실패했습니다 "
"(path=%s, task_id=%s)",
video_path,
task_id,
)
return None
if not uploader.public_url:
logger.warning(
"[video_poster] 포스터 업로드 후 공개 URL이 비어 있습니다 "
"(path=%s, task_id=%s)",
video_path,
task_id,
)
return None
return uploader.public_url
except Exception as exc:
logger.warning(
"[video_poster] 포스터 생성 또는 저장 중 오류가 발생했습니다 "
"(path=%s, task_id=%s, error=%s: %s)",
video_path,
task_id,
type(exc).__name__,
exc,
)
return None

View File

@ -17,7 +17,8 @@ import json
from collections import defaultdict from collections import defaultdict
from typing import Literal from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -29,6 +30,7 @@ from app.utils.pagination import PaginatedResponse
from app.home.models import Image, Project, MarketingIntel from app.home.models import Image, Project, MarketingIntel
from app.home.api.routers.v1.home import _extract_region_from_address from app.home.api.routers.v1.home import _extract_region_from_address
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.utils.upload_blob_as_request import to_playback_url
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
@ -57,16 +59,69 @@ from app.video.schemas.video_schema import (
VideoRenderData, VideoRenderData,
VideoThumbnailItem, VideoThumbnailItem,
) )
from app.video.services.share_page import (
build_video_share_html,
get_video_share_data,
resolve_frontend_base_url,
resolve_share_url,
)
from app.video.worker.video_task import download_and_upload_video_to_blob from app.video.worker.video_task import download_and_upload_video_to_blob
from config import creatomate_settings from config import creatomate_settings, prj_settings
logger = get_logger("video") logger = get_logger("video")
router = APIRouter(prefix="/video", tags=["Video"]) router = APIRouter(prefix="/video", tags=["Video"])
def _place_id_to_site_url(place_id: str | None) -> str | None:
"""MarketingIntel.place_id("nv{네이버 place ID}")를 네이버 플레이스 URL로 변환한다.
크롤링 없이 직접 입력된 업체는 place_id가 없으므로 None을 반환한다.
"""
if place_id and place_id.startswith("nv") and place_id[2:].isdigit():
return f"https://map.naver.com/p/entry/place/{place_id[2:]}"
return None
async def _get_official_site_urls(
session: AsyncSession, projects: list[Project]
) -> dict[int, str | None]:
"""프로젝트 목록에 대해 {project_id: 공식 페이지 URL(or None)}을 일괄 조회한다.
Project.marketing_intelligence(문자열로 저장된 MarketingIntel.id)를 경유해
저장된 official_site_url을 우선 사용하고, 컬럼 도입 전 기존 행은
place_id 기반 네이버 플레이스 URL로 폴백한다.
"""
m_id_by_project: dict[int, int] = {}
for p in projects:
try:
if p.marketing_intelligence is not None:
m_id_by_project[p.id] = int(p.marketing_intelligence)
except (TypeError, ValueError):
continue
url_by_project: dict[int, str | None] = {p.id: None for p in projects}
if not m_id_by_project:
return url_by_project
rows = (
await session.execute(
select(
MarketingIntel.id,
MarketingIntel.place_id,
MarketingIntel.official_site_url,
).where(MarketingIntel.id.in_(set(m_id_by_project.values())))
)
).all()
intel_by_m_id = {m_id: (place_id, site_url) for m_id, place_id, site_url in rows}
for project_id, m_id in m_id_by_project.items():
place_id, site_url = intel_by_m_id.get(m_id, (None, None))
url_by_project[project_id] = site_url or _place_id_to_site_url(place_id)
return url_by_project
@router.get( @router.get(
"/generate/{task_id}", "/generate/{task_id}",
@ -806,7 +861,7 @@ async def download_video(
store_name=project.store_name if project else None, store_name=project.store_name if project else None,
region=project.region or _extract_region_from_address(project.detail_region_info) if project else None, region=project.region or _extract_region_from_address(project.detail_region_info) if project else None,
task_id=task_id, task_id=task_id,
result_movie_url=video.result_movie_url, result_movie_url=to_playback_url(video.result_movie_url),
created_at=video.created_at, created_at=video.created_at,
) )
@ -982,15 +1037,21 @@ async def get_all_videos(
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()} liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
official_site_url_map = await _get_official_site_urls(
session, [p for _, p, _ in rows]
)
items = [ items = [
VideoThumbnailItem( VideoThumbnailItem(
video_id=v.id, video_id=v.id,
store_name=p.store_name, store_name=p.store_name,
result_movie_url=v.result_movie_url, result_movie_url=to_playback_url(v.result_movie_url),
poster_url=v.poster_url,
created_at=v.created_at, created_at=v.created_at,
like_count=like_count_map.get(v.id) or 0, like_count=like_count_map.get(v.id) or 0,
is_liked_by_me=liked_map.get(v.id, False), is_liked_by_me=liked_map.get(v.id, False),
comment_count=comment_count or 0, comment_count=comment_count or 0,
official_site_url=official_site_url_map.get(p.id),
) )
for v, p, comment_count in rows for v, p, comment_count in rows
] ]
@ -1090,6 +1151,50 @@ async def toggle_like(
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}") raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
@router.get(
"/share/{video_id}",
response_class=HTMLResponse,
summary="영상 공유용 Open Graph 페이지",
description="영상별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.",
responses={
200: {"description": "공유 메타데이터 HTML 반환"},
404: {"description": "공유 가능한 완료 영상을 찾을 수 없음"},
},
)
async def get_video_share_page(
video_id: int,
request: Request,
session: AsyncSession = Depends(get_session),
) -> HTMLResponse:
"""공개 공유 페이지를 반환하고 일반 브라우저는 영상 상세로 이동시킵니다."""
share_data = await get_video_share_data(session, video_id)
if share_data is None:
raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.")
share_url = resolve_share_url(
request.headers,
str(request.url).split("?", maxsplit=1)[0],
prj_settings.SHARE_API_BASE_URL,
)
html = build_video_share_html(
share_data,
share_url=share_url,
frontend_base_url=resolve_frontend_base_url(
request.headers,
prj_settings.SHARE_FRONTEND_URL,
),
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
)
return HTMLResponse(
content=html,
headers={
"Cache-Control": "public, max-age=300",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
)
@router.get( @router.get(
"/{video_id}", "/{video_id}",
summary="단일 영상 상세 조회", summary="단일 영상 상세 조회",
@ -1162,15 +1267,21 @@ async def get_video_detail(
liked = False liked = False
is_liked_by_me = liked is_liked_by_me = liked
official_site_url_map = await _get_official_site_urls(session, [project])
logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}") logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
return VideoDetailResponse( return VideoDetailResponse(
video_id=video.id, video_id=video.id,
result_movie_url=video.result_movie_url, result_movie_url=to_playback_url(video.result_movie_url),
poster_url=video.poster_url,
store_name=project.store_name, store_name=project.store_name,
region=project.region or _extract_region_from_address(project.detail_region_info), region=project.region or _extract_region_from_address(project.detail_region_info),
title=video.title,
description=video.description,
created_at=video.created_at, created_at=video.created_at,
like_count=like_count, like_count=like_count,
is_liked_by_me=is_liked_by_me, is_liked_by_me=is_liked_by_me,
official_site_url=official_site_url_map.get(project.id),
) )
except HTTPException: except HTTPException:

View File

@ -1,7 +1,8 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -29,6 +30,10 @@ class Video(Base):
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식) task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
status: 처리 상태 (pending, processing, completed, failed 등) status: 처리 상태 (pending, processing, completed, failed 등)
result_movie_url: 생성된 영상 URL (S3, CDN 경로) result_movie_url: 생성된 영상 URL (S3, CDN 경로)
poster_url: 영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)
title: SNS 업로드 제목
description: SNS 업로드 설명
hashtags: SNS 해시태그 목록
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
Relationships: Relationships:
@ -106,6 +111,30 @@ class Video(Base):
comment="생성된 영상 URL", comment="생성된 영상 URL",
) )
poster_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="영상 첫 프레임 포스터 이미지 URL (SNS 공유용)",
)
title: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="SNS 업로드 제목",
)
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="SNS 업로드 설명",
)
hashtags: Mapped[Optional[list]] = mapped_column(
JSON,
nullable=True,
comment="SNS 해시태그 목록",
)
is_deleted: Mapped[bool] = mapped_column( is_deleted: Mapped[bool] = mapped_column(
Boolean, Boolean,
nullable=False, nullable=False,

View File

@ -5,7 +5,7 @@ Video API Schemas
""" """
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -148,6 +148,7 @@ class VideoListItem(BaseModel):
"region": "군산", "region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890", "task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"poster_url": "http://localhost:8000/media/2025-01-15/video.jpg",
"created_at": "2025-01-15T12:00:00" "created_at": "2025-01-15T12:00:00"
} }
""" """
@ -157,9 +158,17 @@ class VideoListItem(BaseModel):
region: Optional[str] = Field(None, description="지역명") region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자") task_id: str = Field(..., description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
title: Optional[str] = Field(None, description="SNS 업로드 제목")
description: Optional[str] = Field(None, description="SNS 업로드 설명")
hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록")
created_at: Optional[datetime] = Field(None, description="생성 일시") created_at: Optional[datetime] = Field(None, description="생성 일시")
like_count: int = Field(0, description="좋아요 수") like_count: int = Field(0, description="좋아요 수")
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)") comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
is_liked_by_me: bool = Field(
False,
description="현재 로그인 사용자가 좋아요를 눌렀는지",
)
class VideoThumbnailItem(BaseModel): class VideoThumbnailItem(BaseModel):
@ -171,11 +180,16 @@ class VideoThumbnailItem(BaseModel):
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)") video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
store_name: str = Field(..., description="업체명") store_name: str = Field(..., description="업체명")
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용") result_movie_url: str = Field(..., description="영상 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수") like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)") comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
official_site_url: Optional[str] = Field(
None,
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
)
class VideoDetailResponse(BaseModel): class VideoDetailResponse(BaseModel):
@ -187,11 +201,18 @@ class VideoDetailResponse(BaseModel):
video_id: int = Field(..., description="영상 고유 ID") video_id: int = Field(..., description="영상 고유 ID")
result_movie_url: str = Field(..., description="영상 URL") result_movie_url: str = Field(..., description="영상 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
store_name: Optional[str] = Field(None, description="업체명") store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명") region: Optional[str] = Field(None, description="지역명")
title: Optional[str] = Field(None, description="SNS 업로드 제목 (공유 시 og:title 및 공유 제목으로 사용)")
description: Optional[str] = Field(None, description="SNS 업로드 설명")
created_at: datetime = Field(..., description="생성 일시") created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수") like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)") is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
official_site_url: Optional[str] = Field(
None,
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
)
class LikeToggleResponse(BaseModel): class LikeToggleResponse(BaseModel):

View File

@ -0,0 +1,321 @@
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
from collections.abc import Mapping
from dataclasses import dataclass
from html import escape
from urllib.parse import urlsplit, urlunsplit
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.home.models import Project
from app.video.models import Video
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)
class VideoShareData:
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
video_id: int
poster_url: str | None
store_name: str
region: str
title: str | None = None
description: str | None = None
async def get_video_share_data(
session: AsyncSession,
video_id: int,
) -> VideoShareData | None:
"""공유 가능한 완료 영상을 프로젝트 정보와 함께 조회합니다."""
result = await session.execute(
select(
Video.id,
Video.poster_url,
Video.title,
Video.description,
Project.store_name,
Project.region,
)
.join(Project, Video.project_id == Project.id)
.where(
Video.id == video_id,
Video.status == "completed",
Video.is_deleted.is_(False),
Project.is_deleted.is_(False),
)
)
row = result.one_or_none()
if row is None:
return None
return VideoShareData(
video_id=row.id,
poster_url=row.poster_url,
store_name=row.store_name,
region=row.region,
title=row.title,
description=row.description,
)
def build_video_share_html(
data: VideoShareData,
*,
share_url: str,
frontend_base_url: str,
configured_default_image_url: str = "",
) -> str:
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
frontend_base = _normalise_frontend_base_url(frontend_base_url)
detail_url = f"{frontend_base}/video/{data.video_id}"
fallback_image_url = _resolve_default_image_url(
configured_default_image_url,
frontend_base,
share_url=share_url,
)
image_url = _absolute_http_url(data.poster_url) or fallback_image_url
title = _share_title(data.title, data.store_name)
description = _share_description(data.description)
canonical_tags = _canonical_tags(_absolute_http_url(share_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)
escaped_image_url = escape(image_url, quote=True)
escaped_detail_url = escape(detail_url, quote=True)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escaped_title}</title>
<meta name="description" content="{escaped_description}">
{canonical_tags}
<meta property="og:title" content="{escaped_title}">
<meta property="og:description" content="{escaped_description}">
<meta property="og:image" content="{escaped_image_url}">
<meta property="og:image:alt" content="{escaped_title}">
{image_size_tags} <meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{escaped_title}">
<meta name="twitter:description" content="{escaped_description}">
<meta name="twitter:image" content="{escaped_image_url}">
</head>
<body>
<main>
<h1>{escaped_title}</h1>
<p>{escaped_description}</p>
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a>
</main>
<script>
(function () {{
var ua = navigator.userAgent || "";
// 앱 이름만으로 판별하면 인앱 브라우저(예: KAKAOTALK)까지 크롤러로 잡혀
// 사용자가 중간 페이지에 멈춘다. 봇 전용 토큰만 쓴다.
if (/bot|crawl|spider|slurp|facebookexternalhit|Facebot|Twitterbot|LinkedInBot|Pinterestbot|Slackbot|TelegramBot|WhatsApp|Discordbot|kakaotalk-scrap|Embedly|redditbot|Applebot/i.test(ua)) {{
return;
}}
window.location.replace(document.getElementById("continue-link").href);
}})();
</script>
</body>
</html>
"""
def _normalise_text(value: str | None, fallback: str) -> str:
"""메타데이터용 텍스트에서 불필요한 공백을 제거합니다."""
normalised = " ".join((value or "").split())
return normalised or fallback
_OG_DESCRIPTION_MAX_LEN = 300
def _share_title(title: str | None, store_name: str) -> str:
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
stored = _normalise_text(title, "")
if stored:
return stored
name = _normalise_text(store_name, "ADO2 영상")
return f"{name} | ADO2"
def _share_description(description: str | None) -> str:
"""저장된 SNS 설명을 쓰고, 없으면 기본 문구를 사용합니다."""
stored = _normalise_text(description, "")
if stored:
if len(stored) > _OG_DESCRIPTION_MAX_LEN:
return stored[: _OG_DESCRIPTION_MAX_LEN - 1].rstrip() + "…"
return stored
return "ADO2 AI 마케팅 영상"
def resolve_frontend_base_url(headers: Mapping[str, str], fallback: str) -> str:
"""프록시가 넘긴 프론트 호스트를 우선해 canonical 기준 URL을 정합니다."""
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
if not forwarded_host:
return _normalise_frontend_base_url(fallback)
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
if forwarded_proto not in {"http", "https"}:
forwarded_proto = "https"
return _normalise_frontend_base_url(f"{forwarded_proto}://{forwarded_host}")
def resolve_share_url(
headers: Mapping[str, str],
request_url: str,
configured_api_base_url: str = "",
) -> str:
"""크롤러가 다시 읽어도 같은 OG 페이지가 나오는 공유 URL을 만듭니다.
nginx가 ``/api`` prefix를 떼고 넘기면 ``request.url``에는 그 prefix가 없어,
그대로 쓰면 프론트 SPA 주소가 된다. 복원할 근거가 없으면 빈 문자열을 돌려
호출부가 canonical/og:url을 생략하도록 한다.
"""
path = urlsplit(request_url).path
configured_base = _absolute_http_url(configured_api_base_url)
if configured_base:
return f"{configured_base.rstrip('/')}{path}"
origin = _forwarded_origin(headers) or _origin_from_url(request_url)
if not origin:
return ""
forwarded_prefix = (headers.get("x-forwarded-prefix") or "").strip().rstrip("/")
if forwarded_prefix:
return f"{origin}{forwarded_prefix}{path}"
if headers.get("x-forwarded-host"):
# 프록시 뒤인데 prefix 를 못 받았다. 잘못된 URL 을 내보내지 않는다.
return ""
return f"{origin}{path}"
def _forwarded_origin(headers: Mapping[str, str]) -> str | None:
"""프록시가 넘긴 외부 호스트 기준 origin을 만듭니다."""
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
if not forwarded_host:
return None
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
if forwarded_proto not in {"http", "https"}:
forwarded_proto = "https"
return f"{forwarded_proto}://{forwarded_host}"
def _canonical_tags(canonical_url: str | None) -> str:
"""공유 URL을 확신할 때만 canonical/og:url을 붙입니다.
잘못된 og:url을 내보내면 크롤러가 그 주소를 다시 읽어, OG 메타가 없는
프론트 SPA 문서를 미리보기로 쓴다. 확신이 없으면 크롤러가 실제로 받은
URL을 쓰도록 태그 자체를 생략한다.
"""
if not canonical_url:
return ""
escaped = escape(canonical_url, quote=True)
return (
f' <link rel="canonical" href="{escaped}">\n'
f' <meta property="og:url" content="{escaped}">\n'
)
_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 _normalise_frontend_base_url(value: str) -> str:
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL
parts = urlsplit(absolute_url)
path = parts.path.rstrip("/")
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
def _resolve_default_image_url(
configured_url: str,
frontend_base: str,
*,
share_url: str = "",
) -> str:
"""포스터가 없을 때 사용할 기본 OG 이미지 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``
"""
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}"
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, "", "", ""))
idx = (parts.path or "").find("/video/share/")
if idx < 0:
return origin
prefix = parts.path[:idx].rstrip("/")
return f"{origin}{prefix}" if prefix else origin
def _origin_from_url(value: str) -> str | None:
"""URL에서 scheme + host(+port) origin만 추출합니다."""
absolute_url = _absolute_http_url(value)
if not absolute_url:
return None
parts = urlsplit(absolute_url)
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
def _absolute_http_url(value: str | None) -> str | None:
"""값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다."""
candidate = (value or "").strip()
if not candidate:
return None
try:
parts = urlsplit(candidate)
except ValueError:
return None
if parts.scheme.lower() not in {"http", "https"} or not parts.hostname:
return None
return candidate

View File

@ -4,7 +4,6 @@ Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다. 영상 생성 관련 백그라운드 태스크를 정의합니다.
""" """
import traceback
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
@ -17,6 +16,7 @@ from app.user.services.credit import consume_credit
from app.video.models import Video from app.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.logger import get_logger from app.utils.logger import get_logger
from app.utils.video_poster import generate_and_store_poster
# 로거 설정 # 로거 설정
logger = get_logger("video") logger = get_logger("video")
@ -30,7 +30,8 @@ async def _update_video_status(
status: str, status: str,
video_url: str | None = None, video_url: str | None = None,
creatomate_render_id: str | None = None, creatomate_render_id: str | None = None,
) -> bool: poster_url: str | None = None,
) -> int | None:
"""Video 테이블의 상태를 업데이트합니다. """Video 테이블의 상태를 업데이트합니다.
Args: Args:
@ -38,9 +39,10 @@ async def _update_video_status(
status: 변경할 상태 ("processing", "completed", "failed") status: 변경할 상태 ("processing", "completed", "failed")
video_url: 영상 URL video_url: 영상 URL
creatomate_render_id: Creatomate render ID (선택) creatomate_render_id: Creatomate render ID (선택)
poster_url: 영상 첫 프레임 포스터 URL (선택)
Returns: Returns:
bool: 업데이트 성공 여부 int | None: 업데이트된 Video id. 대상이 없거나 실패하면 None.
""" """
try: try:
async with BackgroundSessionLocal() as session: async with BackgroundSessionLocal() as session:
@ -65,19 +67,58 @@ async def _update_video_status(
video.status = status video.status = status
if video_url is not None: if video_url is not None:
video.result_movie_url = video_url video.result_movie_url = video_url
if poster_url is not None:
video.poster_url = poster_url
await session.commit() await session.commit()
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}") logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
return True return video.id
else: else:
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}") logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
return False return None
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}") logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
return False return None
except Exception as e: except Exception as e:
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}") logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False return None
async def _try_generate_sns_metadata(video_id: int) -> None:
"""SEO 생성 실패가 영상 완료 처리에 영향을 주지 않도록 격리합니다."""
from app.social.services.seo_service import seo_service
try:
async with BackgroundSessionLocal() as session:
await seo_service.generate_and_save_for_video(video_id, session)
await session.commit()
except Exception as e:
logger.warning(
f"[VideoSEO] Failed to generate SNS metadata - video_id: {video_id}, error: {e}",
exc_info=True,
)
async def _try_generate_poster(
temp_file_path: Path,
user_uuid: str,
task_id: str,
render_id: str,
) -> str | None:
"""포스터 생성 실패가 영상 생성 완료 처리에 영향을 주지 않도록 격리합니다."""
try:
return await generate_and_store_poster(
video_path=temp_file_path,
user_uuid=user_uuid,
task_id=task_id,
file_stem=render_id,
)
except Exception as e:
logger.warning(
f"[VideoPoster] Failed to generate poster - task_id: {task_id}, render_id: {render_id}, error: {e}",
exc_info=True,
)
return None
async def _download_video(url: str, task_id: str) -> bytes: async def _download_video(url: str, task_id: str) -> bytes:
@ -153,8 +194,20 @@ async def download_and_upload_video_to_blob(
blob_url = uploader.public_url blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
poster_url = await _try_generate_poster(
temp_file_path, user_uuid, task_id, creatomate_render_id
)
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별) # Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id) video_id = await _update_video_status(
task_id,
"completed",
blob_url,
creatomate_render_id,
poster_url=poster_url,
)
if video_id is not None:
await _try_generate_sns_metadata(video_id)
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지) # 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
async with BackgroundSessionLocal() as session: async with BackgroundSessionLocal() as session:
@ -259,13 +312,20 @@ async def download_and_upload_video_by_creatomate_render_id(
blob_url = uploader.public_url blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
poster_url = await _try_generate_poster(
temp_file_path, user_uuid, task_id, creatomate_render_id
)
# Video 테이블 업데이트 # Video 테이블 업데이트
await _update_video_status( video_id = await _update_video_status(
task_id=task_id, task_id=task_id,
status="completed", status="completed",
video_url=blob_url, video_url=blob_url,
creatomate_render_id=creatomate_render_id, creatomate_render_id=creatomate_render_id,
poster_url=poster_url,
) )
if video_id is not None:
await _try_generate_sns_metadata(video_id)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}") logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e: except httpx.HTTPError as e:

View File

@ -33,6 +33,25 @@ class ProjectSettings(BaseSettings):
ADMIN_BASE_URL: str = Field(default="/admin") ADMIN_BASE_URL: str = Field(default="/admin")
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production") ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8) ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
SHARE_FRONTEND_URL: str = Field(
default="https://ado2.o2osolution.ai",
description="공유 페이지에서 영상 상세로 이동할 프론트엔드 공개 기준 URL (.env: SHARE_FRONTEND_URL)",
)
SHARE_API_BASE_URL: str = Field(
default="",
description=(
"공유 OG 페이지가 외부에 노출되는 API 기준 URL (.env: SHARE_API_BASE_URL). "
"예: https://dev-ssul.castad.net/api. 프록시가 /api prefix 를 떼고 넘기면 "
"request.url 로는 복원할 수 없으므로 이 값이 필요하다"
),
)
SHARE_DEFAULT_IMAGE_URL: str = Field(
default="",
description=(
"포스터가 없는 영상 공유 시 사용할 절대 이미지 URL (.env: SHARE_DEFAULT_IMAGE_URL). "
"비우면 공유 API /static/images/ado2_image.png, 없으면 SHARE_FRONTEND_URL/assets 경로 사용"
),
)
DEBUG: bool = Field(default=True) DEBUG: bool = Field(default=True)
TIMEZONE: str = Field( TIMEZONE: str = Field(
default="Asia/Seoul", default="Asia/Seoul",
@ -174,8 +193,8 @@ class AzureBlobSettings(BaseSettings):
ge=1, ge=1,
description="worker별 이미지 upload named lock 동시 점유 상한", description="worker별 이미지 upload named lock 동시 점유 상한",
) )
IMAGE_UPLOAD_MAX_TASK_IMAGES: int = Field( IMAGE_UPLOAD_MAX_TASK_IMAGES: int = Field(
default=100, default=100,
ge=1, ge=1,
description="한 task에 누적할 수 있는 활성 이미지 최대 개수", description="한 task에 누적할 수 있는 활성 이미지 최대 개수",
) )

View File

@ -0,0 +1,10 @@
-- ============================================================
-- Migration: video 테이블에 poster_url 컬럼 추가
-- Date: 2026-08-13
-- Description: 영상 첫 프레임 포스터 이미지 URL. SNS 공유 og:image 용도.
-- 관련 코드: app/utils/video_poster.py, app/video/worker/video_task.py
-- ============================================================
ALTER TABLE `video`
ADD COLUMN `poster_url` VARCHAR(2048) NULL
COMMENT '영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)' AFTER `result_movie_url`;

View File

@ -0,0 +1,15 @@
-- ============================================================
-- Migration: video 테이블에 SNS 메타데이터 컬럼 추가
-- Date: 2026-08-19
-- Description: 영상 생성 완료 시 저장하는 제목/설명/해시태그.
-- 관련 코드: app/social/services/seo_service.py,
-- app/video/worker/video_task.py
-- ============================================================
ALTER TABLE `video`
ADD COLUMN `title` VARCHAR(100) NULL
COMMENT 'SNS 업로드 제목' AFTER `poster_url`,
ADD COLUMN `description` TEXT NULL
COMMENT 'SNS 업로드 설명' AFTER `title`,
ADD COLUMN `hashtags` JSON NULL
COMMENT 'SNS 해시태그 목록' AFTER `description`;

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB