diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index f78e3e8..1631d0e 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -574,8 +574,15 @@ class CreatomateService: 다운로드 실패를 배제로 오판해 풀 전체가 날아가는 것을 막기 위함. 일반 씬 슬롯은 태그 점수를 그대로 반환한다. """ - scores = self.calculate_image_slot_score_multi(pool_subset, slot) - if not thumbnail_fitness_map or not slot.endswith(THUMBNAIL_SLOT_MARKER): + is_thumbnail = 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 adjusted = [] @@ -589,6 +596,31 @@ class CreatomateService: adjusted.append(score * fitness["score"]) 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( self, template: dict, @@ -607,11 +639,7 @@ class CreatomateService: 후보가 없는 슬롯은 키 자체를 포함하지 않는다. """ component = self.parse_template_component_name(template["source"]["elements"]) - thumbnail_slots = [ - 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 - ] + thumbnail_slots = self._collect_thumbnail_slots(component) result: dict[str, list[dict]] = {} for slot in thumbnail_slots: scores = self._slot_scores_with_fitness(taged_image_list, slot, thumbnail_fitness_map) @@ -669,9 +697,13 @@ class CreatomateService: # 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지 # 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고 # 무작위 이미지로 덮어써진다. + # 썸네일 슬롯(-9999)은 파싱 실패해도 배정해야 하므로 여기서 제외하고 + # _collect_thumbnail_slots가 별도로 수집한다. image_slots = [ 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"] @@ -686,8 +718,7 @@ class CreatomateService: # thumbnail_choice(비전 LLM 최종 선택)가 해당 슬롯에 있으면 그 이미지를, # 없으면(선택 실패/미제공) 결정론적 최고점 컷을 사용한다 — 폴백 안전. thumbnail_choice = thumbnail_choice or {} - thumbnail_slots = [s for s in image_slots if s.endswith(THUMBNAIL_SLOT_MARKER)] - image_slots = [s for s in image_slots if not s.endswith(THUMBNAIL_SLOT_MARKER)] + thumbnail_slots = self._collect_thumbnail_slots(template_component_data) for slot in thumbnail_slots: if not pool: logger.warning(f"[template_matching_taged_image] 이미지 풀 없음 — 썸네일 슬롯 배정 불가: {slot}") @@ -885,7 +916,13 @@ class CreatomateService: """슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다. 슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative} - 파싱 실패 시 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리). + + 위치 기반 파싱에 실패하면 토큰 위치를 무시한 대조로 한 번 더 시도한다 + (_parse_slot_name_loosely). 슬롯명 오타로 슬롯이 배정에서 빠지면 + modify_element가 템플릿 원본(샘플 이미지)을 그대로 남겨 완성 영상에 + 그대로 나가기 때문이다. + + 둘 다 실패하면 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리). """ try: tag_list = slot_name.split("-") @@ -907,9 +944,64 @@ class CreatomateService: } return tag_dict 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") 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( self, elements: list,