From a253c5dd3a4f3c08d81e06794811dec54c5c598f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Thu, 16 Jul 2026 15:44:24 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/utils/creatomate.py | 69 ++++++++++++++++++------ app/utils/prompts/schemas/image.py | 38 +++++++++++++ app/video/api/routers/v1/video.py | 6 --- app/video/worker/creative_assets_task.py | 2 +- 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 7a1554c..54aa6d3 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -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,17 +1150,16 @@ 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 hashtaged_target_keywords = [f"#{tk}" for tk in target_keywords[:len_keywords]] - + 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 diff --git a/app/utils/prompts/schemas/image.py b/app/utils/prompts/schemas/image.py index 994cc83..cf0f663 100644 --- a/app/utils/prompts/schemas/image.py +++ b/app/utils/prompts/schemas/image.py @@ -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): """이미지의 촬영 기법·구도·조명 특성. 영상 편집 시 컷의 시각적 스타일을 구분하는 태그.""" diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index ddb3f8a..c664ed9 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -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) diff --git a/app/video/worker/creative_assets_task.py b/app/video/worker/creative_assets_task.py index 4f87732..b94729c 100644 --- a/app/video/worker/creative_assets_task.py +++ b/app/video/worker/creative_assets_task.py @@ -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