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)
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,