썸네일 적용

feature-meta
김성경 2026-07-16 15:44:24 +09:00
parent 7ec9e9749d
commit a253c5dd3a
4 changed files with 93 additions and 22 deletions

View File

@ -229,10 +229,15 @@ DVST0001 = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
DVRT0001 = "3c4b74c4-82d7-4742-9b05-4b999fef4cbd"
DVCF0001 = "ebb532ec-e49a-4a7e-b0e2-a8132f1bf1f7"
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"
HST_LIST = [DHST0001]
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001]
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001,DVSL0001,DVCL0001,DVFT0001,DVAC0001]
# industry → 세로형 템플릿 매핑 (신규 세로형 템플릿 추가 시 여기에만 등록)
VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
@ -240,9 +245,13 @@ VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
"restaurant": DVRT0001,
"cafe": DVCF0001,
"attraction": DVAT0001,
"salon": DVSL0001,
"clinic": DVCL0001,
"fitness": DVFT0001,
"academy": DVAC0001,
}
# 매핑에 없는 업종(salon, clinic 등)의 폴백 템플릿
# 매핑에 없는 업종(general)의 폴백 템플릿
DEFAULT_VERTICAL_TEMPLATE = DVST0001
# 템플릿 슬롯명의 모션 동의어 → MotionRecommended 표준값 정규화 맵.
@ -264,13 +273,21 @@ AUDIO_TRACK = 2
SUBTITLE_TRACK = 3
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를 선택합니다.
Args:
orientation: 영상 방향 ("horizontal" 또는 "vertical")
industry: 업종 분류 (stay|restaurant|cafe|attraction ).
세로형에서만 사용되며, 매핑에 없으면 기본 템플릿으로 폴백합니다.
세로형에서 매핑에 있으면 전용 템플릿을 반환합니다.
project_id: 미매핑 업종(general ) 세로형 분배용 .
project_id % len(VST_LIST) 결정적으로 선택하므로, 같은
프로젝트는 호출해도 동일 템플릿을 받아 사전/렌더 단계가
일치한다. 없으면 DEFAULT_VERTICAL_TEMPLATE로 폴백한다.
Returns:
선택된 템플릿 ID
@ -278,10 +295,17 @@ def select_template(orientation: OrientationType, industry: str | None = None) -
if orientation == "horizontal":
template_id = DHST0001
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:
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
async def get_shared_client() -> httpx.AsyncClient:
@ -332,6 +356,7 @@ class CreatomateService:
api_key: str | None = None,
orientation: OrientationType = "vertical",
industry: str | None = None,
project_id: int | None = None,
):
"""
Args:
@ -339,13 +364,15 @@ class CreatomateService:
None일 경우 config에서 자동으로 가져옴
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
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.orientation = orientation
# orientation·industry에 따른 템플릿 설정 가져오기
self.template_id = select_template(orientation, industry=industry)
self.template_id = select_template(orientation, industry=industry, project_id=project_id)
self.headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
@ -624,14 +651,20 @@ class CreatomateService:
_NARR_BONUS = 1.5 # narrative 독립 보너스: base=0이어도 narrative 신호가 점수에 남음
_NARR_MIN = 0.0 # narrative_preference 값 clamp 하한 (무경계 스키마 방어)
_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):
"""이미지 슬롯에 대한 각 이미지의 매칭 점수를 계산합니다.
점수 = base * narr_mod + NARR_BONUS * narr
점수 = (base * narr_mod + NARR_BONUS * narr) * space_type_penalty
- base: 태그 카테고리 매칭 합산 (최대 5.5)
- narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수
- 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)" 구별할 있음.
"""
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
@ -642,6 +675,11 @@ class CreatomateService:
base_score_list = [0.0] * len(image_tag_list)
# 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():
if slot_tag_cate == "narrative_preference":
@ -674,6 +712,8 @@ class CreatomateService:
narr = clamped_narr / self._NARR_MAX # 0.0~1.0로 정규화
narr_mod = self._NARR_FLOOR + (1.0 - self._NARR_FLOOR) * 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)
return image_score_list
@ -1110,7 +1150,7 @@ class CreatomateService:
return pitching_list
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, category_definition : str, target_keywords : list[str]):
len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3
@ -1118,9 +1158,8 @@ class CreatomateService:
mod_dict = {
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
"thumb-brand-wordmark" : brand_name,
"thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
"thumb-headline-hook_claim-aspirational" : brand_concept,
"thumb-headline-brand_name-factual" : brand_name,
"thumb-subheadline-local_info-factual" : normalize_location(region),
"thumb-badge-category" : category_definition,
}
return mod_dict

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

@ -221,11 +221,6 @@ 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"]
# Lyric 조회
lyric_result = await session.execute(
select(Lyric)
@ -391,7 +386,6 @@ async def generate_video(
thumbnail_modifications = creatomate_service.make_thumbnail_modification(
brand_name =brand_name,
region = region,
brand_concept = brand_concept,
category_definition= category_definition,
target_keywords=target_keywords)

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