fix(creatomate): 슬롯명이 명명 규칙을 어겨도 썸네일·태그 배정이 폴백되도록 수정

This commit is contained in:
김성경 2026-08-12 15:31:02 +09:00
parent b04e239344
commit 4c4297c37a

View File

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