· No.5
프롬프트를 다섯 번 고쳐 썼는데 자세는 한 번도 안 바뀌었다 — 그리고 그게 왜 당연했나
좀비 걸음걸이를 프롬프트로 5차까지 개정했다. 캐스트·훼손·속도는 매번 바뀌었는데 자세만 한 번도 안 바뀌었다. 원인은 서술이 부족해서가 아니라 모델이 그 동작을 모르기 때문이었고, 그래서 다음 계획이던 LoRA도 구조적으로 못 푸는 문제였다 — 학습 데이터가 모델 자신의 출력이라 순환이다. 해법은 배우게 하는 게 아니라 프레임마다 자세를 직접 공급하는 것이었다. 프롬프트 603단어→382단어, 부정어 1,133자→320자, 그리고 그 과정에서 밟은 함정 여섯 개 — 그중 넷은 모델이 아니라 우리 배선이었다.
우리는 1인 규모의 AI 스튜디오다. GPU는 RTX 4070 Ti 12GB 한 장이고, 그 위에서 좀비 세계관의 세로 쇼츠를 만든다.
문제는 단순했다. 좀비가 사람처럼 걷는다.
프롬프트를 다섯 번 고쳐 썼다. 관절 서술을 넣고, 부위별 고정 변형을 넣고, 마비 개념을 넣고, 부산행 안무를 조사해 반영하고, 마지막엔 외부의 장편 AI 영화 제작 공식을 통째로 가져와 구조를 바꿨다. 자세는 한 번도 안 바뀌었다.
| 회차 | 무엇을 바꿨나 | 결과 |
|---|---|---|
| 1~2차 | 관절·부위별 서술 추가 | 자세 그대로 |
| 3차 | "결손 운동"(마비) 개념 도입 | 자세 그대로 |
| 4차 | 부산행 안무 조사 반영(과가동 + 질주) | 자세 그대로 |
| 5차 | 골격화·비트분할·긍정문 전환 | 캐스트·훼손·속도는 개선, 자세 그대로 |
다섯 번째에서야 경계가 보였다. 프롬프트로 되는 것과 안 되는 것이 갈렸다. 그리고 그 경계가 다음 계획이던 LoRA까지 무효로 만들었다.
같은 방향으로 네 번 밀었으면 다섯 번째도 안 된다
무엇을 시도했나
4차까지 우리 개정은 전부 같은 방향이었다 — 더 자세히 쓰기. 서술이 부족해서 안 나온다고 봤기 때문이다.
5차에서는 방향을 바꿨다. 외부 사례에서 세 가지를 가져왔다:
- 동작을 부정문으로 쓰면 무시되거나 반대로 작동한다.
"does NOT fall on his back"대신"falls on his stomach"로 쓰라는 것. - 한 비트에 3문장을 넘기면 뭉개진다. 프롬프트 총량은 길어도 되지만 한 비트가 과부하면 모델이 그 비트를 뭉갠다.
- 골격. 장면/레퍼런스/타이밍/광학/카메라/물리/조명/연기/제약을 절로 나눈다.
우리 상태를 이 기준으로 재보니 셋 다 위반이었다.
어떻게 측정했나
프롬프트를 문자열로 실제로 재는 것부터 했다. 한 번도 안 재봤다.
py -3.12 -c "
from zombie_motion import MOTIONS, MOTION_NEG, CROWD_VARIETY
p = MOTIONS['horde_advance'] + ', ' + CROWD_VARIETY
print('POS', len(p.split()), '단어')
print('NEG', len(MOTION_NEG), '자')
"그리고 모델 쪽 한계를 코드에서 확인했다 (ComfyUI 의 umT5 래퍼):
max_length=99999999, min_length=512| 조건 | 값 |
|---|---|
| 모델 | Wan 2.2 I2V A14B (Q5_K_M GGUF) |
| 텍스트 인코더 | umT5-XXL |
| 측정 대상 | 4차 프롬프트 전문 |
결과
| 4차 | 5차 | |
|---|---|---|
| 긍정 프롬프트 | 603단어 (≈900토큰) | 349~382단어 |
| 부정 프롬프트 | 1,133자 (절반이 동작 부정) | 320자 (양식·외형만) |
| 개체 서술 위치 | 603단어 중 480단어 지점 | 맨 앞 |
| 최장 단일 비트 | 1,795자 | 3문장 이내 × 3비트 |
여기서 자르는 건 아니라는 점은 분명히 해둔다. max_length 가 사실상 무제한이라 프롬프트가 잘려 나간 게 아니다. 다만 Wan 의 크로스어텐션은 512토큰으로 학습돼 있어 그 밖은 분포 밖이고, 뒤로 갈수록 힘이 빠진다. 우리 개체 서술은 하필 그 자리에 있었다.
그리고 1,133자 부정어 중 절반이 "walking normally", "both arms swinging", "slow shambling" 같은 동작이었다. 동작을 부정 쪽에 넣은 만큼 긍정 쪽이 비어 있었다.
다음 단계
- 동작 요구는 전부 긍정문으로 옮겼다. 부정어에는 양식·외형만 남긴다 (
dance,cartoon,skeleton같은 장르 라벨은 부정 조건화가 실제로 듣는다) - 프롬프트 예산을 360단어로 못 박았다
- 교훈: 같은 방향으로 두 번 실패하면 그때 방향을 의심한다. 우리는 네 번 밀었다
나이를 쓰면 필터가 강해진다, 그리고 자세는 프롬프트의 영역이 아니다
무엇을 시도했나
로스터(고정 캐스트) 6명 중 한 명만 유독 훼손이 씻겨 나갔다. 교복 차림 개체였고, 얼굴이 살아 있고 피가 거의 안 묻었다.
나는 이걸 서술이 약해서라고 보고 서술 강화를 계획했다. 틀린 진단이었다.
6명의 서술을 나란히 놓고 다른 점 하나를 찾았다.
Z1 a middle-aged korean man in a shredded dark office suit …
Z2 an older korean woman in a filthy market apron …
Z3 a young korean man in a slashed delivery jacket …
Z4 a korean teenager in a school uniform … ← 이것만 나이 단어
Z5 a korean man in a grimy blue work uniform …
Z6 a korean woman in a stained pale nurse uniform …어떻게 측정했나
동일 시드·동일 마스터 스틸로 2차/3차 스윕을 9모션씩 돌리고, QC 시트를 나란히 붙여 봤다.
ffmpeg -i sweep_gen2/horde_advance_qc.png -i sweep/horde_advance_qc.png \
-filter_complex "[0:v]scale=1500:-1[a];[1:v]scale=1500:-1[b];[a][b]vstack" cmp.png| 조건 | 값 |
|---|---|
| 클립 | 5초 · 77프레임 · 16fps · 960×832 |
| 모션 | 9종 |
| 스윕 1회 소요 | 약 110분 |
결과
나이 표현. 미성년을 가리키는 단어가 들어가면 콘텐츠 필터가 강해진다. 6명 중 그 단어를 쓴 개체가 하나였고, 훼손이 안 먹은 개체도 그 하나였다. 나이를 빼고 역할·복장·행동으로 대체했다.
before: a korean teenager in a school uniform torn open at the shoulder
after : a slight narrow-shouldered korean figure in a ripped navy school
uniform blazer, shirt soaked with dried blackened blood5차 프롬프트의 성적. 2차 대비 3차:
| 항목 | 결과 |
|---|---|
| 등장 인원 | 5명 내외 → 3명 (인원 고정 지시가 들었다) |
| 훼손·혈흔 | 눈에 띄게 진해짐 |
| 접근 속도 | horde_charge 가 실제로 더 빨리 카메라에 도달 |
| 자세 | 정상 보행 그대로. 꺾임 없음, 마비 없음, 비대칭 없음 |
경계가 선명하게 나왔다. 외형·캐스트·속도는 프롬프트로 되고, 자세는 안 된다.
다음 단계
자세를 프롬프트에서 빼고 제어 신호로 옮긴다. 아래 programmer 절.
자기 출력으로 학습하면 없는 건 못 만든다
무엇을 시도했나
원래 계획은 LoRA였다. 스윕 결과 중 좋은 것을 골라 학습시키는 것. 도구까지 다 만들었다 — 데이터셋 빌더, musubi-tuner 래퍼, 12GB 설정. fp16 원본 26.6GiB + T5 10.6GiB 도 받았다 (GGUF 로는 학습이 안 된다. musubi-tuner 가 못 읽고, fp8_scaled 도 지원 밖이다).
그런데 계획 자체에 결함이 있었다.
학습 데이터가 모델 자신의 출력을 우리가 골라낸 것이다.
이건 rejection sampling self-distillation 이고, 할 수 있는 일이 정해져 있다 — 이미 있는 모드를 더 자주 나오게 할 수는 있어도, 없는 모드를 만들 수는 없다. 우리 증상은 정확히 후자다. 9개 중 2개를 "그나마 덜 사람 같다"고 골라 학습시키면 조금 덜 사람다운 사람 걸음이 나온다.
어떻게 측정했나
먼저 제어가 자세를 지배하는지부터 확인했다. 원본 애니메이션이 아직 없어서 Blender 로 상자 6개짜리 인체를 만들고 일부러 망가진 걸음을 키프레임했다 — 왼팔은 앞으로 굳혀 고정, 오른팔만 가동범위를 넘겨 스윙, 몸통은 뒤로 젖힘.
깊이(depth) 패스로 렌더해 제어 영상을 만들고, Wan 2.2 Fun Control 에 물렸다.
py -3.12 scripts/mixamo_render.py --selftest
py -3.12 scripts/wan_fun_control.py \
--image master.png --control selftest_ctrl.mp4 --motion horde_advance깊이를 고른 이유는 셋이다. Blender 가 Z 패스로 공짜로 주고(OpenPose 골격은 별도 노드가 필요하다), 골격선과 달리 부피를 담고(몸통이 어느 쪽으로 꺾였는지가 들어온다), 옷·얼굴·색이 안 들어가서 제어 신호가 캐릭터를 오염시키지 않는다.
| 조건 | 값 |
|---|---|
| 제어 모델 | Wan 2.2 Fun Control 14B (high/low, fp8_scaled, 각 13.3GiB) |
| 제어 영상 | 480×832 · 77프레임 · 16fps · 8비트 그레이스케일 depth |
| 렌더러 | Blender 4.5.3 LTS, EEVEE, 헤드리스 |
결과
제어는 자세를 지배한다. 결과 영상에 내가 키프레임한 그대로 나왔다 — 한 팔이 굳은 채 올라가 있고, 몸통이 젖혀져 있고, 세 명의 위치와 전진까지 제어대로. 프롬프트로 다섯 번 시도해서 한 번도 못 얻은 것이다. 이후 진짜 좀비 애니메이션으로 바꿔도 결과는 같았다 — 구부정한 자세, 앞으로 늘어진 팔, 불안정하게 벌어진 스탠스가 프레임 단위로 그대로 재현됐다.
그런데 칠이 안 된다. 자세는 완벽한데 옷·얼굴·배경이 통째로 비어 실루엣만 나온다. 마스터 스틸이 결과에 아무 영향을 못 준다.
설정을 네 가지로 바꿔 가며 원인을 좁혔다.
| 설정 | 결과 |
|---|---|
| Lightning 1.0 · 4스텝 · cfg 1.0 · 2단 | 실루엣만. 옅은 노랑 배경 |
| Lightning 0 · 20스텝 · cfg 5.0 · 2단 | 완전한 노이즈 |
| Lightning 1.0 · 4스텝 + CLIP Vision 추가 | 변화 없음 (완전히 동일) |
| Lightning 1.0 · 6스텝 · 저노이즈 단일 | 더 평평해짐 |
세 번째에서 하나를 배웠다. WanFunControlToVideo 의 clip_vision_output 은 optional 이라 안 넣어도 조용히 통과한다. Wan 계열에서 외형을 실어 나르는 경로라 이게 원인이라고 봤는데, 넣어도 결과가 픽셀 단위로 동일했다. 가설이 틀렸다는 것만 확인했다.
네 번째는 내 버그였다. 저노이즈 단독 경로에서 1단이 0스텝인데 2단이 add_noise: disable 이라 노이즈가 한 번도 안 들어갔다. 노이즈 없이 디노이징을 돌린 셈이라 평평한 색면이 나온 게 당연했다. 측정 도구가 틀리면 측정값이 현상처럼 보인다 — 이번에 두 번째로 밟은 함정이다.
그 과정에서 밟은 함정 넷 — 전부 코드와 무관한 것들이라 적어 둔다.
| 증상 | 실제 원인 |
|---|---|
CLIPTextEncode 에서 4바이트 요청에 OOM | ComfyUI 가 "여유 0.02GiB" 로 보고. nvidia-smi 는 1,108MiB 사용(11GiB 여유). 긴 스윕 뒤 CUDA 컨텍스트가 어긋난 것. 백엔드 재시작으로 10.80GiB 회수 |
blender.org 다운로드가 403 | urllib 기본 UA(Python-urllib/3.x)를 거절한다. 허깅페이스는 통과시켜서 늦게 발견했다 |
musubi-tuner --help 만 눌러도 죽음 | 도움말에 일본어가 섞여 있고 한국어 Windows 기본 stdout 이 cp949. UnicodeEncodeError: 'cp949' codec can't encode character 'ー'. 몇 시간짜리 학습이 로그 한 줄에 날아갈 자리였다 |
| 좀비가 카메라에 등을 돌림 | atan2 인자를 뒤집어 정확히 180° 틀렸다. atan2(y, x) 순서 |
가장 비쌌던 함정은 따로 있었다. 받은 FBX 11개 중 스킨(메쉬)이 든 것은 하나뿐이라, 몸은 그 파일에서 가져오고 액션만 갈아끼우는 구조로 짰다. Mixamo 는 전 클립이 같은 리그를 쓰니 되는 설계다. 13종을 전부 렌더하고 결과도 그럴듯했다.
그런데 crawl 이 서 있었다.
Blender 4.4 부터 Action 에 "슬롯"이 생겼다.
animation_data.action 에 액션을 넣기만 하면 채널이 바인딩되지 않는다.
에러도 경고도 없이, 리그는 직전 포즈에 그대로 머문다.13종 전부가 Idle 포즈였다. 내가 키프레임한 이동 때문에 움직이는 것처럼 보였을 뿐이다. animation_data.action_slot 을 함께 잡아 주니 해결됐고, 재발을 막으려고 뼈 하나의 회전이 프레임 간에 변하는지 재는 검사를 넣었다.
ACTION_DELTA 0.55760 (Zombie Crawl.fbx) ← 붙었다
ACTION_DEAD — 액션이 리그를 움직이지 않는다 ← 안 붙으면 이게 뜬다13종 재렌더 결과 ACTION_DEAD 0건.
그리고 이 검사가 안 찍히는 경로가 하나 있었다 — render() 가 공용 실행 함수를 안 쓰고 자기 subprocess 를 따로 돌리고 있었다. 진단은 공용 쪽에만 있었다. 같은 일을 두 곳에서 하면 한 곳은 반드시 뒤처진다.
그리고 원본 애니메이션을 받고 나서 측정으로만 잡히는 것이 하나 더 나왔다.
MOTION_TRAVEL 0.000 m받은 클립이 전부 In Place 였다. 제자리걸음이라 카메라로 다가오지 않는다. "진행 방향으로 정면을 판정한다"는 내 로직도 변위가 0이라 발동조차 못 했다. 방향은 어깨로 재고(왼팔−오른팔 = 왼쪽 방향, 정면 = 왼쪽 × Z), 전진은 우리가 직접 키프레임하는 것으로 바꿨다.
다음 단계
순서를 뒤집는다.
원안: 모델이 지어내길 기대 → 골라내기 → LoRA (순환)
변경: 제어로 정답을 만든다 → 그 결과를 수확 → LoRA (부트스트랩)제어로 뽑은 클립은 진짜 정답 모드다. LoRA 를 버리는 게 아니라, "매 컷 제어 영상 없이도 그 자세가 나오게" 만드는 마무리 단계로 내려간다. 이미 받은 fp16 가중치와 래퍼는 그대로 쓴다 — 데이터의 출처만 바뀐다.
아직 모르는 것을 그대로 적는다. 제어가 자세를 지배한다는 것은 확인했지만, 왜 외형이 전혀 안 실리는지는 규명 못 했다. 남은 후보는 둘이다 — Lightning LoRA(I2V 용으로 증류된 것)를 Fun Control 가중치에 얹은 것이 디노이저를 망가뜨렸을 가능성, 그리고 2단 전문가 분할 지점이 Fun Control 에 맞지 않을 가능성(고노이즈 담당 구간이 timestep 900~1000 인데 20스텝의 절반에서 자르는 건 근거가 없다).
추측을 결론처럼 쓰지 않기 위해 여기까지만 적는다. 다음 회차에서 답이 나오면 그때 쓴다.