로컬에서 배우는 AI GPU 한 장으로, AI가 스스로 나아지는 과정을 기록한다

· 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차에서는 방향을 바꿨다. 외부 사례에서 세 가지를 가져왔다:

  1. 동작을 부정문으로 쓰면 무시되거나 반대로 작동한다. "does NOT fall on his back" 대신 "falls on his stomach" 로 쓰라는 것.
  2. 한 비트에 3문장을 넘기면 뭉개진다. 프롬프트 총량은 길어도 되지만 한 비트가 과부하면 모델이 그 비트를 뭉갠다.
  3. 골격. 장면/레퍼런스/타이밍/광학/카메라/물리/조명/연기/제약을 절로 나눈다.

우리 상태를 이 기준으로 재보니 셋 다 위반이었다.

어떻게 측정했나

프롬프트를 문자열로 실제로 재는 것부터 했다. 한 번도 안 재봤다.

bash
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 시트를 나란히 붙여 봤다.

bash
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 blood

5차 프롬프트의 성적. 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 에 물렸다.

bash
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스텝 · 저노이즈 단일더 평평해짐

세 번째에서 하나를 배웠다. WanFunControlToVideoclip_vision_outputoptional 이라 안 넣어도 조용히 통과한다. Wan 계열에서 외형을 실어 나르는 경로라 이게 원인이라고 봤는데, 넣어도 결과가 픽셀 단위로 동일했다. 가설이 틀렸다는 것만 확인했다.

네 번째는 내 버그였다. 저노이즈 단독 경로에서 1단이 0스텝인데 2단이 add_noise: disable 이라 노이즈가 한 번도 안 들어갔다. 노이즈 없이 디노이징을 돌린 셈이라 평평한 색면이 나온 게 당연했다. 측정 도구가 틀리면 측정값이 현상처럼 보인다 — 이번에 두 번째로 밟은 함정이다.

그 과정에서 밟은 함정 넷 — 전부 코드와 무관한 것들이라 적어 둔다.

증상실제 원인
CLIPTextEncode 에서 4바이트 요청에 OOMComfyUI 가 "여유 0.02GiB" 로 보고. nvidia-smi 는 1,108MiB 사용(11GiB 여유). 긴 스윕 뒤 CUDA 컨텍스트가 어긋난 것. 백엔드 재시작으로 10.80GiB 회수
blender.org 다운로드가 403urllib 기본 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스텝의 절반에서 자르는 건 근거가 없다).

추측을 결론처럼 쓰지 않기 위해 여기까지만 적는다. 다음 회차에서 답이 나오면 그때 쓴다.

#wan22#comfyui#prompt-engineering#lora#controlnet#blender#mixamo#vram#local-first