# 3Dキャラの作り方を真似できる動画に ― ハコブくんの7つの手順を Blender と Unity で

- 記録: LOG502（ひとり研究所の裏側）
- 公開: 2026/11/13
- 記事のページ: https://pixelstomotors.com/record/hakobu-3d-seven-steps-explainer-video
- サイト: Pixels to Motors（ADAPT DESIGN / 島津 栄一） https://pixelstomotors.com/
- 要約: マンガのロボット・ハコブくんと看護師の大城さんを3Dにした作業を、見た人が同じ順番で作れる9分の解説動画にした。手順に番号を付け、Blender と Unity の札で道具を分け、残っていなかった失敗の絵は直す前のコードから描き出し直した。

> AI が読むための記事の全文です（コード・実行ログを含む）。引用するときは上の「記事のページ」の住所を使ってください。

---

**マンガのキャラクターを3Dにした作業は、手順に番号を付けて、どの手順がどの道具かを画面で分けると、見た人が同じ順番で作れる解説動画になる。** 4コママンガ『ハコブくん』のロボットのハコブくんと、看護師の大城さんを 3DCG にした作業（LOG496）を、9分05秒の YouTube の解説動画「[マンガのキャラを3Dにする7つの手順 Blender と Unity で作ってみた](https://youtu.be/sYYM82DQclk)」にした。台本から声・図・書き出し・サムネイル・概要欄・投稿文まで、1つの会話で Claude が作り、エイイチが途中で見て直す所を決めた。

この動画づくりで、次のことが変わる。

- 3Dを作りたい人が、7つの手順をそのまま追える。手順1・2は準備、3〜6は Blender（3Dの形を作る無料のソフト）、7は Unity（ゲームや場面を動かすソフト）と、画面の札で分けた
- 次に「作り方の回」を作るときは、決まり（動画の決まりの文書 video_rules.md の2節28と4-4e）を読めば、同じ形で始められる。手順に番号を付ける、謎解きは無理に入れない、画像を見せる所ではカードを隠す、などを書き足した
- 失敗した時の絵が残っていなくても、直す前のコードから描き出し直して見せられる。袖が裂けた絵3枚を、サイトの GitHub に残っていた直す前のコードで描き出した

動画1本にかかった時間は測っていない。作業フォルダのファイルの時刻では、台本の2版から公開用の一式までが 16:38〜21:33 だった（台本の1版を書き始めた時刻は残っていない）。次の作り方の回で、同じ区切りで時間を記録して比べる。

きっかけは、病院の搬送ロボットのシミュレーションのために作った2人の3Dだった。人を青い筒、ロボットを白い箱で表していたシミュレーションでは、看護師さんがどこに立ってどう薬を受け取るかが見えない。そこで受け渡しを近くで見せるための3Dを作り、その作り方を動画にした。作る途中では、首が長すぎた、肩がいかつすぎた、袖が3回裂けた、と失敗が続いた。動画ではその失敗もそのまま見せている。

| やったこと | なぜ | 結果 |
| --- | --- | --- |
| 台本を「作る順番の7つの手順」で書き直した | エイイチから、見た人が自分で再現できる順番を説明するよう指示があったため | 78文・9分05秒。謎解きの形と立ち位置の話は外した |
| 画面の形を「大文字型＋動きあり」にした | 手順の名前と道具の名前を大きく見せるため | 手順の一覧に 準備・Blender・Unity の札を付けた |
| 画像を見せる場面ではカードを一時的に隠した | カードを右へずらして画像と並べると、どちらも見えにくいため | 場面の半分ほどまで画像だけを見せ、そのあとカードを出す |
| 袖が裂けた絵を描き出し直した | 当時の絵がどこにも残っていなかったため | 直す前のコード（p2m-site のコミット e3daa10）から、1回目・2回目・3回目の3枚 |
| 読み間違いを直した | 「首長すぎ」と「角（かど）」を声が違う読みで読んだため | 読みの表に足し、その文だけ声を作り直した |
| 公開した記事 LOG496 と台本を、別のエージェントに1文ずつ突き合わせてもらった | 書いた本人が確かめると同じ見落としをするため | 9か所を直した |

この記事の数字は、作業フォルダのファイル（台本の narration.py・場面の時間の表 timeline.json・書き出した動画）から読んだもの。効果の欄のうち、見た人が実際に作れるかは見込みで、動画のコメントと、記事のコードを使った人の反応で確かめる。

[図解: LOG502_seven_steps_timeline.html](https://pixelstomotors.com/embeds/LOG502_seven_steps_timeline.html)


## 作業ログ ― 台本を「作る順番の7つの手順」に書き直した

最初の台本（1版）は、謎解きの形で書いていた。3Dのハコブくんを初めて見た時の感想と、看護師さんの立ち位置の話を、謎として置く形だった。台本を見せたところ、エイイチから次の指示があった。

> 何が分かったか（謎）…無理して入れないで。3Dを構築していくための作業の順番を説明するようにしてください（どのように進めれば、ユーザー自身で再現して作れるかを教えてください）

立ち位置の話については、謎というには小さすぎる、という判断だった。そこで2版は、作る順番を7つの手順に分けた。

| 手順 | 中身 | 道具 |
| --- | --- | --- |
| 1 | 使い道と、見た目の決まりを先に決める | 準備 |
| 2 | 元の絵（マンガの三面図）と、大きさをそろえる | 準備 |
| 3 | 形を数字で作る（Python のコードで Blender を動かす） | Blender |
| 4 | 絵に描き出して、2人を並べて直す | Blender |
| 5 | 骨と顔を入れる（骨の名前は Unity の人型の決まりどおり） | Blender |
| 6 | ポーズを付けて、服や肌が破れないかを確かめる | Blender |
| 7 | FBX のファイルで Unity に渡し、借りた動きで動かして撮る | Unity |

FBX は、3Dの形と骨をまとめて別のソフトに渡すためのファイルの形式。Unity はこのファイルをそのまま読める。

### 台本で決めた4つのこと

エイイチの指示から、台本の書き方を4つ決めた。

1. 最初に「何に使うための3Dか」を言う。病院の薬の受け渡しを近くで見せるため、を冒頭の2文で言う
2. 言葉は平易にし、難しい言葉は要所だけにする。骨（ボーン）は「見えない棒」、FBX は「Unity がそのまま読めるファイル」と言い換えた
3. 面白くない所は早送りで飛ばす。大城さんの形を15回作り直した所と、Unity での細かい直しは早送りにした
4. 真面目なだけにしない。「ハコブくん、ついに体ができました。……腕は、まだありません。」「人間の首は、数字を変えても短くなりません。3Dの、いいところです。」のような一言を、手順ごとに1つずつ置いた

途中で、エイイチから Blender と Unity の区別について指摘があった。

> どこまでがBlenderでどこまでがUnityかがわからない

台本に「形を作るのは Blender。動かして撮るのは Unity。」の1文（手順3の頭）と、「ここで、Blender から Unity へ、バトンタッチです。」の1文（手順7の頭）を足した。画面では、手順の一覧の右に道具の札を付けた（下の「画面の形」の節）。

### エイイチの言葉は、本人に聞いた言葉だけ

動画には、エイイチの言葉が6つ出てくる。2つはこの会話で聞いた言葉（「Unityでここまでできるの、熱いっ！」「人の描写のバランス悪っ！」）で、残りの4つは LOG496 に書いた言葉（全体の流れ・首と肩・袖・これから見せたいもの）。台本を見せたあとに、3Dのハコブくんを初めて見た時の感想と、看護師さんを見た時の正直な感想の2問を聞いて、答えをそのまま入れた。エイイチの声は、本人の声ではなく AI の声で読ませている（概要欄にもそう書いた）。

### 公開した記事 LOG496 との突き合わせ

台本を書いたのとは別のエージェントに、LOG496 と台本を1文ずつ突き合わせてもらい、食い違い・根拠のない事実・言い過ぎを出してもらった。出た指摘のうち9か所を直した。外部のレビュー（codex）は、自分の作業を題材にした回なのでかけていない（2026-11-01 にエイイチが決めた運用）。


## 声 ― ElevenLabs で作り、読み間違いを2つ直した

声は ElevenLabs（文章を人の声で読み上げる AI のサービス）で、エイイチの Mac で作った。ハコブくんの声とエイイチの声は別の声で、78文のうち6文がエイイチの声。

読み間違いは2つあった。

| 文 | 書いた字 | 声が読んだ読み | 直した読み |
| --- | --- | --- | --- |
| 手順4「首長すぎ、肩がいかつすぎ。」 | 首長すぎ | 違う読み | くびながすぎ |
| 手順4「ハコブくんの角だけが硬く見えました。」ほか | 角 | つの | かど |

どちらも、読みの表（narration.py の ELEVEN_READ）に足して、その文だけ声を作り直した。「角」は、動画の3:53あたりで「つの」と読んでいたのをエイイチが見つけた。「角」が出る3つの文をまとめて作り直した。

### 冒頭の自己紹介を2文に短くした

前の動画まで、自己紹介は「エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。」と長かった。エイイチから短くするよう指示があり、3つの案から次の案Cを選んだ。

> Pixels to Motors、案内役のハコブくんです。エイイチが研究と開発で確かめたことを、ぼくがお伝えします。

自己紹介の2文だけ声を作り直し、前の声と合わせ直した（voice_eleven の「合わせ_紹介短縮後」）。冒頭の45秒ほどは「何に使うための3Dか」に使い、自己紹介はそのあと（0:46）に置いた。


## 画面の形 ― 大文字型に動きを付け、道具の札を付けた

画面の形は、エイイチが「大文字型＋動きあり」を選んだ。背景には Unity で撮った受け渡しの場面の寄りの映像を流し、手順の名前や短い言葉を黒いカードに大きく載せる。

![動画の冒頭。Unity で撮った病院の場面を背景に、黒いカードに「薬の受け渡しを近くで見せたい」と大きく出している](https://pixelstomotors.com/media/LOG502_01_hook.png)

手順の一覧には、右に道具の札を付けた。準備は灰色の枠、Blender はオレンジ（#E87D0D）、Unity は水色（#62BFF5）。手順の一覧は、目次とまとめの2か所で同じ形で出す。

![7つの手順の一覧。手順1・2に「準備」、3〜6に「Blender」、7に「Unity」の札が付いている](https://pixelstomotors.com/media/LOG502_02_tool_badges.png)

- ねらい：どの手順をどのソフトでやるかを、見ている人が迷わないようにする
- 変わったこと：手順の一覧と、手順3・7の頭のカード（「Blender → Unity」）で、道具の切り替わりを毎回見せた
- 見込める効果：真似して作る人が、Blender の作業と Unity の作業を分けて準備できる。分かりやすくなったかは、コメントで確かめる

![手順7の頭。「Blender → Unity」のカードで、形と骨を FBX で渡すことを見せている](https://pixelstomotors.com/media/LOG502_06_baton.png)

### 画像を見せる所ではカードを隠す

最初の書き出しでは、画像を見せたい場面でも、カードと画像を並べていた。カードを中央から右へずらし、画像を左に置く形だった。エイイチから、画像の中身を見せたい時はカードを一時的に消し、無理に並べないよう指示があった。

場面を作る関数 bg_card に img_first という引数を足した。値は場面の長さに対する割合で、その割合の所まではカードを出さず、背景も暗くしないで画像だけを見せる。そのあとカードを出す。使ったのは15の場面（ハコブくんの角・大城さんの最初の形・袖が裂けた所など）。

![手順4の場面の前半。カードを出さずに、直す前のハコブくん（三面図の角のまま）の絵だけを見せている](https://pixelstomotors.com/media/LOG502_03_card_hidden.png)

- ねらい：画像が主役の場面で、画像の中身をちゃんと見せる
- 変わったこと：カードを横にずらして並べる形をやめ、時間で分けた（はじめは画像だけ、あとからカード）
- 見込める効果：画像とカードがどちらも小さくなることがなくなる。次の作り方の回からも、画像が主役の場面はこの形にする（video_rules.md 4-4e に書いた）

ほかにも、画面について次の指示があり、直した。

- 明朝体のフォントは使わない。カードと見出しはゴシックにした
- 画像を中途半端に斜めにしない。まっすぐ置いた
- 白い背景の時間を、意味のある形で減らす。紙色の無地だった場面の背景に、その話の映像や静止画を暗くして敷いた
- 画面に Blender の名前が出ていなかったので、手順3の場面と道具の札に入れた

### 早送りの間は、直した所に寄せる

大城さんの形を15回作り直した所は、早送りにした。最初は全身の絵を並べて早く切り替えるだけだったが、エイイチから、早送りの時は目や肩などに寄せて動きを付けるよう指示があった。

静止画の中の目や肩に、1か所ずつ寄りながら切り替えていく映像を作る関数（make_bg.py の ff_zoom）を書き、最初の形と直した形を交互に見せた。右上には「▶▶ 早送り」の札を出す。

![早送りの場面。大城さんの首と肩に寄った絵に「早送り」の札が付いている](https://pixelstomotors.com/media/LOG502_04_ff_zoom.png)


## 袖が裂けた絵を、直す前のコードから描き出し直した

手順6では、袖が3回裂けた話をする。動画にするには、裂けた時の絵が要る。ところが、当時の確認用の絵は作業フォルダにも KIOXIA にも残っていなかった。描き出した絵を見て直し、次の版で上書きしていたためだった。

エイイチから、拡大した絵はないのかと聞かれ、当時の絵はないこと、直す前のコードは残っていることを伝えた。エイイチの判断で、直す前のコードから描き出し直すことにした。

### 直す前のコードの探し方

大城さんの形を作るコード（nurse_build.py）は、サイトの GitHub（PixelstoMotors/p2m-site の tools/sim_people/）にある。袖を直した後の版は、コミット e3daa10 に入っている。そこから、袖の3回の直しを1つずつ逆にたどって、3つの版を作った。

| 版 | 袖の作り | 直す前の状態 |
| --- | --- | --- |
| 1回目の前（v1） | 袖は胴と1つの面。肩の所は腕の骨に引っぱられる | 腕を前に出すと、袖と胴のつなぎ目が裂ける |
| 2回目の前（v2） | 袖は別の部品。袖の中に入る所の肌が残っている | 袖の中の腕の肌が、外に飛び出す |
| 3回目の前（v3＝e3daa10） | 袖は別の部品で、肌は袖の中で消してある | 腕を上げると、袖の肩の所がゆがむ（このあと、袖をまるごと腕の骨に付けて直した） |

描き出しは、Claude の作業環境（クラウド）で、Blender を Python のライブラリ（bpy 5.3.0）として入れ、画面を開かずに動かした。ポーズは LOG496 と同じ「差し出す（give）」「指さす（point）」の2つで、肩の寄りと全身を描いた（pose.py）。

![直す前のコードで描き出し直した、差し出すポーズの肩の寄り。左から1回目の前・2回目の前・3回目の前](https://pixelstomotors.com/media/LOG502_07_torn_three.png)

動画では、この3枚を背景の静止画にして、それぞれの話の所で見せた。画面の左上には「直す前のコードで描き出し直した絵」と書き、概要欄にも「当時の絵とまったく同じではないかもしれません」と書いた。

![手順6の1回目の場面。袖と胴のつなぎ目が裂けた肩の寄りを背景にしている](https://pixelstomotors.com/media/LOG502_05_torn_bg.png)

- ねらい：失敗した時の絵がなくても、失敗をそのまま見せる
- 変わったこと：想像で描いた絵ではなく、直す前のコードから描き出した絵を使った
- 見込める効果：形をコードで作っていれば、どの時点の失敗でも描き出し直せる。次からは、確認用の絵を上書きせずに残す（今回は残していなかったため、この手間がかかった）


## 書き出し ― 9分05秒、78文

声・BGM・効果音を合わせ、場面の映像を作り、字幕を付けて書き出した。書き出した動画は 1920×1080・30fps・544.8秒。YouTube に上げた本編は約70MB、確認用は約25MB。

場面の書き出しの途中で、何度か止まった。ハコブくんの区画（画面の左下）とカードが重なると、書き出しの道具（render.py）が止まる決まりになっている。手順の一覧・まとめの一覧・手順3と7のカードの字を小さくして、重ならないようにした。動きの場面の書き出し（motion_kit）が時間切れで途中のファイルを残したこともあり、そのときは足りない場面だけを書き出し直した。

全場面の一覧は次の画像（大きいので縮めて見る）。

![動画の78文の場面を並べた一覧。大文字型のカードと、背景の映像や静止画が並んでいる](https://pixelstomotors.com/media/LOG502_10_scene_list.jpg)


## サムネイル・タイトル・概要欄

サムネイルは2案を作り、エイイチが左の案A（受け渡しの3DCG を背景に「マンガのキャラを3Dにする7つの手順」）を選んだ。案B は、袖が裂けた絵を背景に「袖が3回裂けた」とした案。どちらも、背景は Blender で描いた絵で、生成AIの画像は使っていない。

![選んだサムネイルの案A。受け渡しの3DCG を背景に、左に「マンガのキャラを3Dにする7つの手順」、右下にハコブくん](https://pixelstomotors.com/media/LOG502_08_youtube_thumb_A.png)

![使わなかった案B。袖が裂けた絵を背景に「袖が3回裂けた」](https://pixelstomotors.com/media/LOG502_09_youtube_thumb_B.png)

タイトルは、エイイチが「マンガのキャラを3Dにする7つの手順 Blender と Unity で作ってみた」に決めた。

概要欄には、7つの手順と目次、この動画で扱う範囲（実物のロボットはないこと、Blender を Python で動かしたこと、描き出し直した絵のこと、Mixamo から借りた動きは配らないこと）、作り方の記録として LOG496 へのリンクを入れた。


## 公開と、X・LinkedIn の予約

X と LinkedIn の文は、YouTube の公開予約の時刻に合わせて自動で予約される仕組みに置いていた（r2r2r-content の sns/VIDEO_hakobu-3d-seven-steps_sns_posts.json）。ところが、エイイチは公開予約を使わずにすぐ公開した。自動の同期は「公開の時刻を過ぎてから見つけた動画は入れない」決まりなので、このままだと X にも LinkedIn にも出ない。

そこで、予定表（sns/schedule.json）に手で入れた。X は公開の直後の 2026-11-13 22:50、LinkedIn は「業界の読み解き」の枠の空いている最初の日（2027-02-10 17:00）。

ショートは作っていない。本編から切り出すショートは、登録者や再生数が増えるまで作らない運用にしている（video_rules.md 9-0）。


## 動画の決まりに書き足したこと

この動画で決めたことを、動画の決まりの文書 video_rules.md（113回目の更新）に書き足した。

| 節 | 書き足したこと |
| --- | --- |
| 冒頭の作り方の5 | 自己紹介は2文（案C）。問いのあと（作り方の回は「何に使うのか」のあと）に置く |
| 2節28 | 作り方の回は、手順に番号を付けて並べる。最初に使い道を言う。道具の札を毎回見せる。謎が小さいなら謎解きの形にしない。早送りは直した所に寄せる。失敗の絵がなければ直す前のコードで描き出し直し、そう書く |
| 4-4e | 画像を見せる所ではカードを隠す。画像はまっすぐ・見出しはゴシック。白い背景の時間を減らす。手順の道具の札 |
| 更新履歴 | 読み（首長すぎ→くびながすぎ・角→かど）と、袖の絵の描き出し直し |


## 作業フォルダとファイルの置き場所

| 置き場所 | 中身 |
| --- | --- |
| iCloud の 動画/ハコブくんの3D/ | 作り方のファイル（narration.py・scenes.py・sound.py・thumb.py・ei_talk.py・台本・照合の結果・概要欄・タイトル・投稿文・voice_eleven・thumb の案A・サムネ素材・袖の描き出し直し） |
| KIOXIA の videos/ハコブくんの3D/アップロード用/ | YouTube に上げた物（ハコブくんの3D.mp4・サムネイル.png・タイトル.txt・概要欄.txt・投稿文_X_LinkedIn.txt） |
| KIOXIA の videos/ハコブくんの3D/ | 確認用の動画と、取り込みに使った tar（_取り込み/） |
| GitHub r2r2r-content の sns/ | X と LinkedIn の文（VIDEO_hakobu-3d-seven-steps_sns_posts.json） |

使わなかった版（サムネイルの案B・前の台本の写しなど）は、iCloud の 動画/_旧版/置き換え前_20261113_ハコブくんの3D_フォルダ整理/ に移した。


## 台本の全文（narration.py）

78文の台本と、読みの表。SEGS の1行が1文で、（場面の名前・ハコブくんの表情・字幕の文・後ろの間）。

```python
# -*- coding: utf-8 -*-
# 台本「ハコブくんの3D」— 病院の搬送ロボットの手渡しを近くで見せるために、ハコブくんと看護師の大城さんを3Dにした回（2026-11-13 2版・作る順番の7手順）
#   元の記事: LOG496（サービスロボット導入シミュレーション Log#45「受け渡しを寄りで見せる、ハコブくんと看護師の3D」）
#   映像: Unity の場面の寄り（KIOXIA service_sim/unity/closeup/ の依頼162・166・169・171）
#   エイイチの指示（2026-11-13）: 何に使うためかを最初に言う／平易な言葉で、難しい言葉は要所だけ／面白くない所は早送り／ユーモアを交える
#   作る順番を、見た人が真似して作れる形で説明する（エイイチの指示）。立ち位置の話（依頼167〜171 の引き出しとカード）は入れない（エイイチ「謎というには小さすぎる」）
#   エイイチの言葉: LOG496 の引用3つ（流れ・首と肩・袖）と、2026-11-13 に聞いた2つ（「Unityでここまでできるの、熱いっ！」「人の描写のバランス悪っ！」）
#   codex は自分のネタの回なのでかけない（2026-11-01 エイイチの決め）
# 各要素: (場面の名前, 表情, 字幕の文, 後ろの間[秒])
FACES = ("base", "smile", "surprise", "doubt", "think", "trouble", "joy", "wink")

SEGS = [
 # --- 何に使うためか（最初に言う）---
 ("hook_use", "base", "薬を運ぶロボットと、看護師さんが、薬を受け渡す。｜その場面を、近くから見せたい。", 0.35),
 ("hook_use2", "smile", "そのために、ハコブくんと、看護師の大城さんを、3Dで作りました。", 0.35),
 ("hook_joke", "joy", "ハコブくん、ついに体ができました。｜……腕は、まだありません。", 0.7),
 ("hook_why", "base", "これまでの病院のシミュレーションでは、人は青い筒、ロボットは白い箱でした。", 0.35),
 ("hook_why2", "think", "ロボットが何回止まったかは数えられます。｜でも、看護師さんがどこに立って、どう受け取るのかは、筒と箱では分かりません。", 0.35),
 ("hook_joke2", "trouble", "筒から薬を受け取る箱。……絵としては、ちょっと寂しいですよね。", 0.35),
 ("hook_today", "smile", "今日は、この3Dを作った順番を、そのまま真似できる形でお伝えします。｜手順は7つです。", 1.2),
 ("title", "smile", "Pixels to Motors、案内役のハコブくんです。", 0.35),
 ("title_eiichi", "smile", "エイイチが研究と開発で確かめたことを、ぼくがお伝えします。", 1.2),
 ("toc", "base", "7つの手順は、こうです。", 0.35),
 ("toc2", "base", "使い道を決める。元の絵と大きさをそろえる。形を数字で作る。｜絵に描き出して直す。骨と顔を入れる。ポーズを付けて確かめる。そして、Unity で動かす。", 0.35),
 ("toc_note", "base", "なお、ハコブくんはマンガのロボットで、実物はありません。今日の話は、ぜんぶ3DCGの中の話です。", 1.2),
 # --- 手順1 使い道を決める ---
 ("s1", "base", "手順1、使い道と、見た目の決まりを、先に決めます。", 1.2),
 ("s1_use", "base", "今回の使い道は、病院の場面を近くで見せること。｜パソコンの試算、病院の1日のシミュレーションの、その次の段です。", 0.35),
 ("ei_steps", "base", "エイイチは、こう書いていました。", 0.35),
 ("ei_steps", "think", "試算、全体のシミュレーション、場面の細部、実際のロボットでの動画。｜全体がつながっていけば、より伝わりやすくなると考えます。", 0.8),
 ("s1_look", "base", "見た目の決まりは、粘土の人形のような感じ。ただし、デフォルメしすぎない。｜人とロボットを、同じ決まりで作ります。", 0.35),
 ("s1_tip", "smile", "ここを先に決めておくと、あとで、なんか違う、で迷子になりません。", 1.2),
 # --- 手順2 元の絵と大きさ ---
 ("s2", "base", "手順2、元の絵と、大きさをそろえます。", 1.2),
 ("s2_ref", "base", "元の絵は、マンガ『ハコブくん』の三面図です。正面、横、背中の3枚。｜大城さんも、マンガに出てくる看護師さんです。", 0.35),
 ("s2_size", "base", "大きさは、実寸で作ります。ハコブくんは、高さ1メートル15センチ。大城さんは、身長1メートル65センチ。｜頭は、身長の約7分の1にしました。", 0.35),
 ("s2_joke", "wink", "ハコブくん、大城さんより50センチ小さいです。……見上げるのには、慣れています。", 1.2),
 # --- 手順3 形を数字で作る ---
 ("s3", "base", "手順3、形を、数字で作ります。", 1.2),
 ("s3_split", "base", "その前に、道具の役わけです。｜形を作るのは、Blender。動かして撮るのは、Unity。この2つを使い分けます。", 0.35),
 ("s3_tool", "base", "Blender は、3Dの形を作る無料のソフトです。画面は開かずに、Python のコードで動かしました。｜コードを書いたのは、AI の Claude です。", 0.35),
 ("s3_robot", "base", "ハコブくんは、三面図の外形を測って、Blender の中で、箱を組むように作ります。", 0.35),
 ("s3_human", "base", "人は、楕円の頭、丸い棒の腕、角を丸めた箱の胴、のように、簡単な形を Python で足し合わせて、Blender に渡します。", 0.35),
 ("s3_why", "smile", "首の長さも、肩の丸みも、ぜんぶコードの中の数字です。｜だから、首が長い、と言われたら、数字を1つ変えて作り直すだけ。", 0.35),
 ("s3_joke", "wink", "人間の首は、数字を変えても短くなりません。3Dの、いいところです。", 1.2),
 # --- 手順4 絵に描き出して直す ---
 ("s4", "base", "手順4、絵に描き出して、直します。", 1.2),
 ("s4_render", "base", "作った形は、正面、横、斜めから、確認用の絵に描き出します。これも、コードです。", 0.35),
 ("s4_side", "doubt", "ところが、2人を並べると、ハコブくんの角だけが硬く見えました。｜1人だけ、別の絵から切り抜いてきたみたいに。", 0.35),
 ("s4_fix", "base", "そこで、体の角を丸めて、タイヤを太くしました。｜顔の画面のまわりは丸めると白い線が出たので、マンガの形のまま残しています。", 0.35),
 ("s4_tip", "smile", "ここが、手順4のコツです。1人ずつではなく、並べて見る。", 0.35),
 ("s4_oshiro", "surprise", "そして、大城さんの、最初の形がこちら。", 0.35),
 ("ei_first", "base", "エイイチの、最初のひと言です。", 0.35),
 ("ei_first", "doubt", "首長すぎ、肩がいかつすぎ。", 0.8),
 ("ei_first2", "base", "正直な感想も、聞いてみました。", 0.35),
 ("ei_first2", "trouble", "人の描写のバランス悪っ！", 0.8),
 ("s4_ff", "joy", "ここから、首を短く、なで肩に、腕を短く、目を大きく。｜……このあたりは、早送りでどうぞ。", 0.35),
 ("s4_ff2", "base", "作り直しは、全部で15回でした。", 0.35),
 ("s4_tip2", "smile", "直す所は、言葉で言ってもらえば十分です。それを数字に直すのは、AI の仕事です。", 1.2),
 # --- 手順5 骨と顔 ---
 ("s5", "base", "手順5、骨と、顔を入れます。", 1.2),
 ("s5_bone", "base", "人の3Dを動かすには、体の中に、骨を入れます。骨といっても、見えない棒です。｜大城さんには、Blender で45本入れました。", 0.35),
 ("s5_name", "base", "ポイントは、骨の名前を、Unity の人型の決まりどおりに付けること。｜そうしておくと、よそで作った歩く動きなどを、そのまま当てはめられます。", 0.35),
 ("s5_face", "smile", "顔には、まばたきの目と、困った眉。口は、笑う・話す・驚くなど、5つの形を用意しました。", 0.35),
 ("s5_joke", "joy", "ちなみにハコブくんの顔は、画面です。眉は、ありません。｜……困った顔は、気持ちでがんばっています。", 1.2),
 # --- 手順6 ポーズで確かめる ---
 ("s6", "base", "手順6、ポーズを付けて、確かめます。今日いちばん手こずったところです。", 1.2),
 ("s6_pose", "base", "差し出す、両手で受け取る、引き出しの物をつかむ、おじぎ、困った顔など、8つのポーズを付けて、服や肌が破れないかを見ます。", 0.35),
 ("ei_sleeve", "surprise", "すると、エイイチから、こう言われました。", 0.35),
 ("ei_sleeve", "doubt", "体が動いた時に、服の袖が切れてるよ。", 0.8),
 ("s6_try1", "trouble", "1回目。腕を前に出すと、袖と胴のつなぎ目が裂けていました。｜胴と袖が、別々の骨に引っぱられていたんです。袖を、別の部品に分けました。", 0.35),
 ("s6_try2", "doubt", "2回目。まだ裂けて見える。今度は、袖の中の腕の肌が、外に飛び出していました。｜袖の中に入る所の肌は、作らないことにしました。", 0.35),
 ("s6_joke", "trouble", "見えない所は、作らない。……3Dの世界は、なかなか、割り切っています。", 0.35),
 ("s6_try3", "doubt", "3回目。腕を上げると、袖の肩の所がゆがむ。", 0.35),
 ("s6_fix", "surprise", "答えは、袖をまるごと、腕の骨に付けること。これで、腕を上げても、袖の形が変わらなくなりました。", 0.35),
 ("s6_tip", "smile", "動かすと、止まっている時には見えない所が壊れます。だから、ポーズを付けて確かめる手順を、飛ばさないでください。", 0.35),
 ("s6_hakobu", "wink", "ちなみにハコブくんは、服を着ていないので、袖の悩みはありません。", 1.2),
 # --- 手順7 Unity で動かす ---
 ("s7", "base", "手順7、Unity に運んで、動かします。", 1.2),
 ("s7_fbx", "base", "ここで、Blender から Unity へ、バトンタッチです。形と骨を、FBX という、Unity がそのまま読めるファイルにして渡します。", 0.35),
 ("ei_unity", "surprise", "Unity に入った3Dのハコブくんを見て、エイイチは、ひと言。", 0.35),
 ("ei_unity", "joy", "Unityでここまでできるの、熱いっ！", 0.8),
 ("s7_mixamo", "base", "歩く・うなずく・おじぎなどの動きは、Mixamo という、人の動きを無料で借りられる Adobe のサービスから、33本借りました。｜借りた動きのファイルは、決まりにより、そのまま配ることはしません。", 0.35),
 ("s7_scene", "smile", "こうして、Unity の場面の中で、2人が動き出しました。", 0.35),
 ("s7_scene2", "base", "薬剤部で、大城さんが、ハコブくんに薬を積む。｜届け先で、認証のカードをかざすと、引き出しが開き、薬を取る。", 0.35),
 ("s7_ff", "joy", "ここにたどり着くまでの細かい直しも、早送りでどうぞ。", 0.35),
 ("s7_end", "smile", "筒と箱だった世界に、やっと、手と顔が出てきました。", 1.2),
 # --- まとめ・これから ---
 ("sum", "base", "7つの手順を、もう一度。", 0.35),
 ("sum2", "base", "使い道を決める。元の絵と大きさ。数字で形。並べて直す。骨と顔。ポーズで確かめる。Unity で動かす。", 0.35),
 ("sum_code", "smile", "2人の形の作り方のコードは、記事に載せています。同じコードを動かせば、同じ形ができます。", 1.2),
 ("next", "base", "では、この3Dで、何を見せていくのか。エイイチは、こう話していました。", 0.35),
 ("ei_next", "think", "モノを渡す、受け取る、トラブルの時の動き、コミュニケーションの姿などを、表現できたらと思います。", 0.8),
 ("next2", "base", "まだ、作りはじめたばかりです。動きが本物の看護師さんに近いかは、これから確かめます。", 0.35),
 ("next_joke", "joy", "次は、廊下ですれ違う場面です。ハコブくん、ぶつからないように、がんばります。", 1.2),
 ("site", "smile", "作り方のコードと、くわしい記録は、サイトで毎日公開しています。pixelstomotors.com で、どうぞ。", 1.2),
 ("outro", "smile", "この動画が役に立ったら、高評価とチャンネル登録をお願いします。", 0.35),
 ("outro", "base", "ロボットの開発や導入の相談は、概要欄の連絡先から、エイイチに直接どうぞ。", 0.35),
 ("outro", "wink", "コメントは、エイイチが全部読んでいます。それではまた。", 1.2),
]

# エイイチの声で読む文（槙原の声を流用）。ハコブくんの口は動かさない
EI_LINES = [i for i, seg in enumerate(SEGS) if seg[2].startswith((
    "試算、全体のシミュレーション", "Unityでここまで", "首長すぎ", "人の描写の", "体が動いた時に", "モノを渡す、受け取る",
))]

# 「エイイチ」を残す文（自己紹介・アウトロのほか）。文の頭で探す
NAME_OK = [i + 1 for i, seg in enumerate(SEGS) if seg[2].startswith((
    "エイイチが研究と開発で確かめたことを",
    "エイイチは、こう書いていました",
    "Unity に入った3Dのハコブくんを",
    "エイイチの、最初のひと言",
    "すると、エイイチから",
    "では、この3Dで",
    ))]

READ = [
 ("Pixels to Motors", "ピクセルズ トゥー モーターズ"), ("YouTube", "ユーチューブ"),
 ("フィジカルAI", "フィジカルエーアイ"), ("AI", "エーアイ"), ("3DCG", "スリーディーシージー"), ("3D", "スリーディー"),
 ("Blender", "ブレンダー"), ("FBX", "エフビーエックス"), ("Claude", "クロード"), ("Unity", "ユニティ"), ("Mixamo", "ミクサモ"), ("Adobe", "アドビ"),
 ("pixelstomotors.com", "ピクセルズトゥーモーターズ ドット コム"),
 ("1人", "ひとり"), ("2人", "ふたり"), ("1つ", "ひとつ"), ("2つ", "ふたつ"), ("3つ", "みっつ"), ("4つ", "よっつ"), ("5つ", "いつつ"), ("8つ", "やっつ"),
 ("栄一", "えいいち"),
 ("『", ""), ("』", ""), ("｜", ""), ("…", ""),
]

ELEVEN_READ = [
    ('エイイチ', '栄一'),
    ('角', 'かど'),
    ('首長すぎ', 'くびながすぎ'),
    ('額だけ', 'がくだけ'), ('ロボットの側', 'ロボットのがわ'), ('人型', 'ひとがた'), ('Unity', 'ユニティ'),
    # この回で先回りして足す読み（--try で確かめる）
    ('3DCG', 'スリーディーシージー'), ('3D', 'スリーディー'), ('FBX', 'エフビーエックス'), ('1メートル65センチ', 'いちメートルろくじゅうごセンチ'), ('7分の1', 'ななぶんのいち'), ('50センチ', 'ごじゅっセンチ'),
    ('Blender', 'ブレンダー'), ('Mixamo', 'ミクサモ'), ('Claude', 'クロード'),
    ('pixelstomotors.com', 'ピクセルズトゥーモーターズ、ドット、コム'),
    ('1メートル15センチ', 'いちメートルじゅうごセンチ'),
    ('45本', 'よんじゅうごほん'), ('33本', 'さんじゅうさんぼん'), ('15回', 'じゅうごかい'),
    ('薬剤部', 'やくざいぶ'), ('三面図', 'さんめんず'), ('大城さん', 'おおしろさん'),
    ('袖', 'そで'), ('胴', 'どう'), ('眉', 'まゆ'),
]

OVERRIDE = {
 "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。｜その中で分かったことと、そこから考えた見立てを、ハコブくんがお伝えします。":
   "エイイチは、本業のデザイナーをしながら、ロボティクスの研究と開発をしています。その中で分かったことと、そこから考えた見立てを、ハコブくんが、お伝えします。",
}


def reading(t):
    t = OVERRIDE.get(t, t)
    for a, b in READ:
        t = t.replace(a, b)
    return t


def parse(seg):
    if len(seg) >= 3 and seg[1] in FACES:
        scene, face, text = seg[0], seg[1], seg[2]
        pause = seg[3] if len(seg) > 3 else 0.35
    else:
        scene, face, text = seg[0], "base", seg[1]
        pause = seg[2] if len(seg) > 2 else 0.35
    return scene, face, text, pause
```


## 場面の時間の表（timeline.json から）

書き出したときの、文ごとの始まりの秒と長さ。

| 文 | 場面 | 始まり（秒） | 長さ（秒） | 字幕の文 |
| --- | --- | --- | --- | --- |
| 0 | hook_use | 0.0 | 6.4 | 薬を運ぶロボットと、看護師さんが、薬を受け渡す。｜その場面を、近くから見せたい。 |
| 1 | hook_use2 | 6.8 | 4.9 | そのために、ハコブくんと、看護師の大城さんを、3Dで作りました。 |
| 2 | hook_joke | 12.0 | 5.0 | ハコブくん、ついに体ができました。｜……腕は、まだありません。 |
| 3 | hook_why | 17.7 | 5.8 | これまでの病院のシミュレーションでは、人は青い筒、ロボットは白い箱でした。 |
| 4 | hook_why2 | 23.9 | 8.4 | ロボットが何回止まったかは数えられます。｜でも、看護師さんがどこに立って、どう受け取るのかは、筒と箱では分かりません。 |
| 5 | hook_joke2 | 32.6 | 5.5 | 筒から薬を受け取る箱。……絵としては、ちょっと寂しいですよね。 |
| 6 | hook_today | 38.4 | 6.9 | 今日は、この3Dを作った順番を、そのまま真似できる形でお伝えします。｜手順は7つです。 |
| 7 | title | 46.6 | 3.7 | Pixels to Motors、案内役のハコブくんです。 |
| 8 | title_eiichi | 50.6 | 4.0 | エイイチが研究と開発で確かめたことを、ぼくがお伝えします。 |
| 9 | toc | 55.8 | 1.9 | 7つの手順は、こうです。 |
| 10 | toc2 | 58.0 | 13.9 | 使い道を決める。元の絵と大きさをそろえる。形を数字で作る。｜絵に描き出して直す。骨と顔を入れる。ポーズを付けて確かめる。そして、Unity で動かす。 |
| 11 | toc_note | 72.3 | 8.0 | なお、ハコブくんはマンガのロボットで、実物はありません。今日の話は、ぜんぶ3DCGの中の話です。 |
| 12 | s1 | 81.5 | 4.5 | 手順1、使い道と、見た目の決まりを、先に決めます。 |
| 13 | s1_use | 87.2 | 9.2 | 今回の使い道は、病院の場面を近くで見せること。｜パソコンの試算、病院の1日のシミュレーションの、その次の段です。 |
| 14 | ei_steps | 96.7 | 1.9 | エイイチは、こう書いていました。 |
| 15 | ei_steps | 99.0 | 10.2 | 試算、全体のシミュレーション、場面の細部、実際のロボットでの動画。｜全体がつながっていけば、より伝わりやすくなると考えます。 |
| 16 | s1_look | 110.0 | 8.6 | 見た目の決まりは、粘土の人形のような感じ。ただし、デフォルメしすぎない。｜人とロボットを、同じ決まりで作ります。 |
| 17 | s1_tip | 119.0 | 5.0 | ここを先に決めておくと、あとで、なんか違う、で迷子になりません。 |
| 18 | s2 | 125.1 | 3.4 | 手順2、元の絵と、大きさをそろえます。 |
| 19 | s2_ref | 129.7 | 9.7 | 元の絵は、マンガ『ハコブくん』の三面図です。正面、横、背中の3枚。｜大城さんも、マンガに出てくる看護師さんです。 |
| 20 | s2_size | 139.8 | 12.7 | 大きさは、実寸で作ります。ハコブくんは、高さ1メートル15センチ。大城さんは、身長1メートル65センチ。｜頭は、身長の約7分の1にしました。 |
| 21 | s2_joke | 152.8 | 6.1 | ハコブくん、大城さんより50センチ小さいです。……見上げるのには、慣れています。 |
| 22 | s3 | 160.0 | 3.3 | 手順3、形を、数字で作ります。 |
| 23 | s3_split | 164.5 | 9.0 | その前に、道具の役わけです。｜形を作るのは、Blender。動かして撮るのは、Unity。この2つを使い分けます。 |
| 24 | s3_tool | 173.9 | 10.3 | Blender は、3Dの形を作る無料のソフトです。画面は開かずに、Python のコードで動かしました。｜コードを書いたのは、AI の Claude です。 |
| 25 | s3_robot | 184.6 | 5.8 | ハコブくんは、三面図の外形を測って、Blender の中で、箱を組むように作ります。 |
| 26 | s3_human | 190.7 | 10.0 | 人は、楕円の頭、丸い棒の腕、角を丸めた箱の胴、のように、簡単な形を Python で足し合わせて、Blender に渡します。 |
| 27 | s3_why | 201.1 | 9.5 | 首の長さも、肩の丸みも、ぜんぶコードの中の数字です。｜だから、首が長い、と言われたら、数字を1つ変えて作り直すだけ。 |
| 28 | s3_joke | 210.9 | 5.6 | 人間の首は、数字を変えても短くなりません。3Dの、いいところです。 |
| 29 | s4 | 217.7 | 3.2 | 手順4、絵に描き出して、直します。 |
| 30 | s4_render | 222.1 | 7.1 | 作った形は、正面、横、斜めから、確認用の絵に描き出します。これも、コードです。 |
| 31 | s4_side | 229.6 | 8.0 | ところが、2人を並べると、ハコブくんの角だけが硬く見えました。｜1人だけ、別の絵から切り抜いてきたみたいに。 |
| 32 | s4_fix | 237.9 | 9.6 | そこで、体の角を丸めて、タイヤを太くしました。｜顔の画面のまわりは丸めると白い線が出たので、マンガの形のまま残しています。 |
| 33 | s4_tip | 247.9 | 4.7 | ここが、手順4のコツです。1人ずつではなく、並べて見る。 |
| 34 | s4_oshiro | 252.9 | 3.5 | そして、大城さんの、最初の形がこちら。 |
| 35 | ei_first | 256.8 | 2.2 | エイイチの、最初のひと言です。 |
| 36 | ei_first | 259.3 | 2.7 | 首長すぎ、肩がいかつすぎ。 |
| 37 | ei_first2 | 262.8 | 2.3 | 正直な感想も、聞いてみました。 |
| 38 | ei_first2 | 265.5 | 2.2 | 人の描写のバランス悪っ！ |
| 39 | s4_ff | 268.4 | 7.8 | ここから、首を短く、なで肩に、腕を短く、目を大きく。｜……このあたりは、早送りでどうぞ。 |
| 40 | s4_ff2 | 276.6 | 2.6 | 作り直しは、全部で15回でした。 |
| 41 | s4_tip2 | 279.6 | 6.3 | 直す所は、言葉で言ってもらえば十分です。それを数字に直すのは、AI の仕事です。 |
| 42 | s5 | 287.1 | 3.0 | 手順5、骨と、顔を入れます。 |
| 43 | s5_bone | 291.3 | 10.1 | 人の3Dを動かすには、体の中に、骨を入れます。骨といっても、見えない棒です。｜大城さんには、Blender で45本入れました。 |
| 44 | s5_name | 301.7 | 9.8 | ポイントは、骨の名前を、Unity の人型の決まりどおりに付けること。｜そうしておくと、よそで作った歩く動きなどを、そのまま当てはめられます。 |
| 45 | s5_face | 311.9 | 8.4 | 顔には、まばたきの目と、困った眉。口は、笑う・話す・驚くなど、5つの形を用意しました。 |
| 46 | s5_joke | 320.7 | 8.3 | ちなみにハコブくんの顔は、画面です。眉は、ありません。｜……困った顔は、気持ちでがんばっています。 |
| 47 | s6 | 330.1 | 5.8 | 手順6、ポーズを付けて、確かめます。今日いちばん手こずったところです。 |
| 48 | s6_pose | 337.2 | 10.2 | 差し出す、両手で受け取る、引き出しの物をつかむ、おじぎ、困った顔など、8つのポーズを付けて、服や肌が破れないかを見ます。 |
| 49 | ei_sleeve | 347.8 | 2.7 | すると、エイイチから、こう言われました。 |
| 50 | ei_sleeve | 350.8 | 3.2 | 体が動いた時に、服の袖が切れてるよ。 |
| 51 | s6_try1 | 354.8 | 11.3 | 1回目。腕を前に出すと、袖と胴のつなぎ目が裂けていました。｜胴と袖が、別々の骨に引っぱられていたんです。袖を、別の部品に分けました。 |
| 52 | s6_try2 | 366.5 | 10.8 | 2回目。まだ裂けて見える。今度は、袖の中の腕の肌が、外に飛び出していました。｜袖の中に入る所の肌は、作らないことにしました。 |
| 53 | s6_joke | 377.7 | 5.9 | 見えない所は、作らない。……3Dの世界は、なかなか、割り切っています。 |
| 54 | s6_try3 | 383.9 | 4.3 | 3回目。腕を上げると、袖の肩の所がゆがむ。 |
| 55 | s6_fix | 388.6 | 7.9 | 答えは、袖をまるごと、腕の骨に付けること。これで、腕を上げても、袖の形が変わらなくなりました。 |
| 56 | s6_tip | 396.8 | 7.8 | 動かすと、止まっている時には見えない所が壊れます。だから、ポーズを付けて確かめる手順を、飛ばさないでください。 |
| 57 | s6_hakobu | 405.0 | 4.5 | ちなみにハコブくんは、服を着ていないので、袖の悩みはありません。 |
| 58 | s7 | 410.7 | 3.5 | 手順7、Unity に運んで、動かします。 |
| 59 | s7_fbx | 415.4 | 9.0 | ここで、Blender から Unity へ、バトンタッチです。形と骨を、FBX という、Unity がそのまま読めるファイルにして渡します。 |
| 60 | ei_unity | 424.7 | 4.3 | Unity に入った3Dのハコブくんを見て、エイイチは、ひと言。 |
| 61 | ei_unity | 429.3 | 2.7 | Unityでここまでできるの、熱いっ！ |
| 62 | s7_mixamo | 432.9 | 14.0 | 歩く・うなずく・おじぎなどの動きは、Mixamo という、人の動きを無料で借りられる Adobe のサービスから、33本借りました。｜借りた動きのファイルは、決まりにより、そのまま配ることはしません。 |
| 63 | s7_scene | 447.2 | 3.9 | こうして、Unity の場面の中で、2人が動き出しました。 |
| 64 | s7_scene2 | 451.4 | 9.0 | 薬剤部で、大城さんが、ハコブくんに薬を積む。｜届け先で、認証のカードをかざすと、引き出しが開き、薬を取る。 |
| 65 | s7_ff | 460.8 | 3.5 | ここにたどり着くまでの細かい直しも、早送りでどうぞ。 |
| 66 | s7_end | 464.6 | 4.2 | 筒と箱だった世界に、やっと、手と顔が出てきました。 |
| 67 | sum | 470.0 | 2.0 | 7つの手順を、もう一度。 |
| 68 | sum2 | 472.4 | 11.4 | 使い道を決める。元の絵と大きさ。数字で形。並べて直す。骨と顔。ポーズで確かめる。Unity で動かす。 |
| 69 | sum_code | 484.1 | 7.0 | 2人の形の作り方のコードは、記事に載せています。同じコードを動かせば、同じ形ができます。 |
| 70 | next | 492.3 | 5.5 | では、この3Dで、何を見せていくのか。エイイチは、こう話していました。 |
| 71 | ei_next | 498.2 | 7.2 | モノを渡す、受け取る、トラブルの時の動き、コミュニケーションの姿などを、表現できたらと思います。 |
| 72 | next2 | 506.2 | 6.2 | まだ、作りはじめたばかりです。動きが本物の看護師さんに近いかは、これから確かめます。 |
| 73 | next_joke | 512.7 | 5.8 | 次は、廊下ですれ違う場面です。ハコブくん、ぶつからないように、がんばります。 |
| 74 | site | 519.7 | 8.2 | 作り方のコードと、くわしい記録は、サイトで毎日公開しています。pixelstomotors.com で、どうぞ。 |
| 75 | outro | 529.1 | 4.2 | この動画が役に立ったら、高評価とチャンネル登録をお願いします。 |
| 76 | outro | 533.7 | 5.7 | ロボットの開発や導入の相談は、概要欄の連絡先から、エイイチに直接どうぞ。 |
| 77 | outro | 539.8 | 3.9 | コメントは、エイイチが全部読んでいます。それではまた。 |


## 場面の作り方（scenes.py）

大文字型のカード・道具の札（C_list の tool）・画像を先に見せる bg_card の img_first・早送りの場面を書いた、場面の定義。共通の部品（scene_kit.py・motion_kit.py）は動画の作業フォルダの _制作ベース にある。

```python
# -*- coding: utf-8 -*-
# 動画「ハコブくんの3D」（3Dを作る順番の7手順）の場面。2026-11-13 1版
PATTERN = 2   # 大文字型（4-4c）。2026-11-13 エイイチが決定（大文字型＋動きあり）
MOVE = True
# 背景の映像は Unity の場面の寄り（KIOXIA service_sim/unity/closeup/ 依頼162・166・171）と、病院の1日（hospital161 1便の通し）
# 出どころの札は使わない（全部エイイチの記録）
import os as _os, sys as _sys, json as _json, base64 as _b64
_here = _os.path.dirname(_os.path.abspath(__file__))
for _c in (_os.environ.get("P2M_BASE", ""), _os.path.join(_here, "..", "base"), _os.path.join(_here, "..", "_制作ベース")):
    if _c and _os.path.exists(_os.path.join(_c, "scene_kit.py")):
        _sys.path.insert(0, _c); break
from scene_kit import *
from scene_kit import _p2page
from motion_kit import use_motion

# --- 場面ごとの、文の始まりの秒（声の長さから）---
_idx = _json.load(open(_os.path.join(_here, 'tts/index.json'))) if _os.path.exists(_os.path.join(_here, 'tts/index.json')) else []
_LINES = {}
for _o in _idx:
    _d = _LINES.setdefault(_o['scene'], [])
    _t0 = (_d[-1][0] + _d[-1][1] + _d[-1][2]) if _d else 0.0
    _d.append((_t0, _o['dur'], _o['pause']))


def at(scene, k=0, f=0.0):
    """その場面の k 番目の文の、読み上げの f 割の所（秒）"""
    L = _LINES.get(scene)
    if not L:
        return 0.3
    k = min(k, len(L) - 1)
    return round(L[k][0] + L[k][1] * f, 2)


def scene_len(scene):
    L = _LINES.get(scene)
    return (L[-1][0] + L[-1][1] + L[-1][2]) if L else 4.0


# --- 出どころの札（左上）---
FUDA_CSS = """
.fuda{position:absolute;left:140px;top:96px;display:flex;align-items:center;gap:14px;font-size:30px;font-weight:700;color:#0D0D0D}
.fuda .k{padding:6px 18px;border-radius:999px;font-size:28px;font-weight:900}
.fuda .k.hap{background:#0D0D0D;color:#fff}
.fuda .k.news{background:#1A6C98;color:#fff}
.fuda .k.ken{border:3px solid #888888;color:#555}
.fuda .k.ei{background:#C4471F;color:#fff}
.fuda .k.clip{background:#0D0D0D;color:#fff}
.fuda .k.chosa{background:#5A5A5A;color:#fff}
"""
FUDA_NAME = {"hap": "会社の発表", "news": "ニュース", "ken": "論文や本", "ei": "エイイチの記録と見立て", "clip": "公開されている実物の動画", "chosa": "調査会社の推計"}


def fuda(kind, who):
    return f'<style>{FUDA_CSS}</style><div class="fuda"><span class="k {kind}">{FUDA_NAME[kind]}</span><span>{who}</span></div>'


# --- エイイチ（ドット絵）---
def _uri(p):
    return 'data:image/png;base64,' + _b64.b64encode(open(_os.path.join(_here, p), 'rb').read()).decode()


import ei_talk as _talk
EI = {k: (_talk.base_uri(k), _talk.base_uri(k, True)) for k in ('udegumi', 'unazuku', 'yubisasu', 'hirameku')}
# エイイチが話す文（narration.py の EI_LINES）→ (場面, 場面の中で何番目の文か, 文番号)
import importlib.util as _ilu
_sp = _ilu.spec_from_file_location('_narr_here', _os.path.join(_here, 'narration.py'))
_nm = _ilu.module_from_spec(_sp); _sp.loader.exec_module(_nm)
_EIL = getattr(_nm, 'EI_LINES', [])
_EI_SPEAK = {}
_cnt = {}
for _o in _idx:
    _k = _cnt.get(_o['scene'], 0); _cnt[_o['scene']] = _k + 1
    if _o['i'] in _EIL:
        _EI_SPEAK.setdefault(_o['scene'], []).append((_k, _o['i']))

EI_CSS = """
.eiw{position:absolute;right:10px;bottom:0px;width:420px;height:420px;overflow:hidden}
.eiw img{position:absolute;left:0;top:8px;width:420px;height:420px;image-rendering:pixelated}
.ein{position:absolute;right:40px;bottom:346px;width:300px;text-align:center;font-size:30px;font-weight:900;color:#0D0D0D}
.ein span{display:inline-block;background:#F5F5F0;border:3px solid #D6D6D1;border-radius:999px;padding:4px 20px}
"""


def _blinks(start, end):
    t, out = start, []
    while t < end - 0.4:
        out.append(round(t, 2)); t += 3.3
    return out


def ei_wipe(pose, scene, pop=None, name=None, poses=None):
    """右下の小窓。pop=秒 で、ぽんと出る。poses=[(秒, 絵)] で途中で絵を替える（指さしは大事な所だけ。2026-10-28 エイイチの指示）。
    話している間は口と目を声に合わせて動かし、話していない間はまばたきだけする。
    絵ごとにまとまり（div）を作り、替える時刻に前のまとまりを消して（out）、次を出す（fade）。絵の透明な所から前の絵が見えないように"""
    seq_p = sorted(poses or [(0, pose)])
    if seq_p[0][0] > 0:
        seq_p = [(0, pose)] + seq_p
    L = scene_len(scene) + 1
    segs = [(t, seq_p[k + 1][0] if k + 1 < len(seq_p) else L, p) for k, (t, p) in enumerate(seq_p)]
    spans, mouth, blinks = [], [], []
    for k, i in _EI_SPEAK.get(scene, []):
        t0 = at(scene, k, 0.0)
        sq, end = _talk.mouth_seq(i, t0)
        spans.append((t0, end)); mouth += sq
        blinks += [(b, 0.13) for b in _talk.blink_times(t0, end)]
    mouth.sort()
    blinks += [(b, 0.15) for b in _blinks((pop or 0) + 1.3, scene_len(scene)) if not any(a0 - 0.2 <= b <= a1 + 0.2 for a0, a1 in spans)]
    out = ''
    for s0, s1, p in segs:
        ev = []
        lv = ([l for (t, l) in mouth if t <= s0] or [1])[-1]
        if lv != 1 and p in _talk.MOUTH:
            ev.append((s0, f'<img src="{_talk.mouth_patches(p)[lv]}">'))
        for t, l in mouth:
            if s0 < t < s1 and p in _talk.MOUTH:
                ev.append((t, f'<img src="{_talk.mouth_patches(p)[l]}" data-a="fade" data-t="{t}" data-d="0.01">'))
        e = _talk.eye_patches(p) if p != 'unazuku' else None
        for b, d in blinks:
            if e and s0 <= b and b + d < s1:
                ev.append((b, f'<img src="{e[1]}" data-a="fade" data-t="{b}" data-d="0.01">'))
                ev.append((round(b + d, 2), f'<img src="{e[0]}" data-a="fade" data-t="{round(b + d, 2)}" data-d="0.01">'))
        ev.sort(key=lambda x: x[0])
        inner = f'<img src="{EI[p][0]}">' + ''.join(h for _, h in ev)
        if s0 > 0:
            inner = f'<div style="position:absolute;inset:0" data-a="fade" data-t="{s0}" data-d="0.01">{inner}</div>'
        if s1 < L:
            inner = f'<div style="position:absolute;inset:0" data-a="out" data-t="{s1}" data-d="0.01">{inner}</div>'
        out += inner
    a = f' data-a="pop" data-t="{pop}" data-d="0.35"' if pop is not None else ''
    nm = f'<div class="ein"{a}><span>{name}</span></div>' if name else ''
    return f'<style>{EI_CSS}</style>{nm}<div class="eiw"{a}>{out}</div>'


def add(page_html, *parts):
    return page_html.replace('</body>', ''.join(parts) + '</body>', 1)


S = {}
MOTION = {}
O = {}
VIDEO = {}




# --- 背景の映像の上にカード（4-4e）---
HL, WARN = "#C4471F", "#62BFF5"
CARD_CSS = f"""
.cw{{position:absolute;left:0;right:0;top:215px;bottom:300px;display:flex;align-items:center;justify-content:center}}
.cd{{background:rgba(8,8,8,.86);border-radius:30px;padding:46px 84px 52px;text-align:center;color:#fff;max-width:1400px;box-shadow:0 10px 40px rgba(0,0,0,.35)}}
.cd .k{{font-size:42px;font-weight:700;color:#BDBDBD;margin-bottom:22px}}
.cd .b{{font-weight:900;line-height:1.16}}
.cd .s{{font-size:44px;font-weight:700;color:#D0D0D0;margin-top:26px;line-height:1.35}}
.cd .hl{{color:{HL}}} .cd .wn{{color:{WARN}}}
.cap{{position:absolute;left:140px;right:auto;top:110px;background:rgba(8,8,8,.72);color:#fff;font-size:30px;font-weight:700;padding:10px 22px;border-radius:10px}}
.ff{{position:absolute;right:120px;top:170px;background:#C4471F;color:#fff;font-size:64px;font-weight:900;padding:10px 34px;border-radius:16px;letter-spacing:.04em}}
"""


def card(inner, cap="", pop=0.05):
    c = f'<div class="cap">{cap}</div>' if cap else ""
    return f'<style>{CARD_CSS}</style>{c}<div class="cw"><div class="cd" data-a="pop" data-t="{pop:.2f}" data-d="0.4">{inner}</div></div>'


def C_word(lines, kicker="", sub="", size=130):
    k = f'<div class="k">{kicker}</div>' if kicker else ""
    s = f'<div class="s">{sub}</div>' if sub else ""
    return f'{k}<div class="b" style="font-size:{size}px">{"<br>".join(lines)}</div>{s}'


def bg_card(name, file, ss, inner=None, cap="", ei_pose=None, dim=0.5, slow=1.0, ff=False, poses=None, img_first=None):
    """背景に映像を流し、上にカード（inner が None なら映像だけ・左上に何の映像か）。
    img_first=割合：画像を見せたい場面。はじめはカードを出さず、背景を暗くしないで絵を見せ、場面のその割合の所でカードを出す
    （2026-11-13 エイイチの指示「画像の内容を見せたい時はカードを一時的に非表示に。センターから右にずらさない」）"""
    if inner:
        pop = 0.05
        if img_first is not None:
            pop = round(scene_len(name) * img_first, 2)
            dim = 1.0
        parts = card(inner, cap=cap, pop=pop)
    else:
        parts = f'<style>{CARD_CSS}</style>' + (f'<div class="cap">{cap}</div>' if cap else '')
    if ff:
        parts += '<div class="ff" data-a="pop" data-t="0.1" data-d="0.3">▶▶ 早送り</div>'
    if ei_pose:
        parts += ei_wipe(ei_pose, name, poses=poses)
    html = overlay(parts)
    MOTION[name] = dict(type="html", bg=dict(file=file, ss=ss, dim=dim, slow=slow), html=html)
    S[name] = html


def ei(name, page_html, pose='udegumi', pop=None, poses=None):
    S[name] = add(page_html, ei_wipe(pose, name, pop=pop, poses=poses))
    MOTION[name] = dict(type='html', html=S[name])


def img(name, file, title, w=1100, top=230, tag=None, credit=""):
    """画像を出し、上に題（ゴシックの太字。明朝・手書き風は使わない 2026-11-13 エイイチの指示）。画像は y 830 より上・x 400 より右に収める"""
    src = 'file://' + _os.path.join(_here, file)
    left = (1920 - w) // 2
    body = (f'<div style="position:absolute;left:0;right:0;top:110px;text-align:center">'
            f'<span data-a="pop" data-t="0.1" data-d="0.35" style="display:inline-block;font-weight:900;font-size:96px;color:#0D0D0D">{title}</span></div>'
            f'<img data-a="pop" data-t="0.35" data-d="0.5" src="{src}" style="position:absolute;left:{left}px;top:{top}px;width:{w}px">')
    S[name] = page(body, tag=tag)
    MOTION[name] = dict(type='html', html=S[name])


L166 = "src/closeup166/closeup166_load.mp4"       # ① 薬を積む（ハコブくんは新しい形）
R171 = "src/closeup171/closeup171_receive.mp4"     # ② 受け取る（本番のカメラ）
R171S = "src/closeup171/closeup171_receive_side_half.mp4"  # ② 受け取る（横のカメラ・0.5倍の速さ）
R162 = "src/closeup162/closeup162_receive.mp4"
D1 = "src/day1_blue.mp4"                           # 病院の1日（1便の通し・5倍速）
CAP3 = "Unity｜場面の寄り（3DCG）"
TAGS = {n: f"<b>{n}</b>{w}" for n, w in ((1, "使い道"), (2, "絵と大きさ"), (3, "数字で形"), (4, "並べて直す"), (5, "骨と顔"), (6, "ポーズで確認"), (7, "Unity で動かす"))}
STEPS7 = [("1", "使い道"), ("2", "絵と大きさ"), ("3", "数字で形"), ("4", "並べて直す"), ("5", "骨と顔"), ("6", "ポーズで確認"), ("7", "Unityで動かす")]

# 0 何に使うためか
bg_card("hook_use", L166, 4, C_word(['薬の受け渡しを', '<span class="hl">近くで見せたい</span>'], "病院の搬送ロボット", "", 120), cap=CAP3)
img("hook_use2", "src/LOG496_01_handover_scene.png", "ハコブくん × 大城さん", w=1000, top=262)
S["hook_joke"] = p2_word(['体が', '<span class="blue">できた！</span>'], kicker="ハコブくん、ついに", kc="muted", size=200, sub="……腕は、まだない")
bg_card("hook_why", D1, 8, C_word(['人は<span class="wn">青い筒</span>', 'ロボットは<span class="hl">白い箱</span>'], "これまでの病院のシミュレーション", "", 110), cap="Unity｜病院の1日（5倍速）", dim=0.6)
bg_card("hook_why2", D1, 24, C_word(['どう受け取るかは', '<span class="hl">見えない</span>'], "何回止まったかは、数えられる", "", 120), cap="Unity｜病院の1日（5倍速）", dim=0.6)
S["hook_joke2"] = p2_word(['筒から', '<span class="blue">箱へ</span>'], kicker="薬の受け渡し、これまでは", kc="muted", size=200, sub="絵としては、ちょっと寂しい")
S["hook_today"] = p2_word(['作る順番', '<span class="red">7つ</span>'], kicker="そのまま真似できる形で", kc="muted", size=200)
# 1 自己紹介
O.update({
 "title": overlay('<div class="lt"><img class="a" src="' + LOGO + '"><div class="b">フィジカルAI解説</div><div class="c">案内役：ハコブくん（AI音声）</div></div>'),
 "title_eiichi": overlay('<div class="lt"><img class="a" src="' + LOGO + '"><div class="b">エイイチ</div><div class="c">デザイナー／ロボティクスの研究・開発</div></div>'),
 "outro": overlay('<div class="lt"><img class="a" src="' + LOGO + '"><div class="b">相談は概要欄の連絡先から</div><div class="c">高評価・チャンネル登録／コメントはエイイチが全部読んでいます</div></div>'),
})
VIDEO.update({
 "title": ("src/day124_crop.mp4", 4.5, False),
 "title_eiichi": ("src/day124_crop.mp4", None, False),
 "outro": ("src/day124_crop.mp4", 18.0, False),
})
S["toc"] = p2_word(['7つの', '<span class="red">手順</span>'], kicker="3Dを作る順番", kc="muted", size=200)
S["toc2"] = p2_list(STEPS7, kicker="7つの手順", size=62)
S["toc_note"] = p2_word(['ぜんぶ', '<span class="red">3DCG</span>'], kicker="ハコブくんはマンガのロボット", kc="muted", size=200, sub="実物は、ありません")
# 手順1
S["s1"] = chapter2("手順1", "使い道を決める", "見た目の決まりも、先に")
S["s1_use"] = p2_list([("1", "試算"), ("2", "病院の1日"), ("3", "<span class=\"red\">場面を近くで</span>"), ("4", "本物で撮る")], kicker="病院の搬送ロボットを見せる、4つの段", size=96, tag=TAGS[1])
ei("ei_steps", p2_word(['4つが', '<span class="red">つながれば</span>'], kicker="エイイチの言葉", kc="muted", size=170, sub="より伝わりやすくなる", tag=TAGS[1]), pop=0.1)
S["s1_look"] = p2_pair("粘土の人形", "しすぎない", da="簡略化した見た目", db="デフォルメ", ca="blue", cb="red", mid="＋", kicker="見た目の決まり", sub="人とロボットを、同じ決まりで", size=120, tag=TAGS[1])
S["s1_tip"] = p2_word(['なんか違う、で', '<span class="red">迷子にならない</span>'], kicker="先に決めるコツ", kc="muted", size=120, tag=TAGS[1])
# 手順2
S["s2"] = chapter2("手順2", "絵と大きさ", "元の絵と、大きさをそろえる")
img("s2_ref", "src/LOG496_04_oshiro_ref.png", "マンガの三面図", w=450, top=262, tag=TAGS[2])
S["s2_size"] = p2_pair("1.15m", "1.65m", da="ハコブくん（高さ）", db="大城さん（身長）", ca="blue", cb="red", mid="／", kicker="実寸で作る", sub="頭は、身長の約7分の1", size=190, tag=TAGS[2])
S["s2_joke"] = p2_word(['50センチ', '<span class="blue">小さい</span>'], kicker="ハコブくん、大城さんより", kc="muted", size=190, sub="見上げるのには、慣れています", tag=TAGS[2])
# 手順3
S["s3"] = chapter2("手順3", "数字で形を作る", "形を作るのは Blender")
S["s3_split"] = p2_pair("Blender", "Unity", da="形を作る", db="動かして撮る", ca="blue", cb="red", mid="→", kicker="道具の役わけ", size=170, tag=TAGS[3])
S["s3_tool"] = p2_word(['画面を開かず', '<span class="red">コードで</span>'], kicker="Blender を Python で動かす", kc="muted", size=170, sub="コードを書いたのは、AI の Claude", tag=TAGS[3])
img("s3_robot", "src/hakobu_compare_v2_20261113.png", "Blender で、三面図から箱を組む", w=840, top=262, tag=TAGS[3])
img("s3_human", "src/nurse_oshiro_3d_preview.png", "Python で形を足し、Blender へ", w=1300, top=340, tag=TAGS[3])
S["s3_why"] = p2_word(['首の長さも', '<span class="red">ただの数字</span>'], kicker="形は、ぜんぶコードの中", kc="muted", size=170, sub="首が長い → 数字を1つ変えるだけ", tag=TAGS[3])
S["s3_joke"] = p2_word(['人間の首は', '<span class="blue">縮まない</span>'], kicker="ちなみに", kc="muted", size=170, sub="3Dの、いいところ", tag=TAGS[3])
# 手順4
S["s4"] = chapter2("手順4", "並べて直す", "絵に描き出して、見て、直す")
img("s4_render", "src/LOG496_03_hakobu_preview.png", "確認用の絵も Blender で", w=520, top=262, tag=TAGS[4])
bg_card("s4_side", "src/bg_hakobu_before.mp4", 0, C_word(['1人だけ', '<span class="hl">切り抜いた絵</span>'], "2人を並べると、ハコブくんの角だけ硬い", "", 130), cap="直す前のハコブくん（三面図の角のまま）", dim=0.55, img_first=0.5)
img("s4_fix", "src/LOG496_02_hakobu_before_after.png", "体の角を丸めた", w=840, top=262, tag=TAGS[4])
S["s4_tip"] = p2_word(['1人ずつより', '<span class="red">並べて見る</span>'], kicker="手順4のコツ", kc="muted", size=170, tag=TAGS[4])
img("s4_oshiro", "src/oshiro_first.png", "大城さん、最初の形", w=560, top=262, tag=TAGS[4])
bg_card("ei_first", "src/bg_oshiro_first.mp4", 0, C_word(['首長すぎ', '<span class="hl">肩いかつすぎ</span>'], "エイイチの最初のひと言", "", 140), cap="大城さん、最初の形", ei_pose="udegumi", dim=0.5, img_first=0.4)
bg_card("ei_first2", "src/bg_oshiro_first.mp4", 6, C_word(['バランス', '<span class="hl">悪っ！</span>'], "正直な感想", "", 200), cap="大城さん、最初の形", ei_pose="udegumi", dim=0.5, img_first=0.35,
        poses=[(at('ei_first2', 1, 0.1), 'yubisasu'), (round(at('ei_first2', 1, 0.1) + 3.0, 2), 'udegumi')])
# 早送り（最初の形と直した形を、目・肩・首に寄りながら切り替える。2026-11-13 エイイチの指示「目とか肩とかアップにして動きを」）
bg_card("s4_ff", "src/ff_oshiro.mp4", 0, None, cap="最初の形 ⇄ 直した形", dim=1.0, ff=True)
S["s4_ff2"] = p2_num("15", "回", kicker="作り直し", sub="首・肩・腕・目・Vネック", tag=TAGS[4])
S["s4_tip2"] = p2_pair("人", "AI", da="言葉で言う", db="数字に直す", ca="blue", cb="red", mid="→", kicker="直す所の伝え方", size=200, tag=TAGS[4])
# 手順5
S["s5"] = chapter2("手順5", "骨と顔", "動かすための見えない棒")
S["s5_bone"] = p2_num("45", "本", kicker="大城さんの骨（見えない棒）", sub="Blender で入れる", tag=TAGS[5])
S["s5_name"] = p2_word(['骨の名前は', '<span class="red">決まりどおり</span>'], kicker="ポイント｜Unity の人型の決まり", kc="muted", size=160, sub="よその歩く動きを、そのまま当てはめられる", tag=TAGS[5])
img("s5_face", "src/LOG496_08_talk_scene.png", "目・眉・口5つ", w=1000, top=262, tag=TAGS[5])
S["s5_joke"] = p2_word(['眉は', '<span class="blue">ありません</span>'], kicker="ハコブくんの顔は、画面", kc="muted", size=200, sub="困った顔は、気持ちでがんばる", tag=TAGS[5])
# 手順6
S["s6"] = chapter2("手順6", "ポーズで確認", "今日いちばん手こずった所")
img("s6_pose", "src/LOG496_06_nurse_poses.png", "8つのポーズ", w=1040, top=262, tag=TAGS[6])
bg_card("ei_sleeve", "src/bg_torn1.mp4", 0, C_word(['袖が', '<span class="hl">切れてるよ</span>'], "エイイチから", "", 120), cap="袖が裂けた所（直す前のコードで描き出し直した絵）", ei_pose="udegumi", dim=0.85, img_first=0.5)
S["s6_try1"] = p2_item("つなぎ目が裂けた", "袖を、別の部品に", no="1回目", size=130, tag=TAGS[6])
S["s6_try2"] = p2_item("中の肌が飛び出た", "袖の中の肌は、作らない", no="2回目", size=130, tag=TAGS[6])
S["s6_joke"] = p2_word(['見えない所は', '<span class="blue">作らない</span>'], kicker="3Dの世界", kc="muted", size=170, sub="なかなか、割り切っています", tag=TAGS[6])
bg_card("s6_try3", "src/bg_torn3.mp4", 0, C_word(['<span class="hl">3回目</span>', '肩がゆがむ'], "腕を上げると", "", 140), cap="3回目（直す前のコードで描き出した絵）", dim=0.85, img_first=0.5)
img("s6_fix", "src/LOG496_07_sleeve_fixed.png", "袖をまるごと腕の骨に", w=1100, top=290, tag=TAGS[6])
S["s6_tip"] = p2_word(['ポーズの確認を', '<span class="red">飛ばさない</span>'], kicker="動かすと、止まっている時には見えない所が壊れる", kc="muted", size=150, tag=TAGS[6])
S["s6_hakobu"] = p2_word(['袖の悩み', '<span class="blue">なし</span>'], kicker="服を着ていないハコブくん", kc="muted", size=200, tag=TAGS[6])
# 手順7
S["s7"] = chapter2("手順7", "Unityで動かす", "Blender から Unity へ")
S["s7_fbx"] = p2_pair("Blender", "Unity", da="形と骨", db="読み込んで動かす", ca="blue", cb="red", mid="FBX", kicker="バトンタッチ", size=170, tag=TAGS[7])
bg_card("ei_unity", L166, 1, C_word(['熱いっ！'], "Unity でここまでできるの", "", 200), cap=CAP3, ei_pose='udegumi',
        poses=[(at('ei_unity', 1, 0.1), 'hirameku'), (round(at('ei_unity', 1, 0.1) + 3.0, 2), 'udegumi')])
S["s7_mixamo"] = p2_num("33", "本", kicker="Mixamo から借りた動き", sub="歩く・うなずく・おじぎ など｜ファイルは配らない", tag=TAGS[7])
bg_card("s7_scene", L166, 8, None, cap="Unity｜薬剤部で、薬を積む（3DCG）", dim=1.0)
bg_card("s7_scene2", R171S, 2, None, cap="Unity｜届け先で、カードをかざして受け取る（3DCG・横から）", dim=1.0)
bg_card("s7_ff", R171, 0, None, cap="Unity｜細かい直し", dim=1.0, slow=0.3, ff=True)
bg_card("s7_end", R162, 20, C_word(['手と顔が', '<span class="hl">出てきた</span>'], "筒と箱だった世界に", "", 150), cap=CAP3)
# まとめ・これから
S["sum"] = p2_word(['7つの手順', '<span class="red">もう一度</span>'], kicker="まとめ", kc="muted", size=180)
S["sum2"] = p2_list(STEPS7, kicker="3Dを作る順番", size=62)
S["sum_code"] = p2_word(['形のコードは', '<span class="red">記事に</span>'], kicker="2人の形の作り方", kc="muted", size=170, sub="同じコードを動かせば、同じ形ができる")
S["next"] = p2_word(['この3Dで', '<span class="red">何を見せる？</span>'], kicker="これから", kc="muted", size=170)
ei("ei_next", p2_word(['渡す・受け取る', '<span class="red">トラブル・会話</span>'], kicker="エイイチの言葉", kc="muted", size=140), pop=0.1)
S["next2"] = p2_word(['作りはじめた', '<span class="red">ばかり</span>'], kicker="まだ", kc="muted", size=180, sub="本物の看護師さんに近いかは、これから確かめる")
S["next_joke"] = p2_word(['廊下で', '<span class="blue">すれ違う</span>'], kicker="次の場面", kc="muted", size=190, sub="ぶつからないように、がんばります")
bg_card("site", R171S, 8, C_word(['pixelstomotors.com'], "作り方のコードと記録を、毎日公開", "", 120), cap=CAP3)

# --- 白い紙の場面を減らす（2026-11-13 エイイチの指示「なるべく白背景の時間を減らして（意味ある形で）」）---
# 話していることに合う映像や3Dの絵を後ろに流し、文字はカードに載せる
def C_list(items, kicker="", size=64, tool=None):
    """tool={番号: 道具} があれば、右に道具の札（準備・Blender・Unity）を出し、同じ道具の行をまとめて見せる"""
    TC = {"準備": "#8A8A8A", "Blender": "#E87D0D", "Unity": "#62BFF5"}
    rows = ""
    for n, t in items:
        tg = ""
        if tool:
            tl = tool.get(n, "")
            tg = f'<span style="display:inline-block;min-width:{int(size*3.2)}px;margin-left:30px;font-size:{int(size*0.62)}px;padding:4px 16px;border-radius:999px;border:3px solid {TC[tl]};color:{TC[tl]};text-align:center;vertical-align:middle">{tl}</span>'
        rows += f'<div style="font-size:{size}px;font-weight:900;line-height:1.32;text-align:left;display:flex;align-items:center;justify-content:space-between"><span><span class="hl" style="margin-right:26px">{n}</span>{t}</span>{tg}</div>'
    k = f'<div class="k">{kicker}</div>' if kicker else ""
    return f'{k}<div style="display:inline-block">{rows}</div>'

TOOL7 = {"1": "準備", "2": "準備", "3": "Blender", "4": "Blender", "5": "Blender", "6": "Blender", "7": "Unity"}

def chap(name, no, word, sub, file, ss):
    bg_card(name, file, ss, C_word([f'<span class="hl">{word}</span>'], no, sub, 150), dim=0.45)

HDIAG, HFRONT, HAND, TALK = "src/bg_hakobu_diag.mp4", "src/bg_hakobu_front.mp4", "src/bg_handover.mp4", "src/bg_talk.mp4"
HCMP, OFIN, SFIX, POSES, FF = "src/bg_hakobu_cmp.mp4", "src/bg_oshiro_final.mp4", "src/bg_sleeve_fixed.mp4", "src/bg_poses.mp4", "src/ff_oshiro.mp4"
bg_card("hook_joke", HDIAG, 0, C_word(['体が', '<span class="wn">できた！</span>'], "ハコブくん、ついに", "……腕は、まだない", 170), img_first=0.3)
bg_card("hook_joke2", D1, 40, C_word(['筒から', '<span class="wn">箱へ</span>'], "薬の受け渡し、これまでは", "絵としては、ちょっと寂しい", 170), cap="Unity｜病院の1日（5倍速）", dim=0.6)
bg_card("hook_today", R162, 2, C_word(['作る順番', '<span class="hl">7つ</span>'], "そのまま真似できる形で", "", 170), cap=CAP3)
bg_card("toc", L166, 12, C_word(['7つの', '<span class="hl">手順</span>'], "3Dを作る順番", "", 170), cap=CAP3)
bg_card("toc2", L166, 18, C_list(STEPS7, "7つの手順と、使う道具", 40, TOOL7), cap=CAP3)
bg_card("toc_note", R171S, 0, C_word(['ぜんぶ', '<span class="hl">3DCG</span>'], "ハコブくんはマンガのロボット", "実物は、ありません", 170), cap=CAP3)
chap("s1", "手順1｜準備", "使い道を決める", "見た目の決まりも、先に", HAND, 0)
bg_card("s1_use", D1, 15, C_list([("1", "試算"), ("2", "病院の1日"), ("3", '<span class="hl">場面を近くで</span>'), ("4", "本物で撮る")], "病院の搬送ロボットを見せる、4つの段", 80), cap="Unity｜病院の1日（5倍速）", dim=0.55)
bg_card("ei_steps", D1, 30, C_word(['4つが', '<span class="hl">つながれば</span>'], "エイイチの言葉", "より伝わりやすくなる", 150), cap="Unity｜病院の1日（5倍速）", ei_pose="udegumi", dim=0.55)
bg_card("s1_look", HAND, 3, C_word(['粘土の人形', '<span class="hl">デフォルメしすぎない</span>'], "見た目の決まり", "人とロボットを、同じ決まりで", 110), img_first=0.35)
bg_card("s1_tip", HAND, 8, C_word(['なんか違う、で', '<span class="hl">迷子にならない</span>'], "先に決めるコツ", "", 120))
chap("s2", "手順2｜準備", "絵と大きさ", "元の絵と、大きさをそろえる", TALK, 0)
bg_card("s2_size", TALK, 3, C_word(['<span class="wn">1.15m</span> ／ <span class="hl">1.65m</span>'], "実寸で作る｜ハコブくん（高さ）・大城さん（身長）", "頭は、身長の約7分の1", 150), img_first=0.35)
bg_card("s2_joke", TALK, 8, C_word(['50センチ', '<span class="wn">小さい</span>'], "ハコブくん、大城さんより", "見上げるのには、慣れています", 160))
chap("s3", "手順3｜Blender", "数字で形を作る", "道具は Blender（形）と Unity（動き）", HCMP, 0)
bg_card("s3_split", L166, 4, C_word(['<span class="wn">Blender</span> → <span class="hl">Unity</span>'], "道具の役わけ", "形を作る → 動かして撮る", 120), cap=CAP3)
bg_card("s3_tool", HCMP, 4, C_word(['<span class="wn">Blender</span>', '<span class="hl">コードで動かす</span>'], "3Dの形を作る無料のソフト", "画面は開かず Python で｜コードは AI の Claude", 130), dim=0.45, img_first=0.35)
bg_card("s3_why", OFIN, 0, C_word(['首の長さも', '<span class="hl">ただの数字</span>'], "形は、ぜんぶコードの中", "首が長い → 数字を1つ変えるだけ", 150), dim=0.45)
bg_card("s3_joke", OFIN, 8, C_word(['人間の首は', '<span class="wn">縮まない</span>'], "ちなみに", "3Dの、いいところ", 150), dim=0.45)
chap("s4", "手順4｜Blender", "並べて直す", "絵に描き出して、見て、直す", HAND, 4)
bg_card("s4_tip", HAND, 10, C_word(['1人ずつより', '<span class="hl">並べて見る</span>'], "手順4のコツ", "", 150))
bg_card("s4_ff2", FF, 0, C_word(['<span class="hl">15</span>回'], "作り直し", "首・肩・腕・目・Vネック", 200), dim=0.45)
bg_card("s4_tip2", OFIN, 4, C_word(['<span class="wn">人</span> → <span class="hl">AI</span>'], "直す所の伝え方", "言葉で言う → 数字に直す", 170), dim=0.45)
chap("s5", "手順5｜Blender", "骨と顔", "動かすための見えない棒", POSES, 0)
bg_card("s5_bone", POSES, 4, C_word(['<span class="hl">45</span>本'], "大城さんの骨（見えない棒）", "骨は Blender で入れる", 200), cap="8つのポーズ（骨で付けた姿勢）", dim=0.45, img_first=0.35)
bg_card("s5_name", R162, 6, C_word(['骨の名前は', '<span class="hl">決まりどおり</span>'], "ポイント｜Unity の人型の決まり", "よその歩く動きを、そのまま当てはめられる", 140), cap=CAP3)
bg_card("s5_joke", HFRONT, 0, C_word(['眉は', '<span class="wn">ありません</span>'], "ハコブくんの顔は、画面", "困った顔は、気持ちでがんばる", 170), img_first=0.35)
chap("s6", "手順6｜Blender", "ポーズで確認", "今日いちばん手こずった所", POSES, 6)
bg_card("s6_try1", "src/bg_torn1.mp4", 0, C_word(['<span class="hl">1回目</span>', 'つなぎ目が裂けた'], "腕を前に出すと", "袖を、別の部品に", 130), cap="1回目（直す前のコードで描き出し直した絵）", dim=0.85, img_first=0.5)
bg_card("s6_try2", "src/bg_torn2.mp4", 0, C_word(['<span class="hl">2回目</span>', '中の肌が飛び出た'], "腕を前に出すと", "袖の中の肌は、作らない", 130), cap="2回目（直す前のコードで描き出し直した絵）", dim=0.85, img_first=0.5)
bg_card("s6_joke", SFIX, 0, C_word(['見えない所は', '<span class="wn">作らない</span>'], "3Dの世界", "なかなか、割り切っています", 150), dim=0.45, img_first=0.4)
bg_card("s6_tip", POSES, 18, C_word(['ポーズの確認を', '<span class="hl">飛ばさない</span>'], "動かすと、止まっている時には見えない所が壊れる", "", 130), dim=0.45)
bg_card("s6_hakobu", HFRONT, 6, C_word(['袖の悩み', '<span class="wn">なし</span>'], "服を着ていないハコブくん", "", 170), img_first=0.35)
chap("s7", "手順7｜Unity", "Unityで動かす", "Blender から Unity へ", L166, 0)
bg_card("s7_fbx", L166, 6, C_word(['<span class="wn">Blender</span> → <span class="hl">Unity</span>'], "バトンタッチ｜FBX で渡す", "形と骨 → 読み込んで動かす", 120), cap=CAP3)
bg_card("s7_mixamo", R162, 10, C_word(['<span class="hl">33</span>本'], "Mixamo から借りた動き", "歩く・うなずく・おじぎ など｜ファイルは配らない", 200), cap=CAP3)
bg_card("sum", R171S, 4, C_word(['7つの手順', '<span class="hl">もう一度</span>'], "まとめ", "", 160), cap=CAP3)
bg_card("sum2", R171S, 8, C_list(STEPS7, "3Dを作る順番と、使う道具", 40, TOOL7), cap=CAP3)
bg_card("sum_code", HCMP, 8, C_word(['形のコードは', '<span class="hl">記事に</span>'], "2人の形の作り方", "同じコードを動かせば、同じ形ができる", 150), dim=0.45)
bg_card("next", R162, 14, C_word(['この3Dで', '<span class="hl">何を見せる？</span>'], "これから", "", 150), cap=CAP3)
bg_card("ei_next", R162, 18, C_word(['渡す・受け取る', '<span class="hl">トラブル・会話</span>'], "エイイチの言葉", "", 130), cap=CAP3, ei_pose="udegumi")
bg_card("next2", R171S, 12, C_word(['作りはじめた', '<span class="hl">ばかり</span>'], "まだ", "本物の看護師さんに近いかは、これから確かめる", 150), cap=CAP3)
bg_card("next_joke", R162, 24, C_word(['廊下で', '<span class="wn">すれ違う</span>'], "次の場面", "ぶつからないように、がんばります", 160), cap=CAP3)

use_motion(MOTION, VIDEO, O, _here, MOVE)
```


## エイイチのドット絵を話させる部品（ei_talk.py）

```python
# エイイチ（ドット絵）が話している間、口と目を動かす部品（2026-10-28 エイイチの指示「話している時はおれの口や目が動くように」）
# 口は3段（閉じる・いつもの笑顔・大きく開く）を、声の大きさで 0.1 秒ごとに選ぶ（1段ずつしか変えない）。
# 目は、話し始めと、話している間ときどきまばたきする。
# どれも 100 ドットの絵の「その部分だけ」を描いた透明な PNG を、時間で重ねていく（motion_kit の data-a="fade"）。
import os, io, base64, wave
import numpy as np
from PIL import Image

HERE = os.path.dirname(os.path.abspath(__file__))
INK = (1, 1, 1, 255)
TEETH = (250, 250, 246, 255)
INSIDE = (70, 22, 18, 255)
TONGUE = (177, 67, 33, 255)

# 口の両端（左x, 右x, 上y）。100 ドットの絵の座標
MOUTH = {'udegumi': (39, 52, 41), 'yubisasu': (39, 52, 41), 'unazuku': (36, 47, 43), 'hirameku': (38, 49, 42)}


def _load(pose, b=False):
    return np.array(Image.open(os.path.join(HERE, f"src/ei/{pose}{'_b' if b else ''}.png")).convert('RGBA'))


def fill_teeth(a):
    """顔の中の透明な穴（歯の所）を白で埋める。背景に映像があると透けて見えるため"""
    from scipy import ndimage
    t = a[..., 3] < 128
    lab, n = ndimage.label(t)
    edge = set(np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]])))
    for k in range(1, n + 1):
        if k in edge:
            continue
        m = lab == k
        if m.sum() < 80:
            a[m] = TEETH
    return a


def _uri(a):
    b = io.BytesIO(); Image.fromarray(a.astype(np.uint8)).save(b, 'PNG')
    return 'data:image/png;base64,' + base64.b64encode(b.getvalue()).decode()


def _skin(a, xl, xr, yt):
    """顔のいちばん多い肌の色（口のまわりの、明るい色）"""
    from collections import Counter
    reg = a[yt - 6:yt + 6, xl - 3:xr + 4].reshape(-1, 4)
    c = Counter(tuple(int(v) for v in px) for px in reg if px[3] > 200 and px[0] > 220 and 120 < px[1] < 200)
    return c.most_common(1)[0][0]


def mouth_patches(pose):
    """口だけを描いた透明な絵 3枚（0 閉じる・1 いつもの・2 開く）"""
    a = fill_teeth(_load(pose))
    xl, xr, yt = MOUTH[pose]
    sk = _skin(a, xl, xr, yt)
    y0, y1 = yt, yt + 5
    out = []
    for lv in range(3):
        p = np.zeros_like(a)
        if lv == 1:
            p[y0:y1 + 1, xl:xr + 1] = a[y0:y1 + 1, xl:xr + 1]
        else:
            # 元の口の線と歯だけを肌の色で消す（まわりの影は残す）
            reg = a[y0:y1 + 1, xl:xr + 1].copy()
            rg = reg[..., :3].astype(int)
            ink = (rg.max(2) < 90) | (abs(rg[..., 0] - rg[..., 1]) < 40)   # 黒い線・白や灰の歯
            reg[ink] = sk
            p[y0:y1 + 1, xl:xr + 1] = reg
            p[yt, xl] = p[yt, xr] = INK
            p[yt + 1, xl + 1:xr] = INK
            if lv == 2:
                p[yt + 2, xl + 1] = p[yt + 2, xr - 1] = INK
                p[yt + 2, xl + 2:xr - 1] = TEETH
                p[yt + 3, xl + 2] = p[yt + 3, xr - 2] = INK
                p[yt + 3, xl + 3:xr - 2] = INSIDE
                p[yt + 4, xl + 3] = p[yt + 4, xr - 3] = INK
                p[yt + 4, xl + 4:xr - 3] = TONGUE
                p[yt + 5, xl + 4:xr - 3] = INK
        out.append(_uri(p))
    return out


def eye_patches(pose):
    """目の所だけの絵（開いた目・閉じた目）"""
    o, c = _load(pose), _load(pose, True)
    d = np.argwhere((o != c).any(2))
    if not len(d):
        return None
    y0, y1, x0, x1 = d[:, 0].min() - 1, d[:, 0].max() + 1, d[:, 1].min() - 1, d[:, 1].max() + 1
    po, pc = np.zeros_like(o), np.zeros_like(o)
    po[y0:y1 + 1, x0:x1 + 1] = o[y0:y1 + 1, x0:x1 + 1]
    pc[y0:y1 + 1, x0:x1 + 1] = c[y0:y1 + 1, x0:x1 + 1]
    return _uri(po), _uri(pc)


def base_uri(pose, b=False):
    return _uri(fill_teeth(_load(pose, b)))


def envelope(i, step=0.1):
    """tts/{i:03d}.wav の声の大きさ（step 秒ごと・0〜1）"""
    w = wave.open(os.path.join(HERE, f'tts/{i:03d}.wav'))
    sr, n, ch, sw = w.getframerate(), w.getnframes(), w.getnchannels(), w.getsampwidth()
    x = np.frombuffer(w.readframes(n), dtype=np.int16 if sw == 2 else np.int32).astype(np.float32)
    if ch > 1:
        x = x.reshape(-1, ch).mean(1)
    x /= np.abs(x).max() + 1e-9
    h = int(sr * step)
    r = np.array([np.sqrt(np.mean(x[k:k + h] ** 2)) for k in range(0, len(x) - h, h)])
    return r / (np.percentile(r, 95) + 1e-9)


def talk_layers(pose, i, t0, step=0.1):
    """場面の頭から t0 秒にエイイチの文 i が始まるときの、口と目の重ね（img の並び）"""
    m = mouth_patches(pose)
    env = envelope(i, step)
    seq, lv = [], 1
    for k, v in enumerate(env):
        want = 0 if v < 0.18 else (1 if v < 0.55 else 2)
        if want != lv:
            lv += 1 if want > lv else -1
            seq.append((round(t0 + k * step, 2), lv))
    end = round(t0 + len(env) * step, 2)
    seq.append((end, 1))   # 話し終わったら、いつもの笑顔に戻す
    h = ''
    for t, l in seq:
        h += f'<img src="{m[l]}" data-a="fade" data-t="{t}" data-d="0.01">'
    e = eye_patches(pose)
    if e:
        o, c = e
        bt, last = t0 + 0.05, end
        while bt < last - 0.3:
            h += f'<img src="{c}" data-a="fade" data-t="{round(bt, 2)}" data-d="0.01"><img src="{o}" data-a="fade" data-t="{round(bt + 0.13, 2)}" data-d="0.01">'
            bt += 1.6 + (int(bt * 7) % 5) * 0.25
    return h, (t0, end)


# 2026-10-29 「車は作れるのにロボは作れない」で足した（scenes.py の ei_wipe が使う。前の回の作業環境にだけあった版を、同じ動きで書き直した）
def mouth_seq(i, t0, step=0.1):
    """文 i の口の段の並び [(秒, 段)] と、話し終わりの秒。段は 0 閉じる・1 いつもの・2 開く（1段ずつしか変えない）"""
    env = envelope(i, step)
    seq, lv = [], 1
    for k, v in enumerate(env):
        want = 0 if v < 0.18 else (1 if v < 0.55 else 2)
        if want != lv:
            lv += 1 if want > lv else -1
            seq.append((round(t0 + k * step, 2), lv))
    end = round(t0 + len(env) * step, 2)
    seq.append((end, 1))
    return seq, end


def blink_times(t0, end):
    """話している間のまばたき（1.6〜2.6秒おき）"""
    out, bt = [], t0 + 0.05
    while bt < end - 0.3:
        out.append(round(bt, 2))
        bt += 1.6 + (int(bt * 7) % 5) * 0.25
    return out
```


## 音の置き方（sound.py）

```python
# 音の置き方（audio_mix.py が読む。書き方は audio_mix.py の先頭）
# 2026-11-13 「ハコブくんの3D」: 「入口の待ちが減らない」の sound.py を写した（同じ曲・同じ決まり。章の場面は7つの手順に合わせた）

BGM = [
    dict(file='sound/bgm_明るいB_2.mp3', start=0, end=None, level=-23, gap=-16, fade_in=0.6, fade_out=3.0),
]

SE = [
    # (文番号, 文の頭から何秒後, 音, ナレーションより何dB)
]

# 場面が切り替わるたびに「ホワーン」。同じ場面が続く所・エイイチの声の所は鳴らさない
SCENE_SE = dict(file='se_rise', level=-6, offset=-0.15, skip=['title_eiichi', 'ei_first2'])

# 大きな流れの変わり目（自己紹介・7つの手順の頭・まとめ・アウトロ）は、ホワーンの代わりに別の音
CHAPTER_SE = dict(file='se_chapter_marimba', files={'title': 'se_chapter_glock'}, level=-8, offset=-0.3,
                  scenes=['title', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 'sum', 'outro'])
```


## 背景の静止画の映像と、早送りの寄りの映像（make_bg.py）

```python
# 静止画から背景用の映像を作る（ゆっくり寄る）と、早送りの寄りの映像を作る
import subprocess, sys, os
from PIL import Image
W, H, FPS = 1920, 1080, 30
BG = (245, 245, 240)

def pipe(out, n, frame_fn):
    p = subprocess.Popen(['ffmpeg','-v','error','-y','-f','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-',
                          '-c:v','libx264','-pix_fmt','yuv420p','-crf','18','-preset','veryfast',out], stdin=subprocess.PIPE)
    for i in range(n):
        p.stdin.write(frame_fn(i).tobytes())
    p.stdin.close(); p.wait()

def place(img, box_w, box_h, cx, cy, crop):
    """crop=(x,y,w,h) を box に収めて、中心 (cx,cy) に置いた 1920x1080 の絵"""
    x, y, w, h = crop
    c = img.crop((int(x), int(y), int(x + w), int(y + h)))
    s = min(box_w / c.width, box_h / c.height)
    c = c.resize((max(1, int(c.width * s)), max(1, int(c.height * s))), Image.LANCZOS)
    f = Image.new('RGB', (W, H), BG); f.paste(c, (int(cx - c.width / 2), int(cy - c.height / 2)))
    return f

def still(src, out, sec=20, z0=1.0, z1=1.12, fill=True):
    img = Image.open(src).convert('RGB')
    n = int(sec * FPS)
    def fr(i):
        z = z0 + (z1 - z0) * i / n
        if fill:   # 画面いっぱいに（はみ出しは切る）
            s = max(W / img.width, H / img.height) * z
        else:
            s = min(1500 / img.width, 800 / img.height) * z
        r = img.resize((int(img.width * s), int(img.height * s)), Image.LANCZOS)
        f = Image.new('RGB', (W, H), BG); f.paste(r, ((W - r.width) // 2, (H - r.height) // 2 - (0 if fill else 40)))
        return f
    pipe(out, n, fr)

def ff_zoom(out, shots, per=0.55, tail=2.5):
    """shots=[(画像, 寄る所の中心x, y, 寄り始めの幅, 寄り終わりの幅)]。1つずつ寄りながら、ぱっと切り替える"""
    imgs = {}
    frames = []
    for path, cx, cy, w0, w1 in shots:
        if path not in imgs:
            o = Image.open(path).convert('RGB'); P = 500
            pad = Image.new('RGB', (o.width + 2 * P, o.height + 2 * P), BG); pad.paste(o, (P, P)); imgs[path] = pad
        n = int(per * FPS)
        for i in range(n):
            w = w0 + (w1 - w0) * (i / n)
            h = w * 9 / 16
            frames.append((path, (cx + 500 - w / 2, cy + 500 - h / 2, w, h)))
    last = shots[-1]
    for i in range(int(tail * FPS)):
        frames.append((last[0], (last[1] + 500 - last[3] / 2, last[2] + 500 - last[3] * 9 / 32, last[3], last[3] * 9 / 16)))
    pipe(out, len(frames), lambda k: place(imgs[frames[k][0]], 1500, 844, W / 2, H / 2 - 30, frames[k][1]))

if __name__ == '__main__':
    A, B = 'src/oshiro_first.png', 'src/oshiro_final.png'
    ff_zoom('src/ff_oshiro.mp4', [
        (A, 222, 85, 330, 230), (B, 1060 - 850, 85, 330, 230),       # 顔（目）
        (A, 222, 190, 520, 400), (B, 210, 190, 520, 400),            # 肩
        (A, 222, 150, 300, 220), (B, 210, 150, 300, 220),            # 首と Vネック
        (A, 640, 140, 360, 260), (B, 1480 - 850, 140, 360, 260),     # 横の首
        (A, 425, 390, 1500, 1420), (B, 425, 390, 1500, 1420),            # 全体
    ])
    sys.exit(0) if '--ff' in sys.argv else None
    still('src/oshiro_first.png', 'src/bg_oshiro_first.mp4', fill=False)
    still('src/hakobu_before.png', 'src/bg_hakobu_before.mp4', fill=False)
    still('src/sleeve_before_55.png', 'src/bg_sleeve55.mp4', fill=False)
    still('src/LOG496_06_nurse_poses.png', 'src/bg_poses.mp4', fill=False)
    print('ok')
```


## 袖の描き出し直しのポーズと描き出し（pose.py）

```python
"""骨の動きを確かめる：決めた姿勢を付けて描く。
python3 nurse_pose.py <nurse_clay.blend> <出力フォルダ> [姿勢の名前...]
姿勢：rest give receive drawer point bow worried talk
（Unity では Humanoid の動き・腕の IK で動かす。ここの姿勢は、骨と重みで服や肌が破れないかの確認用）
"""
import math
import os
import sys

import bpy  # noqa: I001
from mathutils import Matrix, Vector

src, out = sys.argv[1], sys.argv[2]
want = sys.argv[3:]
os.makedirs(out, exist_ok=True)


def setup_scene():
    sc = bpy.context.scene
    sc.render.engine = "CYCLES"
    sc.cycles.device = "CPU"
    sc.cycles.samples = 24
    sc.cycles.use_denoising = True
    sc.view_settings.view_transform = "Standard"
    w = bpy.data.worlds.new("W")
    sc.world = w
    w.use_nodes = True
    bg = w.node_tree.nodes["Background"]
    bg.inputs["Color"].default_value = (0.80, 0.90, 0.89, 1)
    bg.inputs["Strength"].default_value = 0.65
    for nm, en, sz, loc in (("K", 380, 3, (-2.2, -2.8, 3.0)), ("F", 130, 4, (2.8, -2.0, 1.4))):
        L = bpy.data.lights.new(nm, "AREA")
        L.energy, L.size = en, sz
        o = bpy.data.objects.new(nm, L)
        o.location = loc
        o.rotation_euler = (Vector((0, 0, 1.0)) - Vector(loc)).to_track_quat("-Z", "Y").to_euler()
        sc.collection.objects.link(o)
    return sc


def rot_world(rig, bone, axis, deg, about=None):
    """骨を、ワールドの軸 axis のまわりに deg 度回す（回す中心は骨の根元）"""
    pb = rig.pose.bones[bone]
    h = Vector(about) if about is not None else pb.head.copy()
    R = Matrix.Rotation(math.radians(deg), 4, Vector(axis).normalized())
    pb.matrix = Matrix.Translation(h) @ R @ Matrix.Translation(-h) @ pb.matrix
    bpy.context.view_layer.update()


def twist(rig, bone, deg):
    """骨を自分の向きのまわりにねじる"""
    pb = rig.pose.bones[bone]
    rot_world(rig, bone, pb.tail - pb.head, deg)


def curl(rig, side, deg, fingers=("Index", "Middle", "Ring", "Little"), thumb=None):
    """指を手のひらのほうへ曲げる（骨の z 軸を手のひらに向けてあるので、x 軸まわりに回す）"""
    for f in fingers:
        for seg, k in (("Proximal", 1.0), ("Intermediate", 1.2)):
            pb = rig.pose.bones[side + f + seg]
            pb.rotation_mode = "XYZ"
            pb.rotation_euler.x = math.radians(deg * k)
    if thumb is not None:
        for seg in ("Proximal", "Intermediate"):
            pb = rig.pose.bones[side + "Thumb" + seg]
            pb.rotation_mode = "XYZ"
            pb.rotation_euler.x = math.radians(thumb)
    bpy.context.view_layer.update()


def mouth(kind):
    for ob in bpy.data.objects:
        if ob.name.startswith("Nurse_Mouth_"):
            on = ob.name == "Nurse_Mouth_" + kind
            ob.hide_render = not on
            ob.hide_viewport = not on


def brows(rig, deg, lift=0.0):
    """眉：deg＞0 で眉頭が上がる（困り顔）。lift は上へ（m）"""
    for tag, sgn in (("L", 1), ("R", -1)):
        pb = rig.pose.bones["Brow_" + tag]
        rot_world(rig, "Brow_" + tag, (0, 1, 0), deg * sgn)
        pb.matrix = Matrix.Translation((0, 0, lift)) @ pb.matrix
        bpy.context.view_layer.update()


def reset(rig):
    for pb in rig.pose.bones:
        pb.matrix_basis = Matrix.Identity(4)
    bpy.context.view_layer.update()
    mouth("smile")


X, Y, Z = (1, 0, 0), (0, 1, 0), (0, 0, 1)


def arm_forward(rig, S, up_deg, elbow_deg, palm_up=0.0):
    """腕を前へ上げる（up_deg）。ひじを曲げて前腕をさらに上げる（elbow_deg）。palm_up で手のひらを上へ"""
    rot_world(rig, S + "UpperArm", X, -up_deg)
    rot_world(rig, S + "LowerArm", X, -elbow_deg)
    if palm_up:
        sgn = 1 if S == "Right" else -1
        twist(rig, S + "LowerArm", sgn * palm_up * 0.5)
        twist(rig, S + "Hand", sgn * palm_up * 0.5)


POSES = {}


def pose(fn):
    POSES[fn.__name__] = fn
    return fn


@pose
def rest(rig):
    pass


@pose
def give(rig):      # 片手で物を差し出す
    arm_forward(rig, "Right", 55, 30, palm_up=80)
    curl(rig, "Right", 12, thumb=5)
    rot_world(rig, "Head", X, 8)
    rot_world(rig, "LeftUpperArm", Y, 4)


@pose
def receive(rig):   # 両手で受け取る
    for S in ("Left", "Right"):
        arm_forward(rig, S, 40, 35, palm_up=80)
        curl(rig, S, 15, thumb=5)
    rot_world(rig, "Chest", X, 6)
    rot_world(rig, "Head", X, 10)


@pose
def drawer(rig):    # 前かがみで引き出しの中の物をつかむ
    rot_world(rig, "Spine", X, 12)
    rot_world(rig, "Chest", X, 12)
    for S in ("Left", "Right"):
        arm_forward(rig, S, 35, 10)
        curl(rig, S, 55, thumb=30)
    rot_world(rig, "Head", X, 12)


@pose
def point(rig):     # 指で示す（話しながら）
    arm_forward(rig, "Right", 75, 5)
    rot_world(rig, "RightUpperArm", Z, -18)
    curl(rig, "Right", 85, fingers=("Middle", "Ring", "Little"), thumb=40)
    rot_world(rig, "Head", Z, -12)
    mouth("talk")


@pose
def bow(rig):       # おじぎ
    rot_world(rig, "Spine", X, 18)
    rot_world(rig, "Chest", X, 14)
    rot_world(rig, "Head", X, 6)
    for S, sgn in (("Left", -1), ("Right", 1)):
        rot_world(rig, S + "UpperArm", Y, sgn * -6)
        rot_world(rig, S + "UpperArm", X, -12)


@pose
def worried(rig):   # 困った：首をかしげ、手を口元へ
    rot_world(rig, "Head", Y, 10)
    rot_world(rig, "Head", X, 6)
    arm_forward(rig, "Right", 25, 115)
    rot_world(rig, "RightUpperArm", Z, -8)
    curl(rig, "Right", 30, thumb=10)
    brows(rig, 16, lift=0.002)
    mouth("worried")


@pose
def talk(rig):      # 話しかける：手のひらを見せる
    arm_forward(rig, "Left", 30, 55, palm_up=60)
    curl(rig, "Left", 10, thumb=0)
    rot_world(rig, "Head", Z, 8)
    brows(rig, 0, lift=0.003)
    mouth("talk")


bpy.ops.wm.open_mainfile(filepath=src)
rig = bpy.data.objects["Nurse_Oshiro"]
sc = setup_scene()
H = 1.65
# 右肩（看護師の右＝-x）に寄る：前から斜め・少し上から
views = {"sh": ((-0.62, -0.78, 1.48), (-0.16, -0.02, 1.27), 50, (1280, 720)),
         "full": ((-1.0, -2.6, 1.30), (-0.05, 0, 1.10), 50, (1280, 720))}
for name in (want or list(POSES)):
    reset(rig)
    POSES[name](rig)
    for vname, (loc, tgt, lens, res) in views.items():
        for o in [o for o in bpy.data.objects if o.type == "CAMERA"]:
            bpy.data.objects.remove(o)
        cam = bpy.data.cameras.new("C")
        cam.lens = lens
        co = bpy.data.objects.new("C", cam)
        co.location = loc
        co.rotation_euler = (Vector(tgt) - Vector(loc)).to_track_quat("-Z", "Y").to_euler()
        sc.collection.objects.link(co)
        sc.camera = co
        sc.render.resolution_x, sc.render.resolution_y = res
        sc.render.filepath = os.path.join(out, f"pose_{name}_{vname}.png")
        bpy.ops.render.render(write_still=True)
    print("wrote", name)
```


## 直す前の版と、e3daa10 の nurse_build.py の違い

3回目の前（v3）は、p2m-site のコミット e3daa10 の tools/sim_people/ の nurse_build.py そのもの。そこから1回目の前（v1）と2回目の前（v2）を作った違いを、diff で載せる。

### v3（e3daa10）と v2（2回目の前）の違い

```diff
--- /home/claude/torn/v3/nurse_build.py	2026-11-13 19:06:48.297392271 +0900
+++ /home/claude/torn/v2/nurse_build.py	2026-11-13 19:06:48.355579785 +0900
@@ -175,7 +175,7 @@
         proj = np.sum(q * d1.reshape(3, 1), axis=0)
         radial = length(q - d1.reshape(3, 1) * proj)
         inside = np.maximum(np.maximum(-0.12 - proj, proj - 0.134), radial - 0.065)
-        d = smax(d, -inside, 0.005)
+        pass  # 2回目の前：袖の中の肌を消していない
     return d
 
 
```

### v3（e3daa10）と v1（1回目の前）の違い

```diff
--- /home/claude/torn/v3/nurse_build.py	2026-11-13 19:06:48.297392271 +0900
+++ /home/claude/torn/v1/nurse_build.py	2026-11-13 19:06:56.777392775 +0900
@@ -175,7 +175,7 @@
         proj = np.sum(q * d1.reshape(3, 1), axis=0)
         radial = length(q - d1.reshape(3, 1) * proj)
         inside = np.maximum(np.maximum(-0.12 - proj, proj - 0.134), radial - 0.065)
-        d = smax(d, -inside, 0.005)
+        pass  # 2回目の前：袖の中の肌を消していない
     return d
 
 
@@ -479,6 +479,8 @@
         for side, L in ((1, "Left"), (-1, "Right")):
             sh, _, _, _, _ = arm_points(side)
             R[L + "Shoulder"] = capsule(p, (side * 0.045, 0.012, 1.33), sh.tolist(), 0.045, 0.045)
+            _, _, _, d1, _ = arm_points(side)
+            R[L + "UpperArm"] = capsule(p, (sh + d1 * 0.06).tolist(), (sh + d1 * 0.2).tolist(), 0.055, 0.055)  # 1回目の前：袖の側は腕の骨
     elif kind == "pants":
         R["Hips"] = ellipsoid(p, (0, 0.012, 0.86), (0.148, 0.086, 0.10))
         for side, L in ((1, "Left"), (-1, "Right")):
@@ -593,8 +595,8 @@
 parts = [
     ("Skin", skin, (-0.34, -0.18, 0.58), (0.34, 0.15, 1.67), VOX, SKIN, 0.55, 0.12, 0.07, ("mesh", "skin")),
     ("Hair", hair, (-0.15, -0.16, 1.32), (0.15, 0.21, 1.69), VOX, HAIR, 0.5, 0, 0.06, ("whole", "Head")),
-    ("Top", top, (-0.30, -0.15, 0.78), (0.30, 0.15, 1.40), VOX, ROYAL, 0.75, 0, 0.025, ("mesh", "top")),
-    ("Sleeves", sleeves, (-0.30, -0.12, 1.08), (0.30, 0.12, 1.40), VOX, ROYAL, 0.75, 0, 0.05, ("sleeves", None)),
+    ("Top", lambda p: np.minimum(top(p), sleeves(p)), (-0.30, -0.15, 0.78), (0.30, 0.15, 1.40), VOX, ROYAL, 0.75, 0, 0.025, ("mesh", "top")),
+    # 1回目の前：袖は胴と1つの面
     ("Pants", pants, (-0.20, -0.13, 0.05), (0.20, 0.14, 1.0), VOX * 1.4, ROYAL, 0.75, 0, 0.04, ("mesh", "pants")),
     ("Shoes", shoes, (-0.17, -0.17, -0.01), (0.17, 0.10, 0.11), VOX, WHITE, 0.5, 0, 0.05, ("mesh", "shoes")),
     ("Eyes", eyes, FACE_LO, FACE_HI, 0.0008, DARK, 0.35, 0, 0.15, ("side", "Eye_")),
```

### 1回目の前（v1）を描き出した時の記録（log.txt の最後）

```text
Nurse_Top 8248 faces
Nurse_Pants 4546 faces
Nurse_Shoes 1758 faces
Nurse_Eyes 2616 faces
Nurse_Brows 1004 faces
Nurse_Mouth_smile 1190 faces
Nurse_Mouth_flat 878 faces
Nurse_Mouth_worried 938 faces
Nurse_Mouth_talk 1120 faces
Nurse_Mouth_surprised 1286 faces
Info: Saved as "nurse_clay.blend"

FBX export starting... 'out/nurse_oshiro.fbx'
export finished in 0.1420 sec.
{"Nurse_Skin": 12368, "Nurse_Hair": 3468, "Nurse_Top": 8248, "Nurse_Pants": 4546, "Nurse_Shoes": 1758, "Nurse_Eyes": 2616, "Nurse_Brows": 1004, "Nurse_Mouth_smile": 1190, "Nurse_Mouth_flat": 878, "Nurse_Mouth_worried": 938, "Nurse_Mouth_talk": 1120, "Nurse_Mouth_surprised": 1286, "_total_triangles": 39420}
```


## サムネイルの文言（thumb.py）

```python
# サムネイルの文言と背景（thumbnail.py が読む）。2026-11-13「ハコブくんの3D」
# 背景は A＝記事の受け渡しの3DCG（Blender で描いた絵）／B＝直す前のコードで描き出し直した、袖が裂けた絵。生成AIは使っていない
import os
V = os.environ.get('THUMB_V', 'A')
PHOTO = {'A': 'サムネ素材/A_handover.png', 'B': 'サムネ素材/B_torn1.png'}[V]
PHOTO3 = None
PHOTO_POS_FULL = '50% 50%'
HAKOBU = True
LABEL = {'A': '体ができた！', 'B': '裂けた！'}[V]
KICKER = {'A': 'Blender × Unity', 'B': 'Blender で3D'}[V]
LINES = {'A': ['マンガのキャラを', '<span class="red">3Dにする</span>', '7つの手順'],
         'B': ['袖が', '<span class="red">3回裂けた</span>', '3Dキャラの作り方']}[V]
VARIANTS = ['photo']
SIZE_L1, SIZE_L2, SIZE_L3 = 120, 170, 100
TXT_TOP = 70
HK_BOTTOM = -300
LABEL_BOTTOM = 158
LABEL_RIGHT = 450
LABEL_TAIL = 'bubble'
LABEL_FS = 60
```


## 概要欄（概要欄.txt）

```text
病院で薬を運ぶロボットと看護師さんの受け渡しを、近くから見せたい。そのために、4コママンガ『ハコブくん』のロボット・ハコブくんと、看護師の大城さんを3Dにしました。この動画では、作った順番を7つの手順に分けて、真似できる形でお伝えします。首が長すぎた、肩がいかつすぎた、袖が3回裂けた、という失敗も、そのまま見せます。

▼7つの手順
1. 使い道と見た目の決まりを先に決める（準備）
2. 元の絵（三面図）と大きさをそろえる（準備）
3. 形を数字で作る（Blender を Python で動かす）
4. 絵に描き出して、2人を並べて直す（Blender）
5. 骨と顔を入れる（Blender・骨は Unity の人型の決まりどおりの名前）
6. ポーズを付けて、服や肌が破れないか確かめる（Blender）
7. FBX で Unity に渡し、借りた動きで動かして撮る（Unity）

▼目次
0:00 何に使うための3Dか
0:55 7つの手順
1:21 手順1 使い道を決める
2:05 手順2 元の絵と大きさ
2:40 手順3 形を数字で作る（Blender）
3:37 手順4 並べて直す
4:47 手順5 骨と顔
5:30 手順6 ポーズで確認（袖が裂けた話）
6:50 手順7 Unity で動かす
7:50 まとめ
8:12 これから
8:49 エンディング

▼この動画で扱う範囲
・ハコブくんは4コママンガのロボットで、実物はありません。動画の中は、ぜんぶ3DCGです
・形を作ったのは Blender（3Dの形を作る無料のソフト）で、画面は開かずに Python のコードで動かしました。コードを書いたのは AI の Claude、見て直す所を決めたのはエイイチです
・袖が裂けた1回目と2回目の絵は、当時の絵が残っていなかったため、直す前のコードから描き出し直したものです。当時の絵とまったく同じではないかもしれません
・歩く・うなずく・おじぎなどの動きは、Adobe の Mixamo から33本借りました。借りた動きのファイルは、決まりにより配っていません
・Unity の場面は、病院の1日のシミュレーションとは別に作った、受け渡しを近くで見せるための場面です。動きが本物の看護師さんに近いかは、これから確かめます

▼作り方の記録（2人の形の作り方のコードも載せています）
LOG496 受け渡しを寄りで見せる、ハコブくんと看護師の3D：https://pixelstomotors.com/record/hakobu-nurse-3d-models-for-handover-closeups

▼サービスロボット導入シミュレーション
https://pixelstomotors.com/series8

ナレーションは AI 音声（ElevenLabs）です。エイイチの声も AI 音声で、本人の声ではありません。
4コママンガ『ハコブくん』の絵は、生成AIで作っています。
```


## 出典

- 動画: [マンガのキャラを3Dにする7つの手順 Blender と Unity で作ってみた](https://youtu.be/sYYM82DQclk)（Pixels to Motors の YouTube）
- LOG496: [サービスロボット導入シミュレーション Log#45 | 受け渡しを寄りで見せる、ハコブくんと看護師の3D](https://pixelstomotors.com/record/hakobu-nurse-3d-models-for-handover-closeups)
- [Blender](https://www.blender.org/)
- [Unity](https://unity.com/)
- [ElevenLabs](https://elevenlabs.io/)
- [Mixamo](https://www.mixamo.com/)
