🎭

Synctoon — スクリプト1枚から2Dアニメ動画を生成するAIパイプライン

Geminiが感情・動作・背景を演出し、Gentleがリップシンクを合わせ、Pillowがフレームを合成する

ナレーション音声とスクリプトテキストファイルの2つを投げると、2Dトーキングヘッドアニメ動画が出来上がる。キャラクターがセリフに合わせて口を動かし、感情に応じて表情が変わり、シーンに合った背景が敷かれる。

Synctoonはこれを全部自動で処理する。

3段階パイプライン

全体の流れはシンプルだ。分析 → 合成 → 動画化。

Stage 1 — AI分析 + 音声アライメントcore.py

Gentle(Dockerベースの強制アライナー)に音声とテキストを渡すと、単語ごとの開始/終了タイムスタンプが返る。「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を1行ずつ読み、5レイヤーを合成する。背景 → ボディ → 頭 → 目 → 口。Pillow(PIL)でPNG画像を重ね合わせる。

metadata.jsonに各レイヤーの位置とサイズがピクセル単位で定義されている。頭の上に目が(x, y)座標に来て、口が(x, y)座標に来る。

賢いのは重複フレームキャッシングだ。キャラクター+感情+ポーズ+頭+目+口+背景+ズームの組み合わせが同じなら、前回合成したPNGを再利用する。口だけ変わらない区間が連続すれば、1フレームで数十フレームをカバーする。

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)にグルーピングされる。1つの口の形が複数の類似音素をカバーする構造。

アセットシステム

キャラクターアセットは階層的だ:

images/characters/character_1/の下にbody/head/eyes/mouth/background/フォルダがある。

ボディポーズ47種。感情14種。背景31種。2Dパペットアニメとしては組み合わせ数がかなり多い。各フォルダにバリアント画像が複数あり、同じポーズでも毎回異なる画像がランダム選択される。

現時点の限界

プロトタイプ段階なのがコードの随所に見える。ファイルパスがハードコードされており(/home/oye/Downloads/...)、APIキーがソースに露出している。キャラクターも現在1体のみ有効(マルチキャラ構造は整っているが)。

Gentle Dockerコンテナが起動していなければならず、Gemini API呼び出し間に6秒のsleepが入る(レートリミット回避)。8回呼ぶのでAI分析段階だけで最低48秒。

Web UIはない。全てCLI。

それでもアプローチ自体は面白い。LLMを「アニメーション・ディレクター」として使うパターン。テキストを読んで感情、ポーズ、カメラ、背景を決めるのは人間がやっていた仕事だが、それをプロンプト8本で自動化した。

パイプラインコード探索

各カードをクリックすると該当部分の実際のソースコードが展開されます

Stage 1-AGentle — 強制音声テキストアライメント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()
Stage 1-BGemini API 8回連続呼び出し — AI演出指示core.py ↗

6秒間隔でGeminiにテキストを送り、頭方向・目方向・キャラクター・感情・ポーズ・強度・ズーム・背景を分析。

head_movement = analyzer.get_head_movement_instructions(transcript) time.sleep(6) emotions = analyzer.get_emotion(transcript, emotions) time.sleep(6) body_action = analyzer.get_body_action(transcript, body_actions) time.sleep(6) # ... 合計8回 update_values(response_json, head_movement, "head_direction", "M") update_values(response_json, emotions, "emotion", 1)
検証marshmallow 自己修正ループtext_aligner.py ↗

AI応答をmarshmallowスキーマで検証、失敗時はエラーをプロンプトに付加してリトライ(最大3回)。

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
音素g2p_en — 単語→音素変換 + フレーム配分add_phonemes.py ↗

"Hello" → HH, AH, L, OW。時間区間を音素数で均等配分、フレーム別口の形を決定。

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["phonemes_frame"] = distribute_frames(each_data)
Stage 25レイヤー合成 — 背景→ボディ→頭→目→口CharacterManager.py ↗

metadata.jsonのピクセル座標で目と口を頭に、頭をボディに、キャラクターを背景上に合成。

def get_character(self, Character, Emotion, Body, ...): head, _ = self.get_asset(Character, "head", Head_Direction) eyes, eyes_meta = self.get_asset(Character, "eyes", Emotion, ...) 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)
キャッシュ重複フレームキャッシング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"]: image, meta = manager.get_character(...) image.save(image_file) else: frame_data["key_counter"][key] += 1 # 再利用!

アセットレイヤー合成順序

🌄 背景 🧍 ボディ 😐 頭 👀 目 👄 口

実践ステップ

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体のみ有効 — マルチキャラ構造はあるがアセット不足

ユースケース

YouTubeストーリーチャンネル — ナレーションを録音するだけでキャラアニメ動画を自動生成 教育コンテンツ — 講義スクリプトをキャラクターが説明する形式に変換 プロトタイピング — 本格アニメ制作前にストーリーボード動画を素早く確認