Skip to content

Commit 24ef443

Browse files
wjdtjddns98claude
andauthored
fix(video): 먹는 방향 역전 수정 — endframe lock이 간식을 그릇으로 되돌리고 있었다 (#128)
PO 실측(2026-07-31 run29): "주워서 먹는게 아니라 입에서 그릇으로 내려간다". 원인은 프롬프트 문구가 아니라 **endframe lock과 먹기 동작의 구조적 충돌**이다. lock은 클립의 시작·끝을 같은 '안 먹는' 앵커 프레임으로 묶는다 — 그러면 중간에 먹은 간식을 끝에서 원상복구해야 하므로, Veo가 그걸 입에서 그릇으로 도로 내려놓는 역방향 동작으로 만든다. 먹는 걸 모르는 게 아니라 먹은 상태로 끝낼 수가 없는 구조였다. - eating=bool(food)면 use_lock=False로 lock을 끄고 체이닝 경로(끝 안정 프레임 → 다음 비트 시작 프레임)로 경계를 잇는다. 생성·QC·체이닝 분기가 모두 use_lock을 따른다. - motion_release=use_lock or eating — 먹방은 정적 _MOTION_HOLD 대신 생동 모션 ('한 번의 앞발 제스처'가 곧 집어 먹기). - _EATING_TEMPLATE에 먹는 **방향**을 명시: 앞발로 그릇에서 한 조각 집어 → 입으로. "food never appears out of thin air"로 공중 생성 환각도 이중 방어. - beat_food = food if i == 1 else "" — 2번 비트부터 그릇을 다시 그리지 않는다(안 그러면 먹은 간식이 도로 차오른다). - 회귀 가드 추가: food 있으면 전 비트 lock=False + 경계마다 체이닝 1회, food 없으면 기존 lock=True 유지(하위호환). PR #121에서 같은 진단으로 고쳤던 부분을 직립 없이 이식했다 — #126 revert가 직립과 함께 이 수정까지 되돌렸던 것이다. ruff + pytest 505 passed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6b30e8c commit 24ef443

2 files changed

Lines changed: 112 additions & 18 deletions

File tree

nutti/integrations/video.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -681,13 +681,20 @@ class VeoPromptBuilder:
681681
# 붕괴라 금지, 클립당 한 입 제한(팔흔들기 1회 제한과 같은 과도 샘플링 방어).
682682
# ponytail: 씹는 소리(ASMR)는 _VOICE의 무음 정책·발화 끝 트림 로직과 충돌해 비주얼만
683683
# — 소리까지 원하면 트림 로직 개편이 선행돼야 한다.
684+
# ⚠️ 이 "집어 먹는" 실제 동작은 endframe lock과 공존할 수 없다 — lock은 시작·끝을 같은
685+
# '안 먹는' 앵커 프레임으로 묶으므로, 중간에 먹으면 끝에서 원상복구를 해야 한다. 그래서
686+
# Veo가 **입에서 그릇으로 도로 내려놓는 역방향 동작**을 만든다(2026-07-31 PO 실측:
687+
# "주워서 먹는게 아니라 입에서 그릇으로 내려간다"). food가 있으면
688+
# _produce_clips_veo_fal이 lock을 끄고 체이닝으로 돌린다(끝 안정 프레임을 다음 비트
689+
# 시작 프레임으로). 아래 "food never appears out of thin air"는 같은 환각의 이중 방어다.
684690
_EATING_TEMPLATE = (
685-
"A small snack bowl with {food} sits right in front of the puppy. The very "
686-
"first action of the clip is the puppy taking one quick, nonchalant bite of the "
687-
"snack, chewing it briefly with an unimpressed face — this opening bite counts "
688-
"as the immediate lively start of the clip — and then it immediately starts "
689-
"speaking. At most this one bite in the whole clip, never chewing or holding "
690-
"food while speaking words, and the food never blocks or covers its face."
691+
"A small snack bowl with {food} sits right in front of the puppy. At the very "
692+
"start of the clip, the puppy uses one front paw to pick up a single piece of "
693+
"the snack from the bowl and bring it up to its mouth, calmly eats that one "
694+
"piece, and then starts speaking. The snack it eats is always the food already "
695+
"in the bowl — food never appears out of thin air or drops in from off-screen. "
696+
"Only this one piece is eaten in the whole clip; once it is speaking it is no "
697+
"longer chewing or holding food, and the food never blocks or covers its face."
691698
)
692699
# 마지막 비트(CTA) 전용 음성 앵커 — CTA 대사가 권유·느낌표 톤이라 Veo가 음성을 더
693700
# 들뜨거나 아나운서처럼 바꾸는 경향이 강하다(2026-06-29 PO 실측). 마지막 비트
@@ -906,6 +913,17 @@ def _produce_clips_veo_fal(
906913
# 하도록 first/last 프레임을 frame_path로 고정한다 — 클립이 같은 포즈로 시작·끝나
907914
# 비트 경계가 항상 동일 프레임에서 만나 끊김이 없다. 체이닝(끝 프레임 추출)은 불요.
908915
lock = bool(self.settings.veo_fal_endframe_lock)
916+
# 먹방(food 있음): 앞발로 간식을 집어 입에 넣는 실제 동작이 필요한데, 이 동작은
917+
# endframe lock과 충돌한다 — lock이 시작·끝을 같은 '안 먹는' 앵커로 묶으면 Veo가
918+
# 먹은 상태로 끝낼 수 없어 **입에서 그릇으로 도로 내려놓는 역방향 동작**을 만든다
919+
# (2026-07-31 PO 실측). 그래서 먹방은 lock을 끄고 체이닝(끝 안정 프레임 → 다음 시작
920+
# 프레임)으로 경계를 잇고, 정적(_MOTION_HOLD) 대신 생동 모션을 준다
921+
# (motion_release=True: '한 번의 앞발 제스처'가 곧 집어 먹기).
922+
# food 없는 편(휴면 포맷 폴백)은 기존 lock 동작 그대로(하위호환).
923+
# ponytail: 체이닝 경계는 lock보다 매끄러움이 덜할 수 있다 — 유사도 스티칭
924+
# (_find_similarity_cuts)이 완화하고, 안 맞는 경계만 디졸브 2배로 가린다.
925+
eating = bool(food)
926+
use_lock = lock and not eating
909927
# 영상 내 모든 비트(n1~n4)에 같은 seed를 줘 음색/비주얼 편차를 줄인다(2026-06-29 PO:
910928
# 음색 일관성 보강). 설정값(veo_fal_seed)이 없으면 이 영상용 seed 1개를 뽑아 모든 비트에
911929
# 재사용한다 — 영상 내 일관, 영상 간 다양성 유지. Veo가 seed로 오디오를 완전 통제하진
@@ -926,18 +944,22 @@ def _produce_clips_veo_fal(
926944
# KR "AI 강아지 인터뷰" 유행에 맞춰 로테이션으로 부활.)
927945
# lock 모드는 끝 프레임이 모델로 고정되므로 모션 제약을 풀어(_MOTION_LIVELY)
928946
# 생동감을 준다(2026-06-29 PO). 기본 image-to-video 경로는 _MOTION_HOLD 유지.
947+
# 먹방은 첫 비트에서만 집어 먹는다. 2번 비트부터 food 텍스트를 빼지 않으면
948+
# 매 비트가 "간식 가득한 그릇"을 다시 렌더해 먹은 간식이 도로 차오른다
949+
# (PO 실측). 그릇의 시각적 연속성은 체이닝된 끝 프레임이 잇는다.
950+
beat_food = food if i == 1 else ""
929951
prompt = builder.build_beat(
930952
beat,
931953
off_screen_interviewer=(style.fmt == "interview"),
932954
style=style,
933-
motion_release=lock,
955+
motion_release=use_lock or eating,
934956
final_cta=(i == len(beats)),
935-
food=food,
957+
food=beat_food,
936958
)
937959
# 생성 + 끝 잉여 고정 트림(글리치 온상 제거, 8초→약7초, 2026-06-29 PO).
938960
# QC 재생성이 같은 단계를 다시 밟도록 헬퍼로 묶었다.
939961
clip_path = self._generate_and_trim_clip(
940-
client, prompt, current_frame, frame_path, lock, video_seed
962+
client, prompt, current_frame, frame_path, use_lock, video_seed
941963
)
942964
# 클립 QC 레이어(2026-07-07 PO): 중간 프리즈·블랙·무발화·꼬리 미수렴을
943965
# 잡아 그 비트만 재생성한다. 상한(qc_max_retries) 초과 시 현행 트림·마스킹
@@ -946,7 +968,9 @@ def _produce_clips_veo_fal(
946968
# 없고 모션도 자유(_MOTION_FINAL_FREE)라 수렴 실패가 결함이 아니다.
947969
# 6차 런 실측: 마지막 비트가 tail_not_converged로 2회 재생성($0.8 낭비).
948970
final_beat = i == len(beats)
949-
reasons = self._qc_check_beat(clip_path, frame_path, lock, final_beat=final_beat)
971+
reasons = self._qc_check_beat(
972+
clip_path, frame_path, use_lock, final_beat=final_beat
973+
)
950974
attempt = 0
951975
while reasons and attempt < self.settings.qc_max_retries:
952976
attempt += 1
@@ -959,10 +983,10 @@ def _produce_clips_veo_fal(
959983
# 음색 seed 일관성보다 결함 제거가 우선(2026-07-10, 텍스트 QC와 함께).
960984
retry_seed = (video_seed + attempt) % (2**31)
961985
clip_path = self._generate_and_trim_clip(
962-
client, prompt, current_frame, frame_path, lock, retry_seed
986+
client, prompt, current_frame, frame_path, use_lock, retry_seed
963987
)
964988
reasons = self._qc_check_beat(
965-
clip_path, frame_path, lock, final_beat=final_beat
989+
clip_path, frame_path, use_lock, final_beat=final_beat
966990
)
967991
if reasons:
968992
log.info("video.veo_fal.qc.fallback", beat=i, reasons=reasons)
@@ -972,7 +996,7 @@ def _produce_clips_veo_fal(
972996
# 다음 시작 프레임으로 쓴다. 추출·품질 가드(검정/빈/가로) 실패 시 None → 원본
973997
# 마스코트 프레임으로 안전 폴백(망가진 프레임이 다음 클립에 누적되지 않게 하는
974998
# 핵심 가드). lock 모드는 끝프레임을 frame_path로 고정하므로 체이닝하지 않는다.
975-
if not lock and i < len(beats):
999+
if not use_lock and i < len(beats):
9761000
chained = self._chain_frame(clip_path)
9771001
if chained is not None:
9781002
chain_frames.append(chained)

tests/test_video.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,13 +1584,22 @@ def test_produce_clips_direct_format_keeps_mic_out_of_beat_prompts(tmp_path, mon
15841584

15851585

15861586
def test_build_beat_food_adds_bowl_and_single_bite():
1587-
"""food가 오면 간식 그릇+한 입 먹방 연출이 붙고, 비면 붙지 않는다(하위호환)."""
1587+
"""food가 오면 간식 그릇+집어 먹기 연출이 붙고, 비면 붙지 않는다(하위호환).
1588+
1589+
2026-07-31 PO 실측("주워서 먹는게 아니라 입에서 그릇으로 내려간다"): 먹는 **방향**이
1590+
프롬프트에 명시돼야 한다 — 앞발로 그릇에서 집어 입으로 가져가는 순서. 방향 문구가
1591+
빠지면 endframe lock 해제만으로는 역방향 동작이 다시 나올 수 있다.
1592+
"""
15881593
b = VeoPromptBuilder()
15891594
style = EpisodeStyle("a sporty grey hoodie", "sitting on a sofa", "", "mukbang")
15901595
p = b.build_beat("대사", style=style, food="golden baked sweet potato sticks")
15911596
assert "snack bowl with golden baked sweet potato sticks" in p
1592-
assert "one quick, nonchalant bite" in p
1593-
assert "never chewing or holding food while speaking" in p # 립싱크 보호
1597+
# 방향: 그릇 → 앞발 → 입.
1598+
assert "pick up a single piece" in p
1599+
assert "bring it up to its mouth" in p
1600+
# 공중 생성 환각 방어(같은 뿌리의 이중 방어).
1601+
assert "never appears out of thin air" in p
1602+
assert "no longer chewing or holding food" in p # 립싱크 보호
15941603
p2 = b.build_beat("대사", style=style)
15951604
assert "snack bowl" not in p2
15961605

@@ -1605,15 +1614,76 @@ def test_frame_prompt_includes_food_bowl_matching_beats():
16051614

16061615

16071616
def test_produce_clips_passes_food_into_beat_prompts(tmp_path, monkeypatch):
1608-
"""produce 경로가 Script.food_visual을 전 비트 프롬프트에 배선한다(리버트 가드)."""
1617+
"""produce 경로가 Script.food_visual을 **첫 비트** 프롬프트에만 배선한다(리버트 가드).
1618+
1619+
2026-07-31: 전 비트에 넣으면 매 비트가 "간식 가득한 그릇"을 다시 렌더해 먹은 간식이
1620+
도로 차오른다(PO 실측). 그릇의 시각적 연속성은 체이닝된 끝 프레임이 잇는다.
1621+
"""
16091622
prompts: list[str] = []
16101623
studio = _wiring_capture_studio(tmp_path, monkeypatch, prompts)
16111624
style = EpisodeStyle("a sporty grey hoodie", "sitting on a sofa", "", "mukbang")
16121625
studio._produce_clips_veo_fal(
16131626
"frame.png", ["비트1", "비트2"], style, food="fresh carrot sticks"
16141627
)
16151628
assert len(prompts) == 2
1616-
assert all("snack bowl with fresh carrot sticks" in p for p in prompts)
1629+
assert "snack bowl with fresh carrot sticks" in prompts[0]
1630+
assert "snack bowl" not in prompts[1], "2번 비트부터는 그릇을 다시 그리지 않는다"
1631+
1632+
1633+
def test_produce_clips_food_unlocks_and_chains(tmp_path, monkeypatch):
1634+
"""먹방(food 있음)은 endframe lock을 끄고 체이닝으로 돌린다.
1635+
1636+
lock은 시작·끝을 같은 '안 먹는' 앵커로 묶으므로, 켜진 채로 먹으면 Veo가 먹은 상태로
1637+
끝낼 수 없어 **입에서 그릇으로 도로 내려놓는 역방향 동작**을 만든다(2026-07-31 PO
1638+
실측: "주워서 먹는게 아니라 입에서 그릇으로 내려간다"). food 없는 편은 기존 lock
1639+
동작 그대로(하위호환).
1640+
"""
1641+
clip = tmp_path / "c.mp4"
1642+
clip.write_bytes(b"c")
1643+
stitched = tmp_path / "s.mp4"
1644+
stitched.write_bytes(b"s")
1645+
locks: list[bool] = []
1646+
chained = {"n": 0}
1647+
1648+
monkeypatch.setattr(
1649+
VideoStudio,
1650+
"_generate_and_trim_clip",
1651+
lambda self, client, prompt, cf, fp, lock, seed: (locks.append(lock), str(clip))[1],
1652+
raising=True,
1653+
)
1654+
monkeypatch.setattr(
1655+
VideoStudio, "_qc_check_beat", lambda self, c, f, lock, final_beat=False: [], raising=True
1656+
)
1657+
monkeypatch.setattr(
1658+
VideoStudio,
1659+
"_chain_frame",
1660+
lambda self, c: (chained.__setitem__("n", chained["n"] + 1), None)[1],
1661+
raising=True,
1662+
)
1663+
monkeypatch.setattr(VideoStudio, "_trim_to_speech", lambda self, c: (c, 7.0), raising=True)
1664+
monkeypatch.setattr(
1665+
VideoStudio, "_find_similarity_cuts", lambda self, *a, **kw: None, raising=True
1666+
)
1667+
monkeypatch.setattr(
1668+
VideoStudio, "_stitch", lambda self, clips, durs=None, **kw: str(stitched), raising=True
1669+
)
1670+
# endframe_lock 기본 True 확인(하위호환 대조군의 전제).
1671+
settings = _live_settings_with_key(NUTTI_MEDIA_DIR=str(tmp_path))
1672+
assert settings.veo_fal_endframe_lock is True
1673+
studio = VideoStudio(settings, veo_fal_client=object())
1674+
style = EpisodeStyle("a sporty grey hoodie", "sitting on a sofa", "", "mukbang")
1675+
1676+
# food 있음 → 전 비트 lock=False + 비트 경계마다 체이닝 1회(2비트 → 1경계).
1677+
studio._produce_clips_veo_fal("frame.png", ["비트1", "비트2"], style, food="fresh carrot sticks")
1678+
assert locks == [False, False]
1679+
assert chained["n"] == 1
1680+
1681+
# food 없음 → 기존 lock=True 유지, 체이닝 없음.
1682+
locks.clear()
1683+
chained["n"] = 0
1684+
studio._produce_clips_veo_fal("frame.png", ["비트1", "비트2"], style)
1685+
assert locks == [True, True]
1686+
assert chained["n"] == 0
16171687

16181688

16191689
def test_frame_shots_are_sassy_and_ascii_safe():

0 commit comments

Comments
 (0)