TL;DR
한 줄의 아이디어를 Gemma 4 31B가 10-beat 구조로 전개하고, HiDream으로 2048² 이미지 11장, LTX-2 A2V/I2V로 11개 클립(clip), Irodori-TTS로 대사와 남성 나레이션, ffmpeg로 자막 삽입 및 Hook 타이틀 오버레이(overlay)까지 전 과정을 자동화합니다. 실측 25~30분 만에 40초 분량의 세로형 (512×768) 영상을 생성합니다. 모든 과정은 로컬 GPU 1장 (96 GB Blackwell)으로 수행되며, API 비용은 0원입니다.
완성본 (공개됨):
이 글의 대상 독자
로컬 GPU를 사용하여 AI 영상 코미디를 양산하고 싶은 개인 개발자. 각 모델 단독 사용이 아닌 **「여러 모델을 하나의 파이프라인 (pipeline)으로 연결하여 운용하는 설계」**에 관심이 있는 사람.
수행 내용
Z세대 대상의 다크 코미디 풍자(「No Means Yes?」라는 성적 동의 딜레마를 소재로 하는 해외 쇼츠 영상 포맷)를, 한 줄의 아이디어에서 40초 영상 제작까지 자동화했습니다.
완성 이미지:
Hook (0-5s): 아름다운 여성의 클로즈업 + 나레이터 「'남자애잖아'라는 말에 응답한 그의 운명——」 + 대형 타이틀 「No Means Yes?」 -
본편 (5-37s): 영화관 데이트 → 「키스해도 돼?」 → 「싫어… 안 돼…」 → 낙담 → 「좀 더 적극적으로 해봐. 남자애잖아?」 → 그렇구나 → 키스 -
결말 (37-40s): 법원 「주문 피고인을 부동의 성교 등의 죄로 징역 3년에 처한다」 + 의사봉 「쾅!」 + 감옥에서 눈물 -
before / after:
| 기존 방식 | 이 파이프라인 (pipeline) |
|---|
| 아이디어 → 공개 영상 | 2~3일 (수동 편집) |
| API 비용 | DALL-E + 영상 생성으로 1편당 수백 엔 |
| 자막 | 수동으로 SRT 작성 |
| Hook | 별도 촬영 |
아키텍처 (Architecture)
[Stage A] Gemma 4 31B (vllm, port 8894) → plan.json (10 beats + hook)
[Stage B] HiDream-O1-Image (port 8895) → 11장의 2048² 이미지
+ Gemma 4 31B multimodal로 시각적 판단 (judge) (--judge --max-retries 2)
...
구현은 llm_server/storyboard/ 하위에 집약되어 있습니다 (pipeline.py / visual.py / judge.py / video.py / render.py / run.py).
10-beat consent_dilemma 포맷
prompts.py의 CONSENT_DILEMMA_SYSTEM에서 시스템 프롬프트 (system prompt)로 고정:
| # | type | speaker | renderer | 내용 |
|---|
| 1 | provocation | b | LTX-2 A2V | 의미심장한 유혹 |
| ... | a (silent) | LTX-2 I2V | 남자의 키스 순간 | |
| 8 | verdict | judge | LTX-2 A2V | 빠른 판결 |
| 9 | gavel_se | judge | LTX-2 I2V (keep_audio) | 의사봉 + AI 자동 「쾅!」 소리 |
| 10 | jail | a (silent) | LTX-2 I2V | 감옥에서 눈물 |
핵심 포인트는 3단계의 반전입니다:
거절 (refusal)을 단순한 「안 돼」로 만들지 않음: 「싫어… 안 돼…」와 같이 어미를 늘려, 연극적인 「No that doesn't mean No」의 뉘앙스를 줍니다. 이것이 이후 가스라이팅 (gaslight)의 모순을 성립시키는 전제가 됩니다. -
가스라이팅 직후에 키스를 배치하지 않음: 「pause」(그렇구나)를 1.5초간 삽입합니다. 템포와 감정 곡선을 위해서입니다. -
판결 (verdict)과 감옥 (jail)의 2단 결말: 판결만 있으면 갑작스럽습니다. 감옥에서 우는 장면이 있으면 「그는 정말로 유죄가 되었다」는 사실이 납득됩니다. -
Hook 설계 (TikTok의 초기 3초 문제)
세로형 숏폼은 처음 3초에서 이탈률이 결정된다. 본편 10 beats 앞에 Hook segment를 전치:
"hook": {
"title_overlay": "No Means Yes?",
"narrator_line": "「남자애잖아」라는 말에 응답한, 그의 운명——",
...
구현상의 함정 2가지:
함정 1: narrator TTS 길이가 duration_sec를 초과하면 음성이 끊김. 「그의 운명」의 「그」 부분에서 audio cut-off가 발생했다. 대책: TTS를 먼저 생성 → ffprobe로 실측 → max(plan_duration, narrator + 0.6)을 I2V duration에 전달.
narrator_dur = _ffprobe_duration(narrator_wav)
duration = max(float(hook.get("duration_sec", 0.0)), narrator_dur + 0.6)
ltx_i2v_clip(portrait, i2v_prompt, duration, silent_video, keep_audio=False)
함정 2: drawtext의 y 위치. y=h*0.30 (화면 1/3 높이)로 설정하면 얼굴을 가린다. y=20 (절대값 20 px)로 최상단에 배치.
자막 삽입 (무음 시청 대응)
전철에서 소리를 끄고 보는 사용자 대응 + 플랫폼 간 신뢰성을 위해, burned-in subtitles(자막 삽입)를 적용한다.
style = (
"FontName=Noto Sans CJK JP,FontSize=18,PrimaryColour=&H00FFFFFF,"
"OutlineColour=&H00000000,Outline=2,Shadow=0,BorderStyle=1,"
...
Alignment=2 = bottom center. MarginV=60으로 최하단에서 여유를 둠.
장문 분할: 하나의 beat 내에 30자 이상의 line이 있으면 얼굴을 가린다. _split_subtitle로 문장 부호 。.!?에서 문장을 분할 → 탐욕 알고리즘(Greedy algorithm)을 사용하여 28자 이하의 chunk로 생성 → beat duration을 균등하게 배분:
입력:
말투로 확인하는 건 로맨틱하지 않잖아. 있지, 좀 더 적극적으로 해봐. 남자애잖아?
출력 (하나의 8.9s beat를 2개의 chunk로 시간 분할):
| 시간 | 자막 |
|---|
| 15.16-19.63s | 말투로 확인하는 건 로맨틱하지 않잖아. |
| 19.63-24.10s | 있지, 좀 더 적극적으로 해봐. 남자애잖아? |
LTX-2 I2V로 효과음 생성하기 (gavel_se)
LTX-2 distilled는 I2V 출력 mp4에 AI 생성 음성 (환경음 / 효과음)을 자동으로 포함한다. ffmpeg -map 0:v:0 -map 1:a:0으로 명시적으로 drop 하지 않는 한, prompt로부터 소리가 따라온다.
이를 SE(효과음) 생성기로 전용한다:
def render_se_tail_beat(sb_dir, beat, prior_clip, work_dir):
extract_last_frame(prior_clip, last_frame_png)
...
ltx_i2v_clip에 keep_audio=True 플래그를 추가하여, ffmpeg 재인코딩 시 음성을 drop 하지 않는 경로를 만들었다.
gavel_se용 prompt:
"Single decisive arm motion of the judge bringing the gavel down sharply "
"onto the wooden bench. Loud sharp wood-on-wood thwack impact sound. "
"Brief, contained, no other motion in the frame."
판사의 최종 프레임 + 망치 prompt를 통해 「쾅!」 하는 소리가 나온다. 만약 실패할 경우 역전재판 SE 등으로 fallback(대체)하는 설계다.
함정 리스트
개발 중에 겪은 주요 함정 5가지:
1. vLLM 0.20.2에서 Codex CLI가 멈춤 (hang)
codex exec -p gemma4 명령어로 system prompt + idea를 던지면, /v1/responses의 handshake 단계에서 CPU 사용률 0%인 상태로 20분 이상 멈춤(hang) 현상이 발생한다. subprocess 출력을 tail -200으로 파이프(pipe) 처리한 탓에 초기 stderr(표준 에러) 정보도 차단되었다.
회피 방법: codex를 통한 호출은 포기하고 urllib.request를 사용하여 /v1/chat/completions를 직접 호출한다. response_format={"type":"json_object"}를 사용하여 JSON 형식을 강제했다. 그 결과 25초 만에 plan.json 생성을 완료할 수 있었다.
2. HiDream이 cinema screen을 제거하지 못함
setting_prompt에 "The movie screen is BEHIND the camera and NOT VISIBLE in frame"(영화 스크린은 카메라 뒤에 있으며 프레임 내에 보이지 않음)라고 명시해도, 2048/50 steps 설정에서도 스크린이 배경에 계속 남는다.
회피 방법: t2i(text-to-image)로 scene_base를 생성한 뒤, 동일한 이미지를 I2I(image-to-image) edit에 투입하여 "스크린을 dark wall(어두운 벽)로 교체하고 캐릭터 위치는 동일하게 유지"하라는 프롬프트를 입력하면 한 번에 제거된다. 저해상도 생성 → I2I fix → 해상도를 높여 모든 beat 재생성 순의 2단계 방식을 사용한다.
3. HiDream이 lips-on-lips 키스를 cheek kiss로 처리함
standard amplifier 환경에서 HiDream은 키스를 cheek kiss(볼 키스)로 해석하는 경향이 있다. "CRITICAL: their LIPS meet directly — mouth-to-mouth contact at the CENTER of the frame. NOT a cheek kiss"(중요: 입술이 직접 맞닿아야 함 — 프레임 중앙에서 입과 입이 접촉해야 함. 볼 키스가 아님) 수준의 강력한 지시(directive)가 필수적이다. _beat_edit_prompt에 kiss beat 전용 조기 반환(early return) 블록을 마련했다.
4. CAST / CROP_BOX / SPEAKER_A2V_PROMPT가 2인으로 하드코딩됨
a(Kenta)와 b(Misaki)만 알고 있는 CAST dict / CROP_BOX dict / SPEAKER_A2V_PROMPT dict가 3곳에 존재한다. judge나 narrator를 추가하려면 3개의 dict를 동시에 업데이트해야 한다(KeyError를 보고 나서야 인지하게 된다). setting_override를 가진 beat는 scene_base가 아닌 beat 자체의 이미지에서 portrait crop을 수행하도록 render_speech_beat_ltx_a2v에 분기 로직을 추가했다.
5. Gemma 4 multimodal judge의 과도한 false-positive (오탐)
storyboard/judge.py에서 Gemma 4 31B에 beat 이미지와 기대 표정을 전달하여 YES/NO를 판정하는 시각 judge(visual judge)를 구현했다. 확실히 "손가락 개수 이상", "입을 벌린 대화 포즈(open-mouth conversational pose)", "장면 기하학적 불일치(scene geometry mismatch)"와 같은 명백한 실패는 잡아내지만, "미묘하게 수줍은 표정(subtle shy expression)"과 같은 미세한 영역에서는 FAIL을 연발한다.
실무 적용: max-retries를 2로 설정하여 3회 연속 FAIL이 발생하면 수용하고 진행한다. frontier reviewer (Gemini 3.1 Pro)로 전환하는 임계값(threshold)은 아직 자동화하지 않았다.
VRAM 공존 설계
96 GB Blackwell Max-Q에서의 내역:
| 프로세스 | idle (GiB) | peak (GiB) |
|---|
| Gemma 4 31B (NVFP4) | 38 | 38 |
| ... | | |
모두 peak 상태일 때 109 GiB가 되어 OOM(Out of Memory)이 발생한다. 운영 흐름은 다음과 같다:
- Stage A: Gemma 31B + HiDream idle 상태에서 peak ~62 GiB
- Stage B with judge: Gemma 31B + HiDream peak 상태에서 ~73 GiB
- 최종 정리 전 $
ightarrow$ 38 GiB 해제: pkill -f "vllm.*gemma"
Gemma kill -
Stage B 정리 (2048/50): HiDream peak ~33 GiB -
Stage C 전 $\rightarrow$ 16 GiB 해제 lsof -ti tcp:8895 | xargs kill
HiDream kill -
Stage C: LTX-2 + TTS + Ditto로 peak ~32 GiB
Stage 전환 시 명시적으로 kill 하는 것만으로 전 공정을 한 장의 메모리에 올릴 수 있다.
이터레이션 루프 (cache 전략)
"전부 다시 만들기"가 아니라 **부분 재생성 (Partial Regeneration)**이 고속화의 핵심:
# 1 beat만 이미지 regen (HiDream만 수행)
python -m storyboard.visual --plan ... --out ... --only-beat 7 --steps 50 --resolution 2048
# 부분 video regen (TTS + LTX-2)
...
캐시 계층 (Cache Hierarchy):
- HiDream beat 이미지 (
beat_NN_<type>.png) — --only-beat로 개별 80초 소요
- A2V / I2V clip (
clip_NN_*.mp4) — beat type / speaker / line 변경 시 invalid
- Hook 완성본 (
clip_00_hook.mp4) — 타이틀 위치만 바꾸고 싶을 때는 이것만 삭제 (LTX-2 I2V의 무거운 hook_silent.mp4는 재사용)
- 자막 SRT — 매번 재생성 (10초)
타이틀 위치 / 자막 style / Hook 내 문구 조정은 30초 만에 다시 구울 수 있다. LTX-2 I2V의 100초 부분은 그대로 사용할 수 있다.
Kotonia에서 어떻게 활용되는가
이 파이프라인으로 생성한 영상은 SNS 배포 (TikTok / YouTube Shorts / IG Reels)용이며, Kotonia (kotonia.ai)의 어텐션(Attention) 획득 $\rightarrow$ 유료 결제 유도로 이어지는 상류(Upstream)를 담당한다.
기술적으로는 /studio/ (HiDream의 이미지 생성)와 동일한 스택을 영상 방향으로 확장한 것이다. 향후에는 /video-studio/로서 Web UI에서 클릭 한 번으로 동일한 파이프라인을 호출할 수 있는 구성을 상정하고 있다 (현재는 CLI로만 가능).
관련 기사 / 시도해보고 싶은 분들을 위해
- HiDream-O1-Image의 5개 기능을 1 GPU에 상주시키는 구성 — Studio (
/studio/)의 백엔드 설계
- LTX-2를 95GB GPU 1장에 담는 fp8-cast 양자화(Quantization) — Stage C의 영상 생성 기반
- 언어 학습 숏폼 영상을 Claude Code로 재현해 보았다 — 6-beat "망고 사건" 포맷의 선행 구현
- 시도해보고 싶은 분은
/studio/에서 이미지 생성 측면을 클릭 한 번으로 테스트할 수 있다 (영상화 CLI는 현재 self-host로만 제공).