1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

動画生成AIを使わずClaude CodeとCodexで人形劇アニメをつくった仕組み

1
Last updated at Posted at 2026-10-15

人形劇風アニメーションをつくりました

「シコウスル森のなかま」という、7〜9分ほどの人形劇アニメを7話つくりました。
こもれび谷という森で、ウサギさん、タヌキくん、キツネくん、リスさん、アナグマくん、岩長老が、冬支度、屋根の葺き替えといった仕事をしながら、ちょっとした失敗をしたり、気づきを得たりするお話です。

題材は、社会人やソフトウェアエンジニアの仕事・学習・組織の悩みです。
これまで私が書いてきた記事のテーマを、森の出来事に翻訳しています。
例えば第4話「運の悪いこと」は「マーフィーの法則とトンビに襲われた思い出」、第7話「三つのクルミ」は、「ドメイン駆動設計は残酷」等がもとになっています。
タイトルの「シコウ」は、考える「思考」と、やってみる「試行」をかけています。

登場人物の設定画

人形やセットは実在しません。
絵も声も音楽も生成AIで作り、Pythonで合成して動かしています。
そして、その制作のほとんどを Claude Code に進めてもらい、範囲のはっきりした作業は Codex に任せました。
私がやったのは、テーマを渡すこと、脚本や映像を確認して「ここが変」と伝えること、そして最後に承認することくらいです。

この記事では、その制作の仕組みをご紹介します。
全体の構成、データの流れ、品質を上げるための工夫、途中で作ったものをなるべくそのまま載せていきます。

全体像

項目 数
話数 7話(+毎話共通のオープニング44.5秒、エンディング33.0秒)
公開版の合計 約55分
ショット 412
台詞・ナレーション 345行
生成した静止画 約2,100枚(うち作り直して退けたもの約240枚)
合成・音声の Python スクリプト 83本、約14,500行
期間 2026年10月9日〜14日

1話あたりの静止画は、背景が12〜22枚、1ショット1枚のキー静止画が50〜63枚、口や目の差分が160〜210枚ほどです。
これを Python で1話あたり約9,000フレームに合成しています。

制作に使用したツールと、それぞれの役割は下記のとおりです。

  • Claude Code:進行役です。脚本、指示文、スクリプトを書き、生成物を目で確かめ、合成して書き出します。
  • Codex:画像生成(codex exec の組み込み image_gen、gpt-image-2)の窓口です。効果音の合成スクリプトのような、範囲がはっきりして結果を確かめられる作業も任せました。
  • Gemini:声(TTS)、台本を渡さない聞き取り、静止画の中の顔の位置の検出、ミックスの聞き取り確認。
  • Lyria:要所に流す30秒ほどの曲。
  • Python(numpy, Pillow, scipy)と ffmpeg:合成と書き出し。
  • Blender:形を一貫させたい機械や、場所の配置を決めるための簡易セット。

1話の流れは下記のとおりです。
★のところで私が確認し、承認するまで次の大きな工程には進みません。

ファイルとスクリプトの関係で見ると、下記のようになります。
左上の台詞CSVから始まり、すべての工程が「音だけの仮編集」で決まった秒数を基準に動いているのがポイントです。

最初は3Dで作ろうとしていた

実は、最初は Blender で全部を3Dで作るつもりでした。
人形劇らしさを出すなら、人形を3Dで作って動かすのが王道でしょう。

同じ場面(キツネくんが「外だと、雨が心配で……」と言う5秒)を、Claude Code と GPT にそれぞれ独立して3Dで作ってもらい、比べてみました。
Blender はバックグラウンドで動かし、毛はカーブ毛で40万本以上、編み物の質感はプロシージャルで作っています。

左が設定画、右が Claude Code 版の3D

左が設定画、右が3Dです。
頑張ってはいるのですが、別人ですね……。

GPT 版の3D

こちらは GPT 版です。
どちらも、設定画の手作り感(不揃いな毛束、使い込んだ布、とぼけた顔つき)に届きませんでした。
体が筋肉質になる、口吻の形が違う、手が手袋のように見える。
足りないのは顔の「形」で、毛の本数やレンダーのサンプル数を増やしても埋まりません。

コストもかかります。

3D(Claude Code 版) 静止画+効果
尺・内容 5秒、1体、1カット 13.4秒、2体、3カット、台詞3本
計算時間 60フレームのレンダーに約68分 画像生成8枚を並列で約6分、合成4分16秒
作業時間 約1時間47分(それでも造形の差が残る) 指示から完成まで約20分

これを全編・6体・3セットに広げるのは見合いません。
そこで、制作2日目に方針を変えました。

生成した静止画を土台にして、小さな動きだけで見せる。

3Dは捨てたわけではなく、使う場面を下記の4つに絞りました。

  • 奥行きで見え方が変わるもの(雨、落ち葉)
  • 複数のカットで形や位置を揃えたいもの(機械、水車、場所の配置)
  • 物理的な動きが見どころになるもの
  • カメラが大きく回り込むもの

静止画を動かすという割り切り

静止画方式は、おおまかには下記のようなものです。

  • 1ショットにつき1枚、人物と背景が入った「キー静止画」を生成する
  • 話している人物の口と、全員の目だけを、差分の画像に差し替える(口パクと瞬き)
  • カメラをゆっくり寄せる(1.045倍程度、ショットごとに左右を交互に)
  • 空気の粒(ほこり、花粉、ランプのまわりの暖かい粒)を薄く重ねる
  • フィルムの粒子とゲートウィーブ(フィルムの揺れ)を2コマごとに入れる

人物は切り抜かず、動かしません。
最初は人物を緑背景で生成して切り抜き、背景に重ねていたのですが、どうしても浮いて見えたのです。

第1話の初期の方式:背景と、緑背景の人物と、口・目の差分を別々に作っていた

第1話の確認で「タヌキくんが浮いている」と指摘を受け、キー静止画をそのまま使い、顔のまわりだけを差し替える方式に変えました。
足もとの影も、照明のなじみも、もともと1枚の絵なので崩れません。

人形の口のパーツを何種類か作っておき、コマごとに付け替える。
生成AIを使わない場合は、フェルト人形を作り、口のパーツを3つ作って、写真を撮ればよいわけです(どこまで出来るかは別として)。

脚本と「1話の型」

脚本の前に、作品全体の決まりごとを1つの文書にまとめました。

作品の約束
教訓を読み上げず、登場人物の選択と結果で見せる。誰も完全な悪役にしない。正論、善意、効率化、挑戦には副作用もある。 働き方を扱うが、労働礼賛にはしない。技術用語は森の出来事に翻訳する。
世界
電気もコンピューターもない手仕事の世界。常設の会社はなく、季節ごとに「しごと組」をつくる。
1話の型
具体的な依頼 → 見え方の違い → もっともらしい近道 → 小さな成功 → 副作用の発覚 → 問い直し → 不完全な改善 → 短いオチ。
技術テーマの翻訳辞書
DDD → 東西の村で同じ木の実を別の名前で呼ぶ。属人化 → キツネくんだけが水車のメンテナンス方法を知っている。

この辞書があるので、「この記事のテーマで1話つくって下さい」と記事のURLを渡すだけで、Claude Code が森の出来事に置き換えた脚本を書いてくれます。

第7話の場合は、下記のような対応になりました。

記事のテーマ 森の出来事
ユビキタス言語 「今日から、クルミとは、殻つきの実のこと」と一つに決める
境界づけられたコンテキスト 西の村では、クルミは中身のこと。返される
ドメインエキスパートとの対話 「みなさんの仕事では、何を『一つ』と数えますか」
コンテキストマップ 青クルミ・殻クルミ・むきクルミ。受け渡すところで数え直す

教訓は読み上げません。
帳面の数が増えたり減ったりすること、黒板の一つの絵、茶色く染まった手で見せます。

脚本には、台詞の一覧(CSV)と、読みの難しい語の一覧と、尺の見積もりを付けます。
尺は、台詞が約4.3字/秒、ナレーションが約3.7字/秒、台詞の間が0.6秒という目安で見積もります。

line_id,scene,character,text,direction
601,6,ウサギ,教えてください。みなさんの仕事では、何を、『一つ』と数えますか,…
606,6,ウサギ,クルミは、三つ、ありました,…
804,8,ウサギ,……どの、クルミですか,…

direction の列は、声の演技の指示です。
ここに書いたことが、そのまま TTS への指示文になります。

声:TTSと、台本を渡さない聞き取り

声は Gemini の TTS です。
登場人物ごとに声を決めています(ウサギさんは Kore、タヌキくんは Puck、ナレーターは Schedar など)。

台詞CSVの1行を、1リクエストにします。
複数行をまとめて1回で作り、無音で切り分ける方法も試しました。
台本を渡さずに比べてもらったところ、5行中5行で「1行ずつのほうが良い」という結果になりました(まとめると演技が平板になり、余計な息が入る)。
回数の上限があるので節約したいところですが、演技のある台詞は1行ずつにしています。

def make_payload(row, readings):
    _, voice, style = VOICES[row['character']]
    # Explicit reading overrides affect synthesis only, never the subtitle text.
    spoken = row['text']
    for a, b in readings.items():
        spoken = spoken.replace(a, b)

readings は読みの辞書です。
「一割」を「いちわり」、「青い実」を「あおいみ」のように、合成用の文だけを置き換えます。
字幕の文は変えません。
置き換えは辞書の順に行うので、「割」より先に「一割」を書いておかないと「一わ」になります(実際になりました)。

生成した声は、台本を渡さず Gemini に書き起こしてもらいます。
台本を渡すと、それっぽく聞こえてしまうからです。
書き起こしたかなと、pykakasi で作った期待するかなを比べ、一致率が0.9未満なら印を付けます。

def kana(text, readings):
    for a, b in readings.items():
        text = text.replace(a, b)
    k = ''.join(x['hira'] for x in pykakasi.kakasi().convert(text))
    return re.sub(r'[^ぁ-ゖー]', '', k)

ただし、これにも落とし穴がありました。
第3話で「楽しなよ」が らくしなよ のつもりが たのしなよ と読まれていたのに、自動チェックは満点だったのです。
pykakasi も同じように「たのし」と読んでいたからですね。
それ以来、読みが2通りある漢字は生成前にすべて辞書で指定し、点数ではなく「聞こえたかな」を目で読むようにしています。

読みが怪しい行は、古いテイクを retired/ に移してから、指示を足して1回だけ録り直します。
第7話では3行を録り直しました。

台詞 聞こえ方 足した指示
合いました はいました 「あいました」は、息を入れずに「あ」からはっきり始める
むきクルミ、六百。確かに、だって 六百か。確かに 「ろっぴゃく」のあとに何も足さない
何を割ろうかな 何をおわろうかな 「わろう」は「わ」からはっきり、「割る」の意味で

TTS は1日の回数に上限があります。
上限に当たったら、待って繰り返したりせず、すぐ止めるようにしています。

if exc.code not in (429, 500, 502, 503) or attempt == 3 or 'per day' in body:
    raise RuntimeError(f'Gemini HTTP {exc.code}: {body.replace(key, "[REDACTED]")[:900]}') from None

API キーがログに出ないよう、エラーの本文からも消しています。
地味ですが大事です。

音だけの仮編集で、本当の尺を決める

声がそろったら、映像より先に「音だけの仮編集」を作ります。
台詞を0.6秒ずつ空けて並べ、ト書きの動作のぶんだけ間を足していきます。

BEATS = {'101': 3.0, '102': 3.0, ... '501': 15.0, ...}   # 台詞の前に置く、動作の秒数
for r in rows:
    t += GAP + BEATS.get(r['line_id'], 0.0)
    x = trim(decode(metas[r['line_id']]['path']))
    placed.append({'line_id': r['line_id'], 'start_s': round(t, 3),
                   'end_s': round(t + len(x) / SR, 3)})
    t += len(x) / SR

'501': 15.0 は、第7話の「夜、ウサギさんが一人で青い実の皮をむく」15秒の沈黙です。
台詞のない時間も、ここで秒数として決めます。

この仮編集の JSON(各台詞の開始・終了秒、場面の範囲)が、以後のすべての工程の時間の基準になります。
ショットリストも、口パクも、効果音の位置も、字幕も、この秒数から計算します。
録り直しで台詞が短くなったときは、次の台詞の前の間で吸収して、後ろの時刻を動かさないようにしています。

映像制作の経験はないのですが、アニメの現場で絵より先に声を録る「プレスコ」に近いのかもしれません。

ショットリストと指示文を「コード」にする

尺が決まったら、ショットリストを作ります。
これは表計算ではなく、Python のコードです。

# (id, start, scene, lines, method, description, plate, time, chars, props, action)
('S8-04', 366.0, 8, ['804'], '会', 'ウサギ・キツネ・リスが、いっせいにタヌキを見る「……どの、クルミですか」',
 'ws-desk-am', 'morning', 'UKR', 'ng',
 'Usagi ' + DESK + ', her notebook open; Kitsune and Risu stand just behind her at the right; all three have '
 'turned at once and stare toward frame LEFT (at someone off-screen at the left end of the table), '
 'straight-faced. ' + STAIN + ' ' + PROP_TEXT['g'] + '.'),

1ショットは、ID、開始秒、場面、台詞、分類、説明、背景、時間帯、人物、小道具、動作の11項目です。
分類は「会(会話・口パク)」「差(差分で小さな動き)」「切(切り抜き)」「景(景色)」の4つ。

DESK や STAIN は使い回す文の部品です。

LOOK_L = 'looking toward frame LEFT'; LOOK_R = 'turned and looking toward frame RIGHT'
DOOR = 'standing in the LEFT half of the frame in front of the open doorway, ' + LOOK_R
DESK = 'sitting behind the work table at the RIGHT of the frame, ' + LOOK_L

これを実行すると、下記が書き出されます。

  • ショットリストのJSON(合成で使う)
  • 画像生成の指示文(prompts.md)と、ジョブの一覧(jobs.txt)
  • 人が読むためのショットリスト(Markdown)

jobs.txt は、1行が1枚です。
「指示文の節の名前」「出力先」「参照画像」を並べます。

S8-04 assets/stills/ep07/animatic-v01/S8-04.png assets/characters/usagi-san.png assets/characters/kitsune-kun.png assets/characters/risu-san.png assets/stills/ep07/props-v01/techo.png assets/stills/ep07/plates-v01/ws-desk-am.png

参照画像の順番は「人物の設定画(最大4枚)→ 小道具 → 背景(最後)」に決めていて、指示文に「最後の画像が場所です」と書きます。
画面に映る人物は、全員の設定画を渡します。
渡さないと、別人が描かれます。

このジョブを、下記のシェルスクリプトで codex exec に渡します。

codex exec --cd "$PWD" < /dev/null \
  "Use your built-in image_gen tool (gpt-image-2) to generate the image below and save it as $OUT.
Prefer a 16:9 landscape size at the highest resolution available (for example 2048x1152 or 1536x864); if only 1536x1024 is possible use that.
${REFS:+Reference images to attach to image_gen as visual input:$REFS}
Do not modify any other file.

$PROMPT"

< /dev/null を付けないと、codex が標準入力を待って止まります。
これを xargs -P 7 で7本並列に流します。

grep -v '^\s*#' "$JOBS" | grep -v '^\s*$' | while read -r sec out refs; do
  [ -e "$out" ] && continue
  ...
done | xargs -P "$N" -L 1 sh -c '...gen_still.sh "$P" "$sec" "$out" "$@"...' _

出力先にファイルがあれば飛ばします。
作り直すときは、古いファイルを retired/ に移してから流します。

1つの物は1つの形

画像生成でいちばん困るのは、ショットごとに物の形が変わることです。
帽子の形が変わる、黒板の絵が変わる、部屋の間取りが変わる。

そこで、何度も出てくるものは先に「設定画」を作り、毎回参照に渡します。

第7話の小道具の設定画

背景も、人物なしの状態で先に作ります。
日ごと・場面ごとに状態が変わるものは、前の状態の背景を編集して、段階ごとに作ります。

第6話:屋根の葺き替えの進み具合

第7話:黒板の状態

第7話の黒板は、何もない状態 → クルミの絵を一つ丸で囲んだ状態 → 三つの絵を矢印でつないだ状態、と3段階です。
人物の入るショットでは、黒板の部分をこの寄りの背景に合わせて描かせます。

数や状態は、脚本に表として持っておきます。

日 拾った 皮をむいた 割った
1日目 300 270 540
2日目(定義を一つに決めた後) 330 300(殻つき) —

これを決めておかないと、2日目なのに1日目の数を言う台詞ができたりします(実際にありました)。

Blender の使いどころ

形の一貫性がどうしても必要なものは、Blender で作りました。
3Dで最終の絵を作るのではなく、画像生成の参照にするための3Dです。

機械:歯車がちゃんと噛み合うこと

第3話の「なんでも削る機械」は、最初は画像生成だけで描いていました。
すると、ベルトが何にも掛かっていない、歯車が宙に浮いている、といった絵になります。
それっぽく見えるのですが、仕組みとしてはつながっていないのです。

そこで、踏み板 → クランク → はずみ車 → ベルト → 歯車(14枚と21枚)→ 刃、と動力が実際につながる模型を Blender で作りました。

def gear(name, c, r, n, y0, y1, m, phase=0.0):
    """Spur gear along y: pitch radius r, n teeth, faces at y0 and y1."""
    mod = 2 * r / n
    ra, rf = r + mod, r - 1.25 * mod
    pts = []
    for k in range(n):
        a = 2 * math.pi * k / n + phase
        w = math.pi / n
        for da, rr in ((-0.5 * w, rf), (-0.3 * w, ra), (0.3 * w, ra), (0.5 * w, rf)):
            pts.append((rr * math.cos(a + da), rr * math.sin(a + da)))
    bm = bmesh.new()
    front = [bm.verts.new((c[0] + x, y0, c[2] + z)) for x, z in pts]
    face = bm.faces.new(front)
    ext = bmesh.ops.extrude_face_region(bm, geom=[face])
    ...
D_AB = GEAR_A_R + GEAR_B_R                       # centre distance: the pitch circles touch

歯の形を点で並べて面を作り、押し出しているだけです。
2つの歯車の中心距離を、ピッチ半径の和にしています。
これで、歯車がちゃんと噛み合います。

形だけのクレイ → 材質の色を付けたレンダー → 生成した設定画

このレンダーを参照に渡し、「部品ごとに形をそのまま保って、写真風の設定画にして」と頼みます。
はずみ車のスポーク6本、ベルトの掛かり方、真鍮の歯車の噛み合わせが、そのまま古びたオークと鋳鉄の写真になりました。
以後のショットでは、この設定画を参照に渡します。

第5話の水車も同じ方法です。
水車 → 横軸 → 面歯車(ピン歯24本)→ かご形の小歯車(棒8本)→ 縦軸 → 石臼、とつながっています。

第5話の水車小屋(壁と屋根を外したもの)

噛み合わせは、コードの中で確かめています。

FACE_R, FACE_N, PIN_R, PIN_N = 18.0, 24, 6.0, 8
AXLE_Z, FACE_X, SHAFT_X = 40.0, -25.0, -19.0
...
assert math.isclose(FACE_R/FACE_N, PIN_R/PIN_N)   # 歯の間隔が等しい
assert math.isclose(SHAFT_X-PIN_R, FACE_X)        # 2つのピッチ円が接する

Blender のレンダー → 設定画 → 背景

簡易セット:場所の配置を決めること

場所も同じ問題を抱えています。
別々に生成した背景で、小屋の戸口の位置や棚の段数が食い違うのです。

そこで、直方体と円柱だけの簡易セットを Blender で作り、ショットごとのカメラから見たレンダーを、背景の参照に渡しました。

第1話の作業小屋の簡易セット

CAMERAS = {
    # name: (location, target, lens). Wider than the final shots so the reference shows context.
    'sh180A_usagi': ((34, -40, 24), (-58, -2, 20), 40),
    'sh180B_kitsune': ((-34, -40, 24), (60, 6, 22), 40),
    'sh190_board_top': ((0, 26, 70), (0, 30, 18), 50),
    ...
}
for name, (loc, tgt, lens) in CAMERAS.items():
    cd = bpy.data.cameras.new(name); cd.lens = lens
    cam = bpy.data.objects.new(name, cd); cam.location = loc
    d = Vector(tgt) - Vector(loc)
    cam.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
    sc.collection.objects.link(cam); sc.camera = cam
    sc.render.filepath = os.path.join(OUT, f'{name}.png')
    bpy.ops.render.render(write_still=True)

単位は cm で、人形が約30cm、部屋が120×90×65cm のミニチュアです。
カメラは「位置・注視点・レンズ」の表で決め、to_track_quat で注視点に向けています。
全部コマンドライン(blender -b --factory-startup -P layout_workshop.py -- --out ...)で完結します。

グレーの簡易セット → 生成した背景

第4話の「風見の丘」は、地形ごと作りました。
グリッドの各頂点を、高さの関数で持ち上げています。

def h(x, y):
    """Ground height on the hill: a gently domed top, then a steep slope down to the valley floor."""
    r = math.hypot(x, y)
    if r <= TOP:
        return 50 - 25 * (r / TOP) ** 2
    t = min((r - TOP) / 500, 1)
    return 25 - 445 * (3 * t * t - 2 * t * t * t)

bpy.ops.mesh.primitive_grid_add(x_subdivisions=220, y_subdivisions=220, size=2400)
hill = put(bpy.context.object, 'hill', 'grass')
for v in hill.data.vertices:
    v.co.z = h(v.co.x, v.co.y)

道は帯状のメッシュに Shrinkwrap をかけて地面に沿わせ、白い円柱で人形の立ち位置の目印を置いています。

簡易セット → 生成した背景(風見3本の高さと並び、小屋の位置が一致)

この丘では、昼食の会話の「切り返し」も簡易セットで決めました。
2つのカメラを、2人を結ぶ線の同じ側に置くことで、北側の人物は画面の左を、南側の人物は画面の右を向くようになります。
映画でいう180度ルールですね。

# the lunch conversation: two opposite views across the rock (shot / reverse shot), both cameras east of the
# north-south line between the two groups, so north-side faces look frame-left and south-side faces frame-right
'rock_north': ((125, -265, 28), (105, -80, 22), 30),
'rock_south': ((135, 45, 30), (105, -420, 75), 28),

切り返しの組(上が簡易セット、下が生成した背景)

第2話の薬草畑では、真上から見た地図もレンダーしました。
赤い線が、題材の「近道」です。
地図は画像生成には渡していませんが、どこに何があるかを決めるのに役立ちました。

第2話の薬草畑の地図

文字を彫る

誤字を回避するため、画像生成には文字を描かせません。
タイトルや看板の文字は合成で入れますが、オープニングの看板の「シコウスル森のなかま」は、Blender で板に彫りました。

cu.extrude = 0.0005
cu.bevel_depth = d
cu.bevel_resolution = 0       # straight, sloping walls: a chisel cut
cu.offset = -d * 0.6          # the opening at the surface stays close to the letter's width
...
mod = b.modifiers.new('cut', 'BOOLEAN')
mod.operation = 'DIFFERENCE'
mod.solver = 'EXACT'

フォントのカーブにベベルを付けて、V字のノミ跡の形にし、Boolean で板から引いています。
これを真上から正投影でレンダーし、「彫ったレンダー ÷ 彫っていないレンダー」の比を、陰影として生成した看板に掛け合わせます。

shade = np.clip(carved / np.maximum(flat, 1e-3), 0.15, 1.8)
out = plate * shade * (1 - m + m * stain)

生成した看板 → 彫ったレンダー → 陰影だけを掛け合わせた結果

雨と落ち葉

雨は、奥行きによって大きさも速さもボケ方も変わります。
平面の素材を重ねるより、3Dで作ったほうが自然です。
1,600本の雨筋を板ポリゴン1枚ずつで作り、毎フレーム numpy で頂点を書き換え、EEVEE で透過 PNG の連番にしています。

def update(me, pos, rng_alpha):
    d = np.array([WIND, 0.0, -SPEED]); d /= np.linalg.norm(d)
    side = np.array([d[2], 0.0, -d[0]])  # perpendicular in the XZ plane (faces the camera)
    w = (WIDTH * (1 + pos[:, 1] / 6.0))[:, None]  # far drops slightly wider so they do not vanish
    a = pos; b = pos + d * STREAK
    quad = np.stack([a - side * w, a + side * w, b + side * w, b - side * w], 1).reshape(-1, 3)
    me.vertices.foreach_set('co', quad.astype(np.float32).ravel())

96コマで一周するループにして、第4話と第6話で使い回しました。

ここまでの Blender の作業は、どれも生成AIなしで同じことができます。
簡易セットのレンダーを下絵にして、自分で絵を描く、あるいはミニチュアを作って写真を撮る。
むしろ、絵を描く人にとっての「パースの下書き」そのものかもしれません。
また、スクリプトを再実行すると、リポジトリにある参照画像とピクセル単位で同じものができることも確かめています。
コードで作った下書きは、何度でも作り直せるのが良いですね。

立ち位置と視線

画像生成でもう一つ難しいのが、視線です。
「画面の左を見て」と書いても、3回続けて右を向いたりします。
そして、向かい合って話しているはずの2人が、同じ背景の前で同じ方向を向いていたりします。

第6話でこれが再発したので、第7話ではショットリストの前に、場面ごとの立ち位置の見取り図を書くことにしました。

位置 背景 誰 向き
棚側 ws-desk-* ウサギさん(机に座る) 画面の左を見る
戸口側 ws-board-* 話しかける人たち(戸口の前に立つ) 画面の右を見る

第7話の作業小屋:棚側と戸口側の背景

キー静止画の例

生成のたびに、全ショットを並べたコンタクトシートを作って、Claude Code に自分の目で確かめてもらいます。

第7話のキー静止画59枚

縮小した一覧では視線を見誤るので、怪しいものは顔を切り出して拡大して確かめます。
足の本数、消えた人物、板書の食い違いも、ここで拾います。
自動のチェックが通っても、目で見るまで「済み」と言わない、という決まりにしています。

それでも漏れはあります。
第7話の S8-04 では、手袋の参照のために「干し場の道具」の設定画を渡したところ、作業小屋の机の上に桶と外皮の山が紛れ込みました。

設定画から紛れ込んだ桶を、机の上だけ消す編集で直した

1枚の設定画に写っている物は、全部出てきてしまうのですね。
何度も出る小物は、単体の設定画を作るのが良いでしょう。
こうした教訓は、次の話のために手順書に書き足していきます(後述)。

口と目の差分

ここからが合成の話です。

キー静止画は、たいてい口が半開きです。
そのままでは「閉じた口」として使えません。
そこで、差分を2段階で作ります。

  • 段階1:話すショットは、全員の口を閉じた「基準」を作る。話さないショットは、全員が目を閉じた瞬きの1枚。
  • 段階2:基準を編集して、話す人物の「口・小」「口・大」「瞬き」と、聞いている人物たちの瞬きを作る。

すべて基準の編集なので、位置がそろいます。

キー静止画 → 基準 → 口・小 → 口・大 → 瞬き

指示文は、変えるところを一つに絞り、それ以外は同じに保つように書きます。

KEEP = ('Keep everything else identical to the input: {rest}, the background, framing, lighting and colors. '
        'Output the same resolution as the input.')

f'Edit the attached image. Change ONLY the mouth of {who}: make it a small but clearly visible '
'speaking mouth - the lips parted into a small dark oval opening, about half as big as a fully open '
'mouth (as when saying "u" or "e"). The change must be visible. '

{who} で人物を名指しするときは、その絵で見えている特徴で呼びます。
布団に入っていて服が見えないキツネくんを「紺のカーディガンのキツネ」と呼んだら、誰を編集すればよいか伝わらず、何も変わらない画像が返ってきました。

第7話では、この差分を210枚作りました。

どこを差し替えるか

差し替える範囲は、手で測りません。
まず、Gemini の物体検出で、キー静止画の中の各人物の顔(眉からあごまで)の箱を取ります。

- "head:risu" (chestnut squirrel), "head:tanuki" (tanuki in rust-red jacket), "head:usagi" (white rabbit;
  the head WITHOUT the long ears), ...
  one box per visible head covering the face from brows to chin (eyes and mouth inside)
- "doorway": the open door opening of a building, if one is visible

次に、基準と差分を比べて、実際に変わった範囲を測ります。
編集で絵全体が少しずれることがあるので、まず位相相関でずれを補正し、差をぼかして、しきい値を超えた塊を探します。

fa, fb = np.fft.fft2(ga - ga.mean()), np.fft.fft2(gb - gb.mean())
r = fa * np.conj(fb)
c = np.fft.ifft2(r / (np.abs(r) + 1e-9)).real
dy, dx = np.unravel_index(np.argmax(c), c.shape)
...
lab, n = ndimage.label(d > thr)

変化が見つからない差分は使わずに知らせ、作り直します。
第7話でも1枚(キツネくんの口・小)が「変化なし」で返ってきたので、作り直しました。

差し替えの範囲:瞬きは顔の上半分を幅いっぱいに、口は目より下だけ

ここには、第1話でつまずいた工夫があります。
口の差分は、目も少しだけ描き変えてしまうのです。
口の範囲が目にかかっていると、話すたびに目がぴくぴく動き、瞬きのときに片目だけ開いて見えます。
そこで、瞬きを先に「顔の上 0.7 まで、頭の幅いっぱい」で差し替え、口はその目の下端より下だけにしました。

# the mouth edits also redraw the eyes a little, and a mouth patch reaching the eyes makes them
# twitch while talking and covers one eye of a blink (seen in v01)
eb = EYES_BOTTOM.get((sid, c), 0.7)
within = {'eyes': [box[0] - w // 4, box[1] - h // 4, box[2] + w // 4, box[1] + int(eb * h)],
          'mouth': [box[0] - w // 4, box[1] + int(min(0.5, eb - 0.1) * h), box[2] + w // 4, box[3] + h // 3]}

横顔の人物は口が目のすぐ下にあるので、その人物だけ 0.52 に下げる、といった例外も表で持っています。

合成エンジン

合成は composite.py(約780行)が担っています。
シリーズ全体の中核です。
設定は JSON で、場面2つずつのグループ(s1-s2, s3-s4, …)ごとに作ります。

{"name": "usagi_s5-05", "base": "assets/stills/ep07/fx-v01/s5-05-base.png", "key": false, "feather": 8,
 "patches": {
   "eyes_closed": {"image": ".../s5-05-usagi-eyes-closed.png", "box": [1063, 205, 1448, 310]},
   "mouth_open":  {"image": ".../s5-05-usagi-mouth-open.png",  "box": [1200, 314, 1296, 406]},
   "mouth_small": {"image": ".../s5-05-usagi-mouth-small.png", "box": [1200, 321, 1273, 386]}}}

口パク

口パクは、音から決めます。
2コマごと(1/12秒ごと)に、音量と、「550〜1100Hz と 200〜550Hz のエネルギーの比」を測ります。

def band_ratio(seg, sr):
    """Energy 550-1100 Hz over 200-550 Hz: a cheap stand-in for the first formant (jaw opening)."""
    sp = np.abs(np.fft.rfft(seg * np.hanning(len(seg)))) ** 2
    fr = np.fft.rfftfreq(len(seg), 1 / sr)
    lo = sp[(fr >= 200) & (fr < 550)].sum() + 1e-12
    hi = sp[(fr >= 550) & (fr < 1100)].sum()
    return float(hi / lo)
nb = db[max(j - 1, 0):j + 2].mean()
want = voiced[j] and db[j] >= nb - 1.0          # on a syllable peak, not a dip
rising = j > 0 and db[j] > db[j - 1] + 2.0
if want and run >= 2 and not rising:             # replacement mouths flap: max 2 open poses
    want = False

音節の山で開き、谷で閉じます。
開く口が連続すると口がばたついて見えるので、2回続いたら一度閉じます。
これを3つの状態で表すと、下記のようになります。

第1話の口パクの確認用ログ(話す区間と、閉・小・大の状態)

人形アニメの交換口も、たいてい数種類の口を使い回します。
3状態でも、意外と話しているように見えるものです。

差し替えと仕上げ

差し替えのときも、位相相関で位置を合わせ、縁のリングで色の差を補正してから、ぼかしたマスクで混ぜます。

dy, dx = phase_shift(gb, gv)
if abs(dy) > 30 or abs(dx) > 30:  # implausible: the edit moved the whole figure, do not trust
    dy = dx = 0
var = np.roll(var, (dy, dx), (0, 1))
m = diff_mask(...) if mode == 'diff' else feather_ellipse(base.shape, box, feather)
ring = (m > 0.02) & (m < 0.5)
shift = (base[ring].mean(0) - var[ring].mean(0)) if ring.any() else 0

仕上げに、2コマごとに変わるフィルムの粒子と、ゆっくりした揺れを入れます。

rng = np.random.default_rng(1000 + (f - 1) // 2)  # grain on twos, like re-shot film
img = img + rng.normal(0, g['grain'], img.shape[:2])[..., None] * (0.6 + 0.4 * img)
wx = g['weave'] * smooth_noise(f // 2 * 0.5, 11, 1.0)   # gate weave on twos

人形アニメは2コマ打ち(1枚を2コマ続けて撮る)が多いので、粒子も2コマごとに変えています。
ほんの少しのことですが、これがあるだけで「撮った」感じが出ます。

ほかにも、下記のような機能があります。

  • 瞬きの時刻は、ショットIDから決まる擬似乱数で3〜3.5秒おき(何度合成しても同じ)
  • 空気の粒は場所と時間で決める(林の綿毛、小屋の日差しのほこり、夕方の暖かい粒、夜のランプのまわり)
  • 戸口の鈴は、検出した戸口の幅と位置から、決まった大きさで梁に吊るす(第1話で「鈴があったりなかったりする」と2回指摘され、決まりにした)
  • 最初のショットに話数と題名、最後のショットの終わりにフェードアウト

フレームの番号は、グループの境目で1コマ抜けたり重なったりしないよう、全編共通の格子で数えます。

fr = lambda t: int(round(t * FPS)) - int(round(T0 * FPS)) + 1  # whole-story frame grid

4グループを並列に合成して、1話あたり15〜20分ほどです。
合成したフレームを、オープニング、エンディングとつないで書き出します。

第7話の合成済みフレーム

音の仕上げ

BGM は要所だけ

劇伴はずっとは流さず、1話に4曲ほど、要所だけにしています。
無音は「考えている時間」として扱います。

曲は Lyria で、30秒ほどのものを場面ごとに作ります。
共通の指示文に、場面ごとの内容・テンポ・調・終わる秒を足しています。

'peel': ('干し場の夜', 'SCENE 5: night behind the workshop; the white rabbit who keeps the ledger squats alone by a '
         'lamp, peeling green walnut husks with her bare paws, which slowly stain brown. Patient, curious, a '
         'little lonely, quietly absorbed: slow solo nylon-string guitar, a few soft low pizzicato notes, ...'
         'Approximately 66 BPM, B minor. Very quiet and sparse. A clean ending at about 22 s.'),

生成した曲は、Gemini に聞かせて、楽器、歌声の混入、大きな山、ぶつ切り、点数を返させます。
第7話の「手袋」の曲は、21秒あたりに打撃のような雑音があると指摘されたので、頭の9.5秒だけ使いました。

効果音は合成する

効果音は、すべて numpy で手続き的に合成しています。
実が落ちる音、外皮をむく音、木槌、鉛筆、チョーク、秋の夜の虫。
48kHz、乱数の種は固定で、何度作っても同じ音になります。
この合成スクリプトは、範囲がはっきりしていて結果を確かめやすいので、Codex に任せました。

合成した音で困るのは、木を叩く音や足音が木琴やマリンバに聞こえることです。
音程が出てしまうのですね。
そこで「音程感」を数値で測っています。

def tonality(pcm):
    """Full-file Hann-window FFT: peak / median magnitude in 80-8000 Hz."""
    frequencies = np.fft.rfftfreq(len(pcm), 1 / SR)
    magnitude = np.abs(np.fft.rfft(pcm * np.hanning(len(pcm))))
    band = magnitude[(frequencies >= 80) & (frequencies <= 8000)]
    return float(np.max(band) / max(np.median(band), 1e-12))

40未満を目安にしています。
第7話では、干し棚に実を置く音が43〜74、袋を振る音が125と高く出ていました。

置き方は、絶対時刻ではなく「ショットID+ショット内の秒」で書きます。
ショットの時刻が変わっても追従するからです。

loop('S5-01', 'S5-08', 'crickets_night', 0.035, '秋の夜の虫', lib=S2)
add('S5-01', 1.0, 'husk_peel_1', 0.12, 'ウサギが素手で外皮をむく')
add('S5-02', 3.6, 'lamp_set', 0.06, 'キツネがランプを置く')
add('S7-03', 0.2, 'nut_crack_2', 0.05, 'リスの木槌(このあと止まる)')

音量は0.05〜0.12と、かなり小さめです。
人形は30cmなので、音も小さくないとおかしいのです。

自動のミックス確認

仮編集のいくつかの区間を切り出して、Gemini に聞かせます。
台詞が聞き取れるか、曲と効果音の量、気になる点(区間内の時刻つき)、10点満点の点数を JSON で返させます。

第7話の1回目は、下記のような結果でした。

{
 "file": "mix_190.mp3",
 "dialogue_clear": true,
 "sfx_level_ja": "不適切(楽器的な音階効果音)",
 "issues_ja": [
  "0:06-0:11: マリンバのような音階のある足音・動作効果音が使われており、大人向けパペット劇の素朴な世界観に対してコミカルに浮いている"
 ],
 "score_out_of_10": 5
}

指摘の時刻から、どの音かを特定し、音程感を測って、原因を1つに絞ります。
音程の出ていた2つの音を外したり、前の話の音に替えたりし、大きすぎた木槌・鉛筆・曲を下げました。
直したあとは、指摘の出た区間で9〜10点になりました。

もちろん、これは人の耳の代わりではありません。
最終的には私が通しで見て確認しています。
ただ、人が聞く前に「明らかにおかしいもの」を拾ってくれるので、確認の負担はかなり減りました。

公開準備

公開用の書き出しでは、下記を行います。

  • 音量を −14 LUFS、トゥルーピーク −1 dBTP に合わせる(ffmpeg の loudnorm で2パス)
  • 字幕は映像に焼き込まず、別ファイル(SRT)にする
  • サムネイルを作る
  • YouTube に入力するタイトル、説明文、チャプター、設定をまとめる
def loudnorm(src, target, dst):
    """Two-pass EBU R128 normalisation (linear gain when possible), true peak -1 dBTP."""
    f = f'loudnorm=I={target}:TP=-1.0:LRA=11'
    ...

サムネイルの絵も生成しますが、文字は描かせず、左の1/3を空けておいてもらいます。
そこに題名を縦書きで合成します。

生成した絵 → 題名を合成したサムネイル

7話分のサムネイル

チャプターは、各場面の最初のショットの時刻にオープニングの長さを足して作ります。
ここも、仮編集の秒数がそのまま使えます。

品質を上げるための決まりごと

ここまでの話から、どの工程でも守った決まりごとをまとめると、下記のようになります。

  • 上書きしない:作り直すときは古いファイルを retired/ に移してから。
  • 画像に文字を描かせない:文字は合成で入れる。
  • 画面に映る人物は全員、設定画を渡す。
  • 1つの物は1つの形:何度も出る物は先に設定画(必要なら Blender の模型)を作り、日ごとの状態は表にして守る。
  • 自分の目で確かめる:生成のたびにコンタクトシートを見る。自動チェックが通っても、目で見るまで「済み」と言わない。
  • 一度に1つだけ直す:指摘が出たら原因を1つに絞り、その区間だけ確認し直す。
  • 上限のある API は節約する:拒否されたら繰り返さない。
  • 確認を挟む:★のところで人が確認するまで、次の大きな工程に進まない。

そして、失敗するたびに「何が起きて、原因は何で、どう直したか」を記録していきました。
一部を抜粋すると下記のとおりです。

話 起きたこと 原因 直し方
第1話 切り抜いた人物が浮く 背景と人物を別々に生成していた キー静止画の顔だけを差し替える方式へ
第1話 話すたびに目がぴくぴく 口の差し替え範囲が目にかかっていた 瞬きを先に、口は目の下端より下だけ
第1話 戸口の鈴があったりなかったり ショットごとに描かせていた 鈴は1種類、戸口の幅から大きさと位置を計算して合成
第3話 「楽しなよ」が「たのしなよ」なのに満点 期待する読みも同じ誤読 読みの辞書、聞こえたかなを目で読む
第3話 ベルトが何にも掛かっていない 仕組みを知らずに描いている Blender でつながった模型を作り参照に
第4話 まだ立てていない風見が写っている 日ごとの状態を指示していなかった 本数を表にし、ない風見だけ消す編集
第5話 差分が何も変わっていない 見えていない服の特徴で名指し 見えている特徴で呼ぶ
第6話 向かい合う会話で同じ方向を向く 立ち位置を決めずに描いていた 場面ごとの見取り図を先に書く
第6話 釘を打つ音が木琴に聞こえる 合成した音に音程がある 音程感を測り、前の話の音に替える
第7話 机の上に干し場の桶が出る 設定画に写っていた別の物が漏れる 小物は単体の設定画を作る

第1話は、アニマティックを12版まで作り直しました。
これが第7話の制作時は2版だけで済んでいます。脚本の承認から公開準備まで大幅な時間短縮ができました。
同じ失敗を繰り返さなくなったぶん、速くなったのです。

Claude Code と Codex の役割分担

最後に、AI どうしの分担について。

手順書を「スキル」にする

失敗と直し方の記録は、Claude Code のスキル(~/.claude/skills/still-puppet-animation/)にまとめています。
スキルは、特定の作業の手順書を Claude Code に読み込ませる仕組みです。

still-puppet-animation/
├── SKILL.md            … 全体の流れ、どの工程でも守ること
├── references/
│   ├── planning.md     … 脚本と尺の見積もり
│   ├── voice.md        … TTS と聞き取り
│   ├── visuals.md      … 設定画、背景、キー静止画、視線
│   ├── compositing.md  … 差分と合成
│   ├── sound.md        … BGM、効果音、ミックス確認
│   ├── release.md      … 公開準備
│   └── lessons.md      … 過去の失敗と直し方
├── scripts/            … 汎用化したスクリプト(project.json で動く)
└── templates/

新しい工程に入る前に、該当する節を読む。
新しい失敗をしたら、ここに書き足す。
これを繰り返すことで、話を重ねるごとに Claude Code が賢くなっていきました。
正確には、Claude Code が賢くなったのではなく、手順書が育ったのですが。

会話をまたいで覚えておくこと(「報告は日本語で」「コミットは頼まれたときだけ」「TTS は1日の上限を節約する」など)は、Claude Code のメモリに置いています。

Codex に任せたこと

Codex に任せたのは、下記の2種類です。

  • 画像生成:codex exec の組み込み image_gen を、シェルスクリプトから1枚ずつ呼ぶ
  • 範囲のはっきりした作業:効果音の合成スクリプト、雨や落ち葉の層など

任せるときは、指示書を Markdown で書いて渡します。
何を作るか、ファイル名、長さ、質感、守ること(ほかのファイルを変えない、コミットしない、認証情報を出さない)を明記します。
結果は、Claude Code が自分で確かめます(効果音なら音程感を測り、ミックスで聞かせる)。

Claude Code は全体の進行と判断、Codex は手を動かす職人、という分担です。
人間のチームでいうと、ディレクターと、腕の良い外注先のような関係かもしれません。

私がやったこと

私がやったのは、確認と判断です。

  • テーマを決め、記事を渡す
  • 脚本を読んで、承認する
  • アニマティックを見て、「この人物は誰を見ているのか」「ここの音が大きい」と伝える
  • 公開するかどうか、何をコミットするかを決める

ソフトウェア開発で、AIが書いたコードをレビューするのと似ています。
雑に頼めば雑なものが返ってきますし、何が正しいかを判断できなければ、良いものかどうかもわかりません。
脚本の約束、1話の型、守ることの一覧といった「レール」と「ガードレール」を人間側が持っていたことが、7話を作り切れた理由だと考えています。

生成AIを使わなかったら

この記事で紹介した仕組みは、生成AIがなくても成り立ちます。
置き換えると、下記のようになるでしょう。

工程 この制作 生成AIを使わない場合
設定画・背景・キー静止画 gpt-image-2 フェルト人形とミニチュアセットを作って撮影する、または絵を描く
簡易セット・機械 Blender(同じ) Blender(同じ)。下絵として使う
口・目の差分 画像の編集 交換口のパーツを作って撮り直す、または描き替える
声 Gemini TTS 自分や友人が演じて録音する
読みの確認 台本なしの書き起こし 台本を見ずに聞き直す
顔の位置 物体検出 手で矩形を測る(1話で50枚ほど)
曲 Lyria 作曲する、または利用条件の合う素材
効果音 合成(同じ) 合成(同じ)、または録音する
合成・書き出し Python(同じ) Python(同じ)

時間はとてもかかります。
1話に約50ショット、差分が200枚、人形が6体とセットが3つ。
おそらく、1話に数か月はかかるでしょう。

一方で、ショットリストをコードで持つこと、音を先に決めて時間の基準にすること、状態を表で守ること、上書きしないこと、失敗を記録して手順書を育てること。
こうした仕組みの部分は、生成AIを使っても使わなくても同じです。
むしろ、手作業の時間が短くなったぶん、仕組みの良し悪しがそのまま品質に出るようになったと感じました。

まとめ

7話、約55分の人形劇アニメを、Claude Code と Codex と一緒に作りました。

3Dで人形を作る王道は、設定画の手作り感に届かず、コストも見合いませんでした。
そこで、生成した静止画を土台に、口と目だけを差し替え、カメラの寄りと空気の粒とフィルムの揺れで見せる方式に割り切りました。
3Dは、形の一貫性が要る機械や場所の配置、奥行きのある雨に絞って使っています。

品質を支えたのは、派手な技術ではなく地道な決まりごとです。
音を先に決めて時間の基準にする。
1つの物は1つの形。
自分の目で確かめる。
一度に1つだけ直す。
そして、失敗を記録して手順書を育てる。

これは、ソフトウェア開発とまったく同じですね。
AIはとても頼れるパートナーですが、何を作りたいのか、何が正しいのかを決めるのは、やはり人間の仕事なのでしょう。

当初に予定していた7話分の作成が完了しましたので、森のなかまたちは、これで一区切りです。
試したい映像表現や新しいツール、アプローチが出てきたり、何か伝えたいテーマができたら、こもれび谷に戻ってこようと思います。

それではシーズン2としてパワーアップした森のなかまたちの活躍をお楽しみに。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?