썸네일 생성 기능 추가

feature-thumbnail
김성경 2026-07-16 13:37:13 +09:00
parent 7ec9e9749d
commit 153484f2bd
11 changed files with 1688 additions and 1132 deletions

View File

@ -157,6 +157,7 @@ async def get_videos(
region=project.region,
task_id=video.task_id,
result_movie_url=video.result_movie_url,
thumbnail_url=video.video_thumbnail_url,
created_at=video.created_at,
like_count=like_count_map.get(video.id) or 0,
comment_count=comment_count or 0,

File diff suppressed because it is too large Load Diff

View File

@ -70,6 +70,33 @@ class SpaceType(StrEnum):
photo_spot = auto()
seasonal_scene = auto()
walking_path = auto()
# 미용실(DVSL0001) 템플릿 어휘
mirror_detail = auto()
styling_zone = auto()
waiting_lounge = auto()
# 병원(DVCL0001) 템플릿 어휘
consultation_room = auto()
detail_amenity = auto()
equipment_zone = auto()
recovery_room = auto()
treatment_room = auto()
waiting_area = auto()
# 피트니스(DVFT0001) 템플릿 어휘
apparatus_zone = auto()
brand_sign = auto()
detail_equipment = auto()
locker_room = auto()
powder_room = auto()
pt_zone = auto()
reformer_zone = auto()
# 학원(DVAC0001) 템플릿 어휘
classroom = auto()
counseling_room = auto()
detail_facility = auto()
detail_interior_prop = auto()
detail_materials = auto()
library_corner = auto()
study_room = auto()
class Subject(StrEnum):
"""이미지 내 주요 피사체 유형. 화면에 무엇이 담겨 있는지를 분류하는 태그."""
@ -94,6 +121,17 @@ class Subject(StrEnum):
# 관광지(DVAT0001) 템플릿 어휘
scenery = auto()
structure = auto()
# 병원(DVCL0001) 템플릿 어휘
interior_clean = auto()
medical_equipment = auto()
# 피트니스(DVFT0001) 템플릿 어휘
interior_scale = auto()
pilates_apparatus = auto()
training_action = auto()
# 학원(DVAC0001) 템플릿 어휘
facility_equipment = auto()
learning_material = auto()
study_scene = auto()
class Camera(StrEnum):
"""이미지의 촬영 기법·구도·조명 특성. 영상 편집 시 컷의 시각적 스타일을 구분하는 태그."""

View File

@ -44,3 +44,4 @@ class MarketingPromptOutput(BaseModel):
target_persona: List[TargetPersona] = Field(..., description="타겟 페르소나")
selling_points: List[SellingPoint] = Field(..., description="셀링 포인트")
target_keywords: List[str] = Field(..., description="타겟 키워드 리스트")
hook_headline: str = Field(..., description="썸네일 헤드라인용 훅 문구. 브랜드 컨셉/핵심 매력을 5자 이상 15자 이하의 한국어 한 구절로 축약 (마침표·해시태그 없이, 예: '바다 앞 프라이빗 휴식')") # min/max constraint는 openai json schema에서 미작동 보고가 있어 description으로 유도, 소비처에서 길이 안전망 적용

197
app/utils/thumbnail.py Normal file
View File

@ -0,0 +1,197 @@
"""세로형 썸네일 정지 이미지 렌더용 컴포지션 및 source 조립.
CreatomateService에 의존하지 않는 코드 자산이다. 실제 렌더 요청·이미지 선택은
CreatomateService.render_thumbnail / select_thumbnail_image가 담당한다.
"""
import copy
# 세로형 썸네일(1080×1920) 정지 이미지 렌더용 컴포지션.
# 운영 템플릿에 의존하지 않는 코드 측 자산 — 디자인 수정 시 이 상수만 변경.
# 텍스트 슬롯 이름은 CreatomateService.make_thumbnail_modification()의 반환 키와 일치해야 한다.
THUMBNAIL_COMPOSITION_V: dict = {
"name": "thumbnail-composition",
"type": "composition",
"track": 1,
"time": 0,
"elements": [
{
"name": "thumb-background",
"type": "image",
"track": 1,
"time": 0,
"width": "105%",
"height": "105%",
"smart_crop": True,
"color_overlay": "rgba(0,0,0,0.22)",
},
{
"name": "thumb-badge-category",
"type": "text",
"track": 2,
"time": 0,
"y": "8%",
"width": "75%",
"height": "7%",
"x_alignment": "50%",
"y_alignment": "50%",
"font_family": "Pretendard",
"font_weight": "700",
"font_size_maximum": "4.8 vmin",
"letter_spacing": "12%",
"line_height": "110%",
"background_color": "#FFFFFF",
"background_x_padding": "50%",
"background_y_padding": "65%",
"background_border_radius": "100%",
"fill_color": "#1A1A1A",
},
{
"name": "thumb-headline-hook_claim-aspirational",
"type": "text",
"track": 3,
"time": 0,
"y": "45%",
"width": "85%",
"height": "22%",
"x_alignment": "50%",
"y_alignment": "50%",
"font_family": "Pretendard",
"font_weight": "900",
"font_size_maximum": "11 vmin",
"letter_spacing": "-2%",
"fill_color": "#FFFFFF",
"stroke_color": "rgba(0,0,0,0.65)",
"stroke_width": "0.8 vmin",
"shadow_color": "rgba(0,0,0,0.55)",
"shadow_blur": "2.5 vmin",
"shadow_y": "0.5 vmin",
},
{
"name": "thumb-subheadline-selling_point",
"type": "text",
"track": 4,
"time": 0,
"y": "64%",
"width": "88%",
"height": "9%",
"x_alignment": "50%",
"y_alignment": "50%",
"font_family": "Pretendard",
"font_weight": "500",
"font_size_maximum": "6 vmin",
"letter_spacing": "6%",
"line_height": "130%",
"fill_color": "#FFFFFF",
"stroke_color": "rgba(0,0,0,0.55)",
"stroke_width": "0.4 vmin",
},
{
"name": "thumb-brand-wordmark",
"type": "text",
"track": 5,
"time": 0,
"y": "89%",
"width": "75%",
"height": "9%",
"x_alignment": "50%",
"y_alignment": "50%",
"font_family": "Pretendard",
"font_weight": "900",
"font_size_maximum": "7.5 vmin",
"letter_spacing": "8%",
"line_height": "120%",
"fill_color": "#FFFFFF",
"stroke_color": "rgba(0,0,0,0.6)",
"stroke_width": "0.5 vmin",
"shadow_color": "rgba(0,0,0,0.45)",
"shadow_blur": "1.5 vmin",
"shadow_y": "0.3 vmin",
},
{
"name": "thumb-hashtag-primary",
"type": "text",
"track": 6,
"time": 0,
"y": "95.5%",
"width": "92%",
"height": "6%",
"x_alignment": "50%",
"y_alignment": "50%",
"font_family": "Pretendard",
"font_weight": "600",
"font_size_maximum": "4.2 vmin",
"letter_spacing": "4%",
"line_height": "120%",
"fill_color": "#FFFFFF",
"stroke_color": "rgba(0,0,0,0.5)",
"stroke_width": "0.3 vmin",
},
],
}
# 영상 첫 화면 썸네일 오버레이 노출 시간(초). 30fps 기준 3프레임 — SNS 첫 프레임
# 캡처에는 충분하면서 시청 흐름에는 거의 인지되지 않는 안전 하한선.
THUMBNAIL_INTRO_DURATION = 0.1
# 오버레이가 기존 최상위 트랙(1: 씬, 2: 오디오, 3: 자막, 4: 키워드)과
# 렌더 직전 append되는 가사 캡션(track 3~4) 위에 오도록 여유 있게 잡은 트랙 번호.
THUMBNAIL_INTRO_TRACK = 9
def _fill_thumbnail_slots(composition: dict, image_url: str, text_modifications: dict[str, str]) -> None:
"""썸네일 컴포지션의 이미지/텍스트 슬롯을 in-place로 채웁니다."""
for elem in composition["elements"]:
name = elem.get("name")
if elem["type"] == "image" and name == "thumb-background":
elem["source"] = image_url
elif elem["type"] == "text" and name in text_modifications:
elem["text"] = text_modifications[name]
def build_thumbnail_source(image_url: str, text_modifications: dict[str, str]) -> dict:
"""썸네일 렌더용 Creatomate source를 조립합니다.
Args:
image_url: 배경 이미지 URL
text_modifications: make_thumbnail_modification() 반환값 (thumb-* 텍스트 슬롯)
Returns:
1080×1920 정지 이미지 렌더 source 딕셔너리
"""
composition = copy.deepcopy(THUMBNAIL_COMPOSITION_V)
_fill_thumbnail_slots(composition, image_url, text_modifications)
return {
"width": 1080,
"height": 1920,
"elements": [composition],
}
def build_thumbnail_intro_element(
image_url: str,
text_modifications: dict[str, str],
duration: float = THUMBNAIL_INTRO_DURATION,
) -> dict:
"""영상 첫 화면(SNS 첫 프레임용)에 얹을 썸네일 오버레이 요소를 조립합니다.
duration 스케일링(extend_template_duration) 이후, 렌더 요청 직전에
final_template["source"]["elements"] append해야 노출 시간이 그대로 유지된다.
기존 ·자막·오디오 타이밍은 건드리지 않는 순수 오버레이이다.
Args:
image_url: 배경 이미지 URL
text_modifications: make_thumbnail_modification() 반환값 (thumb-* 텍스트 슬롯)
duration: 오버레이 노출 시간()
Returns:
time=0, 최상위 트랙에 배치된 composition 요소 딕셔너리
"""
composition = copy.deepcopy(THUMBNAIL_COMPOSITION_V)
composition["name"] = "thumbnail-intro"
composition["track"] = THUMBNAIL_INTRO_TRACK
composition["time"] = 0
composition["duration"] = duration
_fill_thumbnail_slots(composition, image_url, text_modifications)
return composition

View File

@ -32,6 +32,9 @@ from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService
# 썸네일 인트로 오버레이 잠정 비활성화 — 재활성화 시 주석 해제 (generate_video 내 블록 참조)
# from app.utils.thumbnail import build_thumbnail_intro_element
# from app.video.services.video import get_image_tags_by_task_id
from app.comment.models import Comment
from app.database.like_cache import (
@ -57,6 +60,7 @@ from app.video.schemas.video_schema import (
VideoRenderData,
VideoThumbnailItem,
)
from app.video.worker.thumbnail_task import generate_thumbnail_background
from app.video.worker.video_task import download_and_upload_video_to_blob
@ -125,6 +129,7 @@ curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5
)
async def generate_video(
task_id: str,
background_tasks: BackgroundTasks,
orientation: Literal["horizontal", "vertical"] = Query(
default="vertical",
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
@ -221,10 +226,24 @@ async def generate_video(
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
target_keywords = marketing_intelligence.intel_result["target_keywords"]
brand_concept = ""
for sp in marketing_intelligence.intel_result["selling_points"]:
if "concept" in sp["english_category"].lower():
brand_concept = sp["description"]
# 썸네일 헤드라인(thumb-headline-hook_claim-aspirational)용 문구 추출.
# 1순위: 마케팅 분석 시 LLM이 5~15자로 축약 생성한 hook_headline (신규 데이터)
# 폴백: hook_headline이 없는 구 데이터는 concept 셀링포인트 → core_value →
# 최고점 셀링포인트 순으로 추출 (make_thumbnail_modification에서 15자 절단 안전망 적용)
brand_concept = marketing_intelligence.intel_result.get("hook_headline", "") or ""
selling_points = marketing_intelligence.intel_result["selling_points"]
if not brand_concept:
for sp in selling_points:
if "concept" in sp["english_category"].lower():
brand_concept = sp["description"]
break
if not brand_concept:
brand_concept = marketing_intelligence.intel_result["market_positioning"].get("core_value", "")
if brand_concept:
logger.info(f"[generate_video] brand_concept 폴백(core_value) 사용 - task_id: {task_id}")
if not brand_concept and selling_points:
brand_concept = max(selling_points, key=lambda sp: sp.get("score", 0))["description"]
logger.info(f"[generate_video] brand_concept 폴백(최고점 셀링포인트) 사용 - task_id: {task_id}")
# Lyric 조회
lyric_result = await session.execute(
@ -336,6 +355,19 @@ async def generate_video(
)
# 세션이 여기서 자동으로 닫힘 (async with 블록 종료)
# 썸네일 백그라운드 생성 (세로형만, 응답 비차단) — 실패해도 영상 생성에 영향 없음
background_tasks.add_task(
generate_thumbnail_background,
video_id=video_id,
task_id=task_id,
orientation=orientation,
brand_name=brand_name,
region=region,
brand_concept=brand_concept,
category_definition=category_definition,
target_keywords=target_keywords,
)
except HTTPException:
raise
except Exception as e:
@ -360,6 +392,7 @@ async def generate_video(
creatomate_service = CreatomateService(
orientation=orientation,
industry=industry,
project_id=project_id,
)
logger.debug(
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
@ -445,6 +478,24 @@ async def generate_video(
)
final_template["source"]["elements"].append(caption)
# END - LYRIC AUTO 결정부
# 썸네일 인트로 오버레이 (세로형만) — SNS 첫 프레임용 0.1초 정지 화면.
# duration 스케일링·자막 append 이후에 붙여야 노출 시간이 유지되며,
# 실패해도 영상 생성 자체에는 영향을 주지 않는다.
# NOTE: 잠정 비활성화 — 재활성화 시 아래 블록과 상단 import 주석 해제
# if orientation == "vertical":
# try:
# taged_image_list = await get_image_tags_by_task_id(task_id)
# thumb_image_url = creatomate_service.select_thumbnail_image(taged_image_list)
# if thumb_image_url:
# final_template["source"]["elements"].append(
# build_thumbnail_intro_element(thumb_image_url, thumbnail_modifications)
# )
# logger.info(f"[generate_video] Thumbnail intro overlay added - task_id: {task_id}")
# else:
# logger.warning(f"[generate_video] 썸네일 인트로 배경 이미지 없음 — skip - task_id: {task_id}")
# except Exception as e:
# logger.warning(f"[generate_video] 썸네일 인트로 추가 실패(무시) - task_id: {task_id}, error: {e}")
# logger.debug(
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
# )
@ -977,6 +1028,7 @@ async def get_all_videos(
video_id=v.id,
store_name=p.store_name,
result_movie_url=v.result_movie_url,
thumbnail_url=v.video_thumbnail_url,
created_at=v.created_at,
like_count=like_count_map.get(v.id) or 0,
is_liked_by_me=liked_map.get(v.id, False),

View File

@ -106,6 +106,12 @@ class Video(Base):
comment="생성된 영상 URL",
)
video_thumbnail_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="영상 생성 시 백그라운드로 렌더된 썸네일 이미지 URL (실패/미생성 시 NULL)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,

View File

@ -157,6 +157,7 @@ class VideoListItem(BaseModel):
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
thumbnail_url: Optional[str] = Field(None, description="백그라운드 렌더된 썸네일 이미지 URL (세로형만, 실패/미생성 시 null)")
created_at: Optional[datetime] = Field(None, description="생성 일시")
like_count: int = Field(0, description="좋아요 수")
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
@ -171,7 +172,8 @@ class VideoThumbnailItem(BaseModel):
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
store_name: str = Field(..., description="업체명")
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
result_movie_url: str = Field(..., description="영상 URL — thumbnail_url 없을 때 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
thumbnail_url: Optional[str] = Field(None, description="백그라운드 렌더된 썸네일 이미지 URL (세로형만, 실패/미생성 시 null)")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")

View File

@ -72,7 +72,7 @@ async def generate_creative_assets_background(
)
marketing_intelligence = marketing_result.scalar_one_or_none()
creatomate_service = CreatomateService(orientation=orientation, industry=project.industry)
creatomate_service = CreatomateService(orientation=orientation, industry=project.industry, project_id=project.id)
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
store_address = project.detail_region_info

View File

@ -0,0 +1,69 @@
"""썸네일 백그라운드 생성 태스크.
/video/generate 요청 응답을 막지 않도록 BackgroundTasks로 실행되며,
세로형 영상에 한해 썸네일 정지 이미지를 렌더해 Video.video_thumbnail_url에 저장한다.
모든 실패는 경고 로그 무시하며 예외를 전파하지 않는다.
"""
from sqlalchemy import select
from app.database.session import BackgroundSessionLocal
from app.utils.creatomate import CreatomateService
from app.utils.logger import get_logger
from app.video.models import Video
from app.video.services.video import get_image_tags_by_task_id
logger = get_logger("video")
async def _save_thumbnail_url(video_id: int, thumbnail_url: str) -> None:
"""렌더된 썸네일 URL을 Video 행에 저장한다."""
async with BackgroundSessionLocal() as session:
result = await session.execute(select(Video).where(Video.id == video_id))
video = result.scalar_one_or_none()
if video is None:
logger.warning(f"[generate_thumbnail_background] Video 없음 - video_id: {video_id}")
return
video.video_thumbnail_url = thumbnail_url
await session.commit()
async def generate_thumbnail_background(
video_id: int,
task_id: str,
orientation: str,
brand_name: str,
region: str,
brand_concept: str,
category_definition: str,
target_keywords: list[str],
) -> None:
"""세로형 영상의 썸네일을 렌더해 Video.video_thumbnail_url에 저장한다.
보조 자산이므로 어떤 실패(이미지 없음, API 오류, 렌더 실패) 예외를
전파하지 않고 경고 로그 종료한다. 가로형은 대상이 아니다.
"""
if orientation != "vertical":
return
try:
service = CreatomateService(orientation=orientation)
taged_image_list = await get_image_tags_by_task_id(task_id)
image_url = service.select_thumbnail_image(taged_image_list)
if not image_url:
logger.warning(f"[generate_thumbnail_background] 썸네일 배경 이미지 없음 — skip - task_id: {task_id}")
return
text_mods = service.make_thumbnail_modification(
brand_name=brand_name,
region=region,
brand_concept=brand_concept,
category_definition=category_definition,
target_keywords=target_keywords,
)
thumbnail_url = await service.render_thumbnail(image_url, text_mods)
if thumbnail_url:
await _save_thumbnail_url(video_id, thumbnail_url)
logger.info(f"[generate_thumbnail_background] 저장 완료 - video_id: {video_id}, url: {thumbnail_url}")
else:
logger.warning(f"[generate_thumbnail_background] 렌더 실패 — skip - task_id: {task_id}")
except Exception as e:
logger.warning(f"[generate_thumbnail_background] 실패(무시) - task_id: {task_id}, error: {e}")

View File

@ -0,0 +1,11 @@
-- ============================================================
-- Migration: video 테이블에 video_thumbnail_url 컬럼 추가
-- Date: 2026-07-16
-- Description: 영상 생성 시 백그라운드로 렌더된 썸네일 이미지 URL 저장
-- - 실패/미생성 시 NULL
-- ============================================================
ALTER TABLE video
ADD COLUMN video_thumbnail_url VARCHAR(2048) NULL
COMMENT '영상 생성 시 백그라운드로 렌더된 썸네일 이미지 URL (실패/미생성 시 NULL)'
AFTER result_movie_url;