썸네일 생성 기능 추가
parent
7ec9e9749d
commit
153484f2bd
|
|
@ -157,6 +157,7 @@ async def get_videos(
|
||||||
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=video.result_movie_url,
|
||||||
|
thumbnail_url=video.video_thumbnail_url,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
||||||
- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
|
- 캐시 만료: 기본 5분 후 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
|
@ -40,6 +41,7 @@ import httpx
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.prompts.schemas.image import SpaceType,Subject,Camera,MotionRecommended,NarrativePhase
|
from app.utils.prompts.schemas.image import SpaceType,Subject,Camera,MotionRecommended,NarrativePhase
|
||||||
from app.utils.common import normalize_location
|
from app.utils.common import normalize_location
|
||||||
|
from app.utils.thumbnail import build_thumbnail_source
|
||||||
|
|
||||||
from config import apikey_settings, creatomate_settings, recovery_settings
|
from config import apikey_settings, creatomate_settings, recovery_settings
|
||||||
|
|
||||||
|
|
@ -229,10 +231,15 @@ DVST0001 = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
|
||||||
DVRT0001 = "3c4b74c4-82d7-4742-9b05-4b999fef4cbd"
|
DVRT0001 = "3c4b74c4-82d7-4742-9b05-4b999fef4cbd"
|
||||||
DVCF0001 = "ebb532ec-e49a-4a7e-b0e2-a8132f1bf1f7"
|
DVCF0001 = "ebb532ec-e49a-4a7e-b0e2-a8132f1bf1f7"
|
||||||
DVAT0001 = "14d4fa22-49b8-49ca-a233-ac0556436f60"
|
DVAT0001 = "14d4fa22-49b8-49ca-a233-ac0556436f60"
|
||||||
|
DVSL0001 = "20c526a7-b184-46f3-9138-9dfaff2fa342"
|
||||||
|
DVCL0001 = "b57dca80-167d-430a-8905-30f4674ff72b"
|
||||||
|
DVFT0001 = "03b6d521-4864-4e69-8e6c-1deab9f0ce6e"
|
||||||
|
DVAC0001 = "4db8f5a3-4fd1-42cf-86b3-d997f0713d99"
|
||||||
|
|
||||||
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
|
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
|
||||||
|
|
||||||
HST_LIST = [DHST0001]
|
HST_LIST = [DHST0001]
|
||||||
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001]
|
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001,DVSL0001,DVCL0001,DVFT0001,DVAC0001]
|
||||||
|
|
||||||
# industry → 세로형 템플릿 매핑 (신규 세로형 템플릿 추가 시 여기에만 등록)
|
# industry → 세로형 템플릿 매핑 (신규 세로형 템플릿 추가 시 여기에만 등록)
|
||||||
VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
|
VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
|
||||||
|
|
@ -240,9 +247,13 @@ VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
|
||||||
"restaurant": DVRT0001,
|
"restaurant": DVRT0001,
|
||||||
"cafe": DVCF0001,
|
"cafe": DVCF0001,
|
||||||
"attraction": DVAT0001,
|
"attraction": DVAT0001,
|
||||||
|
"salon": DVSL0001,
|
||||||
|
"clinic": DVCL0001,
|
||||||
|
"fitness": DVFT0001,
|
||||||
|
"academy": DVAC0001,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 매핑에 없는 업종(salon, clinic 등)의 폴백 템플릿
|
# 매핑에 없는 업종(general)의 폴백 템플릿
|
||||||
DEFAULT_VERTICAL_TEMPLATE = DVST0001
|
DEFAULT_VERTICAL_TEMPLATE = DVST0001
|
||||||
|
|
||||||
# 템플릿 슬롯명의 모션 동의어 → MotionRecommended 표준값 정규화 맵.
|
# 템플릿 슬롯명의 모션 동의어 → MotionRecommended 표준값 정규화 맵.
|
||||||
|
|
@ -264,13 +275,21 @@ AUDIO_TRACK = 2
|
||||||
SUBTITLE_TRACK = 3
|
SUBTITLE_TRACK = 3
|
||||||
KEYWORD_TRACK = 4
|
KEYWORD_TRACK = 4
|
||||||
|
|
||||||
def select_template(orientation: OrientationType, industry: str | None = None) -> str:
|
def select_template(
|
||||||
|
orientation: OrientationType,
|
||||||
|
industry: str | None = None,
|
||||||
|
project_id: int | None = None,
|
||||||
|
) -> str:
|
||||||
"""orientation과 industry에 따라 Creatomate 템플릿 ID를 선택합니다.
|
"""orientation과 industry에 따라 Creatomate 템플릿 ID를 선택합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
orientation: 영상 방향 ("horizontal" 또는 "vertical")
|
orientation: 영상 방향 ("horizontal" 또는 "vertical")
|
||||||
industry: 업종 분류 (stay|restaurant|cafe|attraction 등).
|
industry: 업종 분류 (stay|restaurant|cafe|attraction 등).
|
||||||
세로형에서만 사용되며, 매핑에 없으면 기본 템플릿으로 폴백합니다.
|
세로형에서 매핑에 있으면 전용 템플릿을 반환합니다.
|
||||||
|
project_id: 미매핑 업종(general 등)의 세로형 분배용 키.
|
||||||
|
project_id % len(VST_LIST)로 결정적으로 선택하므로, 같은
|
||||||
|
프로젝트는 몇 번 호출해도 동일 템플릿을 받아 사전/렌더 단계가
|
||||||
|
일치한다. 없으면 DEFAULT_VERTICAL_TEMPLATE로 폴백한다.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
선택된 템플릿 ID
|
선택된 템플릿 ID
|
||||||
|
|
@ -278,10 +297,17 @@ def select_template(orientation: OrientationType, industry: str | None = None) -
|
||||||
if orientation == "horizontal":
|
if orientation == "horizontal":
|
||||||
template_id = DHST0001
|
template_id = DHST0001
|
||||||
elif orientation == "vertical":
|
elif orientation == "vertical":
|
||||||
template_id = VERTICAL_INDUSTRY_TEMPLATE_MAP.get(industry, DEFAULT_VERTICAL_TEMPLATE)
|
mapped = VERTICAL_INDUSTRY_TEMPLATE_MAP.get(industry)
|
||||||
|
if mapped is not None:
|
||||||
|
template_id = mapped
|
||||||
|
elif project_id is not None:
|
||||||
|
# 미매핑 업종: project_id 나머지로 VST_LIST 결정적 분배 (공유 상태 없음)
|
||||||
|
template_id = VST_LIST[project_id % len(VST_LIST)]
|
||||||
|
else:
|
||||||
|
template_id = DEFAULT_VERTICAL_TEMPLATE
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
logger.info(f"[select_template] orientation={orientation}, industry={industry}, template_id={template_id}")
|
logger.info(f"[select_template] orientation={orientation}, industry={industry}, project_id={project_id}, template_id={template_id}")
|
||||||
return template_id
|
return template_id
|
||||||
|
|
||||||
async def get_shared_client() -> httpx.AsyncClient:
|
async def get_shared_client() -> httpx.AsyncClient:
|
||||||
|
|
@ -332,6 +358,7 @@ class CreatomateService:
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
orientation: OrientationType = "vertical",
|
orientation: OrientationType = "vertical",
|
||||||
industry: str | None = None,
|
industry: str | None = None,
|
||||||
|
project_id: int | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -339,13 +366,15 @@ class CreatomateService:
|
||||||
None일 경우 config에서 자동으로 가져옴
|
None일 경우 config에서 자동으로 가져옴
|
||||||
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
||||||
industry: 업종 분류 (stay|restaurant|cafe|attraction 등). 세로형 템플릿 선택에 사용되며,
|
industry: 업종 분류 (stay|restaurant|cafe|attraction 등). 세로형 템플릿 선택에 사용되며,
|
||||||
매핑에 없거나 None이면 기본 세로형 템플릿으로 폴백
|
매핑에 있으면 전용 템플릿을 사용
|
||||||
|
project_id: 미매핑 업종(general 등)의 세로형 분배용 키. 매핑에 없을 때
|
||||||
|
project_id % len(VST_LIST)로 결정적 선택
|
||||||
"""
|
"""
|
||||||
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
|
|
||||||
# orientation·industry에 따른 템플릿 설정 가져오기
|
# orientation·industry에 따른 템플릿 설정 가져오기
|
||||||
self.template_id = select_template(orientation, industry=industry)
|
self.template_id = select_template(orientation, industry=industry, project_id=project_id)
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
|
@ -624,14 +653,20 @@ class CreatomateService:
|
||||||
_NARR_BONUS = 1.5 # narrative 독립 보너스: base=0이어도 narrative 신호가 점수에 남음
|
_NARR_BONUS = 1.5 # narrative 독립 보너스: base=0이어도 narrative 신호가 점수에 남음
|
||||||
_NARR_MIN = 0.0 # narrative_preference 값 clamp 하한 (무경계 스키마 방어)
|
_NARR_MIN = 0.0 # narrative_preference 값 clamp 하한 (무경계 스키마 방어)
|
||||||
_NARR_MAX = 100.0 # narrative_preference 값 clamp 상한. NarrativePreference 스키마(0.0~100.0)와 스케일 일치
|
_NARR_MAX = 100.0 # narrative_preference 값 clamp 상한. NarrativePreference 스키마(0.0~100.0)와 스케일 일치
|
||||||
|
_SPACE_TYPE_MISMATCH_PENALTY = 0.1 # 슬롯이 요구하는 space_type과 다른 이미지에 적용하는 페널티 배수 (하드 필터에 가깝게)
|
||||||
|
|
||||||
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
|
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
|
||||||
"""이미지 슬롯에 대한 각 이미지의 매칭 점수를 계산합니다.
|
"""이미지 슬롯에 대한 각 이미지의 매칭 점수를 계산합니다.
|
||||||
|
|
||||||
점수 = base * narr_mod + NARR_BONUS * narr
|
점수 = (base * narr_mod + NARR_BONUS * narr) * space_type_penalty
|
||||||
- base: 태그 카테고리 매칭 합산 (최대 5.5)
|
- base: 태그 카테고리 매칭 합산 (최대 5.5)
|
||||||
- narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수
|
- narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수
|
||||||
- NARR_BONUS * narr: narrative 독립 보너스 (base=0이어도 신호 유지)
|
- NARR_BONUS * narr: narrative 독립 보너스 (base=0이어도 신호 유지)
|
||||||
|
- space_type_penalty: 슬롯이 요구하는 space_type과 이미지가 불일치하면
|
||||||
|
SPACE_TYPE_MISMATCH_PENALTY(0.1)를 곱해 사실상 탈락시킴. narrative_preference
|
||||||
|
점수가 아무리 높아도(예: bathroom 이미지가 intro narr=100) 슬롯이 지정한
|
||||||
|
space_type(예: exterior_front)과 다르면 우선 배정되지 않도록 하기 위함 —
|
||||||
|
같은 space_type 이미지가 pool에 하나도 없을 때만 상대적으로 선택됨(fallback 유지).
|
||||||
이 구조로 "base=0, narrative가 높음"과 "완전히 틀린 이미지(0,0)"를 구별할 수 있음.
|
이 구조로 "base=0, narrative가 높음"과 "완전히 틀린 이미지(0,0)"를 구별할 수 있음.
|
||||||
"""
|
"""
|
||||||
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
|
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
|
||||||
|
|
@ -642,6 +677,11 @@ class CreatomateService:
|
||||||
|
|
||||||
base_score_list = [0.0] * len(image_tag_list)
|
base_score_list = [0.0] * len(image_tag_list)
|
||||||
# slot_tag_narrative = NarrativePhase.accent # 기본값 (narrative 토큰 없는 슬롯 대비)
|
# slot_tag_narrative = NarrativePhase.accent # 기본값 (narrative 토큰 없는 슬롯 대비)
|
||||||
|
slot_space_type = slot_tag_dict.get("space_type")
|
||||||
|
space_type_match_list = [
|
||||||
|
slot_space_type is not None and slot_space_type.value in image_tag.get("space_type", [])
|
||||||
|
for image_tag in image_tag_list
|
||||||
|
]
|
||||||
|
|
||||||
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
|
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
|
||||||
if slot_tag_cate == "narrative_preference":
|
if slot_tag_cate == "narrative_preference":
|
||||||
|
|
@ -674,6 +714,8 @@ class CreatomateService:
|
||||||
narr = clamped_narr / self._NARR_MAX # 0.0~1.0로 정규화
|
narr = clamped_narr / self._NARR_MAX # 0.0~1.0로 정규화
|
||||||
narr_mod = self._NARR_FLOOR + (1.0 - self._NARR_FLOOR) * narr
|
narr_mod = self._NARR_FLOOR + (1.0 - self._NARR_FLOOR) * narr
|
||||||
score = base_score_list[idx] * narr_mod + self._NARR_BONUS * narr
|
score = base_score_list[idx] * narr_mod + self._NARR_BONUS * narr
|
||||||
|
if not space_type_match_list[idx]:
|
||||||
|
score *= self._SPACE_TYPE_MISMATCH_PENALTY
|
||||||
image_score_list.append(score)
|
image_score_list.append(score)
|
||||||
|
|
||||||
return image_score_list
|
return image_score_list
|
||||||
|
|
@ -707,6 +749,32 @@ class CreatomateService:
|
||||||
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 select_thumbnail_image(self, taged_image_list: list[dict]) -> str | None:
|
||||||
|
"""썸네일 배경 이미지를 독립 선택합니다.
|
||||||
|
|
||||||
|
일반 씬 배정 풀과 무관하게 narrative_preference의 core+intro 합산
|
||||||
|
최고점 이미지를 고른다 (본편 씬과 중복 허용 — 마케팅 자산이므로
|
||||||
|
항상 최고 품질 사진 사용).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
taged_image_list: [{"image_url": str, "image_tag": dict}] 형태의 이미지 풀
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
최고점 이미지 URL. 풀이 비어 있으면 None
|
||||||
|
"""
|
||||||
|
if not taged_image_list:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _score(taged_image: dict) -> float:
|
||||||
|
narr = taged_image.get("image_tag", {}).get("narrative_preference", {})
|
||||||
|
return float(narr.get("core", 0.0)) + float(narr.get("intro", 0.0))
|
||||||
|
|
||||||
|
best = max(taged_image_list, key=_score)
|
||||||
|
logger.info(
|
||||||
|
f"[select_thumbnail_image] selected={best['image_url']}, score={_score(best):.1f}, pool={len(taged_image_list)}"
|
||||||
|
)
|
||||||
|
return best["image_url"]
|
||||||
|
|
||||||
def elements_connect_resource_blackbox(
|
def elements_connect_resource_blackbox(
|
||||||
self,
|
self,
|
||||||
elements: list,
|
elements: list,
|
||||||
|
|
@ -908,6 +976,73 @@ class CreatomateService:
|
||||||
original_response={"last_error": str(last_error)},
|
original_response={"last_error": str(last_error)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def render_thumbnail(
|
||||||
|
self,
|
||||||
|
image_url: str,
|
||||||
|
text_modifications: dict[str, str],
|
||||||
|
poll_interval: float = 3.0,
|
||||||
|
max_polls: int = 10,
|
||||||
|
) -> str | None:
|
||||||
|
"""썸네일 정지 이미지를 렌더링하고 결과 URL을 반환합니다.
|
||||||
|
|
||||||
|
영상 렌더와 독립적인 보조 자산 생성이므로, 모든 실패(요청 오류·
|
||||||
|
렌더 실패·폴링 타임아웃)는 경고 로그 후 None을 반환하며 예외를
|
||||||
|
전파하지 않는다. 재시도하지 않는다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_url: 배경 이미지 URL
|
||||||
|
text_modifications: make_thumbnail_modification() 반환값
|
||||||
|
poll_interval: 상태 폴링 간격(초)
|
||||||
|
max_polls: 최대 폴링 횟수
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
성공 시 썸네일 이미지 URL, 실패 시 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
**build_thumbnail_source(image_url, text_modifications),
|
||||||
|
"output_format": "jpg",
|
||||||
|
}
|
||||||
|
response = await self._request(
|
||||||
|
"POST",
|
||||||
|
f"{self.BASE_URL}/v2/renders",
|
||||||
|
timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if response.status_code not in (200, 201, 202):
|
||||||
|
logger.warning(
|
||||||
|
f"[render_thumbnail] 렌더 요청 실패 - status: {response.status_code}, body: {response.text}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
render = body[0] if isinstance(body, list) and body else body
|
||||||
|
render_id = render.get("id") if isinstance(render, dict) else None
|
||||||
|
if not render_id:
|
||||||
|
logger.warning(f"[render_thumbnail] 렌더 ID 없음 - response: {body}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
for _ in range(max_polls):
|
||||||
|
status_result = await self.get_render_status(render_id)
|
||||||
|
status = status_result.get("status")
|
||||||
|
if status == "succeeded":
|
||||||
|
thumbnail_url = status_result.get("url")
|
||||||
|
logger.info(f"[render_thumbnail] 성공 - url: {thumbnail_url}")
|
||||||
|
return thumbnail_url
|
||||||
|
if status == "failed":
|
||||||
|
logger.warning(f"[render_thumbnail] 렌더 실패 - render_id: {render_id}")
|
||||||
|
return None
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"[render_thumbnail] 폴링 타임아웃 ({max_polls}회) - render_id: {render_id}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[render_thumbnail] 예외 발생 - {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_render_status(self, render_id: str) -> dict:
|
async def get_render_status(self, render_id: str) -> dict:
|
||||||
"""렌더링 작업의 상태를 조회합니다.
|
"""렌더링 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
|
|
@ -1110,6 +1245,50 @@ class CreatomateService:
|
||||||
return pitching_list
|
return pitching_list
|
||||||
|
|
||||||
|
|
||||||
|
# 썸네일 헤드라인 글자 수 범위. brand_concept이 셀링포인트 상세 설명에서 오면
|
||||||
|
# 문장이 길어져 헤드라인 폰트가 잘게 축소·여러 줄로 wrap되므로 훅 문구 길이로 제한.
|
||||||
|
# 첫 문장/단어 경계 절단 결과가 MIN 미만이면 더 긴 원문 기준으로 되돌려 보완한다.
|
||||||
|
THUMB_HEADLINE_MIN_CHARS = 5
|
||||||
|
THUMB_HEADLINE_MAX_CHARS = 15
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _truncate_thumbnail_headline(
|
||||||
|
cls,
|
||||||
|
text: str,
|
||||||
|
min_chars: int | None = None,
|
||||||
|
max_chars: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""썸네일 헤드라인 텍스트를 훅 문구 길이(5~15자)로 축약합니다.
|
||||||
|
|
||||||
|
1) 첫 문장(마침표/느낌표/물음표 기준)만 취하되, min_chars 미만이면 원문 유지
|
||||||
|
2) max_chars를 넘으면 단어 경계에서 잘라 말줄임표를 붙이되,
|
||||||
|
그 결과가 min_chars 미만이면 max_chars에서 하드 컷
|
||||||
|
"""
|
||||||
|
if min_chars is None:
|
||||||
|
min_chars = cls.THUMB_HEADLINE_MIN_CHARS
|
||||||
|
if max_chars is None:
|
||||||
|
max_chars = cls.THUMB_HEADLINE_MAX_CHARS
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
# 첫 문장만 취함. 구분자 뒤가 공백일 때만 문장 끝으로 간주 ("4.5점" 같은 소수점 오인 방지)
|
||||||
|
for sep in (".", "!", "?"):
|
||||||
|
idx = text.find(sep)
|
||||||
|
if 0 < idx < len(text) - 1 and text[idx + 1] == " ":
|
||||||
|
first_sentence = text[: idx + (0 if sep == "." else 1)].strip()
|
||||||
|
if len(first_sentence) >= min_chars:
|
||||||
|
text = first_sentence
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return text
|
||||||
|
|
||||||
|
cut = text[:max_chars]
|
||||||
|
if " " in cut:
|
||||||
|
word_cut = cut[: cut.rfind(" ")].rstrip(" ,.·")
|
||||||
|
if len(word_cut) >= min_chars:
|
||||||
|
return word_cut + "…"
|
||||||
|
return cut.rstrip(" ,.·") + "…"
|
||||||
|
|
||||||
def make_thumbnail_modification(self, brand_name : str, region : str, brand_concept : str, category_definition : str, target_keywords : list[str]):
|
def make_thumbnail_modification(self, brand_name : str, region : str, brand_concept : str, category_definition : str, target_keywords : list[str]):
|
||||||
|
|
||||||
len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3
|
len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3
|
||||||
|
|
@ -1120,7 +1299,7 @@ class CreatomateService:
|
||||||
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
|
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
|
||||||
"thumb-brand-wordmark" : brand_name,
|
"thumb-brand-wordmark" : brand_name,
|
||||||
"thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
|
"thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
|
||||||
"thumb-headline-hook_claim-aspirational" : brand_concept,
|
"thumb-headline-hook_claim-aspirational" : self._truncate_thumbnail_headline(brand_concept),
|
||||||
"thumb-badge-category" : category_definition,
|
"thumb-badge-category" : category_definition,
|
||||||
}
|
}
|
||||||
return mod_dict
|
return mod_dict
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,33 @@ class SpaceType(StrEnum):
|
||||||
photo_spot = auto()
|
photo_spot = auto()
|
||||||
seasonal_scene = auto()
|
seasonal_scene = auto()
|
||||||
walking_path = 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):
|
class Subject(StrEnum):
|
||||||
"""이미지 내 주요 피사체 유형. 화면에 무엇이 담겨 있는지를 분류하는 태그."""
|
"""이미지 내 주요 피사체 유형. 화면에 무엇이 담겨 있는지를 분류하는 태그."""
|
||||||
|
|
@ -94,6 +121,17 @@ class Subject(StrEnum):
|
||||||
# 관광지(DVAT0001) 템플릿 어휘
|
# 관광지(DVAT0001) 템플릿 어휘
|
||||||
scenery = auto()
|
scenery = auto()
|
||||||
structure = 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):
|
class Camera(StrEnum):
|
||||||
"""이미지의 촬영 기법·구도·조명 특성. 영상 편집 시 컷의 시각적 스타일을 구분하는 태그."""
|
"""이미지의 촬영 기법·구도·조명 특성. 영상 편집 시 컷의 시각적 스타일을 구분하는 태그."""
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,4 @@ class MarketingPromptOutput(BaseModel):
|
||||||
target_persona: List[TargetPersona] = Field(..., description="타겟 페르소나")
|
target_persona: List[TargetPersona] = Field(..., description="타겟 페르소나")
|
||||||
selling_points: List[SellingPoint] = Field(..., description="셀링 포인트")
|
selling_points: List[SellingPoint] = Field(..., description="셀링 포인트")
|
||||||
target_keywords: List[str] = Field(..., description="타겟 키워드 리스트")
|
target_keywords: List[str] = Field(..., description="타겟 키워드 리스트")
|
||||||
|
hook_headline: str = Field(..., description="썸네일 헤드라인용 훅 문구. 브랜드 컨셉/핵심 매력을 5자 이상 15자 이하의 한국어 한 구절로 축약 (마침표·해시태그 없이, 예: '바다 앞 프라이빗 휴식')") # min/max constraint는 openai json schema에서 미작동 보고가 있어 description으로 유도, 소비처에서 길이 안전망 적용
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -32,6 +32,9 @@ from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
|
||||||
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
|
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.comment.models import Comment
|
||||||
from app.database.like_cache import (
|
from app.database.like_cache import (
|
||||||
|
|
@ -57,6 +60,7 @@ from app.video.schemas.video_schema import (
|
||||||
VideoRenderData,
|
VideoRenderData,
|
||||||
VideoThumbnailItem,
|
VideoThumbnailItem,
|
||||||
)
|
)
|
||||||
|
from app.video.worker.thumbnail_task import generate_thumbnail_background
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -125,6 +129,7 @@ curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5
|
||||||
)
|
)
|
||||||
async def generate_video(
|
async def generate_video(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
orientation: Literal["horizontal", "vertical"] = Query(
|
orientation: Literal["horizontal", "vertical"] = Query(
|
||||||
default="vertical",
|
default="vertical",
|
||||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
||||||
|
|
@ -221,10 +226,24 @@ async def generate_video(
|
||||||
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
|
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
|
||||||
target_keywords = marketing_intelligence.intel_result["target_keywords"]
|
target_keywords = marketing_intelligence.intel_result["target_keywords"]
|
||||||
|
|
||||||
brand_concept = ""
|
# 썸네일 헤드라인(thumb-headline-hook_claim-aspirational)용 문구 추출.
|
||||||
for sp in marketing_intelligence.intel_result["selling_points"]:
|
# 1순위: 마케팅 분석 시 LLM이 5~15자로 축약 생성한 hook_headline (신규 데이터)
|
||||||
if "concept" in sp["english_category"].lower():
|
# 폴백: hook_headline이 없는 구 데이터는 concept 셀링포인트 → core_value →
|
||||||
brand_concept = sp["description"]
|
# 최고점 셀링포인트 순으로 추출 (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 조회
|
||||||
lyric_result = await session.execute(
|
lyric_result = await session.execute(
|
||||||
|
|
@ -336,6 +355,19 @@ async def generate_video(
|
||||||
)
|
)
|
||||||
# 세션이 여기서 자동으로 닫힘 (async with 블록 종료)
|
# 세션이 여기서 자동으로 닫힘 (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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -360,6 +392,7 @@ async def generate_video(
|
||||||
creatomate_service = CreatomateService(
|
creatomate_service = CreatomateService(
|
||||||
orientation=orientation,
|
orientation=orientation,
|
||||||
industry=industry,
|
industry=industry,
|
||||||
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
|
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)
|
final_template["source"]["elements"].append(caption)
|
||||||
# END - LYRIC AUTO 결정부
|
# 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(
|
# logger.debug(
|
||||||
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
# 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,
|
video_id=v.id,
|
||||||
store_name=p.store_name,
|
store_name=p.store_name,
|
||||||
result_movie_url=v.result_movie_url,
|
result_movie_url=v.result_movie_url,
|
||||||
|
thumbnail_url=v.video_thumbnail_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),
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,12 @@ class Video(Base):
|
||||||
comment="생성된 영상 URL",
|
comment="생성된 영상 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
video_thumbnail_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(2048),
|
||||||
|
nullable=True,
|
||||||
|
comment="영상 생성 시 백그라운드로 렌더된 썸네일 이미지 URL (실패/미생성 시 NULL)",
|
||||||
|
)
|
||||||
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
Boolean,
|
Boolean,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ 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")
|
||||||
|
thumbnail_url: Optional[str] = Field(None, description="백그라운드 렌더된 썸네일 이미지 URL (세로형만, 실패/미생성 시 null)")
|
||||||
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="댓글 수 (대댓글 포함)")
|
||||||
|
|
@ -171,7 +172,8 @@ 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 — thumbnail_url 없을 때 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
|
||||||
|
thumbnail_url: Optional[str] = Field(None, description="백그라운드 렌더된 썸네일 이미지 URL (세로형만, 실패/미생성 시 null)")
|
||||||
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)")
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ async def generate_creative_assets_background(
|
||||||
)
|
)
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
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)
|
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
||||||
|
|
||||||
store_address = project.detail_region_info
|
store_address = project.detail_region_info
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue