🎭

Synctoon — 스크립트 한 장으로 2D 애니메이션 영상을 뽑아내는 AI 파이프라인

Gemini가 감정·동작·배경을 연출하고, Gentle이 립싱크를 맞추고, Pillow가 프레임을 합성한다

나레이션 오디오와 스크립트 텍스트 파일 두 개를 던지면 2D 토킹헤드 애니메이션 영상이 나온다. 캐릭터가 말에 맞춰 입을 움직이고, 감정에 따라 표정이 바뀌고, 장면에 맞는 배경이 깔린다.

Synctoon은 이걸 전부 자동으로 처리한다.

3단계 파이프라인

전체 흐름은 단순하다. 분석 → 합성 → 영상화.

Stage 1 — AI 분석 + 오디오 정렬 (core.py)

Gentle(Docker 기반 forced aligner)에 오디오와 텍스트를 넘기면 단어별 시작/종료 타임스탬프가 나온다. "Hello"가 0.5초~0.8초 구간이라는 식.

그 다음 Gemini 2.0 Flash에 텍스트를 8번 보낸다. 각각 다른 연출 요소를 분석한다:

머리 방향(좌/우/정면), 눈 방향(좌/우/정면, 90% 정면 바이어스), 캐릭터 할당(누가 말하는지), 감정(14종 — happy, sad, angry, shock, evil_laugh 등), 바디 포즈(47종 — dancing, kung_fu, meditation 등), 강도(보통/강조), 줌 레벨(0/1/2), 배경(31종 — office, forest, bedroom, park 등)

AI 응답은 marshmallow 스키마로 검증한다. 검증 실패하면 에러 메시지를 프롬프트에 붙여서 재시도한다. 최대 3회. 자기교정 루프.

그 다음 g2p_en이 각 영어 단어를 음소(phoneme)로 변환한다. "Hello" → HH, AH, L, OW. 단어의 시간 구간을 음소 수로 균등 배분해서 프레임별 입 모양을 결정한다.

마지막으로 24fps 기준 프레임별 CSV를 생성한다. 각 프레임에 캐릭터, 감정, 바디포즈, 머리방향, 눈방향, 배경, 입모양, 줌, 눈깜빡임 정보가 들어간다. 80프레임마다 3프레임짜리 눈깜빡임 시퀀스도 자동 삽입.

Stage 2 — 프레임 합성 (frame_generator.py)

CSV를 한 줄씩 읽으면서 5개 레이어를 합성한다. 배경 → 바디 → 머리 → 눈 → 입. Pillow(PIL)로 PNG 이미지를 겹겹이 쌓는다.

metadata.json에 각 레이어의 위치와 크기가 픽셀 단위로 정의되어 있다. 머리 위에 눈이 (x, y) 좌표에 오고, 입이 (x, y) 좌표에 온다.

똑똑한 부분은 중복 프레임 캐싱이다. 캐릭터+감정+포즈+머리+눈+입+배경+줌 조합이 같으면 이전에 합성한 PNG를 재사용한다. 대사 중간에 입만 바뀌지 않는 구간이 연속되면 프레임 하나로 수십 프레임을 커버한다.

Stage 3 — 영상 컴파일 (frame_to_video.py)

OpenCV의 VideoWriter가 PNG 시퀀스를 24fps MP4로 묶는다. FFmpeg로 원본 오디오를 합쳐서 최종 영상 완성.

음소 → 입 모양 매핑

mouth_image.json이 각 음소를 특정 입 이미지에 매핑한다. 음소 "AH" → m_a_e_ah_h(행복 표정용), m_a_e_ah_s(슬픈 표정용). 감정 상태에 따라 같은 발음이라도 다른 입 모양 에셋을 쓴다.

45개 음소가 17개 입 모양(viseme)으로 그룹핑된다. 한 입 모양이 여러 비슷한 음소를 커버하는 구조.

에셋 시스템

캐릭터 에셋은 계층적이다:

images/characters/character_1/ 아래에 body/, head/, eyes/, mouth/, background/ 폴더가 있다.

바디 포즈 47종. 감정 14종. 배경 31종. 2D 퍼펫 애니메이션치고 조합 수가 꽤 많다. 각 폴더 안에 변형(variant) 이미지가 여러 장 있어서, 같은 포즈라도 매번 다른 이미지가 랜덤 선택된다.

현재 한계

프로토타입 단계라는 게 코드 곳곳에서 보인다. 파일 경로가 하드코딩되어 있고(/home/oye/Downloads/...), API 키가 코드에 노출되어 있다. 캐릭터도 현재 1개만 활성화되어 있다(멀티캐릭터 구조는 갖추고 있지만).

Gentle Docker 컨테이너가 반드시 떠 있어야 하고, Gemini API 호출 간 6초 sleep이 들어간다(rate limit 회피). 8번 호출하니까 AI 분석 단계만 최소 48초.

웹 UI는 없다. 전부 CLI.

그래도 접근법 자체는 흥미롭다. LLM을 "애니메이션 디렉터"로 쓰는 패턴. 텍스트를 읽고 감정, 포즈, 카메라, 배경을 결정하는 건 사람이 하던 일인데, 그걸 프롬프트 8개로 자동화했다.

근데 솔직히 말하면

코드를 까보면 "AI 기반 애니메이션 파이프라인"이라는 타이틀에 비해 AI가 하는 일이 생각보다 얕다.

프롬프트를 보면 안다. Carefully review the text below and create head movement instructions. Left (L), Right (R), Mid (M). — 이게 전부다. 텍스트를 던지고 L/R/M 중 하나 골라달라는 것. 감정도 마찬가지. 14개 감정 딕셔너리를 통째로 프롬프트에 넣고 번호를 고르라는 구조. 프롬프트 엔지니어링이라고 부르기 민망한 수준.

더 근본적인 문제는, 오디오를 전혀 분석하지 않는다는 점이다. 오디오는 Gentle의 립싱크 타이밍에만 쓰인다. 감정 분석? 텍스트만 본다. 바디 포즈? 텍스트만 본다. 머리 방향? 텍스트만 본다. 화자의 어조, 목소리 떨림, 말 빠르기, 감정적 뉘앙스 — 오디오에 담긴 이 모든 정보가 버려진다.

"I\'m fine"을 담담하게 말하는 것과 울면서 말하는 건 같은 텍스트인데 완전히 다른 연출이어야 한다. 이 시스템은 그 차이를 모른다.

8번의 API 호출이 각각 독립적이라는 것도 문제다. 감정이 angry인데 바디 포즈가 meditation으로 나올 수 있다. 배경이 bedroom인데 줌이 2(클로즈업)로 나올 수도 있다. 각 호출이 서로의 결과를 모르니까. 사람이라면 전체 장면을 보고 일관된 연출을 하겠지만, 여기서는 8개의 독립된 판단이 조합될 뿐이다.

rate limit 회피를 위한 6초 sleep도 아키텍처 문제를 드러낸다. 8개 호출을 순차적으로 보내는 건 batch API나 function calling으로 한 번에 처리하면 되는 일이다. Gemini의 structured output을 쓰면 marshmallow 검증 루프도 필요 없다.

그리고 영어 전용이다. g2p_en은 영어 음소만 지원한다. 한국어나 일본어 나레이션은 립싱크가 안 된다.

정리하면:

오디오 무시 — 텍스트만 보고 연출 결정. 어조와 감정 정보 소실.

8개 호출 독립 — 감정/포즈/배경 간 일관성 보장 없음. angry + meditation 같은 모순 가능.

프롬프트가 너무 단순 — few-shot 예시도 없고, 연출 의도에 대한 컨텍스트도 없음. L/R/M 고르라는 게 끝.

API 비효율 — 8번 순차 호출 + 6초 sleep = 최소 48초. 한 번의 structured output으로 대체 가능.

영어 전용 립싱크 — g2p_en 의존. 다국어 확장 불가.

그럼에도 이 프로젝트가 보여주는 건, LLM + forced aligner + 레이어 합성이라는 조합만으로도 "돌아가는" 애니메이션이 나온다는 사실이다. 완성도가 아니라 가능성을 증명한 프로토타입. 위 한계들은 전부 개선 가능한 문제이기도 하다.

리포지토리 현황 (2026-03 기준)

Star 25개, Fork 3개. 사실상 1인 개발 프로젝트다. 컨트리뷰터는 oyekamal 1명(커밋 45개) + google-labs-jules bot 1개(README 자동 생성).

main 브랜치 마지막 커밋이 2025년 9월이다. 6개월 넘게 main에 아무것도 안 들어갔다.

PR이 7개 있는데, 머지된 건 딱 1개(#2 — README 추가, bot이 작성). 나머지 6개 오픈 PR은 전부 2025-10-13 같은 날, 6분 간격으로 올라왔다. 라벨이 codex다. AI 코딩 에이전트가 한꺼번에 생성한 리팩토링 PR인데, 아직 리뷰도 안 됐다.

Issue는 1개. "request i want to work on this project" — 본문 없음. 참여 요청인데 응답도 없다.

커밋 메시지를 보면 개발 타임라인이 보인다. 2024년 8~9월에 핵심 기능(에셋 합성, CSV, 영상 컴파일, 눈깜빡임)을 2주 만에 구현했고, 2025년 7월에 README를 추가하고, 9월에 create_animation.py(통합 엔트리포인트)를 만든 게 마지막이다.

라이선스는 GPL-3.0. 상업적 사용 시 소스 공개 의무가 있다.

파이프라인 코드 탐색

각 카드를 클릭하면 해당 부분의 실제 소스 코드가 펼쳐집니다

Stage 1-A Gentle — 오디오-텍스트 강제 정렬 speach_aligner.py ↗

Docker 컨테이너(port 49153)에 오디오 + 텍스트를 POST하면 단어별 시작/종료 타임스탬프 JSON이 돌아온다.

class TranscriptionService: def __init__(self, files, url="http://localhost:49153/transcriptions?async=false"): self.url = url self.files = files def send_request(self): opened_files = [] for name, path, content_type in self.files: opened_files.append((name, (name, open(path, "rb"), content_type))) response = requests.post(self.url, files=opened_files) return response.json() # 반환: {"transcript":"...", "words":[{"word":"Hello","start":0.5,"end":0.8}, ...]}
Stage 1-B Gemini API 8회 연속 호출 — AI 연출 지시 core.py ↗

6초 간격으로 Gemini에 텍스트를 보내서 머리방향, 눈방향, 캐릭터, 감정, 포즈, 강도, 줌, 배경을 분석한다. 결과는 update_values()로 단어별 데이터에 매핑.

service = TranscriptionService(files=files) response_json = service.send_request() transcript = response_json["transcript"] head_movement = analyzer.get_head_movement_instructions(transcript) time.sleep(6) eyes_movement = analyzer.get_eyes_movement_instructions(transcript) time.sleep(6) character = analyzer.get_character(transcript, characters) time.sleep(6) emotions = analyzer.get_emotion(transcript, emotions) time.sleep(6) body_action = analyzer.get_body_action(transcript, body_actions) time.sleep(6) # ... intensity, zoom, screen_mode도 동일 # 각 AI 결과를 단어별 데이터에 매핑 update_values(response_json, head_movement, "head_direction", "M") update_values(response_json, emotions, "emotion", 1) update_values(response_json, body_action, "body_action", 3)

8개 호출의 상세 흐름 — 각 항목을 클릭하면 호출 체인과 프롬프트가 펼쳐집니다

1 get_head_movement_instructions(transcript) 머리 방향

호출 체인

core.py text_aligner.py :: get_head_movement_instructions(text) prompts.py :: head_movement_instructions

프롬프트 핵심

"Carefully review the text below and create head movement instructions. You can choose from three directions: Left (L), Right (R), and Mid (M). text: ```{text}``` Total Length: {total_length} Word Count: {word_count} Return the instructions in JSON format: [{{ \"text\": {{\"start\": 0, \"end\": 4}}, \"head_direction\": \"\" }}]"
검증 스키마: HeadDirectionSchema
가능한 값: L R M
기본값: M
2 get_eyes_movement_instructions(transcript) 눈 방향

프롬프트 핵심

"Create eye movement instructions... Directions: Left (L), Right (R), Mid (M). Focus 90% of instructions on Mid (M) as the main focal point of character..."
검증: EyeDirectionSchema
값: L R M (90%)
기본값: M
3 get_character(transcript, characters) 캐릭터 할당

프롬프트 핵심

"Given the following list of characters: ```{characters}``` Assign the character to each section of text... Return JSON: [{{ \"text\": {{\"start\": X, \"end\": Y}}, \"character\": N }}]" # characters 딕셔너리 (constants.py에서 전달) {1: {"name": "Hero", "type": "Protagonist"}} # 2: Villain, 3: Sidekick, 4: Mentor (현재 비활성)
검증: CharacterSchema
데코레이터: @retry(max_attempts=3, delay=1)
기본값: 1
4 get_emotion(transcript, emotions) 감정 (14종)

프롬프트 핵심

"Given the following list of Emotions and their types: ```{emotions}``` text: ```{text}``` Return JSON: [{{ \"text\": {{\"start\": X, \"end\": Y}}, \"emotion\": N }}]" # emotions 딕셔너리 (constants.py) {1:"happy", 2:"sad", 3:"angry", 4:"bore", 5:"content", 6:"glare", 7:"sarcasm", 8:"worried", 9:"crazy", 10:"evil_laugh", 11:"lust", 12:"shock", 13:"silly", 14:"spoked"}
검증: EmotionSchema
값: 1~14 (Integer)
기본값: 1 (happy)
5 get_body_action(transcript, body_actions) 바디 포즈 (47종)

constants.py에서 전달되는 포즈 목록 (일부)

achieve explain dancing kung_fu meditation running singing thinking jumping ... 외 38종
검증: BodyActionSchema
값: 1~47 (Integer, 26/29 결번)
기본값: 3 (explain)
6 get_intensity(transcript) 강도
프롬프트: 텍스트의 감정 강도를 판단. 강조 시 2 → 에셋 파일명에 _2 suffix가 붙어 강한 표정 변형 사용. 값: 1 (보통) / 2 (강조)
7 get_zoom(transcript) 줌 레벨
프롬프트: 장면의 드라마틱한 정도에 따라 카메라 줌 결정. CharacterManager에서 crop + scale로 구현. 값: 0 (기본) / 1 (중간) / 2 (클로즈업)
8 get_screen_mode(transcript, screen_mode) 배경 (31종)

프롬프트 핵심

"Select the most thematically appropriate background for each section of the text. Given the following list of screen modes: ```{screen_mode}```" # screen_mode 딕셔너리 (constants.py, 일부) {1:"office", 5:"green", 8:"bedroom-1", 15:"forest", 16:"garden", 25:"outdoor-cafe", 26:"park", 29:"street", 30:"train-station"} # ... 외 22종
검증: ScreenModeSchema
값: 1~31 (Integer)
기본값: 1 (office)
검증 marshmallow 자기교정 루프 text_aligner.py ↗

AI 응답을 marshmallow 스키마로 검증하고, 실패하면 에러 메시지를 프롬프트에 붙여서 재시도. 최대 3회.

# text_aligner.py — 재시도 루프 def _send_message_and_extract(self, prompt, schema): max_attempts = 3 attempts = 0 while attempts < max_attempts: response = self.chat.send_message(prompt) data = self.extract_json_content(response.text) status, message = validate_data(data, schema) if status: break prompt = prompt + "\n" + str(message) # 에러를 프롬프트에 추가! attempts += 1 return data # validater.py — marshmallow 스키마 예시 class HeadDirectionSchema(Schema): text = fields.Nested(TextSchema, required=True) head_direction = fields.String( required=True, validate=lambda x: x in ["L", "R", "M"])
프롬프트 Gemini에 보내는 프롬프트 구조 prompts.py ↗

텍스트 + 단어 수 + JSON 포맷 지정. 감정은 enum dict를, 배경은 scene list를 프롬프트에 직접 넣는다.

"head_movement_instructions": """ Carefully review the text below and create head movement instructions... Directions: Left (L), Right (R), Mid (M). text: ```{text}``` Word Count: {word_count} Return JSON: [{{ "text": {{"start": 0, "end": 4}}, "head_direction": "" }}] """ "emotion_selector": """ Given the following Emotions: ```{emotions}``` text: ```{text}``` Return JSON: [{{ "text": {{"start": X, "end": Y}}, "emotion": N }}] """
음소 g2p_en — 단어 → 음소 변환 + 프레임 배분 add_phonemes.py ↗

"Hello" → HH, AH, L, OW. 단어의 시간 구간(0.5초~0.8초)을 음소 수로 균등 나눠서 프레임별 입 모양 결정.

from g2p_en import G2p g2p = G2p() def add_phonemes(data, FRAME_PER_SECOUND=24): for each_data in data.get("words"): each_data["phonemes"] = g2p(each_data["word"]) each_data["init_frame"] = math.ceil( float(each_data["start"]) * FRAME_PER_SECOUND) each_data["final_frame"] = math.ceil( float(each_data["end"]) * FRAME_PER_SECOUND) each_data["phonemes_frame"] = distribute_frames(each_data) # "Hello"(0.5s-0.8s) → frame 12~19 → HH:2f, AH:2f, L:2f, OW:2f
CSV 프레임 CSV 생성 + 80프레임마다 눈깜빡임 frame_info_generator.py ↗

24fps 기준 프레임별 메타데이터 CSV. 80프레임 간격으로 3프레임짜리 blink 시퀀스를 자동 삽입.

blink_sequence = ["02", "03", "04"] blink_frame_interval = 80 if blink_counter == blink_frame_interval: blink_counter = 0 for blink_value in blink_sequence: writer.writerow([frame_counter, alignedWord, start, end, character, emotion, body, head_direction, f"{blink_value}", background, phoneme, mouth_emotion, mouth_name, zoom, "True"]) frame_counter += 1 else: writer.writerow([..., eyes_direction, ..., "False"]) frame_counter += 1
Stage 2 5레이어 합성 — 배경→바디→머리→눈→입 CharacterManager.py ↗

metadata.json의 픽셀 좌표로 눈과 입을 머리에 붙이고, 머리를 바디에, 캐릭터를 배경 위에 합성.

def get_character(self, Character, Emotion, Body, Head_Direction, Eyes_Direction, Background, Mouth_Emotion, Mouth_Name, zoom, blink=False): head, _ = self.get_asset(Character, "head", Head_Direction) eyes, eyes_meta = self.get_asset(Character, "eyes", Emotion, ...) mouth, mouth_meta = self.get_asset(Character, "mouth", ...) head = self.adding_eyes_and_mouth(head, eyes, mouth, eyes_meta, mouth_meta) body, body_meta = self.get_asset(Character, "body", Body) character = self.adding_head_and_body(body=body, head=head, metadata=body_meta) bg, bg_meta = self.get_asset(Character, "background", Background) scene = self.adding_background(body=character, background=bg, metadata=bg_meta, zoom=zoom) return scene, bg_meta
캐싱 중복 프레임 캐싱 — 동일 조합은 PNG 재사용 frame_generator.py ↗

캐릭터+감정+포즈+머리+눈+입+배경+줌 조합을 문자열 키로 만들어서 이미 합성한 PNG는 건너뛴다.

key = (character + emotion + body + head_direction + eyes_direction + background + mouth_emotion + mouth_name + str(zoom) + str(blink)) if key not in frame_data["key_counter"]: frame_data["key_counter"][key] = 1 image, meta = manager.get_character(...) image.save(image_file) # 새로 합성 → 저장 else: frame_data["key_counter"][key] += 1 # 재사용! frame_data["frame_key"][counter] = key
매핑 update_values — AI 결과 → 단어별 데이터 매핑 utils.py ↗

Gemini가 반환한 "단어 0~4번은 감정 happy" 같은 범위 지시를 각 단어 객체에 하나씩 매핑.

def update_values(data, asset, asset_type, default): direction_map = get_direction_for_numbers(asset, asset_type) for i, each_word in enumerate(data["words"]): each_word[asset_type] = direction_map.get(i, default) def get_direction_for_numbers(data, asset_type): direction_map = {} for entry in data: start = entry["text"]["start"] end = entry["text"]["end"] for num in range(start, end): direction_map[num] = entry[asset_type] return direction_map # [{"text":{"start":0,"end":4},"emotion":3}] → word[0..3].emotion = 3

Gemini API 8회 호출 — 각각의 역할

머리 방향

L / R / M

눈 방향

L / R / M (90% M)

캐릭터

누가 말하는지

감정

14종

바디 포즈

47종

강도

보통 / 강조

줌 레벨

0 / 1 / 2

배경

31종

에셋 레이어 합성 순서

🌄 배경 🧍 바디 😐 머리 👀 눈 👄 입

metadata.json의 픽셀 좌표에 따라 각 레이어가 정확한 위치에 배치된다

GitHub 리포지토리 현황

25 stars
🔌 3 forks
🟢 1 issue
🔀 6 open PRs
📝 45 commits
GPL-3.0 license
Pull Requests 분석 — 7개 중 1개만 머지됨
# 제목 상태 날짜
#2 Add comprehensive README.md merged 2025-07-02
#3 Improve frame metadata handling in video converter open codex 2025-10-13
#4 Refactor character asset updater open codex 2025-10-13
#5 Ensure NLTK resources are loaded on demand open codex 2025-10-13
#6 Add CLI configuration and validation (중복) open codex 2025-10-13
#7 Improve blink parsing for frame generation open codex 2025-10-13
#8 Add CLI configuration and validation (중복) open codex 2025-10-13

주목할 점: 6개 오픈 PR 전부 같은 날(2025-10-13) 6분 간격으로 생성, codex 라벨. AI 코딩 에이전트가 일괄 생성한 리팩토링 PR인데, 5개월째 리뷰 없이 방치 중. #6과 #8은 제목까지 동일한 중복 PR.

커밋 타임라인 — 2주 스프린트 후 6개월 침묵

2024-08-17 ~ 09-26 — 핵심 기능 구현 (2주 스프린트)

에셋 매핑, 이미지 합성, CSV 생성, 줌, 배경, 눈깜빡임, 영상 컴파일 등 전체 파이프라인 완성. 커밋 메시지: eyes access doneblinking addedvideo making added

2024-10 ~ 2025-06 — 9개월 공백

2025-07-02 — README 추가 (bot)

google-labs-jules bot이 PR #2로 README 자동 생성. 유일하게 머지된 PR.

2025-09-06 ~ 09-10 — 통합 엔트리포인트 + 정리

create_animation.py 생성, requirements.txt 추가, README 업데이트. main 브랜치 마지막 활동.

2025-10-13 — codex PR 6개 일괄 생성 (미머지)

AI 에이전트가 리팩토링 PR 6개를 6분 만에 생성. 이후 활동 없음.

컨트리뷰터

oyekamal — 45 commits (사실상 1인 개발)
google-labs-jules[bot] — 1 commit (README)

실전 순서

1

스크립트(.txt)와 나레이션 오디오(.mp3) 준비

2

Gentle Docker 컨테이너 실행 → 오디오-텍스트 강제 정렬(단어별 타임스탬프)

3

Gemini API 8회 호출 → 감정·포즈·배경·카메라 등 연출 지시 자동 생성

4

g2p_en으로 단어 → 음소 변환, 프레임별 입 모양 CSV 생성

5

Pillow로 5레이어(배경→바디→머리→눈→입) 합성, 중복 프레임 캐싱

6

OpenCV로 PNG → 24fps MP4 컴파일, FFmpeg로 오디오 병합

장점

  • 완전 오픈소스 — 코드·에셋 전체 공개, 커스터마이징 자유
  • LLM 기반 자동 연출 — 감정 14종, 포즈 47종, 배경 31종 조합을 AI가 결정
  • 음소 단위 립싱크 — Gentle 강제 정렬 + g2p로 입 모양이 자연스럽게 맞음
  • 프레임 중복 캐싱으로 합성 시간 대폭 절감

단점

  • 초기 프로토타입 — 하드코딩 경로, API 키 노출 등 프로덕션 수준 아님
  • 영어 전용 — g2p_en이 영어 음소만 지원, 한국어/일본어 불가
  • Gemini API 8회 호출 + 6초 간격 → AI 분석만 최소 48초 소요
  • 캐릭터 에셋 1개만 활성화 — 멀티캐릭터는 구조만 있고 에셋 부족

사용 사례

유튜브 스토리 채널 — 나레이션만 녹음하면 캐릭터 애니메이션 영상 자동 생성 교육 콘텐츠 — 강의 스크립트를 캐릭터가 설명하는 형태로 변환 프로토타이핑 — 본격 애니메이션 제작 전에 스토리보드 영상 빠르게 확인